From 6e1d85fb2e013ed3eb4875b4755ca0ee1d00bb3d Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 27 Jul 2026 12:18:56 +0200 Subject: [PATCH 001/101] Refactor type parser to replace custom parser logic with dedicated consumers for `DateTime` and `Enum` types, remove unused `UserDefinedParsers`, and simplify `ArrayConsumer`. --- README.md | 46 ----------------- .../Laravel/LaravelServiceProvider.php | 4 +- src/Contracts/Parser.php | 11 ----- src/Parser/Consumers/ArrayConsumer.php | 22 ++------- .../DateTimeConsumer.php} | 26 +++++++--- src/Parser/Consumers/EnumConsumer.php | 32 ++++++++++++ src/Parser/Consumers/UserDefinedParsers.php | 49 ------------------- src/Parser/Definition/TokenType.php | 3 ++ src/Parser/Nodes/Leaf/BuiltInNode.php | 16 +----- src/Parser/Nodes/NamedNode.php | 2 +- src/Parser/Parsers/EnumCasesParser.php | 23 --------- src/Parser/TypeParser.php | 30 ++---------- tests/Feature/FullSchemaTest.php | 16 +++--- 13 files changed, 71 insertions(+), 209 deletions(-) delete mode 100644 src/Contracts/Parser.php rename src/Parser/{Parsers/DateTimeParser.php => Consumers/DateTimeConsumer.php} (55%) create mode 100644 src/Parser/Consumers/EnumConsumer.php delete mode 100644 src/Parser/Consumers/UserDefinedParsers.php delete mode 100644 src/Parser/Parsers/EnumCasesParser.php diff --git a/README.md b/README.md index 0abc5af..8aaecca 100644 --- a/README.md +++ b/README.md @@ -156,50 +156,4 @@ $registry = require 'asts.php'; $ast = $registry->get('MyClass@methodname@input'); $otherAst = $registry->get('MyClass@methodname@output'); -``` - -## Extending the Parser - -The parser is quite simple and can be extended to support more specific types with custom parsers. - -Ordering matters. The first parser that can parse the type will be used. Therefore, be careful if you prepend or append -parsers to the default parsers. For example, the datetime parser parses any DateTime interface. If you need custom logic -you need to prepend your custom parser. - -```php -use Le0daniel\PhpTsBindings\Contracts\Parser; -use Le0daniel\PhpTsBindings\Parser\Definition\Token; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; -use Le0daniel\PhpTsBindings\Parser\TypeParser; - -class CarbonDateTimeParser implements Parser { - - public function canParse(string $fullyQualifiedClassName, Token $token): bool { - return is_a($fullyQualifiedClassName, Carbon::class, true); - } - - public function parse(string $fullyQualifiedClassName, Token $token, TypeParser $parser): NodeInterface { - return new CarbonLeafNode(); - } -} - -$parser = new TypeParser( - parsers: TypeParser::getDefaultParsers( - prepend: [ - new CarbonDateTimeParser(), - ]; - ), -); -``` - -By default, the parser uses the following parsers: - -- new EnumCasesParser(): Takes an EnumClass and expects a string literal as input -- new DateTimeParser(): Takes a DateTime string, parses it as a DateTime object and serializes it as a string -- new CustomClassParser(): Takes a custom class and creates an object struct for input/output. - -If you don't want to use any of the default parsers, you can pass an empty array to the constructor of TypeParser. - -```php -new TypeParser(parsers: []); ``` \ No newline at end of file diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index eb4a94c..7f79379 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -53,9 +53,7 @@ public function register(): void { $this->app->bind(TypeParser::class, function () { return new TypeParser( - consumers: TypeParser::defaultConsumers( - collectionClasses: [Collection::class] - ), + consumers: TypeParser::defaultConsumers(), ); }); diff --git a/src/Contracts/Parser.php b/src/Contracts/Parser.php deleted file mode 100644 index c06377c..0000000 --- a/src/Contracts/Parser.php +++ /dev/null @@ -1,11 +0,0 @@ - => Array<{id: string}> - * - Collection => Record - * @param array $collectionLikeClasses - */ - public function __construct( - public array $collectionLikeClasses = [], - ) + public function __construct() { } @@ -48,8 +36,7 @@ public function canConsume(ParserState $state): bool return false; } - return in_array($state->current()->value, ['list', 'non-empty-list', 'array', 'non-empty-array'], true) - || in_array($state->context->toFullyQualifiedClassName($state->current()->value), $this->collectionLikeClasses, true); + return in_array($state->current()->value, ['list', 'non-empty-list', 'array', 'non-empty-array'], true); } /** @@ -97,10 +84,7 @@ public function consume(ParserState $state, TypeParser $parser): RecordNode|List $generics = $this->consumeGenerics($state, $parser, min: 1, max: $maxGenerics); if (count($generics) === 1) { - $node = new ListNode($generics[0]); - return $customType - ? new CustomCastingNode($node, $customType, ObjectCastStrategy::COLLECTION) - : $node; + return new ListNode($generics[0]); } $keyType = $generics[0]; diff --git a/src/Parser/Parsers/DateTimeParser.php b/src/Parser/Consumers/DateTimeConsumer.php similarity index 55% rename from src/Parser/Parsers/DateTimeParser.php rename to src/Parser/Consumers/DateTimeConsumer.php index f18a573..5c40b4c 100644 --- a/src/Parser/Parsers/DateTimeParser.php +++ b/src/Parser/Consumers/DateTimeConsumer.php @@ -1,20 +1,26 @@ currentTokenIs(TokenType::IDENTIFIER)) { + return false; + } + + $token = $state->current(); + $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($token->value); + // Built in classes are harder to catch as the fully qualified class name might // be prefixed with the current namespace. if (is_a($fullyQualifiedClassName, DateTimeInterface::class, true)) { @@ -24,8 +30,12 @@ public function canParse(string $fullyQualifiedClassName, Token $token): bool return class_exists($token->value, false) && is_a($token->value, DateTimeInterface::class, true); } - public function parse(string $fullyQualifiedClassName, Token $token): DateTimeNode + public function consume(ParserState $state, TypeParser $parser): NodeInterface { + $token = $state->current(); + $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($token->value); + $state->advance(); + /** @var class-string $className */ $className = is_a($fullyQualifiedClassName, DateTimeInterface::class, true) ? $fullyQualifiedClassName @@ -33,4 +43,4 @@ public function parse(string $fullyQualifiedClassName, Token $token): DateTimeNo return new DateTimeNode($className); } -} \ No newline at end of file +} diff --git a/src/Parser/Consumers/EnumConsumer.php b/src/Parser/Consumers/EnumConsumer.php new file mode 100644 index 0000000..84c1937 --- /dev/null +++ b/src/Parser/Consumers/EnumConsumer.php @@ -0,0 +1,32 @@ +currentTokenIs(TokenType::IDENTIFIER)) { + return false; + } + + return enum_exists($state->context->toFullyQualifiedClassName($state->current()->value)); + } + + public function consume(ParserState $state, TypeParser $parser): NodeInterface + { + $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($state->current()->value); + $state->advance(); + + /** @var class-string $fullyQualifiedClassName */ + return new EnumNode($fullyQualifiedClassName); + } +} diff --git a/src/Parser/Consumers/UserDefinedParsers.php b/src/Parser/Consumers/UserDefinedParsers.php deleted file mode 100644 index 109570b..0000000 --- a/src/Parser/Consumers/UserDefinedParsers.php +++ /dev/null @@ -1,49 +0,0 @@ - $parsers - */ - public function __construct( - private array $parsers, - ) - { - } - - public function canConsume(ParserState $state): bool - { - if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { - return false; - } - - $token = $state->current(); - $fqcn = $state->context->toFullyQualifiedClassName($token->value); - return array_any($this->parsers, fn(Parser $parser) => $parser->canParse($fqcn, $token)); - } - - public function consume(ParserState $state, TypeParser $parser): NodeInterface - { - $token = $state->current(); - $fqcn = $state->context->toFullyQualifiedClassName($token->value); - $state->advance(); - - foreach ($this->parsers as $parser) { - if ($parser->canParse($fqcn, $token)) { - return $parser->parse($fqcn, $token); - } - } - - throw new RuntimeException("No parser found for {$fqcn}"); - } -} \ No newline at end of file diff --git a/src/Parser/Definition/TokenType.php b/src/Parser/Definition/TokenType.php index 2ca5dea..5cd7df4 100644 --- a/src/Parser/Definition/TokenType.php +++ b/src/Parser/Definition/TokenType.php @@ -17,7 +17,10 @@ enum TokenType: string case LBRACKET = "["; case RBRACKET = "]"; case QUESTION_MARK = '?'; + + /** @deprecated */ case CLASS_CONST = "name::CONST"; + case COLON = ":"; case DOUBLE_COLON = '::'; case CLOSED_BRACKETS = '[]'; diff --git a/src/Parser/Nodes/Leaf/BuiltInNode.php b/src/Parser/Nodes/Leaf/BuiltInNode.php index 2d4f39c..a21a76d 100644 --- a/src/Parser/Nodes/Leaf/BuiltInNode.php +++ b/src/Parser/Nodes/Leaf/BuiltInNode.php @@ -11,7 +11,6 @@ use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; use Le0daniel\PhpTsBindings\Utils\PHPExport; -use LogicException; use Stringable; use Throwable; @@ -30,24 +29,11 @@ public function __toString(): string return $this->type->value; } - /** - * @phpstan-assert string $this->brand - */ - public function assertBranded(): void - { - if ($this->brand === null) { - throw new LogicException('Cannot assert branded type without brand'); - } - } - public function exportPhpCode(): string { $className = PHPExport::absolute(BuiltInNode::class); $type = PHPExport::exportEnumCase($this->type); - - return implode('', [ - "new {$className}($type)" - ]); + return "new {$className}($type)"; } public function parseValue(mixed $value, ExecutionContext $context): mixed diff --git a/src/Parser/Nodes/NamedNode.php b/src/Parser/Nodes/NamedNode.php index eac7171..d9c49d3 100644 --- a/src/Parser/Nodes/NamedNode.php +++ b/src/Parser/Nodes/NamedNode.php @@ -16,7 +16,7 @@ public function __construct( public function __toString(): string { - return (string)$this->node; + return "{$this->node} & Brand<{$this->name}>"; } public function exportPhpCode(): string diff --git a/src/Parser/Parsers/EnumCasesParser.php b/src/Parser/Parsers/EnumCasesParser.php deleted file mode 100644 index 36e21cb..0000000 --- a/src/Parser/Parsers/EnumCasesParser.php +++ /dev/null @@ -1,23 +0,0 @@ - $fullyQualifiedClassName */ - return new EnumNode($fullyQualifiedClassName); - } -} diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 720ef3d..58865a6 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -3,16 +3,16 @@ namespace Le0daniel\PhpTsBindings\Parser; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; -use Le0daniel\PhpTsBindings\Contracts\Parser; use Le0daniel\PhpTsBindings\Parser\Consumers\AliasConsumer; use Le0daniel\PhpTsBindings\Parser\Consumers\ArrayConsumer; use Le0daniel\PhpTsBindings\Parser\Consumers\BuiltInLeafConsumer; use Le0daniel\PhpTsBindings\Parser\Consumers\ClassConstConsumer; +use Le0daniel\PhpTsBindings\Parser\Consumers\DateTimeConsumer; +use Le0daniel\PhpTsBindings\Parser\Consumers\EnumConsumer; use Le0daniel\PhpTsBindings\Parser\Consumers\IntConsumer; use Le0daniel\PhpTsBindings\Parser\Consumers\LiteralConsumer; use Le0daniel\PhpTsBindings\Parser\Consumers\StructConsumer; use Le0daniel\PhpTsBindings\Parser\Consumers\UserDefinedObjectConsumer; -use Le0daniel\PhpTsBindings\Parser\Consumers\UserDefinedParsers; use Le0daniel\PhpTsBindings\Parser\Consumers\UtilsConsumer; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; @@ -27,8 +27,6 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; -use Le0daniel\PhpTsBindings\Parser\Parsers\DateTimeParser; -use Le0daniel\PhpTsBindings\Parser\Parsers\EnumCasesParser; final readonly class TypeParser { @@ -60,15 +58,11 @@ public function __construct( /** * @param GlobalTypeAliases $globalTypeAliases - * @param list $collectionClasses - * @param list|null $parsers * @param bool $allowAllObjectCasting * @return TypeConsumer[] */ public static function defaultConsumers( GlobalTypeAliases $globalTypeAliases = new GlobalTypeAliases(), - array $collectionClasses = [], - ?array $parsers = null, bool $allowAllObjectCasting = false, ): array { @@ -79,28 +73,14 @@ public static function defaultConsumers( new IntConsumer(), new BuiltInLeafConsumer(), new StructConsumer(), - new ArrayConsumer($collectionClasses), - new UserDefinedParsers($parsers ?? self::getDefaultParsers()), + new ArrayConsumer(), + new EnumConsumer(), + new DateTimeConsumer(), new UserDefinedObjectConsumer($allowAllObjectCasting), new UtilsConsumer(), ]; } - /** - * @param list $prepend - * @param list $append - * @return list - */ - public static function getDefaultParsers(array $prepend = [], array $append = []): array - { - return [ - ...$prepend, - new EnumCasesParser(), - new DateTimeParser(), - ...$append, - ]; - } - /** * Parsing context is used to correctly Identify types that have been defined on the class level with * `phpstan-type` or `phpstan-import-type` on the class or file level. diff --git a/tests/Feature/FullSchemaTest.php b/tests/Feature/FullSchemaTest.php index 7106c56..eaf5f2e 100644 --- a/tests/Feature/FullSchemaTest.php +++ b/tests/Feature/FullSchemaTest.php @@ -22,7 +22,7 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu $optimizer = new ASTOptimizer(); $parser = new TypeParser( - consumers: TypeParser::defaultConsumers(collectionClasses: [Collection::class]) + consumers: TypeParser::defaultConsumers() ); $ast = $parser->parse($type); @@ -141,34 +141,32 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu test('Execute parsing with custom collection class', function () { $collectionClass = Collection::class; - $executor = prepare("\Illuminate\Support\Collection", 'parse'); + $executor = prepare("array", 'parse'); $validResult = $executor([ ['id' => 'test'], ]); expect($validResult)->toBeSuccess() - ->and($validResult->value)->toBeInstanceOf($collectionClass) - ->and($validResult->value->first())->toEqual(['id' => 'test']); + ->and($validResult->value) + ->and($validResult->value[0])->toEqual(['id' => 'test']); }); test('Execute parsing with custom collection class as record', function () { - $executor = prepare("\Illuminate\Support\Collection", 'parse'); + $executor = prepare("array", 'parse'); $validResult = $executor(['id' => 123]); expect($validResult)->toBeSuccess() - ->and($validResult->value)->toBeInstanceOf(Collection::class) - ->and($validResult->value->toArray())->toEqual(['id' => 123]); + ->and($validResult->value)->toEqual(['id' => 123]); }); test('Execute serialization with custom record class', function () { - $executor = prepare("\Illuminate\Support\Collection", 'serialize'); + $executor = prepare("array", 'serialize'); $validResult = $executor(['id' => 123]); expect($validResult)->toBeSuccess() - ->and($validResult->value)->toBeInstanceOf(stdClass::class) ->and($validResult->value)->toEqual((object)['id' => 123]); }); From 071e320eafbec63d86bc9b343921d5e5ee2b9a2d Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 27 Jul 2026 22:38:16 +0200 Subject: [PATCH 002/101] Refactor parser to replace `TypeStringTokenizer` with `Lexer` and `Lexemes`, simplifying token parsing and decoding logic, removing now-obsolete classes and tests. --- src/Parser/Consumers/AliasConsumer.php | 2 +- src/Parser/Consumers/ArrayConsumer.php | 4 +- src/Parser/Consumers/BuiltInLeafConsumer.php | 2 +- src/Parser/Consumers/ClassConstConsumer.php | 24 +- src/Parser/Consumers/DateTimeConsumer.php | 2 +- src/Parser/Consumers/EnumConsumer.php | 2 +- src/Parser/Consumers/IntConsumer.php | 7 +- .../Consumers/InteractsWithGenerics.php | 2 +- src/Parser/Consumers/LiteralConsumer.php | 27 +- src/Parser/Consumers/StructConsumer.php | 24 +- .../Consumers/UserDefinedObjectConsumer.php | 2 +- src/Parser/Consumers/UtilsConsumer.php | 4 +- src/Parser/Data/ParsingContext.php | 4 +- src/Parser/Definition/Lexemes.php | 146 +++++++++ src/Parser/Definition/ParserState.php | 66 +++-- src/Parser/Definition/Position.php | 13 - src/Parser/Definition/Token.php | 50 ---- src/Parser/Definition/TokenType.php | 39 --- src/Parser/Nodes/Data/ObjectCastStrategy.php | 1 + src/Parser/TypeParser.php | 34 ++- src/Parser/TypeStringTokenizer.php | 204 ------------- tests/Unit/Executor/SchemaExecutorTest.php | 9 - tests/Unit/Parser/Definition/LexemesTest.php | 62 ++++ tests/Unit/Parser/TypeParserTest.php | 280 ++++++++++++++---- tests/Unit/TypeStringTokenizerTest.php | 143 --------- 25 files changed, 575 insertions(+), 578 deletions(-) create mode 100644 src/Parser/Definition/Lexemes.php delete mode 100644 src/Parser/Definition/Position.php delete mode 100644 src/Parser/Definition/Token.php delete mode 100644 src/Parser/Definition/TokenType.php delete mode 100644 src/Parser/TypeStringTokenizer.php create mode 100644 tests/Unit/Parser/Definition/LexemesTest.php delete mode 100644 tests/Unit/TypeStringTokenizerTest.php diff --git a/src/Parser/Consumers/AliasConsumer.php b/src/Parser/Consumers/AliasConsumer.php index 295fadf..f800a6f 100644 --- a/src/Parser/Consumers/AliasConsumer.php +++ b/src/Parser/Consumers/AliasConsumer.php @@ -7,7 +7,7 @@ use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\TypeParser; use ReflectionException; diff --git a/src/Parser/Consumers/ArrayConsumer.php b/src/Parser/Consumers/ArrayConsumer.php index 836ee9a..6f67ad4 100644 --- a/src/Parser/Consumers/ArrayConsumer.php +++ b/src/Parser/Consumers/ArrayConsumer.php @@ -4,7 +4,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; @@ -134,6 +134,8 @@ private function consumeIntegerDeterminedTuple(ParserState $state, TypeParser $p continue; } + // Compares the raw lexeme, so exotic spellings such as array{+0: string} + // are intentionally not accepted here. if (!$state->currentTokenIs(TokenType::INT, (string)count($types))) { $state->produceSyntaxError("Expected int with value " . count($types)); } diff --git a/src/Parser/Consumers/BuiltInLeafConsumer.php b/src/Parser/Consumers/BuiltInLeafConsumer.php index d57ced5..bec5155 100644 --- a/src/Parser/Consumers/BuiltInLeafConsumer.php +++ b/src/Parser/Consumers/BuiltInLeafConsumer.php @@ -5,7 +5,7 @@ use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; diff --git a/src/Parser/Consumers/ClassConstConsumer.php b/src/Parser/Consumers/ClassConstConsumer.php index 4bab5e8..4a081b9 100644 --- a/src/Parser/Consumers/ClassConstConsumer.php +++ b/src/Parser/Consumers/ClassConstConsumer.php @@ -4,7 +4,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; @@ -16,20 +16,34 @@ final class ClassConstConsumer implements TypeConsumer { + /** + * `Foo::BAR` used to arrive as a single CLASS_CONST token. The lexer no longer merges + * it, so this matches the three token sequence instead. The peek(2) guard keeps a + * trailing `Foo::` from being claimed here, and keeps this consumer — which runs ahead + * of the alias, enum and object consumers — from stealing plain identifiers. + */ public function canConsume(ParserState $state): bool { - return $state->currentTokenIs(TokenType::CLASS_CONST); + return $state->currentTokenIs(TokenType::IDENTIFIER) + && $state->nextTokenIs(TokenType::DOUBLE_COLON) + && $state->peek(2)?->is(TokenType::IDENTIFIER) === true; } /** @throws InvalidSyntaxException */ public function consume(ParserState $state, TypeParser $parser): LiteralNode { - $token = $state->current(); - [$className, $constOrEnumCase] = explode('::', $token->value); + $className = $state->current()->value; + $state->advance(2); + + $constOrEnumCase = $state->current()->value; $fqcn = $state->context->toFullyQualifiedClassName($className); try { $reflection = new ReflectionClass($fqcn); + if (!$reflection->hasConstant($constOrEnumCase)) { + $state->produceSyntaxError("Class {$fqcn} has no constant or enum case {$constOrEnumCase}"); + } + $const = $reflection->getConstant($constOrEnumCase); $isEnum = $const instanceof UnitEnum; $state->advance(); @@ -38,6 +52,8 @@ public function consume(ParserState $state, TypeParser $parser): LiteralNode $isEnum ? LiteralType::ENUM_CASE : LiteralType::identifyPrimitiveTypeValue($const), $const ); + } catch (InvalidSyntaxException $exception) { + throw $exception; } catch (Throwable $exception) { $state->produceSyntaxError("Could not identify class const or enum", $exception); } diff --git a/src/Parser/Consumers/DateTimeConsumer.php b/src/Parser/Consumers/DateTimeConsumer.php index 5c40b4c..530deca 100644 --- a/src/Parser/Consumers/DateTimeConsumer.php +++ b/src/Parser/Consumers/DateTimeConsumer.php @@ -6,7 +6,7 @@ use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\DateTimeNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; diff --git a/src/Parser/Consumers/EnumConsumer.php b/src/Parser/Consumers/EnumConsumer.php index 84c1937..e2bce4e 100644 --- a/src/Parser/Consumers/EnumConsumer.php +++ b/src/Parser/Consumers/EnumConsumer.php @@ -5,7 +5,7 @@ use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\EnumNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use UnitEnum; diff --git a/src/Parser/Consumers/IntConsumer.php b/src/Parser/Consumers/IntConsumer.php index 6b3666a..74167d2 100644 --- a/src/Parser/Consumers/IntConsumer.php +++ b/src/Parser/Consumers/IntConsumer.php @@ -4,8 +4,9 @@ use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Definition\Lexemes; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; @@ -33,7 +34,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); $min = match (true) { - $state->currentTokenIs(TokenType::INT) => (int)$state->current()->value, + $state->currentTokenIs(TokenType::INT) => Lexemes::decodeInt($state->current()->value), $state->currentTokenIs(TokenType::IDENTIFIER, 'min') => PHP_INT_MIN, default => $state->produceSyntaxError('Expected int or min'), }; @@ -45,7 +46,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); $max = match (true) { - $state->currentTokenIs(TokenType::INT) => (int)$state->current()->value, + $state->currentTokenIs(TokenType::INT) => Lexemes::decodeInt($state->current()->value), $state->currentTokenIs(TokenType::IDENTIFIER, 'max') => PHP_INT_MAX, default => $state->produceSyntaxError('Expected int or max'), }; diff --git a/src/Parser/Consumers/InteractsWithGenerics.php b/src/Parser/Consumers/InteractsWithGenerics.php index 3248e79..ea199d2 100644 --- a/src/Parser/Consumers/InteractsWithGenerics.php +++ b/src/Parser/Consumers/InteractsWithGenerics.php @@ -4,7 +4,7 @@ use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\TypeParser; diff --git a/src/Parser/Consumers/LiteralConsumer.php b/src/Parser/Consumers/LiteralConsumer.php index 38b84cc..90af3fd 100644 --- a/src/Parser/Consumers/LiteralConsumer.php +++ b/src/Parser/Consumers/LiteralConsumer.php @@ -4,17 +4,29 @@ use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Definition\Lexemes; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; final class LiteralConsumer implements TypeConsumer { + private const array BOOLEANS = ['true', 'false']; + public function canConsume(ParserState $state): bool { - return $state->current()->isAnyTypeOf(TokenType::BOOL, TokenType::STRING, TokenType::FLOAT, TokenType::INT); + $token = $state->current(); + + // The lexer no longer decides that `true` is a boolean, so a boolean literal + // arrives as a plain identifier. This consumer runs first, ahead of + // BuiltInLeafConsumer, which owns `null`. + if ($token->is(TokenType::IDENTIFIER)) { + return in_array($token->value, self::BOOLEANS, true); + } + + return $token->isAnyTypeOf(TokenType::STRING, TokenType::FLOAT, TokenType::INT); } public function consume(ParserState $state, TypeParser $parser): NodeInterface @@ -22,9 +34,16 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $token = $state->current(); $state->advance(); + $value = match ($token->type) { + TokenType::STRING => Lexemes::decodeString($token->value), + TokenType::INT => Lexemes::decodeInt($token->value), + TokenType::FLOAT => Lexemes::decodeFloat($token->value), + default => $token->value === 'true', + }; + return new LiteralNode( - LiteralType::identifyPrimitiveTypeValue($token->coercedValue()), - $token->coercedValue(), + LiteralType::identifyPrimitiveTypeValue($value), + $value, ); } } \ No newline at end of file diff --git a/src/Parser/Consumers/StructConsumer.php b/src/Parser/Consumers/StructConsumer.php index 43bfd64..05ebfc4 100644 --- a/src/Parser/Consumers/StructConsumer.php +++ b/src/Parser/Consumers/StructConsumer.php @@ -4,8 +4,9 @@ use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Definition\Lexemes; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; @@ -21,10 +22,11 @@ public function canConsume(ParserState $state): bool return true; } + // The peeks are null safe: a truncated `array{` used to crash here. return $state->currentTokenIs(TokenType::IDENTIFIER, 'array') - && $state->peek(1)->is(TokenType::LBRACE) - && !$state->peek(2)->is(TokenType::INT) // Do not match array{0: string} - && $state->peek(3)->isAnyTypeOf(TokenType::COLON, TokenType::QUESTION_MARK); + && $state->peek(1)?->is(TokenType::LBRACE) === true + && $state->peek(2)?->is(TokenType::INT) === false // Do not match array{0: string} + && $state->peek(3)?->isAnyTypeOf(TokenType::COLON, TokenType::QUESTION_MARK) === true; } /** @@ -44,11 +46,15 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $properties = []; while ($state->canAdvance()) { - if (!$state->current()->is(TokenType::IDENTIFIER)) { - $state->produceSyntaxError("Expected identifier"); - } - - $name = $state->current()->value; + $key = $state->current(); + + // Shape keys may be quoted, which is the only way to express a key containing + // spaces or punctuation: array{"key something else": string}. + $name = match (true) { + $key->is(TokenType::IDENTIFIER) => $key->value, + $key->is(TokenType::STRING) => Lexemes::decodeString($key->value), + default => $state->produceSyntaxError("Expected identifier"), + }; $state->advance(); $isOptional = $this->consumeOptionalObjectKey($state); diff --git a/src/Parser/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Consumers/UserDefinedObjectConsumer.php index c2c9823..9d58868 100644 --- a/src/Parser/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Consumers/UserDefinedObjectConsumer.php @@ -9,7 +9,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; diff --git a/src/Parser/Consumers/UtilsConsumer.php b/src/Parser/Consumers/UtilsConsumer.php index ebc0c00..413e100 100644 --- a/src/Parser/Consumers/UtilsConsumer.php +++ b/src/Parser/Consumers/UtilsConsumer.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; @@ -24,7 +25,8 @@ final class UtilsConsumer implements TypeConsumer public function canConsume(ParserState $state): bool { - return in_array($state->current()->value, ['Pick', 'Omit', 'BrandedString', 'BrandedInt'], true); + return $state->currentTokenIs(TokenType::IDENTIFIER) + && in_array($state->current()->value, ['Pick', 'Omit', 'BrandedString', 'BrandedInt'], true); } public function consume(ParserState $state, TypeParser $parser): NodeInterface diff --git a/src/Parser/Data/ParsingContext.php b/src/Parser/Data/ParsingContext.php index ddfaf61..ed95f85 100644 --- a/src/Parser/Data/ParsingContext.php +++ b/src/Parser/Data/ParsingContext.php @@ -7,6 +7,8 @@ use Le0daniel\PhpTsBindings\Utils; use ReflectionClass; use ReflectionException; +use ReflectionParameter; +use ReflectionProperty; use RuntimeException; /** @@ -82,7 +84,7 @@ public function getImportedTypeInfo(string $typeName): array return $this->importedTypes[$typeName]; } - public function descendIntoDeclaringClass(\ReflectionProperty|\ReflectionParameter $property): self + public function descendIntoDeclaringClass(ReflectionProperty|ReflectionParameter $property): self { // Declaration is in the same class file. if ($this->declaredInClass === $property->getDeclaringClass()->getName()) { diff --git a/src/Parser/Definition/Lexemes.php b/src/Parser/Definition/Lexemes.php new file mode 100644 index 0000000..539de70 --- /dev/null +++ b/src/Parser/Definition/Lexemes.php @@ -0,0 +1,146 @@ + '\\', + 'n' => "\n", + 'r' => "\r", + 't' => "\t", + 'f' => "\f", + 'v' => "\v", + 'e' => "\x1B", + ]; + + /** + * Strips the surrounding quotes and resolves escape sequences, following PHP's own + * string semantics: single quoted strings recognise only `\\` and `\'`, double quoted + * strings recognise the full set. + * + * @param string $lexeme The raw STRING lexeme, quotes included. + */ + public static function decodeString(string $lexeme): string + { + $quote = $lexeme[0] ?? ''; + $inner = substr($lexeme, 1, -1); + + if ($quote === "'") { + return str_replace(['\\\\', "\\'"], ['\\', "'"], $inner); + } + + return self::resolveEscapeSequences(str_replace('\\"', '"', $inner)); + } + + /** + * Handles `_` separators, an explicit sign and the 0x / 0b / 0o radix prefixes. + * + * Dispatching on the prefix is deliberate: `intval($value, 0)` returns 0 for `0o17` and + * reads a leading-zero decimal such as `010` as octal, neither of which is wanted here. + * + * @param string $lexeme The raw INT lexeme. + */ + public static function decodeInt(string $lexeme): int + { + $value = str_replace('_', '', $lexeme); + $isNegative = str_starts_with($value, '-'); + + if ($isNegative || str_starts_with($value, '+')) { + $value = substr($value, 1); + } + + $magnitude = match (strtolower(substr($value, 0, 2))) { + '0x' => (int)hexdec(substr($value, 2)), + '0b' => (int)bindec(substr($value, 2)), + '0o' => (int)octdec(substr($value, 2)), + default => (int)$value, + }; + + return $isNegative ? -$magnitude : $magnitude; + } + + /** + * Handles `_` separators and exponents. + * + * @param string $lexeme The raw FLOAT lexeme. + */ + public static function decodeFloat(string $lexeme): float + { + return (float)str_replace('_', '', $lexeme); + } + + /** + * Implementation based on PHPStan's StringUnescaper, which in turn is based on + * nikic/PHP-Parser. Ported rather than imported: phpstan/phpdoc-parser is only a + * transitive development dependency of this package. + */ + private static function resolveEscapeSequences(string $string): string + { + $resolved = preg_replace_callback( + '~\\\\([\\\\nrtfve]|[xX][0-9a-fA-F]{1,2}|[0-7]{1,3}|u\{([0-9a-fA-F]+)\})~', + static function (array $matches): string { + $sequence = $matches[1]; + + if (isset(self::ESCAPE_SEQUENCES[$sequence])) { + return self::ESCAPE_SEQUENCES[$sequence]; + } + + if ($sequence[0] === 'x' || $sequence[0] === 'X') { + return chr((int)hexdec(substr($sequence, 1))); + } + + if ($sequence[0] === 'u') { + return self::codePointToUtf8((int)hexdec($matches[2])); + } + + return chr((int)octdec($sequence)); + }, + $string, + ); + + if ($resolved === null) { + throw new RuntimeException('Failed to resolve escape sequences: ' . preg_last_error_msg()); + } + + return $resolved; + } + + private static function codePointToUtf8(int $codePoint): string + { + if ($codePoint <= 0x7F) { + return chr($codePoint); + } + + if ($codePoint <= 0x7FF) { + return chr(($codePoint >> 6) + 0xC0) + . chr(($codePoint & 0x3F) + 0x80); + } + + if ($codePoint <= 0xFFFF) { + return chr(($codePoint >> 12) + 0xE0) + . chr((($codePoint >> 6) & 0x3F) + 0x80) + . chr(($codePoint & 0x3F) + 0x80); + } + + if ($codePoint <= 0x1FFFFF) { + return chr(($codePoint >> 18) + 0xF0) + . chr((($codePoint >> 12) & 0x3F) + 0x80) + . chr((($codePoint >> 6) & 0x3F) + 0x80) + . chr(($codePoint & 0x3F) + 0x80); + } + + // Invalid UTF-8 code point escape sequence: code point too large. + return "\xef\xbf\xbd"; + } +} diff --git a/src/Parser/Definition/ParserState.php b/src/Parser/Definition/ParserState.php index 7f0a18f..333cfe4 100644 --- a/src/Parser/Definition/ParserState.php +++ b/src/Parser/Definition/ParserState.php @@ -2,33 +2,55 @@ namespace Le0daniel\PhpTsBindings\Parser\Definition; -use Closure; use Iterator; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Lexer\SourceLocation; +use Le0daniel\PhpTsBindings\Parser\Lexer\Token; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use RuntimeException; use Throwable; /** + * A cursor over the token stream produced by the Lexer. + * + * The Lexer emits a lossless stream, whitespace included. This is the boundary where trivia + * stops mattering: whitespace is dropped once, here, so that every lookahead below counts + * meaningful tokens only. Tokens keep their absolute byte offsets into $input, so dropping + * whitespace does not disturb error rendering. + * * @implements Iterator */ final class ParserState implements Iterator { private int $currentIndex = 0; - private int $count; + private readonly int $count; + + /** @var non-empty-list */ + private readonly array $tokens; /** * @param string $input - * @param list $tokens + * @param non-empty-list $tokens The raw, lossless token stream. * @param ParsingContext $context */ public function __construct( public readonly string $input, - private readonly array $tokens, + array $tokens, public readonly ParsingContext $context, ) { - $this->count = count($this->tokens); + $significant = array_values( + array_filter($tokens, static fn(Token $token): bool => $token->type !== TokenType::WHITESPACE) + ); + + // The Lexer always terminates the stream with EOF, which is never whitespace. + if ($significant === []) { + throw new RuntimeException('The token stream must contain at least one significant token.'); + } + + $this->tokens = $significant; + $this->count = count($significant); } private function getTokenAtIndex(int $index): ?Token @@ -36,14 +58,18 @@ private function getTokenAtIndex(int $index): ?Token return $this->tokens[$index] ?? null; } + /** + * The cursor never moves past EOF, so this always resolves. The final token is returned + * as a fallback rather than null so that the declared return type is honest. + */ public function current(): Token { - return $this->getTokenAtIndex($this->currentIndex); + return $this->getTokenAtIndex($this->currentIndex) ?? $this->tokens[$this->count - 1]; } public function peek(int $offset = 1): ?Token { - return $this->getTokenAtIndex(($this->currentIndex + $offset)); + return $this->getTokenAtIndex($this->currentIndex + $offset); } public function at(int $index): ?Token @@ -53,16 +79,7 @@ public function at(int $index): ?Token public function currentTokenIs(TokenType $type, ?string $value = null): bool { - if ($this->current()->type !== $type) { - return false; - } - - return is_null($value) || $this->current()->value === $value; - } - - public function currentValueIn(string ... $values): bool - { - return in_array($this->current()->value, $values, true); + return $this->current()->is($type, $value); } public function nextTokenIs(TokenType $type): bool @@ -106,25 +123,22 @@ public function rewind(): void public function highlightCurrentToken(): string { $token = $this->current(); - $length = $token->end->offset - $token->start->offset; + $location = SourceLocation::fromOffset($this->input, $token->offset); return implode(PHP_EOL, [ - "Type: {$token->type->name} ({$token->__toString()})", - $this->input, - str_pad("", $token->start->offset, ' ') . ( - $length > 0 ? str_pad("", $length, '^') : '|' - ) + "Type: {$token->type->name} ({$token->value})", + $location->highlight($this->input, strlen($token->value)), ]); } public function produceSyntaxError(string $message, ?Throwable $throwable = null): never { throw new InvalidSyntaxException( - implode(PHP_EOL, array_filter([ + implode(PHP_EOL, [ "Syntax Error: {$message}", $this->highlightCurrentToken(), - ])), + ]), previous: $throwable, ); } -} \ No newline at end of file +} diff --git a/src/Parser/Definition/Position.php b/src/Parser/Definition/Position.php deleted file mode 100644 index 43eee57..0000000 --- a/src/Parser/Definition/Position.php +++ /dev/null @@ -1,13 +0,0 @@ -type, $types, true); - } - - public function is(TokenType $type, ?string $value = null): bool - { - if ($this->type !== $type) { - return false; - } - - return is_null($value) || $this->value === $value; - } - - public function coercedValue(): int|bool|float|string - { - return match ($this->type) { - TokenType::INT => (int)$this->value, - TokenType::FLOAT => (float)$this->value, - TokenType::BOOL => $this->value === 'true', - default => $this->value, - }; - } - - public function __toString(): string - { - if ($this->type === TokenType::STRING) { - return "\"{$this->value}\""; - } - - return $this->value; - } -} \ No newline at end of file diff --git a/src/Parser/Definition/TokenType.php b/src/Parser/Definition/TokenType.php deleted file mode 100644 index 5cd7df4..0000000 --- a/src/Parser/Definition/TokenType.php +++ /dev/null @@ -1,39 +0,0 @@ -"; - case COMMA = ","; - case LBRACE = "{"; - case RBRACE = "}"; - case LPAREN = "("; - case RPAREN = ")"; - case SINGLE_QUOTE = "'"; - case DOUBLE_QUOTE = '"'; - case LBRACKET = "["; - case RBRACKET = "]"; - case QUESTION_MARK = '?'; - - /** @deprecated */ - case CLASS_CONST = "name::CONST"; - - case COLON = ":"; - case DOUBLE_COLON = '::'; - case CLOSED_BRACKETS = '[]'; - case AND = '&'; - case INT = "int"; - case FLOAT = "float"; - case BOOL = "bool"; - case STRING = "string"; - - // Buffered tokens - case IDENTIFIER = "identifier"; - - // Special tokens - case EOF = "eof"; - case WHITESPACE = "whitespace"; -} diff --git a/src/Parser/Nodes/Data/ObjectCastStrategy.php b/src/Parser/Nodes/Data/ObjectCastStrategy.php index 243027b..052cca9 100644 --- a/src/Parser/Nodes/Data/ObjectCastStrategy.php +++ b/src/Parser/Nodes/Data/ObjectCastStrategy.php @@ -10,6 +10,7 @@ enum ObjectCastStrategy /** * Collection classes expect an array of this type. * Best is to not use it at all. And rely on native PHP types like list or array. + * @deprecated No longer supported. Use native PHP types like list or array instead. */ case COLLECTION; case NEVER; diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 58865a6..b2ffe09 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -18,7 +18,9 @@ use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Definition\TokenType; +use Le0daniel\PhpTsBindings\Parser\Lexer\Exceptions\UnexpectedCharacterException; +use Le0daniel\PhpTsBindings\Parser\Lexer\Lexer; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; @@ -45,11 +47,9 @@ * It's best to run the parser in your build step to create a static file including all the definitions you need * at runtime. * - * @param TypeStringTokenizer $tokenizer * @param TypeConsumer[]|null $consumers */ public function __construct( - private TypeStringTokenizer $tokenizer = new TypeStringTokenizer(), ?array $consumers = null, ) { @@ -89,19 +89,29 @@ public static function defaultConsumers( */ public function parse(string $typeString, ParsingContext $context = new ParsingContext()): NodeInterface { - $tokens = new ParserState( - $typeString, - $this->tokenizer->tokenize($typeString), - $context, - ); + try { + $tokens = new Lexer()->tokenize($typeString); + } catch (UnexpectedCharacterException $exception) { + // A character that cannot start a token is still a syntax error to everyone + // outside the parser. InvalidSyntaxException is final and cannot be extended, + // so the lexical failure is wrapped rather than allowed to escape. + throw new InvalidSyntaxException( + "Syntax Error: {$exception->getMessage()}", + previous: $exception, + ); + } - return $this->consume($tokens); + return $this->consume(new ParserState($typeString, $tokens, $context)); } + /** + * The lexer no longer merges `[` and `]`, so both are matched here. The pair is required: + * a lone `[` is left for the caller to fail on. + */ private function consumeTypeModifiers(ParserState $state, NodeInterface $type): NodeInterface { - while ($state->current()->is(TokenType::CLOSED_BRACKETS)) { - $state->advance(); + while ($state->current()->is(TokenType::LBRACKET) && $state->nextTokenIs(TokenType::RBRACKET)) { + $state->advance(2); $type = new ListNode($type); } return $type; @@ -165,7 +175,7 @@ public function consume(ParserState $state, TokenType ...$stopAt): NodeInterface continue; } - if ($token->is(TokenType::AND)) { + if ($token->is(TokenType::AMPERSAND)) { $mode ??= 'intersection'; if ($expectsType) { $state->produceSyntaxError("Expected Type Identifier, got &"); diff --git a/src/Parser/TypeStringTokenizer.php b/src/Parser/TypeStringTokenizer.php deleted file mode 100644 index 0242962..0000000 --- a/src/Parser/TypeStringTokenizer.php +++ /dev/null @@ -1,204 +0,0 @@ - - */ - public function tokenize(string $typeString): array - { - $currentOffset = 0; - $length = strlen($typeString); - - /** @var list $tokens */ - $tokens = []; - $buffer = ""; - - /** @var null|TokenType $blockType */ - $blockType = null; - - while ($currentOffset < $length) { - $char = $typeString[$currentOffset]; - - if ($blockType === TokenType::CLASS_CONST) { - if (preg_match('/^[a-zA-Z0-9_]+$/', $char) === 1) { - $buffer .= $char; - $currentOffset++; - continue; - } - - $tokens[] = new Token( - TokenType::CLASS_CONST, - $buffer, - new Position(0, $currentOffset - strlen($buffer)), - new Position(0, $currentOffset), - ); - $buffer = ''; - $blockType = null; - } - - // We are not in a token itself - if ($blockType) { - $currentOffset++; - // Buffer in block type - if (!$this->isEndingQuote($blockType, $char)) { - $buffer .= $char; - continue; - } - - $tokens[] = new Token( - TokenType::STRING, - $buffer, - new Position(0, $currentOffset - strlen($buffer) - 2), - new Position(0, $currentOffset), - ); - $buffer = ''; - $blockType = null; - continue; - } - - $identifiedToken = $this->identifyBreakingTokenType($char, $typeString[$currentOffset + 1] ?? null); - // Handle tokenization of "Value::CONST" - if ($identifiedToken === TokenType::DOUBLE_COLON && ctype_alnum($typeString[$currentOffset + 2]) && $buffer !== '') { - $blockType = TokenType::CLASS_CONST; - $buffer .= "::"; - $currentOffset += 2; - continue; - } - - if ($identifiedToken === null) { - $buffer .= $char; - $currentOffset++; - continue; - } - - // Flush buffer first - if ($buffer !== '') { - $tokens[] = new Token( - $this->determineBufferedTokenType($buffer), - $buffer, - new Position(0, $currentOffset - strlen($buffer)), - new Position(0, $currentOffset), - ); - $buffer = ''; - } - - if ($identifiedToken === TokenType::SINGLE_QUOTE || $identifiedToken === TokenType::DOUBLE_QUOTE) { - $blockType = $identifiedToken; - $currentOffset++; - continue; - } - - if ($identifiedToken === TokenType::WHITESPACE) { - $currentOffset++; - continue; - } - - // Add the identified token - $tokenLength = strlen($identifiedToken->value); - $tokens[] = new Token( - $identifiedToken, - $identifiedToken->value, - new Position(0, $currentOffset), - new Position(0, $currentOffset + $tokenLength), - ); - - $currentOffset += $tokenLength; - } - - if ($blockType === TokenType::SINGLE_QUOTE || $blockType === TokenType::DOUBLE_QUOTE) { - throw new RuntimeException("Unclosed block type: {$blockType->value}"); - } - - if (!empty($buffer)) { - $tokens[] = new Token( - $this->determineBufferedTokenType($buffer), - $buffer, - new Position(0, $currentOffset - strlen($buffer)), - new Position(0, $currentOffset), - ); - } - - $tokens[] = new Token( - TokenType::EOF, - '', - new Position(0, $currentOffset), - new Position(0, $currentOffset), - ); - - return $tokens; - } - - private function isEndingQuote(TokenType $endingType, string $character): bool - { - return match ($character) { - TokenType::SINGLE_QUOTE->value => TokenType::SINGLE_QUOTE === $endingType, - TokenType::DOUBLE_QUOTE->value => TokenType::DOUBLE_QUOTE === $endingType, - default => false, - }; - } - - - private function identifyBreakingTokenType(string $character, ?string $nextToken): TokenType|null - { - if (ctype_space($character)) { - return TokenType::WHITESPACE; - } - - $singleMatch = match ($character) { - TokenType::AND->value => TokenType::AND, - TokenType::PIPE->value => TokenType::PIPE, - TokenType::LT->value => TokenType::LT, - TokenType::GT->value => TokenType::GT, - TokenType::COMMA->value => TokenType::COMMA, - TokenType::LBRACE->value => TokenType::LBRACE, - TokenType::RBRACE->value => TokenType::RBRACE, - TokenType::LPAREN->value => TokenType::LPAREN, - TokenType::RPAREN->value => TokenType::RPAREN, - TokenType::SINGLE_QUOTE->value => TokenType::SINGLE_QUOTE, - TokenType::DOUBLE_QUOTE->value => TokenType::DOUBLE_QUOTE, - TokenType::LBRACKET->value => TokenType::LBRACKET, - TokenType::RBRACKET->value => TokenType::RBRACKET, - TokenType::COLON->value => TokenType::COLON, - TokenType::QUESTION_MARK->value => TokenType::QUESTION_MARK, - default => null, - }; - - return match ($singleMatch) { - TokenType::LBRACKET => $nextToken === TokenType::RBRACKET->value ? TokenType::CLOSED_BRACKETS : TokenType::LBRACKET, - TokenType::COLON => $nextToken === TokenType::COLON->value ? TokenType::DOUBLE_COLON : TokenType::COLON, - default => $singleMatch, - }; - } - - private function determineBufferedTokenType(string $characters): TokenType - { - if (filter_var($characters, FILTER_VALIDATE_INT) !== false) { - return TokenType::INT; - } - - if (filter_var($characters, FILTER_VALIDATE_FLOAT) !== false) { - return TokenType::FLOAT; - } - - if ($characters === 'true' || $characters === 'false') { - return TokenType::BOOL; - } - - if (preg_match('/^[a-zA-Z0-9\\\_]+::[a-zA-Z0-9_]+$/', $characters) === 1) { - return TokenType::CLASS_CONST; - } - - return TokenType::IDENTIFIER; - } - -} \ No newline at end of file diff --git a/tests/Unit/Executor/SchemaExecutorTest.php b/tests/Unit/Executor/SchemaExecutorTest.php index 685ba8f..7a6df1d 100644 --- a/tests/Unit/Executor/SchemaExecutorTest.php +++ b/tests/Unit/Executor/SchemaExecutorTest.php @@ -2,17 +2,8 @@ namespace Tests\Unit\Executor; -use Closure; use DateTimeImmutable; -use JsonException; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; -use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\Success; -use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; -use Le0daniel\PhpTsBindings\Parser\ASTOptimizer; -use Le0daniel\PhpTsBindings\Parser\AstValidator; -use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Parser\TypeStringTokenizer; use Stringable; use Tests\Unit\Executor\Mocks\UserSchema; diff --git a/tests/Unit/Parser/Definition/LexemesTest.php b/tests/Unit/Parser/Definition/LexemesTest.php new file mode 100644 index 0000000..b2e329e --- /dev/null +++ b/tests/Unit/Parser/Definition/LexemesTest.php @@ -0,0 +1,62 @@ +toBe('hello') + ->and(Lexemes::decodeString("''"))->toBe('') + ->and(Lexemes::decodeString("'18'"))->toBe('18') + ->and(Lexemes::decodeString("' '"))->toBe(' ') + ->and(Lexemes::decodeString("'key something else'"))->toBe('key something else') + ->and(Lexemes::decodeString("'it\\'s'"))->toBe("it's") + ->and(Lexemes::decodeString("'say \"hi\"'"))->toBe('say "hi"') + ->and(Lexemes::decodeString("'\\\\'"))->toBe('\\') + ->and(Lexemes::decodeString("'foo\\\\bar'"))->toBe('foo\\bar') + // PHP does NOT interpret \n inside single quotes. + ->and(Lexemes::decodeString("'a\\nb'"))->toBe('a\\nb'); +}); + +test('double quoted literals resolve the full escape set', function () { + expect(Lexemes::decodeString('"hello"'))->toBe('hello') + ->and(Lexemes::decodeString('""'))->toBe('') + ->and(Lexemes::decodeString('"0"'))->toBe('0') + ->and(Lexemes::decodeString('"it\'s"'))->toBe("it's") + ->and(Lexemes::decodeString('"say \\"hi\\""'))->toBe('say "hi"') + ->and(Lexemes::decodeString('"a\\nb"'))->toBe("a\nb") + ->and(Lexemes::decodeString('"a\\tb"'))->toBe("a\tb") + ->and(Lexemes::decodeString('"a\\rb"'))->toBe("a\rb") + ->and(Lexemes::decodeString('"\\x41"'))->toBe('A') + ->and(Lexemes::decodeString('"\\101"'))->toBe('A') + ->and(Lexemes::decodeString('"\\u{1F600}"'))->toBe("\u{1F600}") + ->and(Lexemes::decodeString('"C:\\\\path"'))->toBe('C:\\path') + ->and(Lexemes::decodeString('"non-empty-string"'))->toBe('non-empty-string'); +}); + +test('integers decode with separators, sign and radix prefixes', function () { + expect(Lexemes::decodeInt('0'))->toBe(0) + ->and(Lexemes::decodeInt('1'))->toBe(1) + ->and(Lexemes::decodeInt('-1'))->toBe(-1) + ->and(Lexemes::decodeInt('+1'))->toBe(1) + ->and(Lexemes::decodeInt('-100'))->toBe(-100) + ->and(Lexemes::decodeInt('1_000'))->toBe(1000) + ->and(Lexemes::decodeInt('-1_000_000'))->toBe(-1000000) + ->and(Lexemes::decodeInt('0x1F'))->toBe(31) + ->and(Lexemes::decodeInt('0X1f'))->toBe(31) + ->and(Lexemes::decodeInt('0b1010'))->toBe(10) + ->and(Lexemes::decodeInt('0o17'))->toBe(15) + ->and(Lexemes::decodeInt('-0x10'))->toBe(-16) + // A leading zero stays DECIMAL, matching the old tokenizer's (int) cast. + // This is why intval($value, 0) cannot be used here. + ->and(Lexemes::decodeInt('010'))->toBe(10); +}); + +test('floats decode with separators and exponents', function () { + expect(Lexemes::decodeFloat('0.1'))->toBe(0.1) + ->and(Lexemes::decodeFloat('-0.3'))->toBe(-0.3) + ->and(Lexemes::decodeFloat('.5'))->toBe(0.5) + ->and(Lexemes::decodeFloat('1e5'))->toBe(100000.0) + ->and(Lexemes::decodeFloat('1.5e-3'))->toBe(0.0015) + ->and(Lexemes::decodeFloat('1_000.5'))->toBe(1000.5); +}); diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 96b9d87..5172c5f 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\CodeGen\TypescriptDefinitionGenerator; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; +use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; @@ -23,7 +24,6 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Parser\TypeStringTokenizer; use Le0daniel\PhpTsBindings\Validators\Email; use Tests\Feature\Mocks\Paginated; use Tests\Mocks\ResultEnum; @@ -38,7 +38,7 @@ test('test simple union', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); expect($node = $parser->parse("string | int")) ->toBeInstanceOf(UnionNode::class); @@ -47,7 +47,7 @@ }); test('test literal union', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ expect($node = $parser->parse("7|'18'|true")) @@ -75,7 +75,7 @@ }); test('Complex inheritance', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse(FullAccount::class); @@ -95,7 +95,7 @@ }); test('test scalar', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse("scalar"); @@ -118,7 +118,7 @@ }); test('test questionmark nullability support', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse("?float"); @@ -134,14 +134,14 @@ }); test('test failure on question mark union', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); expect(fn() => $parser->parse("?float|null")) ->toThrow("Cannot mix union with intersection or nullable types. Use brackets to do so. Example: (A&B)|C or null|A|B"); }); test('test group support of question mark nullability and flattened result', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse("(?float)|string"); @@ -158,7 +158,7 @@ }); test('float', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var BuiltInNode $node */ $node = $parser->parse("float"); @@ -169,7 +169,7 @@ }); test('int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var BuiltInNode $node */ $node = $parser->parse("int"); @@ -180,7 +180,7 @@ }); test('Generic Int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse("int<0, 100>"); @@ -195,7 +195,7 @@ }); test('Generic Int Min', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse("int"); @@ -210,7 +210,7 @@ }); test('Generic Int Max', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse("int<-1, max>"); @@ -225,7 +225,7 @@ }); test('Generic Int Negative Values', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse("int<-100, -3>"); @@ -240,7 +240,7 @@ }); test('numeric', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse("numeric"); @@ -260,7 +260,6 @@ test('Global aliases', function () { $parser = new TypeParser( - new TypeStringTokenizer(), TypeParser::defaultConsumers(new GlobalTypeAliases([ 'Email' => fn() => new ConstraintNode( new BuiltInNode(BuiltInType::STRING), @@ -279,7 +278,7 @@ }); test('positive-int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ConstraintNode $node */ $node = $parser->parse("positive-int"); @@ -291,7 +290,7 @@ }); test('Local type resolution', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ConstraintNode $node */ $node = $parser->parse("AddressInput", ParsingContext::fromClassString(Address::class)); compareToOptimizedAst($node); @@ -301,7 +300,7 @@ }); test('Local imported resolution', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ConstraintNode $node */ $node = $parser->parse("AddressInputData", ParsingContext::fromClassString(MyUserClass::class)); compareToOptimizedAst($node); @@ -311,7 +310,7 @@ }); test('non-negative-int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ConstraintNode $node */ $node = $parser->parse("non-negative-int"); @@ -323,7 +322,7 @@ }); test('non-positive-int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ConstraintNode $node */ $node = $parser->parse("non-positive-int"); @@ -335,7 +334,7 @@ }); test('negative-int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ConstraintNode $node */ $node = $parser->parse("negative-int"); @@ -347,7 +346,7 @@ }); test('object struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var StructNode $node */ $node = $parser->parse("object{a: string, b: int}"); expect($node)->toBeInstanceOf(StructNode::class); @@ -363,7 +362,7 @@ }); test('array struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var StructNode $node */ $node = $parser->parse("array{a: string, b: int}"); expect($node)->toBeInstanceOf(StructNode::class); @@ -379,7 +378,7 @@ }); test('simplified tuple struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var TupleNode $node */ $node = $parser->parse("array{string, int}"); expect($node)->toBeInstanceOf(TupleNode::class); @@ -394,7 +393,7 @@ }); test('classic tuple struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var TupleNode $node */ $node = $parser->parse("array{0:string, 1: int}"); expect($node)->toBeInstanceOf(TupleNode::class); @@ -409,7 +408,7 @@ }); test('List struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ListNode $node */ $node = $parser->parse("array"); expect($node)->toBeInstanceOf(ListNode::class); @@ -421,7 +420,7 @@ }); test('List by modifier', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ListNode $node */ $node = $parser->parse("string[]"); expect($node)->toBeInstanceOf(ListNode::class); @@ -433,7 +432,7 @@ }); test('Grouped Modifier', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ListNode $node */ $node = $parser->parse("(string|int)[]"); expect($node)->toBeInstanceOf(ListNode::class); @@ -450,7 +449,7 @@ }); test('Record struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var RecordNode $node */ $node = $parser->parse("array"); expect($node)->toBeInstanceOf(RecordNode::class); @@ -462,7 +461,7 @@ }); test('Test simple literals', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse("1|-2|true|false|'string'"); expect($node)->toBeInstanceOf(UnionNode::class); @@ -482,7 +481,7 @@ }); test('Test date time literals', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse(\DateTime::class); expect($node)->toBeInstanceOf(DateTimeNode::class); @@ -490,7 +489,7 @@ }); test('Test date time with a namespace', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse(\DateTime::class, new ParsingContext('SomeName\\Space')); expect($node)->toBeInstanceOf(DateTimeNode::class); @@ -498,7 +497,7 @@ }); test('Test EnumCase and class const literal', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse( "ResultEnumBase::SUCCESS|ResultEnumBase::FAILURE|ResultEnum::OTHER", @@ -522,7 +521,7 @@ }); test('Simple intersection', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var IntersectionNode $node */ $node = $parser->parse('array{id:string}&array{reason:string}'); expect($node)->toBeInstanceOf(IntersectionNode::class); @@ -531,7 +530,7 @@ }); test('Tailing comma', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var IntersectionNode $node */ $node = $parser->parse('array{id:string,}'); expect($node)->toBeInstanceOf(StructNode::class); @@ -540,7 +539,7 @@ }); test('Tailing comma on object struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var IntersectionNode $node */ $node = $parser->parse('object{id:string,}'); expect($node)->toBeInstanceOf(StructNode::class); @@ -549,7 +548,7 @@ }); test('Tailing comma tuple', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var IntersectionNode $node */ $node = $parser->parse('array{string, string,}'); expect($node)->toBeInstanceOf(TupleNode::class); @@ -558,7 +557,7 @@ }); test('Tailing comma tuple with integer keys', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var IntersectionNode $node */ $node = $parser->parse('array{0:string, 1:string,}'); expect($node)->toBeInstanceOf(TupleNode::class); @@ -567,7 +566,7 @@ }); test('Complex intersection', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var IntersectionNode $node */ $node = $parser->parse('(array{id:string}|array{token:string})&array{reason:string}'); expect($node)->toBeInstanceOf(IntersectionNode::class); @@ -576,7 +575,7 @@ }); test('Generics parsing', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse(Paginated::class . ''); expect($node)->toBeInstanceOf(CustomCastingNode::class); compareToOptimizedAst($node); @@ -588,7 +587,7 @@ }); test('Generics parsing with readonly output properties', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse(ReadonlyOutputFields::class); expect($node)->toBeInstanceOf(CustomCastingNode::class); compareToOptimizedAst($node); @@ -600,7 +599,7 @@ }); test('Do not cast in default mode', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse(UncastableClass::class); expect($node)->toBeInstanceOf(CustomCastingNode::class); compareToOptimizedAst($node); @@ -615,7 +614,7 @@ }); test('fails on missing or too many generics', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); expect(fn() => $parser->parse(Paginated::class . '')) ->toThrow('Number of generics does not match. Expected 1 , got 2.') @@ -624,7 +623,7 @@ }); test('Test Pick Node simple case', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var StructNode $node */ $node = $parser->parse("Pick"); expect($node)->toBeInstanceOf(StructNode::class) @@ -645,7 +644,7 @@ }); test('Test Omit Node simple case', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var StructNode $node */ $node = $parser->parse("Omit"); expect($node)->toBeInstanceOf(StructNode::class) @@ -666,7 +665,7 @@ }); test('Test Pick and Omit Node with custom class', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var StructNode $node */ $node = $parser->parse("Pick<" . UserMock::class . ", 'username'>"); @@ -690,7 +689,7 @@ }); test('Pick and Omit Typescript definitions', function (string $expectedDefinition, string $type) { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse($type); compareToOptimizedAst($node); @@ -710,7 +709,7 @@ ]); test("parse interface properties", function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse(SomeFileInterface::class); compareToOptimizedAst($node); @@ -722,7 +721,7 @@ }); test("parse abstract class properties", function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse(SomeAbstractClass::class); compareToOptimizedAst($node); @@ -735,7 +734,7 @@ test("parse BrandedInt correctly", function () { // Branded types are optimized away. They have no runtime Impact - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse("BrandedInt<'wow'>"); compareToOptimizedAst($node); @@ -756,7 +755,7 @@ test("parse BrandedString correctly", function () { // Branded types are optimized away. They have no runtime Impact - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse("BrandedString<'wow'>"); compareToOptimizedAst($node); @@ -774,4 +773,179 @@ $inputDef = $tsGeneratorWithoutBrand->toDefinition($node, DefinitionTarget::INPUT); expect($inputDef)->toBe('string'); -}); \ No newline at end of file +}); + +/** + * --------------------------------------------------------------------------- + * Lexer migration: new functionality + * + * These constructs are unreachable through the old TypeStringTokenizer and + * become parseable once TypeParser runs on Parser\Lexer\Lexer. + * --------------------------------------------------------------------------- + */ + +test('Array shape keys may be double quoted', function () { + /** @var StructNode $node */ + $node = new TypeParser()->parse('array{"key something else": string, b: int}'); + + expect($node)->toBeInstanceOf(StructNode::class) + ->and($node->phpType)->toBe(StructPhpType::ARRAY) + ->and($node->hasProperty('key something else'))->toBeTrue() + ->and($node->getProperty('key something else')?->node)->toBeInstanceOf(BuiltInNode::class) + ->and($node->getProperty('key something else')?->node->type)->toBe(BuiltInType::STRING) + ->and($node->hasProperty('b'))->toBeTrue() + ->and($node->getProperty('b')?->node->type)->toBe(BuiltInType::INT); + + compareToOptimizedAst($node); + validateAst($node); +}); + +test('Array shape keys may be single quoted and optional', function () { + /** @var StructNode $node */ + $node = new TypeParser()->parse("object{'k': int, 'two words'?: string}"); + + expect($node)->toBeInstanceOf(StructNode::class) + ->and($node->phpType)->toBe(StructPhpType::OBJECT) + ->and($node->getProperty('k')?->isOptional)->toBeFalse() + ->and($node->getProperty('two words')?->isOptional)->toBeTrue(); + + compareToOptimizedAst($node); +}); + +test('Quoted keys with spaces emit valid Typescript', function () { + $node = new TypeParser()->parse('array{"key something else": string}'); + + expect(typescriptDefinition($node, DefinitionTarget::OUTPUT)) + ->toBe('{"key something else":string;}'); +}); + +test('Quoted key escapes are resolved', function () { + /** @var StructNode $node */ + $node = new TypeParser()->parse("array{'it\\'s': string}"); + + expect($node->hasProperty("it's"))->toBeTrue(); +}); + +test('String literals honour escapes', function () { + // The old tokenizer threw RuntimeException('Unclosed block type') on both of these. + /** @var UnionNode $node */ + $node = new TypeParser()->parse("'it\\'s'|\"say \\\"hi\\\"\""); + + expect($node)->toBeInstanceOf(UnionNode::class) + ->and($node->types[0])->toBeInstanceOf(LiteralNode::class) + ->and($node->types[0]->type)->toBe(LiteralType::STRING) + ->and($node->types[0]->value)->toBe("it's") + ->and($node->types[1]->value)->toBe('say "hi"'); +}); + +test('Whitespace between brackets is allowed', function () { + // The old tokenizer merged [ and ] with a one character lookahead. + expect(new TypeParser()->parse('string[ ]'))->toBeInstanceOf(ListNode::class) + ->and(new TypeParser()->parse('string[]'))->toBeInstanceOf(ListNode::class); +}); + +test('A trailing double colon is a syntax error and raises no PHP warning', function () { + // The old tokenizer read $typeString[$currentOffset + 2] unguarded, which emitted + // "PHP Warning: Uninitialized string offset 5". + $warnings = []; + set_error_handler(function (int $severity, string $message) use (&$warnings): bool { + $warnings[] = $message; + return true; + }); + + try { + expect(fn() => new TypeParser()->parse('Foo::')) + ->toThrow(InvalidSyntaxException::class); + } finally { + restore_error_handler(); + } + + expect($warnings)->toBe([]); +}); + +test('Illegal characters raise InvalidSyntaxException, not a lexer exception', function () { + // Regexes::findFirstVarDeclaration() leaks the closing */ out of single line + // docblocks, so this exact string reaches the parser in the wild. + expect(fn() => new TypeParser()->parse('array{id: string} */')) + ->toThrow(InvalidSyntaxException::class) + ->and(fn() => new TypeParser()->parse('a#b')) + ->toThrow(InvalidSyntaxException::class) + ->and(fn() => new TypeParser()->parse('%')) + ->toThrow(InvalidSyntaxException::class) + ->and(fn() => new TypeParser()->parse("array{'unterminated: int}")) + ->toThrow(InvalidSyntaxException::class); +}); + +test('A truncated array shape is a syntax error, not a PHP Error', function () { + // Before the migration this crashed with: + // "Call to a member function isAnyTypeOf() on null". + expect(fn() => new TypeParser()->parse('array{')) + ->toThrow(InvalidSyntaxException::class) + ->and(fn() => new TypeParser()->parse('array{a')) + ->toThrow(InvalidSyntaxException::class); +}); + +/** + * --------------------------------------------------------------------------- + * Lexer migration: gap locks + * + * The new lexer happily tokenizes all of these. The parser deliberately does + * not support them, and this test pins that boundary. + * --------------------------------------------------------------------------- + */ + +test('Constructs the lexer accepts but the parser does not support', function () { + $unsupported = [ + 'array{foo: int, ...}', + 'array{...}', + 'array{}', + 'callable(int): void', + 'Closure(int, ...): void', + '$this', + 'Foo::*', + 'Foo', + '($x is int ? string : bool)', + ]; + + foreach ($unsupported as $type) { + expect(fn() => new TypeParser()->parse($type)) + ->toThrow(InvalidSyntaxException::class, message: "Should reject: {$type}"); + } +}); + +/** + * --------------------------------------------------------------------------- + * Lexer migration: regression guards + * --------------------------------------------------------------------------- + */ + +test('Literal booleans stay literals and null stays a built in', function () { + // true/false used to be their own TokenType::BOOL; they are plain IDENTIFIERs now. + /** @var UnionNode $node */ + $node = new TypeParser()->parse('true|false'); + + expect($node->types[0])->toBeInstanceOf(LiteralNode::class) + ->and($node->types[0]->type)->toBe(LiteralType::BOOL) + ->and($node->types[0]->value)->toBeTrue() + ->and($node->types[1]->type)->toBe(LiteralType::BOOL) + ->and($node->types[1]->value)->toBeFalse() + ->and(new TypeParser()->parse('null'))->toBeInstanceOf(BuiltInNode::class); +}); + +test('Numeric literal forms decode correctly', function () { + // 1e5 already worked by accident via filter_var; 1_000 and 0x1F used to die + // with "No parser found." because they fell through to IDENTIFIER. + /** @var LiteralNode $exponent */ + $exponent = new TypeParser()->parse('1e5'); + /** @var LiteralNode $separated */ + $separated = new TypeParser()->parse('1_000'); + /** @var LiteralNode $hex */ + $hex = new TypeParser()->parse('0x1F'); + + expect($exponent->type)->toBe(LiteralType::FLOAT) + ->and($exponent->value)->toBe(100000.0) + ->and($separated->type)->toBe(LiteralType::INT) + ->and($separated->value)->toBe(1000) + ->and($hex->type)->toBe(LiteralType::INT) + ->and($hex->value)->toBe(31); +}); diff --git a/tests/Unit/TypeStringTokenizerTest.php b/tests/Unit/TypeStringTokenizerTest.php deleted file mode 100644 index c484f78..0000000 --- a/tests/Unit/TypeStringTokenizerTest.php +++ /dev/null @@ -1,143 +0,0 @@ -tokenize($string), - new ParsingContext - ); -} - - -test('tokenize', function () { - - expect(tokenize("string|int"))->toHaveCount(4) - ->and(tokenize("string | int"))->toHaveCount(4) - ->and(tokenize("string | int[]"))->toHaveCount(5) - ->and(tokenize("string|int[]"))->toHaveCount(5) - ->and(tokenize("string|int[]|object{name: 5}"))->toHaveCount(12) - ->and(tokenize("string::class"))->toHaveCount(2); -}); - -test("0 Values caught correctly", function () { - $tokens = tokenize("string|0|array{0: string, 1: string}"); - - expect($tokens->at(2)->is(TokenType::INT))->toBeTrue(); - expect($tokens->at(2)->value)->toBe("0"); - expect($tokens->at(6)->is(TokenType::INT))->toBeTrue(); - expect($tokens->at(6)->value)->toBe("0"); -}); - -test("Tailing comma allowed on array", function () { - $tokens = tokenize("array{name: string,}"); - - expect($tokens->at(0)->is(TokenType::IDENTIFIER))->toBeTrue(); - expect($tokens->at(1)->is(TokenType::LBRACE))->toBeTrue(); - expect($tokens->at(2)->is(TokenType::IDENTIFIER))->toBeTrue(); - expect($tokens->at(3)->is(TokenType::COLON))->toBeTrue(); - expect($tokens->at(4)->is(TokenType::IDENTIFIER))->toBeTrue(); - expect($tokens->at(5)->is(TokenType::COMMA))->toBeTrue(); - expect($tokens->at(6)->is(TokenType::RBRACE))->toBeTrue(); -}); - -test("Tailing comma allowed on object", function () { - $tokens = tokenize("object{name: string,}"); - - expect($tokens->at(0)->is(TokenType::IDENTIFIER))->toBeTrue(); - expect($tokens->at(1)->is(TokenType::LBRACE))->toBeTrue(); - expect($tokens->at(2)->is(TokenType::IDENTIFIER))->toBeTrue(); - expect($tokens->at(3)->is(TokenType::COLON))->toBeTrue(); - expect($tokens->at(4)->is(TokenType::IDENTIFIER))->toBeTrue(); - expect($tokens->at(5)->is(TokenType::COMMA))->toBeTrue(); - expect($tokens->at(6)->is(TokenType::RBRACE))->toBeTrue(); -}); - -test("Identifies Class Const correctly", function () { - - - $tokens = tokenize("string|0|Value::INVALID|array{0: string, 1: string}"); - - expect($tokens->at(4)->is(TokenType::CLASS_CONST))->toBeTrue(); - expect($tokens->at(4)->value)->toBe("Value::INVALID"); - expect($tokens)->toHaveCount(17); -}); - -test("Identifies groups correctly", function () { - - - $tokens = tokenize("(string|int)|string"); - - foreach ($tokens as $index => $token) { - match ($index) { - 0 => expect($token->is(TokenType::LPAREN))->toBeTrue(), - 1 => expect($token->is(TokenType::IDENTIFIER, 'string'))->toBeTrue(), - 2 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 3 => expect($token->is(TokenType::IDENTIFIER, 'int'))->toBeTrue(), - 4 => expect($token->is(TokenType::RPAREN))->toBeTrue(), - 5 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 6 => expect($token->is(TokenType::IDENTIFIER, 'string'))->toBeTrue(), - default => expect($token->is(TokenType::EOF))->toBeTrue(), - }; - } -}); - -test("positive and negative numbers", function () { - - $tokens = tokenize("0|1|-1|0.1|-0.3"); - foreach ($tokens as $index => $token) { - match ($index) { - 0 => expect($token->is(TokenType::INT, '0'))->toBeTrue(), - 1 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 2 => expect($token->is(TokenType::INT, '1'))->toBeTrue(), - 3 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 4 => expect($token->is(TokenType::INT, '-1'))->toBeTrue(), - 5 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 6 => expect($token->is(TokenType::FLOAT, '0.1'))->toBeTrue(), - 7 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 8 => expect($token->is(TokenType::FLOAT, '-0.3'))->toBeTrue(), - default => expect($token->is(TokenType::EOF))->toBeTrue(), - }; - } -}); - -test("Test tokenizer with complex string", function () { - - $tokens = tokenize("string|0|Value::INVALID_INVALID|array{0: string, 1: string}|object{name: 'leo'}"); - - foreach ($tokens as $index => $token) { - match ($index) { - 0 => expect($token->is(TokenType::IDENTIFIER, 'string'))->toBeTrue(), - 1 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 2 => expect($token->is(TokenType::INT, '0'))->toBeTrue(), - 3 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 4 => expect($token->is(TokenType::CLASS_CONST, 'Value::INVALID_INVALID'))->toBeTrue(), - 5 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 6 => expect($token->is(TokenType::IDENTIFIER, 'array'))->toBeTrue(), - 7 => expect($token->is(TokenType::LBRACE))->toBeTrue(), - 8 => expect($token->is(TokenType::INT, '0'))->toBeTrue(), - 9 => expect($token->is(TokenType::COLON))->toBeTrue(), - 10 => expect($token->is(TokenType::IDENTIFIER, 'string'))->toBeTrue(), - 11 => expect($token->is(TokenType::COMMA))->toBeTrue(), - 12 => expect($token->is(TokenType::INT, '1'))->toBeTrue(), - 13 => expect($token->is(TokenType::COLON))->toBeTrue(), - 14 => expect($token->is(TokenType::IDENTIFIER, 'string'))->toBeTrue(), - 15 => expect($token->is(TokenType::RBRACE))->toBeTrue(), - 16 => expect($token->is(TokenType::PIPE))->toBeTrue(), - 17 => expect($token->is(TokenType::IDENTIFIER, 'object'))->toBeTrue(), - 18 => expect($token->is(TokenType::LBRACE))->toBeTrue(), - 19 => expect($token->is(TokenType::IDENTIFIER, 'name'))->toBeTrue(), - 20 => expect($token->is(TokenType::COLON))->toBeTrue(), - 21 => expect($token->is(TokenType::STRING, 'leo'))->toBeTrue(), - 22 => expect($token->is(TokenType::RBRACE))->toBeTrue(), - default => expect($token->is(TokenType::EOF))->toBeTrue(), - }; - } -}); \ No newline at end of file From 718e49e692c1dbe066638916d922e92961b1978b Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 08:01:42 +0200 Subject: [PATCH 003/101] Add value object parsing and serialization, including brand support for TypeScript code generation and related tests. --- README.md | 75 +++++++ src/CodeGen/CodeGenerators/EmitTypes.php | 19 +- src/CodeGen/TypescriptDefinitionGenerator.php | 8 +- src/Contracts/Attributes/Brand.php | 26 +++ src/Contracts/Branded.php | 15 ++ src/Contracts/ValueObjects/IntValueObject.php | 23 ++ .../ValueObjects/StringValueObject.php | 24 ++ src/Parser/Consumers/ValueObjectConsumer.php | 87 ++++++++ src/Parser/Nodes/Leaf/BuiltInNode.php | 8 +- src/Parser/Nodes/Leaf/ValueObjectNode.php | 207 ++++++++++++++++++ src/Parser/TypeParser.php | 9 +- .../ValueObjects/AbstractValueObject.php | 26 +++ .../ValueObjects/AmbiguousValueObject.php | 37 ++++ .../Mocks/ValueObjects/CreateAccountInput.php | 12 + tests/Mocks/ValueObjects/Email.php | 29 +++ .../ValueObjects/ExplodingValueObject.php | 26 +++ tests/Mocks/ValueObjects/Slug.php | 38 ++++ tests/Mocks/ValueObjects/StatusEnum.php | 26 +++ tests/Mocks/ValueObjects/UserId.php | 29 +++ tests/Unit/CodeGen/EmitTypesTest.php | 69 ++++++ tests/Unit/Executor/SchemaExecutorTest.php | 168 +++++++++++++- tests/Unit/Parser/ValueObjectConsumerTest.php | 161 ++++++++++++++ 22 files changed, 1108 insertions(+), 14 deletions(-) create mode 100644 src/Contracts/Attributes/Brand.php create mode 100644 src/Contracts/Branded.php create mode 100644 src/Contracts/ValueObjects/IntValueObject.php create mode 100644 src/Contracts/ValueObjects/StringValueObject.php create mode 100644 src/Parser/Consumers/ValueObjectConsumer.php create mode 100644 src/Parser/Nodes/Leaf/ValueObjectNode.php create mode 100644 tests/Mocks/ValueObjects/AbstractValueObject.php create mode 100644 tests/Mocks/ValueObjects/AmbiguousValueObject.php create mode 100644 tests/Mocks/ValueObjects/CreateAccountInput.php create mode 100644 tests/Mocks/ValueObjects/Email.php create mode 100644 tests/Mocks/ValueObjects/ExplodingValueObject.php create mode 100644 tests/Mocks/ValueObjects/Slug.php create mode 100644 tests/Mocks/ValueObjects/StatusEnum.php create mode 100644 tests/Mocks/ValueObjects/UserId.php create mode 100644 tests/Unit/CodeGen/EmitTypesTest.php create mode 100644 tests/Unit/Parser/ValueObjectConsumerTest.php diff --git a/README.md b/README.md index 8aaecca..7ebbffb 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,81 @@ $parsed = $executor->parse($node, ['key' => 'value']); $serialized = $executor->serialize($node, "my string"); ``` +## Value Objects + +Wrapping an id or an email in its own class usually costs you the type on the wire: a plain class is +reflected property by property, so `UserId` would show up in TypeScript as `{value: number}`. Value +objects avoid that. A class implementing `StringValueObject` or `IntValueObject` is treated as its +backing primitive — a bare `string` or `number` in JSON — and hydrated back into the class on input. + +```php +use Le0daniel\PhpTsBindings\Contracts\Attributes\Brand; +use Le0daniel\PhpTsBindings\Contracts\ValueObjects\IntValueObject; + +#[Brand] +final readonly class UserId implements IntValueObject +{ + private function __construct(public int $value) {} + + public static function fromIntValue(int $value): static + { + if ($value < 1) { + throw new InvalidArgumentException("UserId must be positive, got {$value}"); + } + return new self($value); + } + + public function toIntValue(): int + { + return $this->value; + } +} +``` + +The two interfaces are: + +| Interface | Methods | JSON type | +|---|---|---| +| `StringValueObject` | `static fromStringValue(string): static`, `toStringValue(): string` | `string` | +| `IntValueObject` | `static fromIntValue(int): static`, `toIntValue(): int` | `number` | + +The methods carry the `...Value` suffix so the interfaces stay safe to add to a class that already +implements `Stringable` or declares its own `toString()`. + +Implementing the interface *is* the opt-in: unlike a plain class, a value object needs no `#[Castable]` +attribute and works for both input and output. Use it anywhere a type is parsed: + +```php +/** @return object{id: UserId, email: Email, tags: list} */ +``` + +**Rejecting values.** `fromStringValue()` / `fromIntValue()` may throw to reject input. The exception is +caught and reported as a validation issue on that field, with the original exception attached for +debugging — it never reaches the client as an internal error, and never escapes the executor. + +### Branded types + +Without a brand, `UserId` and any other int are interchangeable in TypeScript. Add `#[Brand]` and the +generated type becomes opaque: + +```php +#[Brand] // brand name defaults to lcfirst('UserId') => "userId" +#[Brand('customerId')] // or name it yourself +``` + +```typescript +declare const __brand: unique symbol; +export type Brand = {readonly [__brand]: TBrand;}; + +export type UserId = number & Brand<"userId">; + +declare function getUser(id: UserId): void; +getUser(1); // Type error: number is not assignable to UserId +``` + +Value objects without `#[Brand]` stay plain `string` / `number`. Brands are code generation metadata +only — they have no runtime impact, and `php artisan operations:codegen --no-branded-types` strips them. + ## Validating AST By default, the parsed AST is not validated. This means, the AST itself can be invalid. For example Intersection types diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 010c740..cbb3eda 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -6,13 +6,13 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\DefinitionTarget; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\Contracts\Branded; use Le0daniel\PhpTsBindings\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Contracts\ValidatableNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\NamedNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; @@ -98,7 +98,7 @@ private function generateNamespaceUnion(array $namespaces): string */ private function collectBrandedTypes(NodeInterface $ast, DefinitionTarget $target): array { - /** @var BuiltInNode[] $brandedNodes */ + /** @var list $brandedNodes */ $brandedNodes = []; $stack = [ @@ -111,7 +111,7 @@ private function collectBrandedTypes(NodeInterface $ast, DefinitionTarget $targe } if ($current instanceof LeafNode) { - if ($current instanceof BuiltInNode && $current->brand !== null) { + if ($current instanceof Branded && $current->brandName() !== null) { $brandedNodes[] = $current; } @@ -128,17 +128,22 @@ private function collectBrandedTypes(NodeInterface $ast, DefinitionTarget $targe $brandedTypes = []; foreach ($brandedNodes as $node) { + $brand = $node->brandName(); + if ($brand === null) { + continue; + } + $typeDefinition = $target === DefinitionTarget::INPUT ? $node->inputDefinition() : $node->outputDefinition(); - if (!isset($brandedTypes[$node->brand])) { - $brandedTypes[$node->brand] = $typeDefinition; + if (!isset($brandedTypes[$brand])) { + $brandedTypes[$brand] = $typeDefinition; continue; } - if ($typeDefinition !== $brandedTypes[$node->brand]) { - throw new RuntimeException("Branded type {$node->brand} has different definitions"); + if ($typeDefinition !== $brandedTypes[$brand]) { + throw new RuntimeException("Branded type {$brand} has different definitions"); } } diff --git a/src/CodeGen/TypescriptDefinitionGenerator.php b/src/CodeGen/TypescriptDefinitionGenerator.php index c1d2ef0..4241dd7 100644 --- a/src/CodeGen/TypescriptDefinitionGenerator.php +++ b/src/CodeGen/TypescriptDefinitionGenerator.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\CodeGen; use Le0daniel\PhpTsBindings\CodeGen\Utils\Typescript; +use Le0daniel\PhpTsBindings\Contracts\Branded; use Le0daniel\PhpTsBindings\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\CodeGen\Data\DefinitionTarget; @@ -10,7 +11,6 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; @@ -31,8 +31,10 @@ public function __construct( public function toDefinition(NodeInterface $node, DefinitionTarget $target): string { if ($node instanceof LeafNode) { - if ($this->emitBrandedTypes && $node instanceof BuiltInNode && $node->brand) { - $encodedBrand = json_encode($node->brand, JSON_THROW_ON_ERROR); + $brand = $node instanceof Branded ? $node->brandName() : null; + + if ($this->emitBrandedTypes && $brand !== null) { + $encodedBrand = json_encode($brand, JSON_THROW_ON_ERROR); return $target === DefinitionTarget::INPUT ? "{$node->inputDefinition()} & Brand<{$encodedBrand}>" : "{$node->outputDefinition()} & Brand<{$encodedBrand}>"; diff --git a/src/Contracts/Attributes/Brand.php b/src/Contracts/Attributes/Brand.php new file mode 100644 index 0000000..d6cff2b --- /dev/null +++ b/src/Contracts/Attributes/Brand.php @@ -0,0 +1,26 @@ +`, because the code + * generator capitalizes the brand when naming the alias. + * + * Brands are code generation metadata only. They have no runtime impact and are stripped + * entirely when `operations:codegen` runs with --no-branded-types. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class Brand +{ + public function __construct( + public ?string $name = null, + ) + { + } +} diff --git a/src/Contracts/Branded.php b/src/Contracts/Branded.php new file mode 100644 index 0000000..b62724e --- /dev/null +++ b/src/Contracts/Branded.php @@ -0,0 +1,15 @@ +currentTokenIs(TokenType::IDENTIFIER)) { + return false; + } + + $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($state->current()->value); + + return is_a($fullyQualifiedClassName, StringValueObject::class, true) + || is_a($fullyQualifiedClassName, IntValueObject::class, true); + } + + /** + * @throws ReflectionException + */ + public function consume(ParserState $state, TypeParser $parser): NodeInterface + { + $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($state->current()->value); + + $isStringBacked = is_a($fullyQualifiedClassName, StringValueObject::class, true); + $isIntBacked = is_a($fullyQualifiedClassName, IntValueObject::class, true); + + // Validate before advancing, so the syntax error highlights the offending token. + if ($isStringBacked && $isIntBacked) { + $state->produceSyntaxError( + "Value object {$fullyQualifiedClassName} must implement either StringValueObject or IntValueObject, not both." + ); + } + + $reflectionClass = new ReflectionClass($fullyQualifiedClassName); + if ($reflectionClass->isAbstract() || $reflectionClass->isInterface()) { + $state->produceSyntaxError( + "Value object {$fullyQualifiedClassName} must be instantiable. Abstract classes and interfaces are not supported." + ); + } + + $state->advance(); + + /** @var class-string $fullyQualifiedClassName */ + return new ValueObjectNode( + $fullyQualifiedClassName, + $isStringBacked ? BuiltInType::STRING : BuiltInType::INT, + $this->resolveBrand($reflectionClass), + ); + } + + /** + * @param ReflectionClass $reflectionClass + */ + private function resolveBrand(ReflectionClass $reflectionClass): ?string + { + $attributes = new AttributesReflector($reflectionClass->getAttributes()); + if (!$attributes->has(Brand::class)) { + return null; + } + + // Without an explicit name, the brand is lcfirst of the base class name: UserId -> "userId". + return $attributes->getSingleInstance(Brand::class)->name + ?? lcfirst($reflectionClass->getShortName()); + } +} diff --git a/src/Parser/Nodes/Leaf/BuiltInNode.php b/src/Parser/Nodes/Leaf/BuiltInNode.php index a21a76d..932d9d8 100644 --- a/src/Parser/Nodes/Leaf/BuiltInNode.php +++ b/src/Parser/Nodes/Leaf/BuiltInNode.php @@ -2,6 +2,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes\Leaf; +use Le0daniel\PhpTsBindings\Contracts\Branded; use Le0daniel\PhpTsBindings\Contracts\Coercible; use Le0daniel\PhpTsBindings\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; @@ -14,7 +15,7 @@ use Stringable; use Throwable; -readonly class BuiltInNode implements NodeInterface, LeafNode, Coercible +readonly class BuiltInNode implements NodeInterface, LeafNode, Coercible, Branded { public function __construct( @@ -117,6 +118,11 @@ public function outputDefinition(): string return $this->inputDefinition(); } + public function brandName(): ?string + { + return $this->brand; + } + public function coerce(mixed $value): mixed { return match ($this->type) { diff --git a/src/Parser/Nodes/Leaf/ValueObjectNode.php b/src/Parser/Nodes/Leaf/ValueObjectNode.php new file mode 100644 index 0000000..0205e67 --- /dev/null +++ b/src/Parser/Nodes/Leaf/ValueObjectNode.php @@ -0,0 +1,207 @@ + $className + * @param BuiltInType $backingType Must be BuiltInType::STRING or BuiltInType::INT. + */ + public function __construct( + public string $className, + public BuiltInType $backingType, + public ?string $brand = null, + ) + { + if ($this->backingType !== BuiltInType::STRING && $this->backingType !== BuiltInType::INT) { + throw new InvalidArgumentException( + "Value objects can only be backed by string or int, got: {$this->backingType->value}" + ); + } + } + + /** + * The brand is deliberately excluded here, exactly as in BuiltInNode. It is code generation + * metadata with no runtime impact, and the class name alone already identifies this node + * uniquely for the ASTOptimizer dedupe hash. + */ + public function __toString(): string + { + return "valueObject<{$this->className},{$this->backingType->value}>"; + } + + public function exportPhpCode(): string + { + $className = PHPExport::absolute(self::class); + $valueObjectClass = PHPExport::absolute($this->className); + $backingType = PHPExport::exportEnumCase($this->backingType); + + // The brand is not exported: see __toString(). + return "new {$className}({$valueObjectClass}::class, {$backingType})"; + } + + public function parseValue(mixed $value, ExecutionContext $context): mixed + { + if ($this->backingType === BuiltInType::STRING) { + if (!is_string($value)) { + $context->addIssue($this->invalidBackingTypeIssue($value)); + return Value::INVALID; + } + + try { + /** @var class-string $className */ + $className = $this->className; + return $className::fromStringValue($value); + } catch (Throwable $throwable) { + $context->addIssue($this->rejectedByFactoryIssue($value, $throwable)); + return Value::INVALID; + } + } + + if (!is_int($value)) { + $context->addIssue($this->invalidBackingTypeIssue($value)); + return Value::INVALID; + } + + try { + /** @var class-string $className */ + $className = $this->className; + return $className::fromIntValue($value); + } catch (Throwable $throwable) { + $context->addIssue($this->rejectedByFactoryIssue($value, $throwable)); + return Value::INVALID; + } + } + + public function serializeValue(mixed $value, ExecutionContext $context): mixed + { + if ($this->backingType === BuiltInType::STRING) { + if (!$value instanceof StringValueObject || !is_a($value, $this->className)) { + $context->addIssue($this->notAnInstanceIssue($value)); + return Value::INVALID; + } + + try { + return $value->toStringValue(); + } catch (Throwable $throwable) { + $context->addIssue($this->failedToSerializeIssue($throwable)); + return Value::INVALID; + } + } + + if (!$value instanceof IntValueObject || !is_a($value, $this->className)) { + $context->addIssue($this->notAnInstanceIssue($value)); + return Value::INVALID; + } + + try { + return $value->toIntValue(); + } catch (Throwable $throwable) { + $context->addIssue($this->failedToSerializeIssue($throwable)); + return Value::INVALID; + } + } + + public function inputDefinition(): string + { + return $this->backingType === BuiltInType::STRING ? 'string' : 'number'; + } + + public function outputDefinition(): string + { + return $this->inputDefinition(); + } + + public function brandName(): ?string + { + return $this->brand; + } + + public function coerce(mixed $value): mixed + { + if ($this->backingType === BuiltInType::STRING) { + // Unlike BuiltInNode, only scalars are cast: (string) on an array or a non + // Stringable object throws, and coerce() runs outside the executor's try/catch. + return is_scalar($value) ? (string)$value : $value; + } + + return filter_var($value, FILTER_VALIDATE_INT) !== false + ? (int)$value + : $value; + } + + private function invalidBackingTypeIssue(mixed $value): Issue + { + return new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => "Expected value of type {$this->backingType->value} for {$this->className}, got: " . get_debug_type($value), + 'node' => self::class, + ], + ); + } + + /** + * A throwing factory means the incoming value was rejected, which is a validation failure and + * not a server fault. Issue::fromThrowable() is deliberately not used here: it maps to + * IssueMessage::INTERNAL_ERROR, which would present bad user input as a server error. + */ + private function rejectedByFactoryIssue(mixed $value, Throwable $throwable): Issue + { + return new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => "Value rejected by {$this->className}: {$throwable->getMessage()}", + 'node' => self::class, + 'value' => $value, + ], + exception: $throwable, + ); + } + + private function notAnInstanceIssue(mixed $value): Issue + { + return new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => "Expected instance of {$this->className}, got: " . get_debug_type($value), + 'node' => self::class, + ], + ); + } + + /** + * On the serialize path the value came from the server, so a throwing accessor is a genuine + * internal error. This mirrors BuiltInNode::serializeValue(). + */ + private function failedToSerializeIssue(Throwable $throwable): Issue + { + return Issue::fromThrowable($throwable, [ + 'message' => "Failed to serialize {$this->className}", + 'node' => self::class, + ]); + } +} diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index b2ffe09..153e82e 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -14,6 +14,7 @@ use Le0daniel\PhpTsBindings\Parser\Consumers\StructConsumer; use Le0daniel\PhpTsBindings\Parser\Consumers\UserDefinedObjectConsumer; use Le0daniel\PhpTsBindings\Parser\Consumers\UtilsConsumer; +use Le0daniel\PhpTsBindings\Parser\Consumers\ValueObjectConsumer; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; @@ -47,7 +48,7 @@ * It's best to run the parser in your build step to create a static file including all the definitions you need * at runtime. * - * @param TypeConsumer[]|null $consumers + * @param list|null $consumers */ public function __construct( ?array $consumers = null, @@ -74,6 +75,12 @@ public static function defaultConsumers( new BuiltInLeafConsumer(), new StructConsumer(), new ArrayConsumer(), + + // Must precede the three consumers below, each of which would otherwise claim the class + // first. Implementing StringValueObject/IntValueObject cannot happen by accident, so the + // explicit opt-in wins. A backed enum can therefore opt into serializing by backing + // value instead of EnumConsumer's case-name default. + new ValueObjectConsumer(), new EnumConsumer(), new DateTimeConsumer(), new UserDefinedObjectConsumer($allowAllObjectCasting), diff --git a/tests/Mocks/ValueObjects/AbstractValueObject.php b/tests/Mocks/ValueObjects/AbstractValueObject.php new file mode 100644 index 0000000..9c5c2a3 --- /dev/null +++ b/tests/Mocks/ValueObjects/AbstractValueObject.php @@ -0,0 +1,26 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/AmbiguousValueObject.php b/tests/Mocks/ValueObjects/AmbiguousValueObject.php new file mode 100644 index 0000000..e25216a --- /dev/null +++ b/tests/Mocks/ValueObjects/AmbiguousValueObject.php @@ -0,0 +1,37 @@ +value; + } + + public function toIntValue(): int + { + return (int)$this->value; + } +} diff --git a/tests/Mocks/ValueObjects/CreateAccountInput.php b/tests/Mocks/ValueObjects/CreateAccountInput.php new file mode 100644 index 0000000..f43c490 --- /dev/null +++ b/tests/Mocks/ValueObjects/CreateAccountInput.php @@ -0,0 +1,12 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/ExplodingValueObject.php b/tests/Mocks/ValueObjects/ExplodingValueObject.php new file mode 100644 index 0000000..de0069a --- /dev/null +++ b/tests/Mocks/ValueObjects/ExplodingValueObject.php @@ -0,0 +1,26 @@ +value; + } + + public function toString(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/tests/Mocks/ValueObjects/StatusEnum.php b/tests/Mocks/ValueObjects/StatusEnum.php new file mode 100644 index 0000000..a0d0173 --- /dev/null +++ b/tests/Mocks/ValueObjects/StatusEnum.php @@ -0,0 +1,26 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/UserId.php b/tests/Mocks/ValueObjects/UserId.php new file mode 100644 index 0000000..5bf0c71 --- /dev/null +++ b/tests/Mocks/ValueObjects/UserId.php @@ -0,0 +1,29 @@ +value; + } +} diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php new file mode 100644 index 0000000..255bf4a --- /dev/null +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -0,0 +1,69 @@ +parse($inputType), + output: $parser->parse($outputType), + ); + + $files = new EmitTypes()->emitFiles( + [new TypedOperation('', '', '', $operation)], + new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + ); + + return $files['types']; +} + +test('branded value objects are exported as branded typescript types', function () { + $types = emitTypesFor( + 'array{id: \\' . UserId::class . '}', + 'array{email: \\' . Email::class . ', slug: \\' . Slug::class . '}', + ); + + // EmitTypes ucfirst()s the brand name for the alias, so the camelCase brand tag + // "customerId" becomes the exported type CustomerId. + expect($types) + ->toContain('export type CustomerId = number & Brand<"customerId">') + ->toContain('export type Email = string & Brand<"email">') + // Slug carries no #[Brand], so it must not produce an exported alias. + ->not->toContain('Slug'); +}); + +test('branded value objects nested in lists and unions are still collected', function () { + $types = emitTypesFor( + 'array{ids: list<\\' . UserId::class . '>}', + 'array{email: ?\\' . Email::class . '}', + ); + + expect($types) + ->toContain('export type CustomerId = number & Brand<"customerId">') + ->toContain('export type Email = string & Brand<"email">'); +}); + +test('the existing BrandedString utility type still emits alongside value objects', function () { + $types = emitTypesFor( + 'array{token: BrandedString<\'token\'>}', + 'array{email: \\' . Email::class . '}', + ); + + expect($types) + ->toContain('export type Token = string & Brand<"token">') + ->toContain('export type Email = string & Brand<"email">'); +}); diff --git a/tests/Unit/Executor/SchemaExecutorTest.php b/tests/Unit/Executor/SchemaExecutorTest.php index 7a6df1d..c9eab63 100644 --- a/tests/Unit/Executor/SchemaExecutorTest.php +++ b/tests/Unit/Executor/SchemaExecutorTest.php @@ -3,8 +3,18 @@ namespace Tests\Unit\Executor; use DateTimeImmutable; +use InvalidArgumentException; +use Le0daniel\PhpTsBindings\Executor\Data\Issue; +use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; use Le0daniel\PhpTsBindings\Executor\Data\Success; +use LogicException; use Stringable; +use ValueError; +use Tests\Mocks\ValueObjects\CreateAccountInput; +use Tests\Mocks\ValueObjects\Email; +use Tests\Mocks\ValueObjects\ExplodingValueObject; +use Tests\Mocks\ValueObjects\StatusEnum; +use Tests\Mocks\ValueObjects\UserId; use Tests\Unit\Executor\Mocks\UserSchema; test('parse success', function (string $type, mixed $value, mixed $expected) { @@ -82,7 +92,13 @@ 'Omit', ['id' => 1, "name" => "my name"], ["name" => "my name"], - ] + ], + + // Value objects + [Email::class, 'ada@example.test', Email::fromStringValue('ada@example.test')], + [UserId::class, 42, UserId::fromIntValue(42)], + ['?\\' . Email::class, null, null], + [StatusEnum::class, 'active', StatusEnum::ACTIVE], ]); test('serialize success', function (string $type, mixed $value, mixed $expected) { @@ -178,7 +194,20 @@ public function __toString(): string 'Pick< \\' . UserSchema::class . ', "age">', new UserSchema(12, 'email', 'username'), (object)['age' => 12], - ] + ], + + // Value objects + [Email::class, Email::fromStringValue('ada@example.test'), 'ada@example.test'], + [UserId::class, UserId::fromIntValue(42), 42], + ['?\\' . Email::class, null, null], + // Serializes by backing value, NOT by the enum case name + [StatusEnum::class, StatusEnum::INACTIVE, 'inactive'], + ['\\' . Email::class . '[]', [Email::fromStringValue('a@b.test')], ['a@b.test']], + [ + 'array{id: \\' . UserId::class . ', email: \\' . Email::class . '}', + ['id' => UserId::fromIntValue(7), 'email' => Email::fromStringValue('ada@example.test')], + (object)['id' => 7, 'email' => 'ada@example.test'], + ], ]); @@ -210,3 +239,138 @@ public function __toString(): string ]); }); +/** + * --------------------------------------------------------------------------- + * Value objects + * --------------------------------------------------------------------------- + */ + +test('value object rejects the wrong primitive type', function () { + expect(executeParse(UserId::class, '42'))->toBeFailure('validation.invalid_type'); + expect(executeParse(Email::class, 123))->toBeFailure('validation.invalid_type'); + expect(executeParse(Email::class, ['a' => 'b']))->toBeFailure('validation.invalid_type'); + expect(executeParse(Email::class, null))->toBeFailure('validation.invalid_type'); +}); + +test('value object reports a throwing factory as a validation issue, not an internal error', function () { + expect(executeParse(Email::class, 'not-an-email'))->toBeFailure('validation.invalid_type'); + expect(executeParse(UserId::class, 0))->toBeFailure('validation.invalid_type'); + expect(executeParse(UserId::class, -1))->toBeFailure('validation.invalid_type'); + + $result = executeParse(Email::class, 'not-an-email'); + $messages = array_map(fn(Issue $issue) => $issue->messageOrLocalizationKey, $result->issues->allFlat()); + expect($messages)->not->toContain('internal_error'); +}); + +test('the original exception is attached to the issue for debugging', function () { + $result = executeParse(Email::class, 'not-an-email'); + $issue = $result->issues->allFlat()[0]; + + expect($issue->messageOrLocalizationKey)->toBe('validation.invalid_type') + ->and($issue->exception)->toBeInstanceOf(InvalidArgumentException::class) + ->and($issue->exception->getMessage())->toBe('Invalid email: not-an-email'); +}); + +test('an Error thrown by the factory is caught, not just an Exception', function () { + // StatusEnum::fromStringValue() delegates to self::from(), which throws \ValueError. + // \ValueError extends Error, NOT Exception, so catching Exception would let it escape. + $result = executeParse(StatusEnum::class, 'not-a-case'); + + expect($result)->toBeFailure('validation.invalid_type') + ->and($result->issues->allFlat()[0]->exception)->toBeInstanceOf(ValueError::class); +}); + +test('a throwing accessor on the serialize path is an internal error, not a validation issue', function () { + $result = executeSerialize(ExplodingValueObject::class, ExplodingValueObject::fromStringValue('x')); + + expect($result)->toBeFailure('internal_error') + ->and($result->issues->allFlat()[0]->exception)->toBeInstanceOf(LogicException::class); +}); + +test('a throwing accessor never escapes the executor', function () { + expect(fn() => executeSerialize( + 'array{a: \\' . ExplodingValueObject::class . '}', + ['a' => ExplodingValueObject::fromStringValue('x')], + ))->not->toThrow(LogicException::class); +}); + +test('a throwing accessor degrades to null at a nullable boundary', function () { + /** @var Success $result */ + $result = executeSerialize( + 'array{a: ?\\' . ExplodingValueObject::class . '}', + ['a' => ExplodingValueObject::fromStringValue('x')], + ); + + expect($result)->toBeSuccess() + ->and($result->value)->toEqual((object)['a' => null]) + ->and($result->isPartial())->toBeTrue(); +}); + +test('value objects nested in structs and lists hydrate correctly', function () { + // Not in the 'parse success' dataset: that helper compares with toBe(), which is identity + // based for objects nested inside an array. + $struct = executeParse( + 'array{id: \\' . UserId::class . ', email: \\' . Email::class . '}', + ['id' => 1, 'email' => 'ada@example.test'], + ); + + expect($struct)->toBeSuccess() + ->and($struct->value)->toEqual([ + 'email' => Email::fromStringValue('ada@example.test'), + 'id' => UserId::fromIntValue(1), + ]); + + $list = executeParse('\\' . Email::class . '[]', ['a@b.test', 'c@d.test']); + + expect($list)->toBeSuccess() + ->and($list->value)->toEqual([ + Email::fromStringValue('a@b.test'), + Email::fromStringValue('c@d.test'), + ]); +}); + +test('value object issues are reported at the right field path', function () { + $result = executeParse('array{email: \\' . Email::class . '}', ['email' => 'nope']); + + expect($result)->toBeFailureAt('email', 'validation.invalid_type'); +}); + +test('value object coerces primitives when coercion is enabled', function () { + $result = executeParse(UserId::class, '42', new ParsingOptions(coercePrimitives: true)); + expect($result)->toBeSuccess() + ->and($result->value)->toEqual(UserId::fromIntValue(42)); + + $result = executeParse(Email::class, 'ada@example.test', new ParsingOptions(coercePrimitives: true)); + expect($result)->toBeSuccess() + ->and($result->value)->toEqual(Email::fromStringValue('ada@example.test')); +}); + +test('serializing something that is not the value object fails', function () { + expect(executeSerialize(Email::class, 'ada@example.test'))->toBeFailure('validation.invalid_type'); + expect(executeSerialize(Email::class, UserId::fromIntValue(1)))->toBeFailure('validation.invalid_type'); + expect(executeSerialize(UserId::class, null))->toBeFailure('validation.invalid_type'); + expect(executeSerialize(UserId::class, 42))->toBeFailure('validation.invalid_type'); +}); + +test('nullable value objects tolerate null at the union boundary', function () { + expect(executeSerialize('?\\' . Email::class, null))->toBeSuccess(); + expect(executeParse('?\\' . Email::class, null))->toBeSuccess(); +}); + +test('a castable class hydrates and serializes its value object properties', function () { + $parsed = executeParse(CreateAccountInput::class, [ + 'email' => 'ada@example.test', + 'ownerId' => 7, + ]); + + expect($parsed)->toBeSuccess() + ->and($parsed->value)->toBeInstanceOf(CreateAccountInput::class) + ->and($parsed->value->email)->toEqual(Email::fromStringValue('ada@example.test')) + ->and($parsed->value->ownerId)->toEqual(UserId::fromIntValue(7)); + + $serialized = executeSerialize(CreateAccountInput::class, $parsed->value); + + expect($serialized)->toBeSuccess() + ->and($serialized->value)->toEqual((object)['email' => 'ada@example.test', 'ownerId' => 7]); +}); + diff --git a/tests/Unit/Parser/ValueObjectConsumerTest.php b/tests/Unit/Parser/ValueObjectConsumerTest.php new file mode 100644 index 0000000..b94f449 --- /dev/null +++ b/tests/Unit/Parser/ValueObjectConsumerTest.php @@ -0,0 +1,161 @@ +parse(Email::class); + + expect($node)->toBeInstanceOf(ValueObjectNode::class) + ->and($node->className)->toBe(Email::class) + ->and($node->backingType)->toBe(BuiltInType::STRING) + ->and($node->brand)->toBe('email') + ->and($node->inputDefinition())->toBe('string') + ->and($node->outputDefinition())->toBe('string'); + + compareToOptimizedAst($node); + validateAst($node); +}); + +test('parses an int value object with an explicit brand name', function () { + $node = new TypeParser()->parse(UserId::class); + + expect($node)->toBeInstanceOf(ValueObjectNode::class) + ->and($node->className)->toBe(UserId::class) + ->and($node->backingType)->toBe(BuiltInType::INT) + ->and($node->brand)->toBe('customerId') + ->and($node->inputDefinition())->toBe('number') + ->and($node->outputDefinition())->toBe('number'); + + compareToOptimizedAst($node); + validateAst($node); +}); + +test('the default brand name is lcfirst of the base class name', function (string $type, ?string $expected) { + $node = new TypeParser()->parse($type); + expect($node->brand)->toBe($expected); +})->with([ + 'bare #[Brand] on Email' => [Email::class, 'email'], + 'explicit #[Brand(customerId)]' => [UserId::class, 'customerId'], + 'no attribute' => [Slug::class, null], +]); + +test('a value object without the Brand attribute is not branded', function () { + $node = new TypeParser()->parse(Slug::class); + + expect($node)->toBeInstanceOf(ValueObjectNode::class) + ->and($node->brand)->toBeNull(); + + compareToOptimizedAst($node); +}); + +test('a value object may also implement Stringable without colliding', function () { + // Slug declares its own toString() and __toString(); the interface uses toStringValue(). + $node = new TypeParser()->parse(Slug::class); + expect($node)->toBeInstanceOf(ValueObjectNode::class); + + $result = executeSerialize($node, Slug::fromStringValue('my-slug')); + expect($result)->toBeSuccess() + ->and($result->value)->toBe('my-slug'); +}); + +test('resolves value objects through the namespace of the parsing context', function () { + $node = new TypeParser()->parse('Email', new ParsingContext('Tests\\Mocks\\ValueObjects')); + + expect($node)->toBeInstanceOf(ValueObjectNode::class) + ->and($node->className)->toBe(Email::class); +}); + +test('resolves value objects through a use-statement alias', function () { + $node = new TypeParser()->parse('Mail', new ParsingContext('Some\\Space', ['Mail' => Email::class])); + + expect($node)->toBeInstanceOf(ValueObjectNode::class) + ->and($node->className)->toBe(Email::class); +}); + +test('value objects compose with the rest of the grammar', function (string $type, string $expected) { + $node = new TypeParser()->parse($type); + + expect($node)->toBeInstanceOf($expected); + + compareToOptimizedAst($node); + validateAst($node); +})->with([ + 'nullable' => ['?\\' . Email::class, UnionNode::class], + 'array shorthand' => ['\\' . Email::class . '[]', ListNode::class], + 'list generic' => ['list<\\' . Email::class . '>', ListNode::class], + 'union' => ['\\' . Email::class . '|null', UnionNode::class], + 'record' => ['array', RecordNode::class], + 'struct' => ['array{id: \\' . UserId::class . ', email: \\' . Email::class . '}', StructNode::class], + 'object struct' => ['object{id: \\' . UserId::class . '}', StructNode::class], +]); + +test('rejects a class implementing both value object interfaces', function () { + expect(fn() => new TypeParser()->parse(AmbiguousValueObject::class)) + ->toThrow('must implement either StringValueObject or IntValueObject, not both'); +}); + +test('rejects an abstract value object', function () { + expect(fn() => new TypeParser()->parse(AbstractValueObject::class)) + ->toThrow('must be instantiable'); +}); + +test('the value object interface wins over the enum consumer', function () { + $node = new TypeParser()->parse(StatusEnum::class); + + expect($node)->toBeInstanceOf(ValueObjectNode::class) + ->and($node->backingType)->toBe(BuiltInType::STRING); + + compareToOptimizedAst($node); +}); + +test('a value object is never treated as a castable object', function () { + $parser = new TypeParser(TypeParser::defaultConsumers(allowAllObjectCasting: true)); + + expect($parser->parse(Slug::class))->toBeInstanceOf(ValueObjectNode::class); +}); + +test('value objects emit their backing primitive when brands are disabled', function () { + $node = new TypeParser()->parse(Email::class); + + expect(typescriptDefinition($node, DefinitionTarget::OUTPUT))->toBe('string'); + expect(typescriptDefinition($node, DefinitionTarget::INPUT))->toBe('string'); +}); + +test('value objects emit branded types when brands are enabled', function (string $type, string $expected) { + $node = new TypeParser()->parse($type); + $generator = new TypescriptDefinitionGenerator(true); + + expect($generator->toDefinition($node, DefinitionTarget::INPUT))->toBe($expected); + expect($generator->toDefinition($node, DefinitionTarget::OUTPUT))->toBe($expected); +})->with([ + 'string vo' => [Email::class, 'string & Brand<"email">'], + 'int vo renamed' => [UserId::class, 'number & Brand<"customerId">'], + 'unbranded vo' => [Slug::class, 'string'], +]); + +test('a castable class carrying value object properties', function () { + $node = new TypeParser()->parse(CreateAccountInput::class); + + expect($node)->toBeInstanceOf(CustomCastingNode::class); + expect(typescriptDefinition($node, DefinitionTarget::INPUT))->toBe('{email:string;ownerId:number;}'); + expect(typescriptDefinition($node, DefinitionTarget::OUTPUT))->toBe('{email:string;ownerId:number;}'); + + compareToOptimizedAst($node); +}); From fc868c4674f03369612067bbf983824339b2cdb1 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 09:18:36 +0200 Subject: [PATCH 004/101] Add `DateTimeString` type support with parsing, serialization, validation, and TypeScript code generation, including related tests and documentation updates. --- README.md | 60 +++++++++++++++ src/PHPStan/UtilitiesNodeResolver.php | 31 +++++++- src/Parser/Consumers/UtilsConsumer.php | 46 ++++++++--- src/Parser/Nodes/Leaf/DateTimeNode.php | 21 ++++- tests/Unit/Executor/SchemaExecutorTest.php | 90 ++++++++++++++++++++++ tests/Unit/Parser/TypeParserTest.php | 85 ++++++++++++++++++++ tests/Unit/PhpStan/data/types.php | 42 ++++++++++ 7 files changed, 361 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7ebbffb..4d4d9e8 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,66 @@ $parsed = $executor->parse($node, ['key' => 'value']); $serialized = $executor->serialize($node, "my string"); ``` +## Utility types + +A handful of type names are understood in docblocks even though no such PHP class exists. They are +resolved by the bundled PHPStan extension too, so static analysis agrees with the generated types. + +| Type | PHP / PHPStan | TypeScript | +| --- | --- | --- | +| `Pick` | struct with only those properties | `{a: …; b: …;}` | +| `Omit` | struct without those properties | `{…}` | +| `BrandedString<'name'>` | `string` | `string & Brand<"name">` | +| `BrandedInt<'name'>` | `int` | `number & Brand<"name">` | +| `DateTimeString<'format'>` | `DateTimeImmutable` | `string` | + +### DateTimeString + +`DateTimeString` is a date that travels as a string and arrives as a `DateTimeImmutable`. The +optional generic is the [PHP date format](https://www.php.net/manual/en/datetime.format.php); it +defaults to `DateTimeInterface::ATOM`. + +```php +/** + * @param DateTimeString $createdAt // 2025-09-10T12:09:01+00:00 + * @param DateTimeString<'Y-m-d'> $birthday // 2025-01-01 + */ +public function __construct( + public DateTimeImmutable $createdAt, + public DateTimeImmutable $birthday, +) {} +``` + +Both are `string` in TypeScript. On input the string is parsed with the format, on output the +`DateTimeInterface` is formatted back with it. + +**Prefer single quotes for the format.** Date formats escape literal characters with a backslash, +and the parser applies PHP's own string semantics: single quotes leave `\T` alone, while double +quotes resolve the full escape set. `"H:i\t"` is a tab, `'H:i\t'` is an escaped `t`. + +**Parsing is strict.** The value has to match the format exactly — the parsed date is formatted +again and compared to the input. Fields the format does not cover are zeroed rather than taken +from the current clock, so `DateTimeString<'Y-m-d'>` gives you midnight, not "today at 14:32". + +```php +DateTimeString<'Y-m-d'> + '2025-01-01' // 2025-01-01 00:00:00 + '2025-1-1' // rejected, single digit month and day + '2025-02-30' // rejected, would silently roll over to March 2nd + '2025-01-01T10:00:00' // rejected, trailing data +``` + +This also applies to `DateTimeImmutable`, `DateTime` and any other `DateTimeInterface` written +directly as a type. + +One consequence worth knowing: ATOM renders UTC as `+00:00`, so the `Z` suffix that +`Date.toISOString()` produces is *not* accepted by the default. Use the lowercase `p` specifier, +which renders UTC as `Z`: + +```php +/** @param DateTimeString<'Y-m-d\TH:i:sp'> $when */ // accepts 2025-09-10T12:09:01Z +``` + ## Value Objects Wrapping an id or an email in its own class usually costs you the type on the wire: a plain class is diff --git a/src/PHPStan/UtilitiesNodeResolver.php b/src/PHPStan/UtilitiesNodeResolver.php index bd4abb4..7105b0b 100644 --- a/src/PHPStan/UtilitiesNodeResolver.php +++ b/src/PHPStan/UtilitiesNodeResolver.php @@ -3,7 +3,9 @@ namespace Le0daniel\PhpTsBindings\PHPStan; +use DateTimeImmutable; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Analyser\NameScope; use PHPStan\PhpDoc\TypeNodeResolver; @@ -11,7 +13,6 @@ use PHPStan\PhpDoc\TypeNodeResolverExtension; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectShape; use PHPStan\Type\ObjectShapeType; @@ -37,6 +38,14 @@ public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type { + // DateTimeString is the one utility type usable without generics, so it is the only + // one that has to be caught before the GenericTypeNode guard. + if ($typeNode instanceof IdentifierTypeNode) { + return $typeNode->name === 'DateTimeString' + ? new ObjectType(DateTimeImmutable::class) + : null; + } + if (!$typeNode instanceof GenericTypeNode) { // returning null means this extension is not interested in this node return null; @@ -44,12 +53,32 @@ public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type $typeName = $typeNode->type; return match ($typeName->name) { + 'DateTimeString' => $this->resolveDateTimeString($typeNode, $nameScope), 'BrandedString', 'BrandedInt' => $this->resolveBrandedTypes($typeName->name, $typeNode, $nameScope), 'Pick', 'Omit' => $this->resolvePickAndOmitUtil($typeName->name, $typeNode, $nameScope), default => null, }; } + /** + * The generic argument is the date format. It carries no type information beyond having to + * be a single constant string, so only its shape is validated here. + */ + private function resolveDateTimeString(GenericTypeNode $typeNode, NameScope $nameScope): ?Type + { + $arguments = $typeNode->genericTypes; + if (count($arguments) !== 1) { + return null; + } + + $formatType = $this->typeNodeResolver->resolve($arguments[0], $nameScope); + if (count($formatType->getConstantStrings()) !== 1) { + return null; + } + + return new ObjectType(DateTimeImmutable::class); + } + private function resolveBrandedTypes(string $typeName, GenericTypeNode $typeNode, NameScope $nameScope): ?Type { $arguments = $typeNode->genericTypes; diff --git a/src/Parser/Consumers/UtilsConsumer.php b/src/Parser/Consumers/UtilsConsumer.php index 413e100..a75a8ad 100644 --- a/src/Parser/Consumers/UtilsConsumer.php +++ b/src/Parser/Consumers/UtilsConsumer.php @@ -2,6 +2,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; +use DateTimeImmutable; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; @@ -13,6 +14,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Data\PropertyType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\DateTimeNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; @@ -26,7 +28,7 @@ final class UtilsConsumer implements TypeConsumer public function canConsume(ParserState $state): bool { return $state->currentTokenIs(TokenType::IDENTIFIER) - && in_array($state->current()->value, ['Pick', 'Omit', 'BrandedString', 'BrandedInt'], true); + && in_array($state->current()->value, ['Pick', 'Omit', 'BrandedString', 'BrandedInt', 'DateTimeString'], true); } public function consume(ParserState $state, TypeParser $parser): NodeInterface @@ -34,23 +36,30 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $type = $state->current()->value; $state->advance(); - if ($type === 'BrandedString' || $type === 'BrandedInt') { - [$literalNode] = $this->consumeGenerics($state, $parser, 1, 1); - if (!$literalNode instanceof LiteralNode || $literalNode->type !== LiteralType::STRING) { - $state->produceSyntaxError("Expected literal string value for branded type, got: " . $literalNode::class); + if ($type === 'DateTimeString') { + // The format is optional: passing no minimum lets consumeGenerics return an empty + // array when there is no generic block at all. + $generics = $this->consumeGenerics($state, $parser, null, 1); + if ($generics === []) { + return new DateTimeNode(DateTimeImmutable::class); } - $literalValue = $literalNode->value; - if (!is_string($literalValue)) { - $state->produceSyntaxError("Expected literal string value for branded type, got: " . gettype($literalValue)); - } + [$formatNode] = $generics; + return new DateTimeNode( + DateTimeImmutable::class, + $this->literalStringValue($state, $formatNode, 'date format'), + ); + } + + if ($type === 'BrandedString' || $type === 'BrandedInt') { + [$literalNode] = $this->consumeGenerics($state, $parser, 1, 1); return new BuiltInNode( match ($type) { 'BrandedString' => BuiltInType::STRING, 'BrandedInt' => BuiltInType::INT, }, - brand: $literalValue + brand: $this->literalStringValue($state, $literalNode, 'branded type'), ); } @@ -78,6 +87,23 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface } + /** + * @param string $usage Named in the error message so it points at the utility type that failed. + * @throws InvalidSyntaxException + */ + private function literalStringValue(ParserState $state, NodeInterface $node, string $usage): string + { + if (!$node instanceof LiteralNode || $node->type !== LiteralType::STRING) { + $state->produceSyntaxError("Expected literal string value for {$usage}, got: " . $node::class); + } + + if (!is_string($node->value)) { + $state->produceSyntaxError("Expected literal string value for {$usage}, got: " . gettype($node->value)); + } + + return $node->value; + } + /** * @param ParserState $state * @param NodeInterface $node diff --git a/src/Parser/Nodes/Leaf/DateTimeNode.php b/src/Parser/Nodes/Leaf/DateTimeNode.php index 89885c4..951675b 100644 --- a/src/Parser/Nodes/Leaf/DateTimeNode.php +++ b/src/Parser/Nodes/Leaf/DateTimeNode.php @@ -53,11 +53,26 @@ public function parseValue(mixed $value, ExecutionContext $context): DateTimeInt return Value::INVALID; } + // The trailing `|` resets every field the format did not parse to a zero-like value. + // Without it, `Y-m-d` inherits the current clock time and the result is not deterministic. + $parsed = DateTimeImmutable::createFromFormat("{$this->format}|", $value); + + // createFromFormat() is lenient: it accepts `2025-1-1` for `Y-m-d` without so much as a + // warning, and rolls `2025-02-30` over into March. Re-formatting the result and comparing + // it to the input is the only check that holds the value to the format exactly. + if ($parsed === false || $parsed->format($this->format) !== $value) { + $context->addIssue(new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => "Expected a date string of format '{$this->format}', got: {$value}", + ] + )); + return Value::INVALID; + } + try { // @phpstan-ignore-next-line - return $this->dateTimeClass::createFromInterface( - DateTimeImmutable::createFromFormat($this->format, $value) - ); + return $this->dateTimeClass::createFromInterface($parsed); } catch (Throwable $exception) { $context->addIssue(Issue::fromThrowable($exception)); return Value::INVALID; diff --git a/tests/Unit/Executor/SchemaExecutorTest.php b/tests/Unit/Executor/SchemaExecutorTest.php index c9eab63..828e0d3 100644 --- a/tests/Unit/Executor/SchemaExecutorTest.php +++ b/tests/Unit/Executor/SchemaExecutorTest.php @@ -329,6 +329,96 @@ public function __toString(): string ]); }); +/** + * --------------------------------------------------------------------------- + * DateTimeString + * --------------------------------------------------------------------------- + */ + +test('DateTimeString parses a string into a DateTimeImmutable', function (string $type, string $value, string $expected) { + $result = executeParse($type, $value); + + expect($result)->toBeSuccess() + ->and($result->value)->toBeInstanceOf(DateTimeImmutable::class) + ->and($result->value->format('Y-m-d H:i:s.u P'))->toBe($expected); +})->with([ + 'default ATOM format' => ['DateTimeString', '2025-09-10T12:09:01+00:00', '2025-09-10 12:09:01.000000 +00:00'], + + // Fields the format does not parse are zeroed out rather than inherited from the + // current clock, so the result is deterministic. + 'date only' => ["DateTimeString<'Y-m-d'>", '2025-01-01', '2025-01-01 00:00:00.000000 +00:00'], + 'time only' => ["DateTimeString<'H:i'>", '08:30', '1970-01-01 08:30:00.000000 +00:00'], + 'custom format' => ["DateTimeString<'d.m.Y H:i'>", '01.02.2025 08:30', '2025-02-01 08:30:00.000000 +00:00'], + + // Lowercase p renders UTC as Z, which is the shape Date.toISOString() produces. + 'lowercase p accepts Z' => ["DateTimeString<'Y-m-d\\TH:i:sp'>", '2025-09-10T12:09:01Z', '2025-09-10 12:09:01.000000 +00:00'], + 'lowercase p accepts an offset' => ["DateTimeString<'Y-m-d\\TH:i:sp'>", '2025-09-10T12:09:01+02:00', '2025-09-10 12:09:01.000000 +02:00'], +]); + +test('DateTimeString rejects input that does not match the format exactly', function (string $type, mixed $value) { + expect(executeParse($type, $value))->toBeFailure('validation.invalid_type'); +})->with([ + // createFromFormat() silently accepts these, so only the re-format round trip catches them. + 'single digit month and day' => ["DateTimeString<'Y-m-d'>", '2025-1-1'], + 'day out of range' => ["DateTimeString<'Y-m-d'>", '2025-02-30'], + 'month and day out of range' => ["DateTimeString<'Y-m-d'>", '2025-13-45'], + + 'trailing data' => ["DateTimeString<'Y-m-d'>", '2025-01-01T10:00:00'], + 'not a date' => ["DateTimeString<'Y-m-d'>", 'not-a-date'], + 'empty string' => ["DateTimeString<'Y-m-d'>", ''], + 'whitespace' => ["DateTimeString<'Y-m-d'>", ' 2025-01-01'], + 'wrong format' => ["DateTimeString<'Y-m-d'>", '01.02.2025'], + + 'int' => ["DateTimeString<'Y-m-d'>", 123], + 'null' => ["DateTimeString<'Y-m-d'>", null], + 'array' => ["DateTimeString<'Y-m-d'>", []], + 'bool' => ["DateTimeString<'Y-m-d'>", true], + 'an already hydrated date' => ["DateTimeString<'Y-m-d'>", new DateTimeImmutable('2025-01-01')], +]); + +test('the ATOM default does not accept a Z suffix', function (string $type, string $value) { + // ATOM's P specifier renders UTC as +00:00, so a Z suffix no longer round trips. + // Clients sending Date.toISOString() output need DateTimeString<'Y-m-d\TH:i:sp'>. + expect(executeParse($type, $value))->toBeFailure('validation.invalid_type'); +})->with([ + 'utility type' => ['DateTimeString', '2025-09-10T12:09:01Z'], + 'class name' => ['\DateTimeImmutable', '2025-09-10T12:09:01Z'], + 'with milliseconds' => ['DateTimeString', '2025-09-10T12:09:01.000Z'], +]); + +test('DateTimeString serializes a date back to its format', function (string $type, mixed $value, string $expected) { + $result = executeSerialize($type, $value); + + expect($result)->toBeSuccess()->and($result->value)->toBe($expected); +})->with([ + 'immutable' => ["DateTimeString<'Y-m-d'>", new DateTimeImmutable('2025-01-01 10:11:12'), '2025-01-01'], + 'mutable' => ["DateTimeString<'Y-m-d'>", new \DateTime('2025-01-01 10:11:12'), '2025-01-01'], + 'default ATOM format' => ['DateTimeString', new DateTimeImmutable('2025-09-10 12:09:01'), '2025-09-10T12:09:01+00:00'], + 'custom format' => ["DateTimeString<'d.m.Y H:i'>", new DateTimeImmutable('2025-02-01 08:30:00'), '01.02.2025 08:30'], +]); + +test('DateTimeString rejects a non date on serialization', function (mixed $value) { + expect(executeSerialize("DateTimeString<'Y-m-d'>", $value))->toBeFailure('validation.invalid_type'); +})->with([ + 'a formatted string' => ['2025-01-01'], + 'an int' => [123], + 'null' => [null], + 'an array' => [[]], +]); + +test('DateTimeString round trips through parse and serialize', function (string $type, string $value) { + $parsed = executeParse($type, $value); + expect($parsed)->toBeSuccess(); + + expect(executeSerialize($type, $parsed->value))->toBeSuccess() + ->and(executeSerialize($type, $parsed->value)->value)->toBe($value); +})->with([ + ['DateTimeString', '2025-09-10T12:09:01+00:00'], + ["DateTimeString<'Y-m-d'>", '2025-01-01'], + ["DateTimeString<'d.m.Y H:i'>", '01.02.2025 08:30'], + ["DateTimeString<'Y-m-d\\TH:i:sp'>", '2025-09-10T12:09:01Z'], +]); + test('value object issues are reported at the right field path', function () { $result = executeParse('array{email: \\' . Email::class . '}', ['email' => 'nope']); diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 5172c5f..521ae13 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -775,6 +775,91 @@ expect($inputDef)->toBe('string'); }); +test('DateTimeString without a format defaults to ATOM', function () { + $parser = new TypeParser(); + $node = $parser->parse('DateTimeString'); + + expect($node)->toBeInstanceOf(DateTimeNode::class) + ->and($node->dateTimeClass)->toBe(\DateTimeImmutable::class) + ->and($node->format)->toBe(\DateTimeInterface::ATOM); + + compareToOptimizedAst($node); +}); + +test('DateTimeString without a format is indistinguishable from DateTimeImmutable', function () { + // Both produce the same node, so they share a hash and dedupe into one registry entry. + $parser = new TypeParser(); + + expect((string)$parser->parse('DateTimeString')) + ->toBe((string)$parser->parse('\DateTimeImmutable')); +}); + +test('DateTimeString takes the format from its single generic', function (string $type, string $expectedFormat) { + $parser = new TypeParser(); + $node = $parser->parse($type); + + expect($node)->toBeInstanceOf(DateTimeNode::class) + ->and($node->dateTimeClass)->toBe(\DateTimeImmutable::class) + ->and($node->format)->toBe($expectedFormat); + + compareToOptimizedAst($node); +})->with([ + 'single quoted' => ["DateTimeString<'Y-m-d'>", 'Y-m-d'], + 'double quoted' => ['DateTimeString<"Y-m-d">', 'Y-m-d'], + 'spaces in the format' => ["DateTimeString<'d.m.Y H:i'>", 'd.m.Y H:i'], + 'padded generic' => ["DateTimeString< 'Y-m-d' >", 'Y-m-d'], + + // Date formats escape literal characters with a backslash. Single quotes only resolve + // \\ and \', so the escape survives untouched. + 'single quoted escape' => ["DateTimeString<'Y-m-d\\TH:i:sP'>", 'Y-m-d\TH:i:sP'], + + // Double quotes resolve the full PHP escape set, but only the lowercase ones, so an + // uppercase \T is still safe. + 'double quoted uppercase escape' => ['DateTimeString<"Y-m-d\TH:i:sP">', 'Y-m-d\TH:i:sP'], +]); + +test('a double quoted format resolves lowercase escape sequences', function () { + // Documented gotcha: "\t" is a TAB, not an escaped `t` day-count specifier. Single + // quotes are the safe choice for date formats. + $parser = new TypeParser(); + + expect($parser->parse('DateTimeString<"H:i\t">')->format)->toBe("H:i\t") + ->and($parser->parse("DateTimeString<'H:i\\t'>")->format)->toBe('H:i\t'); +}); + +test('DateTimeString is emitted as a string in Typescript', function () { + $parser = new TypeParser(); + $node = $parser->parse("DateTimeString<'Y-m-d'>"); + + expect(typescriptDefinition($node, DefinitionTarget::INPUT))->toBe('string') + ->and(typescriptDefinition($node, DefinitionTarget::OUTPUT))->toBe('string'); +}); + +test('DateTimeString composes with other types', function (string $type, string $expectedDefinition) { + $parser = new TypeParser(); + $node = $parser->parse($type); + + compareToOptimizedAst($node); + expect(typescriptDefinition($node, DefinitionTarget::OUTPUT))->toBe($expectedDefinition); +})->with([ + 'nullable' => ["DateTimeString<'Y-m-d'>|null", 'string|null'], + 'questionmark nullable' => ["?DateTimeString<'Y-m-d'>", 'null|string'], + 'in a struct' => ["array{createdAt: DateTimeString<'Y-m-d'>}", '{createdAt:string;}'], + 'in a list' => ['list', 'Array'], + 'bracket list' => ["DateTimeString<'Y-m-d'>[]", 'Array'], +]); + +test('DateTimeString rejects invalid generics', function (string $type) { + expect(fn() => new TypeParser()->parse($type))->toThrow(InvalidSyntaxException::class); +})->with([ + 'empty generics' => ['DateTimeString<>'], + 'two generics' => ["DateTimeString<'Y-m-d','H:i'>"], + 'unterminated generics' => ["DateTimeString<'Y-m-d'"], + 'a type instead of a literal' => ['DateTimeString'], + 'an int literal' => ['DateTimeString<123>'], + 'a union of literals' => ["DateTimeString<'Y-m-d'|'H:i'>"], +]); + /** * --------------------------------------------------------------------------- * Lexer migration: new functionality diff --git a/tests/Unit/PhpStan/data/types.php b/tests/Unit/PhpStan/data/types.php index 2432b89..09a8eb4 100644 --- a/tests/Unit/PhpStan/data/types.php +++ b/tests/Unit/PhpStan/data/types.php @@ -88,4 +88,46 @@ function brandedInt(int $i): int { function brandedString(string $i): string { assertType("string", $i); return $i; +} + +/** + * @param DateTimeString $d + */ +function dateTimeStringDefault(object $d): void { + assertType("DateTimeImmutable", $d); +} + +/** + * @param DateTimeString<'Y-m-d'> $d + */ +function dateTimeStringWithFormat(object $d): void { + assertType("DateTimeImmutable", $d); +} + +/** + * @param DateTimeString<'Y-m-d\TH:i:sP'> $d + */ +function dateTimeStringWithEscapedFormat(object $d): void { + assertType("DateTimeImmutable", $d); +} + +/** + * @param DateTimeString|null $d + */ +function dateTimeStringNullable(?object $d): void { + assertType("DateTimeImmutable|null", $d); +} + +/** + * @param list> $d + */ +function dateTimeStringList(array $d): void { + assertType("list", $d); +} + +/** + * @param array{createdAt: DateTimeString<'Y-m-d'>} $d + */ +function dateTimeStringInStruct(array $d): void { + assertType("array{createdAt: DateTimeImmutable}", $d); } \ No newline at end of file From 90c0beb34ca283499464108a7241a69747244d1b Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 09:35:44 +0200 Subject: [PATCH 005/101] Refactor utilities by introducing `Dicts` and `Lists` classes, replace `Arrays::filterNullValues` with specialized methods, and update PHP version requirement to `^8.5`. --- composer.json | 2 +- phpstan.neon | 2 +- src/Adapters/Laravel/LaravelHttpController.php | 3 ++- src/CodeGen/CodeGenerators/EmitOperations.php | 4 +++- src/CodeGen/CodeGenerators/EmitQueryKey.php | 3 +++ src/CodeGen/CodeGenerators/EmitTanstackQuery.php | 3 +++ src/CodeGen/TypescriptServerCodeGenerator.php | 3 ++- src/Contracts/LeafNode.php | 2 ++ .../Consumers/UserDefinedObjectConsumer.php | 3 ++- src/Server/Client/OperationSPAClient.php | 3 ++- src/Server/Pipeline/ContextualPipeline.php | 8 ++++++++ src/Utils/Arrays.php | 15 --------------- src/Utils/Dicts.php | 16 ++++++++++++++++ src/Utils/Lists.php | 16 ++++++++++++++++ 14 files changed, 61 insertions(+), 22 deletions(-) create mode 100644 src/Utils/Dicts.php create mode 100644 src/Utils/Lists.php diff --git a/composer.json b/composer.json index e4d7831..26dae57 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Library to create type bindings between PHP8 and TS, supporting parsing, serialization and emitting of TS types for PHP objects/input strongly typed", "type": "library", "require": { - "php": "^8.4" + "php": "^8.5" }, "require-dev": { "pestphp/pest": "4.x-dev", diff --git a/phpstan.neon b/phpstan.neon index 4ea24e5..4666d23 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,7 +7,7 @@ parameters: # ToDo: Enable this in the future reportPossiblyNonexistentGeneralArrayOffset: false - checkMissingCallableSignature: false + checkMissingCallableSignature: true checkBenevolentUnionTypes: true reportPossiblyNonexistentConstantArrayOffset: true diff --git a/src/Adapters/Laravel/LaravelHttpController.php b/src/Adapters/Laravel/LaravelHttpController.php index af13d1d..1611ed3 100644 --- a/src/Adapters/Laravel/LaravelHttpController.php +++ b/src/Adapters/Laravel/LaravelHttpController.php @@ -21,6 +21,7 @@ use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Utils\Arrays; +use Le0daniel\PhpTsBindings\Utils\Dicts; use Throwable; readonly class LaravelHttpController @@ -169,7 +170,7 @@ private function produceJsonResponse(RpcSuccess|RpcError $result, Client $client if ($this->debug) { $exception = $result->cause; - $content['__debug'] = Arrays::filterNullValues([ + $content['__debug'] = Dicts::filterNullValues([ 'class' => $exception::class, 'message' => $exception->getMessage(), 'code' => $exception->getCode(), diff --git a/src/CodeGen/CodeGenerators/EmitOperations.php b/src/CodeGen/CodeGenerators/EmitOperations.php index a29ad31..be23975 100644 --- a/src/CodeGen/CodeGenerators/EmitOperations.php +++ b/src/CodeGen/CodeGenerators/EmitOperations.php @@ -13,7 +13,9 @@ final class EmitOperations implements GeneratesOperationCode, DependsOn { - + /** + * @param (Closure(TypedOperation):string)|null $nameGenerator + */ public function __construct( private readonly ?Closure $nameGenerator = null, ) diff --git a/src/CodeGen/CodeGenerators/EmitQueryKey.php b/src/CodeGen/CodeGenerators/EmitQueryKey.php index 80da4fb..8f310ab 100644 --- a/src/CodeGen/CodeGenerators/EmitQueryKey.php +++ b/src/CodeGen/CodeGenerators/EmitQueryKey.php @@ -21,6 +21,9 @@ public function dependsOnGenerator(): array ]; } + /** + * @param (Closure(TypedOperation):string)|null $nameGenerator + */ public function __construct(private readonly ?Closure $nameGenerator = null) { } diff --git a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php index 1fbdd69..21b1466 100644 --- a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php +++ b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php @@ -21,6 +21,9 @@ public function dependsOnGenerator(): array ]; } + /** + * @param (Closure(TypedOperation):string)|null $nameGenerator + */ public function __construct(private ?Closure $nameGenerator = null) { } diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index e7cc333..6f4cd41 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -15,6 +15,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Utils\Arrays; +use Le0daniel\PhpTsBindings\Utils\Lists; use RuntimeException; final readonly class TypescriptServerCodeGenerator @@ -108,7 +109,7 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore private function generateAllErrorTypes(Server $server, Definition $operation): string { - $possibleTypes = Arrays::filterNullValues(array_map(function (ExceptionPresenter $presenter) use ($operation): null|string { + $possibleTypes = Lists::filterNullValues(array_map(function (ExceptionPresenter $presenter) use ($operation): null|string { $code = $presenter::errorType(); $details = $presenter->toTypeScriptDefinition($operation); return $details === null ? null : "{code: {$code->value}, details: {$details}}"; diff --git a/src/Contracts/LeafNode.php b/src/Contracts/LeafNode.php index 0b8830d..7cf7729 100644 --- a/src/Contracts/LeafNode.php +++ b/src/Contracts/LeafNode.php @@ -26,6 +26,8 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed; */ public function serializeValue(mixed $value, ExecutionContext $context): mixed; + /** @deprecated DO not use anymore */ public function inputDefinition(): string; + /** @deprecated DO not use anymore */ public function outputDefinition(): string; } \ No newline at end of file diff --git a/src/Parser/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Consumers/UserDefinedObjectConsumer.php index 9d58868..6cb63cd 100644 --- a/src/Parser/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Consumers/UserDefinedObjectConsumer.php @@ -22,6 +22,7 @@ use Le0daniel\PhpTsBindings\Reflection\AttributesReflector; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; use Le0daniel\PhpTsBindings\Utils\Arrays; +use Le0daniel\PhpTsBindings\Utils\Lists; use ReflectionAttribute; use ReflectionClass; use ReflectionException; @@ -195,7 +196,7 @@ private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, Ty private function applyConstraints(ReflectionProperty|ReflectionParameter $reflection, NodeInterface $node): NodeInterface { - $constraints = Arrays::filterNullValues( + $constraints = Lists::filterNullValues( array_map( static function (ReflectionAttribute $attribute): null|Constraint { $instance = $attribute->newInstance(); diff --git a/src/Server/Client/OperationSPAClient.php b/src/Server/Client/OperationSPAClient.php index e2318e5..27af16c 100644 --- a/src/Server/Client/OperationSPAClient.php +++ b/src/Server/Client/OperationSPAClient.php @@ -5,6 +5,7 @@ use JsonSerializable; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Utils\Arrays; +use Le0daniel\PhpTsBindings\Utils\Dicts; use Le0daniel\PhpTsBindings\Utils\Strings; use UnitEnum; @@ -62,7 +63,7 @@ public function invalidate(UnitEnum|string $namespace, ...$key): void */ public function jsonSerialize(): array|null { - $data = Arrays::filterNullValues([ + $data = Dicts::filterNullValues([ 'redirect' => $this->redirect, 'toasts' => $this->toasts, 'invalidations' => $this->invalidations, diff --git a/src/Server/Pipeline/ContextualPipeline.php b/src/Server/Pipeline/ContextualPipeline.php index 25dfe7c..9f1809d 100644 --- a/src/Server/Pipeline/ContextualPipeline.php +++ b/src/Server/Pipeline/ContextualPipeline.php @@ -7,7 +7,14 @@ final class ContextualPipeline { + /** + * @var (Closure(Throwable): mixed)|null + */ private Closure|null $catchErrorsWith = null; + + /** + * @var (Closure(mixed, mixed...): mixed)|null + */ private Closure|null $then = null; /** @@ -41,6 +48,7 @@ public function then(Closure $then): self /** * @param list $context + * @return Closure(mixed, object): mixed */ private function reducer(array $context): Closure { diff --git a/src/Utils/Arrays.php b/src/Utils/Arrays.php index 58f2559..7cefcd8 100644 --- a/src/Utils/Arrays.php +++ b/src/Utils/Arrays.php @@ -23,19 +23,4 @@ public static function mapWithKeys(array $array, Closure $callback): array } return $mapped; } - - /** - * @template TKey - * @template TValue - * @param array $array - * @return array - */ - public static function filterNullValues(array $array): array - { - $result = array_filter($array, fn($value) => $value !== null); - if (array_is_list($array)) { - return array_values($result); - } - return $result; - } } \ No newline at end of file diff --git a/src/Utils/Dicts.php b/src/Utils/Dicts.php new file mode 100644 index 0000000..da750cc --- /dev/null +++ b/src/Utils/Dicts.php @@ -0,0 +1,16 @@ + $dict + * @return array + */ + public static function filterNullValues(array $dict): array + { + return array_filter($dict, fn($value) => $value !== null); + } +} \ No newline at end of file diff --git a/src/Utils/Lists.php b/src/Utils/Lists.php new file mode 100644 index 0000000..428ed2d --- /dev/null +++ b/src/Utils/Lists.php @@ -0,0 +1,16 @@ + $list + * @return list + */ + public static function filterNullValues(array $list): array + { + return array_filter($list, fn($value) => $value !== null) |> array_values(...); + } +} \ No newline at end of file From 55027a12f9d89e87a7e5d6a7d3e64a336e3c4b99 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 10:35:06 +0200 Subject: [PATCH 006/101] Add `TypescriptGenerator` implementation with support for branded types, enums, structs, collections, and unions. Include related utilities, registry handling, and extensive test coverage. --- src/Parser/Nodes/Leaf/EnumNode.php | 2 +- src/Typescript/Data/EmissionContext.php | 19 + src/Typescript/Data/IO.php | 17 + src/Typescript/Data/Options.php | 19 + src/Typescript/Data/TypeRegistry.php | 77 ++++ src/Typescript/Data/TypeScript.php | 35 ++ .../Exceptions/UnknownAliasException.php | 28 ++ .../Exceptions/UnsupportedTypeException.php | 49 +++ src/Typescript/TypescriptGenerator.php | 250 ++++++++++++ src/Typescript/Utils/Syntax.php | 53 +++ tests/Unit/Typescript/Stubs/EmptyEnum.php | 10 + tests/Unit/Typescript/TypeRegistryTest.php | 93 +++++ .../Typescript/TypescriptGeneratorTest.php | 367 ++++++++++++++++++ 13 files changed, 1018 insertions(+), 1 deletion(-) create mode 100644 src/Typescript/Data/EmissionContext.php create mode 100644 src/Typescript/Data/IO.php create mode 100644 src/Typescript/Data/Options.php create mode 100644 src/Typescript/Data/TypeRegistry.php create mode 100644 src/Typescript/Data/TypeScript.php create mode 100644 src/Typescript/Exceptions/UnknownAliasException.php create mode 100644 src/Typescript/Exceptions/UnsupportedTypeException.php create mode 100644 src/Typescript/TypescriptGenerator.php create mode 100644 src/Typescript/Utils/Syntax.php create mode 100644 tests/Unit/Typescript/Stubs/EmptyEnum.php create mode 100644 tests/Unit/Typescript/TypeRegistryTest.php create mode 100644 tests/Unit/Typescript/TypescriptGeneratorTest.php diff --git a/src/Parser/Nodes/Leaf/EnumNode.php b/src/Parser/Nodes/Leaf/EnumNode.php index 23b7abf..c23712e 100644 --- a/src/Parser/Nodes/Leaf/EnumNode.php +++ b/src/Parser/Nodes/Leaf/EnumNode.php @@ -18,7 +18,7 @@ * @param class-string $enumClassName */ public function __construct( - private string $enumClassName, + public string $enumClassName, ) { } diff --git a/src/Typescript/Data/EmissionContext.php b/src/Typescript/Data/EmissionContext.php new file mode 100644 index 0000000..3a77604 --- /dev/null +++ b/src/Typescript/Data/EmissionContext.php @@ -0,0 +1,19 @@ + definition. + * + * Today every entry comes from a brand (`Email` => `string & Brand<"email">`), but nothing here is + * brand specific. Any future construct that wants to be emitted once and referenced by name — a + * shared enum, a deduplicated object shape — belongs in the same registry. + * + * Not to be confused with Parser\Contracts\TypeRegistry, which is the AST optimizer's node cache. + */ +final class TypeRegistry +{ + /** @var array */ + private array $definitions = []; + + /** + * @param array $definitions Alias => definition. + */ + public function __construct(array $definitions = []) + { + foreach ($definitions as $alias => $definition) { + $this->set($alias, $definition); + } + } + + /** + * An alias names exactly one type. Two definitions claiming the same alias would generate two + * conflicting `export type` statements, so the collision is rejected here rather than emitted + * as broken TypeScript. Registering the identical definition twice is fine. + */ + public function set(string $alias, string $definition): void + { + $existing = $this->definitions[$alias] ?? null; + if ($existing !== null && $existing !== $definition) { + throw UnsupportedTypeException::conflictingAlias($alias, $existing, $definition); + } + + $this->definitions[$alias] = $definition; + } + + public function has(string $alias): bool + { + return isset($this->definitions[$alias]); + } + + /** + * Call has() first: an unknown alias is a programming error, not a miss to be handled. + */ + public function get(string $alias): string + { + return $this->definitions[$alias] + ?? throw UnknownAliasException::forAlias($alias, array_keys($this->definitions)); + } + + public function isEmpty(): bool + { + return $this->definitions === []; + } + + /** + * Sorted by alias, so the same schema always generates byte identical output. + * + * @return array + */ + public function toArray(): array + { + $definitions = $this->definitions; + ksort($definitions); + return $definitions; + } +} diff --git a/src/Typescript/Data/TypeScript.php b/src/Typescript/Data/TypeScript.php new file mode 100644 index 0000000..42f6dbc --- /dev/null +++ b/src/Typescript/Data/TypeScript.php @@ -0,0 +1,35 @@ + 'string & Brand<"email">']. A consumer emits each entry as + * `export type {$alias} = {$definition}`. + */ + public function __construct( + public string $type, + public TypeRegistry $registry = new TypeRegistry(), + ) + { + } + + /** + * The same type with every alias replaced by its definition, so it can be used on its own + * without also emitting the declarations from $registry. + * + * strtr() rather than str_replace(): it substitutes in a single pass, longest alias first, and + * never rescans what it just wrote. str_replace() would let `User` corrupt `UserId`, and an + * alias named `Brand` would eat the `Brand<"...">` of a definition inserted moments earlier. + */ + public function toStandaloneType(): string + { + return strtr($this->type, $this->registry->toArray()); + } +} diff --git a/src/Typescript/Exceptions/UnknownAliasException.php b/src/Typescript/Exceptions/UnknownAliasException.php new file mode 100644 index 0000000..d950c02 --- /dev/null +++ b/src/Typescript/Exceptions/UnknownAliasException.php @@ -0,0 +1,28 @@ + $knownAliases + */ + public static function forAlias(string $alias, array $knownAliases): self + { + $known = $knownAliases === [] ? 'none' : implode(', ', $knownAliases); + + return new self( + "Unknown type alias '{$alias}'. Call has() before get(). Known aliases: {$known}." + ); + } +} diff --git a/src/Typescript/Exceptions/UnsupportedTypeException.php b/src/Typescript/Exceptions/UnsupportedTypeException.php new file mode 100644 index 0000000..67cb16c --- /dev/null +++ b/src/Typescript/Exceptions/UnsupportedTypeException.php @@ -0,0 +1,49 @@ +fullyQualifiedCastingClass}: the class cannot be built from user input. Mark it #[Castable] or keep it out of input schemas." + ); + } + + public static function emptyEnum(string $enumClassName): self + { + return new self( + "Cannot emit TypeScript for enum {$enumClassName}: it declares no cases." + ); + } + + public static function conflictingAlias(string $alias, string $existing, string $conflicting): self + { + return new self( + "Type alias {$alias} has conflicting definitions: '{$existing}' and '{$conflicting}'." + ); + } +} diff --git a/src/Typescript/TypescriptGenerator.php b/src/Typescript/TypescriptGenerator.php new file mode 100644 index 0000000..defee4d --- /dev/null +++ b/src/Typescript/TypescriptGenerator.php @@ -0,0 +1,250 @@ +registry; + $type = $this->emit($node, new EmissionContext($io, $options, $registry), 0); + + return new TypeScript($type, $registry); + } + + /** + * @param int $depth Nesting level of object literals, used for indentation when pretty printing. + */ + private function emit(NodeInterface $node, EmissionContext $context, int $depth): string + { + return match (true) { + $node instanceof BuiltInNode => $this->brand(self::builtIn($node->type), $node, $context), + $node instanceof ValueObjectNode => $this->brand(self::builtIn($node->backingType), $node, $context), + $node instanceof LiteralNode => self::literal($node), + $node instanceof EnumNode => self::enum($node, $context), + $node instanceof DateTimeNode => 'string', + $node instanceof StructNode => $this->struct($node, $context, $depth), + $node instanceof UnionNode => $this->union($node, $context, $depth), + $node instanceof IntersectionNode => $this->intersection($node, $context, $depth), + $node instanceof TupleNode => $this->tuple($node, $context, $depth), + $node instanceof ListNode => "Array<{$this->emit($node->node, $context, $depth)}>", + $node instanceof RecordNode => $context->options->pretty + ? "Recordemit($node->node, $context, $depth)}>" + : "Recordemit($node->node, $context, $depth)}>", + $node instanceof ConstraintNode => $this->emit($node->node, $context, $depth), + $node instanceof CustomCastingNode => $this->customCasting($node, $context, $depth), + + // NamedNode is the superseded branding path and is never constructed; ReferencedNode + // only exists inside optimizer generated PHP, where it resolves against a registry the + // generator does not have. Both are genuinely unrepresentable here. + default => throw UnsupportedTypeException::forNode($node), + }; + } + + private static function builtIn(BuiltInType $type): string + { + return match ($type) { + BuiltInType::STRING => 'string', + BuiltInType::INT, BuiltInType::FLOAT => 'number', + BuiltInType::BOOL => 'boolean', + BuiltInType::NULL => 'null', + BuiltInType::MIXED => 'unknown', + }; + } + + private static function literal(LiteralNode $node): string + { + return match ($node->type) { + LiteralType::BOOL => $node->value ? 'true' : 'false', + + // Enum cases travel by name, never by backing value, so that is what the client sees. + LiteralType::ENUM_CASE => $node->value instanceof UnitEnum + ? Syntax::stringLiteral($node->value->name) + : throw UnsupportedTypeException::forNode($node), + + LiteralType::STRING, LiteralType::INT, LiteralType::FLOAT, LiteralType::NULL + => json_encode($node->value, JSON_THROW_ON_ERROR), + }; + } + + private static function enum(EnumNode $node, EmissionContext $context): string + { + $cases = array_map( + fn(UnitEnum $case): string => Syntax::stringLiteral($case->name), + $node->enumClassName::cases(), + ); + + if ($cases === []) { + throw UnsupportedTypeException::emptyEnum($node->enumClassName); + } + + return implode($context->options->pretty ? ' | ' : '|', $cases); + } + + /** + * A branded leaf is always referenced by its alias; the definition it stands for travels back + * in TypeScript::$registry. + */ + private function brand(string $baseType, Branded $node, EmissionContext $context): string + { + $brandName = $node->brandName(); + if ($brandName === null || $brandName === '') { + return $baseType; + } + + $alias = Syntax::brandAlias($brandName); + $context->registry->set($alias, Syntax::branded($baseType, $brandName)); + + return $alias; + } + + private function customCasting(CustomCastingNode $node, EmissionContext $context, int $depth): string + { + // The class exists on the wire in one direction only: it can be serialized, but there is + // no way to build an instance from an incoming payload. + if ($context->io === IO::INPUT && $node->strategy === ObjectCastStrategy::NEVER) { + throw UnsupportedTypeException::uncastableInput($node); + } + + return $this->emit($node->node, $context, $depth); + } + + private function struct(StructNode $node, EmissionContext $context, int $depth): string + { + /** @var list $properties */ + $properties = []; + + foreach ($node->properties as $property) { + if (!$property instanceof PropertyNode) { + throw UnsupportedTypeException::forNode($property); + } + + $isVisible = $context->io === IO::INPUT + ? $property->propertyType->isInput() + : $property->propertyType->isOutput(); + + if (!$isVisible) { + continue; + } + + $properties[] = [ + Syntax::objectKey($property->name, $property->isOptional), + $this->emit($property->node, $context, $depth + 1), + ]; + } + + if ($properties === []) { + return '{}'; + } + + if (!$context->options->pretty) { + return '{' . implode('', array_map( + fn(array $property): string => "{$property[0]}:{$property[1]};", + $properties, + )) . '}'; + } + + $indent = Syntax::indent($depth + 1); + $lines = array_map( + fn(array $property): string => "{$indent}{$property[0]}: {$property[1]};", + $properties, + ); + + return "{\n" . implode("\n", $lines) . "\n" . Syntax::indent($depth) . '}'; + } + + /** + * @param UnionNode $node + */ + private function union(UnionNode $node, EmissionContext $context, int $depth): string + { + $members = array_map( + function (NodeInterface $member) use ($context, $depth): string { + $definition = $this->emit($member, $context, $depth); + $declaring = self::declaringNode($member); + + return $declaring instanceof UnionNode || $declaring instanceof IntersectionNode + ? "({$definition})" + : $definition; + }, + $node->types, + ); + + // Distinct schema nodes can render to the same type: `int|float` is one `number`. + return implode($context->options->pretty ? ' | ' : '|', array_unique($members)); + } + + private function intersection(IntersectionNode $node, EmissionContext $context, int $depth): string + { + $members = array_map( + function (NodeInterface $member) use ($context, $depth): string { + $definition = $this->emit($member, $context, $depth); + + return self::declaringNode($member) instanceof UnionNode + ? "({$definition})" + : $definition; + }, + $node->types, + ); + + return implode($context->options->pretty ? ' & ' : '&', $members); + } + + private function tuple(TupleNode $node, EmissionContext $context, int $depth): string + { + $members = array_map( + fn(NodeInterface $member): string => $this->emit($member, $context, $depth), + $node->types, + ); + + return '[' . implode($context->options->pretty ? ', ' : ',', $members) . ']'; + } + + /** + * Constraints are invisible in TypeScript, so precedence is decided by what they wrap. + */ + private static function declaringNode(NodeInterface $node): NodeInterface + { + while ($node instanceof ConstraintNode) { + $node = $node->node; + } + return $node; + } +} diff --git a/src/Typescript/Utils/Syntax.php b/src/Typescript/Utils/Syntax.php new file mode 100644 index 0000000..6c74ac7 --- /dev/null +++ b/src/Typescript/Utils/Syntax.php @@ -0,0 +1,53 @@ + `Email`. + */ + public static function brandAlias(string $brandName): string + { + return ucfirst($brandName); + } + + /** + * The full definition of a branded type, e.g. `string & Brand<"email">`. + */ + public static function branded(string $baseType, string $brandName): string + { + return "{$baseType} & Brand<" . self::stringLiteral($brandName) . ">"; + } + + public static function indent(int $level): string + { + return str_repeat(self::INDENT, $level); + } +} diff --git a/tests/Unit/Typescript/Stubs/EmptyEnum.php b/tests/Unit/Typescript/Stubs/EmptyEnum.php new file mode 100644 index 0000000..b60997a --- /dev/null +++ b/tests/Unit/Typescript/Stubs/EmptyEnum.php @@ -0,0 +1,10 @@ +isEmpty())->toBeTrue() + ->and($registry->toArray())->toBe([]) + ->and($registry->has('Anything'))->toBeFalse(); +}); + +test('is seeded from the constructor', function () { + $registry = new TypeRegistry(['Email' => 'string & Brand<"email">']); + + expect($registry->isEmpty())->toBeFalse() + ->and($registry->has('Email'))->toBeTrue() + ->and($registry->get('Email'))->toBe('string & Brand<"email">'); +}); + +test('stores and reads back a definition', function () { + $registry = new TypeRegistry(); + $registry->set('Email', 'string & Brand<"email">'); + + expect($registry->has('Email'))->toBeTrue() + ->and($registry->get('Email'))->toBe('string & Brand<"email">') + ->and($registry->isEmpty())->toBeFalse(); +}); + +test('accepts the identical definition twice', function () { + $registry = new TypeRegistry(); + $registry->set('Email', 'string & Brand<"email">'); + $registry->set('Email', 'string & Brand<"email">'); + + expect($registry->toArray())->toBe(['Email' => 'string & Brand<"email">']); +}); + +test('throws when an alias is rebound to a different definition', function () { + $registry = new TypeRegistry(); + $registry->set('Email', 'string & Brand<"email">'); + + expect(fn() => $registry->set('Email', 'number & Brand<"email">')) + ->toThrow(UnsupportedTypeException::class, 'Type alias Email has conflicting definitions'); +}); + +test('a seed array cannot conflict with itself, only a later set() can', function () { + $registry = new TypeRegistry(['Email' => 'string & Brand<"email">']); + + // Duplicate keys collapse inside an array literal, so the last one simply wins. + expect(fn() => new TypeRegistry([...$registry->toArray(), 'Email' => 'number'])) + ->not->toThrow(UnsupportedTypeException::class); + + expect(fn() => $registry->set('Email', 'number')) + ->toThrow(UnsupportedTypeException::class); +}); + +test('throws when reading an alias that was never defined', function () { + $registry = new TypeRegistry(['Email' => 'string & Brand<"email">']); + + expect(fn() => $registry->get('Missing')) + ->toThrow(UnknownAliasException::class, "Unknown type alias 'Missing'. Call has() before get(). Known aliases: Email."); +}); + +test('names no aliases when reading from an empty registry', function () { + expect(fn() => new TypeRegistry()->get('Missing')) + ->toThrow(UnknownAliasException::class, 'Known aliases: none.'); +}); + +test('returns definitions sorted by alias', function () { + $registry = new TypeRegistry(); + $registry->set('Zulu', 'string'); + $registry->set('Alpha', 'number'); + $registry->set('Mike', 'boolean'); + + expect($registry->toArray())->toBe([ + 'Alpha' => 'number', + 'Mike' => 'boolean', + 'Zulu' => 'string', + ]); +}); + +test('a clone does not share state with its original', function () { + $original = new TypeRegistry(['Email' => 'string & Brand<"email">']); + + $copy = clone $original; + $copy->set('Token', 'string & Brand<"token">'); + + expect($copy->has('Token'))->toBeTrue() + ->and($original->has('Token'))->toBeFalse() + ->and($original->toArray())->toBe(['Email' => 'string & Brand<"email">']); +}); diff --git a/tests/Unit/Typescript/TypescriptGeneratorTest.php b/tests/Unit/Typescript/TypescriptGeneratorTest.php new file mode 100644 index 0000000..0e619b3 --- /dev/null +++ b/tests/Unit/Typescript/TypescriptGeneratorTest.php @@ -0,0 +1,367 @@ +parse($type) : $type; + return new TypescriptGenerator()->toTypescript($node, $io, $options); +} + +/** + * Emitting the same node for both directions must not differ unless the schema says so. + */ +function typescriptOfBoth(string|NodeInterface $type): string +{ + $input = typescriptOf($type, IO::INPUT); + $output = typescriptOf($type, IO::OUTPUT); + expect($input->type)->toBe($output->type) + ->and($input->registry->toArray())->toBe($output->registry->toArray()); + return $input->type; +} + +test('emits built in scalar types', function (string $type, string $expected) { + expect(typescriptOfBoth($type))->toBe($expected); +})->with([ + 'string' => ['string', 'string'], + 'int' => ['int', 'number'], + 'float' => ['float', 'number'], + 'bool' => ['bool', 'boolean'], + 'null' => ['null', 'null'], + 'mixed' => ['mixed', 'unknown'], +]); + +test('emits constrained and aliased built in types', function (string $type, string $expected) { + expect(typescriptOfBoth($type))->toBe($expected); +})->with([ + 'positive-int is a constrained int' => ['positive-int', 'number'], + 'non-empty-string is a constrained string' => ['non-empty-string', 'string'], + 'numeric collapses int|float to one number' => ['numeric', 'number'], + 'scalar dedupes int|float' => ['scalar', 'number|boolean|string'], +]); + +test('emits literal types', function (string $type, string $expected) { + expect(typescriptOfBoth($type))->toBe($expected); +})->with([ + 'string literal' => ["'foo'", '"foo"'], + 'int literal' => ['123', '123'], + 'float literal' => ['1.5', '1.5'], + 'true' => ['true', 'true'], + 'false' => ['false', 'false'], + 'enum case literal uses the case name' => ['\\' . ResultEnum::class . '::SUCCESS', '"SUCCESS"'], +]); + +test('escapes string literals for typescript', function (string $type, string $expected) { + expect(typescriptOfBoth($type))->toBe($expected); +})->with([ + 'double quotes' => ["'he said \"hi\"'", '"he said \\"hi\\""'], + 'backslash' => ["'back\\\\slash'", '"back\\\\slash"'], + 'unicode' => ["'héllo'", '"h\\u00e9llo"'], + 'empty string' => ["''", '""'], +]); + +test('emits an enum as a union of its case names', function () { + expect(typescriptOfBoth('\\' . ResultEnum::class))->toBe('"SUCCESS"|"FAILURE"'); +}); + +test('throws for an enum without cases', function () { + expect(fn() => typescriptOf(new EnumNode(EmptyEnum::class))) + ->toThrow(UnsupportedTypeException::class, 'declares no cases'); +}); + +test('emits every date time flavour as string', function (string $type) { + expect(typescriptOfBoth($type))->toBe('string'); +})->with([ + 'DateTimeString' => ['DateTimeString'], + 'DateTimeString with format' => ["DateTimeString<'Y-m-d'>"], + 'DateTimeImmutable' => ['\\DateTimeImmutable'], +]); + +test('emits struct types', function (string $type, string $expected) { + expect(typescriptOfBoth($type))->toBe($expected); +})->with([ + 'array shape' => ['array{name: string}', '{name:string;}'], + 'optional key' => ['array{name?: string}', '{name?:string;}'], + 'object shape renders the same as an array shape' => ['object{id: int}', '{id:number;}'], + 'quoted key' => ["array{'key something else': string}", '{"key something else":string;}'], + 'optional quoted key' => ["array{'key something else'?: string}", '{"key something else"?:string;}'], + 'key needing escaping' => ["array{'quote\"key': string}", '{"quote\\"key":string;}'], + 'nested struct' => ['array{a: array{b: string}}', '{a:{b:string;};}'], +]); + +test('emits collection types', function (string $type, string $expected) { + expect(typescriptOfBoth($type))->toBe($expected); +})->with([ + 'list' => ['list', 'Array'], + 'array shorthand' => ['string[]', 'Array'], + 'int keyed array' => ['array', 'Array'], + 'bare array' => ['array', 'Array'], + 'record' => ['array', 'Record'], + 'tuple' => ['array{string, int}', '[string,number]'], + 'explicitly keyed tuple' => ['array{0: string, 1: int}', '[string,number]'], +]); + +test('emits unions and intersections with correct precedence', function (string $type, string $expected) { + expect(typescriptOfBoth($type))->toBe($expected); +})->with([ + 'union' => ['array{name: string}|string', '{name:string;}|string'], + 'nullable' => ['?string', 'null|string'], + 'union dedupes rendered members' => ['int|string|int', 'number|string'], + 'union dedupes equal literals' => ["'a'|'b'|'a'", '"a"|"b"'], + 'union member that is an intersection is parenthesised' => [ + 'array{a: string}|(array{b: int}&array{c: bool})', + '{a:string;}|({b:number;}&{c:boolean;})', + ], + 'intersection member that is a union is parenthesised' => [ + '(array{id: positive-int}|array{token: string})&array{reason: string}', + '({id:number;}|{token:string;})&{reason:string;}', + ], +]); + +test('references a branded alias at the use site and returns its definition', function ( + string $type, + string $expectedType, + array $expectedBrands, +) { + $result = typescriptOf($type); + + expect($result->type)->toBe($expectedType) + ->and($result->registry->toArray())->toBe($expectedBrands); +})->with([ + 'string value object' => ['\\' . Email::class, 'Email', ['Email' => 'string & Brand<"email">']], + 'int value object aliased by its explicit brand' => [ + '\\' . UserId::class, + 'CustomerId', + ['CustomerId' => 'number & Brand<"customerId">'], + ], + 'unbranded value object stays a plain string' => ['\\' . Slug::class, 'string', []], + 'BrandedString' => ["BrandedString<'token'>", 'Token', ['Token' => 'string & Brand<"token">']], + 'BrandedInt' => ["BrandedInt<'wow'>", 'Wow', ['Wow' => 'number & Brand<"wow">']], +]); + +test('collects brands from any depth of the tree', function (string $type, string $expectedType, array $expectedBrands) { + $result = typescriptOf($type); + + expect($result->type)->toBe($expectedType) + ->and($result->registry->toArray())->toBe($expectedBrands); +})->with([ + 'inside a struct' => [ + '\\' . CreateAccountInput::class, + '{email:Email;ownerId:CustomerId;}', + ['CustomerId' => 'number & Brand<"customerId">', 'Email' => 'string & Brand<"email">'], + ], + 'inside a list' => [ + 'list<\\' . Email::class . '>', + 'Array', + ['Email' => 'string & Brand<"email">'], + ], + 'inside a union' => [ + '?\\' . Email::class, + 'null|Email', + ['Email' => 'string & Brand<"email">'], + ], + 'inside a record' => [ + 'array', + 'Record', + ['CustomerId' => 'number & Brand<"customerId">'], + ], + 'the same brand used twice is collected once' => [ + "array{a: BrandedString<'token'>, b: BrandedString<'token'>}", + '{a:Token;b:Token;}', + ['Token' => 'string & Brand<"token">'], + ], +]); + +test('returns branded types sorted by alias', function () { + $result = typescriptOf("array{z: BrandedString<'zulu'>, a: BrandedString<'alpha'>, m: BrandedString<'mike'>}"); + + expect(array_keys($result->registry->toArray()))->toBe(['Alpha', 'Mike', 'Zulu']) + ->and($result->type)->toBe('{z:Zulu;a:Alpha;m:Mike;}'); +}); + +test('throws when one brand resolves to two different definitions', function () { + expect(fn() => typescriptOf("array{a: BrandedString<'token'>, b: BrandedInt<'token'>}")) + ->toThrow(UnsupportedTypeException::class, 'Token'); +}); + +test('toStandaloneType inlines every branded alias', function () { + $result = typescriptOf('\\' . CreateAccountInput::class); + + expect($result->toStandaloneType()) + ->toBe('{email:string & Brand<"email">;ownerId:number & Brand<"customerId">;}'); +}); + +test('toStandaloneType does not let one alias corrupt another that starts with it', function () { + $result = typescriptOf("array{a: BrandedString<'user'>, b: BrandedInt<'userId'>}"); + + expect($result->type)->toBe('{a:User;b:UserId;}') + ->and($result->toStandaloneType()) + ->toBe('{a:string & Brand<"user">;b:number & Brand<"userId">;}'); +}); + +test('toStandaloneType equals the type when nothing is branded', function () { + $result = typescriptOf('array{name: string, tags: list}'); + + expect($result->registry->isEmpty())->toBeTrue() + ->and($result->toStandaloneType())->toBe($result->type); +}); + +test('reads a collected alias back out of the registry', function () { + $registry = typescriptOf('\\' . CreateAccountInput::class)->registry; + + expect($registry->isEmpty())->toBeFalse() + ->and($registry->has('Email'))->toBeTrue() + ->and($registry->get('Email'))->toBe('string & Brand<"email">') + ->and($registry->has('Nope'))->toBeFalse(); +}); + +test('generates against a registry passed in without mutating it', function () { + $shared = new TypeRegistry(['Existing' => 'string & Brand<"existing">']); + + $result = typescriptOf('\\' . Email::class, IO::INPUT, new Options(registry: $shared)); + + expect($result->registry->toArray())->toBe([ + 'Email' => 'string & Brand<"email">', + 'Existing' => 'string & Brand<"existing">', + ]) + ->and($result->registry)->not->toBe($shared) + ->and($shared->toArray())->toBe(['Existing' => 'string & Brand<"existing">']); +}); + +test('throws when the incoming registry already binds an alias to something else', function () { + $shared = new TypeRegistry(['Email' => 'number & Brand<"email">']); + + expect(fn() => typescriptOf('\\' . Email::class, IO::INPUT, new Options(registry: $shared))) + ->toThrow(UnsupportedTypeException::class, 'Email'); +}); + +test('filters struct properties by direction', function () { + $type = '\\' . UserSchema::class; + + expect(typescriptOf($type, IO::INPUT)->type)->toBe('{age:number;email:string;username:string;}') + ->and(typescriptOf($type, IO::OUTPUT)->type)->toBe('{age:number;username:string;}'); +}); + +test('emits an empty object when no property survives the direction filter', function () { + $node = new StructNode(StructPhpType::OBJECT, [ + new PropertyNode('name', new BuiltInNode(BuiltInType::STRING), false, PropertyType::OUTPUT), + ]); + + expect(typescriptOf($node, IO::INPUT)->type)->toBe('{}') + ->and(typescriptOf($node, IO::OUTPUT)->type)->toBe('{name:string;}'); +}); + +test('throws for an uncastable class on input but emits it on output', function (string $class, string $output) { + $type = '\\' . $class; + + expect(fn() => typescriptOf($type, IO::INPUT)) + ->toThrow(UnsupportedTypeException::class, $class); + + expect(typescriptOf($type, IO::OUTPUT)->type)->toBe($output); +})->with([ + 'class without #[Castable]' => [UncastableClass::class, '{email:string;name:string;}'], + 'abstract class' => [SomeAbstractClass::class, '{id:number;email:string;}'], + 'interface' => [SomeFileInterface::class, '{id:number;url:string;}'], + 'readonly output fields' => [ReadonlyOutputFields::class, '{name:string;email:string;}'], +]); + +test('throws for nodes it cannot represent', function (NodeInterface $node) { + expect(fn() => typescriptOf($node))->toThrow(UnsupportedTypeException::class); +})->with([ + 'NamedNode' => [new NamedNode(new BuiltInNode(BuiltInType::STRING), 'Legacy')], + 'ReferencedNode' => [new ReferencedNode('#leaf_abc', 'string', 'registry')], + 'unknown node implementation' => [new class implements NodeInterface { + public function __toString(): string + { + return 'unknown'; + } + + public function exportPhpCode(): string + { + return ''; + } + }], +]); + +test('pretty prints nested struct literals', function () { + $result = typescriptOf( + 'array{items: list, total: int}', + IO::INPUT, + new Options(pretty: true), + ); + + expect($result->type)->toBe(<<; + total: number; + } + TS); +}); + +test('pretty printing spaces out the remaining separators', function (string $type, string $expected) { + expect(typescriptOf($type, IO::INPUT, new Options(pretty: true))->type)->toBe($expected); +})->with([ + 'union' => ['int|string', 'number | string'], + 'intersection' => ['array{a: int}&array{b: string}', "{\n a: number;\n} & {\n b: string;\n}"], + 'tuple' => ['array{string, int}', '[string, number]'], + 'record' => ['array', 'Record'], + 'list' => ['list', 'Array'], + 'optional key' => ['array{name?: string}', "{\n name?: string;\n}"], + 'quoted key' => ["array{'a b': string}", "{\n \"a b\": string;\n}"], +]); + +test('pretty printing keeps an empty object on one line', function () { + $node = new StructNode(StructPhpType::OBJECT, [ + new PropertyNode('name', new BuiltInNode(BuiltInType::STRING), false, PropertyType::OUTPUT), + ]); + + expect(typescriptOf($node, IO::INPUT, new Options(pretty: true))->type)->toBe('{}'); +}); + +test('pretty printing still references branded aliases', function () { + $result = typescriptOf('\\' . CreateAccountInput::class, IO::INPUT, new Options(pretty: true)); + + expect($result->type)->toBe(<<and($result->registry->toArray())->toBe([ + 'CustomerId' => 'number & Brand<"customerId">', + 'Email' => 'string & Brand<"email">', + ]); +}); From 0d829128de1db91225b426154951ef4503cfaa41 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 11:03:06 +0200 Subject: [PATCH 007/101] Add `TypescriptGenerator` implementation with support for branded types, enums, structs, collections, and unions. Include related utilities, registry handling, and extensive test coverage. --- src/Adapters/Laravel/Commands/OptimizeCommand.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Adapters/Laravel/Commands/OptimizeCommand.php b/src/Adapters/Laravel/Commands/OptimizeCommand.php index 71ce66d..921de02 100644 --- a/src/Adapters/Laravel/Commands/OptimizeCommand.php +++ b/src/Adapters/Laravel/Commands/OptimizeCommand.php @@ -5,7 +5,6 @@ use Illuminate\Console\Command; use Illuminate\Container\Attributes\Give; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider; -use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Server\Operations\CachedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; use Le0daniel\PhpTsBindings\Server\Server; From 1b965073f4ceed2f7050056ba5e4a5d178e2b1ad Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 11:22:35 +0200 Subject: [PATCH 008/101] Remove deprecated `TypescriptDefinitionGenerator` and `DefinitionTarget`, refactor code generators to use `TypescriptGenerator` and new `Options`, add tests for branded type handling and unsupported type exception. --- README.md | 37 ++++-- .../Laravel/Commands/CodeGenCommand.php | 14 ++- src/Adapters/Laravel/Commands/ListCommand.php | 3 - src/CodeGen/CodeGenerators/EmitOperations.php | 26 +++- src/CodeGen/CodeGenerators/EmitTypes.php | 115 +++-------------- src/CodeGen/Data/DefinitionTarget.php | 9 -- src/CodeGen/Data/TypedOperation.php | 17 ++- src/CodeGen/TypescriptDefinitionGenerator.php | 116 ------------------ src/CodeGen/TypescriptServerCodeGenerator.php | 47 +++++-- src/CodeGen/Utils/Typescript.php | 17 --- src/Contracts/LeafNode.php | 5 - src/Parser/Nodes/Leaf/BuiltInNode.php | 16 --- src/Parser/Nodes/Leaf/DateTimeNode.php | 9 -- src/Parser/Nodes/Leaf/EnumNode.php | 20 --- src/Parser/Nodes/Leaf/LiteralNode.php | 20 --- src/Parser/Nodes/Leaf/ValueObjectNode.php | 10 -- src/Typescript/Data/Options.php | 4 + src/Typescript/TypescriptGenerator.php | 4 + src/Typescript/Utils/Syntax.php | 5 +- tests/Pest.php | 31 +++-- tests/Unit/CodeGen/EmitTypesTest.php | 14 ++- .../Mocks/UnrepresentableOperations.php | 23 ++++ tests/Unit/CodeGen/Mocks/UserOperations.php | 39 ++++++ .../TypescriptServerCodeGeneratorTest.php | 81 ++++++++++++ tests/Unit/CodeGen/Utils/TypescriptTest.php | 12 -- tests/Unit/Parser/TypeParserTest.php | 95 ++++++-------- tests/Unit/Parser/ValueObjectConsumerTest.php | 42 ++++--- .../OptimizedAstTest.php} | 31 ++--- .../Typescript/TypescriptGeneratorTest.php | 29 +++++ tests/Unit/Typescript/Utils/SyntaxTest.php | 12 ++ 30 files changed, 434 insertions(+), 469 deletions(-) delete mode 100644 src/CodeGen/Data/DefinitionTarget.php delete mode 100644 src/CodeGen/TypescriptDefinitionGenerator.php delete mode 100644 src/CodeGen/Utils/Typescript.php create mode 100644 tests/Unit/CodeGen/Mocks/UnrepresentableOperations.php create mode 100644 tests/Unit/CodeGen/Mocks/UserOperations.php create mode 100644 tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php delete mode 100644 tests/Unit/CodeGen/Utils/TypescriptTest.php rename tests/Unit/{Definition/TypescriptDefinitionTest.php => Typescript/OptimizedAstTest.php} (70%) create mode 100644 tests/Unit/Typescript/Utils/SyntaxTest.php diff --git a/README.md b/README.md index 4d4d9e8..39d6f2c 100644 --- a/README.md +++ b/README.md @@ -89,12 +89,13 @@ customizations, including writing your very own code generation plugin. ## Type Parsing ```php -use Le0daniel\PhpTsBindings\CodeGen\Data\DefinitionTarget; -use Le0daniel\PhpTsBindings\CodeGen\TypescriptDefinitionGenerator; use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; +use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use Le0daniel\PhpTsBindings\Typescript\Data\Options; +use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; $typeString = TypeReflector::reflectParameter( new ReflectionParameter() @@ -107,11 +108,24 @@ $ast = $parser->parse( ParsingContext::fromClassString(MyClassDeclaringThisParameter::class) ); -$inputDefinition = new TypescriptDefinitionGenerator()->toDefinition($ast, DefinitionTarget::INPUT); -// => string|Record|{name: string;} +$generator = new TypescriptGenerator(); -$outputDefinition = new TypescriptDefinitionGenerator()->toDefinition($ast, DefinitionTarget::OUTPUT); -// => string|Record|{name: string;} +$input = $generator->toTypescript($ast, IO::INPUT); +$input->type; // => string|Record|{name:string;} + +$output = $generator->toTypescript($ast, IO::OUTPUT); +$output->type; // => string|Record|{name:string;} + +// Branded leaves are referenced by an alias; its definition comes back in the registry, so you can +// emit `export type Email = string & Brand<"email">` once and reference it everywhere. +$branded = $generator->toTypescript($parser->parse(Email::class), IO::INPUT); +$branded->type; // => Email +$branded->registry->toArray(); // => ['Email' => 'string & Brand<"email">'] +$branded->toStandaloneType(); // => string & Brand<"email"> + +// Options: pretty prints object literals across lines, ignoreBrandedTypes drops the brands and +// emits the backing primitive instead. +$generator->toTypescript($ast, IO::INPUT, new Options(pretty: true, ignoreBrandedTypes: true)); $executor = new SchemaExecutor() @@ -129,8 +143,8 @@ resolved by the bundled PHPStan extension too, so static analysis agrees with th | --- | --- | --- | | `Pick` | struct with only those properties | `{a: …; b: …;}` | | `Omit` | struct without those properties | `{…}` | -| `BrandedString<'name'>` | `string` | `string & Brand<"name">` | -| `BrandedInt<'name'>` | `int` | `number & Brand<"name">` | +| `BrandedString<'name'>` | `string` | `Name`, declared as `string & Brand<"name">` | +| `BrandedInt<'name'>` | `int` | `Name`, declared as `number & Brand<"name">` | | `DateTimeString<'format'>` | `DateTimeImmutable` | `string` | ### DateTimeString @@ -252,8 +266,11 @@ declare function getUser(id: UserId): void; getUser(1); // Type error: number is not assignable to UserId ``` -Value objects without `#[Brand]` stay plain `string` / `number`. Brands are code generation metadata -only — they have no runtime impact, and `php artisan operations:codegen --no-branded-types` strips them. +Each brand is declared once, in the generated types file, and every operation that uses it references +it by that name (`{id: UserId}`) and imports it. Value objects without `#[Brand]` stay plain +`string` / `number`. Brands are code generation metadata only — they have no runtime impact, and +`php artisan operations:codegen --no-branded-types` strips them, emitting the backing primitive at +every use site and declaring nothing. ## Validating AST diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index 5bee051..b4ba02f 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -25,9 +25,11 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypeScriptFile; -use Le0daniel\PhpTsBindings\CodeGen\TypescriptDefinitionGenerator; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\Server\Server; +use Le0daniel\PhpTsBindings\Typescript\Data\Options; +use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; +use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; @@ -89,8 +91,9 @@ public function handle( $codeGenerator = new TypescriptServerCodeGenerator( $this->getGeneratorsFromInput($application), - new TypescriptDefinitionGenerator( - emitBrandedTypes: $this->option('no-branded-types') === false + new TypescriptGenerator(), + new Options( + ignoreBrandedTypes: $this->option('no-branded-types') === true, ), ); @@ -105,6 +108,11 @@ public function handle( $this->error($message); } return 1; + } catch (UnsupportedTypeException $exception) { + // A schema that cannot be expressed in TypeScript is a bug worth surfacing here, rather + // than a placeholder type that fails later inside the generated client. + $this->error($exception->getMessage()); + return 1; } $directory = str_starts_with('/', $this->argument('directory')) diff --git a/src/Adapters/Laravel/Commands/ListCommand.php b/src/Adapters/Laravel/Commands/ListCommand.php index 5fe913f..c603237 100644 --- a/src/Adapters/Laravel/Commands/ListCommand.php +++ b/src/Adapters/Laravel/Commands/ListCommand.php @@ -7,9 +7,6 @@ use Illuminate\Routing\Router; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider; -use Le0daniel\PhpTsBindings\CodeGen\Data\DefinitionTarget; -use Le0daniel\PhpTsBindings\CodeGen\TypescriptDefinitionGenerator; -use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Server; diff --git a/src/CodeGen/CodeGenerators/EmitOperations.php b/src/CodeGen/CodeGenerators/EmitOperations.php index be23975..fa42f64 100644 --- a/src/CodeGen/CodeGenerators/EmitOperations.php +++ b/src/CodeGen/CodeGenerators/EmitOperations.php @@ -34,6 +34,27 @@ private function generateName(TypedOperation $operation): string return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->operation->definition->name; } + /** + * The types below reference branded leaves by their alias, which lives in the generated types + * file. An operation without a single branded leaf must not emit `import {} from …`. + * + * @return list + */ + private function aliasImports(TypedOperation $operation): array + { + $aliases = array_keys($operation->registry->toArray()); + if ($aliases === []) { + return []; + } + + return [ + new TypescriptImportStatement( + from: Paths::libImport("types"), + imports: array_map(fn(string $alias): string => "type {$alias}", $aliases), + ), + ]; + } + public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): TypescriptCodeBlock { $definition = $operation->operation->definition; @@ -53,10 +74,7 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata from: Paths::libImport("OperationClient"), imports: ["OperationOptions"] ), - new TypescriptImportStatement( - from: Paths::libImport("types"), - imports: ["type Brand"], - ) + ...$this->aliasImports($operation), ]; $docBlock = <<collectBrandedTypes($operation->operation->inputNode(), DefinitionTarget::INPUT); - $outputBrands = $this->collectBrandedTypes($operation->operation->outputNode(), DefinitionTarget::OUTPUT); - return $this->mergeBrandedTypes($carry, $inputBrands, $outputBrands); - }, []); - - $brandedTypeStrings = Arrays::mapWithKeys( - $brands, - function (string $brandName, string $type): string { - $capitalizedBrandName = ucfirst($brandName); - $encodedBrandName = json_encode($brandName, JSON_THROW_ON_ERROR); - return "export type {$capitalizedBrandName} = {$type} & Brand<{$encodedBrandName}>"; - }); - - $brandedTypeString = implode("\n", $brandedTypeStrings); + $brandedTypeString = implode("\n", Arrays::mapWithKeys( + $this->collectAliases($operations), + fn(string $alias, string $definition): string => "export type {$alias} = {$definition}", + )); return [ "types" => << $operations * @return array */ - private function collectBrandedTypes(NodeInterface $ast, DefinitionTarget $target): array + private function collectAliases(array $operations): array { - /** @var list $brandedNodes */ - $brandedNodes = []; + $registry = new TypeRegistry(); - $stack = [ - $ast, - ]; - - while ($current = array_pop($stack)) { - if ($current instanceof ValidatableNode) { - $current->validate(); + foreach ($operations as $operation) { + foreach ($operation->registry->toArray() as $alias => $definition) { + $registry->set($alias, $definition); } - - if ($current instanceof LeafNode) { - if ($current instanceof Branded && $current->brandName() !== null) { - $brandedNodes[] = $current; - } - - continue; - } - - match ($current::class) { - ConstraintNode::class, CustomCastingNode::class, ListNode::class, NamedNode::class, PropertyNode::class, RecordNode::class => $stack[] = $current->node, - TupleNode::class, IntersectionNode::class, UnionNode::class => array_push($stack, ...$current->types), - StructNode::class => array_push($stack, ... $current->properties), - default => throw new RuntimeException("Unexpected node: " . $current::class), - }; } - $brandedTypes = []; - foreach ($brandedNodes as $node) { - $brand = $node->brandName(); - if ($brand === null) { - continue; - } - - $typeDefinition = $target === DefinitionTarget::INPUT - ? $node->inputDefinition() - : $node->outputDefinition(); - - if (!isset($brandedTypes[$brand])) { - $brandedTypes[$brand] = $typeDefinition; - continue; - } - - if ($typeDefinition !== $brandedTypes[$brand]) { - throw new RuntimeException("Branded type {$brand} has different definitions"); - } - } - - return $brandedTypes; - } - - /** - * @param array $brands - * @param array ...$otherTypes - * @return array - */ - private function mergeBrandedTypes(array $brands, array ... $otherTypes): array - { - foreach ($otherTypes as $keyValuePairs) { - foreach ($keyValuePairs as $key => $value) { - if (!isset($brands[$key])) { - $brands[$key] = $value; - continue; - } - if ($brands[$key] !== $value) { - throw new RuntimeException("Branded type {$key} has different definitions"); - } - } - } - return $brands; + return $registry->toArray(); } } diff --git a/src/CodeGen/Data/DefinitionTarget.php b/src/CodeGen/Data/DefinitionTarget.php deleted file mode 100644 index 2870737..0000000 --- a/src/CodeGen/Data/DefinitionTarget.php +++ /dev/null @@ -1,9 +0,0 @@ - $this->operation->key; } + /** + * @param string $inputDefinition The input type. Branded leaves appear as their alias name. + * @param string $outputDefinition The output type. Branded leaves appear as their alias name. + * @param TypeRegistry $registry The aliases the two types above refer to. A generator writing + * into the file that declares them emits one `export type` per entry; a generator writing + * into any other file imports them by name. + */ public function __construct( - public readonly string $inputDefinition, - public readonly string $outputDefinition, - public readonly string $errorDefinition, - public readonly Operation $operation, + public readonly string $inputDefinition, + public readonly string $outputDefinition, + public readonly string $errorDefinition, + public readonly Operation $operation, + public readonly TypeRegistry $registry = new TypeRegistry(), ) { } diff --git a/src/CodeGen/TypescriptDefinitionGenerator.php b/src/CodeGen/TypescriptDefinitionGenerator.php deleted file mode 100644 index 4241dd7..0000000 --- a/src/CodeGen/TypescriptDefinitionGenerator.php +++ /dev/null @@ -1,116 +0,0 @@ -brandName() : null; - - if ($this->emitBrandedTypes && $brand !== null) { - $encodedBrand = json_encode($brand, JSON_THROW_ON_ERROR); - return $target === DefinitionTarget::INPUT - ? "{$node->inputDefinition()} & Brand<{$encodedBrand}>" - : "{$node->outputDefinition()} & Brand<{$encodedBrand}>"; - } - - return $target === DefinitionTarget::INPUT ? $node->inputDefinition() : $node->outputDefinition(); - } - - return match ($node::class) { - StructNode::class => $this->printStructNode($node, $target), - UnionNode::class => $this->printUnionNode($node, $target), - IntersectionNode::class => $this->printIntersectionNode($node, $target), - ListNode::class => "Array<{$this->toDefinition($node->node, $target)}>", - RecordNode::class => "RecordtoDefinition($node->node, $target)}>", - TupleNode::class => '[' . implode(',', array_map(fn(NodeInterface $node) => $this->toDefinition($node, $target), $node->types)) . ']', - ConstraintNode::class => $this->toDefinition($node->node, $target), - CustomCastingNode::class => $this->printCustomCastingNode($node, $target), - default => throw new RuntimeException("Not implemented: " . $node::class), - }; - } - - private function printCustomCastingNode(CustomCastingNode $node, DefinitionTarget $target): string - { - // Returns if an object can ever be targeted for input. - return $target === DefinitionTarget::INPUT && $node->strategy === ObjectCastStrategy::NEVER - ? 'never' - : $this->toDefinition($node->node, $target); - } - - private function printStructNode(StructNode $node, DefinitionTarget $target): string - { - $filteredProperties = array_filter( - $node->properties, - fn(NodeInterface $property) => $target === DefinitionTarget::INPUT - ? $property->propertyType->isInput() - : $property->propertyType->isOutput(), - ); - - $properties = array_map( - function (PropertyNode $property) use ($target): string { - return Typescript::objectKey($property->name, $property->isOptional) . ":{$this->toDefinition($property->node, $target)};"; - }, - $filteredProperties, - ); - - return "{" . implode("", $properties) . "}"; - } - - /** @param UnionNode $node */ - private function printUnionNode(UnionNode $node, DefinitionTarget $target): string - { - return implode( - '|', array_unique(array_map( - function (NodeInterface $node) use ($target) { - $definition = $this->toDefinition($node, $target); - $definingNode = Nodes::getDeclaringNode($node); - - return match ($definingNode::class) { - UnionNode::class, IntersectionNode::class => "({$definition})", - default => $definition, - }; - }, - $node->types - )) - ); - } - - private function printIntersectionNode(IntersectionNode $node, DefinitionTarget $target): string - { - return implode('&', array_map( - function (NodeInterface $node) use ($target) { - $definition = $this->toDefinition($node, $target); - return Nodes::getDeclaringNode($node) instanceof UnionNode - ? "({$definition})" : $definition; - }, - $node->types) - ); - } -} \ No newline at end of file diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 6f4cd41..08e4c2d 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -5,16 +5,19 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\DependsOn; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; -use Le0daniel\PhpTsBindings\CodeGen\Data\DefinitionTarget; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypeScriptFile; use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; +use Le0daniel\PhpTsBindings\Parser\AstValidator; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Server; -use Le0daniel\PhpTsBindings\Utils\Arrays; +use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use Le0daniel\PhpTsBindings\Typescript\Data\Options; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Le0daniel\PhpTsBindings\Utils\Lists; use RuntimeException; @@ -22,11 +25,14 @@ { /** * @param array $generators + * @param Options $options Applies to every type generated in this run. The registry it carries is + * ignored: each operation collects its aliases into its own. * @throws InvalidGeneratorDependencies */ public function __construct( - private array $generators, - private TypescriptDefinitionGenerator $definitionGenerator, + private array $generators, + private TypescriptGenerator $typescriptGenerator = new TypescriptGenerator(), + private Options $options = new Options(), ) { $this->verifyGeneratorDependencies(); @@ -80,15 +86,24 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore $definitions = array_values( array_map(function (Operation $operation) use ($server): TypedOperation { - $inputType = $this->definitionGenerator->toDefinition($operation->inputNode(), DefinitionTarget::INPUT); - $successOutputType = $this->definitionGenerator->toDefinition($operation->outputNode(), DefinitionTarget::OUTPUT); - $possibleErrorType = $this->generateAllErrorTypes($server, $operation->definition); + AstValidator::validate($operation->inputNode()); + AstValidator::validate($operation->outputNode()); + + // Both directions share one registry, so an operation hands its aliases on as a single + // set and a brand that means two different things across them is rejected right here. + $input = $this->typescriptGenerator->toTypescript( + $operation->inputNode(), IO::INPUT, $this->optionsWith(new TypeRegistry()), + ); + $output = $this->typescriptGenerator->toTypescript( + $operation->outputNode(), IO::OUTPUT, $this->optionsWith($input->registry), + ); return new TypedOperation( - $inputType, - $successOutputType, - $possibleErrorType, + $input->type, + $output->type, + $this->generateAllErrorTypes($server, $operation->definition), $operation, + $output->registry, ); }, $filteredDefinitions) ); @@ -107,6 +122,18 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore ]; } + /** + * The run wide options, aimed at the given registry. + */ + private function optionsWith(TypeRegistry $registry): Options + { + return new Options( + pretty: $this->options->pretty, + ignoreBrandedTypes: $this->options->ignoreBrandedTypes, + registry: $registry, + ); + } + private function generateAllErrorTypes(Server $server, Definition $operation): string { $possibleTypes = Lists::filterNullValues(array_map(function (ExceptionPresenter $presenter) use ($operation): null|string { diff --git a/src/CodeGen/Utils/Typescript.php b/src/CodeGen/Utils/Typescript.php deleted file mode 100644 index 8e8dad0..0000000 --- a/src/CodeGen/Utils/Typescript.php +++ /dev/null @@ -1,17 +0,0 @@ -type) { - BuiltInType::STRING => 'string', - BuiltInType::INT, BuiltInType::FLOAT => 'number', - BuiltInType::BOOL => 'boolean', - BuiltInType::NULL => 'null', - BuiltInType::MIXED => 'unknown', - }; - } - - public function outputDefinition(): string - { - return $this->inputDefinition(); - } - public function brandName(): ?string { return $this->brand; diff --git a/src/Parser/Nodes/Leaf/DateTimeNode.php b/src/Parser/Nodes/Leaf/DateTimeNode.php index 951675b..f0d10de 100644 --- a/src/Parser/Nodes/Leaf/DateTimeNode.php +++ b/src/Parser/Nodes/Leaf/DateTimeNode.php @@ -96,13 +96,4 @@ public function serializeValue(mixed $value, ExecutionContext $context): string| return $value->format($this->format); } - public function inputDefinition(): string - { - return "string"; - } - - public function outputDefinition(): string - { - return "string"; - } } \ No newline at end of file diff --git a/src/Parser/Nodes/Leaf/EnumNode.php b/src/Parser/Nodes/Leaf/EnumNode.php index c23712e..a44afe5 100644 --- a/src/Parser/Nodes/Leaf/EnumNode.php +++ b/src/Parser/Nodes/Leaf/EnumNode.php @@ -75,26 +75,6 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed return $value->name; } - /** - * @throws JsonException - */ - public function inputDefinition(): string - { - $enumStrings = array_map( - fn(UnitEnum $enum) => json_encode($enum->name, flags: JSON_THROW_ON_ERROR), - $this->enumClassName::cases() - ); - return implode('|', $enumStrings); - } - - /** - * @throws JsonException - */ - public function outputDefinition(): string - { - return $this->inputDefinition(); - } - public function name(): string { return str_replace('\\', '_', $this->enumClassName); diff --git a/src/Parser/Nodes/Leaf/LiteralNode.php b/src/Parser/Nodes/Leaf/LiteralNode.php index 1779865..5fb5102 100644 --- a/src/Parser/Nodes/Leaf/LiteralNode.php +++ b/src/Parser/Nodes/Leaf/LiteralNode.php @@ -81,26 +81,6 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed return $value === $this->value ? $this->value : Value::INVALID; } - /** - * @throws JsonException - */ - public function inputDefinition(): string - { - return match ($this->type) { - LiteralType::BOOL => $this->value ? 'true' : 'false', - LiteralType::ENUM_CASE => json_encode($this->value->name, JSON_THROW_ON_ERROR), - default => json_encode($this->value, JSON_THROW_ON_ERROR), - }; - } - - /** - * @throws JsonException - */ - public function outputDefinition(): string - { - return $this->inputDefinition(); - } - public function coerce(mixed $value): mixed { return match ($this->type) { diff --git a/src/Parser/Nodes/Leaf/ValueObjectNode.php b/src/Parser/Nodes/Leaf/ValueObjectNode.php index 0205e67..888c39d 100644 --- a/src/Parser/Nodes/Leaf/ValueObjectNode.php +++ b/src/Parser/Nodes/Leaf/ValueObjectNode.php @@ -125,16 +125,6 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed } } - public function inputDefinition(): string - { - return $this->backingType === BuiltInType::STRING ? 'string' : 'number'; - } - - public function outputDefinition(): string - { - return $this->inputDefinition(); - } - public function brandName(): ?string { return $this->brand; diff --git a/src/Typescript/Data/Options.php b/src/Typescript/Data/Options.php index 16ee925..0a42df7 100644 --- a/src/Typescript/Data/Options.php +++ b/src/Typescript/Data/Options.php @@ -6,12 +6,16 @@ { /** * @param bool $pretty Break object literals across lines and space out separators. + * @param bool $ignoreBrandedTypes Emit branded leaves as their backing primitive and register no + * alias for them. The brand is a compile time only marker, so dropping it costs nothing at + * runtime and yields a type any TypeScript project can consume without the Brand helper. * @param TypeRegistry $registry Aliases already known to the caller, so several types can be * generated against one shared set. It is never mutated: generation works on a copy and * hands that copy back in TypeScript::$registry. */ public function __construct( public bool $pretty = false, + public bool $ignoreBrandedTypes = false, public TypeRegistry $registry = new TypeRegistry(), ) { diff --git a/src/Typescript/TypescriptGenerator.php b/src/Typescript/TypescriptGenerator.php index defee4d..a6a4492 100644 --- a/src/Typescript/TypescriptGenerator.php +++ b/src/Typescript/TypescriptGenerator.php @@ -124,6 +124,10 @@ private static function enum(EnumNode $node, EmissionContext $context): string */ private function brand(string $baseType, Branded $node, EmissionContext $context): string { + if ($context->options->ignoreBrandedTypes) { + return $baseType; + } + $brandName = $node->brandName(); if ($brandName === null || $brandName === '') { return $baseType; diff --git a/src/Typescript/Utils/Syntax.php b/src/Typescript/Utils/Syntax.php index 6c74ac7..6501758 100644 --- a/src/Typescript/Utils/Syntax.php +++ b/src/Typescript/Utils/Syntax.php @@ -5,9 +5,8 @@ /** * TypeScript syntax primitives. * - * Deliberately a copy of what CodeGen\Utils\Typescript does rather than a reuse of it: this - * package must not depend on CodeGen, and the old generator must stay byte-for-byte unchanged - * while both exist. + * Everything the generator needs to write is spelled out here, so this package depends on nothing + * outside itself and CodeGen depends on it rather than the other way round. */ final class Syntax { diff --git a/tests/Pest.php b/tests/Pest.php index fee29a8..11e8df9 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -11,8 +11,6 @@ | */ -use Le0daniel\PhpTsBindings\CodeGen\Data\DefinitionTarget; -use Le0daniel\PhpTsBindings\CodeGen\TypescriptDefinitionGenerator; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\Issue; @@ -24,6 +22,10 @@ use Le0daniel\PhpTsBindings\Parser\AstSorter; use Le0daniel\PhpTsBindings\Parser\AstValidator; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use Le0daniel\PhpTsBindings\Typescript\Data\Options; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; pest()->extend(Tests\TestCase::class)->in('Feature'); @@ -117,7 +119,18 @@ function compareToOptimizedAst(NodeInterface $node) { )->toEqual((string) $sortedNode); } -function typescriptDefinition(NodeInterface $node, DefinitionTarget $target): string +/** + * Generates TypeScript for a node and asserts the optimized AST generates exactly the same thing. + * + * Only the requested direction is checked: a schema can legitimately be unrepresentable one way + * round, and generating the other way would then throw instead of asserting. + * + * The parity check runs with brands ignored. Brands are code generation metadata with no runtime + * impact, so BuiltInNode and ValueObjectNode deliberately leave them out of exportPhpCode() — an + * optimized AST genuinely knows less about brands than the one the parser produced, and comparing + * them branded would assert something the optimizer never promised. + */ +function typescriptFor(NodeInterface $node, IO $io, Options $options = new Options()): TypeScript { $sortedNode = AstSorter::sort($node); $optimizer = new ASTOptimizer(); @@ -126,15 +139,13 @@ function typescriptDefinition(NodeInterface $node, DefinitionTarget $target): st /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); - $tsGenerator = new TypescriptDefinitionGenerator(); + $generator = new TypescriptGenerator(); + $unbranded = new Options(pretty: $options->pretty, ignoreBrandedTypes: true); - foreach (DefinitionTarget::cases() as $case) { - $expected = $tsGenerator->toDefinition($sortedNode, $case); - $optimized = $tsGenerator->toDefinition($registry->get('node'), $case); - expect($expected)->toEqual($optimized); - } + expect($generator->toTypescript($registry->get('node'), $io, $unbranded)->type) + ->toEqual($generator->toTypescript($sortedNode, $io, $unbranded)->type); - return $tsGenerator->toDefinition($sortedNode, $target); + return $generator->toTypescript($sortedNode, $io, $options); } function executeParse(NodeInterface|string $node, mixed $data, ParsingOptions $options = new ParsingOptions()): Success|Failure diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 255bf4a..256d2dc 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -9,10 +9,18 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use Le0daniel\PhpTsBindings\Typescript\Data\Options; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Mocks\ValueObjects\Email; use Tests\Mocks\ValueObjects\Slug; use Tests\Mocks\ValueObjects\UserId; +/** + * Mirrors how TypescriptServerCodeGenerator builds a TypedOperation: both directions collect their + * aliases into one registry, which is what EmitTypes reads. + */ function emitTypesFor(string $inputType, string $outputType): string { $parser = new TypeParser(); @@ -23,8 +31,12 @@ function emitTypesFor(string $inputType, string $outputType): string output: $parser->parse($outputType), ); + $generator = new TypescriptGenerator(); + $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, new Options(registry: new TypeRegistry())); + $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, new Options(registry: $input->registry)); + $files = new EmitTypes()->emitFiles( - [new TypedOperation('', '', '', $operation)], + [new TypedOperation($input->type, $output->type, '', $operation, $output->registry)], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), ); diff --git a/tests/Unit/CodeGen/Mocks/UnrepresentableOperations.php b/tests/Unit/CodeGen/Mocks/UnrepresentableOperations.php new file mode 100644 index 0000000..fbd8784 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/UnrepresentableOperations.php @@ -0,0 +1,23 @@ + $input->id > 0]; + } +} diff --git a/tests/Unit/CodeGen/Mocks/UserOperations.php b/tests/Unit/CodeGen/Mocks/UserOperations.php new file mode 100644 index 0000000..22264e5 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/UserOperations.php @@ -0,0 +1,39 @@ + Email::fromStringValue('user@example.com'), + 'slug' => Slug::fromStringValue("user-{$input['id']->toIntValue()}"), + ]; + } + + /** + * @param array{name: string} $input + * @return array{id: UserId} + */ + #[Command('users')] + public function create(array $input): array + { + return ['id' => UserId::fromIntValue(strlen($input['name']))]; + } +} diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php new file mode 100644 index 0000000..83ff168 --- /dev/null +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -0,0 +1,81 @@ + $classes + * @return array + */ +function generateFor(array $classes, Options $options = new Options()): array +{ + $server = new Server( + EagerlyLoadedRegistry::withClasses($classes, keyGenerator: new PlainlyExposedKeyGenerator()), + [], + ); + + return new TypescriptServerCodeGenerator( + [ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations(), + ], + options: $options, + )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); +} + +test('declares every referenced brand once in lib/types.ts', function () { + $files = generateFor([UserOperations::class]); + + expect($files)->toHaveKey('lib/types.ts') + ->and($files['lib/types.ts']->toString()) + ->toContain('export type CustomerId = number & Brand<"customerId">') + ->toContain('export type Email = string & Brand<"email">') + // Slug carries no #[Brand], so it registers no alias. + ->not->toContain('Slug'); +}); + +test('references brands by alias in the operation types and imports them', function () { + $files = generateFor([UserOperations::class]); + $operations = $files['users.ts']->toString(); + + expect($operations) + ->toContain('export type GetInput = {id:CustomerId;};') + ->toContain('export type GetResult = {email:Email;slug:string;};') + ->toContain('export type CreateInput = {name:string;};') + ->toContain('export type CreateResult = {id:CustomerId;};') + ->toContain("import {type CustomerId, type Email} from './lib/types';"); +}); + +test('emits the backing primitives and no type import when brands are ignored', function () { + $files = generateFor([UserOperations::class], new Options(ignoreBrandedTypes: true)); + $operations = $files['users.ts']->toString(); + + expect($operations) + ->toContain('export type GetInput = {id:number;};') + ->toContain('export type GetResult = {email:string;slug:string;};') + ->not->toContain("from './lib/types'") + ->and($files['lib/types.ts']->toString()) + ->not->toContain('export type CustomerId') + ->not->toContain('export type Email'); +}); + +test('fails the whole run when an operation input has no TypeScript representation', function () { + expect(fn() => generateFor([UnrepresentableOperations::class])) + ->toThrow(UnsupportedTypeException::class, 'SomeFileInterface'); +}); diff --git a/tests/Unit/CodeGen/Utils/TypescriptTest.php b/tests/Unit/CodeGen/Utils/TypescriptTest.php deleted file mode 100644 index 4e0b450..0000000 --- a/tests/Unit/CodeGen/Utils/TypescriptTest.php +++ /dev/null @@ -1,12 +0,0 @@ -toBe('foo'); - expect(Typescript::objectKey('foo', true))->toBe('foo?'); - expect(Typescript::objectKey('foo a'))->toBe('"foo a"'); - expect(Typescript::objectKey('foo a', true))->toBe('"foo a"?'); -}); \ No newline at end of file diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 521ae13..1122f37 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -2,8 +2,6 @@ namespace Tests\Unit\Parser; -use Le0daniel\PhpTsBindings\CodeGen\Data\DefinitionTarget; -use Le0daniel\PhpTsBindings\CodeGen\TypescriptDefinitionGenerator; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; @@ -24,6 +22,10 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use Le0daniel\PhpTsBindings\Typescript\Data\Options; +use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; +use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Le0daniel\PhpTsBindings\Validators\Email; use Tests\Feature\Mocks\Paginated; use Tests\Mocks\ResultEnum; @@ -581,9 +583,7 @@ compareToOptimizedAst($node); validateAst($node); - $typescriptGenerator = new TypescriptDefinitionGenerator(); - $definition = $typescriptGenerator->toDefinition($node, DefinitionTarget::OUTPUT); - expect($definition)->toBe('{items:Array<{id:string;}>;total:number;}'); + expect(typescriptFor($node, IO::OUTPUT)->type)->toBe('{items:Array<{id:string;}>;total:number;}'); }); test('Generics parsing with readonly output properties', function () { @@ -593,9 +593,9 @@ compareToOptimizedAst($node); validateAst($node); - $typescriptGenerator = new TypescriptDefinitionGenerator(); - $definition = $typescriptGenerator->toDefinition($node, DefinitionTarget::OUTPUT); - expect($definition)->toBe('{name:string;email:string;}'); + // Generated from the node as parsed, so the assertion pins declaration order. + expect(new TypescriptGenerator()->toTypescript($node, IO::OUTPUT)->type) + ->toBe('{name:string;email:string;}'); }); test('Do not cast in default mode', function () { @@ -605,12 +605,9 @@ compareToOptimizedAst($node); validateAst($node); - $typescriptGenerator = new TypescriptDefinitionGenerator(); - $inputDef = $typescriptGenerator->toDefinition($node, DefinitionTarget::INPUT); - $outputDef = $typescriptGenerator->toDefinition($node, DefinitionTarget::OUTPUT); - - expect($inputDef)->toBe('never'); - expect($outputDef)->toBe('{email:string;name:string;}'); + expect(typescriptFor($node, IO::OUTPUT)->type)->toBe('{email:string;name:string;}') + ->and(fn() => typescriptFor($node, IO::INPUT)) + ->toThrow(UnsupportedTypeException::class, UncastableClass::class); }); test('fails on missing or too many generics', function () { @@ -693,8 +690,7 @@ $node = $parser->parse($type); compareToOptimizedAst($node); - $outputDef = typescriptDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe($expectedDefinition); + expect(typescriptFor($node, IO::OUTPUT)->type)->toBe($expectedDefinition); })->with([ 'Simple Pick' => ['{name:string;}', 'Pick'], 'Simple Omit' => ['{id:string;}', 'Omit'], @@ -713,11 +709,9 @@ $node = $parser->parse(SomeFileInterface::class); compareToOptimizedAst($node); - $outputDef = typescriptDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe('{id:number;url:string;}'); - - $inputDef = typescriptDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('never'); + expect(typescriptFor($node, IO::OUTPUT)->type)->toBe('{id:number;url:string;}') + ->and(fn() => typescriptFor($node, IO::INPUT)) + ->toThrow(UnsupportedTypeException::class, SomeFileInterface::class); }); test("parse abstract class properties", function () { @@ -725,11 +719,9 @@ $node = $parser->parse(SomeAbstractClass::class); compareToOptimizedAst($node); - $outputDef = typescriptDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe('{email:string;id:number;}'); - - $inputDef = typescriptDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('never'); + expect(typescriptFor($node, IO::OUTPUT)->type)->toBe('{email:string;id:number;}') + ->and(fn() => typescriptFor($node, IO::INPUT)) + ->toThrow(UnsupportedTypeException::class, SomeAbstractClass::class); }); test("parse BrandedInt correctly", function () { @@ -738,19 +730,16 @@ $node = $parser->parse("BrandedInt<'wow'>"); compareToOptimizedAst($node); - $tsGenerator = new TypescriptDefinitionGenerator(true); - $outputDef = $tsGenerator->toDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe('number & Brand<"wow">'); - - $inputDef = $tsGenerator->toDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('number & Brand<"wow">'); - - $tsGeneratorWithoutBrand = new TypescriptDefinitionGenerator(false); - $outputDef = $tsGeneratorWithoutBrand->toDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe('number'); + foreach ([IO::INPUT, IO::OUTPUT] as $io) { + $branded = typescriptFor($node, $io); + expect($branded->type)->toBe('Wow') + ->and($branded->registry->toArray())->toBe(['Wow' => 'number & Brand<"wow">']) + ->and($branded->toStandaloneType())->toBe('number & Brand<"wow">'); - $inputDef = $tsGeneratorWithoutBrand->toDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('number'); + $unbranded = typescriptFor($node, $io, new Options(ignoreBrandedTypes: true)); + expect($unbranded->type)->toBe('number') + ->and($unbranded->registry->isEmpty())->toBeTrue(); + } }); test("parse BrandedString correctly", function () { @@ -759,20 +748,16 @@ $node = $parser->parse("BrandedString<'wow'>"); compareToOptimizedAst($node); - $tsGenerator = new TypescriptDefinitionGenerator(true); - - $outputDef = $tsGenerator->toDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe('string & Brand<"wow">'); - - $inputDef = $tsGenerator->toDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('string & Brand<"wow">'); + foreach ([IO::INPUT, IO::OUTPUT] as $io) { + $branded = typescriptFor($node, $io); + expect($branded->type)->toBe('Wow') + ->and($branded->registry->toArray())->toBe(['Wow' => 'string & Brand<"wow">']) + ->and($branded->toStandaloneType())->toBe('string & Brand<"wow">'); - $tsGeneratorWithoutBrand = new TypescriptDefinitionGenerator(false); - $outputDef = $tsGeneratorWithoutBrand->toDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe('string'); - - $inputDef = $tsGeneratorWithoutBrand->toDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('string'); + $unbranded = typescriptFor($node, $io, new Options(ignoreBrandedTypes: true)); + expect($unbranded->type)->toBe('string') + ->and($unbranded->registry->isEmpty())->toBeTrue(); + } }); test('DateTimeString without a format defaults to ATOM', function () { @@ -831,8 +816,8 @@ $parser = new TypeParser(); $node = $parser->parse("DateTimeString<'Y-m-d'>"); - expect(typescriptDefinition($node, DefinitionTarget::INPUT))->toBe('string') - ->and(typescriptDefinition($node, DefinitionTarget::OUTPUT))->toBe('string'); + expect(typescriptFor($node, IO::INPUT)->type)->toBe('string') + ->and(typescriptFor($node, IO::OUTPUT)->type)->toBe('string'); }); test('DateTimeString composes with other types', function (string $type, string $expectedDefinition) { @@ -840,7 +825,7 @@ $node = $parser->parse($type); compareToOptimizedAst($node); - expect(typescriptDefinition($node, DefinitionTarget::OUTPUT))->toBe($expectedDefinition); + expect(typescriptFor($node, IO::OUTPUT)->type)->toBe($expectedDefinition); })->with([ 'nullable' => ["DateTimeString<'Y-m-d'>|null", 'string|null'], 'questionmark nullable' => ["?DateTimeString<'Y-m-d'>", 'null|string'], @@ -900,7 +885,7 @@ test('Quoted keys with spaces emit valid Typescript', function () { $node = new TypeParser()->parse('array{"key something else": string}'); - expect(typescriptDefinition($node, DefinitionTarget::OUTPUT)) + expect(typescriptFor($node, IO::OUTPUT)->type) ->toBe('{"key something else":string;}'); }); diff --git a/tests/Unit/Parser/ValueObjectConsumerTest.php b/tests/Unit/Parser/ValueObjectConsumerTest.php index b94f449..6c67af5 100644 --- a/tests/Unit/Parser/ValueObjectConsumerTest.php +++ b/tests/Unit/Parser/ValueObjectConsumerTest.php @@ -1,7 +1,5 @@ toBeInstanceOf(ValueObjectNode::class) ->and($node->className)->toBe(Email::class) ->and($node->backingType)->toBe(BuiltInType::STRING) - ->and($node->brand)->toBe('email') - ->and($node->inputDefinition())->toBe('string') - ->and($node->outputDefinition())->toBe('string'); + ->and($node->brand)->toBe('email'); compareToOptimizedAst($node); validateAst($node); @@ -39,9 +37,7 @@ expect($node)->toBeInstanceOf(ValueObjectNode::class) ->and($node->className)->toBe(UserId::class) ->and($node->backingType)->toBe(BuiltInType::INT) - ->and($node->brand)->toBe('customerId') - ->and($node->inputDefinition())->toBe('number') - ->and($node->outputDefinition())->toBe('number'); + ->and($node->brand)->toBe('customerId'); compareToOptimizedAst($node); validateAst($node); @@ -133,29 +129,37 @@ test('value objects emit their backing primitive when brands are disabled', function () { $node = new TypeParser()->parse(Email::class); + $options = new Options(ignoreBrandedTypes: true); - expect(typescriptDefinition($node, DefinitionTarget::OUTPUT))->toBe('string'); - expect(typescriptDefinition($node, DefinitionTarget::INPUT))->toBe('string'); + expect(typescriptFor($node, IO::OUTPUT, $options)->type)->toBe('string'); + expect(typescriptFor($node, IO::INPUT, $options)->type)->toBe('string'); }); -test('value objects emit branded types when brands are enabled', function (string $type, string $expected) { +test('value objects emit branded types when brands are enabled', function (string $type, string $alias, string $expected) { $node = new TypeParser()->parse($type); - $generator = new TypescriptDefinitionGenerator(true); - expect($generator->toDefinition($node, DefinitionTarget::INPUT))->toBe($expected); - expect($generator->toDefinition($node, DefinitionTarget::OUTPUT))->toBe($expected); + foreach ([IO::INPUT, IO::OUTPUT] as $io) { + // The use site carries the alias, the definition travels in the registry, and inlining the + // one into the other reproduces the full branded type. + expect(typescriptFor($node, $io)->type)->toBe($alias); + expect(typescriptFor($node, $io)->toStandaloneType())->toBe($expected); + } })->with([ - 'string vo' => [Email::class, 'string & Brand<"email">'], - 'int vo renamed' => [UserId::class, 'number & Brand<"customerId">'], - 'unbranded vo' => [Slug::class, 'string'], + 'string vo' => [Email::class, 'Email', 'string & Brand<"email">'], + 'int vo renamed' => [UserId::class, 'CustomerId', 'number & Brand<"customerId">'], + 'unbranded vo' => [Slug::class, 'string', 'string'], ]); test('a castable class carrying value object properties', function () { $node = new TypeParser()->parse(CreateAccountInput::class); expect($node)->toBeInstanceOf(CustomCastingNode::class); - expect(typescriptDefinition($node, DefinitionTarget::INPUT))->toBe('{email:string;ownerId:number;}'); - expect(typescriptDefinition($node, DefinitionTarget::OUTPUT))->toBe('{email:string;ownerId:number;}'); + + foreach ([IO::INPUT, IO::OUTPUT] as $io) { + expect(typescriptFor($node, $io)->type)->toBe('{email:Email;ownerId:CustomerId;}'); + expect(typescriptFor($node, $io, new Options(ignoreBrandedTypes: true))->type) + ->toBe('{email:string;ownerId:number;}'); + } compareToOptimizedAst($node); }); diff --git a/tests/Unit/Definition/TypescriptDefinitionTest.php b/tests/Unit/Typescript/OptimizedAstTest.php similarity index 70% rename from tests/Unit/Definition/TypescriptDefinitionTest.php rename to tests/Unit/Typescript/OptimizedAstTest.php index 26fd213..25b258f 100644 --- a/tests/Unit/Definition/TypescriptDefinitionTest.php +++ b/tests/Unit/Typescript/OptimizedAstTest.php @@ -1,16 +1,20 @@ -parse($typeString); @@ -20,13 +24,13 @@ function toDefinition(string $typeString, ?DefinitionTarget $mode = null): strin /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); - $definitionWriter = new TypescriptDefinitionGenerator(); + $generator = new TypescriptGenerator(); /** @var string|null $definition */ $definition = null; - foreach ($modes as $mode) { - $realDef = $definitionWriter->toDefinition($ast, $mode); - $optimizedDef = $definitionWriter->toDefinition($registry->get('node'), $mode); + foreach ($directions as $direction) { + $realDef = $generator->toTypescript($ast, $direction)->type; + $optimizedDef = $generator->toTypescript($registry->get('node'), $direction)->type; expect($realDef)->toEqual($optimizedDef); $definition ??= $realDef; expect($definition)->toEqual($realDef); @@ -58,12 +62,12 @@ function toDefinition(string $typeString, ?DefinitionTarget $mode = null): strin }); test('Custom class type input', function () { - expect(toDefinition(UserSchema::class, DefinitionTarget::INPUT)) + expect(toDefinition(UserSchema::class, IO::INPUT)) ->toBe("{age:number;email:string;username:string;}"); }); test('Custom class type output', function () { - expect(toDefinition(UserSchema::class, DefinitionTarget::OUTPUT)) + expect(toDefinition(UserSchema::class, IO::OUTPUT)) ->toBe("{age:number;username:string;}"); }); @@ -78,8 +82,7 @@ function toDefinition(string $typeString, ?DefinitionTarget $mode = null): strin }); test('Complex union intersection', function () { - expect(toDefinition('((array{id: positive-int}|array{token: string})&array{reason: string})|' . UserSchema::class, DefinitionTarget::INPUT)) + expect(toDefinition('((array{id: positive-int}|array{token: string})&array{reason: string})|' . UserSchema::class, IO::INPUT)) ->toBe("(({id:number;}|{token:string;})&{reason:string;})|{age:number;email:string;username:string;}"); }); }); - diff --git a/tests/Unit/Typescript/TypescriptGeneratorTest.php b/tests/Unit/Typescript/TypescriptGeneratorTest.php index 0e619b3..f98392b 100644 --- a/tests/Unit/Typescript/TypescriptGeneratorTest.php +++ b/tests/Unit/Typescript/TypescriptGeneratorTest.php @@ -266,6 +266,35 @@ function typescriptOfBoth(string|NodeInterface $type): string ->toThrow(UnsupportedTypeException::class, 'Email'); }); +test('emits the backing primitive when brands are ignored', function (string $type, string $expectedType) { + $result = typescriptOf($type, IO::INPUT, new Options(ignoreBrandedTypes: true)); + + expect($result->type)->toBe($expectedType) + ->and($result->registry->isEmpty())->toBeTrue(); +})->with([ + 'string value object' => ['\\' . Email::class, 'string'], + 'int value object' => ['\\' . UserId::class, 'number'], + 'unbranded value object' => ['\\' . Slug::class, 'string'], + 'BrandedString' => ["BrandedString<'token'>", 'string'], + 'BrandedInt' => ["BrandedInt<'wow'>", 'number'], + 'branded and unbranded mixed in a struct' => [ + 'array{email: \\' . Email::class . ', slug: \\' . Slug::class . ', id: \\' . UserId::class . '}', + '{email:string;slug:string;id:number;}', + ], +]); + +test('ignoring brands neither reads nor extends an incoming registry', function () { + $shared = new TypeRegistry(['Email' => 'number & Brand<"email">']); + + // The seeded definition contradicts what Email would otherwise register, so this would throw + // if the alias were still computed. + $result = typescriptOf('\\' . Email::class, IO::INPUT, new Options(ignoreBrandedTypes: true, registry: $shared)); + + expect($result->type)->toBe('string') + ->and($result->registry->toArray())->toBe(['Email' => 'number & Brand<"email">']) + ->and($shared->toArray())->toBe(['Email' => 'number & Brand<"email">']); +}); + test('filters struct properties by direction', function () { $type = '\\' . UserSchema::class; diff --git a/tests/Unit/Typescript/Utils/SyntaxTest.php b/tests/Unit/Typescript/Utils/SyntaxTest.php new file mode 100644 index 0000000..955c349 --- /dev/null +++ b/tests/Unit/Typescript/Utils/SyntaxTest.php @@ -0,0 +1,12 @@ +toBe('foo'); + expect(Syntax::objectKey('foo', true))->toBe('foo?'); + expect(Syntax::objectKey('foo a'))->toBe('"foo a"'); + expect(Syntax::objectKey('foo a', true))->toBe('"foo a"?'); +}); From 4065c108ffc0b19071b0f00cf872c57d1cfccac6 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 11:25:45 +0200 Subject: [PATCH 009/101] Refactor `ArrayConsumer` to remove unnecessary array type check and update dependencies in `composer.lock`. --- composer.json | 6 +- composer.lock | 1741 +++++++++++++----------- src/Parser/Consumers/ArrayConsumer.php | 2 +- 3 files changed, 957 insertions(+), 792 deletions(-) diff --git a/composer.json b/composer.json index 26dae57..925e5fd 100644 --- a/composer.json +++ b/composer.json @@ -6,9 +6,9 @@ "php": "^8.5" }, "require-dev": { - "pestphp/pest": "4.x-dev", - "phpstan/phpstan": "2.1.x-dev", - "laravel/framework": "^11|^12", + "pestphp/pest": "^v4.7.0", + "phpstan/phpstan": "^2.1", + "laravel/framework": "^13", "mockery/mockery": "^1.6", "phpstan/phpstan-strict-rules": "^2.0" }, diff --git a/composer.lock b/composer.lock index abd684f..aad59ce 100644 --- a/composer.lock +++ b/composer.lock @@ -4,21 +4,21 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5f0ba66c901786eb445a74b3ccb09f50", + "content-hash": "6d5c07534be4b25cb6bd3067aad1b73f", "packages": [], "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.16.1", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -29,24 +29,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.4", - "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.7 || ^8.0.7", + "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.33", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.11", - "phpstan/phpstan-strict-rules": "^2.0.7", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, "bin": [ "bin/paratest", @@ -86,7 +86,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -98,27 +98,26 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "brick/math", - "version": "0.14.1", + "version": "0.18.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", "phpstan/phpstan": "2.1.22", "phpunit/phpunit": "^11.5" }, @@ -150,7 +149,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.1" + "source": "https://github.com/brick/math/tree/0.18.0" }, "funding": [ { @@ -158,7 +157,7 @@ "type": "github" } ], - "time": "2025-11-24T14:40:29+00:00" + "time": "2026-06-14T18:21:03+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -229,6 +228,148 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -306,29 +447,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -348,9 +489,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/inflector", @@ -917,25 +1058,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.15.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "744101956d78b7c1384d0cbf379db13e859167bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", + "reference": "744101956d78b7c1384d0cbf379db13e859167bf", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -943,9 +1085,10 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -1023,7 +1166,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.15.2" }, "funding": [ { @@ -1039,28 +1182,29 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-07-26T23:23:20+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -1106,7 +1250,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -1122,27 +1266,29 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1150,8 +1296,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1222,7 +1369,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -1238,29 +1385,29 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.5", + "version": "v1.0.10", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "uri-template/tests": "1.0.0" }, "type": "library", @@ -1308,7 +1455,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" }, "funding": [ { @@ -1324,7 +1471,7 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:27:06+00:00" + "time": "2026-07-17T13:53:03+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -1439,24 +1586,24 @@ }, { "name": "laravel/framework", - "version": "v12.46.0", + "version": "v13.23.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "9dcff48d25a632c1fadb713024c952fec489c4ae" + "reference": "92a707229148e57f08a249211c8a5a194159c619" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/9dcff48d25a632c1fadb713024c952fec489c4ae", - "reference": "9dcff48d25a632c1fadb713024c952fec489c4ae", + "url": "https://api.github.com/repos/laravel/framework/zipball/92a707229148e57f08a249211c8a5a194159c619", + "reference": "92a707229148e57f08a249211c8a5a194159c619", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", - "egulias/email-validator": "^3.2.1|^4.0", + "egulias/email-validator": "^4.0", "ext-ctype": "*", "ext-filter": "*", "ext-hash": "*", @@ -1466,35 +1613,36 @@ "ext-tokenizer": "*", "fruitcake/php-cors": "^1.3", "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/promises": "^2.0.3", "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", - "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.7", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", - "monolog/monolog": "^3.0", + "monolog/monolog": "^3.10", "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", - "php": "^8.2", - "psr/container": "^1.1.1|^2.0.1", - "psr/log": "^1.0|^2.0|^3.0", - "psr/simple-cache": "^1.0|^2.0|^3.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^7.2.0", - "symfony/error-handler": "^7.2.0", - "symfony/finder": "^7.2.0", - "symfony/http-foundation": "^7.2.0", - "symfony/http-kernel": "^7.2.0", - "symfony/mailer": "^7.2.0", - "symfony/mime": "^7.2.0", - "symfony/polyfill-php83": "^1.33", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33", - "symfony/process": "^7.2.0", - "symfony/routing": "^7.2.0", - "symfony/uid": "^7.2.0", - "symfony/var-dumper": "^7.2.0", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", "tijsverkoyen/css-to-inline-styles": "^2.2.5", "vlucas/phpdotenv": "^5.6.1", "voku/portable-ascii": "^2.0.2" @@ -1503,9 +1651,9 @@ "tightenco/collect": "<5.5.33" }, "provide": { - "psr/container-implementation": "1.1|2.0", - "psr/log-implementation": "1.0|2.0|3.0", - "psr/simple-cache-implementation": "1.0|2.0|3.0" + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" }, "replace": { "illuminate/auth": "self.version", @@ -1526,6 +1674,7 @@ "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", + "illuminate/image": "self.version", "illuminate/json-schema": "self.version", "illuminate/log": "self.version", "illuminate/macroable": "self.version", @@ -1551,8 +1700,8 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/promises": "^2.0.3", - "guzzlehttp/psr7": "^2.4", + "guzzlehttp/psr7": "^2.9", + "intervention/image": "^4.0", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -1561,22 +1710,23 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.8.1", - "pda/pheanstalk": "^5.0.6|^7.0.0", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", - "predis/predis": "^2.3|^3.0", - "resend/resend-php": "^0.10.0|^1.0", - "symfony/cache": "^7.2.0", - "symfony/http-client": "^7.2.0", - "symfony/psr-http-message-bridge": "^7.2.0", - "symfony/translation": "^7.2.0" + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "^2.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", - "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", "ext-apcu": "Required to use the APC cache driver.", "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", @@ -1585,9 +1735,10 @@ "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", - "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.0).", "laravel/tinker": "Required to use the tinker console command (^2.0).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", @@ -1595,24 +1746,25 @@ "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", "mockery/mockery": "Required to use mocking (^1.6).", - "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", - "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "12.x-dev" + "dev-master": "13.0.x-dev" } }, "autoload": { @@ -1657,34 +1809,34 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-07T23:26:53+00:00" + "time": "2026-07-27T14:48:58+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.8", + "version": "v0.3.21", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "096748cdfb81988f60090bbb839ce3205ace0d35" + "reference": "7753c65c281c2550c7c183f14e18062073b7d821" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/096748cdfb81988f60090bbb839ce3205ace0d35", - "reference": "096748cdfb81988f60090bbb839ce3205ace0d35", + "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -1714,33 +1866,33 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.8" + "source": "https://github.com/laravel/prompts/tree/v0.3.21" }, - "time": "2025-11-21T20:52:52+00:00" + "time": "2026-06-26T00:11:25+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.7", + "version": "v2.0.15", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -1777,20 +1929,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-11-21T20:52:36+00:00" + "time": "2026-07-21T16:49:22+00:00" }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", "shasum": "" }, "require": { @@ -1812,12 +1964,12 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -1884,7 +2036,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-07-12T15:29:16+00:00" }, { "name": "league/config", @@ -1970,16 +2122,16 @@ }, { "name": "league/flysystem", - "version": "3.30.2", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" + "reference": "b277b5dc3d56650b68904117124e79c851e12376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", - "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", "shasum": "" }, "require": { @@ -2047,22 +2199,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" }, - "time": "2025-11-10T17:13:11+00:00" + "time": "2026-07-06T14:42:07+00:00" }, { "name": "league/flysystem-local", - "version": "3.30.2", + "version": "3.31.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", - "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", "shasum": "" }, "require": { @@ -2096,22 +2248,22 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" }, - "time": "2025-11-10T11:23:37+00:00" + "time": "2026-01-23T15:30:45+00:00" }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -2121,7 +2273,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -2142,7 +2294,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -2154,24 +2306,24 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "league/uri", - "version": "7.7.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.7", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -2185,11 +2337,11 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "ext-uri": "to use the PHP native URI class", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", - "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2244,7 +2396,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.7.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -2252,20 +2404,20 @@ "type": "github" } ], - "time": "2025-12-07T16:02:06+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.7.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -2278,7 +2430,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2328,7 +2480,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -2336,7 +2488,7 @@ "type": "github" } ], - "time": "2025-12-07T16:03:21+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "mockery/mockery", @@ -2586,16 +2738,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.0", + "version": "3.13.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1" + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", "shasum": "" }, "require": { @@ -2619,7 +2771,7 @@ "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan": "^2.1.22", "phpunit/phpunit": "^10.5.53", - "squizlabs/php_codesniffer": "^3.13.4" + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, "bin": [ "bin/carbon" @@ -2662,14 +2814,14 @@ } ], "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ "date", "datetime", "time" ], "support": { - "docs": "https://carbon.nesbot.com/docs", + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", "issues": "https://github.com/CarbonPHP/carbon/issues", "source": "https://github.com/CarbonPHP/carbon" }, @@ -2687,20 +2839,20 @@ "type": "tidelift" } ], - "time": "2025-12-02T21:04:28+00:00" + "time": "2026-07-09T18:23:49+00:00" }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "shasum": "" }, "require": { @@ -2708,8 +2860,10 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -2750,22 +2904,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.5" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-23T03:47:12+00:00" }, { "name": "nette/utils", - "version": "v4.1.1", + "version": "v4.1.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", "shasum": "" }, "require": { @@ -2777,13 +2931,15 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", "ext-json": "to use Nette\\Utils\\Json", "ext-mbstring": "to use Strings::lower() etc...", @@ -2839,26 +2995,25 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.1" + "source": "https://github.com/nette/utils/tree/v4.1.5" }, - "time": "2025-12-22T12:14:32+00:00" + "time": "2026-07-17T23:02:45+00:00" }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -2897,45 +3052,42 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.5", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.14 || ^8.1.1" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" }, "type": "library", "extra": { @@ -2998,35 +3150,35 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-07-15T19:09:14+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3058,7 +3210,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -3069,7 +3221,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -3085,47 +3237,48 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "pestphp/pest", - "version": "4.x-dev", + "version": "v4.7.5", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96" + "reference": "5dc49a71d63602a9b98fed0f2017c4679ef9f8e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/bc57a84e77afd4544ff9643a6858f68d05aeab96", - "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96", + "url": "https://api.github.com/repos/pestphp/pest/zipball/5dc49a71d63602a9b98fed0f2017c4679ef9f8e0", + "reference": "5dc49a71d63602a9b98fed0f2017c4679ef9f8e0", "shasum": "" }, "require": { - "brianium/paratest": "^7.16.0", - "nunomaduro/collision": "^8.8.3", - "nunomaduro/termwind": "^2.3.3", + "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", - "pestphp/pest-plugin-arch": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.4", - "symfony/process": "^7.4.3|^8.0.0" + "phpunit/phpunit": "^12.5.30", + "symfony/process": "^7.4.13|^8.1.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.4", + "phpunit/phpunit": ">12.5.30", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-browser": "^4.1.1", - "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.18" + "mrpunyapal/peststan": "^0.2.11", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "psy/psysh": "^0.12.24" }, - "default-branch": true, "bin": [ "bin/pest" ], @@ -3151,6 +3304,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, @@ -3190,7 +3344,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/4.x" + "source": "https://github.com/pestphp/pest/tree/v4.7.5" }, "funding": [ { @@ -3202,7 +3356,7 @@ "type": "github" } ], - "time": "2026-01-04T16:29:59+00:00" + "time": "2026-07-06T17:06:29+00:00" }, { "name": "pestphp/pest-plugin", @@ -3276,26 +3430,26 @@ }, { "name": "pestphp/pest-plugin-arch", - "version": "v4.0.0", + "version": "v4.0.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-arch.git", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d" + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", "shasum": "" }, "require": { "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "ta-tikoma/phpunit-architecture-test": "^0.8.5" + "ta-tikoma/phpunit-architecture-test": "^0.8.7" }, "require-dev": { - "pestphp/pest": "^4.0.0", - "pestphp/pest-dev-tools": "^4.0.0" + "pestphp/pest": "^4.4.6", + "pestphp/pest-dev-tools": "^4.1.0" }, "type": "library", "extra": { @@ -3330,7 +3484,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2" }, "funding": [ { @@ -3342,7 +3496,7 @@ "type": "github" } ], - "time": "2025-08-20T13:10:51+00:00" + "time": "2026-04-10T17:20:19+00:00" }, { "name": "pestphp/pest-plugin-mutate", @@ -3649,16 +3803,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -3666,8 +3820,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -3677,7 +3831,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -3707,44 +3862,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -3765,9 +3920,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpoption/phpoption", @@ -3846,16 +4001,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.1", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/16dbf9937da8d4528ceb2145c9c7c0bd29e26374", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -3887,17 +4042,17 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-12T11:33:04+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.x-dev", + "version": "2.2.6", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/595438dfba0d853736c333b661e342f53402dd76", - "reference": "595438dfba0d853736c333b661e342f53402dd76", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "reference": "a6e9b5a9420f6109c091e87d82683bd1a80b87ed", "shasum": "" }, "require": { @@ -3906,7 +4061,6 @@ "conflict": { "phpstan/phpstan-shim": "*" }, - "default-branch": true, "bin": [ "phpstan", "phpstan.phar" @@ -3921,6 +4075,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -3943,31 +4108,32 @@ "type": "github" } ], - "time": "2026-01-12T14:26:54+00:00" + "time": "2026-07-26T21:22:49+00:00" }, { "name": "phpstan/phpstan-strict-rules", - "version": "2.0.7", + "version": "2.0.12", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538" + "reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/d6211c46213d4181054b3d77b10a5c5cb0d59538", - "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/2bc5ae19ae965663b62ac907ee6342c3903ec93b", + "reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.29" + "phpstan/phpstan": "^2.1.52" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" }, "type": "phpstan-extension", "extra": { @@ -3987,24 +4153,27 @@ "MIT" ], "description": "Extra strict and opinionated rules for PHPStan", + "keywords": [ + "static analysis" + ], "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.7" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.12" }, - "time": "2025-09-26T11:19:08+00:00" + "time": "2026-07-19T07:24:06+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { @@ -4013,16 +4182,15 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", "sebastian/version": "^6.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -4060,7 +4228,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { @@ -4080,20 +4248,20 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -4133,15 +4301,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -4329,16 +4509,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.4", + "version": "12.5.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a" + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a", - "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/900400a5b616d6fb306f9549f6da33ba615d3fbb", + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb", "shasum": "" }, "require": { @@ -4352,19 +4532,20 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.1", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.3", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", "sebastian/object-enumerator": "^7.0.0", - "sebastian/type": "^6.0.3", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -4406,31 +4587,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.4" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.30" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2025-12-15T06:05:34+00:00" + "time": "2026-06-15T13:12:30+00:00" }, { "name": "psr/clock", @@ -4966,20 +5131,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -5038,29 +5203,29 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2025-12-14T04:43:48+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -5089,7 +5254,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -5109,20 +5274,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.3", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/dc904b4bb3ab070865fa4068cd84f3da8b945148", - "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -5130,10 +5295,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -5181,7 +5346,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -5201,7 +5366,7 @@ "type": "tidelift" } ], - "time": "2025-08-20T11:27:00+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -5330,23 +5495,23 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.26" }, "suggest": { "ext-posix": "*" @@ -5354,7 +5519,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -5382,7 +5547,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { @@ -5402,29 +5567,29 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -5472,7 +5637,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -5492,30 +5657,30 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { "php": ">=8.3", "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { @@ -5546,7 +5711,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -5566,28 +5731,28 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -5616,15 +5781,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -5818,23 +5995,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -5863,7 +6040,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -5883,7 +6060,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -5993,20 +6170,20 @@ }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/clock": "^1.0" }, "provide": { @@ -6046,7 +6223,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v8.1.0" }, "funding": [ { @@ -6066,51 +6243,53 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/console", - "version": "v7.4.3", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" + "symfony/string": "^7.4.6|^8.0.6" }, "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6144,7 +6323,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.3" + "source": "https://github.com/symfony/console/tree/v8.1.1" }, "funding": [ { @@ -6164,24 +6343,24 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-06-16T12:55:20+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.0", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -6213,7 +6392,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" }, "funding": [ { @@ -6233,20 +6412,20 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -6259,7 +6438,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -6284,7 +6463,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -6295,42 +6474,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.0", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/var-dumper": "^7.4|^8.0" }, "conflict": { - "symfony/deprecation-contracts": "<2.5", - "symfony/http-kernel": "<6.4" + "symfony/deprecation-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -6362,7 +6544,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.0" + "source": "https://github.com/symfony/error-handler/tree/v8.1.0" }, "funding": [ { @@ -6382,24 +6564,25 @@ "type": "tidelift" } ], - "time": "2025-11-05T14:29:59+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "573f95783a2ec6e38752979db139f09fec033f03" + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/573f95783a2ec6e38752979db139f09fec033f03", - "reference": "573f95783a2ec6e38752979db139f09fec033f03", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -6447,7 +6630,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" }, "funding": [ { @@ -6467,20 +6650,20 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-06-09T12:28:30+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -6494,7 +6677,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -6527,7 +6710,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -6538,32 +6721,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/finder", - "version": "v7.4.3", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06" + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06", + "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6591,7 +6778,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.3" + "source": "https://github.com/symfony/finder/tree/v8.1.1" }, "funding": [ { @@ -6611,41 +6798,40 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-06-27T09:05:56+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.3", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" + "reference": "6a168c8fcee806b57ac020244da14293d1f9a883" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6a168c8fcee806b57ac020244da14293d1f9a883", + "reference": "6a168c8fcee806b57ac020244da14293d1f9a883", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "doctrine/dbal": "<3.6", - "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + "doctrine/dbal": "<4.3" }, "require-dev": { - "doctrine/dbal": "^3.6|^4", + "doctrine/dbal": "^4.3", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6673,7 +6859,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.1" }, "funding": [ { @@ -6693,78 +6879,68 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:23:49+00:00" + "time": "2026-06-12T08:43:41+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.3", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "885211d4bed3f857b8c964011923528a55702aa5" + "reference": "89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", - "reference": "885211d4bed3f857b8c964011923528a55702aa5", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2", + "reference": "89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<6.4", - "symfony/cache": "<6.4", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<6.4", + "symfony/dependency-injection": "<8.1", "symfony/flex": "<2.10", - "symfony/form": "<6.4", - "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<6.4", - "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.4", - "twig/twig": "<3.12" + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^7.1|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.1|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" }, "type": "library", "autoload": { @@ -6792,7 +6968,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.1" }, "funding": [ { @@ -6812,43 +6988,39 @@ "type": "tidelift" } ], - "time": "2025-12-31T08:43:57+00:00" + "time": "2026-06-27T09:27:36+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.3", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" + "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", + "url": "https://api.github.com/repos/symfony/mailer/zipball/4fa583a7377f28d54e4de442fba76375b2e20a12", + "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", + "php": ">=8.4.1", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/mime": "^7.2|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" + "symfony/http-client-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/twig-bridge": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6876,7 +7048,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.3" + "source": "https://github.com/symfony/mailer/tree/v8.1.1" }, "funding": [ { @@ -6896,44 +7068,41 @@ "type": "tidelift" } ], - "time": "2025-12-16T08:02:06+00:00" + "time": "2026-06-16T12:55:20+00:00" }, { "name": "symfony/mime", - "version": "v7.4.0", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", + "url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<6.4", - "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6965,7 +7134,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.0" + "source": "https://github.com/symfony/mime/tree/v8.1.0" }, "funding": [ { @@ -6985,20 +7154,20 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:14:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -7048,7 +7217,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -7068,20 +7237,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -7130,7 +7299,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -7150,20 +7319,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -7217,7 +7386,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -7237,20 +7406,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -7302,7 +7471,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -7322,20 +7491,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -7387,7 +7556,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -7407,20 +7576,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -7471,7 +7640,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -7491,20 +7660,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -7522,7 +7691,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, "classmap": [ "Resources/stubs" @@ -7542,7 +7711,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -7551,7 +7720,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -7571,20 +7740,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "name": "symfony/polyfill-php85", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -7602,7 +7771,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -7622,7 +7791,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -7631,7 +7800,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -7651,20 +7820,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "name": "symfony/polyfill-php86", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", "shasum": "" }, "require": { @@ -7682,7 +7851,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" + "Symfony\\Polyfill\\Php86\\": "" }, "classmap": [ "Resources/stubs" @@ -7702,7 +7871,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -7711,7 +7880,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" }, "funding": [ { @@ -7731,20 +7900,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-07-02T13:42:24+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -7794,7 +7963,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -7814,24 +7983,24 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v7.4.3", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -7859,7 +8028,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.3" + "source": "https://github.com/symfony/process/tree/v8.1.0" }, "funding": [ { @@ -7879,38 +8048,33 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/routing", - "version": "v7.4.3", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, - "conflict": { - "symfony/config": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/yaml": "<6.4" - }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -7944,7 +8108,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.3" + "source": "https://github.com/symfony/routing/tree/v8.1.0" }, "funding": [ { @@ -7964,20 +8128,20 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -7995,7 +8159,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8031,7 +8195,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -8051,24 +8215,24 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v8.0.1", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -8121,7 +8285,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.1" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -8141,24 +8305,24 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation", - "version": "v8.0.3", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d" + "reference": "342b4218630dc2cf284cedcb2080c80b13404014" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d", - "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d", + "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0", "symfony/translation-contracts": "^3.6.1" }, @@ -8214,7 +8378,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.3" + "source": "https://github.com/symfony/translation/tree/v8.1.1" }, "funding": [ { @@ -8234,20 +8398,20 @@ "type": "tidelift" } ], - "time": "2025-12-21T10:59:45+00:00" + "time": "2026-06-06T11:11:44+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -8260,7 +8424,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8296,7 +8460,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -8316,28 +8480,28 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", - "version": "v7.4.0", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", + "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -8374,7 +8538,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.0" + "source": "https://github.com/symfony/uid/tree/v8.1.0" }, "funding": [ { @@ -8394,35 +8558,35 @@ "type": "tidelift" } ], - "time": "2025-09-25T11:02:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.3", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92" + "reference": "40096a2515a979f3125c5c928603995b8664c62a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/40096a2515a979f3125c5c928603995b8664c62a", + "reference": "40096a2515a979f3125c5c928603995b8664c62a", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -8461,7 +8625,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.1" }, "funding": [ { @@ -8481,28 +8645,28 @@ "type": "tidelift" } ], - "time": "2025-12-18T07:04:31+00:00" + "time": "2026-06-09T10:54:51+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.5", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "cf6fb197b676ba716837c886baca842e4db29005" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/cf6fb197b676ba716837c886baca842e4db29005", - "reference": "cf6fb197b676ba716837c886baca842e4db29005", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", - "phpdocumentor/reflection-docblock": "^5.3.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", - "symfony/finder": "^6.4.0 || ^7.0.0" + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { "laravel/pint": "^1.13.7", @@ -8538,9 +8702,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.5" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2025-04-20T20:23:40+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", @@ -8649,16 +8813,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.4", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { @@ -8717,7 +8881,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { @@ -8729,27 +8893,27 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-07-06T19:11:50+00:00" }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -8779,7 +8943,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -8803,20 +8967,20 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" }, { "name": "webmozart/assert", - "version": "2.1.1", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "bdbabc199a7ba9965484e4725d66170e5711323b" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/bdbabc199a7ba9965484e4725d66170e5711323b", - "reference": "bdbabc199a7ba9965484e4725d66170e5711323b", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -8832,7 +8996,11 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { + "dev-master": "2.0-dev", "dev-feature/2-0": "2.0-dev" } }, @@ -8863,21 +9031,18 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.1" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-01-08T11:28:40+00:00" + "time": "2026-06-15T15:31:57+00:00" } ], "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "pestphp/pest": 20, - "phpstan/phpstan": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.4" + "php": "^8.5" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/src/Parser/Consumers/ArrayConsumer.php b/src/Parser/Consumers/ArrayConsumer.php index 6f67ad4..a2927a8 100644 --- a/src/Parser/Consumers/ArrayConsumer.php +++ b/src/Parser/Consumers/ArrayConsumer.php @@ -52,7 +52,7 @@ public function consume(ParserState $state, TypeParser $parser): RecordNode|List ? null : $state->context->toFullyQualifiedClassName($state->current()->value); - if (!$state->current()->is(TokenType::IDENTIFIER) || !in_array($type, ['array', 'list'], true)) { + if (!$state->current()->is(TokenType::IDENTIFIER)) { $state->produceSyntaxError("Expected Array Type Identifier: array or list"); } From 2ac8e280602bd567de1c8792d95bcffe54c72613 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 15:09:12 +0200 Subject: [PATCH 010/101] Fixed hashing --- src/Server/KeyGenerators/HashSha256KeyGenerator.php | 8 ++++---- src/Utils/Hashs.php | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Server/KeyGenerators/HashSha256KeyGenerator.php b/src/Server/KeyGenerators/HashSha256KeyGenerator.php index 4e59bad..5e998fd 100644 --- a/src/Server/KeyGenerators/HashSha256KeyGenerator.php +++ b/src/Server/KeyGenerators/HashSha256KeyGenerator.php @@ -6,12 +6,12 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Utils\Hashs; -final class HashSha256KeyGenerator implements OperationKeyGenerator +final readonly class HashSha256KeyGenerator implements OperationKeyGenerator { public function __construct( - private readonly string $pepper, - private readonly int $namespaceLength = 8, - private readonly int $fnNameLength = 24, + private string $pepper, + private int $namespaceLength = 8, + private int $fnNameLength = 24, ) { } diff --git a/src/Utils/Hashs.php b/src/Utils/Hashs.php index 8f7b5cd..f417f08 100644 --- a/src/Utils/Hashs.php +++ b/src/Utils/Hashs.php @@ -7,9 +7,10 @@ final class Hashs public static function base64UrlEncodedSha256(string $message): string { - $hash = hash('sha256', $message, true); - $encoded = base64_encode($hash); - return rtrim(strtr(base64_encode($encoded), '+/', '-_'), '='); + return hash('sha256', $message, true) + |> base64_encode(...) + |> (fn($x) => strtr($x, '+/', '-_')) + |> (fn($x) => rtrim($x, '=')); } } \ No newline at end of file From a54bdeb932eb856b168a89707a3d1f557b5f8e18 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 15:27:23 +0200 Subject: [PATCH 011/101] Refactor built-in types by replacing `BuiltInNode` and `BuiltInType` with dedicated nodes (`BoolNode`, `FloatNode`, `IntNode`, `MixedNode`, `NullNode`, `StringNode`) and associated `BackingType` enum; update consumers, tests, and utilities accordingly. --- src/Parser/Consumers/ArrayConsumer.php | 17 +- src/Parser/Consumers/BuiltInLeafConsumer.php | 42 ++--- src/Parser/Consumers/IntConsumer.php | 7 +- src/Parser/Consumers/UtilsConsumer.php | 15 +- src/Parser/Consumers/ValueObjectConsumer.php | 4 +- src/Parser/Nodes/Data/BackingType.php | 12 ++ src/Parser/Nodes/Data/BuiltInType.php | 18 -- src/Parser/Nodes/Leaf/BoolNode.php | 43 +++++ src/Parser/Nodes/Leaf/BuiltInNode.php | 128 -------------- src/Parser/Nodes/Leaf/FloatNode.php | 45 +++++ src/Parser/Nodes/Leaf/IntNode.php | 57 +++++++ src/Parser/Nodes/Leaf/MixedNode.php | 31 ++++ src/Parser/Nodes/Leaf/NullNode.php | 33 ++++ src/Parser/Nodes/Leaf/RejectsInvalidType.php | 22 +++ src/Parser/Nodes/Leaf/StringNode.php | 70 ++++++++ src/Parser/Nodes/Leaf/ValueObjectNode.php | 27 ++- src/Parser/Nodes/UnionNode.php | 5 +- src/Parser/TypeParser.php | 5 +- src/Typescript/TypescriptGenerator.php | 36 ++-- tests/Pest.php | 6 +- tests/Unit/Parser/TypeParserTest.php | 158 ++++++++---------- tests/Unit/Parser/ValueObjectConsumerTest.php | 8 +- .../Typescript/TypescriptGeneratorTest.php | 9 +- tests/Unit/Utils/NodesTest.php | 19 +-- 24 files changed, 480 insertions(+), 337 deletions(-) create mode 100644 src/Parser/Nodes/Data/BackingType.php delete mode 100644 src/Parser/Nodes/Data/BuiltInType.php create mode 100644 src/Parser/Nodes/Leaf/BoolNode.php delete mode 100644 src/Parser/Nodes/Leaf/BuiltInNode.php create mode 100644 src/Parser/Nodes/Leaf/FloatNode.php create mode 100644 src/Parser/Nodes/Leaf/IntNode.php create mode 100644 src/Parser/Nodes/Leaf/MixedNode.php create mode 100644 src/Parser/Nodes/Leaf/NullNode.php create mode 100644 src/Parser/Nodes/Leaf/RejectsInvalidType.php create mode 100644 src/Parser/Nodes/Leaf/StringNode.php diff --git a/src/Parser/Consumers/ArrayConsumer.php b/src/Parser/Consumers/ArrayConsumer.php index a2927a8..e331325 100644 --- a/src/Parser/Consumers/ArrayConsumer.php +++ b/src/Parser/Consumers/ArrayConsumer.php @@ -7,9 +7,10 @@ use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\MixedNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; @@ -78,7 +79,7 @@ public function consume(ParserState $state, TypeParser $parser): RecordNode|List // No generics if (!$state->currentTokenIs(TokenType::LT)) { - return new ListNode(new BuiltInNode(BuiltInType::MIXED)); + return new ListNode(new MixedNode()); } $generics = $this->consumeGenerics($state, $parser, min: 1, max: $maxGenerics); @@ -88,13 +89,9 @@ public function consume(ParserState $state, TypeParser $parser): RecordNode|List } $keyType = $generics[0]; - if (!$keyType instanceof BuiltInNode) { - $state->produceSyntaxError("Array key type must be 'string' or 'int'. Got: {$keyType}"); - } - - $node = match ($keyType->type) { - BuiltInType::STRING => new RecordNode($generics[1]), - BuiltInType::INT => new ListNode($generics[1]), + $node = match (true) { + $keyType instanceof StringNode => new RecordNode($generics[1]), + $keyType instanceof IntNode => new ListNode($generics[1]), default => $state->produceSyntaxError("Array key type must be 'string' or 'int'. Got: {$keyType}"), }; diff --git a/src/Parser/Consumers/BuiltInLeafConsumer.php b/src/Parser/Consumers/BuiltInLeafConsumer.php index bec5155..4b2d493 100644 --- a/src/Parser/Consumers/BuiltInLeafConsumer.php +++ b/src/Parser/Consumers/BuiltInLeafConsumer.php @@ -8,8 +8,12 @@ use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BoolNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\FloatNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\MixedNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Validators\LengthValidator; @@ -52,45 +56,45 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); return match ($token->value) { - 'string', - 'bool', - 'null', - 'float', - 'mixed' => new BuiltInNode(BuiltInType::from($token->value)), + 'string' => new StringNode(), + 'bool' => new BoolNode(), + 'null' => new NullNode(), + 'float' => new FloatNode(), + 'mixed' => new MixedNode(), 'truthy-string', 'non-falsy-string' => new ConstraintNode( - new BuiltInNode(BuiltInType::STRING), + new StringNode(), [new NonFalsyStringValidator()], ), 'non-empty-string' => new ConstraintNode( - new BuiltInNode(BuiltInType::STRING), + new StringNode(), [new NonEmptyString()], ), 'scalar' => new UnionNode([ - new BuiltInNode(BuiltInType::INT), - new BuiltInNode(BuiltInType::FLOAT), - new BuiltInNode(BuiltInType::BOOL), - new BuiltInNode(BuiltInType::STRING), + new IntNode(), + new FloatNode(), + new BoolNode(), + new StringNode(), ]), 'positive-int' => new ConstraintNode( - new BuiltInNode(BuiltInType::INT), + new IntNode(), [new LengthValidator(min: 1, including: true)] ), 'negative-int' => new ConstraintNode( - new BuiltInNode(BuiltInType::INT), + new IntNode(), [new LengthValidator(max: -1, including: true)] ), "non-negative-int" => new ConstraintNode( - new BuiltInNode(BuiltInType::INT), + new IntNode(), [new LengthValidator(min: 0, including: true)] ), 'non-positive-int' => new ConstraintNode( - new BuiltInNode(BuiltInType::INT), + new IntNode(), [new LengthValidator(max: 0, including: true)] ), 'numeric' => new UnionNode([ - new BuiltInNode(BuiltInType::INT), - new BuiltInNode(BuiltInType::FLOAT), + new IntNode(), + new FloatNode(), ]), default => $state->produceSyntaxError('Expected valid built-in type, got ' . $token->value), }; diff --git a/src/Parser/Consumers/IntConsumer.php b/src/Parser/Consumers/IntConsumer.php index 74167d2..45733b9 100644 --- a/src/Parser/Consumers/IntConsumer.php +++ b/src/Parser/Consumers/IntConsumer.php @@ -9,8 +9,7 @@ use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Validators\LengthValidator; @@ -29,7 +28,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); if (!$state->currentTokenIs(TokenType::LT)) { - return new BuiltInNode(BuiltInType::INT); + return new IntNode(); } $state->advance(); @@ -59,7 +58,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); return new ConstraintNode( - new BuiltInNode(BuiltInType::INT), + new IntNode(), [new LengthValidator(min: $min, max: $max, including: true)] ); } diff --git a/src/Parser/Consumers/UtilsConsumer.php b/src/Parser/Consumers/UtilsConsumer.php index a75a8ad..ccaef64 100644 --- a/src/Parser/Consumers/UtilsConsumer.php +++ b/src/Parser/Consumers/UtilsConsumer.php @@ -9,13 +9,13 @@ use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\PropertyType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\DateTimeNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; @@ -53,14 +53,11 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface if ($type === 'BrandedString' || $type === 'BrandedInt') { [$literalNode] = $this->consumeGenerics($state, $parser, 1, 1); + $brand = $this->literalStringValue($state, $literalNode, 'branded type'); - return new BuiltInNode( - match ($type) { - 'BrandedString' => BuiltInType::STRING, - 'BrandedInt' => BuiltInType::INT, - }, - brand: $this->literalStringValue($state, $literalNode, 'branded type'), - ); + return $type === 'BrandedString' + ? new StringNode(brand: $brand) + : new IntNode(brand: $brand); } [$nodeToPickFrom, $pick] = $this->consumeGenerics($state, $parser, 2, 2); diff --git a/src/Parser/Consumers/ValueObjectConsumer.php b/src/Parser/Consumers/ValueObjectConsumer.php index 77544af..066cd9a 100644 --- a/src/Parser/Consumers/ValueObjectConsumer.php +++ b/src/Parser/Consumers/ValueObjectConsumer.php @@ -9,7 +9,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; +use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BackingType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\ValueObjectNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\AttributesReflector; @@ -65,7 +65,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface /** @var class-string $fullyQualifiedClassName */ return new ValueObjectNode( $fullyQualifiedClassName, - $isStringBacked ? BuiltInType::STRING : BuiltInType::INT, + $isStringBacked ? BackingType::STRING : BackingType::INT, $this->resolveBrand($reflectionClass), ); } diff --git a/src/Parser/Nodes/Data/BackingType.php b/src/Parser/Nodes/Data/BackingType.php new file mode 100644 index 0000000..8fcd08a --- /dev/null +++ b/src/Parser/Nodes/Data/BackingType.php @@ -0,0 +1,12 @@ +invalidType('bool', $value, $context); + } + + public function serializeValue(mixed $value, ExecutionContext $context): mixed + { + return is_bool($value) ? $value : $this->invalidType('bool', $value, $context); + } + + public function coerce(mixed $value): mixed + { + return match ($value) { + 'true', '1' => true, + 'false', '0' => false, + default => $value, + }; + } +} diff --git a/src/Parser/Nodes/Leaf/BuiltInNode.php b/src/Parser/Nodes/Leaf/BuiltInNode.php deleted file mode 100644 index eee6b91..0000000 --- a/src/Parser/Nodes/Leaf/BuiltInNode.php +++ /dev/null @@ -1,128 +0,0 @@ -type->value; - } - - public function exportPhpCode(): string - { - $className = PHPExport::absolute(BuiltInNode::class); - $type = PHPExport::exportEnumCase($this->type); - return "new {$className}($type)"; - } - - public function parseValue(mixed $value, ExecutionContext $context): mixed - { - $result = match ($this->type) { - BuiltInType::STRING => is_string($value) ? $value : Value::INVALID, - BuiltInType::INT => is_int($value) ? $value : Value::INVALID, - BuiltInType::BOOL => is_bool($value) ? $value : Value::INVALID, - BuiltInType::NULL => is_null($value) ? $value : Value::INVALID, - BuiltInType::FLOAT => is_float($value) || is_int($value) ? $value : Value::INVALID, - BuiltInType::MIXED => $value, - }; - - if ($result === Value::INVALID) { - $context->addIssue(new Issue( - IssueMessage::INVALID_TYPE, - [ - 'message' => "Expected value of type {$this->type->value}, got: " . gettype($value), - ] - )); - return Value::INVALID; - } - - return $result; - } - - public function serializeValue(mixed $value, ExecutionContext $context): mixed - { - try { - $value = match ($this->type) { - BuiltInType::STRING => is_string($value) || $value instanceof Stringable - ? (string) $value - : Value::INVALID, - BuiltInType::INT => is_int($value) - ? $value - : Value::INVALID, - BuiltInType::BOOL => is_bool($value) - ? $value - : Value::INVALID, - BuiltInType::NULL => is_null($value) - ? null - : Value::INVALID, - BuiltInType::FLOAT => is_numeric($value) - ? (float) $value - : Value::INVALID, - BuiltInType::MIXED => $value, - }; - - if ($value === Value::INVALID) { - $context->addIssue(new Issue( - IssueMessage::INVALID_TYPE, - [ - 'message' => "Expected value of type {$this->type->value}, got: " . gettype($value), - ] - )); - } - return $value; - } catch (Throwable $throwable) { - $context->addIssue(Issue::fromThrowable($throwable, [ - 'node' => self::class, - 'message' => "Failed to serialize value of type: " . gettype($value), - 'value' => $value, - ])); - return Value::INVALID; - } - } - - public function brandName(): ?string - { - return $this->brand; - } - - public function coerce(mixed $value): mixed - { - return match ($this->type) { - BuiltInType::STRING => (string) $value, - BuiltInType::INT => filter_var($value, FILTER_VALIDATE_INT) !== false - ? (int) $value - : $value, - BuiltInType::BOOL => match ($value) { - 'true', '1' => true, - 'false', '0' => false, - default => $value, - }, - BuiltInType::FLOAT => filter_var($value, FILTER_VALIDATE_INT) !== false || filter_var($value, FILTER_VALIDATE_FLOAT) !== false - ? (float) $value - : $value, - default => $value - }; - } -} \ No newline at end of file diff --git a/src/Parser/Nodes/Leaf/FloatNode.php b/src/Parser/Nodes/Leaf/FloatNode.php new file mode 100644 index 0000000..ab1b027 --- /dev/null +++ b/src/Parser/Nodes/Leaf/FloatNode.php @@ -0,0 +1,45 @@ +invalidType('float', $value, $context); + } + + public function serializeValue(mixed $value, ExecutionContext $context): mixed + { + return is_numeric($value) + ? (float) $value + : $this->invalidType('float', $value, $context); + } + + public function coerce(mixed $value): mixed + { + return filter_var($value, FILTER_VALIDATE_INT) !== false || filter_var($value, FILTER_VALIDATE_FLOAT) !== false + ? (float) $value + : $value; + } +} diff --git a/src/Parser/Nodes/Leaf/IntNode.php b/src/Parser/Nodes/Leaf/IntNode.php new file mode 100644 index 0000000..0e6496a --- /dev/null +++ b/src/Parser/Nodes/Leaf/IntNode.php @@ -0,0 +1,57 @@ +invalidType('int', $value, $context); + } + + public function serializeValue(mixed $value, ExecutionContext $context): mixed + { + return is_int($value) ? $value : $this->invalidType('int', $value, $context); + } + + public function brandName(): ?string + { + return $this->brand; + } + + public function coerce(mixed $value): mixed + { + return filter_var($value, FILTER_VALIDATE_INT) !== false + ? (int) $value + : $value; + } +} diff --git a/src/Parser/Nodes/Leaf/MixedNode.php b/src/Parser/Nodes/Leaf/MixedNode.php new file mode 100644 index 0000000..985f763 --- /dev/null +++ b/src/Parser/Nodes/Leaf/MixedNode.php @@ -0,0 +1,31 @@ +invalidType('null', $value, $context); + } + + public function serializeValue(mixed $value, ExecutionContext $context): mixed + { + return is_null($value) ? null : $this->invalidType('null', $value, $context); + } +} diff --git a/src/Parser/Nodes/Leaf/RejectsInvalidType.php b/src/Parser/Nodes/Leaf/RejectsInvalidType.php new file mode 100644 index 0000000..5a65d79 --- /dev/null +++ b/src/Parser/Nodes/Leaf/RejectsInvalidType.php @@ -0,0 +1,22 @@ +addIssue(new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => "Expected value of type {$expected}, got: " . gettype($value), + ], + )); + return Value::INVALID; + } +} diff --git a/src/Parser/Nodes/Leaf/StringNode.php b/src/Parser/Nodes/Leaf/StringNode.php new file mode 100644 index 0000000..1f458d9 --- /dev/null +++ b/src/Parser/Nodes/Leaf/StringNode.php @@ -0,0 +1,70 @@ +invalidType('string', $value, $context); + } + + public function serializeValue(mixed $value, ExecutionContext $context): mixed + { + try { + return is_string($value) || $value instanceof Stringable + ? (string) $value + : $this->invalidType('string', $value, $context); + } catch (Throwable $throwable) { + $context->addIssue(Issue::fromThrowable($throwable, [ + 'node' => self::class, + 'message' => "Failed to serialize value of type: " . gettype($value), + 'value' => $value, + ])); + return Value::INVALID; + } + } + + public function brandName(): ?string + { + return $this->brand; + } + + public function coerce(mixed $value): mixed + { + return (string) $value; + } +} diff --git a/src/Parser/Nodes/Leaf/ValueObjectNode.php b/src/Parser/Nodes/Leaf/ValueObjectNode.php index 888c39d..8599840 100644 --- a/src/Parser/Nodes/Leaf/ValueObjectNode.php +++ b/src/Parser/Nodes/Leaf/ValueObjectNode.php @@ -2,7 +2,6 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes\Leaf; -use InvalidArgumentException; use Le0daniel\PhpTsBindings\Contracts\Branded; use Le0daniel\PhpTsBindings\Contracts\Coercible; use Le0daniel\PhpTsBindings\Contracts\LeafNode; @@ -13,7 +12,7 @@ use Le0daniel\PhpTsBindings\Executor\Contracts\ExecutionContext; use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; +use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BackingType; use Le0daniel\PhpTsBindings\Utils\PHPExport; use Throwable; @@ -28,25 +27,19 @@ { /** * @param class-string $className - * @param BuiltInType $backingType Must be BuiltInType::STRING or BuiltInType::INT. */ public function __construct( public string $className, - public BuiltInType $backingType, + public BackingType $backingType, public ?string $brand = null, ) { - if ($this->backingType !== BuiltInType::STRING && $this->backingType !== BuiltInType::INT) { - throw new InvalidArgumentException( - "Value objects can only be backed by string or int, got: {$this->backingType->value}" - ); - } } /** - * The brand is deliberately excluded here, exactly as in BuiltInNode. It is code generation - * metadata with no runtime impact, and the class name alone already identifies this node - * uniquely for the ASTOptimizer dedupe hash. + * The brand is deliberately excluded here, exactly as in StringNode and IntNode. It is code + * generation metadata with no runtime impact, and the class name alone already identifies + * this node uniquely for the ASTOptimizer dedupe hash. */ public function __toString(): string { @@ -65,7 +58,7 @@ public function exportPhpCode(): string public function parseValue(mixed $value, ExecutionContext $context): mixed { - if ($this->backingType === BuiltInType::STRING) { + if ($this->backingType === BackingType::STRING) { if (!is_string($value)) { $context->addIssue($this->invalidBackingTypeIssue($value)); return Value::INVALID; @@ -98,7 +91,7 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed public function serializeValue(mixed $value, ExecutionContext $context): mixed { - if ($this->backingType === BuiltInType::STRING) { + if ($this->backingType === BackingType::STRING) { if (!$value instanceof StringValueObject || !is_a($value, $this->className)) { $context->addIssue($this->notAnInstanceIssue($value)); return Value::INVALID; @@ -132,8 +125,8 @@ public function brandName(): ?string public function coerce(mixed $value): mixed { - if ($this->backingType === BuiltInType::STRING) { - // Unlike BuiltInNode, only scalars are cast: (string) on an array or a non + if ($this->backingType === BackingType::STRING) { + // Unlike StringNode, only scalars are cast: (string) on an array or a non // Stringable object throws, and coerce() runs outside the executor's try/catch. return is_scalar($value) ? (string)$value : $value; } @@ -185,7 +178,7 @@ private function notAnInstanceIssue(mixed $value): Issue /** * On the serialize path the value came from the server, so a throwing accessor is a genuine - * internal error. This mirrors BuiltInNode::serializeValue(). + * internal error. This mirrors StringNode::serializeValue(). */ private function failedToSerializeIssue(Throwable $throwable): Issue { diff --git a/src/Parser/Nodes/UnionNode.php b/src/Parser/Nodes/UnionNode.php index ae06f83..1b573fe 100644 --- a/src/Parser/Nodes/UnionNode.php +++ b/src/Parser/Nodes/UnionNode.php @@ -4,8 +4,7 @@ use InvalidArgumentException; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; use Le0daniel\PhpTsBindings\Utils\PHPExport; /** @@ -18,7 +17,7 @@ final class UnionNode implements NodeInterface // Improves the performance of nullable Unions. public function acceptsNull(): bool { - return $this->acceptsNull ??= array_any($this->types, fn(NodeInterface $type) => $type instanceof BuiltInNode && $type->type === BuiltInType::NULL); + return $this->acceptsNull ??= array_any($this->types, fn(NodeInterface $type) => $type instanceof NullNode); } /** diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 153e82e..8443fd2 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -23,10 +23,9 @@ use Le0daniel\PhpTsBindings\Parser\Lexer\Lexer; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; @@ -155,7 +154,7 @@ public function consume(ParserState $state, TokenType ...$stopAt): NodeInterface if ($state->currentTokenIs(TokenType::QUESTION_MARK)) { $state->advance(); - $types[] = new BuiltInNode(BuiltInType::NULL); + $types[] = new NullNode(); $mode = 'questionmark-union'; } diff --git a/src/Typescript/TypescriptGenerator.php b/src/Typescript/TypescriptGenerator.php index a6a4492..8e4526a 100644 --- a/src/Typescript/TypescriptGenerator.php +++ b/src/Typescript/TypescriptGenerator.php @@ -6,14 +6,19 @@ use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; +use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BackingType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BoolNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\DateTimeNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\EnumNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\FloatNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\MixedNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\ValueObjectNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; @@ -55,8 +60,20 @@ public function toTypescript(NodeInterface $node, IO $io, Options $options = new private function emit(NodeInterface $node, EmissionContext $context, int $depth): string { return match (true) { - $node instanceof BuiltInNode => $this->brand(self::builtIn($node->type), $node, $context), - $node instanceof ValueObjectNode => $this->brand(self::builtIn($node->backingType), $node, $context), + $node instanceof StringNode => $this->brand('string', $node, $context), + $node instanceof IntNode => $this->brand('number', $node, $context), + $node instanceof FloatNode => 'number', + $node instanceof BoolNode => 'boolean', + $node instanceof NullNode => 'null', + $node instanceof MixedNode => 'unknown', + $node instanceof ValueObjectNode => $this->brand( + match ($node->backingType) { + BackingType::STRING => 'string', + BackingType::INT => 'number', + }, + $node, + $context, + ), $node instanceof LiteralNode => self::literal($node), $node instanceof EnumNode => self::enum($node, $context), $node instanceof DateTimeNode => 'string', @@ -78,17 +95,6 @@ private function emit(NodeInterface $node, EmissionContext $context, int $depth) }; } - private static function builtIn(BuiltInType $type): string - { - return match ($type) { - BuiltInType::STRING => 'string', - BuiltInType::INT, BuiltInType::FLOAT => 'number', - BuiltInType::BOOL => 'boolean', - BuiltInType::NULL => 'null', - BuiltInType::MIXED => 'unknown', - }; - } - private static function literal(LiteralNode $node): string { return match ($node->type) { diff --git a/tests/Pest.php b/tests/Pest.php index 11e8df9..29dd65c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -126,9 +126,9 @@ function compareToOptimizedAst(NodeInterface $node) { * round, and generating the other way would then throw instead of asserting. * * The parity check runs with brands ignored. Brands are code generation metadata with no runtime - * impact, so BuiltInNode and ValueObjectNode deliberately leave them out of exportPhpCode() — an - * optimized AST genuinely knows less about brands than the one the parser produced, and comparing - * them branded would assert something the optimizer never promised. + * impact, so StringNode, IntNode and ValueObjectNode deliberately leave them out of + * exportPhpCode() — an optimized AST genuinely knows less about brands than the one the parser + * produced, and comparing them branded would assert something the optimizer never promised. */ function typescriptFor(NodeInterface $node, IO $io, Options $options = new Options()): TypeScript { diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 1122f37..2ca6b90 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -7,15 +7,18 @@ use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\PropertyType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BoolNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\DateTimeNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\FloatNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; @@ -88,8 +91,7 @@ $node = $parser->parse('?'.FullAccount::class); expect($node)->toBeInstanceOf(UnionNode::class) - ->and($node->types[0])->toBeInstanceOf(BuiltInNode::class) - ->and($node->types[0]->type)->toEqual(BuiltInType::NULL) + ->and($node->types[0])->toBeInstanceOf(NullNode::class) ->and($node->types[1])->toBeInstanceOf(CustomCastingNode::class) ->and($node->types[1]->node)->toBeInstanceOf(StructNode::class) ->and($node->types[1]->node->phpType)->toEqual(StructPhpType::ARRAY) @@ -103,16 +105,12 @@ $node = $parser->parse("scalar"); expect($node)->toBeInstanceOf(UnionNode::class); - /** - * @var int $index - * @var BuiltInNode $type - */ foreach ($node->types as $index => $type) { match ($index) { - 0 => expect($type->type)->toEqual(BuiltInType::INT), - 1 => expect($type->type)->toEqual(BuiltInType::FLOAT), - 2 => expect($type->type)->toEqual(BuiltInType::BOOL), - 3 => expect($type->type)->toEqual(BuiltInType::STRING), + 0 => expect($type)->toBeInstanceOf(IntNode::class), + 1 => expect($type)->toBeInstanceOf(FloatNode::class), + 2 => expect($type)->toBeInstanceOf(BoolNode::class), + 3 => expect($type)->toBeInstanceOf(StringNode::class), }; } @@ -126,11 +124,8 @@ expect($node)->toBeInstanceOf(UnionNode::class); - expect($node->types[0])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[0]->type)->toBe(BuiltInType::NULL); - - expect($node->types[1])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[1]->type)->toBe(BuiltInType::FLOAT); + expect($node->types[0])->toBeInstanceOf(NullNode::class); + expect($node->types[1])->toBeInstanceOf(FloatNode::class); compareToOptimizedAst($node); }); @@ -149,34 +144,27 @@ expect($node)->toBeInstanceOf(UnionNode::class); - expect($node->types[0])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[0]->type)->toBe(BuiltInType::NULL); - expect($node->types[1])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[1]->type)->toBe(BuiltInType::FLOAT); - expect($node->types[2])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[2]->type)->toBe(BuiltInType::STRING); + expect($node->types[0])->toBeInstanceOf(NullNode::class); + expect($node->types[1])->toBeInstanceOf(FloatNode::class); + expect($node->types[2])->toBeInstanceOf(StringNode::class); compareToOptimizedAst($node); }); test('float', function () { $parser = new TypeParser(); - /** @var BuiltInNode $node */ $node = $parser->parse("float"); - expect($node)->toBeInstanceOf(BuiltInNode::class); - expect($node->type)->toEqual(BuiltInType::FLOAT); + expect($node)->toBeInstanceOf(FloatNode::class); compareToOptimizedAst($node); }); test('int', function () { $parser = new TypeParser(); - /** @var BuiltInNode $node */ $node = $parser->parse("int"); - expect($node)->toBeInstanceOf(BuiltInNode::class); - expect($node->type)->toEqual(BuiltInType::INT); + expect($node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -190,8 +178,7 @@ ->and($node->constraints[0]->min)->toBe(0) ->and($node->constraints[0]->max)->toBe(100) ->and($node->constraints[0]->including)->toBe(true) - ->and($node->node)->toBeInstanceOf(BuiltInNode::class) - ->and($node->node->type)->toEqual(BuiltInType::INT); + ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -205,8 +192,7 @@ ->and($node->constraints[0]->min)->toBe(PHP_INT_MIN) ->and($node->constraints[0]->max)->toBe(100) ->and($node->constraints[0]->including)->toBe(true) - ->and($node->node)->toBeInstanceOf(BuiltInNode::class) - ->and($node->node->type)->toEqual(BuiltInType::INT); + ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -220,8 +206,7 @@ ->and($node->constraints[0]->min)->toBe(-1) ->and($node->constraints[0]->max)->toBe(PHP_INT_MAX) ->and($node->constraints[0]->including)->toBe(true) - ->and($node->node)->toBeInstanceOf(BuiltInNode::class) - ->and($node->node->type)->toEqual(BuiltInType::INT); + ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -235,8 +220,7 @@ ->and($node->constraints[0]->min)->toBe(-100) ->and($node->constraints[0]->max)->toBe(-3) ->and($node->constraints[0]->including)->toBe(true) - ->and($node->node)->toBeInstanceOf(BuiltInNode::class) - ->and($node->node->type)->toEqual(BuiltInType::INT); + ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -246,14 +230,10 @@ /** @var UnionNode $node */ $node = $parser->parse("numeric"); - /** - * @var int $index - * @var BuiltInNode $type - */ foreach ($node->types as $index => $type) { match ($index) { - 0 => expect($type->type)->toEqual(BuiltInType::INT), - 1 => expect($type->type)->toEqual(BuiltInType::FLOAT), + 0 => expect($type)->toBeInstanceOf(IntNode::class), + 1 => expect($type)->toBeInstanceOf(FloatNode::class), }; } @@ -264,7 +244,7 @@ $parser = new TypeParser( TypeParser::defaultConsumers(new GlobalTypeAliases([ 'Email' => fn() => new ConstraintNode( - new BuiltInNode(BuiltInType::STRING), + new StringNode(), [new Email()], ), ])) @@ -285,8 +265,7 @@ $node = $parser->parse("positive-int"); expect($node)->toBeInstanceOf(ConstraintNode::class); - expect($node->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -317,8 +296,7 @@ $node = $parser->parse("non-negative-int"); expect($node)->toBeInstanceOf(ConstraintNode::class); - expect($node->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -329,8 +307,7 @@ $node = $parser->parse("non-positive-int"); expect($node)->toBeInstanceOf(ConstraintNode::class); - expect($node->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -341,8 +318,7 @@ $node = $parser->parse("negative-int"); expect($node)->toBeInstanceOf(ConstraintNode::class); - expect($node->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -354,11 +330,8 @@ expect($node)->toBeInstanceOf(StructNode::class); expect($node->phpType)->toEqual(StructPhpType::OBJECT); - expect($node->getProperty('a')->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->getProperty('a')->node->type)->toEqual(BuiltInType::STRING); - - expect($node->getProperty('b')->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->getProperty('b')->node->type)->toEqual(BuiltInType::INT); + expect($node->getProperty('a')->node)->toBeInstanceOf(StringNode::class); + expect($node->getProperty('b')->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -370,11 +343,8 @@ expect($node)->toBeInstanceOf(StructNode::class); expect($node->phpType)->toEqual(StructPhpType::ARRAY); - expect($node->getProperty('a')->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->getProperty('a')->node->type)->toEqual(BuiltInType::STRING); - - expect($node->getProperty('b')->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->getProperty('b')->node->type)->toEqual(BuiltInType::INT); + expect($node->getProperty('a')->node)->toBeInstanceOf(StringNode::class); + expect($node->getProperty('b')->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -385,11 +355,8 @@ $node = $parser->parse("array{string, int}"); expect($node)->toBeInstanceOf(TupleNode::class); - expect($node->types[0])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[0]->type)->toEqual(BuiltInType::STRING); - - expect($node->types[1])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[1]->type)->toEqual(BuiltInType::INT); + expect($node->types[0])->toBeInstanceOf(StringNode::class); + expect($node->types[1])->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -400,11 +367,8 @@ $node = $parser->parse("array{0:string, 1: int}"); expect($node)->toBeInstanceOf(TupleNode::class); - expect($node->types[0])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[0]->type)->toEqual(BuiltInType::STRING); - - expect($node->types[1])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[1]->type)->toEqual(BuiltInType::INT); + expect($node->types[0])->toBeInstanceOf(StringNode::class); + expect($node->types[1])->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -415,8 +379,7 @@ $node = $parser->parse("array"); expect($node)->toBeInstanceOf(ListNode::class); - expect($node->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::STRING); + expect($node->node)->toBeInstanceOf(StringNode::class); compareToOptimizedAst($node); }); @@ -427,8 +390,7 @@ $node = $parser->parse("string[]"); expect($node)->toBeInstanceOf(ListNode::class); - expect($node->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::STRING); + expect($node->node)->toBeInstanceOf(StringNode::class); compareToOptimizedAst($node); }); @@ -441,11 +403,8 @@ expect($node->node)->toBeInstanceOf(UnionNode::class); - expect($node->node->types[0])->toBeInstanceOf(BuiltInNode::class); - expect($node->node->types[0]->type)->toBe(BuiltInType::STRING); - - expect($node->node->types[1])->toBeInstanceOf(BuiltInNode::class); - expect($node->node->types[1]->type)->toBe(BuiltInType::INT); + expect($node->node->types[0])->toBeInstanceOf(StringNode::class); + expect($node->node->types[1])->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -456,8 +415,7 @@ $node = $parser->parse("array"); expect($node)->toBeInstanceOf(RecordNode::class); - expect($node->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -760,6 +718,33 @@ } }); +test('brands are code generation metadata and stay out of the string form and exported php code', function () { + $parser = new TypeParser(); + $string = $parser->parse("BrandedString<'wow'>"); + $int = $parser->parse("BrandedInt<'wow'>"); + + expect($string)->toBeInstanceOf(StringNode::class) + ->and($string->brand)->toBe('wow') + ->and((string)$string)->toBe('string') + ->and($string->exportPhpCode())->not->toContain('wow') + ->and($int)->toBeInstanceOf(IntNode::class) + ->and($int->brand)->toBe('wow') + ->and((string)$int)->toBe('int') + ->and($int->exportPhpCode())->not->toContain('wow'); +}); + +test('a questionmark union accepts null', function () { + /** @var UnionNode $node */ + $node = new TypeParser()->parse('?bool'); + + expect($node)->toBeInstanceOf(UnionNode::class) + ->and($node->acceptsNull())->toBeTrue() + ->and($node->types[0])->toBeInstanceOf(NullNode::class) + ->and($node->types[1])->toBeInstanceOf(BoolNode::class); + + compareToOptimizedAst($node); +}); + test('DateTimeString without a format defaults to ATOM', function () { $parser = new TypeParser(); $node = $parser->parse('DateTimeString'); @@ -861,10 +846,9 @@ expect($node)->toBeInstanceOf(StructNode::class) ->and($node->phpType)->toBe(StructPhpType::ARRAY) ->and($node->hasProperty('key something else'))->toBeTrue() - ->and($node->getProperty('key something else')?->node)->toBeInstanceOf(BuiltInNode::class) - ->and($node->getProperty('key something else')?->node->type)->toBe(BuiltInType::STRING) + ->and($node->getProperty('key something else')?->node)->toBeInstanceOf(StringNode::class) ->and($node->hasProperty('b'))->toBeTrue() - ->and($node->getProperty('b')?->node->type)->toBe(BuiltInType::INT); + ->and($node->getProperty('b')?->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); validateAst($node); @@ -999,7 +983,7 @@ ->and($node->types[0]->value)->toBeTrue() ->and($node->types[1]->type)->toBe(LiteralType::BOOL) ->and($node->types[1]->value)->toBeFalse() - ->and(new TypeParser()->parse('null'))->toBeInstanceOf(BuiltInNode::class); + ->and(new TypeParser()->parse('null'))->toBeInstanceOf(NullNode::class); }); test('Numeric literal forms decode correctly', function () { diff --git a/tests/Unit/Parser/ValueObjectConsumerTest.php b/tests/Unit/Parser/ValueObjectConsumerTest.php index 6c67af5..1db9cf5 100644 --- a/tests/Unit/Parser/ValueObjectConsumerTest.php +++ b/tests/Unit/Parser/ValueObjectConsumerTest.php @@ -2,7 +2,7 @@ use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; +use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BackingType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\ValueObjectNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; @@ -24,7 +24,7 @@ expect($node)->toBeInstanceOf(ValueObjectNode::class) ->and($node->className)->toBe(Email::class) - ->and($node->backingType)->toBe(BuiltInType::STRING) + ->and($node->backingType)->toBe(BackingType::STRING) ->and($node->brand)->toBe('email'); compareToOptimizedAst($node); @@ -36,7 +36,7 @@ expect($node)->toBeInstanceOf(ValueObjectNode::class) ->and($node->className)->toBe(UserId::class) - ->and($node->backingType)->toBe(BuiltInType::INT) + ->and($node->backingType)->toBe(BackingType::INT) ->and($node->brand)->toBe('customerId'); compareToOptimizedAst($node); @@ -116,7 +116,7 @@ $node = new TypeParser()->parse(StatusEnum::class); expect($node)->toBeInstanceOf(ValueObjectNode::class) - ->and($node->backingType)->toBe(BuiltInType::STRING); + ->and($node->backingType)->toBe(BackingType::STRING); compareToOptimizedAst($node); }); diff --git a/tests/Unit/Typescript/TypescriptGeneratorTest.php b/tests/Unit/Typescript/TypescriptGeneratorTest.php index f98392b..05e8cc2 100644 --- a/tests/Unit/Typescript/TypescriptGeneratorTest.php +++ b/tests/Unit/Typescript/TypescriptGeneratorTest.php @@ -1,11 +1,10 @@ type)->toBe('{}') @@ -328,7 +327,7 @@ function typescriptOfBoth(string|NodeInterface $type): string test('throws for nodes it cannot represent', function (NodeInterface $node) { expect(fn() => typescriptOf($node))->toThrow(UnsupportedTypeException::class); })->with([ - 'NamedNode' => [new NamedNode(new BuiltInNode(BuiltInType::STRING), 'Legacy')], + 'NamedNode' => [new NamedNode(new StringNode(), 'Legacy')], 'ReferencedNode' => [new ReferencedNode('#leaf_abc', 'string', 'registry')], 'unknown node implementation' => [new class implements NodeInterface { public function __toString(): string @@ -374,7 +373,7 @@ public function exportPhpCode(): string test('pretty printing keeps an empty object on one line', function () { $node = new StructNode(StructPhpType::OBJECT, [ - new PropertyNode('name', new BuiltInNode(BuiltInType::STRING), false, PropertyType::OUTPUT), + new PropertyNode('name', new StringNode(), false, PropertyType::OUTPUT), ]); expect(typescriptOf($node, IO::INPUT, new Options(pretty: true))->type)->toBe('{}'); diff --git a/tests/Unit/Utils/NodesTest.php b/tests/Unit/Utils/NodesTest.php index 0cb9c95..571c541 100644 --- a/tests/Unit/Utils/NodesTest.php +++ b/tests/Unit/Utils/NodesTest.php @@ -3,9 +3,8 @@ namespace Tests\Unit\Utils; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BuiltInType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BuiltInNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Utils\Nodes; @@ -15,13 +14,13 @@ new ConstraintNode( new StructNode( StructPhpType::ARRAY, - [new PropertyNode('name', new BuiltInNode(BuiltInType::STRING), false)] + [new PropertyNode('name', new StringNode(), false)] ), [], ), new StructNode( StructPhpType::ARRAY, - [new PropertyNode('name', new BuiltInNode(BuiltInType::STRING), false)] + [new PropertyNode('name', new StringNode(), false)] ), ]))->toBeTrue(); }); @@ -31,13 +30,13 @@ new ConstraintNode( new StructNode( StructPhpType::OBJECT, - [new PropertyNode('name', new BuiltInNode(BuiltInType::STRING), false)] + [new PropertyNode('name', new StringNode(), false)] ), [], ), new StructNode( StructPhpType::ARRAY, - [new PropertyNode('name', new BuiltInNode(BuiltInType::STRING), false)] + [new PropertyNode('name', new StringNode(), false)] ), ]))->toBeFalse(); }); @@ -45,12 +44,12 @@ test('Not all nodes are struct nodes', function () { expect(Nodes::areAllNodesOfSameStructType([ new ConstraintNode( - new BuiltInNode(BuiltInType::STRING), + new StringNode(), [], ), new StructNode( StructPhpType::ARRAY, - [new PropertyNode('name', new BuiltInNode(BuiltInType::STRING), false)] + [new PropertyNode('name', new StringNode(), false)] ), ]))->toBeFalse(); }); @@ -59,11 +58,11 @@ expect(Nodes::areAllNodesOfSameStructType([ new StructNode( StructPhpType::ARRAY, - [new PropertyNode('other', new BuiltInNode(BuiltInType::STRING), false)] + [new PropertyNode('other', new StringNode(), false)] ), new StructNode( StructPhpType::ARRAY, - [new PropertyNode('name', new BuiltInNode(BuiltInType::STRING), false)] + [new PropertyNode('name', new StringNode(), false)] ), ]))->toBeTrue(); }); \ No newline at end of file From a75dcb74574173633a95964d98453e275caa30ae Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 28 Jul 2026 15:37:17 +0200 Subject: [PATCH 012/101] Replace `ClientAwareException` with `ExposeAs` attribute; refactor `ClientAwareExceptionPresenter` to `ExposedExceptionPresenter`; update dependencies, tests, and usages accordingly. --- .../Laravel/LaravelServiceProvider.php | 5 +-- src/Contracts/Attributes/ExposeAs.php | 18 ++++++++ src/Contracts/Attributes/Throws.php | 6 ++- src/Contracts/ClientAwareException.php | 23 ---------- src/Parser/Definition/ParserState.php | 4 +- src/Server/Data/Definition.php | 1 - ...nter.php => ExposedExceptionPresenter.php} | 44 +++++++++++++------ .../Operations/InvalidNameException.php | 12 ++--- tests/Feature/ServerTest.php | 10 ++--- 9 files changed, 66 insertions(+), 57 deletions(-) create mode 100644 src/Contracts/Attributes/ExposeAs.php delete mode 100644 src/Contracts/ClientAwareException.php rename src/Server/Presenter/{ClientAwareExceptionPresenter.php => ExposedExceptionPresenter.php} (61%) diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 7f79379..663caa7 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Support\DeferrableProvider; -use Illuminate\Support\Collection; use Illuminate\Support\ServiceProvider; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\ClearOptimizeCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\CodeGenCommand; @@ -18,7 +17,7 @@ use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; use Le0daniel\PhpTsBindings\Server\Presenter\CatchAllPresenter; -use Le0daniel\PhpTsBindings\Server\Presenter\ClientAwareExceptionPresenter; +use Le0daniel\PhpTsBindings\Server\Presenter\ExposedExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Presenter\InvalidInputPresenter; use Le0daniel\PhpTsBindings\Server\Presenter\NotFoundPresenter; use Le0daniel\PhpTsBindings\Server\Presenter\UnauthenticatedPresenter; @@ -83,7 +82,7 @@ public function register(): void new UnauthorizedPresenter($config->get('operations.exceptions.unauthorized', [])), new UnauthenticatedPresenter($config->get('operations.exceptions.unauthenticated', [])), new NotFoundPresenter($config->get('operations.exceptions.not_found', [])), - new ClientAwareExceptionPresenter(), + new ExposedExceptionPresenter(), ], new CatchAllPresenter(), $app, diff --git a/src/Contracts/Attributes/ExposeAs.php b/src/Contracts/Attributes/ExposeAs.php new file mode 100644 index 0000000..b39ab9e --- /dev/null +++ b/src/Contracts/Attributes/ExposeAs.php @@ -0,0 +1,18 @@ + $exceptionClass + * @param class-string $exceptionClass */ public function __construct( public string $exceptionClass, diff --git a/src/Contracts/ClientAwareException.php b/src/Contracts/ClientAwareException.php deleted file mode 100644 index d11644b..0000000 --- a/src/Contracts/ClientAwareException.php +++ /dev/null @@ -1,23 +0,0 @@ -> + * @return list> * @throws ReflectionException */ - private function extractExposedExceptions(Definition $definition): array + private function extractDeclaredExceptions(Definition $definition): array { $reflection = new ReflectionMethod($definition->fullyQualifiedClassName, $definition->methodName); $attributes = $reflection->getAttributes(Throws::class); @@ -43,25 +44,43 @@ private function extractExposedExceptions(Definition $definition): array }, $attributes); } + /** + * @param class-string $exceptionClass + */ + private function exposedTypeOf(string $exceptionClass): ?string + { + $attributes = new ReflectionClass($exceptionClass)->getAttributes(ExposeAs::class); + if (count($attributes) === 0) { + return null; + } + + return $attributes[0]->newInstance()->type; + } + /** * @throws ReflectionException */ public function matches(Throwable $throwable, Definition $definition): bool { - return $throwable instanceof ClientAwareException && in_array($throwable::class, $this->extractExposedExceptions($definition), true); + return $this->exposedTypeOf($throwable::class) !== null + && in_array($throwable::class, $this->extractDeclaredExceptions($definition), true); } public function toTypeScriptDefinition(Definition $definition): ?string { - $exceptionClasses = $this->extractExposedExceptions($definition); - if (empty($exceptionClasses)) { + $exposedTypes = array_filter(array_map( + $this->exposedTypeOf(...), + $this->extractDeclaredExceptions($definition), + )); + + if (empty($exposedTypes)) { return null; } - return implode('|', array_map(function (string $exceptionClass): string { - $type = json_encode($exceptionClass::type(), JSON_THROW_ON_ERROR); + return implode('|', array_map(function (string $exposedType): string { + $type = json_encode($exposedType, JSON_THROW_ON_ERROR); return "{type: {$type}}"; - }, $exceptionClasses)); + }, $exposedTypes)); } /** @@ -69,9 +88,8 @@ public function toTypeScriptDefinition(Definition $definition): ?string */ public function details(Throwable $throwable): array { - /** @var ClientAwareException $throwable */ return [ - 'type' => $throwable::type(), + 'type' => $this->exposedTypeOf($throwable::class), ]; } @@ -79,4 +97,4 @@ public static function errorType(): ErrorType { return ErrorType::DOMAIN_ERROR; } -} \ No newline at end of file +} diff --git a/tests/Feature/Operations/InvalidNameException.php b/tests/Feature/Operations/InvalidNameException.php index dc1440f..334757f 100644 --- a/tests/Feature/Operations/InvalidNameException.php +++ b/tests/Feature/Operations/InvalidNameException.php @@ -2,13 +2,9 @@ namespace Tests\Feature\Operations; -use Le0daniel\PhpTsBindings\Contracts\ClientAwareException; +use Le0daniel\PhpTsBindings\Contracts\Attributes\ExposeAs; -final class InvalidNameException extends \Exception implements ClientAwareException +#[ExposeAs('invalid_name')] +final class InvalidNameException extends \Exception { - - public static function type(): string - { - return 'invalid_name'; - } -} \ No newline at end of file +} diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index d8a9998..eef3d17 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -8,15 +8,15 @@ use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\CachedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; -use Le0daniel\PhpTsBindings\Server\Presenter\ClientAwareExceptionPresenter; +use Le0daniel\PhpTsBindings\Server\Presenter\ExposedExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Server; function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { $registry = EagerlyLoadedRegistry::eagerlyDiscover(__DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator); $cachedRegistry = eval(CachedOperationRegistry::toPhpCode($registry)); - $server = new Server($registry, [new ClientAwareExceptionPresenter(),],); - $cachedServer = new Server($cachedRegistry, [new ClientAwareExceptionPresenter(),],); + $server = new Server($registry, [new ExposedExceptionPresenter(),],); + $cachedServer = new Server($cachedRegistry, [new ExposedExceptionPresenter(),],); $regularResponse = $server->command($name, $input, null, new NullClient()); $cachedResponse = $cachedServer->command($name, $input, null, new NullClient()); @@ -58,12 +58,12 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { keyGenerator: new PlainlyExposedKeyGenerator ), [ - new ClientAwareExceptionPresenter(), + new ExposedExceptionPresenter(), ], ); $operation = $server->registry->get(OperationType::COMMAND, 'test.run'); - $errorPresenter = new ClientAwareExceptionPresenter(); + $errorPresenter = new ExposedExceptionPresenter(); $definition = $errorPresenter->toTypeScriptDefinition($operation->definition); expect($definition)->toEqual('{type: "invalid_name"}'); }); \ No newline at end of file From 21746d07bb9e725f1b1a222713d6431f3bf0b485 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 00:35:36 +0200 Subject: [PATCH 013/101] Remove `Branded`, `NamedNode`, and `Options` classes; refactor tests and mocks to handle branded and named types directly via attributes; expand type system validation with additional tests and exceptions. --- README.md | 89 +++++-- .../Laravel/Commands/CodeGenCommand.php | 7 - .../EmitOperationClientBindings.php | 3 +- src/CodeGen/CodeGenerators/EmitOperations.php | 24 +- src/CodeGen/CodeGenerators/EmitQueryKey.php | 26 +- .../CodeGenerators/EmitTanstackQuery.php | 2 +- src/CodeGen/CodeGenerators/EmitTypeMap.php | 9 +- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 3 +- src/CodeGen/CodeGenerators/EmitTypes.php | 54 +++-- src/CodeGen/Contracts/GeneratesLibFiles.php | 4 +- src/CodeGen/Data/TypedOperation.php | 36 ++- src/CodeGen/Helpers/TypeScriptFile.php | 10 +- .../Helpers/TypescriptImportStatement.php | 35 ++- src/CodeGen/TypescriptServerCodeGenerator.php | 46 ++-- src/Contracts/Attributes/Brand.php | 27 ++- src/Contracts/Attributes/Named.php | 52 ++++ src/Contracts/Branded.php | 15 -- src/Contracts/OperationKeyGenerator.php | 3 - src/Contracts/OperationRegistry.php | 1 - src/Executor/Handlers/CustomClassHandler.php | 12 +- src/Executor/SchemaExecutor.php | 8 +- src/Parser/ASTOptimizer.php | 6 +- src/Parser/AstSorter.php | 4 +- src/Parser/AstValidator.php | 4 +- src/Parser/Consumers/ArrayConsumer.php | 17 +- src/Parser/Consumers/EnumConsumer.php | 9 +- .../Consumers/UserDefinedObjectConsumer.php | 6 +- src/Parser/Consumers/UtilsConsumer.php | 24 +- src/Parser/Consumers/ValueObjectConsumer.php | 30 +-- src/Parser/Contracts/TypeRegistry.php | 2 +- src/Parser/Nodes/Data/NamedType.php | 24 ++ src/Parser/Nodes/Data/ObjectCastStrategy.php | 7 - src/Parser/Nodes/Leaf/IntNode.php | 18 +- src/Parser/Nodes/Leaf/StringNode.php | 18 +- src/Parser/Nodes/Leaf/ValueObjectNode.php | 19 +- src/Parser/Nodes/MetadataNode.php | 54 +++++ src/Parser/Nodes/NamedNode.php | 28 --- src/Parser/Registry/CachedTypeRegistry.php | 4 +- src/Reflection/MetadataAttributes.php | 44 ++++ src/Typescript/Data/EmissionContext.php | 1 - src/Typescript/Data/IO.php | 4 + src/Typescript/Data/Options.php | 23 -- src/Typescript/Data/TypeRegistry.php | 15 +- src/Typescript/Data/TypeScript.php | 21 +- .../InvalidStringLiteralException.php | 21 ++ .../Exceptions/UnsupportedTypeException.php | 9 + src/Typescript/TypescriptGenerator.php | 181 ++++++-------- src/Typescript/Utils/Syntax.php | 19 +- src/Utils/Nodes.php | 17 +- tests/Mocks/Named/AsymmetricNamed.php | 23 ++ tests/Mocks/Named/BrandedPayload.php | 16 ++ tests/Mocks/Named/Conflict/Customer.php | 17 ++ tests/Mocks/Named/Customer.php | 18 ++ tests/Mocks/Named/InvalidlyBranded.php | 13 + tests/Mocks/Named/InvalidlyNamed.php | 13 + tests/Mocks/Named/NamedValueObject.php | 31 +++ tests/Mocks/Named/Order.php | 18 ++ tests/Mocks/Named/OrderStatus.php | 16 ++ tests/Mocks/Named/PublicResource.php | 17 ++ tests/Mocks/Named/RenamedThing.php | 13 + tests/Pest.php | 31 +-- tests/Unit/CodeGen/EmitQueryKeyTest.php | 72 ++++++ tests/Unit/CodeGen/EmitTypesTest.php | 55 +++-- .../Mocks/ConflictingNamedOperations.php | 33 +++ tests/Unit/CodeGen/Mocks/NamedOperations.php | 35 +++ .../TypescriptServerCodeGeneratorTest.php | 60 +++-- tests/Unit/Parser/NamedTypeTest.php | 92 +++++++ tests/Unit/Parser/TypeParserTest.php | 63 +++-- tests/Unit/Parser/ValueObjectConsumerTest.php | 76 +++--- tests/Unit/Typescript/NamedTypesTest.php | 187 ++++++++++++++ tests/Unit/Typescript/OptimizedAstTest.php | 10 +- tests/Unit/Typescript/TypeRegistryTest.php | 9 + .../Typescript/TypescriptGeneratorTest.php | 228 +++++------------- 73 files changed, 1495 insertions(+), 746 deletions(-) create mode 100644 src/Contracts/Attributes/Named.php delete mode 100644 src/Contracts/Branded.php create mode 100644 src/Parser/Nodes/Data/NamedType.php create mode 100644 src/Parser/Nodes/MetadataNode.php delete mode 100644 src/Parser/Nodes/NamedNode.php create mode 100644 src/Reflection/MetadataAttributes.php delete mode 100644 src/Typescript/Data/Options.php create mode 100644 src/Typescript/Exceptions/InvalidStringLiteralException.php create mode 100644 tests/Mocks/Named/AsymmetricNamed.php create mode 100644 tests/Mocks/Named/BrandedPayload.php create mode 100644 tests/Mocks/Named/Conflict/Customer.php create mode 100644 tests/Mocks/Named/Customer.php create mode 100644 tests/Mocks/Named/InvalidlyBranded.php create mode 100644 tests/Mocks/Named/InvalidlyNamed.php create mode 100644 tests/Mocks/Named/NamedValueObject.php create mode 100644 tests/Mocks/Named/Order.php create mode 100644 tests/Mocks/Named/OrderStatus.php create mode 100644 tests/Mocks/Named/PublicResource.php create mode 100644 tests/Mocks/Named/RenamedThing.php create mode 100644 tests/Unit/CodeGen/EmitQueryKeyTest.php create mode 100644 tests/Unit/CodeGen/Mocks/ConflictingNamedOperations.php create mode 100644 tests/Unit/CodeGen/Mocks/NamedOperations.php create mode 100644 tests/Unit/Parser/NamedTypeTest.php create mode 100644 tests/Unit/Typescript/NamedTypesTest.php diff --git a/README.md b/README.md index 39d6f2c..19fc802 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\Options; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; $typeString = TypeReflector::reflectParameter( @@ -116,16 +116,22 @@ $input->type; // => string|Record|{name:string;} $output = $generator->toTypescript($ast, IO::OUTPUT); $output->type; // => string|Record|{name:string;} -// Branded leaves are referenced by an alias; its definition comes back in the registry, so you can -// emit `export type Email = string & Brand<"email">` once and reference it everywhere. +// A #[Brand] renders inline at every use site, always parenthesised; it declares no alias. $branded = $generator->toTypescript($parser->parse(Email::class), IO::INPUT); -$branded->type; // => Email -$branded->registry->toArray(); // => ['Email' => 'string & Brand<"email">'] -$branded->toStandaloneType(); // => string & Brand<"email"> +$branded->type; // => (string & Brand<"email">) -// Options: pretty prints object literals across lines, ignoreBrandedTypes drops the brands and -// emits the backing primitive instead. -$generator->toTypescript($ast, IO::INPUT, new Options(pretty: true, ignoreBrandedTypes: true)); +// Named types are referenced by their alias; each definition comes back in the registry, so you +// can emit `export type Token = (string & Brand<"token">)` once and reference it everywhere. +$named = $generator->toTypescript($parser->parse("BrandedString<'token'>"), IO::INPUT); +$named->type; // => Token +$named->registry->toArray(); // => ['Token' => '(string & Brand<"token">)'] +$named->registry->usedAliases(); // => ['Token'] — every alias in the registry counts as used + +// Each call emits into its own registry — the result always carries exactly the aliases that +// schema produced. Pass an optional shared registry and every call registers its aliases into it +// at the end of the pass; that hand-over is where an alias meaning two different things across +// several schemas is rejected. +$generator->toTypescript($ast, IO::INPUT, $shared = new TypeRegistry()); $executor = new SchemaExecutor() @@ -143,8 +149,8 @@ resolved by the bundled PHPStan extension too, so static analysis agrees with th | --- | --- | --- | | `Pick` | struct with only those properties | `{a: …; b: …;}` | | `Omit` | struct without those properties | `{…}` | -| `BrandedString<'name'>` | `string` | `Name`, declared as `string & Brand<"name">` | -| `BrandedInt<'name'>` | `int` | `Name`, declared as `number & Brand<"name">` | +| `BrandedString<'name'>` | `string` | `Name`, declared as `(string & Brand<"name">)` | +| `BrandedInt<'name'>` | `int` | `Name`, declared as `(number & Brand<"name">)` | | `DateTimeString<'format'>` | `DateTimeImmutable` | `string` | ### DateTimeString @@ -249,7 +255,8 @@ debugging — it never reaches the client as an internal error, and never escape ### Branded types Without a brand, `UserId` and any other int are interchangeable in TypeScript. Add `#[Brand]` and the -generated type becomes opaque: +generated type becomes opaque. A brand is an **inline** intersection at every use site — it declares +no alias of its own: ```php #[Brand] // brand name defaults to lcfirst('UserId') => "userId" @@ -257,20 +264,62 @@ generated type becomes opaque: ``` ```typescript +// declared once in the generated types file: declare const __brand: unique symbol; export type Brand = {readonly [__brand]: TBrand;}; -export type UserId = number & Brand<"userId">; +// at every use site: +declare function getUser(id: (number & Brand<"userId">)): void; +getUser(1); // Type error: number is not assignable to the branded type +``` + +`#[Brand]` works on any class, interface, enum or value object — an object shape simply becomes +`({...} & Brand<"...">)`. Combine it with `#[Named]` to export the branded type once by name: + +```php +#[Brand] #[Named(io: IO::BOTH)] +final readonly class UserId implements IntValueObject { /* ... */ } +``` + +```typescript +export type UserId = (number & Brand<"userId">); +``` -declare function getUser(id: UserId): void; -getUser(1); // Type error: number is not assignable to UserId +### Named types + +`#[Named]` exports a class, interface, enum or value object as a named type alias: instead of +inlining the structure at every use site, the generator declares it once and references it by name. + +```php +#[Named] // alias defaults to the class base name: App\Data\Order => Order +#[Named('CustomOrder')] // or name it yourself +#[Named(io: IO::BOTH)] // name input and output alike (see below) +final class Order +{ + public Customer $customer; // Customer may itself be #[Named] — aliases nest recursively + public UserId $id; // and mix freely with brands +} +``` + +```typescript +export type Customer = {email:(string & Brand<"email">);name:string;}; +export type Order = {customer:Customer;id:(number & Brand<"customerId">);}; ``` -Each brand is declared once, in the generated types file, and every operation that uses it references -it by that name (`{id: UserId}`) and imports it. Value objects without `#[Brand]` stay plain -`string` / `number`. Brands are code generation metadata only — they have no runtime impact, and -`php artisan operations:codegen --no-branded-types` strips them, emitting the backing primitive at -every use site and declaring nothing. +Because a class can legitimately have a different input shape than output shape (constructor-only +parameters, output-only properties), the name applies to **output only by default**; on input the +structure is inlined as if the attribute were absent. Opt into `IO::BOTH` when both directions are +identical — if they are not, generation fails hard with a conflicting alias error instead of +emitting a lying type. The same error protects against two classes resolving to the same alias with +different shapes anywhere in a run, and a handful of names the generated types file always declares +(`Brand`, `Result`, `Success`, `Failure`, ...) are rejected outright. + +Brands and names are pure code generation metadata with zero runtime impact: values travel the wire +in their plain shape, and the metadata is stripped from cached ASTs entirely — TypeScript +generation always runs on freshly parsed schemas. The `BrandedString<'x'>` / `BrandedInt<'x'>` +docblock utilities are the shorthand for brand + name in one, since docblocks cannot carry +attributes: `BrandedString<'token'>` is referenced as `Token` and declared as +`export type Token = (string & Brand<"token">)`. ## Validating AST diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index b4ba02f..72f378e 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -27,9 +27,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypeScriptFile; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\Server\Server; -use Le0daniel\PhpTsBindings\Typescript\Data\Options; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; -use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; @@ -42,7 +40,6 @@ final class CodeGenCommand extends Command . '{--without=* : tanstack-query | type-map} ' . '{--ignore=* : Ignored namespaces (namespace) or specific operations by specifying namespace.name} ' . '{--naming=name : Naming mode to use. Modes: name, fqn, operation-prefix, namespace-postfix or classname::methodName for custom function}' - . '{--no-branded-types}' . '{--verify} '; protected $description = 'Generate the typescript bindings for all operations'; @@ -91,10 +88,6 @@ public function handle( $codeGenerator = new TypescriptServerCodeGenerator( $this->getGeneratorsFromInput($application), - new TypescriptGenerator(), - new Options( - ignoreBrandedTypes: $this->option('no-branded-types') === true, - ), ); $files = $codeGenerator->generate( diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 42e9179..69a2c81 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -5,6 +5,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\DependsOn; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; final class EmitOperationClientBindings implements GeneratesLibFiles, DependsOn { @@ -19,7 +20,7 @@ public function dependsOnGenerator(): array /** * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata): array + public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array { return [ "OperationClient" => << */ private function aliasImports(TypedOperation $operation): array { - $aliases = array_keys($operation->registry->toArray()); - if ($aliases === []) { - return []; - } + $aliases = ['Brand', ...$operation->usedAliases()]; + sort($aliases); return [ new TypescriptImportStatement( @@ -85,12 +85,12 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata */ TypeScript; - if ($operation->inputDefinition === 'null') { + if ($operation->inputDef->type === 'null') { return new TypescriptCodeBlock( <<outputDefinition}; +export type {$resultTypeName} = {$operation->outputDef->type}; export type {$resultInputTypeName} = null; -export type {$errorTypeName} = {$operation->errorDefinition}; +export type {$errorTypeName} = {$operation->errorDef->type}; {$docBlock} export async function {$name}(options?: OperationOptions) { @@ -107,9 +107,9 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata return new TypescriptCodeBlock( <<outputDefinition}; -export type {$resultInputTypeName} = {$operation->inputDefinition}; -export type {$errorTypeName} = {$operation->errorDefinition}; +export type {$resultTypeName} = {$operation->outputDef->type}; +export type {$resultInputTypeName} = {$operation->inputDef->type}; +export type {$errorTypeName} = {$operation->errorDef->type}; {$docBlock} export async function {$name}(input: {$resultInputTypeName}, options?: OperationOptions) { diff --git a/src/CodeGen/CodeGenerators/EmitQueryKey.php b/src/CodeGen/CodeGenerators/EmitQueryKey.php index 8f310ab..4d4af40 100644 --- a/src/CodeGen/CodeGenerators/EmitQueryKey.php +++ b/src/CodeGen/CodeGenerators/EmitQueryKey.php @@ -43,20 +43,32 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata $name = $this->generateName($operation); + // The input definition is inlined verbatim, so the aliases its registry carries must be + // imported here as well — plus Brand, unconditionally, for inline brands. The file level + // import merge dedupes them with EmitOperations' imports. + $aliases = ['Brand', ...$operation->inputDef->registry->usedAliases()]; + sort($aliases); + + $imports = [ + new TypescriptImportStatement( + from: Paths::libImport("utils"), + imports: ['queryKey'], + ), + new TypescriptImportStatement( + from: Paths::libImport("types"), + imports: array_map(fn(string $alias): string => "type {$alias}", $aliases), + ), + ]; + return new TypescriptCodeBlock( <<inputDefinition}) { +export function {$name}QueryKey(input: {$operation->inputDef->type}) { return queryKey('{$definition->namespace}', '{$definition->name}', input); } TypeScript , - [ - new TypescriptImportStatement( - from: Paths::libImport("utils"), - imports: ['queryKey'], - ), - ] + $imports, ); } } \ No newline at end of file diff --git a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php index 21b1466..242ff56 100644 --- a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php +++ b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php @@ -65,7 +65,7 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata - if ($operation->inputDefinition === 'null') { + if ($operation->inputDef->type === 'null') { return new TypescriptCodeBlock( <<> $map */ $map = array_reduce($operations, function (array $carry, TypedOperation $operation): array { $carry[$operation->definition->type->lowerCase()][$operation->definition->fullyQualifiedName()] = [ - 'input' => $operation->inputDefinition, - 'output' => $operation->outputDefinition, - 'errors' => $operation->errorDefinition, + 'input' => $operation->inputDef->type, + 'output' => $operation->outputDef->type, + 'errors' => $operation->errorDef->type, ]; return $carry; }, []); diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index 67eee77..b42babd 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -7,6 +7,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; final class EmitTypeUtils implements GeneratesLibFiles, DependsOn { @@ -20,7 +21,7 @@ public function dependsOnGenerator(): array /** * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata): array + public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array { $queryNamespaces = array_reduce($operations, function (array $carry, TypedOperation $operation) { if ($operation->operation->definition->type !== OperationType::QUERY) { diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 2dc5c93..982bbf8 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -6,16 +6,37 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Utils\Arrays; final class EmitTypes implements GeneratesLibFiles { + /** + * Declarations this file always contains. An alias claiming one of these names would generate + * a second, conflicting declaration right next to them. + */ + private const array RESERVED_ALIASES = [ + 'Brand', + 'Success', + 'Failure', + 'Result', + 'OperationNamespaces', + 'WithClientDirectives', + 'SPAClientDirectives', + 'TYPE_MAP', + ]; /** * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata): array + public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array { + foreach ($registry->usedAliases() as $alias) { + if (in_array($alias, self::RESERVED_ALIASES, true)) { + throw UnsupportedTypeException::reservedAlias($alias); + } + } + $uniqueNamespaces = array_reduce($operations, function (array $carry, TypedOperation $operation) { if (!in_array($operation->operation->definition->namespace, $carry, true)) { return [ @@ -26,8 +47,10 @@ public function emitFiles(array $operations, ServerMetadata $metadata): array return $carry; }, []); - $brandedTypeString = implode("\n", Arrays::mapWithKeys( - $this->collectAliases($operations), + // The shared registry holds every alias any pass produced; the types file declares them + // all, so every operation file can import any key of its own definitions' registries. + $aliasTypeString = implode("\n", Arrays::mapWithKeys( + $registry->toArray(), fn(string $alias, string $definition): string => "export type {$alias} = {$definition}", )); @@ -51,8 +74,8 @@ public function emitFiles(array $operations, ServerMetadata $metadata): array declare const __brand: unique symbol; export type Brand = {readonly [__brand]: TBrand;}; -/* All Branded types exported */ -{$brandedTypeString} +/* All branded and named types exported */ +{$aliasTypeString} TypeScript, ]; @@ -66,25 +89,4 @@ private function generateNamespaceUnion(array $namespaces): string { return implode("|", array_map(fn(string $namespace) => "'$namespace'", $namespaces)); } - - /** - * Every alias referenced anywhere in the server, sorted by name. Each operation brings the - * aliases its own types refer to; the same alias standing for two different types across - * operations would generate contradicting declarations and is rejected by the registry. - * - * @param list $operations - * @return array - */ - private function collectAliases(array $operations): array - { - $registry = new TypeRegistry(); - - foreach ($operations as $operation) { - foreach ($operation->registry->toArray() as $alias => $definition) { - $registry->set($alias, $definition); - } - } - - return $registry->toArray(); - } } diff --git a/src/CodeGen/Contracts/GeneratesLibFiles.php b/src/CodeGen/Contracts/GeneratesLibFiles.php index 6d99e5f..ab88c84 100644 --- a/src/CodeGen/Contracts/GeneratesLibFiles.php +++ b/src/CodeGen/Contracts/GeneratesLibFiles.php @@ -5,6 +5,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypeScriptFile; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; interface GeneratesLibFiles { @@ -17,7 +18,8 @@ interface GeneratesLibFiles * ] * * @param list $operations + * @param TypeRegistry $registry The run's shared registry: every alias any operation produced. * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata): array; + public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array; } \ No newline at end of file diff --git a/src/CodeGen/Data/TypedOperation.php b/src/CodeGen/Data/TypedOperation.php index 960760b..c416d09 100644 --- a/src/CodeGen/Data/TypedOperation.php +++ b/src/CodeGen/Data/TypedOperation.php @@ -4,7 +4,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; final class TypedOperation { @@ -17,19 +17,31 @@ final class TypedOperation } /** - * @param string $inputDefinition The input type. Branded leaves appear as their alias name. - * @param string $outputDefinition The output type. Branded leaves appear as their alias name. - * @param TypeRegistry $registry The aliases the two types above refer to. A generator writing - * into the file that declares them emits one `export type` per entry; a generator writing - * into any other file imports them by name. + * Each definition carries its own registry with every alias it relies on: what the operation's + * file imports, and what the generated types file declares (via the run's shared registry). */ public function __construct( - public readonly string $inputDefinition, - public readonly string $outputDefinition, - public readonly string $errorDefinition, - public readonly Operation $operation, - public readonly TypeRegistry $registry = new TypeRegistry(), + public readonly TypeScript $inputDef, + public readonly TypeScript $outputDef, + public readonly TypeScript $errorDef, + public readonly Operation $operation, ) { } -} \ No newline at end of file + + /** + * The aliases the operation's own file references, ready to import. + * + * @return list sorted + */ + public function usedAliases(): array + { + $aliases = array_values(array_unique([ + ...$this->inputDef->registry->usedAliases(), + ...$this->outputDef->registry->usedAliases(), + ...$this->errorDef->registry->usedAliases(), + ])); + sort($aliases); + return $aliases; + } +} diff --git a/src/CodeGen/Helpers/TypeScriptFile.php b/src/CodeGen/Helpers/TypeScriptFile.php index 856a6ae..116d322 100644 --- a/src/CodeGen/Helpers/TypeScriptFile.php +++ b/src/CodeGen/Helpers/TypeScriptFile.php @@ -59,9 +59,15 @@ public function merge(TypeScriptFile $other): void public function toString(): string { - $imports = implode(PHP_EOL, array_map(fn(TypescriptImportStatement $import): string => $import->toString(), $this->imports)); + $imports = []; + foreach ($this->imports as $import) { + array_push($imports, ...$import->toStatements()); + } + + $importLines = implode(PHP_EOL, $imports); + $fullFile = <<code} TypeScript; diff --git a/src/CodeGen/Helpers/TypescriptImportStatement.php b/src/CodeGen/Helpers/TypescriptImportStatement.php index ee16688..da281b7 100644 --- a/src/CodeGen/Helpers/TypescriptImportStatement.php +++ b/src/CodeGen/Helpers/TypescriptImportStatement.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\CodeGen\Helpers; use InvalidArgumentException; +use Le0daniel\PhpTsBindings\Utils\Lists; final readonly class TypescriptImportStatement { @@ -37,13 +38,41 @@ public function merge(TypescriptImportStatement $other): TypescriptImportStateme return new TypescriptImportStatement($this->from, $uniqueImports); } - public function toString(): string + /** @return list */ + public function toStatements(): array { - $imports = $this->getImports(); + $typeImports = []; + $valueImports = []; + + foreach ($this->imports as $statement) { + if (str_starts_with($statement, 'type ')) { + $typeImports[] = substr($statement, 5); + } else { + $valueImports[] = $statement; + } + } + + return Lists::filterNullValues([ + $this->toImport(true, $typeImports), + $this->toImport(false, $valueImports) + ]); + } + + /** + * @param bool $isTypeImport + * @param list $imports + * @return string|null + */ + private function toImport(bool $isTypeImport, array $imports): string|null + { + if (empty($imports)) { + return null; + } + usort($imports, fn(string $a, string $b): int => strcmp($a, $b)); $importedValues = implode(', ', $imports); - return "import {{$importedValues}} from '{$this->from}';"; + return $isTypeImport ? "import type {{$importedValues}} from '{$this->from}';" : "import {{$importedValues}} from '{$this->from}';"; } /** diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 08e4c2d..3b684b4 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -15,8 +15,8 @@ use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\Options; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Le0daniel\PhpTsBindings\Utils\Lists; use RuntimeException; @@ -25,14 +25,11 @@ { /** * @param array $generators - * @param Options $options Applies to every type generated in this run. The registry it carries is - * ignored: each operation collects its aliases into its own. * @throws InvalidGeneratorDependencies */ public function __construct( private array $generators, private TypescriptGenerator $typescriptGenerator = new TypescriptGenerator(), - private Options $options = new Options(), ) { $this->verifyGeneratorDependencies(); @@ -84,26 +81,28 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore }) ); + // Cross-operation and cross-direction alias conflicts are only caught when every pass hands + // its aliases into one shared registry, so the run always has one. It is also what the + // generated types file declares. + $registry = new TypeRegistry(); + $definitions = array_values( - array_map(function (Operation $operation) use ($server): TypedOperation { + array_map(function (Operation $operation) use ($server, $registry): TypedOperation { AstValidator::validate($operation->inputNode()); AstValidator::validate($operation->outputNode()); - // Both directions share one registry, so an operation hands its aliases on as a single - // set and a brand that means two different things across them is rejected right here. $input = $this->typescriptGenerator->toTypescript( - $operation->inputNode(), IO::INPUT, $this->optionsWith(new TypeRegistry()), + $operation->inputNode(), IO::INPUT, $registry, ); $output = $this->typescriptGenerator->toTypescript( - $operation->outputNode(), IO::OUTPUT, $this->optionsWith($input->registry), + $operation->outputNode(), IO::OUTPUT, $registry, ); return new TypedOperation( - $input->type, - $output->type, - $this->generateAllErrorTypes($server, $operation->definition), + $input, + $output, + TypeScript::fromRawString($this->generateAllErrorTypes($server, $operation->definition)), $operation, - $output->registry, ); }, $filteredDefinitions) ); @@ -117,23 +116,11 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore }); return [ - ...$this->generateLibFiles($definitions, $metadata), + ...$this->generateLibFiles($definitions, $metadata, $registry), ...$this->generateOperationDefinitions($definitions, $metadata), ]; } - /** - * The run wide options, aimed at the given registry. - */ - private function optionsWith(TypeRegistry $registry): Options - { - return new Options( - pretty: $this->options->pretty, - ignoreBrandedTypes: $this->options->ignoreBrandedTypes, - registry: $registry, - ); - } - private function generateAllErrorTypes(Server $server, Definition $operation): string { $possibleTypes = Lists::filterNullValues(array_map(function (ExceptionPresenter $presenter) use ($operation): null|string { @@ -148,18 +135,19 @@ private function generateAllErrorTypes(Server $server, Definition $operation): s /** * @param list $definitions * @param ServerMetadata $metadata + * @param TypeRegistry $registry The run's shared registry, holding every alias any pass produced. * @return array */ - private function generateLibFiles(array $definitions, ServerMetadata $metadata): array + private function generateLibFiles(array $definitions, ServerMetadata $metadata, TypeRegistry $registry): array { return array_reduce( $this->generators, - function (array $carry, $codeGenerator) use ($definitions, $metadata): array { + function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry): array { if (!$codeGenerator instanceof GeneratesLibFiles) { return $carry; } - foreach ($codeGenerator->emitFiles($definitions, $metadata) as $fileName => $fileContent) { + foreach ($codeGenerator->emitFiles($definitions, $metadata, $registry) as $fileName => $fileContent) { if (preg_match('/^[a-zA-Z0-9_\-]+$/', $fileName) !== 1) { throw new RuntimeException("Invalid file name '{$fileName}' for lib file. File names must only contain a-z, A-Z, 0-9, - and _."); } diff --git a/src/Contracts/Attributes/Brand.php b/src/Contracts/Attributes/Brand.php index d6cff2b..24bafdc 100644 --- a/src/Contracts/Attributes/Brand.php +++ b/src/Contracts/Attributes/Brand.php @@ -3,17 +3,19 @@ namespace Le0daniel\PhpTsBindings\Contracts\Attributes; use Attribute; +use Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException; +use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; /** - * Marks a value object as branded in the generated TypeScript, so that a bare string or number - * can no longer be passed where the value object is expected. + * Brands the generated TypeScript of a class, interface, enum or value object, so that a + * structurally identical value can no longer be passed where this type is expected. The emitted + * type becomes `(... & Brand<"name">)`, INLINE at every use site — a brand alone declares no alias. + * Combine with #[Named] to export it once by name: `export type UserId = (number & Brand<"userId">)`. * - * Without a name, the brand is lcfirst() of the base class name: UserId becomes "userId". The - * emitted alias is then `export type UserId = number & Brand<"userId">`, because the code - * generator capitalizes the brand when naming the alias. + * Without a name, the brand is lcfirst() of the base class name: UserId becomes "userId". * - * Brands are code generation metadata only. They have no runtime impact and are stripped - * entirely when `operations:codegen` runs with --no-branded-types. + * Brands are code generation metadata only: they have zero runtime impact, values travel the wire + * in their plain shape, and the metadata never enters a cached AST. */ #[Attribute(Attribute::TARGET_CLASS)] final readonly class Brand @@ -23,4 +25,15 @@ public function __construct( ) { } + + public function brandName(string $classString): string + { + $name = $this->name ?? lcfirst(explode('\\', $classString) |> array_last(...)); + + if (!Syntax::isValidIdentifier($name)) { + throw InvalidStringLiteralException::notAValidTypescriptIdentifier($name, "#[Brand] on {$classString}"); + } + + return $name; + } } diff --git a/src/Contracts/Attributes/Named.php b/src/Contracts/Attributes/Named.php new file mode 100644 index 0000000..42e2ba6 --- /dev/null +++ b/src/Contracts/Attributes/Named.php @@ -0,0 +1,52 @@ +name ?? (explode('\\', $classString) |> array_last(...)); + + if (!Syntax::isValidIdentifier($name)) { + throw InvalidStringLiteralException::notAValidTypescriptIdentifier($name, "#[Named] on {$classString}"); + } + + return $name; + } +} diff --git a/src/Contracts/Branded.php b/src/Contracts/Branded.php deleted file mode 100644 index b62724e..0000000 --- a/src/Contracts/Branded.php +++ /dev/null @@ -1,15 +0,0 @@ -|Value + * @return stdClass|Value */ - public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): stdClass|array|Value + public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): stdClass|Value { $object = $executor->executeSerialize($node->node, $value, $context); @@ -32,10 +32,6 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E return Value::INVALID; } - if ($node->strategy === ObjectCastStrategy::COLLECTION && is_array($object)) { - return $object; - } - if (!$object instanceof stdClass) { $objectClass = get_class($object); $context->addIssue( @@ -66,10 +62,6 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu } try { - if ($node->strategy === ObjectCastStrategy::COLLECTION) { - return new ($node->fullyQualifiedCastingClass)($arrayValue); - } - if ($node->strategy === ObjectCastStrategy::CONSTRUCTOR) { return new ($node->fullyQualifiedCastingClass)(...$arrayValue); } diff --git a/src/Executor/SchemaExecutor.php b/src/Executor/SchemaExecutor.php index 7ebde54..a2b960d 100644 --- a/src/Executor/SchemaExecutor.php +++ b/src/Executor/SchemaExecutor.php @@ -25,7 +25,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\NamedNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; @@ -94,8 +94,9 @@ public function executeSerialize(NodeInterface $node, mixed $data, Context $cont } $serializedValue = match (true) { + // Codegen metadata has no runtime effect. + $node instanceof MetadataNode => $this->executeSerialize($node->node, $data, $context), array_key_exists($node::class, $this->handlers) => $this->handlers[$node::class]->serialize($node, $data, $context, $this), - $node instanceof NamedNode => $this->executeSerialize($node->node, $data, $context), $node instanceof LeafNode => $node->serializeValue($data, $context), default => Value::INVALID, }; @@ -122,8 +123,9 @@ public function executeParse(NodeInterface $node, mixed $data, Context $context) } return match (true) { + // Codegen metadata has no runtime effect. + $node instanceof MetadataNode => $this->executeParse($node->node, $data, $context), array_key_exists($node::class, $this->handlers) => $this->handlers[$node::class]->parse($node, $data, $context, $this), - $node instanceof NamedNode => $this->executeParse($node->node, $data, $context), $node instanceof LeafNode => $context->coercePrimitives && $node instanceof Coercible ? $node->parseValue($node->coerce($data), $context) : $node->parseValue($data, $context), diff --git a/src/Parser/ASTOptimizer.php b/src/Parser/ASTOptimizer.php index c6ad327..0418055 100644 --- a/src/Parser/ASTOptimizer.php +++ b/src/Parser/ASTOptimizer.php @@ -10,7 +10,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\NamedNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ReferencedNode; @@ -89,7 +89,9 @@ public function generateOptimizedCode(array $nodes): string */ private function dedupeNode(NodeInterface $node): NodeInterface { - if ($node instanceof NamedNode) { + // Codegen metadata (brands, named types) has no runtime effect and is eliminated from + // cached ASTs entirely: TypeScript generation runs on freshly parsed schemas. + if ($node instanceof MetadataNode) { return $this->dedupeNode($node->node); } diff --git a/src/Parser/AstSorter.php b/src/Parser/AstSorter.php index faafe61..8563be0 100644 --- a/src/Parser/AstSorter.php +++ b/src/Parser/AstSorter.php @@ -8,7 +8,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\NamedNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; @@ -43,7 +43,7 @@ public static function sort(NodeInterface $node): NodeInterface array_map(self::sort(...), $node->types), ), ListNode::class => new ListNode(self::sort($node->node)), - NamedNode::class => new NamedNode(self::sort($node->node), $node->name), + MetadataNode::class => new MetadataNode(self::sort($node->node), $node->name, $node->brand), PropertyNode::class => new PropertyNode($node->name, self::sort($node->node), $node->isOptional, $node->propertyType), RecordNode::class => new RecordNode(self::sort($node->node)), StructNode::class => new StructNode($node->phpType, array_map(self::sort(...), $node->sortedProperties())), diff --git a/src/Parser/AstValidator.php b/src/Parser/AstValidator.php index 70f4134..7f2384a 100644 --- a/src/Parser/AstValidator.php +++ b/src/Parser/AstValidator.php @@ -9,7 +9,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\NamedNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; @@ -34,7 +34,7 @@ public static function validate(NodeInterface $node): void } match ($current::class) { - ConstraintNode::class, CustomCastingNode::class, ListNode::class, NamedNode::class, PropertyNode::class, RecordNode::class => $stack[] = $current->node, + ConstraintNode::class, CustomCastingNode::class, ListNode::class, MetadataNode::class, PropertyNode::class, RecordNode::class => $stack[] = $current->node, TupleNode::class, IntersectionNode::class, UnionNode::class => array_push($stack, ...$current->types), StructNode::class => array_push($stack, ... $current->properties), default => throw new RuntimeException("Unexpected node: " . $current::class), diff --git a/src/Parser/Consumers/ArrayConsumer.php b/src/Parser/Consumers/ArrayConsumer.php index e331325..e0a879c 100644 --- a/src/Parser/Consumers/ArrayConsumer.php +++ b/src/Parser/Consumers/ArrayConsumer.php @@ -15,6 +15,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Utils\Nodes; /** * Most complex consumer. It consumes the php array type which is a bit of everything: @@ -43,15 +44,12 @@ public function canConsume(ParserState $state): bool /** * @throws InvalidSyntaxException */ - public function consume(ParserState $state, TypeParser $parser): RecordNode|ListNode|TupleNode|CustomCastingNode + public function consume(ParserState $state, TypeParser $parser): RecordNode|ListNode|TupleNode { $type = match ($state->current()->value) { 'list', 'non-empty-list' => 'list', default => 'array', }; - $customType = in_array($state->current()->value, ['list', 'non-empty-list', 'array', 'non-empty-array'], true) - ? null - : $state->context->toFullyQualifiedClassName($state->current()->value); if (!$state->current()->is(TokenType::IDENTIFIER)) { $state->produceSyntaxError("Expected Array Type Identifier: array or list"); @@ -88,16 +86,15 @@ public function consume(ParserState $state, TypeParser $parser): RecordNode|List return new ListNode($generics[0]); } - $keyType = $generics[0]; - $node = match (true) { + // A branded key (array, V>) is still a string key on the wire. + // Constraints are deliberately NOT unwrapped: a constrained key (array) + // could never be validated at runtime, so it is rejected instead of silently loosened. + $keyType = Nodes::unwrapMetadata($generics[0]); + return match (true) { $keyType instanceof StringNode => new RecordNode($generics[1]), $keyType instanceof IntNode => new ListNode($generics[1]), default => $state->produceSyntaxError("Array key type must be 'string' or 'int'. Got: {$keyType}"), }; - - return $customType - ? new CustomCastingNode($node, $customType, ObjectCastStrategy::COLLECTION) - : $node; } /** diff --git a/src/Parser/Consumers/EnumConsumer.php b/src/Parser/Consumers/EnumConsumer.php index e2bce4e..67a3a18 100644 --- a/src/Parser/Consumers/EnumConsumer.php +++ b/src/Parser/Consumers/EnumConsumer.php @@ -8,6 +8,9 @@ use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\EnumNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Reflection\MetadataAttributes; +use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use ReflectionClass; use UnitEnum; final class EnumConsumer implements TypeConsumer @@ -27,6 +30,10 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); /** @var class-string $fullyQualifiedClassName */ - return new EnumNode($fullyQualifiedClassName); + return MetadataAttributes::wrap( + new EnumNode($fullyQualifiedClassName), + new ReflectionClass($fullyQualifiedClassName), + defaultIo: IO::BOTH, + ); } } diff --git a/src/Parser/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Consumers/UserDefinedObjectConsumer.php index 6cb63cd..a788267 100644 --- a/src/Parser/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Consumers/UserDefinedObjectConsumer.php @@ -20,6 +20,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\AttributesReflector; +use Le0daniel\PhpTsBindings\Reflection\MetadataAttributes; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; use Le0daniel\PhpTsBindings\Utils\Arrays; use Le0daniel\PhpTsBindings\Utils\Lists; @@ -106,12 +107,13 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $context = ParsingContext::fromReflectionClass($reflectionClass, $this->consumeGenerics($state, $parser)); - return match ($castingStrategy) { + $node = match ($castingStrategy) { ObjectCastStrategy::NEVER => $this->parseNeverStrategy($reflectionClass, $parser, $context), ObjectCastStrategy::ASSIGN_PROPERTIES => $this->parseSetPropertiesStrategy($reflectionClass, $parser, $context), ObjectCastStrategy::CONSTRUCTOR => $this->parseConstructorStrategy($reflectionClass, $parser, $context), - default => throw new RuntimeException("Casting strategy {$castingStrategy->name} is not supported"), }; + + return MetadataAttributes::wrap($node, $reflectionClass); } private function allowsOptional(ReflectionProperty|ReflectionParameter $param): bool diff --git a/src/Parser/Consumers/UtilsConsumer.php b/src/Parser/Consumers/UtilsConsumer.php index ccaef64..268c42a 100644 --- a/src/Parser/Consumers/UtilsConsumer.php +++ b/src/Parser/Consumers/UtilsConsumer.php @@ -10,16 +10,22 @@ use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; +use Le0daniel\PhpTsBindings\Parser\Nodes\Data\NamedType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\PropertyType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\DateTimeNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException; +use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; +use Le0daniel\PhpTsBindings\Utils\Nodes; final class UtilsConsumer implements TypeConsumer { @@ -55,13 +61,25 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface [$literalNode] = $this->consumeGenerics($state, $parser, 1, 1); $brand = $this->literalStringValue($state, $literalNode, 'branded type'); - return $type === 'BrandedString' - ? new StringNode(brand: $brand) - : new IntNode(brand: $brand); + // Docblocks cannot carry #[Named], so the utility is the shorthand for brand + name: + // the use site references the alias (Token), which resolves to `(string & Brand<"token">)`. + if (!Syntax::isValidIdentifier($brand)) { + throw InvalidStringLiteralException::notAValidTypescriptIdentifier($brand, "{$type}<'{$brand}'>"); + } + + return new MetadataNode( + $type === 'BrandedString' ? new StringNode() : new IntNode(), + new NamedType(ucfirst($brand), IO::BOTH), + $brand, + ); } [$nodeToPickFrom, $pick] = $this->consumeGenerics($state, $parser, 2, 2); + // Codegen metadata is irrelevant here: picking from a named or branded type produces a new + // shape, so the alias and brand are dropped along the way. + $nodeToPickFrom = Nodes::getDeclaringNode($nodeToPickFrom); + if (!$nodeToPickFrom instanceof StructNode && !$nodeToPickFrom instanceof CustomCastingNode) { $state->produceSyntaxError("Expected struct or custom casting node for picking or omitting"); } diff --git a/src/Parser/Consumers/ValueObjectConsumer.php b/src/Parser/Consumers/ValueObjectConsumer.php index 066cd9a..7782475 100644 --- a/src/Parser/Consumers/ValueObjectConsumer.php +++ b/src/Parser/Consumers/ValueObjectConsumer.php @@ -2,7 +2,6 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; -use Le0daniel\PhpTsBindings\Contracts\Attributes\Brand; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Contracts\ValueObjects\IntValueObject; use Le0daniel\PhpTsBindings\Contracts\ValueObjects\StringValueObject; @@ -12,7 +11,8 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BackingType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\ValueObjectNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Reflection\AttributesReflector; +use Le0daniel\PhpTsBindings\Reflection\MetadataAttributes; +use Le0daniel\PhpTsBindings\Typescript\Data\IO; use ReflectionClass; use ReflectionException; @@ -63,25 +63,13 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); /** @var class-string $fullyQualifiedClassName */ - return new ValueObjectNode( - $fullyQualifiedClassName, - $isStringBacked ? BackingType::STRING : BackingType::INT, - $this->resolveBrand($reflectionClass), + return MetadataAttributes::wrap( + new ValueObjectNode( + $fullyQualifiedClassName, + $isStringBacked ? BackingType::STRING : BackingType::INT, + ), + $reflectionClass, + defaultIo: IO::BOTH, ); } - - /** - * @param ReflectionClass $reflectionClass - */ - private function resolveBrand(ReflectionClass $reflectionClass): ?string - { - $attributes = new AttributesReflector($reflectionClass->getAttributes()); - if (!$attributes->has(Brand::class)) { - return null; - } - - // Without an explicit name, the brand is lcfirst of the base class name: UserId -> "userId". - return $attributes->getSingleInstance(Brand::class)->name - ?? lcfirst($reflectionClass->getShortName()); - } } diff --git a/src/Parser/Contracts/TypeRegistry.php b/src/Parser/Contracts/TypeRegistry.php index 2366c7a..0603e7c 100644 --- a/src/Parser/Contracts/TypeRegistry.php +++ b/src/Parser/Contracts/TypeRegistry.php @@ -7,5 +7,5 @@ /** @internal This is only used when optimizing AST's */ interface TypeRegistry { - public function get(string $fullyQualifiedClassName): NodeInterface; + public function get(string $key): NodeInterface; } \ No newline at end of file diff --git a/src/Parser/Nodes/Data/NamedType.php b/src/Parser/Nodes/Data/NamedType.php new file mode 100644 index 0000000..30bbf79 --- /dev/null +++ b/src/Parser/Nodes/Data/NamedType.php @@ -0,0 +1,24 @@ +io === IO::BOTH || $this->io === $io; + } +} diff --git a/src/Parser/Nodes/Data/ObjectCastStrategy.php b/src/Parser/Nodes/Data/ObjectCastStrategy.php index 052cca9..6de89c7 100644 --- a/src/Parser/Nodes/Data/ObjectCastStrategy.php +++ b/src/Parser/Nodes/Data/ObjectCastStrategy.php @@ -6,12 +6,5 @@ enum ObjectCastStrategy { case CONSTRUCTOR; case ASSIGN_PROPERTIES; - - /** - * Collection classes expect an array of this type. - * Best is to not use it at all. And rely on native PHP types like list or array. - * @deprecated No longer supported. Use native PHP types like list or array instead. - */ - case COLLECTION; case NEVER; } \ No newline at end of file diff --git a/src/Parser/Nodes/Leaf/IntNode.php b/src/Parser/Nodes/Leaf/IntNode.php index 0e6496a..d8f7e4e 100644 --- a/src/Parser/Nodes/Leaf/IntNode.php +++ b/src/Parser/Nodes/Leaf/IntNode.php @@ -2,27 +2,16 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes\Leaf; -use Le0daniel\PhpTsBindings\Contracts\Branded; use Le0daniel\PhpTsBindings\Contracts\Coercible; use Le0daniel\PhpTsBindings\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Executor\Contracts\ExecutionContext; use Le0daniel\PhpTsBindings\Utils\PHPExport; -final readonly class IntNode implements NodeInterface, LeafNode, Coercible, Branded +final readonly class IntNode implements NodeInterface, LeafNode, Coercible { use RejectsInvalidType; - public function __construct( - public ?string $brand = null, - ) - { - } - - /** - * The brand is deliberately excluded here and in exportPhpCode(): it is code generation - * metadata with no runtime impact, exactly as in ValueObjectNode. - */ public function __toString(): string { return 'int'; @@ -43,11 +32,6 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed return is_int($value) ? $value : $this->invalidType('int', $value, $context); } - public function brandName(): ?string - { - return $this->brand; - } - public function coerce(mixed $value): mixed { return filter_var($value, FILTER_VALIDATE_INT) !== false diff --git a/src/Parser/Nodes/Leaf/StringNode.php b/src/Parser/Nodes/Leaf/StringNode.php index 1f458d9..aed2ce7 100644 --- a/src/Parser/Nodes/Leaf/StringNode.php +++ b/src/Parser/Nodes/Leaf/StringNode.php @@ -2,7 +2,6 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes\Leaf; -use Le0daniel\PhpTsBindings\Contracts\Branded; use Le0daniel\PhpTsBindings\Contracts\Coercible; use Le0daniel\PhpTsBindings\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; @@ -13,20 +12,10 @@ use Stringable; use Throwable; -final readonly class StringNode implements NodeInterface, LeafNode, Coercible, Branded +final readonly class StringNode implements NodeInterface, LeafNode, Coercible { use RejectsInvalidType; - public function __construct( - public ?string $brand = null, - ) - { - } - - /** - * The brand is deliberately excluded here and in exportPhpCode(): it is code generation - * metadata with no runtime impact, exactly as in ValueObjectNode. - */ public function __toString(): string { return 'string'; @@ -58,11 +47,6 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed } } - public function brandName(): ?string - { - return $this->brand; - } - public function coerce(mixed $value): mixed { return (string) $value; diff --git a/src/Parser/Nodes/Leaf/ValueObjectNode.php b/src/Parser/Nodes/Leaf/ValueObjectNode.php index 8599840..396963b 100644 --- a/src/Parser/Nodes/Leaf/ValueObjectNode.php +++ b/src/Parser/Nodes/Leaf/ValueObjectNode.php @@ -2,7 +2,6 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes\Leaf; -use Le0daniel\PhpTsBindings\Contracts\Branded; use Le0daniel\PhpTsBindings\Contracts\Coercible; use Le0daniel\PhpTsBindings\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; @@ -20,10 +19,10 @@ * A user defined value object backed by a single string or int. * * The class opts in by implementing StringValueObject or IntValueObject. On the wire it is - * indistinguishable from its backing primitive; the brand is what keeps the two apart on the - * TypeScript side. + * indistinguishable from its backing primitive; a #[Brand] (carried by a wrapping MetadataNode) + * is what keeps the two apart on the TypeScript side. */ -final readonly class ValueObjectNode implements NodeInterface, LeafNode, Coercible, Branded +final readonly class ValueObjectNode implements NodeInterface, LeafNode, Coercible { /** * @param class-string $className @@ -31,16 +30,10 @@ public function __construct( public string $className, public BackingType $backingType, - public ?string $brand = null, ) { } - /** - * The brand is deliberately excluded here, exactly as in StringNode and IntNode. It is code - * generation metadata with no runtime impact, and the class name alone already identifies - * this node uniquely for the ASTOptimizer dedupe hash. - */ public function __toString(): string { return "valueObject<{$this->className},{$this->backingType->value}>"; @@ -52,7 +45,6 @@ public function exportPhpCode(): string $valueObjectClass = PHPExport::absolute($this->className); $backingType = PHPExport::exportEnumCase($this->backingType); - // The brand is not exported: see __toString(). return "new {$className}({$valueObjectClass}::class, {$backingType})"; } @@ -118,11 +110,6 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed } } - public function brandName(): ?string - { - return $this->brand; - } - public function coerce(mixed $value): mixed { if ($this->backingType === BackingType::STRING) { diff --git a/src/Parser/Nodes/MetadataNode.php b/src/Parser/Nodes/MetadataNode.php new file mode 100644 index 0000000..3632fc3 --- /dev/null +++ b/src/Parser/Nodes/MetadataNode.php @@ -0,0 +1,54 @@ +node; + } + + public function exportPhpCode(): string + { + return $this->node->exportPhpCode(); + } + + public function validate(): void + { + if ($this->name === null && $this->brand === null) { + throw new InvalidArgumentException( + 'MetadataNode without a name or brand is meaningless; use the inner node directly.' + ); + } + + if ($this->node instanceof MetadataNode) { + throw new InvalidArgumentException( + 'MetadataNode should not be nested.' + ); + } + } +} diff --git a/src/Parser/Nodes/NamedNode.php b/src/Parser/Nodes/NamedNode.php deleted file mode 100644 index d9c49d3..0000000 --- a/src/Parser/Nodes/NamedNode.php +++ /dev/null @@ -1,28 +0,0 @@ -node} & Brand<{$this->name}>"; - } - - public function exportPhpCode(): string - { - $className = PHPExport::absolute(self::class); - $name = PHPExport::export($this->name); - return "new {$className}({$this->node->exportPhpCode()} ,{$name})"; - } -} \ No newline at end of file diff --git a/src/Parser/Registry/CachedTypeRegistry.php b/src/Parser/Registry/CachedTypeRegistry.php index ad4c408..9fe3580 100644 --- a/src/Parser/Registry/CachedTypeRegistry.php +++ b/src/Parser/Registry/CachedTypeRegistry.php @@ -23,8 +23,8 @@ public function __construct( { } - public function get(string $fullyQualifiedClassName): NodeInterface + public function get(string $key): NodeInterface { - return $this->instantiatedNodes[$fullyQualifiedClassName] ??= ($this->registeredSchemas[$fullyQualifiedClassName])($this); + return $this->instantiatedNodes[$key] ??= ($this->registeredSchemas[$key])($this); } } \ No newline at end of file diff --git a/src/Reflection/MetadataAttributes.php b/src/Reflection/MetadataAttributes.php new file mode 100644 index 0000000..19abae2 --- /dev/null +++ b/src/Reflection/MetadataAttributes.php @@ -0,0 +1,44 @@ + $reflectionClass + * @param IO $defaultIo The direction a #[Named] without an explicit io applies to. Value + * objects and enums pass IO::BOTH — their input and output shapes are always identical. + */ + public static function wrap(NodeInterface $node, ReflectionClass $reflectionClass, IO $defaultIo = IO::OUTPUT): NodeInterface + { + $attributes = new AttributesReflector($reflectionClass->getAttributes()); + + $named = $attributes->has(Named::class) ? $attributes->getSingleInstance(Named::class) : null; + $brand = $attributes->has(Brand::class) ? $attributes->getSingleInstance(Brand::class) : null; + + if ($named === null && $brand === null) { + return $node; + } + + $className = $reflectionClass->getName(); + + return new MetadataNode( + $node, + $named === null ? null : new NamedType($named->typeName($className), $named->io ?? $defaultIo), + $brand?->brandName($className), + ); + } +} diff --git a/src/Typescript/Data/EmissionContext.php b/src/Typescript/Data/EmissionContext.php index 3a77604..75d838d 100644 --- a/src/Typescript/Data/EmissionContext.php +++ b/src/Typescript/Data/EmissionContext.php @@ -11,7 +11,6 @@ { public function __construct( public IO $io, - public Options $options, public TypeRegistry $registry, ) { diff --git a/src/Typescript/Data/IO.php b/src/Typescript/Data/IO.php index 55d5396..30340b3 100644 --- a/src/Typescript/Data/IO.php +++ b/src/Typescript/Data/IO.php @@ -9,9 +9,13 @@ * not promoted only exists on the way in, a public property assigned in the constructor body only * exists on the way out, and a class that cannot be constructed from user input has no input type * at all. + * + * BOTH is not a direction anything is generated for — TypescriptGenerator::toTypescript() rejects + * it. It only exists as a #[Named] scope, saying the name applies to input and output alike. */ enum IO { case INPUT; case OUTPUT; + case BOTH; } diff --git a/src/Typescript/Data/Options.php b/src/Typescript/Data/Options.php deleted file mode 100644 index 0a42df7..0000000 --- a/src/Typescript/Data/Options.php +++ /dev/null @@ -1,23 +0,0 @@ - definition. * - * Today every entry comes from a brand (`Email` => `string & Brand<"email">`), but nothing here is + * Today every entry comes from a brand (`Email` => `(string & Brand<"email">)`), but nothing here is * brand specific. Any future construct that wants to be emitted once and referenced by name — a * shared enum, a deduplicated object shape — belongs in the same registry. * @@ -63,6 +63,19 @@ public function isEmpty(): bool return $this->definitions === []; } + /** + * Every alias in here counts as used: the registry travels with the generated type and only + * ever contains what that type relies on. + * + * @return list sorted, so import statements are deterministic + */ + public function usedAliases(): array + { + $aliases = array_keys($this->definitions); + sort($aliases); + return $aliases; + } + /** * Sorted by alias, so the same schema always generates byte identical output. * diff --git a/src/Typescript/Data/TypeScript.php b/src/Typescript/Data/TypeScript.php index 42f6dbc..481fb2b 100644 --- a/src/Typescript/Data/TypeScript.php +++ b/src/Typescript/Data/TypeScript.php @@ -8,28 +8,21 @@ final readonly class TypeScript { /** - * @param string $type The type. Branded leaves are always referenced by their alias name. - * @param TypeRegistry $registry The aliases $type refers to, e.g. - * ['Email' => 'string & Brand<"email">']. A consumer emits each entry as + * @param string $type The type. Named types are referenced by their alias name, brands appear + * inline as `(... & Brand<"...">)`. + * @param TypeRegistry $registry Every alias this emission produced, e.g. + * ['Order' => '{id:(number & Brand<"orderId">);}']. A consumer emits each entry as * `export type {$alias} = {$definition}`. */ public function __construct( public string $type, - public TypeRegistry $registry = new TypeRegistry(), + public TypeRegistry $registry ) { } - /** - * The same type with every alias replaced by its definition, so it can be used on its own - * without also emitting the declarations from $registry. - * - * strtr() rather than str_replace(): it substitutes in a single pass, longest alias first, and - * never rescans what it just wrote. str_replace() would let `User` corrupt `UserId`, and an - * alias named `Brand` would eat the `Brand<"...">` of a definition inserted moments earlier. - */ - public function toStandaloneType(): string + public static function fromRawString(string $type): TypeScript { - return strtr($this->type, $this->registry->toArray()); + return new TypeScript($type, new TypeRegistry()); } } diff --git a/src/Typescript/Exceptions/InvalidStringLiteralException.php b/src/Typescript/Exceptions/InvalidStringLiteralException.php new file mode 100644 index 0000000..6e0c9df --- /dev/null +++ b/src/Typescript/Exceptions/InvalidStringLiteralException.php @@ -0,0 +1,21 @@ +registry; - $type = $this->emit($node, new EmissionContext($io, $options, $registry), 0); + if ($io === IO::BOTH) { + throw new InvalidArgumentException('Emit for IO::INPUT or IO::OUTPUT; IO::BOTH is only a #[Named] scope.'); + } + + // Every pass emits into its own local registry, so the result always carries exactly the + // aliases this schema produced. When a shared registry is given, all of them are + // registered into it after the pass — that hand-over is where an alias meaning two + // different things across several schemas is rejected. + $localRegistry = new TypeRegistry(); + $context = new EmissionContext($io, $localRegistry); + $type = $this->emit($node, $context); + + foreach ($localRegistry->toArray() as $alias => $definition) { + $sharedRegistry?->set($alias, $definition); + } - return new TypeScript($type, $registry); + return new TypeScript($type, $localRegistry); } - /** - * @param int $depth Nesting level of object literals, used for indentation when pretty printing. - */ - private function emit(NodeInterface $node, EmissionContext $context, int $depth): string + private function emit(NodeInterface $node, EmissionContext $context): string { return match (true) { - $node instanceof StringNode => $this->brand('string', $node, $context), - $node instanceof IntNode => $this->brand('number', $node, $context), - $node instanceof FloatNode => 'number', + $node instanceof MetadataNode => $this->metadata($node, $context), + $node instanceof StringNode => 'string', + $node instanceof IntNode, $node instanceof FloatNode => 'number', $node instanceof BoolNode => 'boolean', $node instanceof NullNode => 'null', $node instanceof MixedNode => 'unknown', - $node instanceof ValueObjectNode => $this->brand( - match ($node->backingType) { - BackingType::STRING => 'string', - BackingType::INT => 'number', - }, - $node, - $context, - ), + $node instanceof ValueObjectNode => match ($node->backingType) { + BackingType::STRING => 'string', + BackingType::INT => 'number', + }, $node instanceof LiteralNode => self::literal($node), - $node instanceof EnumNode => self::enum($node, $context), + $node instanceof EnumNode => self::enum($node), $node instanceof DateTimeNode => 'string', - $node instanceof StructNode => $this->struct($node, $context, $depth), - $node instanceof UnionNode => $this->union($node, $context, $depth), - $node instanceof IntersectionNode => $this->intersection($node, $context, $depth), - $node instanceof TupleNode => $this->tuple($node, $context, $depth), - $node instanceof ListNode => "Array<{$this->emit($node->node, $context, $depth)}>", - $node instanceof RecordNode => $context->options->pretty - ? "Recordemit($node->node, $context, $depth)}>" - : "Recordemit($node->node, $context, $depth)}>", - $node instanceof ConstraintNode => $this->emit($node->node, $context, $depth), - $node instanceof CustomCastingNode => $this->customCasting($node, $context, $depth), - - // NamedNode is the superseded branding path and is never constructed; ReferencedNode - // only exists inside optimizer generated PHP, where it resolves against a registry the - // generator does not have. Both are genuinely unrepresentable here. + $node instanceof StructNode => $this->struct($node, $context), + $node instanceof UnionNode => $this->union($node, $context), + $node instanceof IntersectionNode => $this->intersection($node, $context), + $node instanceof TupleNode => $this->tuple($node, $context), + $node instanceof ListNode => "Array<{$this->emit($node->node, $context)}>", + $node instanceof RecordNode => "Recordemit($node->node, $context)}>", + $node instanceof ConstraintNode => $this->emit($node->node, $context), + $node instanceof CustomCastingNode => $this->customCasting($node, $context), + + // ReferencedNode only exists inside optimizer generated PHP, where it resolves against + // a registry the generator does not have. It is genuinely unrepresentable here. default => throw UnsupportedTypeException::forNode($node), }; } @@ -110,7 +112,7 @@ private static function literal(LiteralNode $node): string }; } - private static function enum(EnumNode $node, EmissionContext $context): string + private static function enum(EnumNode $node): string { $cases = array_map( fn(UnitEnum $case): string => Syntax::stringLiteral($case->name), @@ -121,31 +123,36 @@ private static function enum(EnumNode $node, EmissionContext $context): string throw UnsupportedTypeException::emptyEnum($node->enumClassName); } - return implode($context->options->pretty ? ' | ' : '|', $cases); + return implode('|', $cases) |> Syntax::wrapInParentheses(...); } /** - * A branded leaf is always referenced by its alias; the definition it stands for travels back - * in TypeScript::$registry. + * Codegen metadata never nests (MetadataNode::validate()), so emission is one fixed pipeline: + * emit the inner type, apply the brand, apply the name. + * + * A brand intersects the inner type with Brand<"..."> and is always parenthesised + * (`(string & Brand<"email">)`), so the result composes into any surrounding type unchanged. + * A name applying to the direction registers the result as an alias; the use site references + * the bare identifier. The registry accepts the identical re-registration a second use site + * produces and rejects a contradicting one. */ - private function brand(string $baseType, Branded $node, EmissionContext $context): string + private function metadata(MetadataNode $node, EmissionContext $context): string { - if ($context->options->ignoreBrandedTypes) { - return $baseType; - } + $inner = $this->emit($node->node, $context); - $brandName = $node->brandName(); - if ($brandName === null || $brandName === '') { - return $baseType; + if ($node->brand !== null) { + $inner = Syntax::branded($inner, $node->brand) |> Syntax::wrapInParentheses(...); } - $alias = Syntax::brandAlias($brandName); - $context->registry->set($alias, Syntax::branded($baseType, $brandName)); + if ($node->name?->appliesTo($context->io)) { + $context->registry->set($node->name->name, $inner); + return $node->name->name; + } - return $alias; + return $inner; } - private function customCasting(CustomCastingNode $node, EmissionContext $context, int $depth): string + private function customCasting(CustomCastingNode $node, EmissionContext $context): string { // The class exists on the wire in one direction only: it can be serialized, but there is // no way to build an instance from an incoming payload. @@ -153,10 +160,10 @@ private function customCasting(CustomCastingNode $node, EmissionContext $context throw UnsupportedTypeException::uncastableInput($node); } - return $this->emit($node->node, $context, $depth); + return $this->emit($node->node, $context); } - private function struct(StructNode $node, EmissionContext $context, int $depth): string + private function struct(StructNode $node, EmissionContext $context): string { /** @var list $properties */ $properties = []; @@ -175,8 +182,8 @@ private function struct(StructNode $node, EmissionContext $context, int $depth): } $properties[] = [ - Syntax::objectKey($property->name, $property->isOptional), - $this->emit($property->node, $context, $depth + 1), + Syntax::objectKey($property->name, optional: $property->isOptional), + $this->emit($property->node, $context), ]; } @@ -184,77 +191,43 @@ private function struct(StructNode $node, EmissionContext $context, int $depth): return '{}'; } - if (!$context->options->pretty) { - return '{' . implode('', array_map( - fn(array $property): string => "{$property[0]}:{$property[1]};", - $properties, - )) . '}'; - } - - $indent = Syntax::indent($depth + 1); - $lines = array_map( - fn(array $property): string => "{$indent}{$property[0]}: {$property[1]};", - $properties, - ); - - return "{\n" . implode("\n", $lines) . "\n" . Syntax::indent($depth) . '}'; + return '{' . implode('', array_map( + fn(array $property): string => "{$property[0]}:{$property[1]};", + $properties, + )) . '}'; } /** * @param UnionNode $node */ - private function union(UnionNode $node, EmissionContext $context, int $depth): string + private function union(UnionNode $node, EmissionContext $context): string { $members = array_map( - function (NodeInterface $member) use ($context, $depth): string { - $definition = $this->emit($member, $context, $depth); - $declaring = self::declaringNode($member); - - return $declaring instanceof UnionNode || $declaring instanceof IntersectionNode - ? "({$definition})" - : $definition; - }, + fn($member): string => $this->emit($member, $context), $node->types, ); // Distinct schema nodes can render to the same type: `int|float` is one `number`. - return implode($context->options->pretty ? ' | ' : '|', array_unique($members)); + return implode('|', array_unique($members)) |> Syntax::wrapInParentheses(...); } - private function intersection(IntersectionNode $node, EmissionContext $context, int $depth): string + private function intersection(IntersectionNode $node, EmissionContext $context): string { $members = array_map( - function (NodeInterface $member) use ($context, $depth): string { - $definition = $this->emit($member, $context, $depth); - - return self::declaringNode($member) instanceof UnionNode - ? "({$definition})" - : $definition; - }, + fn($member): string => $this->emit($member, $context), $node->types, ); - return implode($context->options->pretty ? ' & ' : '&', $members); + return implode('&', $members) |> Syntax::wrapInParentheses(...); } - private function tuple(TupleNode $node, EmissionContext $context, int $depth): string + private function tuple(TupleNode $node, EmissionContext $context): string { $members = array_map( - fn(NodeInterface $member): string => $this->emit($member, $context, $depth), + fn(NodeInterface $member): string => $this->emit($member, $context), $node->types, ); - return '[' . implode($context->options->pretty ? ', ' : ',', $members) . ']'; - } - - /** - * Constraints are invisible in TypeScript, so precedence is decided by what they wrap. - */ - private static function declaringNode(NodeInterface $node): NodeInterface - { - while ($node instanceof ConstraintNode) { - $node = $node->node; - } - return $node; + return '[' . implode(',', $members) . ']'; } } diff --git a/src/Typescript/Utils/Syntax.php b/src/Typescript/Utils/Syntax.php index 6501758..832a33a 100644 --- a/src/Typescript/Utils/Syntax.php +++ b/src/Typescript/Utils/Syntax.php @@ -10,7 +10,10 @@ */ final class Syntax { - private const string INDENT = ' '; + public static function isValidIdentifier(string $name): bool + { + return preg_match('/^[A-Za-z_$][A-Za-z0-9_$]*$/', $name) === 1; + } /** * Bare identifiers stay bare; anything else is quoted so it survives as a key. @@ -29,24 +32,16 @@ public static function stringLiteral(string $value): string return json_encode($value, JSON_THROW_ON_ERROR); } - /** - * The alias a brand is exported under, e.g. `email` => `Email`. - */ - public static function brandAlias(string $brandName): string + public static function wrapInParentheses(string $value): string { - return ucfirst($brandName); + return "({$value})"; } /** - * The full definition of a branded type, e.g. `string & Brand<"email">`. + * A branded type, e.g. `string & Brand<"email">`. */ public static function branded(string $baseType, string $brandName): string { return "{$baseType} & Brand<" . self::stringLiteral($brandName) . ">"; } - - public static function indent(int $level): string - { - return str_repeat(self::INDENT, $level); - } } diff --git a/src/Utils/Nodes.php b/src/Utils/Nodes.php index ce2874b..b159be4 100644 --- a/src/Utils/Nodes.php +++ b/src/Utils/Nodes.php @@ -5,7 +5,7 @@ use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; -use Le0daniel\PhpTsBindings\Parser\Nodes\NamedNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; @@ -13,7 +13,20 @@ final class Nodes { public static function getDeclaringNode(NodeInterface $node): NodeInterface { - while ($node instanceof ConstraintNode || $node instanceof NamedNode) { + while ($node instanceof ConstraintNode || $node instanceof MetadataNode) { + $node = $node->node; + } + return $node; + } + + /** + * Strips codegen metadata only. Unlike getDeclaringNode(), constraints stay attached — use + * this where a ConstraintNode must remain visible, e.g. so a constrained array key is + * rejected instead of silently losing its runtime validation. + */ + public static function unwrapMetadata(NodeInterface $node): NodeInterface + { + while ($node instanceof MetadataNode) { $node = $node->node; } return $node; diff --git a/tests/Mocks/Named/AsymmetricNamed.php b/tests/Mocks/Named/AsymmetricNamed.php new file mode 100644 index 0000000..fee7d2f --- /dev/null +++ b/tests/Mocks/Named/AsymmetricNamed.php @@ -0,0 +1,23 @@ +visible = strrev($secret); + } +} diff --git a/tests/Mocks/Named/BrandedPayload.php b/tests/Mocks/Named/BrandedPayload.php new file mode 100644 index 0000000..908d429 --- /dev/null +++ b/tests/Mocks/Named/BrandedPayload.php @@ -0,0 +1,16 @@ +)` + * and referenced by name at every use site. No explicit io — on a value object #[Named] defaults + * to IO::BOTH. + */ +#[Brand('accountId')] +#[Named('AccountId')] +final readonly class NamedValueObject implements StringValueObject +{ + private function __construct(public string $value) + { + } + + public static function fromStringValue(string $value): static + { + return new self($value); + } + + public function toStringValue(): string + { + return $this->value; + } +} diff --git a/tests/Mocks/Named/Order.php b/tests/Mocks/Named/Order.php new file mode 100644 index 0000000..04c8d3a --- /dev/null +++ b/tests/Mocks/Named/Order.php @@ -0,0 +1,18 @@ +generateOptimizedCode(['node' => $sortedNode]); - - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ - $registry = eval("return {$optimizedCode};"); - - $generator = new TypescriptGenerator(); - $unbranded = new Options(pretty: $options->pretty, ignoreBrandedTypes: true); - - expect($generator->toTypescript($registry->get('node'), $io, $unbranded)->type) - ->toEqual($generator->toTypescript($sortedNode, $io, $unbranded)->type); + compareToOptimizedAst($sortedNode); - return $generator->toTypescript($sortedNode, $io, $options); + return new TypescriptGenerator()->toTypescript($sortedNode, $io, $sharedRegistry); } function executeParse(NodeInterface|string $node, mixed $data, ParsingOptions $options = new ParsingOptions()): Success|Failure diff --git a/tests/Unit/CodeGen/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php new file mode 100644 index 0000000..ae4bb89 --- /dev/null +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -0,0 +1,72 @@ +generateOperationCode( + $typedOperation, + new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + ); + + return [ + $block->code, + array_map(fn(TypescriptImportStatement $import): string => implode(PHP_EOL, $import->toStatements()), $block->imports ?? []), + ]; +} + +function queryOperation(): Operation +{ + $parser = new TypeParser(); + return new Operation( + key: 'orders.get', + definition: new Definition(OperationType::QUERY, Email::class, 'getOrder', 'get', 'orders', []), + input: $parser->parse('array{id: int}'), + output: $parser->parse('string'), + ); +} + +test('imports the aliases the inlined input definition carries', function () { + [$code, $imports] = queryKeyCodeFor(new TypedOperation( + new TypeScript('{status:OrderStatus;}', new TypeRegistry(['OrderStatus' => '"OPEN"|"SHIPPED"'])), + new TypeScript('Order', new TypeRegistry(['Order' => '{id:number;}'])), + TypeScript::fromRawString(''), + queryOperation(), + )); + + expect($code)->toContain('export function getQueryKey(input: {status:OrderStatus;})') + ->and($imports)->toContain("import type {Brand, OrderStatus} from './lib/types';") + // The output-only alias is not referenced by the query key. + ->and(implode("\n", $imports))->not->toContain('Order,'); +}); + +test('always imports the Brand helper, whether the input renders an inline brand or not', function () { + [, $withBrand] = queryKeyCodeFor(new TypedOperation( + new TypeScript('{id:number & Brand<"customerId">;}', new TypeRegistry()), + TypeScript::fromRawString('string'), + TypeScript::fromRawString(''), + queryOperation(), + )); + + [, $withoutBrand] = queryKeyCodeFor(new TypedOperation( + TypeScript::fromRawString('{id:number;}'), + TypeScript::fromRawString('string'), + TypeScript::fromRawString(''), + queryOperation(), + )); + + expect($withBrand)->toContain("import type {Brand} from './lib/types';") + ->and($withoutBrand)->toContain("import type {Brand} from './lib/types';"); +}); diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 256d2dc..806579f 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -10,16 +10,19 @@ use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\Options; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; +use Tests\Mocks\Named\Order; +use Tests\Mocks\Named\OrderStatus; use Tests\Mocks\ValueObjects\Email; use Tests\Mocks\ValueObjects\Slug; use Tests\Mocks\ValueObjects\UserId; /** - * Mirrors how TypescriptServerCodeGenerator builds a TypedOperation: both directions collect their - * aliases into one registry, which is what EmitTypes reads. + * Mirrors how TypescriptServerCodeGenerator wires EmitTypes: both directions emit into the run's + * shared registry, which is what the types file declares. */ function emitTypesFor(string $inputType, string $outputType): string { @@ -32,50 +35,62 @@ function emitTypesFor(string $inputType, string $outputType): string ); $generator = new TypescriptGenerator(); - $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, new Options(registry: new TypeRegistry())); - $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, new Options(registry: $input->registry)); + $registry = new TypeRegistry(); + $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, $registry); + $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); $files = new EmitTypes()->emitFiles( - [new TypedOperation($input->type, $output->type, '', $operation, $output->registry)], + [new TypedOperation($input, $output, TypeScript::fromRawString(''), $operation)], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + $registry, ); return $files['types']; } -test('branded value objects are exported as branded typescript types', function () { +test('rejects an alias colliding with a declaration the types file always contains', function (string $alias) { + $registry = new TypeRegistry([$alias => '{a:string;}']); + + expect(fn() => new EmitTypes()->emitFiles([], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), $registry)) + ->toThrow(UnsupportedTypeException::class, 'collides with a declaration'); +})->with([ + 'the Brand helper generic' => ['Brand'], + 'the Result envelope' => ['Result'], + 'the TYPE_MAP constant' => ['TYPE_MAP'], +]); + +test('attribute brands stay inline and declare no alias, only the Brand helper is exported', function () { $types = emitTypesFor( 'array{id: \\' . UserId::class . '}', 'array{email: \\' . Email::class . ', slug: \\' . Slug::class . '}', ); - // EmitTypes ucfirst()s the brand name for the alias, so the camelCase brand tag - // "customerId" becomes the exported type CustomerId. expect($types) - ->toContain('export type CustomerId = number & Brand<"customerId">') - ->toContain('export type Email = string & Brand<"email">') - // Slug carries no #[Brand], so it must not produce an exported alias. + ->toContain('export type Brand') + ->not->toContain('export type CustomerId') + ->not->toContain('export type Email') ->not->toContain('Slug'); }); -test('branded value objects nested in lists and unions are still collected', function () { +test('named types are exported once, nested aliases and inline brands included', function () { $types = emitTypesFor( - 'array{ids: list<\\' . UserId::class . '>}', - 'array{email: ?\\' . Email::class . '}', + 'array{status: \\' . OrderStatus::class . '}', + '\\' . Order::class, ); expect($types) - ->toContain('export type CustomerId = number & Brand<"customerId">') - ->toContain('export type Email = string & Brand<"email">'); + ->toContain('export type Customer = {email:(string & Brand<"email">);name:string;}') + ->toContain('export type Order = {customer:Customer;id:(number & Brand<"customerId">);}') + ->toContain('export type OrderStatus = ("OPEN"|"SHIPPED")'); }); -test('the existing BrandedString utility type still emits alongside value objects', function () { +test('the BrandedString utility type keeps its implicit alias', function () { $types = emitTypesFor( 'array{token: BrandedString<\'token\'>}', 'array{email: \\' . Email::class . '}', ); expect($types) - ->toContain('export type Token = string & Brand<"token">') - ->toContain('export type Email = string & Brand<"email">'); + ->toContain('export type Token = (string & Brand<"token">)') + ->not->toContain('export type Email'); }); diff --git a/tests/Unit/CodeGen/Mocks/ConflictingNamedOperations.php b/tests/Unit/CodeGen/Mocks/ConflictingNamedOperations.php new file mode 100644 index 0000000..508fa4f --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/ConflictingNamedOperations.php @@ -0,0 +1,33 @@ + OrderStatus::OPEN]; + } +} diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 83ff168..a813261 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -12,8 +12,9 @@ use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; use Le0daniel\PhpTsBindings\Server\Server; -use Le0daniel\PhpTsBindings\Typescript\Data\Options; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; +use Tests\Unit\CodeGen\Mocks\ConflictingNamedOperations; +use Tests\Unit\CodeGen\Mocks\NamedOperations; use Tests\Unit\CodeGen\Mocks\UnrepresentableOperations; use Tests\Unit\CodeGen\Mocks\UserOperations; @@ -21,7 +22,7 @@ * @param list $classes * @return array */ -function generateFor(array $classes, Options $options = new Options()): array +function generateFor(array $classes): array { $server = new Server( EagerlyLoadedRegistry::withClasses($classes, keyGenerator: new PlainlyExposedKeyGenerator()), @@ -35,44 +36,61 @@ function generateFor(array $classes, Options $options = new Options()): array new EmitTypeUtils(), new EmitOperations(), ], - options: $options, )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); } -test('declares every referenced brand once in lib/types.ts', function () { +test('attribute brands declare no aliases in lib/types.ts, only the Brand helper', function () { $files = generateFor([UserOperations::class]); expect($files)->toHaveKey('lib/types.ts') ->and($files['lib/types.ts']->toString()) - ->toContain('export type CustomerId = number & Brand<"customerId">') - ->toContain('export type Email = string & Brand<"email">') - // Slug carries no #[Brand], so it registers no alias. + ->toContain('export type Brand') + ->not->toContain('export type CustomerId') + ->not->toContain('export type Email') ->not->toContain('Slug'); }); -test('references brands by alias in the operation types and imports them', function () { +test('renders brands inline in the operation types and imports the Brand helper', function () { $files = generateFor([UserOperations::class]); $operations = $files['users.ts']->toString(); expect($operations) - ->toContain('export type GetInput = {id:CustomerId;};') - ->toContain('export type GetResult = {email:Email;slug:string;};') + ->toContain('export type GetInput = {id:(number & Brand<"customerId">);};') + ->toContain('export type GetResult = {email:(string & Brand<"email">);slug:string;};') ->toContain('export type CreateInput = {name:string;};') - ->toContain('export type CreateResult = {id:CustomerId;};') - ->toContain("import {type CustomerId, type Email} from './lib/types';"); + ->toContain('export type CreateResult = {id:(number & Brand<"customerId">);};') + ->toContain("import type {Brand} from './lib/types';"); }); -test('emits the backing primitives and no type import when brands are ignored', function () { - $files = generateFor([UserOperations::class], new Options(ignoreBrandedTypes: true)); - $operations = $files['users.ts']->toString(); +test('declares named types once in lib/types.ts, nested aliases and inline brands included', function () { + $files = generateFor([NamedOperations::class]); + + expect($files['lib/types.ts']->toString()) + ->toContain('export type Customer = {email:(string & Brand<"email">);name:string;}') + ->toContain('export type Order = {customer:Customer;id:(number & Brand<"customerId">);}') + ->toContain('export type OrderStatus = ("OPEN"|"SHIPPED")') + ->not->toContain('export type Email') + ->not->toContain('export type CustomerId'); +}); + +test('references named types by alias and imports every alias the operation relies on', function () { + $operations = generateFor([NamedOperations::class])['orders.ts']->toString(); + // Every alias in the operation's registries is imported — Customer comes along as Order's + // dependency. Brand is always imported; a linter drops it where unused. Email is an unnamed + // brand, so it is no alias at all and never appears. expect($operations) - ->toContain('export type GetInput = {id:number;};') - ->toContain('export type GetResult = {email:string;slug:string;};') - ->not->toContain("from './lib/types'") - ->and($files['lib/types.ts']->toString()) - ->not->toContain('export type CustomerId') - ->not->toContain('export type Email'); + ->toContain('export type GetResult = Order;') + ->toContain('export type GetInput = {status:OrderStatus;};') + ->toContain('export type StatusResult = {status:OrderStatus;};') + ->toContain('export type StatusInput = {id:(number & Brand<"customerId">);};') + ->toContain("import type {Brand, Customer, Order, OrderStatus} from './lib/types'") + ->not->toContain('Email'); +}); + +test('fails the run when two classes resolve to the same name with different shapes', function () { + expect(fn() => generateFor([ConflictingNamedOperations::class])) + ->toThrow(UnsupportedTypeException::class, 'Customer'); }); test('fails the whole run when an operation input has no TypeScript representation', function () { diff --git a/tests/Unit/Parser/NamedTypeTest.php b/tests/Unit/Parser/NamedTypeTest.php new file mode 100644 index 0000000..c80c045 --- /dev/null +++ b/tests/Unit/Parser/NamedTypeTest.php @@ -0,0 +1,92 @@ +parse(Customer::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->name?->name)->toBe('Customer') + ->and($node->name?->io)->toBe(IO::OUTPUT) + ->and($node->brand)->toBeNull() + ->and($node->node)->toBeInstanceOf(CustomCastingNode::class); + + compareToOptimizedAst($node); + validateAst($node); +}); + +test('an explicit name wins over the base name', function () { + $node = new TypeParser()->parse(RenamedThing::class); + + expect($node->name?->name)->toBe('CustomThing'); +}); + +test('a class without codegen attributes carries no metadata wrapper', function () { + $node = new TypeParser()->parse(\Tests\Mocks\ValueObjects\CreateAccountInput::class); + + expect($node)->toBeInstanceOf(CustomCastingNode::class); +}); + +test('#[Named] on an enum defaults to IO::BOTH, its shape is identical in both directions', function () { + $node = new TypeParser()->parse(OrderStatus::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->name?->name)->toBe('OrderStatus') + ->and($node->name?->io)->toBe(IO::BOTH) + ->and($node->node)->toBeInstanceOf(EnumNode::class); + + compareToOptimizedAst($node); +}); + +test('a value object can combine #[Brand] and #[Named]', function () { + $node = new TypeParser()->parse(NamedValueObject::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->name?->name)->toBe('AccountId') + ->and($node->name?->io)->toBe(IO::BOTH) + ->and($node->brand)->toBe('accountId') + ->and($node->node)->toBeInstanceOf(ValueObjectNode::class); +}); + +test('metadata is transparent in the string form and eliminated from the optimized AST', function () { + $node = new TypeParser()->parse(Order::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and((string)$node)->toBe((string)$node->node) + ->and($node->exportPhpCode())->not->toContain('MetadataNode'); + + $sortedNode = AstSorter::sort($node); + $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $sortedNode]); + + /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + $registry = eval("return {$optimizedCode};"); + + expect($optimizedCode)->not->toContain('MetadataNode') + ->and($registry->get('node'))->not->toBeInstanceOf(MetadataNode::class) + ->and((string)$registry->get('node'))->toBe((string)$sortedNode); +}); + +test('rejects a name that is not a valid TypeScript identifier', function () { + expect(fn() => new TypeParser()->parse(InvalidlyNamed::class)) + ->toThrow(InvalidStringLiteralException::class, 'not a valid TypeScript identifier'); +}); + +test('rejects a brand tag that is not a valid TypeScript identifier', function () { + expect(fn() => new TypeParser()->parse(InvalidlyBranded::class)) + ->toThrow(InvalidStringLiteralException::class, 'not a valid TypeScript identifier'); +}); diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 2ca6b90..5be78a1 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -19,6 +19,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; @@ -26,7 +27,6 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\Options; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Le0daniel\PhpTsBindings\Validators\Email; @@ -420,6 +420,25 @@ compareToOptimizedAst($node); }); +test('a constrained array key is rejected, the constraint would be silently unenforceable', function (string $type) { + expect(fn() => new TypeParser()->parse($type)) + ->toThrow(InvalidSyntaxException::class, "Array key type must be 'string' or 'int'"); +})->with([ + 'non-empty-string key' => ['array'], + 'positive-int key' => ['array'], +]); + +test('a branded array key is still a plain string or int key on the wire', function (string $type, string $expected) { + $node = new TypeParser()->parse($type); + + expect($node::class)->toBe($expected); + + compareToOptimizedAst($node); +})->with([ + 'branded string key' => ["array, int>", RecordNode::class], + 'branded int key' => ["array, string>", ListNode::class], +]); + test('Test simple literals', function () { $parser = new TypeParser(); /** @var UnionNode $node */ @@ -658,7 +677,7 @@ 'Omit from object' => ['{id:string;}', 'Omit'], 'Pick from class' => ['{username:string;}', 'Pick<' . UserMock::class . ', "username">'], 'Omit from class' => ['{email:string;username:string;}', 'Omit<' . UserMock::class . ', "age">'], - 'Simple Pick with optional' => ['{name?:string|null;}', 'Pick'], + 'Simple Pick with optional' => ['{name?:(string|null);}', 'Pick'], 'Simple Omit with optional' => ['{id?:string;}', 'Omit'], ]); @@ -683,7 +702,6 @@ }); test("parse BrandedInt correctly", function () { - // Branded types are optimized away. They have no runtime Impact $parser = new TypeParser(); $node = $parser->parse("BrandedInt<'wow'>"); compareToOptimizedAst($node); @@ -691,17 +709,11 @@ foreach ([IO::INPUT, IO::OUTPUT] as $io) { $branded = typescriptFor($node, $io); expect($branded->type)->toBe('Wow') - ->and($branded->registry->toArray())->toBe(['Wow' => 'number & Brand<"wow">']) - ->and($branded->toStandaloneType())->toBe('number & Brand<"wow">'); - - $unbranded = typescriptFor($node, $io, new Options(ignoreBrandedTypes: true)); - expect($unbranded->type)->toBe('number') - ->and($unbranded->registry->isEmpty())->toBeTrue(); + ->and($branded->registry->toArray())->toBe(['Wow' => '(number & Brand<"wow">)']); } }); test("parse BrandedString correctly", function () { - // Branded types are optimized away. They have no runtime Impact $parser = new TypeParser(); $node = $parser->parse("BrandedString<'wow'>"); compareToOptimizedAst($node); @@ -709,26 +721,35 @@ foreach ([IO::INPUT, IO::OUTPUT] as $io) { $branded = typescriptFor($node, $io); expect($branded->type)->toBe('Wow') - ->and($branded->registry->toArray())->toBe(['Wow' => 'string & Brand<"wow">']) - ->and($branded->toStandaloneType())->toBe('string & Brand<"wow">'); - - $unbranded = typescriptFor($node, $io, new Options(ignoreBrandedTypes: true)); - expect($unbranded->type)->toBe('string') - ->and($unbranded->registry->isEmpty())->toBeTrue(); + ->and($branded->registry->toArray())->toBe(['Wow' => '(string & Brand<"wow">)']); } }); -test('brands are code generation metadata and stay out of the string form and exported php code', function () { +test('rejects a branded utility tag that is not a valid TypeScript identifier', function (string $type) { + expect(fn() => new TypeParser()->parse($type)) + ->toThrow( + \Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException::class, + 'not a valid TypeScript identifier', + ); +})->with([ + 'BrandedString' => ["BrandedString<'not valid'>"], + 'BrandedInt' => ["BrandedInt<'not valid'>"], +]); + +test('codegen metadata is transparent in the string form and the exported php code', function () { $parser = new TypeParser(); $string = $parser->parse("BrandedString<'wow'>"); $int = $parser->parse("BrandedInt<'wow'>"); - expect($string)->toBeInstanceOf(StringNode::class) + expect($string)->toBeInstanceOf(MetadataNode::class) ->and($string->brand)->toBe('wow') + ->and($string->name?->name)->toBe('Wow') + ->and($string->node)->toBeInstanceOf(StringNode::class) ->and((string)$string)->toBe('string') ->and($string->exportPhpCode())->not->toContain('wow') - ->and($int)->toBeInstanceOf(IntNode::class) + ->and($int)->toBeInstanceOf(MetadataNode::class) ->and($int->brand)->toBe('wow') + ->and($int->node)->toBeInstanceOf(IntNode::class) ->and((string)$int)->toBe('int') ->and($int->exportPhpCode())->not->toContain('wow'); }); @@ -812,8 +833,8 @@ compareToOptimizedAst($node); expect(typescriptFor($node, IO::OUTPUT)->type)->toBe($expectedDefinition); })->with([ - 'nullable' => ["DateTimeString<'Y-m-d'>|null", 'string|null'], - 'questionmark nullable' => ["?DateTimeString<'Y-m-d'>", 'null|string'], + 'nullable' => ["DateTimeString<'Y-m-d'>|null", '(string|null)'], + 'questionmark nullable' => ["?DateTimeString<'Y-m-d'>", '(null|string)'], 'in a struct' => ["array{createdAt: DateTimeString<'Y-m-d'>}", '{createdAt:string;}'], 'in a list' => ['list', 'Array'], 'bracket list' => ["DateTimeString<'Y-m-d'>[]", 'Array'], diff --git a/tests/Unit/Parser/ValueObjectConsumerTest.php b/tests/Unit/Parser/ValueObjectConsumerTest.php index 1db9cf5..358a975 100644 --- a/tests/Unit/Parser/ValueObjectConsumerTest.php +++ b/tests/Unit/Parser/ValueObjectConsumerTest.php @@ -5,12 +5,12 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BackingType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\ValueObjectNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\Options; use Tests\Mocks\ValueObjects\AbstractValueObject; use Tests\Mocks\ValueObjects\AmbiguousValueObject; use Tests\Mocks\ValueObjects\CreateAccountInput; @@ -19,13 +19,15 @@ use Tests\Mocks\ValueObjects\StatusEnum; use Tests\Mocks\ValueObjects\UserId; -test('parses a string value object', function () { +test('parses a string value object into a brand-carrying metadata wrapper', function () { $node = new TypeParser()->parse(Email::class); - expect($node)->toBeInstanceOf(ValueObjectNode::class) - ->and($node->className)->toBe(Email::class) - ->and($node->backingType)->toBe(BackingType::STRING) - ->and($node->brand)->toBe('email'); + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->brand)->toBe('email') + ->and($node->name)->toBeNull() + ->and($node->node)->toBeInstanceOf(ValueObjectNode::class) + ->and($node->node->className)->toBe(Email::class) + ->and($node->node->backingType)->toBe(BackingType::STRING); compareToOptimizedAst($node); validateAst($node); @@ -34,10 +36,11 @@ test('parses an int value object with an explicit brand name', function () { $node = new TypeParser()->parse(UserId::class); - expect($node)->toBeInstanceOf(ValueObjectNode::class) - ->and($node->className)->toBe(UserId::class) - ->and($node->backingType)->toBe(BackingType::INT) - ->and($node->brand)->toBe('customerId'); + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->brand)->toBe('customerId') + ->and($node->node)->toBeInstanceOf(ValueObjectNode::class) + ->and($node->node->className)->toBe(UserId::class) + ->and($node->node->backingType)->toBe(BackingType::INT); compareToOptimizedAst($node); validateAst($node); @@ -45,18 +48,17 @@ test('the default brand name is lcfirst of the base class name', function (string $type, ?string $expected) { $node = new TypeParser()->parse($type); - expect($node->brand)->toBe($expected); + expect($node instanceof MetadataNode ? $node->brand : null)->toBe($expected); })->with([ 'bare #[Brand] on Email' => [Email::class, 'email'], 'explicit #[Brand(customerId)]' => [UserId::class, 'customerId'], 'no attribute' => [Slug::class, null], ]); -test('a value object without the Brand attribute is not branded', function () { +test('a value object without codegen attributes stays a bare node', function () { $node = new TypeParser()->parse(Slug::class); - expect($node)->toBeInstanceOf(ValueObjectNode::class) - ->and($node->brand)->toBeNull(); + expect($node)->toBeInstanceOf(ValueObjectNode::class); compareToOptimizedAst($node); }); @@ -74,15 +76,15 @@ test('resolves value objects through the namespace of the parsing context', function () { $node = new TypeParser()->parse('Email', new ParsingContext('Tests\\Mocks\\ValueObjects')); - expect($node)->toBeInstanceOf(ValueObjectNode::class) - ->and($node->className)->toBe(Email::class); + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->node->className)->toBe(Email::class); }); test('resolves value objects through a use-statement alias', function () { $node = new TypeParser()->parse('Mail', new ParsingContext('Some\\Space', ['Mail' => Email::class])); - expect($node)->toBeInstanceOf(ValueObjectNode::class) - ->and($node->className)->toBe(Email::class); + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->node->className)->toBe(Email::class); }); test('value objects compose with the rest of the grammar', function (string $type, string $expected) { @@ -127,27 +129,18 @@ expect($parser->parse(Slug::class))->toBeInstanceOf(ValueObjectNode::class); }); -test('value objects emit their backing primitive when brands are disabled', function () { - $node = new TypeParser()->parse(Email::class); - $options = new Options(ignoreBrandedTypes: true); - - expect(typescriptFor($node, IO::OUTPUT, $options)->type)->toBe('string'); - expect(typescriptFor($node, IO::INPUT, $options)->type)->toBe('string'); -}); - -test('value objects emit branded types when brands are enabled', function (string $type, string $alias, string $expected) { +test('value objects emit their brand inline in both directions', function (string $type, string $expected) { $node = new TypeParser()->parse($type); foreach ([IO::INPUT, IO::OUTPUT] as $io) { - // The use site carries the alias, the definition travels in the registry, and inlining the - // one into the other reproduces the full branded type. - expect(typescriptFor($node, $io)->type)->toBe($alias); - expect(typescriptFor($node, $io)->toStandaloneType())->toBe($expected); + $result = typescriptFor($node, $io); + expect($result->type)->toBe($expected) + ->and($result->registry->isEmpty())->toBeTrue(); } })->with([ - 'string vo' => [Email::class, 'Email', 'string & Brand<"email">'], - 'int vo renamed' => [UserId::class, 'CustomerId', 'number & Brand<"customerId">'], - 'unbranded vo' => [Slug::class, 'string', 'string'], + 'string vo' => [Email::class, '(string & Brand<"email">)'], + 'int vo renamed' => [UserId::class, '(number & Brand<"customerId">)'], + 'unbranded vo' => [Slug::class, 'string'], ]); test('a castable class carrying value object properties', function () { @@ -156,10 +149,19 @@ expect($node)->toBeInstanceOf(CustomCastingNode::class); foreach ([IO::INPUT, IO::OUTPUT] as $io) { - expect(typescriptFor($node, $io)->type)->toBe('{email:Email;ownerId:CustomerId;}'); - expect(typescriptFor($node, $io, new Options(ignoreBrandedTypes: true))->type) - ->toBe('{email:string;ownerId:number;}'); + expect(typescriptFor($node, $io)->type) + ->toBe('{email:(string & Brand<"email">);ownerId:(number & Brand<"customerId">);}'); } compareToOptimizedAst($node); }); + +test('branded value objects execute like their bare counterpart', function () { + $parsed = executeParse(Email::class, 'user@example.com'); + expect($parsed)->toBeSuccess() + ->and($parsed->value)->toBeInstanceOf(Email::class); + + $serialized = executeSerialize(Email::class, Email::fromStringValue('user@example.com')); + expect($serialized)->toBeSuccess() + ->and($serialized->value)->toBe('user@example.com'); +}); diff --git a/tests/Unit/Typescript/NamedTypesTest.php b/tests/Unit/Typescript/NamedTypesTest.php new file mode 100644 index 0000000..1dcb199 --- /dev/null +++ b/tests/Unit/Typescript/NamedTypesTest.php @@ -0,0 +1,187 @@ +parse(Customer::class); + $result = typescriptFor($node, IO::OUTPUT); + + expect($result->type)->toBe('Customer') + ->and($result->registry->usedAliases())->toBe(['Customer']) + ->and($result->registry->toArray())->toBe([ + 'Customer' => '{email:(string & Brand<"email">);name:string;}', + ]); +}); + +test('a named class defaults to output only and is inlined on input', function () { + $node = new TypeParser()->parse(Customer::class); + $shared = new TypeRegistry(); + + $result = new TypescriptGenerator()->toTypescript($node, IO::INPUT, $shared); + + expect($result->type)->toBe('{email:(string & Brand<"email">);name:string;}') + ->and($result->registry->isEmpty())->toBeTrue() + // The name never registers for a direction it does not apply to. + ->and($shared->has('Customer'))->toBeFalse(); +}); + +test('named types nest recursively: the outer definition references the inner alias', function () { + $node = new TypeParser()->parse(Order::class); + $result = typescriptFor($node, IO::OUTPUT); + + expect($result->type)->toBe('Order') + ->and($result->registry->usedAliases())->toBe(['Customer', 'Order']) + ->and($result->registry->toArray())->toBe([ + 'Customer' => '{email:(string & Brand<"email">);name:string;}', + 'Order' => '{customer:Customer;id:(number & Brand<"customerId">);}', + ]); +}); + +test('on input a named-by-default tree is fully inlined and registers nothing', function () { + $node = new TypeParser()->parse(Order::class); + $result = typescriptFor($node, IO::INPUT); + + expect($result->type)->toBe('{customer:{email:(string & Brand<"email">);name:string;};id:(number & Brand<"customerId">);}') + ->and($result->registry->isEmpty())->toBeTrue(); +}); + +test('a use site inside a struct references the alias and carries its dependencies as used', function () { + $node = new TypeParser()->parse('array{order: \\' . Order::class . '}'); + $result = typescriptFor($node, IO::OUTPUT); + + expect($result->type)->toBe('{order:Order;}') + ->and($result->registry->usedAliases())->toBe(['Customer', 'Order']); +}); + +test('a brand on a whole class intersects the object shape inline', function () { + $node = new TypeParser()->parse(BrandedPayload::class); + + foreach ([IO::INPUT, IO::OUTPUT] as $io) { + $result = typescriptFor($node, $io); + expect($result->type)->toBe('({value:string;} & Brand<"payload">)') + ->and($result->registry->isEmpty())->toBeTrue(); + } +}); + +test('Brand and Named combined export the branded type once under the alias', function () { + $node = new TypeParser()->parse(NamedValueObject::class); + + foreach ([IO::INPUT, IO::OUTPUT] as $io) { + $result = typescriptFor($node, $io); + expect($result->type)->toBe('AccountId') + ->and($result->registry->usedAliases())->toBe(['AccountId']) + ->and($result->registry->toArray())->toBe(['AccountId' => '(string & Brand<"accountId">)']); + } +}); + +test('an explicit name is used verbatim', function () { + $result = typescriptFor(new TypeParser()->parse(RenamedThing::class), IO::OUTPUT); + + expect($result->type)->toBe('CustomThing') + ->and($result->registry->toArray())->toBe(['CustomThing' => '{value:string;}']); +}); + +test('a named enum with IO::BOTH is aliased identically in both directions', function () { + $node = new TypeParser()->parse(OrderStatus::class); + + foreach ([IO::INPUT, IO::OUTPUT] as $io) { + $result = typescriptFor($node, $io); + expect($result->type)->toBe('OrderStatus') + ->and($result->registry->toArray())->toBe(['OrderStatus' => '("OPEN"|"SHIPPED")']); + } +}); + +test('without a shared registry each pass stands alone and never conflicts with another', function () { + $node = new TypeParser()->parse(AsymmetricNamed::class); + $generator = new TypescriptGenerator(); + + $input = $generator->toTypescript($node, IO::INPUT); + $output = $generator->toTypescript($node, IO::OUTPUT); + + expect($input->registry->toArray())->toBe(['AsymmetricNamed' => '{secret:string;}']) + ->and($output->registry->toArray())->toBe(['AsymmetricNamed' => '{visible:string;}']); +}); + +test('IO::BOTH fails hard when the input and output shapes differ', function () { + $node = new TypeParser()->parse(AsymmetricNamed::class); + $generator = new TypescriptGenerator(); + $shared = new TypeRegistry(); + + expect($generator->toTypescript($node, IO::INPUT, $shared)->type)->toBe('AsymmetricNamed'); + + expect(fn() => $generator->toTypescript($node, IO::OUTPUT, $shared)) + ->toThrow(UnsupportedTypeException::class, 'IO::BOTH'); +}); + +test('a named interface works on output and stays uncastable on input', function () { + $node = new TypeParser()->parse(PublicResource::class); + + $result = typescriptFor($node, IO::OUTPUT); + expect($result->type)->toBe('PublicResource') + ->and($result->registry->toArray())->toBe(['PublicResource' => '{url:string;}']); + + expect(fn() => typescriptFor($node, IO::INPUT)) + ->toThrow(UnsupportedTypeException::class, PublicResource::class); +}); + +test('Pick over a named class produces a new shape and drops the alias', function () { + $result = typescriptFor( + new TypeParser()->parse('Pick<\\' . Customer::class . ", 'name'>"), + IO::OUTPUT, + ); + + expect($result->type)->toBe('{name:string;}') + ->and($result->registry->isEmpty())->toBeTrue(); +}); + +test('emitting for IO::BOTH is rejected', function () { + expect(fn() => new TypescriptGenerator()->toTypescript(new StringNode(), IO::BOTH)) + ->toThrow(InvalidArgumentException::class, 'IO::BOTH'); +}); + +test('two named nodes claiming one alias with different shapes are rejected', function () { + $inner = new MetadataNode(new StringNode(), new NamedType('Cycle', IO::BOTH)); + $outer = new MetadataNode( + new StructNode(StructPhpType::ARRAY, [ + new PropertyNode('self', $inner, false, PropertyType::BOTH), + ]), + new NamedType('Cycle', IO::BOTH), + ); + + expect(fn() => new TypescriptGenerator()->toTypescript($outer, IO::OUTPUT)) + ->toThrow(UnsupportedTypeException::class, 'Cycle'); +}); + +test('cached ASTs are metadata free and emit the plain structural type', function () { + $sortedNode = AstSorter::sort(new TypeParser()->parse(Order::class)); + $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $sortedNode]); + + /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + $registry = eval("return {$optimizedCode};"); + + $result = new TypescriptGenerator()->toTypescript($registry->get('node'), IO::OUTPUT); + + expect($result->type)->toBe('{customer:{email:string;name:string;};id:number;}') + ->and($result->registry->isEmpty())->toBeTrue(); +}); diff --git a/tests/Unit/Typescript/OptimizedAstTest.php b/tests/Unit/Typescript/OptimizedAstTest.php index 25b258f..45b3793 100644 --- a/tests/Unit/Typescript/OptimizedAstTest.php +++ b/tests/Unit/Typescript/OptimizedAstTest.php @@ -43,12 +43,12 @@ function toDefinition(string $typeString, ?IO $io = null): string test('Simple union type', function () { expect(toDefinition('array{name: string}|string')) - ->toBe("{name:string;}|string"); + ->toBe("({name:string;}|string)"); }); test('Optional Fields', function () { expect(toDefinition('array{name?: string}|string')) - ->toBe("{name?:string;}|string"); + ->toBe("({name?:string;}|string)"); }); test('Array type returns object', function () { @@ -73,16 +73,16 @@ function toDefinition(string $typeString, ?IO $io = null): string test('scalar', function () { expect(toDefinition('scalar')) - ->toBe("number|boolean|string"); + ->toBe("(number|boolean|string)"); }); test('intersection type with union', function () { expect(toDefinition('(array{id: positive-int}|array{token: string})&array{reason: string}')) - ->toBe("({id:number;}|{token:string;})&{reason:string;}"); + ->toBe("(({id:number;}|{token:string;})&{reason:string;})"); }); test('Complex union intersection', function () { expect(toDefinition('((array{id: positive-int}|array{token: string})&array{reason: string})|' . UserSchema::class, IO::INPUT)) - ->toBe("(({id:number;}|{token:string;})&{reason:string;})|{age:number;email:string;username:string;}"); + ->toBe("((({id:number;}|{token:string;})&{reason:string;})|{age:number;email:string;username:string;})"); }); }); diff --git a/tests/Unit/Typescript/TypeRegistryTest.php b/tests/Unit/Typescript/TypeRegistryTest.php index 9bb0831..38d731d 100644 --- a/tests/Unit/Typescript/TypeRegistryTest.php +++ b/tests/Unit/Typescript/TypeRegistryTest.php @@ -68,6 +68,15 @@ ->toThrow(UnknownAliasException::class, 'Known aliases: none.'); }); +test('every stored alias counts as used, sorted', function () { + $registry = new TypeRegistry(); + $registry->set('Zulu', 'string'); + $registry->set('Alpha', 'number'); + + expect($registry->usedAliases())->toBe(['Alpha', 'Zulu']) + ->and(new TypeRegistry()->usedAliases())->toBe([]); +}); + test('returns definitions sorted by alias', function () { $registry = new TypeRegistry(); $registry->set('Zulu', 'string'); diff --git a/tests/Unit/Typescript/TypescriptGeneratorTest.php b/tests/Unit/Typescript/TypescriptGeneratorTest.php index 05e8cc2..c4d53b5 100644 --- a/tests/Unit/Typescript/TypescriptGeneratorTest.php +++ b/tests/Unit/Typescript/TypescriptGeneratorTest.php @@ -5,13 +5,11 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\EnumNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\NamedNode; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ReferencedNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\Options; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; @@ -31,11 +29,11 @@ function typescriptOf( string|NodeInterface $type, IO $io = IO::INPUT, - Options $options = new Options(), + ?TypeRegistry $sharedRegistry = null, ): TypeScript { $node = is_string($type) ? new TypeParser()->parse($type) : $type; - return new TypescriptGenerator()->toTypescript($node, $io, $options); + return new TypescriptGenerator()->toTypescript($node, $io, $sharedRegistry); } /** @@ -66,8 +64,8 @@ function typescriptOfBoth(string|NodeInterface $type): string })->with([ 'positive-int is a constrained int' => ['positive-int', 'number'], 'non-empty-string is a constrained string' => ['non-empty-string', 'string'], - 'numeric collapses int|float to one number' => ['numeric', 'number'], - 'scalar dedupes int|float' => ['scalar', 'number|boolean|string'], + 'numeric collapses int|float to one number' => ['numeric', '(number)'], + 'scalar dedupes int|float' => ['scalar', '(number|boolean|string)'], ]); test('emits literal types', function (string $type, string $expected) { @@ -91,7 +89,7 @@ function typescriptOfBoth(string|NodeInterface $type): string ]); test('emits an enum as a union of its case names', function () { - expect(typescriptOfBoth('\\' . ResultEnum::class))->toBe('"SUCCESS"|"FAILURE"'); + expect(typescriptOfBoth('\\' . ResultEnum::class))->toBe('("SUCCESS"|"FAILURE")'); }); test('throws for an enum without cases', function () { @@ -131,74 +129,60 @@ function typescriptOfBoth(string|NodeInterface $type): string 'explicitly keyed tuple' => ['array{0: string, 1: int}', '[string,number]'], ]); -test('emits unions and intersections with correct precedence', function (string $type, string $expected) { +test('emits unions and intersections fully parenthesised', function (string $type, string $expected) { expect(typescriptOfBoth($type))->toBe($expected); })->with([ - 'union' => ['array{name: string}|string', '{name:string;}|string'], - 'nullable' => ['?string', 'null|string'], - 'union dedupes rendered members' => ['int|string|int', 'number|string'], - 'union dedupes equal literals' => ["'a'|'b'|'a'", '"a"|"b"'], + 'union' => ['array{name: string}|string', '({name:string;}|string)'], + 'nullable' => ['?string', '(null|string)'], + 'union dedupes rendered members' => ['int|string|int', '(number|string)'], + 'union dedupes equal literals' => ["'a'|'b'|'a'", '("a"|"b")'], 'union member that is an intersection is parenthesised' => [ 'array{a: string}|(array{b: int}&array{c: bool})', - '{a:string;}|({b:number;}&{c:boolean;})', + '({a:string;}|({b:number;}&{c:boolean;}))', ], 'intersection member that is a union is parenthesised' => [ '(array{id: positive-int}|array{token: string})&array{reason: string}', - '({id:number;}|{token:string;})&{reason:string;}', + '(({id:number;}|{token:string;})&{reason:string;})', ], ]); -test('references a branded alias at the use site and returns its definition', function ( - string $type, - string $expectedType, - array $expectedBrands, -) { +test('an attribute brand renders inline and declares no alias', function (string $type, string $expectedType) { $result = typescriptOf($type); expect($result->type)->toBe($expectedType) - ->and($result->registry->toArray())->toBe($expectedBrands); + ->and($result->registry->isEmpty())->toBeTrue(); })->with([ - 'string value object' => ['\\' . Email::class, 'Email', ['Email' => 'string & Brand<"email">']], - 'int value object aliased by its explicit brand' => [ - '\\' . UserId::class, - 'CustomerId', - ['CustomerId' => 'number & Brand<"customerId">'], + 'string value object' => ['\\' . Email::class, '(string & Brand<"email">)'], + 'int value object with an explicit brand' => ['\\' . UserId::class, '(number & Brand<"customerId">)'], + 'unbranded value object stays a plain string' => ['\\' . Slug::class, 'string'], + 'inside a struct' => [ + '\\' . CreateAccountInput::class, + '{email:(string & Brand<"email">);ownerId:(number & Brand<"customerId">);}', + ], + 'inside a list' => ['list<\\' . Email::class . '>', 'Array<(string & Brand<"email">)>'], + 'inside a union' => ['?\\' . Email::class, '(null|(string & Brand<"email">))'], + 'inside a record' => [ + 'array', + 'Record)>', ], - 'unbranded value object stays a plain string' => ['\\' . Slug::class, 'string', []], - 'BrandedString' => ["BrandedString<'token'>", 'Token', ['Token' => 'string & Brand<"token">']], - 'BrandedInt' => ["BrandedInt<'wow'>", 'Wow', ['Wow' => 'number & Brand<"wow">']], ]); -test('collects brands from any depth of the tree', function (string $type, string $expectedType, array $expectedBrands) { +test('the BrandedString and BrandedInt utilities keep their implicit alias', function ( + string $type, + string $expectedType, + array $expectedAliases, +) { $result = typescriptOf($type); expect($result->type)->toBe($expectedType) - ->and($result->registry->toArray())->toBe($expectedBrands); + ->and($result->registry->toArray())->toBe($expectedAliases); })->with([ - 'inside a struct' => [ - '\\' . CreateAccountInput::class, - '{email:Email;ownerId:CustomerId;}', - ['CustomerId' => 'number & Brand<"customerId">', 'Email' => 'string & Brand<"email">'], - ], - 'inside a list' => [ - 'list<\\' . Email::class . '>', - 'Array', - ['Email' => 'string & Brand<"email">'], - ], - 'inside a union' => [ - '?\\' . Email::class, - 'null|Email', - ['Email' => 'string & Brand<"email">'], - ], - 'inside a record' => [ - 'array', - 'Record', - ['CustomerId' => 'number & Brand<"customerId">'], - ], - 'the same brand used twice is collected once' => [ + 'BrandedString' => ["BrandedString<'token'>", 'Token', ['Token' => '(string & Brand<"token">)']], + 'BrandedInt' => ["BrandedInt<'wow'>", 'Wow', ['Wow' => '(number & Brand<"wow">)']], + 'the same alias used twice is collected once' => [ "array{a: BrandedString<'token'>, b: BrandedString<'token'>}", '{a:Token;b:Token;}', - ['Token' => 'string & Brand<"token">'], + ['Token' => '(string & Brand<"token">)'], ], ]); @@ -214,84 +198,47 @@ function typescriptOfBoth(string|NodeInterface $type): string ->toThrow(UnsupportedTypeException::class, 'Token'); }); -test('toStandaloneType inlines every branded alias', function () { - $result = typescriptOf('\\' . CreateAccountInput::class); - - expect($result->toStandaloneType()) - ->toBe('{email:string & Brand<"email">;ownerId:number & Brand<"customerId">;}'); -}); - -test('toStandaloneType does not let one alias corrupt another that starts with it', function () { - $result = typescriptOf("array{a: BrandedString<'user'>, b: BrandedInt<'userId'>}"); - - expect($result->type)->toBe('{a:User;b:UserId;}') - ->and($result->toStandaloneType()) - ->toBe('{a:string & Brand<"user">;b:number & Brand<"userId">;}'); -}); - -test('toStandaloneType equals the type when nothing is branded', function () { - $result = typescriptOf('array{name: string, tags: list}'); - - expect($result->registry->isEmpty())->toBeTrue() - ->and($result->toStandaloneType())->toBe($result->type); -}); - test('reads a collected alias back out of the registry', function () { - $registry = typescriptOf('\\' . CreateAccountInput::class)->registry; + $registry = typescriptOf("BrandedString<'email'>")->registry; expect($registry->isEmpty())->toBeFalse() ->and($registry->has('Email'))->toBeTrue() - ->and($registry->get('Email'))->toBe('string & Brand<"email">') + ->and($registry->get('Email'))->toBe('(string & Brand<"email">)') ->and($registry->has('Nope'))->toBeFalse(); }); -test('generates against a registry passed in without mutating it', function () { - $shared = new TypeRegistry(['Existing' => 'string & Brand<"existing">']); +test('registers into the passed registry but returns only what the emission needs', function () { + $shared = new TypeRegistry(['Existing' => '(string & Brand<"existing">)']); - $result = typescriptOf('\\' . Email::class, IO::INPUT, new Options(registry: $shared)); + $result = typescriptOf("BrandedString<'email'>", IO::INPUT, $shared); - expect($result->registry->toArray())->toBe([ - 'Email' => 'string & Brand<"email">', - 'Existing' => 'string & Brand<"existing">', + expect($shared->toArray())->toBe([ + 'Email' => '(string & Brand<"email">)', + 'Existing' => '(string & Brand<"existing">)', ]) - ->and($result->registry)->not->toBe($shared) - ->and($shared->toArray())->toBe(['Existing' => 'string & Brand<"existing">']); + ->and($result->registry->toArray())->toBe(['Email' => '(string & Brand<"email">)']) + ->and($result->registry->usedAliases())->toBe(['Email']); }); -test('throws when the incoming registry already binds an alias to something else', function () { - $shared = new TypeRegistry(['Email' => 'number & Brand<"email">']); - - expect(fn() => typescriptOf('\\' . Email::class, IO::INPUT, new Options(registry: $shared))) - ->toThrow(UnsupportedTypeException::class, 'Email'); -}); +test('one shared registry accumulates aliases across emissions', function () { + $shared = new TypeRegistry(); -test('emits the backing primitive when brands are ignored', function (string $type, string $expectedType) { - $result = typescriptOf($type, IO::INPUT, new Options(ignoreBrandedTypes: true)); - - expect($result->type)->toBe($expectedType) - ->and($result->registry->isEmpty())->toBeTrue(); -})->with([ - 'string value object' => ['\\' . Email::class, 'string'], - 'int value object' => ['\\' . UserId::class, 'number'], - 'unbranded value object' => ['\\' . Slug::class, 'string'], - 'BrandedString' => ["BrandedString<'token'>", 'string'], - 'BrandedInt' => ["BrandedInt<'wow'>", 'number'], - 'branded and unbranded mixed in a struct' => [ - 'array{email: \\' . Email::class . ', slug: \\' . Slug::class . ', id: \\' . UserId::class . '}', - '{email:string;slug:string;id:number;}', - ], -]); + $first = typescriptOf("BrandedString<'email'>", IO::INPUT, $shared); + $second = typescriptOf("BrandedInt<'customerId'>", IO::INPUT, $shared); -test('ignoring brands neither reads nor extends an incoming registry', function () { - $shared = new TypeRegistry(['Email' => 'number & Brand<"email">']); + expect($shared->toArray())->toBe([ + 'CustomerId' => '(number & Brand<"customerId">)', + 'Email' => '(string & Brand<"email">)', + ]) + ->and($first->registry->toArray())->toBe(['Email' => '(string & Brand<"email">)']) + ->and($second->registry->toArray())->toBe(['CustomerId' => '(number & Brand<"customerId">)']); +}); - // The seeded definition contradicts what Email would otherwise register, so this would throw - // if the alias were still computed. - $result = typescriptOf('\\' . Email::class, IO::INPUT, new Options(ignoreBrandedTypes: true, registry: $shared)); +test('throws when the incoming registry already binds an alias to something else', function () { + $shared = new TypeRegistry(['Email' => '(number & Brand<"email">)']); - expect($result->type)->toBe('string') - ->and($result->registry->toArray())->toBe(['Email' => 'number & Brand<"email">']) - ->and($shared->toArray())->toBe(['Email' => 'number & Brand<"email">']); + expect(fn() => typescriptOf("BrandedString<'email'>", IO::INPUT, $shared)) + ->toThrow(UnsupportedTypeException::class, 'Email'); }); test('filters struct properties by direction', function () { @@ -327,7 +274,6 @@ function typescriptOfBoth(string|NodeInterface $type): string test('throws for nodes it cannot represent', function (NodeInterface $node) { expect(fn() => typescriptOf($node))->toThrow(UnsupportedTypeException::class); })->with([ - 'NamedNode' => [new NamedNode(new StringNode(), 'Legacy')], 'ReferencedNode' => [new ReferencedNode('#leaf_abc', 'string', 'registry')], 'unknown node implementation' => [new class implements NodeInterface { public function __toString(): string @@ -341,55 +287,3 @@ public function exportPhpCode(): string } }], ]); - -test('pretty prints nested struct literals', function () { - $result = typescriptOf( - 'array{items: list, total: int}', - IO::INPUT, - new Options(pretty: true), - ); - - expect($result->type)->toBe(<<; - total: number; - } - TS); -}); - -test('pretty printing spaces out the remaining separators', function (string $type, string $expected) { - expect(typescriptOf($type, IO::INPUT, new Options(pretty: true))->type)->toBe($expected); -})->with([ - 'union' => ['int|string', 'number | string'], - 'intersection' => ['array{a: int}&array{b: string}', "{\n a: number;\n} & {\n b: string;\n}"], - 'tuple' => ['array{string, int}', '[string, number]'], - 'record' => ['array', 'Record'], - 'list' => ['list', 'Array'], - 'optional key' => ['array{name?: string}', "{\n name?: string;\n}"], - 'quoted key' => ["array{'a b': string}", "{\n \"a b\": string;\n}"], -]); - -test('pretty printing keeps an empty object on one line', function () { - $node = new StructNode(StructPhpType::OBJECT, [ - new PropertyNode('name', new StringNode(), false, PropertyType::OUTPUT), - ]); - - expect(typescriptOf($node, IO::INPUT, new Options(pretty: true))->type)->toBe('{}'); -}); - -test('pretty printing still references branded aliases', function () { - $result = typescriptOf('\\' . CreateAccountInput::class, IO::INPUT, new Options(pretty: true)); - - expect($result->type)->toBe(<<and($result->registry->toArray())->toBe([ - 'CustomerId' => 'number & Brand<"customerId">', - 'Email' => 'string & Brand<"email">', - ]); -}); From a93e128c3a80c7b836116cf07aac491ba0580411 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 10:02:43 +0200 Subject: [PATCH 014/101] Remove `AstSorter` class; refactor to use `ASTOptimizer` for AST sorting and optimization; add comprehensive unit tests for AST optimization, metadata elimination, node diagnostics, and registry shape validation across multiple scenarios. --- src/Contracts/NodeInterface.php | 14 +- src/Executor/SchemaExecutor.php | 14 +- src/Parser/ASTOptimizer.php | 97 ++++++---- src/Parser/AstSorter.php | 56 ------ .../Exceptions/UnknownTypeKeyException.php | 31 ++++ src/Parser/Nodes/ConstraintNode.php | 11 +- src/Parser/Nodes/Leaf/EnumNode.php | 20 ++- src/Parser/Nodes/Leaf/LiteralNode.php | 4 +- src/Parser/Nodes/StructNode.php | 55 +++--- src/Parser/Nodes/TupleNode.php | 2 +- src/Parser/Nodes/UnionNode.php | 6 +- src/Parser/Registry/CachedTypeRegistry.php | 30 +++- .../Operations/CachedOperationRegistry.php | 9 +- src/Utils/PHPExport.php | 21 +++ tests/Feature/Operations/PoolingTestClass.php | 86 +++++++++ tests/Feature/ServerTest.php | 32 +++- tests/Pest.php | 19 +- .../Executor/UnionAndEnumDispatchTest.php | 137 +++++++++++++++ tests/Unit/Parser/ASTOptimizerTest.php | 165 ++++++++++++++++++ tests/Unit/Parser/MetadataEliminationTest.php | 145 +++++++++++++++ tests/Unit/Parser/NamedTypeTest.php | 6 +- .../Unit/Parser/NodeDiagnosticStringTest.php | 53 ++++++ tests/Unit/Parser/OptimizedCodeShapeTest.php | 95 ++++++++++ tests/Unit/Parser/StructNodeOrderTest.php | 106 +++++++++++ tests/Unit/Parser/TypeParserTest.php | 5 +- tests/Unit/Typescript/NamedTypesTest.php | 5 +- .../Typescript/TypescriptGeneratorTest.php | 6 +- 27 files changed, 1069 insertions(+), 161 deletions(-) delete mode 100644 src/Parser/AstSorter.php create mode 100644 src/Parser/Exceptions/UnknownTypeKeyException.php create mode 100644 tests/Feature/Operations/PoolingTestClass.php create mode 100644 tests/Unit/Executor/UnionAndEnumDispatchTest.php create mode 100644 tests/Unit/Parser/ASTOptimizerTest.php create mode 100644 tests/Unit/Parser/MetadataEliminationTest.php create mode 100644 tests/Unit/Parser/NodeDiagnosticStringTest.php create mode 100644 tests/Unit/Parser/OptimizedCodeShapeTest.php create mode 100644 tests/Unit/Parser/StructNodeOrderTest.php diff --git a/src/Contracts/NodeInterface.php b/src/Contracts/NodeInterface.php index d0ea7f6..c86a701 100644 --- a/src/Contracts/NodeInterface.php +++ b/src/Contracts/NodeInterface.php @@ -5,9 +5,17 @@ use Stringable; /** - * The stringable method should return all parameters that influence the type. - * It is used internally to generate the hash of the type. Best is if this is humanly readable - * but not required as long as it is unique for the given instance properties. + * exportPhpCode() is a node's identity. The ASTOptimizer interns nodes by hashing it: two nodes + * that export the same PHP construct the same object, so sharing one instance between them is + * correct by definition rather than by convention. + * + * INVARIANT: every constructor argument that changes runtime behaviour MUST appear in + * exportPhpCode(). Anything omitted is, by definition, information the cache discards — that is + * the escape hatch MetadataNode uses deliberately, exporting nothing of itself so codegen + * metadata can never reach a cached AST. + * + * __toString() is a human readable type label for diagnostics and error messages. It is allowed + * to be lossy and MUST NOT be used as a cache key. */ interface NodeInterface extends Stringable, ExportableToPhpCode { diff --git a/src/Executor/SchemaExecutor.php b/src/Executor/SchemaExecutor.php index a2b960d..dc0d50f 100644 --- a/src/Executor/SchemaExecutor.php +++ b/src/Executor/SchemaExecutor.php @@ -93,11 +93,14 @@ public function executeSerialize(NodeInterface $node, mixed $data, Context $cont return $this->executeSerialize($node->node, $data, $context); } + // Ordered by how often each case is hit. Leaves outnumber every other node in a typical + // schema, and MetadataNode is last because the optimizer strips it: in a cached AST that + // arm can never match. $serializedValue = match (true) { + $node instanceof LeafNode => $node->serializeValue($data, $context), + array_key_exists($node::class, $this->handlers) => $this->handlers[$node::class]->serialize($node, $data, $context, $this), // Codegen metadata has no runtime effect. $node instanceof MetadataNode => $this->executeSerialize($node->node, $data, $context), - array_key_exists($node::class, $this->handlers) => $this->handlers[$node::class]->serialize($node, $data, $context, $this), - $node instanceof LeafNode => $node->serializeValue($data, $context), default => Value::INVALID, }; @@ -122,13 +125,14 @@ public function executeParse(NodeInterface $node, mixed $data, Context $context) return $constrainedValue; } + // Ordered by how often each case is hit; see executeSerialize(). return match (true) { - // Codegen metadata has no runtime effect. - $node instanceof MetadataNode => $this->executeParse($node->node, $data, $context), - array_key_exists($node::class, $this->handlers) => $this->handlers[$node::class]->parse($node, $data, $context, $this), $node instanceof LeafNode => $context->coercePrimitives && $node instanceof Coercible ? $node->parseValue($node->coerce($data), $context) : $node->parseValue($data, $context), + array_key_exists($node::class, $this->handlers) => $this->handlers[$node::class]->parse($node, $data, $context, $this), + // Codegen metadata has no runtime effect. + $node instanceof MetadataNode => $this->executeParse($node->node, $data, $context), default => Value::INVALID, }; } diff --git a/src/Parser/ASTOptimizer.php b/src/Parser/ASTOptimizer.php index 0418055..11c9f6e 100644 --- a/src/Parser/ASTOptimizer.php +++ b/src/Parser/ASTOptimizer.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\Contracts\Constraint; use Le0daniel\PhpTsBindings\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Exceptions\UnknownTypeKeyException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; @@ -24,13 +25,52 @@ final class ASTOptimizer { - /** @var array */ + /** + * Identifiers are content derived: sha1 of the node's exported PHP, truncated. A parent's id + * therefore depends only on its children's content, never on traversal order, which keeps the + * generated artifact byte identical across machines. + * + * @var array id => [node, exported code] + */ private array $dedupedNodes = []; + private const string KEY_VARIABLE_NAME = 'key'; + public function __construct( - private readonly string $registryVariableName = 'registry', + private readonly string $registryVariableName = 'r', + private readonly int $idLength = 10, ) { + if ($this->registryVariableName === self::KEY_VARIABLE_NAME) { + throw new RuntimeException( + "The registry variable cannot be named '" . self::KEY_VARIABLE_NAME + . "'; it would collide with the generated factory's key parameter.", + ); + } + } + + /** + * Interns a node under a content derived id and returns the reference that replaces it. + * + * Identity is exportPhpCode(), not __toString(): the registry entry for an interned node IS + * its exported code, so two nodes exporting the same PHP are interchangeable by definition. + * __toString() is lossy — ConstraintNode and MetadataNode both delegate to their inner node — + * and using it here silently merged schemas that differ in validation. + */ + private function intern(string $prefix, NodeInterface $node, string $originalTypeString): ReferencedNode + { + $exported = $node->exportPhpCode(); + $identifier = '#' . $prefix . substr(sha1($exported), 0, $this->idLength); + + if (isset($this->dedupedNodes[$identifier]) && $this->dedupedNodes[$identifier][1] !== $exported) { + throw new RuntimeException( + "Identity hash collision on '{$identifier}'. Increase the idLength of the ASTOptimizer.", + ); + } + + $this->dedupedNodes[$identifier] = [$node, $exported]; + + return new ReferencedNode($identifier, $originalTypeString, $this->registryVariableName); } /** @@ -38,12 +78,10 @@ public function __construct( */ public function optimizeAndWriteToFile(string $fileName, array $nodes): void { - if (file_put_contents($fileName, <<generateOptimizedCode($nodes)}; -PHP) === false) { - throw new RuntimeException("Could not write to file: {$fileName}"); - } +PHP); } /** @@ -63,23 +101,26 @@ public function generateOptimizedCode(array $nodes): string ); $registryClass = PHPExport::absolute(CachedTypeRegistry::class); + $nodeInterface = PHPExport::absolute(NodeInterface::class); + $unknownKeyException = PHPExport::absolute(UnknownTypeKeyException::class); - $dedupedAsString = Arrays::mapWithKeys( + $internedArms = Arrays::mapWithKeys( $this->dedupedNodes, - fn(string $key, NodeInterface $node) => PHPExport::export($key) . " => static fn({$registryClass} \${$this->registryVariableName}) => {$node->exportPhpCode()}", + fn(string $key, array $entry) => PHPExport::export($key) . " => {$entry[1]},", ); - $optimizedNodesFactories = Arrays::mapWithKeys( + $schemaArms = Arrays::mapWithKeys( $optimizedNodes, - fn(string $key, NodeInterface $ast) => PHPExport::export($key) . " => static fn({$registryClass} \${$this->registryVariableName}) => {$ast->exportPhpCode()}" + fn(string $key, NodeInterface $ast) => PHPExport::export($key) . " => {$ast->exportPhpCode()}," ); - $factories = implode(',', [ - ... $dedupedAsString, - ... $optimizedNodesFactories, - ]); + $arms = implode(PHP_EOL, [... $internedArms, ... $schemaArms]); + $key = self::KEY_VARIABLE_NAME; - return "new {$registryClass}([{$factories}])"; + // One match arm per entry rather than one closure per entry: arms are only evaluated when + // their key is requested, so this stays lazy while allocating nothing per entry. + return "new {$registryClass}(static function (string \${$key}, {$registryClass} \${$this->registryVariableName}): {$nodeInterface} { " + . "return match (\${$key}) { {$arms} default => throw {$unknownKeyException}::forKey(\${$key}) }; })"; } /** @@ -100,36 +141,30 @@ private function dedupeNode(NodeInterface $node): NodeInterface } if ($node instanceof LeafNode) { - $identifier = '#leaf_' . sha1((string)$node); - $this->dedupedNodes[$identifier] ??= $node; - return new ReferencedNode($identifier, (string)$node, $this->registryVariableName); + return $this->intern('l', $node, (string)$node); } + // Children are deduped first, so the interned node exports its children as short + // `$registry->get('#…')` references. Hashing is therefore O(1) per node, not O(subtree). if ($node instanceof PropertyNode) { - $identifier = '#prop_' . sha1((string)$node); - $this->dedupedNodes[$identifier] ??= new PropertyNode( + return $this->intern('p', new PropertyNode( $node->name, $this->dedupeNode($node->node), $node->isOptional, $node->propertyType - ); - - return new ReferencedNode($identifier, (string)$node, $this->registryVariableName); + ), (string)$node); } // Deep optimization if ($node instanceof StructNode) { - $deepOptimizedNode = new StructNode( + return $this->intern('s', new StructNode( $node->phpType, - array_map($this->dedupeNode(...), $node->sortedProperties()), - ); - $identifier = '#struct_' . sha1((string)$deepOptimizedNode); - $this->dedupedNodes[$identifier] ??= $deepOptimizedNode; - return new ReferencedNode($identifier, (string)$node, $this->registryVariableName); + array_map($this->dedupeNode(...), $node->properties), + ), (string)$node); } - // ToDo: Further optimization for example on union nodes with only Primitive Types or - // more intelligent node determination for better runtime performance. + // Composite nodes are rebuilt inline rather than interned: a single use composite costs + // more as a registry entry than it does written out at the use site. return match ($node::class) { ConstraintNode::class => $this->flattenConstraintNode($node), CustomCastingNode::class => new CustomCastingNode( diff --git a/src/Parser/AstSorter.php b/src/Parser/AstSorter.php deleted file mode 100644 index 8563be0..0000000 --- a/src/Parser/AstSorter.php +++ /dev/null @@ -1,56 +0,0 @@ - new ConstraintNode( - self::sort($node->node), $node->constraints, - ), - CustomCastingNode::class => new CustomCastingNode( - self::sort($node->node), - $node->fullyQualifiedCastingClass, - $node->strategy, - ), - IntersectionNode::class => new IntersectionNode( - array_map(self::sort(...), $node->types), - ), - ListNode::class => new ListNode(self::sort($node->node)), - MetadataNode::class => new MetadataNode(self::sort($node->node), $node->name, $node->brand), - PropertyNode::class => new PropertyNode($node->name, self::sort($node->node), $node->isOptional, $node->propertyType), - RecordNode::class => new RecordNode(self::sort($node->node)), - StructNode::class => new StructNode($node->phpType, array_map(self::sort(...), $node->sortedProperties())), - TupleNode::class => new TupleNode(array_map(self::sort(...), $node->types)), - UnionNode::class => new UnionNode(array_map(self::sort(...), $node->types), $node->discriminator, $node->discriminatorMap), - default => $node - }; - } - -} \ No newline at end of file diff --git a/src/Parser/Exceptions/UnknownTypeKeyException.php b/src/Parser/Exceptions/UnknownTypeKeyException.php new file mode 100644 index 0000000..4d2a964 --- /dev/null +++ b/src/Parser/Exceptions/UnknownTypeKeyException.php @@ -0,0 +1,31 @@ +node->__toString(); + if (empty($this->constraints)) { + return $this->node->__toString(); + } + + $names = implode(', ', array_map( + static fn(Constraint $constraint) => new \ReflectionClass($constraint)->getShortName(), + $this->constraints, + )); + + return "{$this->node} & {$names}"; } public function exportPhpCode(): string diff --git a/src/Parser/Nodes/Leaf/EnumNode.php b/src/Parser/Nodes/Leaf/EnumNode.php index a44afe5..a7a910a 100644 --- a/src/Parser/Nodes/Leaf/EnumNode.php +++ b/src/Parser/Nodes/Leaf/EnumNode.php @@ -12,13 +12,16 @@ use Le0daniel\PhpTsBindings\Utils\PHPExport; use UnitEnum; -final readonly class EnumNode implements NodeInterface, LeafNode +final class EnumNode implements NodeInterface, LeafNode { + /** @var array */ + private array $cases; + /** * @param class-string $enumClassName */ public function __construct( - public string $enumClassName, + public readonly string $enumClassName, ) { } @@ -49,11 +52,14 @@ public function parseValue(mixed $value, ExecutionContext $context): UnitEnum|Va return Value::INVALID; } - $cases = $this->enumClassName::cases(); - foreach ($cases as $case) { - if ($case->name === $value) { - return $case; - } + $cases = $this->cases ??= array_column( + $this->enumClassName::cases(), + null, + 'name', + ); + + if (isset($cases[$value])) { + return $cases[$value]; } $context->addIssue(new Issue( diff --git a/src/Parser/Nodes/Leaf/LiteralNode.php b/src/Parser/Nodes/Leaf/LiteralNode.php index 5fb5102..1ad74cc 100644 --- a/src/Parser/Nodes/Leaf/LiteralNode.php +++ b/src/Parser/Nodes/Leaf/LiteralNode.php @@ -34,7 +34,9 @@ public function __toString(): string // @phpstan-ignore-next-line classConstant.nonObject LiteralType::ENUM_CASE => "enum-value<{$this->value->name}@" . $this->value::class . ">", LiteralType::NULL => 'literal', - LiteralType::INT, LiteralType::FLOAT => "literal<{$this->value}>", + LiteralType::INT => "literal<{$this->value}>", + // Rendered via var_export so 1.0 stays distinguishable from 1. + LiteralType::FLOAT => 'literal<' . var_export($this->value, true) . '>', }; } diff --git a/src/Parser/Nodes/StructNode.php b/src/Parser/Nodes/StructNode.php index 3afd099..909578a 100644 --- a/src/Parser/Nodes/StructNode.php +++ b/src/Parser/Nodes/StructNode.php @@ -12,14 +12,47 @@ final readonly class StructNode implements NodeInterface, ValidatableNode { + /** @var non-empty-list */ + public array $properties; + /** + * Properties are canonically ordered here rather than by a separate pass, so there is only + * ever one form of a given shape. That keeps a cached AST behaviourally identical to a freshly + * parsed one, and lets two declarations of the same shape in different orders share a single + * interned registry entry. + * * @param non-empty-list $properties */ public function __construct( public StructPhpType $phpType, - public array $properties, + array $properties, ) { + $this->properties = self::canonicalise($properties); + } + + + /** + * @param non-empty-list $properties + * @return non-empty-list + */ + private static function canonicalise(array $properties): array + { + // ReferencedNode carries no name to sort by. The ASTOptimizer rebuilds structs by mapping + // over an already canonical list, so order is preserved and sorting is unnecessary there. + if (!array_all($properties, static fn(PropertyNode|ReferencedNode $property) => $property instanceof PropertyNode)) { + return $properties; + } + + /** @var non-empty-list $properties */ + usort($properties, static function (PropertyNode $a, PropertyNode $b): int { + $byName = strcmp($a->name, $b->name); + return $byName !== 0 + ? $byName + : $a->propertyType->name <=> $b->propertyType->name; + }); + + return $properties; } public function validate(): void @@ -58,26 +91,6 @@ public function ofType(StructPhpType $type): self return new self($type, $this->properties); } - /** - * @return PropertyNode[] - */ - public function sortedProperties(): array - { - /** @var list $properties */ - $properties = $this->properties; - - // Sort by name, then by type - usort($properties, function (PropertyNode $a, PropertyNode $b): int { - $nameComparison = strcmp($a->name, $b->name); - if ($nameComparison !== 0) { - return $nameComparison; - } - return $a->propertyType->name <=> $b->propertyType->name; - }); - - return $properties; - } - public function getProperty(string $name): ?PropertyNode { /** @var list $properties */ diff --git a/src/Parser/Nodes/TupleNode.php b/src/Parser/Nodes/TupleNode.php index 87febcc..8ee49d8 100644 --- a/src/Parser/Nodes/TupleNode.php +++ b/src/Parser/Nodes/TupleNode.php @@ -21,7 +21,7 @@ public function __toString(): string { $typeString = Arrays::mapWithKeys($this->types, fn(int $key, NodeInterface $type) => "{$key}: {$type}"); $imploded = implode(', ', $typeString); - return "array{$imploded}"; + return 'array{' . $imploded . '}'; } public function exportPhpCode(): string diff --git a/src/Parser/Nodes/UnionNode.php b/src/Parser/Nodes/UnionNode.php index 1b573fe..7f60d7e 100644 --- a/src/Parser/Nodes/UnionNode.php +++ b/src/Parser/Nodes/UnionNode.php @@ -43,7 +43,11 @@ public function validate(): void public function __toString(): string { - return implode('|', array_map(fn(NodeInterface $type) => (string)$type, $this->types)); + $types = implode('|', array_map(fn(NodeInterface $type) => (string)$type, $this->types)); + + return $this->discriminator === null + ? $types + : "{$types} by '{$this->discriminator}'"; } public function isDiscriminated(): bool diff --git a/src/Parser/Registry/CachedTypeRegistry.php b/src/Parser/Registry/CachedTypeRegistry.php index 9fe3580..a6deaab 100644 --- a/src/Parser/Registry/CachedTypeRegistry.php +++ b/src/Parser/Registry/CachedTypeRegistry.php @@ -5,8 +5,15 @@ use Closure; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeRegistry; +use Le0daniel\PhpTsBindings\Parser\Exceptions\UnknownTypeKeyException; - +/** + * Lazily instantiates schemas from generated code, memoizing each one. + * + * The factory is a single closure wrapping a match over every key, rather than an array holding + * one closure per key: a match arm costs nothing until it is reached, so only the schemas a + * request actually touches are ever built, and nothing is allocated per entry at load time. + */ final class CachedTypeRegistry implements TypeRegistry { /** @@ -15,16 +22,29 @@ final class CachedTypeRegistry implements TypeRegistry private array $instantiatedNodes = []; /** - * @param array $registeredSchemas + * @var Closure(string, self): NodeInterface + */ + private readonly Closure $factory; + + /** + * @param Closure(string, self): NodeInterface|array $factory The array form is + * the format written before schema identity was fixed and is rejected: such a cache can + * silently merge schemas that differ only in their constraints. */ public function __construct( - private readonly array $registeredSchemas, + Closure|array $factory, ) { + if (!$factory instanceof Closure) { + throw UnknownTypeKeyException::forLegacyCacheShape(); + } + + $this->factory = $factory; } public function get(string $key): NodeInterface { - return $this->instantiatedNodes[$key] ??= ($this->registeredSchemas[$key])($this); + // An unknown key throws before the assignment, so misses are never memoized. + return $this->instantiatedNodes[$key] ??= ($this->factory)($key, $this); } -} \ No newline at end of file +} diff --git a/src/Server/Operations/CachedOperationRegistry.php b/src/Server/Operations/CachedOperationRegistry.php index f288613..09034cc 100644 --- a/src/Server/Operations/CachedOperationRegistry.php +++ b/src/Server/Operations/CachedOperationRegistry.php @@ -74,11 +74,14 @@ public static function toPhpCode(OperationRegistry $registry): string "'{$key}' => fn() => new {$endpointClass}('{$endpoint->key}', $exportedDefinition, fn() => \$typeRegistry->get('{$inputAstName}'), fn() => \$typeRegistry->get('{$outputAstName}'))"; } - // The ast optimizer is used to deduplicate all the ASTs, minimizing the nodes required. - // Additional optimizations are performed on structs and unions for faster execution. + // The ast optimizer deduplicates all the ASTs, minimizing the nodes required at runtime. $optimizer = new AstOptimizer(); $operationRegistryClass = PHPExport::absolute(CachedOperationRegistry::class); + // Operation discovery order depends on the filesystem, so sorting by key is what makes the + // generated artifact byte identical across machines. + ksort($asts); + sort($endpoints); $endpointsCode = implode(',', $endpoints); return << $data['email']]; + } + + /** + * Same shape as looseEmail, but the constraint must survive pooling. + * + * @param array{email: non-empty-string} $data + * @return array{email: string} + */ + #[Command('pooling')] + public function constrainedEmail(array $data): array + { + return ['email' => $data['email']]; + } + + /** + * Declares its properties in non-alphabetical order; key order must match the uncached path. + * + * @param array{zebra: string, alpha: string, middle: int} $data + * @return array{zebra: string, alpha: string, middle: int} + */ + #[Command('pooling')] + public function declarationOrder(array $data): array + { + return $data; + } + + /** + * The same shape as declarationOrder, declared in a different order. Both must resolve to the + * same interned entry without either changing behaviour. + * + * @param array{alpha: string, middle: int, zebra: string} $data + * @return array{alpha: string, middle: int, zebra: string} + */ + #[Command('pooling')] + public function reversedOrder(array $data): array + { + return $data; + } + + /** + * @param array{value: 1|2} $data + * @return array{value: int} + */ + #[Command('pooling')] + public function intLiteral(array $data): array + { + return ['value' => $data['value']]; + } + + /** + * Float literals that stringify like the integer ones above. + * + * @param array{value: 1.0|2.0} $data + * @return array{value: float} + */ + #[Command('pooling')] + public function floatLiteral(array $data): array + { + return ['value' => $data['value']]; + } +} diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index eef3d17..8c21b61 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -66,4 +66,34 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { $errorPresenter = new ExposedExceptionPresenter(); $definition = $errorPresenter->toTypeScriptDefinition($operation->definition); expect($definition)->toEqual('{type: "invalid_name"}'); -}); \ No newline at end of file +}); +/** + * The cached registry pools every operation's schemas together, so these cases only mean anything + * on the production path: executeOperation() compares the eagerly discovered server against the + * generated cache for each one. + */ +test('a constrained schema keeps its constraint when pooled with an unconstrained twin', function () { + expect(executeOperation('pooling.constrainedEmail', ['email' => 'a@b.c']))->toBeInstanceOf(RpcSuccess::class) + ->and(executeOperation('pooling.constrainedEmail', ['email' => '']))->toBeInstanceOf(RpcError::class) + ->and(executeOperation('pooling.looseEmail', ['email' => '']))->toBeInstanceOf(RpcSuccess::class); +}); + +test('property key order is identical cached and uncached', function () { + $result = executeOperation('pooling.declarationOrder', ['zebra' => 'z', 'alpha' => 'a', 'middle' => 1]); + + expect($result)->toBeInstanceOf(RpcSuccess::class) + ->and(json_encode($result->data, JSON_THROW_ON_ERROR)) + ->toBe('{"alpha":"a","middle":1,"zebra":"z"}'); +}); + +test('the same shape declared in two orders behaves identically', function () { + $data = ['zebra' => 'z', 'alpha' => 'a', 'middle' => 1]; + + expect(json_encode(executeOperation('pooling.declarationOrder', $data)->data, JSON_THROW_ON_ERROR)) + ->toBe(json_encode(executeOperation('pooling.reversedOrder', $data)->data, JSON_THROW_ON_ERROR)); +}); + +test('int and float literal schemas do not merge when pooled', function () { + expect(executeOperation('pooling.intLiteral', ['value' => 1]))->toBeInstanceOf(RpcSuccess::class) + ->and(executeOperation('pooling.floatLiteral', ['value' => 1.0]))->toBeInstanceOf(RpcSuccess::class); +}); diff --git a/tests/Pest.php b/tests/Pest.php index e0d16bf..29cc2b5 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -19,7 +19,6 @@ use Le0daniel\PhpTsBindings\Executor\Data\Success; use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; use Le0daniel\PhpTsBindings\Parser\ASTOptimizer; -use Le0daniel\PhpTsBindings\Parser\AstSorter; use Le0daniel\PhpTsBindings\Parser\AstValidator; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\Data\IO; @@ -107,16 +106,15 @@ */ function compareToOptimizedAst(NodeInterface $node) { - $sortedNode = AstSorter::sort($node); $optimizer = new ASTOptimizer(); - $optimizedCode = $optimizer->generateOptimizedCode(['node' => $sortedNode]); + $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); expect( (string) $registry->get('node') - )->toEqual((string) $sortedNode); + )->toEqual((string) $node); } /** @@ -129,17 +127,14 @@ function compareToOptimizedAst(NodeInterface $node) { */ function typescriptFor(NodeInterface $node, IO $io, ?TypeRegistry $sharedRegistry = null): TypeScript { - $sortedNode = AstSorter::sort($node); - compareToOptimizedAst($sortedNode); + compareToOptimizedAst($node); - return new TypescriptGenerator()->toTypescript($sortedNode, $io, $sharedRegistry); + return new TypescriptGenerator()->toTypescript($node, $io, $sharedRegistry); } function executeParse(NodeInterface|string $node, mixed $data, ParsingOptions $options = new ParsingOptions()): Success|Failure { - $node = AstSorter::sort( - is_string($node) ? new TypeParser()->parse($node) : $node, - ); + $node = is_string($node) ? new TypeParser()->parse($node) : $node; $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); @@ -170,9 +165,7 @@ function executeParse(NodeInterface|string $node, mixed $data, ParsingOptions $o function executeSerialize(NodeInterface|string $node, mixed $data, SerializationOptions $options = new SerializationOptions()): Success|Failure { - $node = AstSorter::sort( - is_string($node) ? new TypeParser()->parse($node) : $node, - ); + $node = is_string($node) ? new TypeParser()->parse($node) : $node; $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); diff --git a/tests/Unit/Executor/UnionAndEnumDispatchTest.php b/tests/Unit/Executor/UnionAndEnumDispatchTest.php new file mode 100644 index 0000000..2f2fc86 --- /dev/null +++ b/tests/Unit/Executor/UnionAndEnumDispatchTest.php @@ -0,0 +1,137 @@ +parse( + "array{kind: 'a', a: string}|array{kind: 'b', b: int}|array{kind: 'c', c: bool}", + ); + + expect($node->discriminator)->toBe('kind'); + + expect(executeParse($node, ['kind' => 'a', 'a' => 'x']))->toBeSuccess(); + expect(executeParse($node, ['kind' => 'b', 'b' => 1]))->toBeSuccess(); + expect(executeParse($node, ['kind' => 'c', 'c' => true]))->toBeSuccess(); + expect(executeParse($node, ['kind' => 'd']))->toBeFailure(); +}); + +test('a boolean discriminator map still resolves; array_flip would silently drop every arm', function () { + $node = new TypeParser()->parse('array{ok: true, value: string}|array{ok: false, error: string}'); + + expect($node->discriminatorMap)->toBe([true, false]); + + expect(executeParse($node, ['ok' => true, 'value' => 'v']))->toBeSuccess(); + expect(executeParse($node, ['ok' => false, 'error' => 'e']))->toBeSuccess(); +}); + +test('a numeric string discriminator is not confused with its integer twin', function () { + // PHP coerces the array key '1' to int 1, so a flipped table would match both. + $stringTagged = new TypeParser()->parse("array{v: '1', s: string}|array{v: '2', t: string}"); + + expect(executeParse($stringTagged, ['v' => '1', 's' => 'x']))->toBeSuccess(); + expect(executeParse($stringTagged, ['v' => 1, 's' => 'x'], new ParsingOptions(coercePrimitives: false)))->toBeFailure(); +}); + +test('a mixed string and integer discriminator map keeps both branches distinct', function () { + $node = new TypeParser()->parse("array{v: '1', s: string}|array{v: 1, i: int}"); + + expect($node->discriminatorMap)->toBe(['1', 1]); + + $executor = new SchemaExecutor(); + $stringBranch = $node->getDiscriminatedType('1'); + $intBranch = $node->getDiscriminatedType(1); + + expect($stringBranch)->not->toBe($intBranch) + ->and($executor->parse($stringBranch, ['v' => '1', 's' => 'x'])->value)->not->toBeNull() + ->and($executor->parse($intBranch, ['v' => 1, 'i' => 2])->value)->not->toBeNull(); +}); + +test('a discriminator lookup miss returns null rather than a wrong branch', function () { + $node = new TypeParser()->parse("array{kind: 'a', a: string}|array{kind: 'b', b: int}"); + + expect($node->getDiscriminatedType('nope'))->toBeNull() + ->and($node->getDiscriminatedType(null))->toBeNull() + ->and($node->getDiscriminatedType(0))->toBeNull() + ->and($node->getDiscriminatedType(true))->toBeNull(); +}); + +test('an all literal string union matches every member and rejects unknown values', function () { + $node = "'draft'|'pending'|'active'|'archived'|'deleted'"; + + foreach (['draft', 'pending', 'active', 'archived', 'deleted'] as $value) { + expect(executeParse($node, $value))->toBeSuccess(); + } + + expect(executeParse($node, 'unknown'))->toBeFailure(); + expect(executeParse($node, 1))->toBeFailure(); + expect(executeParse($node, null))->toBeFailure(); +}); + +test('a literal union containing numeric strings stays exact', function () { + expect(executeParse("'1'|'2'", '1'))->toBeSuccess(); + expect(executeParse("'1'|'2'", '02'))->toBeFailure(); +}); + +test('a mixed literal union is unaffected by the string only fast path', function () { + expect(executeParse("'a'|1|true|null", 'a'))->toBeSuccess(); + expect(executeParse("'a'|1|true|null", 1))->toBeSuccess(); + expect(executeParse("'a'|1|true|null", true))->toBeSuccess(); + expect(executeParse("'a'|1|true|null", null))->toBeSuccess(); + expect(executeParse("'a'|1|true|null", 'b'))->toBeFailure(); +}); + +test('string literals coerce to themselves, which is what makes the fast path safe', function () { + $node = new LiteralNode(LiteralType::STRING, 'draft'); + + expect($node->coerce('draft'))->toBe('draft') + ->and($node->coerce('other'))->toBe('other') + ->and($node->coerce(1))->toBe(1); +}); + +test('a literal union still reports the same issues on failure', function () { + $failure = executeParse("'a'|'b'", 'c'); + + expect($failure)->toBeFailure(); +}); + +test('enum values resolve by name and reject unknown names', function () { + // Executed directly: the shared helper json_encodes results, which a non-backed enum cannot do. + $node = new TypeParser()->parse(Tests\Mocks\ResultEnum::class); + $executor = new SchemaExecutor(); + + expect($executor->parse($node, 'SUCCESS')->value)->toBe(Tests\Mocks\ResultEnum::SUCCESS) + ->and($executor->parse($node, 'FAILURE')->value)->toBe(Tests\Mocks\ResultEnum::FAILURE) + ->and($executor->parse($node, 'NOT_A_CASE'))->toBeFailure() + ->and($executor->parse($node, 1))->toBeFailure() + ->and($executor->parse($node, 'OTHER'))->toBeFailure(); +}); + +test('struct property direction filtering is unchanged by precomputed partitions', function () { + $node = new TypeParser()->parse(Tests\Unit\Executor\Mocks\UserSchema::class); + + expect(executeParse($node, ['username' => 'ada', 'email' => 'ada@example.com', 'age' => 30]))->toBeSuccess(); +}); + +test('an undiscriminated union still probes members in declaration order', function () { + // First match wins, so order remains observable and must not be reordered by any fast path. + $node = new UnionNode([ + new LiteralNode(LiteralType::STRING, 'x'), + new LiteralNode(LiteralType::STRING, 'y'), + ]); + + $executor = new SchemaExecutor(); + expect($executor->parse($node, 'x')->value)->toBe('x') + ->and($executor->parse($node, 'y')->value)->toBe('y'); +}); diff --git a/tests/Unit/Parser/ASTOptimizerTest.php b/tests/Unit/Parser/ASTOptimizerTest.php new file mode 100644 index 0000000..df93dc4 --- /dev/null +++ b/tests/Unit/Parser/ASTOptimizerTest.php @@ -0,0 +1,165 @@ + $schemas + */ +function optimizePooled(array $schemas): CachedTypeRegistry +{ + $parser = new TypeParser(); + $nodes = array_map( + static fn(NodeInterface|string $schema) => is_string($schema) ? $parser->parse($schema) : $schema, + $schemas, + ); + + $code = new ASTOptimizer()->generateOptimizedCode($nodes); + + /** @var CachedTypeRegistry $registry */ + $registry = eval("return {$code};"); + return $registry; +} + +/** + * Asserts the optimized schema behaves exactly like the freshly parsed one, in BOTH pool orders — + * a collision's direction flips with iteration order, so one order alone can hide the bug. + * + * @param array $schemas + * @param array> $probes schema key => values to parse + */ +function assertPooledParity(array $schemas, array $probes): void +{ + $parser = new TypeParser(); + $executor = new SchemaExecutor(); + + foreach ([$schemas, array_reverse($schemas, true)] as $ordered) { + $registry = optimizePooled($ordered); + + foreach ($probes as $key => $values) { + foreach ($values as $value) { + $raw = $executor->parse($parser->parse($schemas[$key]), $value); + $optimized = $executor->parse($registry->get($key), $value); + + $encoded = json_encode($value, JSON_THROW_ON_ERROR); + expect($optimized::class)->toBe( + $raw::class, + "Schema '{$key}' with {$encoded} diverged (pool order: " . implode(',', array_keys($ordered)) . ')', + ); + + if ($raw instanceof Success) { + expect(json_encode($optimized->value, JSON_THROW_ON_ERROR)) + ->toBe(json_encode($raw->value, JSON_THROW_ON_ERROR)); + } + } + } + } +} + +test('C1: a constrained schema keeps its constraints when pooled with an unconstrained twin', function () { + assertPooledParity( + [ + 'unconstrained' => 'array{email: string}', + 'constrained' => 'array{email: non-empty-string}', + ], + [ + 'unconstrained' => [['email' => ''], ['email' => 'a@b.c']], + 'constrained' => [['email' => ''], ['email' => 'a@b.c']], + ], + ); +}); + +test('C1: constraints on nested structs survive pooling', function () { + assertPooledParity( + [ + 'loose' => 'array{user: array{name: string}}', + 'strict' => 'array{user: array{name: non-empty-string}}', + ], + [ + 'loose' => [['user' => ['name' => '']]], + 'strict' => [['user' => ['name' => '']]], + ], + ); +}); + +test('C2: int and float literals do not collapse into one another', function () { + $intNode = new LiteralNode(LiteralType::INT, 1); + $floatNode = new LiteralNode(LiteralType::FLOAT, 1.0); + + expect($intNode->exportPhpCode())->not->toBe($floatNode->exportPhpCode()); + + $registry = optimizePooled(['int' => $intNode, 'float' => $floatNode]); + + expect($registry->get('int'))->toBeInstanceOf(LiteralNode::class) + ->and($registry->get('int')->type)->toBe(LiteralType::INT) + ->and($registry->get('float')->type)->toBe(LiteralType::FLOAT); +}); + +test('C2: a float literal schema pooled with an int literal twin still rejects the int', function () { + $executor = new SchemaExecutor(); + $registry = optimizePooled([ + 'int' => new LiteralNode(LiteralType::INT, 1), + 'float' => new LiteralNode(LiteralType::FLOAT, 1.0), + ]); + + expect($executor->parse($registry->get('float'), 1.0))->toBeInstanceOf(Success::class) + ->and($executor->parse($registry->get('int'), 1))->toBeInstanceOf(Success::class) + ->and($executor->parse($registry->get('float'), 1))->toBeInstanceOf(Failure::class); +}); + +test('C3: unions differing only in discriminator do not share an entry', function () { + $discriminated = new TypeParser()->parse("array{kind: 'a', v: string}|array{kind: 'b', v: int}"); + $plain = new Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode($discriminated->types); + + expect($discriminated->exportPhpCode())->not->toBe($plain->exportPhpCode()); + + $registry = optimizePooled(['discriminated' => $discriminated, 'plain' => $plain]); + + expect($registry->get('discriminated')->discriminator)->toBe('kind') + ->and($registry->get('plain')->discriminator)->toBeNull(); +}); + +test('identical schemas still share a single interned entry', function () { + $code = new ASTOptimizer()->generateOptimizedCode([ + 'a' => new TypeParser()->parse('array{name: string}'), + 'b' => new TypeParser()->parse('array{name: string}'), + ]); + + // Prefix-agnostic: matches both the '#struct_' and the shortened '#s' form. + expect(preg_match_all('/\'#s(?:truct_)?[a-f0-9]+\' =>/', $code)) + ->toBe(1, 'Two identical schemas must intern to exactly one struct entry.'); +}); + +test('the collision guard fires when identifiers are truncated too far', function () { + $optimizer = new ASTOptimizer(idLength: 1); + + // 16 distinct property names cannot fit in a single hex character without colliding. + $schemas = []; + foreach (range('a', 'z') as $letter) { + $schemas[$letter] = new TypeParser()->parse("array{{$letter}: string}"); + } + + expect(fn() => $optimizer->generateOptimizedCode($schemas)) + ->toThrow(RuntimeException::class, 'collision'); +}); + +test('node keys starting with a hash are rejected so they cannot shadow interned ids', function () { + expect(fn() => new ASTOptimizer()->generateOptimizedCode([ + '#leaf_evil' => new TypeParser()->parse('string'), + ]))->toThrow(RuntimeException::class, 'MUST not start with a # character'); +}); diff --git a/tests/Unit/Parser/MetadataEliminationTest.php b/tests/Unit/Parser/MetadataEliminationTest.php new file mode 100644 index 0000000..dd810ce --- /dev/null +++ b/tests/Unit/Parser/MetadataEliminationTest.php @@ -0,0 +1,145 @@ +generateOptimizedCode(['node' => $node]); + + /** @var CachedTypeRegistry $registry */ + $registry = eval("return {$code};"); + + return ['code' => $code, 'node' => $registry->get('node')]; +} + +function containsMetadataNode(NodeInterface $node): bool +{ + $stack = [$node]; + while ($current = array_pop($stack)) { + if ($current instanceof MetadataNode) { + return true; + } + + foreach (new ReflectionObject($current)->getProperties() as $property) { + // UnionNode::$acceptsNull is a lazily populated memo and may be uninitialized. + if (!$property->isInitialized($current)) { + continue; + } + + $value = $property->getValue($current); + foreach (is_array($value) ? $value : [$value] as $child) { + if ($child instanceof NodeInterface) { + $stack[] = $child; + } + } + } + } + + return false; +} + +/** + * Every structural position a MetadataNode can occupy. Each entry must survive parsing with + * metadata present and come out of the optimizer with none. + */ +dataset('metadata positions', [ + 'bare branded utility' => "BrandedString<'tok'>", + 'struct property' => "array{t: BrandedString<'tok'>}", + 'list element' => "list>", + 'record value' => "array>", + 'union member' => "BrandedString<'tok'>|int", + 'tuple element' => "array{0: BrandedString<'tok'>, 1: int}", + 'deeply nested' => "array{a: array{b: list>}}", + 'named class' => Customer::class, + 'branded value object' => Email::class, + 'value object in struct' => 'array{e: ' . Email::class . '}', + 'named class in list' => 'list<' . Customer::class . '>', + 'named class in union' => Customer::class . '|null', + 'named class in record' => 'array', +]); + +test('the parser produces metadata for every position under test', function (string $type) { + expect(containsMetadataNode(new TypeParser()->parse($type)))->toBeTrue( + "Expected {$type} to carry metadata before optimization; the elimination assertion would be vacuous otherwise.", + ); +})->with('metadata positions'); + +test('the optimizer eliminates metadata from the generated code', function (string $type) { + ['code' => $code] = optimizeSingle(new TypeParser()->parse($type)); + + expect($code)->not->toContain('MetadataNode'); +})->with('metadata positions'); + +test('the optimizer eliminates metadata from the instantiated AST', function (string $type) { + ['node' => $node] = optimizeSingle(new TypeParser()->parse($type)); + + expect(containsMetadataNode($node))->toBeFalse(); +})->with('metadata positions'); + +test('an optimized AST parses identically to the metadata carrying one', function () { + $node = new TypeParser()->parse("array{token: BrandedString<'tok'>, count: int}"); + ['node' => $optimized] = optimizeSingle($node); + + $executor = new Le0daniel\PhpTsBindings\Executor\SchemaExecutor(); + $data = ['token' => 'abc', 'count' => 3]; + + expect($executor->parse($optimized, $data)->value) + ->toEqual($executor->parse($node, $data)->value); +}); + +test('MetadataNode cannot serialize itself even outside the optimizer', function () { + $node = new MetadataNode(new StringNode(), new NamedType('Token'), 'token'); + + expect($node->exportPhpCode())->not->toContain('MetadataNode') + ->and($node->exportPhpCode())->toBe(new StringNode()->exportPhpCode()) + ->and((string)$node)->toBe((string)new StringNode()); +}); + +test('MetadataNode rejects being nested', function () { + $node = new MetadataNode(new MetadataNode(new StringNode(), null, 'inner'), null, 'outer'); + + expect(fn() => $node->validate()) + ->toThrow(InvalidArgumentException::class, 'should not be nested'); +}); + +test('MetadataNode rejects carrying neither a name nor a brand', function () { + expect(fn() => new MetadataNode(new StringNode())->validate()) + ->toThrow(InvalidArgumentException::class, 'meaningless'); +}); + +test('unwrapMetadata strips the wrapper and leaves everything else alone', function () { + $inner = new IntNode(); + + expect(Nodes::unwrapMetadata(new MetadataNode($inner, null, 'tag')))->toBe($inner) + ->and(Nodes::unwrapMetadata($inner))->toBe($inner); +}); + +test('unwrapMetadata keeps constraints attached, unlike getDeclaringNode', function () { + $constrained = new Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode( + new StringNode(), + [new Le0daniel\PhpTsBindings\Validators\NonEmptyString()], + ); + $wrapped = new MetadataNode($constrained, null, 'tag'); + + expect(Nodes::unwrapMetadata($wrapped))->toBe($constrained) + ->and(Nodes::getDeclaringNode($wrapped))->toBeInstanceOf(StringNode::class); +}); diff --git a/tests/Unit/Parser/NamedTypeTest.php b/tests/Unit/Parser/NamedTypeTest.php index c80c045..81c9370 100644 --- a/tests/Unit/Parser/NamedTypeTest.php +++ b/tests/Unit/Parser/NamedTypeTest.php @@ -1,7 +1,6 @@ and((string)$node)->toBe((string)$node->node) ->and($node->exportPhpCode())->not->toContain('MetadataNode'); - $sortedNode = AstSorter::sort($node); - $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $sortedNode]); + $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $node]); /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); expect($optimizedCode)->not->toContain('MetadataNode') ->and($registry->get('node'))->not->toBeInstanceOf(MetadataNode::class) - ->and((string)$registry->get('node'))->toBe((string)$sortedNode); + ->and((string)$registry->get('node'))->toBe((string)$node); }); test('rejects a name that is not a valid TypeScript identifier', function () { diff --git a/tests/Unit/Parser/NodeDiagnosticStringTest.php b/tests/Unit/Parser/NodeDiagnosticStringTest.php new file mode 100644 index 0000000..33cf480 --- /dev/null +++ b/tests/Unit/Parser/NodeDiagnosticStringTest.php @@ -0,0 +1,53 @@ +not->toBe('string') + ->and((string)$node)->toContain('string') + ->and((string)$node)->toContain('NonEmptyString'); +}); + +test('a constraint free node reads as its inner node', function () { + expect((string)new ConstraintNode(new StringNode(), []))->toBe('string'); +}); + +test('integer and float literals are distinguishable', function () { + expect((string)new LiteralNode(LiteralType::INT, 1)) + ->not->toBe((string)new LiteralNode(LiteralType::FLOAT, 1.0)); +}); + +test('a tuple keeps its braces', function () { + expect((string)new TypeParser()->parse('array{0: string, 1: int}')) + ->toBe('array{0: string, 1: int}'); +}); + +test('a discriminated union names its discriminator', function () { + $discriminated = new TypeParser()->parse("array{kind: 'a', v: string}|array{kind: 'b', v: int}"); + $plain = new Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode($discriminated->types); + + expect((string)$discriminated)->toContain('kind') + ->and((string)$discriminated)->not->toBe((string)$plain); +}); + +test('metadata stays transparent, which the elimination guarantee depends on', function () { + $inner = new StringNode(); + $node = new MetadataNode($inner, new NamedType('Token'), 'token'); + + expect((string)$node)->toBe((string)$inner); +}); diff --git a/tests/Unit/Parser/OptimizedCodeShapeTest.php b/tests/Unit/Parser/OptimizedCodeShapeTest.php new file mode 100644 index 0000000..5020c2f --- /dev/null +++ b/tests/Unit/Parser/OptimizedCodeShapeTest.php @@ -0,0 +1,95 @@ + $type) { + $schemas["schema{$index}"] = $parser->parse($type); + } + + return new ASTOptimizer()->generateOptimizedCode($schemas); +} + +test('the registry is built from a single match factory, not one closure per entry', function () { + $code = generateFor('array{a: string, b: int}', 'array{c: bool}'); + + expect($code)->toContain('match (') + ->and($code)->not->toContain('static fn('); +}); + +test('identifiers are short', function () { + $code = generateFor('array{a: string, b: int, c: list}'); + + preg_match_all("/'(#[a-z][a-f0-9]+)'/", $code, $matches); + + expect($matches[1])->not->toBeEmpty(); + foreach (array_unique($matches[1]) as $identifier) { + expect(strlen($identifier))->toBeLessThanOrEqual(13, "Identifier {$identifier} is too long"); + } +}); + +test('the generated code is loadable and resolves its schemas', function () { + $code = generateFor('array{a: string, b: int}'); + + /** @var CachedTypeRegistry $registry */ + $registry = eval("return {$code};"); + + expect($registry)->toBeInstanceOf(CachedTypeRegistry::class) + ->and((string)$registry->get('schema0'))->toBe('array{a: string, b: int}'); +}); + +test('generation is deterministic: the same input yields byte identical output', function () { + $type = 'array{zebra: string, alpha: list, nested: array{x: bool}}'; + + expect(generateFor($type))->toBe(generateFor($type)); +}); + +test('an unknown key raises a typed exception naming the regeneration command', function () { + $code = generateFor('array{a: string}'); + + /** @var CachedTypeRegistry $registry */ + $registry = eval("return {$code};"); + + expect(fn() => $registry->get('does-not-exist')) + ->toThrow(UnknownTypeKeyException::class, 'operations:optimize'); +}); + +test('the legacy array shape is rejected rather than silently accepted', function () { + // A cache written before identity was fixed carries merged schemas; booting it would run with + // constraints silently dropped, so it must fail loudly instead. + expect(fn() => new CachedTypeRegistry(['key' => static fn() => new TypeParser()->parse('string')])) + ->toThrow(UnknownTypeKeyException::class, 'operations:optimize'); +}); + +test('resolved nodes are memoized', function () { + $code = generateFor('array{a: string}'); + + /** @var CachedTypeRegistry $registry */ + $registry = eval("return {$code};"); + + expect($registry->get('schema0'))->toBe($registry->get('schema0')); +}); + +test('shared subtrees resolve to one shared instance', function () { + $code = new ASTOptimizer()->generateOptimizedCode([ + 'a' => new TypeParser()->parse('array{shared: string}'), + 'b' => new TypeParser()->parse('array{shared: string}'), + ]); + + /** @var CachedTypeRegistry $registry */ + $registry = eval("return {$code};"); + + expect($registry->get('a'))->toBe($registry->get('b')); +}); diff --git a/tests/Unit/Parser/StructNodeOrderTest.php b/tests/Unit/Parser/StructNodeOrderTest.php new file mode 100644 index 0000000..6e58a06 --- /dev/null +++ b/tests/Unit/Parser/StructNodeOrderTest.php @@ -0,0 +1,106 @@ + new PropertyNode($name, new StringNode(), false), $names), + ); +} + +test('property declaration order does not change a struct identity', function () { + expect(structOf('zebra', 'alpha', 'middle')->exportPhpCode()) + ->toBe(structOf('alpha', 'middle', 'zebra')->exportPhpCode()); +}); + +test('shapes declared in different orders intern to one entry', function () { + $code = new ASTOptimizer()->generateOptimizedCode([ + 'a' => new TypeParser()->parse('array{name: string, firstName: string}'), + 'b' => new TypeParser()->parse('array{firstName: string, name: string}'), + ]); + + expect(preg_match_all('/\'#s[a-f0-9]+\' =>/', $code)) + ->toBe(1, 'The same shape declared in two orders must produce exactly one struct entry.'); +}); + +test('properties are ordered by name', function () { + $properties = structOf('zebra', 'alpha', 'middle')->properties; + + expect(array_map(static fn(PropertyNode $p) => $p->name, $properties)) + ->toBe(['alpha', 'middle', 'zebra']); +}); + +test('properties sharing a name are ordered by property type', function () { + $struct = new StructNode(StructPhpType::ARRAY, [ + new PropertyNode('field', new StringNode(), false, PropertyType::OUTPUT), + new PropertyNode('field', new IntNode(), false, PropertyType::INPUT), + ]); + + expect(array_map(static fn(PropertyNode $p) => $p->propertyType, $struct->properties)) + ->toBe([PropertyType::INPUT, PropertyType::OUTPUT]); +}); + +test('a struct built from references is left untouched', function () { + // ReferencedNode has no ->name; the optimizer rebuilds structs by mapping over an already + // canonical list, so ordering must not be attempted here. + $references = [ + new ReferencedNode('#pzzz', 'zebra: string', 'registry'), + new ReferencedNode('#paaa', 'alpha: string', 'registry'), + ]; + + $struct = new StructNode(StructPhpType::ARRAY, $references); + + expect($struct->properties)->toBe($references); +}); + +test('filter and map preserve canonical order', function () { + $struct = structOf('zebra', 'alpha', 'middle'); + + $mapped = $struct->map(static fn(PropertyNode $p) => $p->changePropertyType(PropertyType::INPUT)); + $filtered = $struct->filter(static fn(PropertyNode $p) => $p->name !== 'middle'); + + expect(array_map(static fn(PropertyNode $p) => $p->name, $mapped->properties)) + ->toBe(['alpha', 'middle', 'zebra']) + ->and(array_map(static fn(PropertyNode $p) => $p->name, $filtered->properties)) + ->toBe(['alpha', 'zebra']); +}); + +test('C5: an optimized struct serializes in the same key order as the parsed one', function () { + $node = new TypeParser()->parse('array{zebra: string, alpha: string, middle: int}'); + $code = new ASTOptimizer()->generateOptimizedCode(['node' => $node]); + + /** @var CachedTypeRegistry $registry */ + $registry = eval("return {$code};"); + + $executor = new SchemaExecutor(); + $data = ['zebra' => 'z', 'alpha' => 'a', 'middle' => 1]; + + expect(json_encode($executor->serialize($registry->get('node'), $data)->value, JSON_THROW_ON_ERROR)) + ->toBe(json_encode($executor->serialize($node, $data)->value, JSON_THROW_ON_ERROR)); +}); + +test('C5: key order is stable without any external sorting pass', function () { + $node = new TypeParser()->parse('array{zebra: string, alpha: string, middle: int}'); + $executor = new SchemaExecutor(); + + expect(json_encode($executor->serialize($node, ['zebra' => 'z', 'alpha' => 'a', 'middle' => 1])->value, JSON_THROW_ON_ERROR)) + ->toBe('{"alpha":"a","middle":1,"zebra":"z"}'); +}); diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 5be78a1..f918132 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -570,9 +570,10 @@ compareToOptimizedAst($node); validateAst($node); - // Generated from the node as parsed, so the assertion pins declaration order. + // Struct properties are canonically ordered at construction, so emission is alphabetical + // regardless of declaration order. expect(new TypescriptGenerator()->toTypescript($node, IO::OUTPUT)->type) - ->toBe('{name:string;email:string;}'); + ->toBe('{email:string;name:string;}'); }); test('Do not cast in default mode', function () { diff --git a/tests/Unit/Typescript/NamedTypesTest.php b/tests/Unit/Typescript/NamedTypesTest.php index 1dcb199..23a0725 100644 --- a/tests/Unit/Typescript/NamedTypesTest.php +++ b/tests/Unit/Typescript/NamedTypesTest.php @@ -1,6 +1,5 @@ parse(Order::class)); - $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $sortedNode]); + $node = new TypeParser()->parse(Order::class); + $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $node]); /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); diff --git a/tests/Unit/Typescript/TypescriptGeneratorTest.php b/tests/Unit/Typescript/TypescriptGeneratorTest.php index c4d53b5..10640a2 100644 --- a/tests/Unit/Typescript/TypescriptGeneratorTest.php +++ b/tests/Unit/Typescript/TypescriptGeneratorTest.php @@ -190,7 +190,7 @@ function typescriptOfBoth(string|NodeInterface $type): string $result = typescriptOf("array{z: BrandedString<'zulu'>, a: BrandedString<'alpha'>, m: BrandedString<'mike'>}"); expect(array_keys($result->registry->toArray()))->toBe(['Alpha', 'Mike', 'Zulu']) - ->and($result->type)->toBe('{z:Zulu;a:Alpha;m:Mike;}'); + ->and($result->type)->toBe('{a:Alpha;m:Mike;z:Zulu;}'); }); test('throws when one brand resolves to two different definitions', function () { @@ -266,9 +266,9 @@ function typescriptOfBoth(string|NodeInterface $type): string expect(typescriptOf($type, IO::OUTPUT)->type)->toBe($output); })->with([ 'class without #[Castable]' => [UncastableClass::class, '{email:string;name:string;}'], - 'abstract class' => [SomeAbstractClass::class, '{id:number;email:string;}'], + 'abstract class' => [SomeAbstractClass::class, '{email:string;id:number;}'], 'interface' => [SomeFileInterface::class, '{id:number;url:string;}'], - 'readonly output fields' => [ReadonlyOutputFields::class, '{name:string;email:string;}'], + 'readonly output fields' => [ReadonlyOutputFields::class, '{email:string;name:string;}'], ]); test('throws for nodes it cannot represent', function (NodeInterface $node) { From 0105ece41aa82bec5d93456290d415387cba8257 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 11:06:38 +0200 Subject: [PATCH 015/101] Add `@throws \JsonException` annotation to `stringLiteral` method in Syntax utility --- src/Typescript/Utils/Syntax.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Typescript/Utils/Syntax.php b/src/Typescript/Utils/Syntax.php index 832a33a..fd5ecf2 100644 --- a/src/Typescript/Utils/Syntax.php +++ b/src/Typescript/Utils/Syntax.php @@ -27,6 +27,9 @@ public static function objectKey(string $key, bool $optional = false): string return $optional ? "{$encoded}?" : $encoded; } + /** + * @throws \JsonException + */ public static function stringLiteral(string $value): string { return json_encode($value, JSON_THROW_ON_ERROR); From bc907e478c2c74156f05426ccd484cedc673dd8f Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 11:50:38 +0200 Subject: [PATCH 016/101] Add `TypescriptFile` and `TypescriptImport` classes to handle TypeScript source generation with imports and code blocks; include comprehensive unit tests for deterministic behavior and edge cases. --- src/Typescript/Code/TypescriptFile.php | 143 +++++++++ src/Typescript/Code/TypescriptImport.php | 151 +++++++++ src/Typescript/Utils/Syntax.php | 14 + src/Utils/Lists.php | 22 ++ .../Typescript/Code/TypescriptFileTest.php | 292 ++++++++++++++++++ .../Typescript/Code/TypescriptImportTest.php | 172 +++++++++++ 6 files changed, 794 insertions(+) create mode 100644 src/Typescript/Code/TypescriptFile.php create mode 100644 src/Typescript/Code/TypescriptImport.php create mode 100644 tests/Unit/Typescript/Code/TypescriptFileTest.php create mode 100644 tests/Unit/Typescript/Code/TypescriptImportTest.php diff --git a/src/Typescript/Code/TypescriptFile.php b/src/Typescript/Code/TypescriptFile.php new file mode 100644 index 0000000..b5e2f2d --- /dev/null +++ b/src/Typescript/Code/TypescriptFile.php @@ -0,0 +1,143 @@ + One entry per module, sorted by module specifier. + */ + public array $imports; + + /** + * @param list $imports Duplicated modules are merged, empty ones dropped. + */ + public function __construct(string $code = '', array $imports = []) + { + $this->code = self::normalizeBlock($code); + $this->imports = self::mergeByModule($imports); + } + + public function withImports(TypescriptImport ...$imports): self + { + return new self($this->code, [...$this->imports, ...$imports]); + } + + /** + * Appends a block of code. A string carries no imports; a file carries its own, merged into + * this one. Blocks are separated by a blank line, and appending nothing changes nothing. + */ + public function append(string|self $block): self + { + $code = $block instanceof self ? $block->code : self::normalizeBlock($block); + $imports = $block instanceof self ? [...$this->imports, ...$block->imports] : $this->imports; + + return new self( + $this->code === '' || $code === '' + ? $this->code . $code + : $this->code . PHP_EOL . PHP_EOL . $code, + $imports, + ); + } + + public function toString(): string + { + $importLines = []; + foreach ($this->imports as $import) { + array_push($importLines, ...self::statementsFor($import)); + } + + $body = $this->code === '' ? '' : $this->code . PHP_EOL; + if ($importLines === []) { + return $body; + } + + return implode(PHP_EOL, $importLines) . PHP_EOL . ($body === '' ? '' : PHP_EOL . $body); + } + + public function __toString(): string + { + return $this->toString(); + } + + /** + * @param list $imports + * @return list + */ + private static function mergeByModule(array $imports): array + { + /** @var array $byModule */ + $byModule = []; + foreach ($imports as $import) { + if ($import->isEmpty()) { + continue; + } + + $byModule[$import->from] = ($byModule[$import->from] ?? null)?->merge($import) ?? $import; + } + + // SORT_STRING: a specifier that looks numeric would otherwise compare as a number. + ksort($byModule, SORT_STRING); + return array_values($byModule); + } + + /** + * @return list + */ + private static function statementsFor(TypescriptImport $import): array + { + return Lists::filterNullValues([ + self::statement('import type', $import->types, $import->from), + self::statement('import', $import->values, $import->from), + ]); + } + + /** + * @param list $names + */ + private static function statement(string $keyword, array $names, string $from): ?string + { + return $names === [] + ? null + : "{$keyword} {" . implode(', ', $names) . '} from ' . Syntax::moduleSpecifier($from) . ';'; + } + + /** + * A block owns its own indentation but not the blank lines around it — the file does. + */ + private static function normalizeBlock(string $code): string + { + return trim($code) === '' ? '' : trim($code, "\r\n"); + } +} diff --git a/src/Typescript/Code/TypescriptImport.php b/src/Typescript/Code/TypescriptImport.php new file mode 100644 index 0000000..173879d --- /dev/null +++ b/src/Typescript/Code/TypescriptImport.php @@ -0,0 +1,151 @@ + Sorted, unique. + */ + public array $values; + + /** + * @var list Sorted, unique, disjoint from $values. + */ + public array $types; + + /** + * Prefer values()/types(); the constructor is for the rare module that gives both. + * + * @param list $values + * @param list $types + * @throws InvalidArgumentException When $from cannot be written as a module specifier. + * @throws InvalidStringLiteralException When a name is not a valid TypeScript identifier. + */ + public function __construct( + public string $from, + array $values = [], + array $types = [], + ) + { + self::assertUsableSpecifier($from); + + $this->values = self::canonical($values, $from); + $this->types = array_diff(self::canonical($types, $from), $this->values) |> array_values(...); + } + + /** + * @param string|list $names + */ + public static function values(string $from, string|array $names): self + { + return new self($from, values: is_string($names) ? [$names] : $names); + } + + /** + * @param string|list $names + */ + public static function types(string $from, string|array $names): self + { + return new self($from, types: is_string($names) ? [$names] : $names); + } + + /** + * @param string|list $valuesOrNames + */ + public static function mixed(string $from, string|array $valuesOrNames): self + { + $valuesOrNames = is_array($valuesOrNames) ? $valuesOrNames : [$valuesOrNames]; + $values = []; + $types = []; + + foreach ($valuesOrNames as $name) { + $trimmed = trim($name); + if (str_starts_with($trimmed, 'type ')) { + $types[] = substr($trimmed, 5); + } else { + $values[] = $trimmed; + } + } + + return new self($from, values: $values, types: $types); + } + + /** + * @throws InvalidArgumentException When the two imports name different modules. + */ + public function merge(self $other): self + { + if ($this->from !== $other->from) { + throw new InvalidArgumentException( + "Cannot merge imports of '{$this->from}' and '{$other->from}': they are different modules." + ); + } + + // The constructor re-canonicalizes, so merging can neither duplicate a name nor leave one + // in both buckets. + return new self( + $this->from, + [...$this->values, ...$other->values], + [...$this->types, ...$other->types], + ); + } + + public function isEmpty(): bool + { + return $this->values === [] && $this->types === []; + } + + /** + * @param list $names + * @return list + */ + private static function canonical(array $names, string $from): array + { + foreach ($names as $name) { + if (!Syntax::isValidIdentifier($name)) { + throw InvalidStringLiteralException::notAValidTypescriptIdentifier( + $name, + "imported from '{$from}'", + ); + } + } + + return $names |> Lists::unique(...) |> Lists::sorted(...); + } + + private static function assertUsableSpecifier(string $from): void + { + // The specifier is written verbatim inside a single quoted string literal. Whitespace, + // quotes and backslashes would either break the literal or silently name a module that + // does not exist, so reject them rather than escape them into something plausible. + if ($from === '' || preg_match('/[\s\'"\\\\]/', $from) === 1) { + throw new InvalidArgumentException( + "'{$from}' cannot be written as a TypeScript module specifier." + ); + } + } +} diff --git a/src/Typescript/Utils/Syntax.php b/src/Typescript/Utils/Syntax.php index fd5ecf2..330639a 100644 --- a/src/Typescript/Utils/Syntax.php +++ b/src/Typescript/Utils/Syntax.php @@ -40,6 +40,20 @@ public static function wrapInParentheses(string $value): string return "({$value})"; } + /** + * A module specifier as it appears after `from`. Single quoted, matching the rest of the + * generated output — unlike stringLiteral(), which is JSON and therefore double quotes. + * The specifier is written verbatim, so the caller vouches for it being writable. + */ + public static function moduleSpecifier(string $specifier): string + { + if (str_contains($specifier, "'")) { + throw new \RuntimeException("Invalid path specified: '{$specifier}'"); + } + + return "'{$specifier}'"; + } + /** * A branded type, e.g. `string & Brand<"email">`. */ diff --git a/src/Utils/Lists.php b/src/Utils/Lists.php index 428ed2d..57f90aa 100644 --- a/src/Utils/Lists.php +++ b/src/Utils/Lists.php @@ -13,4 +13,26 @@ public static function filterNullValues(array $list): array { return array_filter($list, fn($value) => $value !== null) |> array_values(...); } + + /** + * array_unique preserves keys, which breaks the list. This does not. + * + * @template TValue of int|string + * @param list $list + * @return list + */ + public static function unique(array $list): array + { + return array_unique($list) |> array_values(...); + } + + /** + * @param list $list + * @return list + */ + public static function sorted(array $list): array + { + usort($list, strcmp(...)); + return $list; + } } \ No newline at end of file diff --git a/tests/Unit/Typescript/Code/TypescriptFileTest.php b/tests/Unit/Typescript/Code/TypescriptFileTest.php new file mode 100644 index 0000000..72da265 --- /dev/null +++ b/tests/Unit/Typescript/Code/TypescriptFileTest.php @@ -0,0 +1,292 @@ +toString())->toBe(''); +}); + +test('renders code with no imports', function () { + expect(new TypescriptFile('export type A = 1;')->toString())->toBe("export type A = 1;\n"); +}); + +test('always ends with exactly one newline', function (string $code) { + expect(new TypescriptFile($code)->toString())->toBe("const a = 1;\n"); +})->with([ + 'none' => ['const a = 1;'], + 'one' => ["const a = 1;\n"], + 'several' => ["const a = 1;\n\n\n"], + 'leading' => ["\n\nconst a = 1;"], +]); + +test('separates the imports from the code with one blank line', function () { + $file = new TypescriptFile('const a = queryKey();', [ + TypescriptImport::values('./lib/utils', 'queryKey'), + ]); + + expect($file->toString())->toBe( + "import {queryKey} from './lib/utils';\n\nconst a = queryKey();\n" + ); +}); + +test('renders imports alone when there is no code', function () { + $file = new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')]); + + expect($file->toString())->toBe("import type {Brand} from './lib/types';\n"); +}); + +test('splits a module into a type line and a value line, type first', function () { + $file = new TypescriptFile('', [ + new TypescriptImport('./lib/types', values: ['isBrand'], types: ['Brand']), + ]); + + expect($file->toString())->toBe( + "import type {Brand} from './lib/types';\n" + . "import {isBrand} from './lib/types';\n" + ); +}); + +test('renders only the line it has names for', function () { + $values = new TypescriptFile('', [TypescriptImport::values('./lib/utils', 'queryKey')]); + $types = new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')]); + + expect($values->toString())->toBe("import {queryKey} from './lib/utils';\n") + ->and($types->toString())->toBe("import type {Brand} from './lib/types';\n"); +}); + +test('quotes module specifiers with single quotes', function () { + // Syntax::stringLiteral() is json_encode and would produce double quotes here. + expect(new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')])->toString()) + ->toContain("from './lib/types';") + ->not->toContain('"./lib/types"'); +}); + +test('separates names with a comma and a space and does not pad the braces', function () { + $file = new TypescriptFile('', [TypescriptImport::types('./lib/types', ['Brand', 'Order'])]); + + expect($file->toString())->toBe("import type {Brand, Order} from './lib/types';\n"); +}); + +test('sorts the names inside each line', function () { + $file = new TypescriptFile('', [ + TypescriptImport::types('./lib/types', ['OrderStatus', 'Brand', 'Customer']), + ]); + + expect($file->toString())->toBe("import type {Brand, Customer, OrderStatus} from './lib/types';\n"); +}); + +test('sorts modules by specifier', function () { + $file = new TypescriptFile('', [ + TypescriptImport::values('@tanstack/react-query', 'useQuery'), + TypescriptImport::values('./lib/utils', 'queryKey'), + TypescriptImport::types('./lib/types', 'Brand'), + ]); + + expect($file->toString())->toBe( + "import type {Brand} from './lib/types';\n" + . "import {queryKey} from './lib/utils';\n" + . "import {useQuery} from '@tanstack/react-query';\n" + ); +}); + +test('merges two imports of the same module given to the constructor', function () { + $file = new TypescriptFile('', [ + TypescriptImport::types('./lib/types', 'Order'), + TypescriptImport::types('./lib/types', 'Brand'), + ]); + + expect($file->imports)->toHaveCount(1) + ->and($file->toString())->toBe("import type {Brand, Order} from './lib/types';\n"); +}); + +test('test mixed import', function () { + $file = new TypescriptFile('', [ + TypescriptImport::mixed('./lib/types', [ + ' type Order', + 'type Brand ', + ' SomeValue' + ]), + ]); + + expect($file->imports)->toHaveCount(1) + ->and($file->toString())->toBe("import type {Brand, Order} from './lib/types';\nimport {SomeValue} from './lib/types';\n"); +}); + +test('merges constructor imports with imports added later', function () { + $file = new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Order')]) + ->withImports(TypescriptImport::types('./lib/types', 'Brand')); + + expect($file->imports)->toHaveCount(1) + ->and($file->toString())->toBe("import type {Brand, Order} from './lib/types';\n"); +}); + +test('drops an import that names nothing', function () { + $file = new TypescriptFile('const a = 1;', [ + new TypescriptImport('./lib/types'), + TypescriptImport::values('./lib/utils', 'queryKey'), + ]); + + expect($file->imports)->toHaveCount(1) + ->and($file->toString())->toBe("import {queryKey} from './lib/utils';\n\nconst a = 1;\n"); +}); + +test('never emits a name as both a type and a value import of one module', function () { + $file = new TypescriptFile('', [ + TypescriptImport::types('./lib/types', ['Status', 'Order']), + TypescriptImport::values('./lib/types', 'Status'), + ]); + + expect($file->toString())->toBe( + "import type {Order} from './lib/types';\n" + . "import {Status} from './lib/types';\n" + ); +}); + +test('renders the same bytes whatever order the imports arrived in', function () { + $imports = [ + TypescriptImport::values('@tanstack/react-query', 'useQuery'), + TypescriptImport::types('./lib/types', 'Brand'), + TypescriptImport::values('./lib/utils', 'queryKey'), + TypescriptImport::types('./lib/types', 'Order'), + ]; + + expect(new TypescriptFile('const a = 1;', $imports)->toString()) + ->toBe(new TypescriptFile('const a = 1;', array_reverse($imports))->toString()); +}); + +test('appending a string keeps the existing imports', function () { + $file = new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')]) + ->append('const b = 2;'); + + expect($file->imports)->toHaveCount(1) + ->and($file->toString())->toBe( + "import type {Brand} from './lib/types';\n\nconst a = 1;\n\nconst b = 2;\n" + ); +}); + +test('appending a file merges its imports', function () { + $file = new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Order')]) + ->append(new TypescriptFile('const b = 2;', [ + TypescriptImport::types('./lib/types', 'Brand'), + TypescriptImport::values('./lib/utils', 'queryKey'), + ])); + + expect($file->toString())->toBe( + "import type {Brand, Order} from './lib/types';\n" + . "import {queryKey} from './lib/utils';\n" + . "\nconst a = 1;\n\nconst b = 2;\n" + ); +}); + +test('appending a file with no imports leaves the imports alone', function () { + $file = new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')]) + ->append(new TypescriptFile('const b = 2;')); + + expect($file->imports)->toHaveCount(1) + ->and($file->imports[0]->types)->toBe(['Brand']); +}); + +test('separates appended blocks with a blank line', function () { + $file = new TypescriptFile('export type A = 1;') + ->append('export type B = 2;') + ->append('export type C = 3;'); + + expect($file->toString())->toBe("export type A = 1;\n\nexport type B = 2;\n\nexport type C = 3;\n"); +}); + +test('strips the newlines around an appended block', function () { + $file = new TypescriptFile('export type A = 1;')->append("\n\nexport type B = 2;\n\n"); + + expect($file->toString())->toBe("export type A = 1;\n\nexport type B = 2;\n"); +}); + +test('keeps the indentation of an appended block', function () { + $file = new TypescriptFile('function a() {')->append("\n return 1;\n"); + + expect($file->toString())->toBe("function a() {\n\n return 1;\n"); +}); + +test('appending nothing is a no-op', function (string $code) { + $file = new TypescriptFile('const a = 1;'); + + expect($file->append($code)->toString())->toBe("const a = 1;\n"); +})->with([ + 'empty string' => [''], + 'newlines' => ["\n\n"], + 'whitespace' => [" \n "], +]); + +test('appending to an empty file does not start it with a blank line', function () { + expect(new TypescriptFile()->append('const a = 1;')->toString())->toBe("const a = 1;\n") + ->and(new TypescriptFile()->append(new TypescriptFile('const a = 1;'))->toString()) + ->toBe("const a = 1;\n"); +}); + +test('constructing with code is the same as appending it to an empty file', function (string $code) { + expect(new TypescriptFile($code)->toString())->toBe(new TypescriptFile()->append($code)->toString()); +})->with([ + 'plain' => ['const a = 1;'], + 'padded with newlines' => ["\nconst a = 1;\n\n"], + 'indented' => [" const a = 1;"], + 'empty' => [''], +]); + +test('append returns a new file and leaves the original untouched', function () { + $original = new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')]); + + $appended = $original->append(new TypescriptFile('const b = 2;', [ + TypescriptImport::values('./lib/utils', 'queryKey'), + ])); + + expect($appended)->not->toBe($original) + ->and($original->code)->toBe('const a = 1;') + ->and($original->imports)->toHaveCount(1) + ->and($appended->imports)->toHaveCount(2); +}); + +test('withImports returns a new file and leaves the original untouched', function () { + $original = new TypescriptFile('const a = 1;'); + + $withImports = $original->withImports(TypescriptImport::types('./lib/types', 'Brand')); + + expect($withImports)->not->toBe($original) + ->and($original->imports)->toBe([]) + ->and($withImports->imports)->toHaveCount(1) + ->and($withImports->code)->toBe('const a = 1;'); +}); + +test('renders a full file: imports, a blank line, then every block', function () { + $file = new TypescriptFile('export type Id = number;', [ + TypescriptImport::values('./lib/utils', 'queryKey'), + TypescriptImport::types('./lib/types', ['Order', 'Brand']), + ])->append(new TypescriptFile( + <<toString())->toBe(<<toBeInstanceOf(Stringable::class) + ->and((string)$file)->toBe($file->toString()); +}); diff --git a/tests/Unit/Typescript/Code/TypescriptImportTest.php b/tests/Unit/Typescript/Code/TypescriptImportTest.php new file mode 100644 index 0000000..de9c054 --- /dev/null +++ b/tests/Unit/Typescript/Code/TypescriptImportTest.php @@ -0,0 +1,172 @@ +from)->toBe('./lib/types') + ->and($import->values)->toBe([]) + ->and($import->types)->toBe([]) + ->and($import->isEmpty())->toBeTrue(); +}); + +test('keeps value imports and type imports in separate buckets', function () { + $import = new TypescriptImport('./lib/utils', values: ['queryKey'], types: ['QueryKey']); + + expect($import->values)->toBe(['queryKey']) + ->and($import->types)->toBe(['QueryKey']) + ->and($import->isEmpty())->toBeFalse(); +}); + +test('takes a single name as a plain string', function () { + expect(TypescriptImport::values('./lib/utils', 'queryKey')->values)->toBe(['queryKey']) + ->and(TypescriptImport::types('./lib/types', 'Brand')->types)->toBe(['Brand']); +}); + +test('takes a list of names', function () { + expect(TypescriptImport::types('./lib/types', ['Brand', 'Order'])->types)->toBe(['Brand', 'Order']) + ->and(TypescriptImport::values('./lib/utils', ['queryKey', 'commandKey'])->values) + ->toBe(['commandKey', 'queryKey']); +}); + +test('the named constructors leave the other bucket empty', function () { + expect(TypescriptImport::types('./lib/types', 'Brand')->values)->toBe([]) + ->and(TypescriptImport::values('./lib/utils', 'queryKey')->types)->toBe([]); +}); + +test('sorts names alphabetically', function () { + $import = new TypescriptImport( + './lib/types', + values: ['zeta', 'alpha', 'Mike'], + types: ['Zulu', 'Alpha', 'Kilo'], + ); + + // strcmp is byte order, so uppercase sorts before lowercase. + expect($import->values)->toBe(['Mike', 'alpha', 'zeta']) + ->and($import->types)->toBe(['Alpha', 'Kilo', 'Zulu']); +}); + +test('drops duplicate names inside a bucket', function () { + $import = new TypescriptImport( + './lib/types', + values: ['queryKey', 'queryKey'], + types: ['Brand', 'Brand', 'Order'], + ); + + expect($import->values)->toBe(['queryKey']) + ->and($import->types)->toBe(['Brand', 'Order']); +}); + +test('drops a type import that is also imported as a value', function () { + // `import type {Foo}` next to `import {Foo}` from one module is TS2440. The value import + // already carries the type meaning, so the value wins. + $import = new TypescriptImport('./lib/types', values: ['Status'], types: ['Status', 'Order']); + + expect($import->values)->toBe(['Status']) + ->and($import->types)->toBe(['Order']); +}); + +test('reports whether it imports anything', function () { + expect(new TypescriptImport('./x')->isEmpty())->toBeTrue() + ->and(new TypescriptImport('./x', values: [])->isEmpty())->toBeTrue() + ->and(TypescriptImport::values('./x', 'a')->isEmpty())->toBeFalse() + ->and(TypescriptImport::types('./x', 'A')->isEmpty())->toBeFalse(); +}); + +test('rejects an empty module specifier', function () { + expect(fn() => new TypescriptImport('')) + ->toThrow(InvalidArgumentException::class, 'cannot be written as a TypeScript module specifier'); +}); + +test('rejects a module specifier that could not be written as a string literal', function (string $from) { + expect(fn() => TypescriptImport::values($from, 'a')) + ->toThrow(InvalidArgumentException::class, 'cannot be written as a TypeScript module specifier'); +})->with([ + 'single quote' => ["./li'b"], + 'double quote' => ['./li"b'], + 'backslash' => ['.\\lib'], + 'space' => ['./li b'], + 'newline' => ["./lib\n"], + 'leading whitespace' => [' ./lib'], +]); + +test('rejects a name that is not a valid TypeScript identifier', function (string $name) { + expect(fn() => TypescriptImport::values('./lib/types', $name)) + ->toThrow(InvalidStringLiteralException::class, 'is not a valid TypeScript identifier') + ->and(fn() => TypescriptImport::types('./lib/types', $name)) + ->toThrow(InvalidStringLiteralException::class, 'is not a valid TypeScript identifier'); +})->with([ + 'empty' => [''], + 'padded' => [' Foo '], + 'kebab case' => ['foo-bar'], + 'leading digit' => ['1Foo'], + 'a space' => ['Foo Bar'], + 'a dotted path' => ['Foo.Bar'], + // The old `"type Foo"` prefix convention is not an input format any more. + 'the old type prefix' => ['type Foo'], + 'an alias' => ['Foo as Bar'], + 'a namespace import' => ['* as ns'], +]); + +test('accepts identifiers with dollar signs and underscores', function () { + $import = TypescriptImport::values('./lib/utils', ['$foo', '_bar', 'Foo$1', '_']); + + expect($import->values)->toBe(['$foo', 'Foo$1', '_', '_bar']); +}); + +test('names the module in the error message so the bad import can be found', function () { + expect(fn() => TypescriptImport::types('./lib/types', 'foo-bar')) + ->toThrow(InvalidStringLiteralException::class, "imported from './lib/types'"); +}); + +test('merges the buckets of two imports of the same module', function () { + $merged = TypescriptImport::types('./lib/types', ['Order']) + ->merge(new TypescriptImport('./lib/types', values: ['queryKey'], types: ['Brand'])); + + expect($merged->from)->toBe('./lib/types') + ->and($merged->types)->toBe(['Brand', 'Order']) + ->and($merged->values)->toBe(['queryKey']); +}); + +test('dedupes while merging', function () { + $merged = TypescriptImport::types('./lib/types', ['Brand', 'Order']) + ->merge(TypescriptImport::types('./lib/types', ['Brand', 'Customer'])); + + expect($merged->types)->toBe(['Brand', 'Customer', 'Order']); +}); + +test('a merged value import removes the same name from the type bucket', function () { + $merged = TypescriptImport::types('./lib/types', ['Status', 'Order']) + ->merge(TypescriptImport::values('./lib/types', 'Status')); + + expect($merged->values)->toBe(['Status']) + ->and($merged->types)->toBe(['Order']); +}); + +test('refuses to merge imports of different modules', function () { + expect(fn() => TypescriptImport::types('./lib/types', 'Brand') + ->merge(TypescriptImport::types('./lib/utils', 'Brand'))) + ->toThrow(InvalidArgumentException::class, 'different modules'); +}); + +test('merging leaves both operands untouched', function () { + $one = TypescriptImport::types('./lib/types', 'Brand'); + $two = TypescriptImport::values('./lib/types', 'queryKey'); + + $one->merge($two); + + expect($one->types)->toBe(['Brand']) + ->and($one->values)->toBe([]) + ->and($two->types)->toBe([]) + ->and($two->values)->toBe(['queryKey']); +}); + +test('merging is order independent', function () { + $one = new TypescriptImport('./lib/types', values: ['queryKey'], types: ['Order']); + $two = new TypescriptImport('./lib/types', values: ['commandKey'], types: ['Brand', 'Order']); + + expect($one->merge($two)->values)->toBe($two->merge($one)->values) + ->and($one->merge($two)->types)->toBe($two->merge($one)->types); +}); From 9c51865519cdb1f2aef8bf48526fcd8b582ea511 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 12:01:30 +0200 Subject: [PATCH 017/101] Remove deprecated `TypescriptCodeBlock`, `TypescriptImportStatement`, and `TypeScriptFile` classes; refactor to use `TypescriptFile` and `TypescriptImport` for streamlined source generation with unified import handling and code composition; update tests and dependencies accordingly. --- .../Laravel/Commands/CodeGenCommand.php | 10 +-- .../EmitOperationClientBindings.php | 19 +++-- src/CodeGen/CodeGenerators/EmitOperations.php | 34 +++----- src/CodeGen/CodeGenerators/EmitQueryKey.php | 26 +++--- .../CodeGenerators/EmitTanstackQuery.php | 31 +++---- src/CodeGen/CodeGenerators/EmitTypeMap.php | 7 +- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 7 +- src/CodeGen/CodeGenerators/EmitTypes.php | 8 +- src/CodeGen/Contracts/GeneratesLibFiles.php | 4 +- .../Contracts/GeneratesOperationCode.php | 10 +-- src/CodeGen/Helpers/TypeScriptFile.php | 81 ------------------ src/CodeGen/Helpers/TypescriptCodeBlock.php | 34 -------- .../Helpers/TypescriptImportStatement.php | 85 ------------------- src/CodeGen/TypescriptServerCodeGenerator.php | 31 ++++--- tests/Unit/CodeGen/EmitQueryKeyTest.php | 20 +++-- tests/Unit/CodeGen/EmitTypesTest.php | 2 +- .../TypescriptServerCodeGeneratorTest.php | 36 +++++++- 17 files changed, 129 insertions(+), 316 deletions(-) delete mode 100644 src/CodeGen/Helpers/TypeScriptFile.php delete mode 100644 src/CodeGen/Helpers/TypescriptCodeBlock.php delete mode 100644 src/CodeGen/Helpers/TypescriptImportStatement.php diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index 72f378e..5abbd1f 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -24,9 +24,9 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypeScriptFile; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\Server\Server; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -165,8 +165,8 @@ private function getNamingGenerator(Application $application): Closure /** * @param string $directory - * @param array $files - * @return Generator + * @param array $files + * @return Generator */ private function iterateFiles(string $directory, array $files): Generator { @@ -178,7 +178,7 @@ private function iterateFiles(string $directory, array $files): Generator /** * @param string $directory - * @param array $files + * @param array $files * @return int */ private function verifyContentOnly(string $directory, array $files): int @@ -210,7 +210,7 @@ private function verifyContentOnly(string $directory, array $files): int /** * @param string $directory - * @param array $files + * @param array $files * @return void */ private function writeFiles(string $directory, array $files): void diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 69a2c81..41d23c4 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -5,6 +5,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\DependsOn; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; final class EmitOperationClientBindings implements GeneratesLibFiles, DependsOn @@ -18,12 +19,12 @@ public function dependsOnGenerator(): array } /** - * @return array + * @return array */ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array { return [ - "OperationClient" => << new TypescriptFile(<<>>; } -TypeScript, - "DefaultClient" => << new TypescriptFile(<< << new TypescriptFile(<< << new TypescriptFile(<< + * @return list */ private function aliasImports(TypedOperation $operation): array { - $aliases = ['Brand', ...$operation->usedAliases()]; - sort($aliases); - return [ - new TypescriptImportStatement( - from: Paths::libImport("types"), - imports: array_map(fn(string $alias): string => "type {$alias}", $aliases), - ), + TypescriptImport::types(Paths::libImport("types"), ['Brand', ...$operation->usedAliases()]), ]; } - public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): TypescriptCodeBlock + public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): TypescriptFile { $definition = $operation->operation->definition; $name = $this->generateName($operation); @@ -66,14 +60,8 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata $errorTypeName = $operationBaseTypeName . "Error"; $imports = [ - new TypescriptImportStatement( - from: Paths::libImport("bindings"), - imports: ["executeOperation"] - ), - new TypescriptImportStatement( - from: Paths::libImport("OperationClient"), - imports: ["OperationOptions"] - ), + TypescriptImport::values(Paths::libImport("bindings"), "executeOperation"), + TypescriptImport::types(Paths::libImport("OperationClient"), "OperationOptions"), ...$this->aliasImports($operation), ]; $docBlock = <<inputDef->type === 'null') { - return new TypescriptCodeBlock( + return new TypescriptFile( <<outputDef->type}; export type {$resultInputTypeName} = null; @@ -105,7 +93,7 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata ); } - return new TypescriptCodeBlock( + return new TypescriptFile( <<outputDef->type}; export type {$resultInputTypeName} = {$operation->inputDef->type}; diff --git a/src/CodeGen/CodeGenerators/EmitQueryKey.php b/src/CodeGen/CodeGenerators/EmitQueryKey.php index 4d4af40..d98c7b6 100644 --- a/src/CodeGen/CodeGenerators/EmitQueryKey.php +++ b/src/CodeGen/CodeGenerators/EmitQueryKey.php @@ -7,12 +7,12 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypescriptCodeBlock; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypescriptImportStatement; use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; -final class EmitQueryKey implements DependsOn, GeneratesOperationCode +final readonly class EmitQueryKey implements DependsOn, GeneratesOperationCode { public function dependsOnGenerator(): array { @@ -24,7 +24,7 @@ public function dependsOnGenerator(): array /** * @param (Closure(TypedOperation):string)|null $nameGenerator */ - public function __construct(private readonly ?Closure $nameGenerator = null) + public function __construct(private ?Closure $nameGenerator = null) { } @@ -34,7 +34,7 @@ private function generateName(TypedOperation $operation): string } - public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptCodeBlock + public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptFile { $definition = $operation->operation->definition; if ($definition->type !== OperationType::QUERY) { @@ -46,21 +46,15 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata // The input definition is inlined verbatim, so the aliases its registry carries must be // imported here as well — plus Brand, unconditionally, for inline brands. The file level // import merge dedupes them with EmitOperations' imports. - $aliases = ['Brand', ...$operation->inputDef->registry->usedAliases()]; - sort($aliases); - $imports = [ - new TypescriptImportStatement( - from: Paths::libImport("utils"), - imports: ['queryKey'], - ), - new TypescriptImportStatement( - from: Paths::libImport("types"), - imports: array_map(fn(string $alias): string => "type {$alias}", $aliases), + TypescriptImport::values(Paths::libImport("utils"), 'queryKey'), + TypescriptImport::types( + Paths::libImport("types"), + ['Brand', ...$operation->inputDef->registry->usedAliases()], ), ]; - return new TypescriptCodeBlock( + return new TypescriptFile( <<inputDef->type}) { diff --git a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php index 242ff56..8eba49b 100644 --- a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php +++ b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php @@ -7,10 +7,10 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypescriptCodeBlock; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypescriptImportStatement; use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; final readonly class EmitTanstackQuery implements GeneratesOperationCode, DependsOn { @@ -33,7 +33,7 @@ private function generateName(TypedOperation $operation): string return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->operation->definition->name; } - public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptCodeBlock + public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptFile { $definition = $operation->operation->definition; if ($definition->type !== OperationType::QUERY) { @@ -49,26 +49,18 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata $optionsTypeName = $operationBaseTypeName . "Options"; $imports = [ - new TypescriptImportStatement( - from: "@tanstack/react-query", - imports: ['useQuery', 'UseQueryOptions', 'queryOptions'], - ), - new TypescriptImportStatement( - from: Paths::libImport("utils"), - imports: ['queryKey'], - ), - new TypescriptImportStatement( - from: Paths::libImport("bindings"), - imports: ['throwOnFailure'], + new TypescriptImport( + "@tanstack/react-query", + values: ['useQuery', 'queryOptions'], + types: ['UseQueryOptions'], ), + TypescriptImport::values(Paths::libImport("utils"), 'queryKey'), + TypescriptImport::values(Paths::libImport("bindings"), 'throwOnFailure'), ]; - - if ($operation->inputDef->type === 'null') { - return new TypescriptCodeBlock( + return new TypescriptFile( <<, 'queryKey' | 'queryFn'>; export function {$queryOptionsName}(options?: {$optionsTypeName}) { @@ -89,9 +81,8 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata TypeScript, $imports); } - return new TypescriptCodeBlock( + return new TypescriptFile( <<, 'queryKey' | 'queryFn'>; export function {$queryOptionsName}(input: {$resultInputTypeName}, options?: {$optionsTypeName}) { diff --git a/src/CodeGen/CodeGenerators/EmitTypeMap.php b/src/CodeGen/CodeGenerators/EmitTypeMap.php index 86a58d5..a339b61 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeMap.php +++ b/src/CodeGen/CodeGenerators/EmitTypeMap.php @@ -5,9 +5,9 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypeScriptFile; -use Le0daniel\PhpTsBindings\Utils\Arrays; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Utils\Arrays; final class EmitTypeMap implements GeneratesLibFiles { @@ -34,8 +34,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegis })) . '}'; return [ - 'types' => new TypeScriptFile(code: << new TypescriptFile(<< + * @return array */ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array { @@ -38,7 +39,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegis }, []); return [ - "utils" => << new TypescriptFile(<<generateLiteralUnion($queryNamespaces)}; @@ -54,7 +55,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegis return "type" in result.__client && result.__client.type === "operations-spa"; } -TypeScript +TypeScript) ]; } diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 982bbf8..58a75df 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -5,6 +5,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Utils\Arrays; @@ -27,7 +28,7 @@ final class EmitTypes implements GeneratesLibFiles ]; /** - * @return array + * @return array */ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array { @@ -55,7 +56,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegis )); return [ - "types" => << new TypescriptFile(<<generateNamespaceUnion($uniqueNamespaces)}; export type Success = {success: true, data: T} @@ -76,8 +77,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegis /* All branded and named types exported */ {$aliasTypeString} - -TypeScript, +TypeScript), ]; } diff --git a/src/CodeGen/Contracts/GeneratesLibFiles.php b/src/CodeGen/Contracts/GeneratesLibFiles.php index ab88c84..824024b 100644 --- a/src/CodeGen/Contracts/GeneratesLibFiles.php +++ b/src/CodeGen/Contracts/GeneratesLibFiles.php @@ -4,7 +4,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypeScriptFile; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; interface GeneratesLibFiles @@ -19,7 +19,7 @@ interface GeneratesLibFiles * * @param list $operations * @param TypeRegistry $registry The run's shared registry: every alias any operation produced. - * @return array + * @return array */ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array; } \ No newline at end of file diff --git a/src/CodeGen/Contracts/GeneratesOperationCode.php b/src/CodeGen/Contracts/GeneratesOperationCode.php index cab5da1..6486826 100644 --- a/src/CodeGen/Contracts/GeneratesOperationCode.php +++ b/src/CodeGen/Contracts/GeneratesOperationCode.php @@ -4,14 +4,14 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypescriptCodeBlock; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; interface GeneratesOperationCode { /** - * @param TypedOperation $operation - * @param ServerMetadata $metadata - * @return TypescriptCodeBlock|null + * The code this generator contributes for one operation, with the imports it relies on, or null + * when the operation is none of its business. The file it is appended to merges the imports and + * places the blank lines around the block. */ - public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptCodeBlock; + public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptFile; } \ No newline at end of file diff --git a/src/CodeGen/Helpers/TypeScriptFile.php b/src/CodeGen/Helpers/TypeScriptFile.php deleted file mode 100644 index 116d322..0000000 --- a/src/CodeGen/Helpers/TypeScriptFile.php +++ /dev/null @@ -1,81 +0,0 @@ - $imports - * @param string $code - */ - public function __construct( - private(set) array $imports = [], - private(set) string $code = "", - ) - { - } - - public static function from(string|TypeScriptFile $content): TypeScriptFile - { - if ($content instanceof TypeScriptFile) { - return $content; - } - return new TypeScriptFile(imports: [], code: $content); - } - - public function addImports(TypescriptImportStatement ...$imports): void - { - foreach ($imports as $import) { - if (array_key_exists($import->from, $this->imports)) { - $this->imports[$import->from] = $this->imports[$import->from]->merge($import); - continue; - } - - $this->imports[$import->from] = $import; - } - } - - public function append(string|TypescriptCodeBlock $code): void - { - if ($code instanceof TypescriptCodeBlock) { - $this->addImports(...$code->imports ?? []); - - // For codeblocks we append a new line at the end. - $this->code .= $code->code . PHP_EOL; - return; - } - - $this->code .= $code; - } - - public function merge(TypeScriptFile $other): void - { - $this->addImports(...$other->imports); - $this->append($other->code); - } - - public function toString(): string - { - $imports = []; - foreach ($this->imports as $import) { - array_push($imports, ...$import->toStatements()); - } - - $importLines = implode(PHP_EOL, $imports); - - $fullFile = <<code} -TypeScript; - return trim($fullFile) . PHP_EOL; - } - - public function __toString(): string - { - return $this->toString(); - } -} \ No newline at end of file diff --git a/src/CodeGen/Helpers/TypescriptCodeBlock.php b/src/CodeGen/Helpers/TypescriptCodeBlock.php deleted file mode 100644 index 7a7e3bc..0000000 --- a/src/CodeGen/Helpers/TypescriptCodeBlock.php +++ /dev/null @@ -1,34 +0,0 @@ -|null $imports - */ - public function __construct( - public string $code = '', - public ?array $imports = null, - ) - { - } - - public function append(string $code): self - { - $this->code .= $code; - return $this; - } - - public function addImport(TypescriptImportStatement $import): self - { - $this->imports ??= []; - $this->imports[] = $import; - return $this; - } -} \ No newline at end of file diff --git a/src/CodeGen/Helpers/TypescriptImportStatement.php b/src/CodeGen/Helpers/TypescriptImportStatement.php deleted file mode 100644 index da281b7..0000000 --- a/src/CodeGen/Helpers/TypescriptImportStatement.php +++ /dev/null @@ -1,85 +0,0 @@ - - */ - private array $imports; - - /** - * @param string $from - * @param string|list $imports - */ - public function __construct( - public string $from, - string|array $imports = [], - ) - { - $this->imports = is_string($imports) ? [$imports] : $imports; - } - - public function merge(TypescriptImportStatement $other): TypescriptImportStatement - { - if ($this->from !== $other->from) { - throw new InvalidArgumentException("Cannot merge imports from different files"); - } - - $uniqueImports = array_values(array_unique([ - ... $this->getImports(), - ... $other->getImports() - ])); - - return new TypescriptImportStatement($this->from, $uniqueImports); - } - - /** @return list */ - public function toStatements(): array - { - $typeImports = []; - $valueImports = []; - - foreach ($this->imports as $statement) { - if (str_starts_with($statement, 'type ')) { - $typeImports[] = substr($statement, 5); - } else { - $valueImports[] = $statement; - } - } - - return Lists::filterNullValues([ - $this->toImport(true, $typeImports), - $this->toImport(false, $valueImports) - ]); - } - - /** - * @param bool $isTypeImport - * @param list $imports - * @return string|null - */ - private function toImport(bool $isTypeImport, array $imports): string|null - { - if (empty($imports)) { - return null; - } - - usort($imports, fn(string $a, string $b): int => strcmp($a, $b)); - - $importedValues = implode(', ', $imports); - return $isTypeImport ? "import type {{$importedValues}} from '{$this->from}';" : "import {{$importedValues}} from '{$this->from}';"; - } - - /** - * @return list - */ - public function getImports(): array - { - return array_map(fn(string $import): string => trim($import), $this->imports); - } -} \ No newline at end of file diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 3b684b4..ad6c775 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -8,12 +8,12 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypeScriptFile; use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; use Le0daniel\PhpTsBindings\Parser\AstValidator; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Server; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; @@ -64,7 +64,7 @@ private function verifyGeneratorDependencies(): void * @param Server $server * @param ServerMetadata $metadata * @param list $ignore - * @return array + * @return array */ public function generate(Server $server, ServerMetadata $metadata, array $ignore = []): array { @@ -136,12 +136,16 @@ private function generateAllErrorTypes(Server $server, Definition $operation): s * @param list $definitions * @param ServerMetadata $metadata * @param TypeRegistry $registry The run's shared registry, holding every alias any pass produced. - * @return array + * @return array */ private function generateLibFiles(array $definitions, ServerMetadata $metadata, TypeRegistry $registry): array { return array_reduce( $this->generators, + /** + * @param array $carry + * @return array + */ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry): array { if (!$codeGenerator instanceof GeneratesLibFiles) { return $carry; @@ -152,8 +156,10 @@ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry) throw new RuntimeException("Invalid file name '{$fileName}' for lib file. File names must only contain a-z, A-Z, 0-9, - and _."); } - $carry["lib/{$fileName}.ts"] ??= new TypeScriptFile(); - $carry["lib/{$fileName}.ts"]->merge(TypeScriptFile::from($fileContent)); + // Several generators may contribute to one lib file, so they accumulate rather + // than overwrite. + $fileKey = "lib/{$fileName}.ts"; + $carry[$fileKey] = ($carry[$fileKey] ?? new TypescriptFile())->append($fileContent); } return $carry; }, @@ -164,15 +170,18 @@ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry) /** * @param list $definitions * @param ServerMetadata $metadata - * @return array + * @return array */ private function generateOperationDefinitions(array $definitions, ServerMetadata $metadata): array { - /** @var array $operationFiles */ + /** @var array $operationFiles */ $operationFiles = []; foreach ($definitions as $operationData) { - $namespace = $operationData->definition->namespace; - $file = $operationFiles["{$namespace}.ts"] ??= new TypeScriptFile(); + $fileKey = "{$operationData->definition->namespace}.ts"; + + // The file is immutable, so each block produces a new one and the last is kept. It also + // owns the blank lines between blocks, which is why nothing is appended as a separator. + $file = $operationFiles[$fileKey] ?? new TypescriptFile(); foreach ($this->generators as $codeGenerator) { if (!$codeGenerator instanceof GeneratesOperationCode) { @@ -180,11 +189,11 @@ private function generateOperationDefinitions(array $definitions, ServerMetadata } if ($code = $codeGenerator->generateOperationCode($operationData, $metadata)) { - $file->append($code); + $file = $file->append($code); } } - $file->append(PHP_EOL . PHP_EOL); + $operationFiles[$fileKey] = $file; } return $operationFiles; diff --git a/tests/Unit/CodeGen/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php index ae4bb89..16e33b7 100644 --- a/tests/Unit/CodeGen/EmitQueryKeyTest.php +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -5,7 +5,6 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypescriptImportStatement; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; @@ -14,17 +13,20 @@ use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; use Tests\Mocks\ValueObjects\Email; +/** + * The code block and the file it renders to — rendering imports is the file's business, so the + * import statements are only observable through the rendered output. + * + * @return array{string, string} + */ function queryKeyCodeFor(TypedOperation $typedOperation): array { - $block = new EmitQueryKey()->generateOperationCode( + $file = new EmitQueryKey()->generateOperationCode( $typedOperation, new ServerMetadata('/query/{fqn}', '/command/{fqn}'), ); - return [ - $block->code, - array_map(fn(TypescriptImportStatement $import): string => implode(PHP_EOL, $import->toStatements()), $block->imports ?? []), - ]; + return [$file->code, $file->toString()]; } function queryOperation(): Operation @@ -39,7 +41,7 @@ function queryOperation(): Operation } test('imports the aliases the inlined input definition carries', function () { - [$code, $imports] = queryKeyCodeFor(new TypedOperation( + [$code, $rendered] = queryKeyCodeFor(new TypedOperation( new TypeScript('{status:OrderStatus;}', new TypeRegistry(['OrderStatus' => '"OPEN"|"SHIPPED"'])), new TypeScript('Order', new TypeRegistry(['Order' => '{id:number;}'])), TypeScript::fromRawString(''), @@ -47,9 +49,9 @@ function queryOperation(): Operation )); expect($code)->toContain('export function getQueryKey(input: {status:OrderStatus;})') - ->and($imports)->toContain("import type {Brand, OrderStatus} from './lib/types';") + ->and($rendered)->toContain("import type {Brand, OrderStatus} from './lib/types';") // The output-only alias is not referenced by the query key. - ->and(implode("\n", $imports))->not->toContain('Order,'); + ->and($rendered)->not->toContain('Order,'); }); test('always imports the Brand helper, whether the input renders an inline brand or not', function () { diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 806579f..73cf962 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -45,7 +45,7 @@ function emitTypesFor(string $inputType, string $outputType): string $registry, ); - return $files['types']; + return $files['types']->toString(); } test('rejects an alias colliding with a declaration the types file always contains', function (string $alias) { diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index a813261..f1cd63d 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -4,14 +4,16 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTanstackQuery; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; -use Le0daniel\PhpTsBindings\CodeGen\Helpers\TypeScriptFile; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; use Le0daniel\PhpTsBindings\Server\Server; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Tests\Unit\CodeGen\Mocks\ConflictingNamedOperations; use Tests\Unit\CodeGen\Mocks\NamedOperations; @@ -20,9 +22,10 @@ /** * @param list $classes - * @return array + * @param list $generators + * @return array */ -function generateFor(array $classes): array +function generateFor(array $classes, ?array $generators = null): array { $server = new Server( EagerlyLoadedRegistry::withClasses($classes, keyGenerator: new PlainlyExposedKeyGenerator()), @@ -30,7 +33,7 @@ function generateFor(array $classes): array ); return new TypescriptServerCodeGenerator( - [ + $generators ?? [ new EmitTypes(), new EmitOperationClientBindings(), new EmitTypeUtils(), @@ -88,6 +91,31 @@ function generateFor(array $classes): array ->not->toContain('Email'); }); +test('merges what every generator imports into one sorted block per module', function () { + $operations = generateFor([NamedOperations::class], [ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations(), + new EmitQueryKey(), + new EmitTanstackQuery(), + ])['orders.ts']->toString(); + + // Modules are sorted by specifier and each appears exactly once, however the generators ran: + // bindings collects executeOperation and throwOnFailure, utils' queryKey is claimed twice and + // deduped, and the aliases come from both EmitOperations and EmitQueryKey. Type only exports + // are on their own line, which is what verbatimModuleSyntax requires. + expect($operations)->toStartWith(<< generateFor([ConflictingNamedOperations::class])) ->toThrow(UnsupportedTypeException::class, 'Customer'); From b291f3e845c3c7ea8e5e8fd06a5feade7995c84c Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 13:22:22 +0200 Subject: [PATCH 018/101] Add toast handling and directive management for SPA clients using `SerializableClient` and TypeScript utilities; implement `InteractsWithToasts` trait, `ToastType`, and `Toast` classes; update client behaviors, type definitions, and Laravel controller integration tests. --- .../Laravel/LaravelHttpController.php | 6 +- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 53 ++++++- src/CodeGen/CodeGenerators/EmitTypes.php | 29 ++-- src/Contracts/Client.php | 25 ++-- src/Contracts/SerializableClient.php | 20 +++ src/Parser/Nodes/UnionNode.php | 3 +- src/Server/Client/InteractsWithToasts.php | 40 ++++++ src/Server/Client/NullClient.php | 13 +- src/Server/Client/OperationSPAClient.php | 42 +++--- src/Server/Data/RpcSuccess.php | 1 + src/Server/Data/Toast.php | 28 ++++ src/Server/Data/ToastType.php | 12 ++ src/Utils/PHPExport.php | 7 +- .../Laravel/LaravelHttpControllerTest.php | 66 +++++++++ tests/Mocks/InvalidationNamespace.php | 13 ++ tests/Unit/CodeGen/EmitTypeUtilsTest.php | 75 ++++++++++ tests/Unit/CodeGen/EmitTypesTest.php | 35 +++++ tests/Unit/Server/Client/NullClientTest.php | 29 ++++ .../Server/Client/OperationSPAClientTest.php | 131 ++++++++++++++++++ 19 files changed, 568 insertions(+), 60 deletions(-) create mode 100644 src/Contracts/SerializableClient.php create mode 100644 src/Server/Client/InteractsWithToasts.php create mode 100644 src/Server/Data/Toast.php create mode 100644 src/Server/Data/ToastType.php create mode 100644 tests/Mocks/InvalidationNamespace.php create mode 100644 tests/Unit/CodeGen/EmitTypeUtilsTest.php create mode 100644 tests/Unit/Server/Client/NullClientTest.php create mode 100644 tests/Unit/Server/Client/OperationSPAClientTest.php diff --git a/src/Adapters/Laravel/LaravelHttpController.php b/src/Adapters/Laravel/LaravelHttpController.php index 1611ed3..b97dc3a 100644 --- a/src/Adapters/Laravel/LaravelHttpController.php +++ b/src/Adapters/Laravel/LaravelHttpController.php @@ -9,9 +9,9 @@ use Illuminate\Http\Request; use Illuminate\Routing\Route; use Illuminate\Support\Facades; -use JsonSerializable; use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; use Le0daniel\PhpTsBindings\Contracts\Client; +use Le0daniel\PhpTsBindings\Contracts\SerializableClient; use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Client\OperationSPAClient; @@ -120,11 +120,11 @@ private function gatherInputFromRequest(OperationType $type, Http\Request $reque */ private function appendClientDirectives(array $response, Client $client): array { - if (!$client instanceof JsonSerializable) { + if (!$client instanceof SerializableClient) { return $response; } - $clientData = $client->jsonSerialize(); + $clientData = $client->serializeToArray(); if ($clientData === null) { return $response; } diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index caed252..68f5973 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -7,6 +7,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; @@ -38,22 +39,70 @@ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegis return $carry; }, []); + // Derived from the enum, so the values the guard accepts can never drift from ToastType. + $toastTypes = implode(', ', array_map( + fn(ToastType $type): string => "'{$type->value}'", + ToastType::cases(), + )); + return [ "utils" => new TypescriptFile(<<generateLiteralUnion($queryNamespaces)}; +const TOAST_TYPES = [{$toastTypes}] as const; + export function queryKey(ns: QueryNamespaces, ...args: unknown[]): [string, ...unknown[]] { return [ns, ...args]; } +function isArrayOf(value: unknown, predicate: (item: unknown) => item is V): value is V[] { + return Array.isArray(value) && value.every(predicate); +} + +export function isClientToast(value: unknown): value is ClientToast { + if (!value || typeof value !== 'object') { + return false; + } + + const toast = value as Partial; + return typeof toast.message === 'string' + && typeof toast.type === 'string' + && (TOAST_TYPES as readonly string[]).includes(toast.type); +} + +export function isClientRedirect(value: unknown): value is ClientRedirect { + if (!value || typeof value !== 'object') { + return false; + } + + const redirect = value as Partial; + return typeof redirect.url === 'string' && typeof redirect.reload === 'boolean'; +} + +function isClientInvalidation(value: unknown): value is [string, ...unknown[]] { + return Array.isArray(value) && typeof value[0] === 'string'; +} + +/** + * Narrows to the full directive payload, so it verifies every directive it claims and not just + * the discriminator: a server on an older format would otherwise be narrowed to a shape it does + * not have. Unknown directive keys are ignored, adding one stays backwards compatible. + */ export function isSpaClientDirectives(result: WithClientDirectives): result is SPAClientDirectives { if (!result.__client || typeof result.__client !== 'object') { return false; } - return "type" in result.__client && result.__client.type === "operations-spa"; + const directives = result.__client as Partial; + if (directives.type !== 'operations-spa') { + return false; + } + + return (directives.redirect === undefined || isClientRedirect(directives.redirect)) + && (directives.toasts === undefined || isArrayOf(directives.toasts, isClientToast)) + && (directives.invalidations === undefined || isArrayOf(directives.invalidations, isClientInvalidation)); } TypeScript) ]; diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 58a75df..59481d3 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -5,6 +5,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; @@ -24,6 +25,10 @@ final class EmitTypes implements GeneratesLibFiles 'OperationNamespaces', 'WithClientDirectives', 'SPAClientDirectives', + 'ClientDirectives', + 'ClientToast', + 'ClientRedirect', + 'ClientInvalidation', 'TYPE_MAP', ]; @@ -55,6 +60,12 @@ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegis fn(string $alias, string $definition): string => "export type {$alias} = {$definition}", )); + // Derived from the enum so the emitted union can never drift from what Client::toast accepts. + $toastTypes = implode('|', array_map( + fn(ToastType $type): string => "'{$type->value}'", + ToastType::cases(), + )); + return [ "types" => new TypescriptFile(<<generateNamespaceUnion($uniqueNamespaces)}; @@ -62,15 +73,17 @@ public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegis export type Success = {success: true, data: T} export type Failure = {success: false} & E; export type Result = Success | Failure; -export type WithClientDirectives = T & {__client?: unknown} -export type SPAClientDirectives = T & { - __client: { - type: "operations-spa", - redirect?: {type: "soft"|"hard"; url: string;}, - toasts?: {type: 'success'|'error'|'alert'|'info', message: string;}[], - invalidations?: [string, string, ...unknown[]][] - } +export type ClientToast = {type: {$toastTypes}; message: string;}; +export type ClientRedirect = {url: string; reload: boolean;}; +export type ClientInvalidation = [string, ...unknown[]]; +export type ClientDirectives = { + type: "operations-spa"; + redirect?: ClientRedirect; + toasts?: ClientToast[]; + invalidations?: ClientInvalidation[]; }; +export type WithClientDirectives = T & {__client?: unknown} +export type SPAClientDirectives = T & {__client: ClientDirectives}; declare const __brand: unique symbol; export type Brand = {readonly [__brand]: TBrand;}; diff --git a/src/Contracts/Client.php b/src/Contracts/Client.php index 214c7f6..d0ab76d 100644 --- a/src/Contracts/Client.php +++ b/src/Contracts/Client.php @@ -2,20 +2,29 @@ namespace Le0daniel\PhpTsBindings\Contracts; +use Le0daniel\PhpTsBindings\Server\Data\Toast; use UnitEnum; interface Client { + public function toast(Toast $toast): void; + + public function success(string $message): void; + + public function error(string $message): void; + + public function warning(string $message): void; + + public function alert(string $message): void; + + public function info(string $message): void; + /** - * @param 'success'|'error'|'alert'|'info' $type - * @param string $message + * @param string $url + * @param bool $reload Forces the client to do a full page load instead of a client side navigation. * @return void */ - public function toast(string $type, string $message): void; - - public function redirect(string $url): void; - - public function hardRedirect(string $url): void; + public function redirect(string $url, bool $reload = false): void; /** * @param UnitEnum|string $namespace @@ -23,4 +32,4 @@ public function hardRedirect(string $url): void; * @return void */ public function invalidate(UnitEnum|string $namespace, mixed... $key): void; -} \ No newline at end of file +} diff --git a/src/Contracts/SerializableClient.php b/src/Contracts/SerializableClient.php new file mode 100644 index 0000000..6bf0c48 --- /dev/null +++ b/src/Contracts/SerializableClient.php @@ -0,0 +1,20 @@ +|null + */ + public function serializeToArray(): ?array; +} diff --git a/src/Parser/Nodes/UnionNode.php b/src/Parser/Nodes/UnionNode.php index 7f60d7e..4cfa892 100644 --- a/src/Parser/Nodes/UnionNode.php +++ b/src/Parser/Nodes/UnionNode.php @@ -4,13 +4,14 @@ use InvalidArgumentException; use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Contracts\ValidatableNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; use Le0daniel\PhpTsBindings\Utils\PHPExport; /** * @template T of NodeInterface */ -final class UnionNode implements NodeInterface +final class UnionNode implements NodeInterface, ValidatableNode { private bool $acceptsNull; diff --git a/src/Server/Client/InteractsWithToasts.php b/src/Server/Client/InteractsWithToasts.php new file mode 100644 index 0000000..38d3b1e --- /dev/null +++ b/src/Server/Client/InteractsWithToasts.php @@ -0,0 +1,40 @@ +toast(new Toast(ToastType::SUCCESS, $message)); + } + + public function error(string $message): void + { + $this->toast(new Toast(ToastType::ERROR, $message)); + } + + public function warning(string $message): void + { + $this->toast(new Toast(ToastType::WARNING, $message)); + } + + public function alert(string $message): void + { + $this->toast(new Toast(ToastType::ALERT, $message)); + } + + public function info(string $message): void + { + $this->toast(new Toast(ToastType::INFO, $message)); + } +} diff --git a/src/Server/Client/NullClient.php b/src/Server/Client/NullClient.php index 2b6df6d..4c03e10 100644 --- a/src/Server/Client/NullClient.php +++ b/src/Server/Client/NullClient.php @@ -3,22 +3,19 @@ namespace Le0daniel\PhpTsBindings\Server\Client; use Le0daniel\PhpTsBindings\Contracts\Client; +use Le0daniel\PhpTsBindings\Server\Data\Toast; use UnitEnum; final class NullClient implements Client { + use InteractsWithToasts; - public function toast(string $type, string $message): void + public function toast(Toast $toast): void { } - public function redirect(string $url): void - { - - } - - public function hardRedirect(string $url): void + public function redirect(string $url, bool $reload = false): void { } @@ -27,4 +24,4 @@ public function invalidate(UnitEnum|string $namespace, ...$key): void { } -} \ No newline at end of file +} diff --git a/src/Server/Client/OperationSPAClient.php b/src/Server/Client/OperationSPAClient.php index 27af16c..929ec01 100644 --- a/src/Server/Client/OperationSPAClient.php +++ b/src/Server/Client/OperationSPAClient.php @@ -2,19 +2,20 @@ namespace Le0daniel\PhpTsBindings\Server\Client; -use JsonSerializable; -use Le0daniel\PhpTsBindings\Contracts\Client; -use Le0daniel\PhpTsBindings\Utils\Arrays; +use Le0daniel\PhpTsBindings\Contracts\SerializableClient; +use Le0daniel\PhpTsBindings\Server\Data\Toast; use Le0daniel\PhpTsBindings\Utils\Dicts; use Le0daniel\PhpTsBindings\Utils\Strings; use UnitEnum; /** - * @phpstan-type Redirect array{type: 'soft'|'hard', url: string} - * @phpstan-type Toast array{type: 'success'|'error'|'alert'|'info', message: string} + * @phpstan-type Redirect array{url: string, reload: bool} + * @phpstan-type SerializedToast array{type: value-of<\Le0daniel\PhpTsBindings\Server\Data\ToastType>, message: string} */ -final class OperationSPAClient implements Client, JsonSerializable +final class OperationSPAClient implements SerializableClient { + use InteractsWithToasts; + /** @var Redirect|null */ private ?array $redirect = null; @@ -24,28 +25,17 @@ final class OperationSPAClient implements Client, JsonSerializable /** @var list>|null */ private ?array $invalidations = null; - public function toast(string $type, string $message): void + public function toast(Toast $toast): void { $this->toasts ??= []; - $this->toasts[] = [ - 'type' => $type, - 'message' => $message, - ]; - } - - public function redirect(string $url): void - { - $this->redirect = [ - 'url' => $url, - 'type' => 'soft', - ]; + $this->toasts[] = $toast; } - public function hardRedirect(string $url): void + public function redirect(string $url, bool $reload = false): void { $this->redirect = [ 'url' => $url, - 'type' => 'hard', + 'reload' => $reload, ]; } @@ -59,13 +49,15 @@ public function invalidate(UnitEnum|string $namespace, ...$key): void } /** - * @return array{redirect?: Redirect, toasts?: null|Toast[], invalidations?: list>, type: 'operations-spa'}|null + * @return array{redirect?: Redirect, toasts?: list, invalidations?: list>, type: 'operations-spa'}|null */ - public function jsonSerialize(): array|null + public function serializeToArray(): array|null { $data = Dicts::filterNullValues([ 'redirect' => $this->redirect, - 'toasts' => $this->toasts, + 'toasts' => $this->toasts === null + ? null + : array_map(fn(Toast $toast): array => $toast->toArray(), $this->toasts), 'invalidations' => $this->invalidations, ]); @@ -78,4 +70,4 @@ public function jsonSerialize(): array|null 'type' => 'operations-spa', ]; } -} \ No newline at end of file +} diff --git a/src/Server/Data/RpcSuccess.php b/src/Server/Data/RpcSuccess.php index cf74c4c..474d58a 100644 --- a/src/Server/Data/RpcSuccess.php +++ b/src/Server/Data/RpcSuccess.php @@ -8,6 +8,7 @@ { /** * @param array $metadata + * @internal */ public function __construct( public mixed $data, diff --git a/src/Server/Data/Toast.php b/src/Server/Data/Toast.php new file mode 100644 index 0000000..60d2918 --- /dev/null +++ b/src/Server/Data/Toast.php @@ -0,0 +1,28 @@ +, message: string} + */ + public function toArray(): array + { + return [ + 'type' => $this->type->value, + 'message' => $this->message, + ]; + } +} diff --git a/src/Server/Data/ToastType.php b/src/Server/Data/ToastType.php new file mode 100644 index 0000000..9938fdf --- /dev/null +++ b/src/Server/Data/ToastType.php @@ -0,0 +1,12 @@ + 'some_value']; + + $typeParser = new TypeParser(); + $operationRegistry = Mockery::mock(OperationRegistry::class); + $exceptionHandler = Mockery::mock(ExceptionHandler::class); + $app = Mockery::mock(Application::class); + $request = Mockery::mock(Request::class); + $request->query = new InputBag($inputData); + + $operationDefinition = new Definition( + OperationType::QUERY, + 'MyClass', + 'someMethod', + 'method', + 'docs', + [], + ); + + $operation = new Operation( + 'somekey', + $operationDefinition, + fn() => $typeParser->parse('array{name: string}'), + fn() => $typeParser->parse('array{id: string, name: string}'), + ); + + $controllerInstance = new class() { + public function someMethod(array $input, null $context, Client $client): array + { + $client->success('Saved'); + $client->redirect('/docs/123', true); + return ['id' => '123', 'name' => $input['name']]; + } + }; + + $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); + $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); + + $request->shouldReceive('header')->with(LaravelHttpController::CLIENT_ID_HEADER)->andReturn('operations-spa'); + $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); + + $controller = new LaravelHttpController( + new Server($operationRegistry, [], new CatchAllPresenter(), $app), + $exceptionHandler, + null, + ); + + // Act + $response = $controller->handleHttpQueryRequest($fcn, $request); + + // Assert + expect($response->getData(true))->toEqual([ + 'success' => true, + 'data' => ['id' => '123', 'name' => 'some_value'], + '__client' => [ + 'redirect' => ['url' => '/docs/123', 'reload' => true], + 'toasts' => [ + ['type' => 'success', 'message' => 'Saved'], + ], + 'type' => 'operations-spa', + ], + ]); +}); + test('handle invalid input http query request', function () { // Arrange $fcn = 'docs.method'; diff --git a/tests/Mocks/InvalidationNamespace.php b/tests/Mocks/InvalidationNamespace.php new file mode 100644 index 0000000..4922e07 --- /dev/null +++ b/tests/Mocks/InvalidationNamespace.php @@ -0,0 +1,13 @@ +parse('array{id: string}'), + output: $parser->parse('array{id: string}'), + ); + + $generator = new TypescriptGenerator(); + $registry = new TypeRegistry(); + $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, $registry); + $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); + + $files = new EmitTypeUtils()->emitFiles( + [new TypedOperation($input, $output, TypeScript::fromRawString(''), $operation)], + new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + $registry, + ); + + return $files['utils']->toString(); +} + +test('query namespaces are emitted as a literal union', function () { + expect(emitUtilsFor(namespace: 'orders'))->toContain("type QueryNamespaces = 'orders';"); +}); + +test('the toast type list the guard checks against is derived from the PHP enum', function () { + $utils = emitUtilsFor(); + + $cases = implode(', ', array_map( + fn(ToastType $type): string => "'{$type->value}'", + ToastType::cases(), + )); + + expect($utils)->toContain("const TOAST_TYPES = [{$cases}] as const;"); +}); + +test('the directive guard verifies every directive it narrows, not just the discriminator', function () { + $utils = emitUtilsFor(); + + // A guard that only checks __client.type would happily narrow a payload from a server + // still emitting the old {type: 'soft'|'hard'} redirect. + expect($utils) + ->toContain('export function isClientRedirect(value: unknown): value is ClientRedirect') + ->toContain('export function isClientToast(value: unknown): value is ClientToast') + ->toContain("typeof redirect.reload === 'boolean'") + ->toContain('isClientRedirect(directives.redirect)') + ->toContain('isArrayOf(directives.toasts, isClientToast)') + ->toContain('isArrayOf(directives.invalidations, isClientInvalidation)'); +}); + +test('the guard imports the named directive types instead of restating their shape', function () { + expect(emitUtilsFor()) + ->toContain('import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from "./types";'); +}); diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 73cf962..9171de9 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -9,6 +9,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; @@ -57,8 +58,42 @@ function emitTypesFor(string $inputType, string $outputType): string 'the Brand helper generic' => ['Brand'], 'the Result envelope' => ['Result'], 'the TYPE_MAP constant' => ['TYPE_MAP'], + 'the client directive wrapper' => ['WithClientDirectives'], + 'the SPA client directives' => ['SPAClientDirectives'], + 'the directive payload' => ['ClientDirectives'], + 'the toast directive' => ['ClientToast'], + 'the redirect directive' => ['ClientRedirect'], + 'the invalidation directive' => ['ClientInvalidation'], ]); +test('the SPA client directives mirror the PHP client contract', function () { + $types = emitTypesFor( + 'array{id: \\' . UserId::class . '}', + 'array{email: \\' . Email::class . '}', + ); + + $toastTypes = implode('|', array_map( + fn(ToastType $type): string => "'{$type->value}'", + ToastType::cases(), + )); + + expect($types) + ->toContain("export type ClientToast = {type: {$toastTypes}; message: string;};") + ->toContain('export type ClientRedirect = {url: string; reload: boolean;};') + ->toContain('export type ClientInvalidation = [string, ...unknown[]];') + ->toContain('export type SPAClientDirectives = T & {__client: ClientDirectives};') + ->not->toContain('"soft"|"hard"') + ->not->toContain('hardRedirect'); +}); + +test('an invalidation is a namespace followed by any number of keys, matching queryKey and PHP', function () { + $types = emitTypesFor('array{id: string}', 'array{id: string}'); + + // Client::invalidate($namespace) emits a single element array, so requiring a second + // string would describe a payload the server never produces. + expect($types)->not->toContain('[string, string, ...unknown[]]'); +}); + test('attribute brands stay inline and declare no alias, only the Brand helper is exported', function () { $types = emitTypesFor( 'array{id: \\' . UserId::class . '}', diff --git a/tests/Unit/Server/Client/NullClientTest.php b/tests/Unit/Server/Client/NullClientTest.php new file mode 100644 index 0000000..f2ade48 --- /dev/null +++ b/tests/Unit/Server/Client/NullClientTest.php @@ -0,0 +1,29 @@ +toast(new Toast(ToastType::INFO, 'Heads up')); + $client->success('Saved'); + $client->error('Failed'); + $client->warning('Careful'); + $client->alert('Heads up'); + $client->info('FYI'); + $client->redirect('/orders'); + $client->redirect('/logout', true); + $client->invalidate(InvalidationNamespace::USERS, 'get'); + + expect(true)->toBeTrue(); +}); + +test('it is not serializable, which is what keeps __client off the response', function () { + expect(new NullClient())->not->toBeInstanceOf(SerializableClient::class); +}); diff --git a/tests/Unit/Server/Client/OperationSPAClientTest.php b/tests/Unit/Server/Client/OperationSPAClientTest.php new file mode 100644 index 0000000..4902e23 --- /dev/null +++ b/tests/Unit/Server/Client/OperationSPAClientTest.php @@ -0,0 +1,131 @@ +serializeToArray())->toBeNull(); +}); + +test('a toast is serialized as its type value and message', function () { + $client = new OperationSPAClient(); + $client->toast(new Toast(ToastType::INFO, 'Heads up')); + + expect($client->serializeToArray())->toBe([ + 'toasts' => [ + ['type' => 'info', 'message' => 'Heads up'], + ], + 'type' => 'operations-spa', + ]); +}); + +test('each toast helper emits a toast of its own type', function (string $method, string $expectedType) { + $client = new OperationSPAClient(); + $client->{$method}('Message'); + + expect($client->serializeToArray()['toasts'])->toBe([ + ['type' => $expectedType, 'message' => 'Message'], + ]); +})->with([ + 'success' => ['success', 'success'], + 'error' => ['error', 'error'], + 'warning' => ['warning', 'warning'], + 'alert' => ['alert', 'alert'], + 'info' => ['info', 'info'], +]); + +test('toasts accumulate in call order', function () { + $client = new OperationSPAClient(); + $client->error('First'); + $client->toast(new Toast(ToastType::SUCCESS, 'Second')); + $client->warning('Third'); + + expect($client->serializeToArray()['toasts'])->toBe([ + ['type' => 'error', 'message' => 'First'], + ['type' => 'success', 'message' => 'Second'], + ['type' => 'warning', 'message' => 'Third'], + ]); +}); + +test('a redirect defaults to not reloading', function () { + $client = new OperationSPAClient(); + $client->redirect('/orders'); + + expect($client->serializeToArray())->toBe([ + 'redirect' => ['url' => '/orders', 'reload' => false], + 'type' => 'operations-spa', + ]); +}); + +test('a redirect can request a full reload', function () { + $client = new OperationSPAClient(); + $client->redirect('/logout', true); + + expect($client->serializeToArray()['redirect'])->toBe(['url' => '/logout', 'reload' => true]); +}); + +test('the redirect slot holds a single directive, the last call wins', function () { + $client = new OperationSPAClient(); + $client->redirect('/first', true); + $client->redirect('/second'); + + expect($client->serializeToArray()['redirect'])->toBe(['url' => '/second', 'reload' => false]); +}); + +test('an invalidation stringifies its namespace and appends the keys verbatim', function () { + $client = new OperationSPAClient(); + $client->invalidate(InvalidationNamespace::USERS, 'get', ['id' => 1]); + + expect($client->serializeToArray())->toBe([ + 'invalidations' => [ + ['users', 'get', ['id' => 1]], + ], + 'type' => 'operations-spa', + ]); +}); + +test('a pure enum namespace falls back to its case name', function () { + $client = new OperationSPAClient(); + $client->invalidate(ResultEnum::SUCCESS); + $client->invalidate('plain-string'); + + expect($client->serializeToArray()['invalidations'])->toBe([ + ['SUCCESS'], + ['plain-string'], + ]); +}); + +test('every directive kind is emitted side by side', function () { + $client = new OperationSPAClient(); + $client->success('Saved'); + $client->redirect('/orders/1'); + $client->invalidate(InvalidationNamespace::ORDERS, 'get'); + + expect($client->serializeToArray())->toBe([ + 'redirect' => ['url' => '/orders/1', 'reload' => false], + 'toasts' => [ + ['type' => 'success', 'message' => 'Saved'], + ], + 'invalidations' => [ + ['orders', 'get'], + ], + 'type' => 'operations-spa', + ]); +}); + +test('the serialized directives are plain data, nothing relies on json_encode to unwrap objects', function () { + $client = new OperationSPAClient(); + $client->warning('Careful'); + $client->redirect('/orders'); + $client->invalidate(InvalidationNamespace::ORDERS); + + $directives = $client->serializeToArray(); + $roundTripped = json_decode(json_encode($directives, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + + expect($roundTripped)->toBe($directives); +}); From 1bdd160c51f417966d4e9698bd1e56fe3cd90098 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 14:02:14 +0200 Subject: [PATCH 019/101] Introduce `MiddlewareContract` and `InvalidMiddlewareException`; enforce --- .../Middleware/LocalMetadataMiddleware.php | 30 +- src/Adapters/Laravel/config/config.php | 12 +- src/Contracts/Attributes/Middleware.php | 7 +- src/Contracts/MiddlewareContract.php | 35 +++ src/Contracts/RpcResult.php | 24 ++ src/Server/Data/Definition.php | 3 +- .../Exceptions/InvalidMiddlewareException.php | 27 ++ src/Server/Data/ResolveInfo.php | 4 +- src/Server/Data/RpcError.php | 11 +- src/Server/Data/RpcSuccess.php | 11 +- src/Server/Data/ServerConfiguration.php | 6 +- src/Server/Operations/OperationDiscovery.php | 16 +- src/Server/Pipeline/ContextualPipeline.php | 130 ++++----- .../Presenter/ExposedExceptionPresenter.php | 3 +- src/Server/Server.php | 56 ++-- tests/Feature/Mocks/NotAMiddleware.php | 11 + .../Operations/NameCheckingMiddleware.php | 17 +- tests/Feature/ServerTest.php | 22 ++ .../Pipeline/ContextualPipelineTest.php | 270 +++++++++++++----- 19 files changed, 483 insertions(+), 212 deletions(-) create mode 100644 src/Contracts/MiddlewareContract.php create mode 100644 src/Contracts/RpcResult.php create mode 100644 src/Server/Data/Exceptions/InvalidMiddlewareException.php create mode 100644 tests/Feature/Mocks/NotAMiddleware.php diff --git a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php index 76fee99..bb6e626 100644 --- a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php +++ b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php @@ -4,21 +4,17 @@ use Closure; use Le0daniel\PhpTsBindings\Contracts\Client; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Server\Data\ResolveInfo; use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; -final class LocalMetadataMiddleware +/** + * @implements MiddlewareContract + */ +final class LocalMetadataMiddleware implements MiddlewareContract { - /** - * @param mixed $input - * @param Closure(mixed): (RpcSuccess|RpcError) $next - * @param mixed $context - * @param ResolveInfo $resolveInfo - * @param Client $client - * @return RpcSuccess|RpcError - */ - public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $resolveInfo, Client $client): RpcSuccess|RpcError + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { if (config('app.debug') !== true) { return $next($input); @@ -34,16 +30,16 @@ public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo 'class' => $client::class, ], 'info' => [ - 'namespace' => $resolveInfo->namespace, - 'name' => $resolveInfo->name, - 'fqn' => $resolveInfo->fullyQualifiedName, - 'operationType' => $resolveInfo->operationType->name, + 'namespace' => $info->namespace, + 'name' => $info->name, + 'fqn' => $info->fullyQualifiedName, + 'operationType' => $info->operationType->name, ], 'handler' => [ - 'className' => $resolveInfo->className, - 'methodName' => $resolveInfo->methodName, + 'className' => $info->className, + 'methodName' => $info->methodName, ], - 'middleware' => $resolveInfo->middleware, + 'middleware' => $info->middleware, 'input' => $input, 'context' => [ 'class' => is_object($context) ? get_class($context) : gettype($context), diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index ebf2aff..4a99638 100644 --- a/src/Adapters/Laravel/config/config.php +++ b/src/Adapters/Laravel/config/config.php @@ -47,19 +47,23 @@ ], /** - * A list of global middleware class names run on every single Operation (Query and Command) - * Must implement: - * - public handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + * A list of global middleware class names run on every single Operation (Query and Command). + * Every class must implement Le0daniel\PhpTsBindings\Contracts\MiddlewareContract. + * + * $next() always hands back an RpcSuccess or an RpcError - a failure further in is converted + * before it reaches you, so post-processing runs either way. * * Usage: * ```php - * public function handle(mixed $input, Closure $next) { + * public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { * // (...) * $result = $next($input); * // (...) * return $result; * } * ``` + * + * @see Le0daniel\PhpTsBindings\Contracts\MiddlewareContract */ "middleware" => [], diff --git a/src/Contracts/Attributes/Middleware.php b/src/Contracts/Attributes/Middleware.php index 88893a8..82b90ca 100644 --- a/src/Contracts/Attributes/Middleware.php +++ b/src/Contracts/Attributes/Middleware.php @@ -3,22 +3,23 @@ namespace Le0daniel\PhpTsBindings\Contracts\Attributes; use Attribute; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] final readonly class Middleware { /** - * @var array + * @var list>> */ public array $middleware; /** - * @param class-string|array $middleware + * @param class-string>|array>> $middleware */ public function __construct( string|array $middleware, ) { - $this->middleware = is_array($middleware) ? $middleware : [$middleware]; + $this->middleware = is_array($middleware) ? array_values($middleware) : [$middleware]; } } \ No newline at end of file diff --git a/src/Contracts/MiddlewareContract.php b/src/Contracts/MiddlewareContract.php new file mode 100644 index 0000000..6ed1d11 --- /dev/null +++ b/src/Contracts/MiddlewareContract.php @@ -0,0 +1,35 @@ + $metadata + */ + public function withMetadata(array $metadata): static; + + /** + * Append metadata to the result. + * @param array $metadata + */ + public function appendMetadata(array $metadata): static; +} diff --git a/src/Server/Data/Definition.php b/src/Server/Data/Definition.php index 658f3e7..0ab526c 100644 --- a/src/Server/Data/Definition.php +++ b/src/Server/Data/Definition.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\Server\Data; use Le0daniel\PhpTsBindings\Contracts\ExportableToPhpCode; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Utils\PHPExport; final class Definition implements ExportableToPhpCode @@ -13,7 +14,7 @@ final class Definition implements ExportableToPhpCode * @param string $methodName * @param string $name * @param string $namespace - * @param list $middleware + * @param list>> $middleware */ public function __construct( public OperationType $type, diff --git a/src/Server/Data/Exceptions/InvalidMiddlewareException.php b/src/Server/Data/Exceptions/InvalidMiddlewareException.php new file mode 100644 index 0000000..f8a766a --- /dev/null +++ b/src/Server/Data/Exceptions/InvalidMiddlewareException.php @@ -0,0 +1,27 @@ + $className * @param string $methodName - * @param list $middleware + * @param list>> $middleware */ public function __construct( public readonly string $namespace, diff --git a/src/Server/Data/RpcError.php b/src/Server/Data/RpcError.php index 37c3ce3..77be985 100644 --- a/src/Server/Data/RpcError.php +++ b/src/Server/Data/RpcError.php @@ -2,9 +2,10 @@ namespace Le0daniel\PhpTsBindings\Server\Data; +use Le0daniel\PhpTsBindings\Contracts\RpcResult; use Throwable; -final readonly class RpcError +final readonly class RpcError implements RpcResult { /** * @param array $metadata @@ -21,20 +22,20 @@ public function __construct( /** * @param array $metadata - * @return self + * @return static * @api */ - public function withMetadata(array $metadata): self + public function withMetadata(array $metadata): static { return new self($this->type, $this->cause, $this->details, $this->resolveInfo, $metadata); } /** * @param array $metadata - * @return self + * @return static * @api */ - public function appendMetadata(array $metadata): self + public function appendMetadata(array $metadata): static { return new self($this->type, $this->cause, $this->details, $this->resolveInfo, [ ...$this->metadata, diff --git a/src/Server/Data/RpcSuccess.php b/src/Server/Data/RpcSuccess.php index 474d58a..e82c71f 100644 --- a/src/Server/Data/RpcSuccess.php +++ b/src/Server/Data/RpcSuccess.php @@ -3,8 +3,9 @@ namespace Le0daniel\PhpTsBindings\Server\Data; use Le0daniel\PhpTsBindings\Contracts\Client; +use Le0daniel\PhpTsBindings\Contracts\RpcResult; -final readonly class RpcSuccess +final readonly class RpcSuccess implements RpcResult { /** * @param array $metadata @@ -22,10 +23,10 @@ public function __construct( /** * Overwrite all existing metadata * @param array $metadata - * @return self + * @return static * @api */ - public function withMetadata(array $metadata): self + public function withMetadata(array $metadata): static { return new self($this->data, $this->client, $this->resolveInfo, $metadata); } @@ -33,10 +34,10 @@ public function withMetadata(array $metadata): self /** * Append metadata to the result * @param array $metadata - * @return self + * @return static * @api */ - public function appendMetadata(array $metadata): self + public function appendMetadata(array $metadata): static { return new self($this->data, $this->client, $this->resolveInfo, [ ...$this->metadata, diff --git a/src/Server/Data/ServerConfiguration.php b/src/Server/Data/ServerConfiguration.php index 8796b2e..bee66e7 100644 --- a/src/Server/Data/ServerConfiguration.php +++ b/src/Server/Data/ServerConfiguration.php @@ -2,11 +2,13 @@ namespace Le0daniel\PhpTsBindings\Server\Data; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; + final readonly class ServerConfiguration { /** * @param bool $coerceQueryInput - * @param list $middleware + * @param list>> $middleware */ public function __construct( public bool $coerceQueryInput = false, @@ -16,7 +18,7 @@ public function __construct( } /** - * @param class-string ...$middlewares + * @param class-string> ...$middlewares * @return self */ public function withMiddlewares(string ...$middlewares): self diff --git a/src/Server/Operations/OperationDiscovery.php b/src/Server/Operations/OperationDiscovery.php index 998a741..26da367 100644 --- a/src/Server/Operations/OperationDiscovery.php +++ b/src/Server/Operations/OperationDiscovery.php @@ -7,9 +7,9 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Middleware; use Le0daniel\PhpTsBindings\Contracts\Attributes\Query; use Le0daniel\PhpTsBindings\Contracts\Discoverer; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; use RuntimeException; @@ -95,17 +95,17 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, throw new RuntimeException("Method {$method->name} must have at least one parameter."); } - $attributes = [ - // Collect all middlewares, on the class and the method itself. + // Collect all middlewares, on the class and the method itself. + $middlewareAttributes = [ ... $class->getAttributes(Middleware::class), ... $method->getAttributes(Middleware::class), ]; - $middlewares = empty($attributes) ? [] : array_reduce($attributes, function (array $carry, ReflectionAttribute $attribute) { - $instance = $attribute->newInstance(); - array_push($carry, ...$instance->middleware); - return $carry; - }, []); + /** @var list>> $middlewares */ + $middlewares = []; + foreach ($middlewareAttributes as $middlewareAttribute) { + array_push($middlewares, ...$middlewareAttribute->newInstance()->middleware); + } return new Definition( $type, diff --git a/src/Server/Pipeline/ContextualPipeline.php b/src/Server/Pipeline/ContextualPipeline.php index 9f1809d..a8d71b2 100644 --- a/src/Server/Pipeline/ContextualPipeline.php +++ b/src/Server/Pipeline/ContextualPipeline.php @@ -3,92 +3,92 @@ namespace Le0daniel\PhpTsBindings\Server\Pipeline; use Closure; +use Le0daniel\PhpTsBindings\Contracts\Client; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; +use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use Le0daniel\PhpTsBindings\Server\Data\ResolveInfo; +use Le0daniel\PhpTsBindings\Server\Data\RpcError; +use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; use Throwable; -final class ContextualPipeline +/** + * Runs the middlewares as an onion around the destination, first middleware outermost. + * + * INVARIANT: nothing escapes this pipeline as a Throwable. Every ring - and the destination - + * is wrapped, so a failure is turned into an RpcError right where it happened and handed back to + * the enclosing middleware as the return value of its $next() call. The stack is never unwound + * past a middleware, which means outer rings always get to run their post-processing on the error. + * + * The conversion goes through $onError, so failures are presented the same way whether they come + * from a middleware or from the operation itself. If $onError fails too there is nobody left to + * ask, so the pipeline falls back to a bare INTERNAL_ERROR rather than letting the request crash. + * + * @phpstan-import-type Next from MiddlewareContract + * @template-contravariant TContext = mixed + */ +final readonly class ContextualPipeline { /** - * @var (Closure(Throwable): mixed)|null - */ - private Closure|null $catchErrorsWith = null; - - /** - * @var (Closure(mixed, mixed...): mixed)|null - */ - private Closure|null $then = null; - - /** - * @param list $pipes + * @param list> $middlewares + * @param Closure(Throwable): RpcError $onError + * @param Closure(mixed): (RpcSuccess|RpcError) $destination */ public function __construct( - private readonly array $pipes, + private array $middlewares, + private Closure $onError, + private Closure $destination, ) { } /** - * @param Closure(Throwable): mixed $closure - * @return $this + * @param TContext $context */ - public function catchErrorsWith(Closure $closure): self + public function execute(mixed $input, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { - $this->catchErrorsWith = $closure; - return $this; - } + $next = function (mixed $input) use ($info): RpcSuccess|RpcError { + try { + return ($this->destination)($input); + } catch (Throwable $throwable) { + return $this->toRpcError($throwable, $info); + } + }; - /** - * @param Closure(mixed, mixed...): mixed $then - * @return $this - */ - public function then(Closure $then): self - { - $this->then = $then; - return $this; + foreach (array_reverse($this->middlewares) as $middleware) { + $next = $this->ring($middleware, $next, $context, $info, $client); + } + + return $next($input); } /** - * @param list $context - * @return Closure(mixed, object): mixed + * @param MiddlewareContract $middleware + * @param Next $next + * @param TContext $context + * @return Next */ - private function reducer(array $context): Closure + private function ring(MiddlewareContract $middleware, Closure $next, mixed $context, ResolveInfo $info, Client $client): Closure { - return function ($stack, object $instance) use ($context) { - return function (mixed $input) use ($stack, $instance, $context) { - try { - $result = $instance->handle($input, $stack, ...$context); - if ($result instanceof Throwable) { - return $this->catchErrorsWith ? ($this->catchErrorsWith)($result) : $result; - } - return $result; - } catch (Throwable $exception) { - if ($this->catchErrorsWith) { - return ($this->catchErrorsWith)($exception); - } - return $exception; - } - }; + return function (mixed $input) use ($middleware, $next, $context, $info, $client): RpcSuccess|RpcError { + try { + return $middleware->handle($input, $next, $context, $info, $client); + } catch (Throwable $throwable) { + return $this->toRpcError($throwable, $info); + } }; } - public function execute(mixed $value, mixed ... $context): mixed + private function toRpcError(Throwable $throwable, ResolveInfo $info): RpcError { - $context = array_values($context); - $middle = function ($value) use ($context) { - if ($this->then) { - try { - return ($this->then)($value, ...$context); - } catch (Throwable $exception) { - return $exception; - } - } - - return $value; - }; - - $pipeline = array_reduce( - array_reverse($this->pipes), $this->reducer($context), $middle, - ); - - return $pipeline($value); + try { + return ($this->onError)($throwable); + } catch (Throwable $failedToPresent) { + return new RpcError( + ErrorType::INTERNAL_ERROR, + $failedToPresent, + ['type' => 'INTERNAL_SERVER_ERROR'], + $info, + ); + } } -} \ No newline at end of file +} diff --git a/src/Server/Presenter/ExposedExceptionPresenter.php b/src/Server/Presenter/ExposedExceptionPresenter.php index 7791c6b..505d42f 100644 --- a/src/Server/Presenter/ExposedExceptionPresenter.php +++ b/src/Server/Presenter/ExposedExceptionPresenter.php @@ -26,7 +26,8 @@ private function extractDeclaredExceptions(Definition $definition): array $reflection = new ReflectionMethod($definition->fullyQualifiedClassName, $definition->methodName); $attributes = $reflection->getAttributes(Throws::class); - // We go through all middleware and extract their throws attributes + // We go through all middleware and extract their throws attributes. + // handle() is guaranteed to exist: every middleware implements MiddlewareContract. if (count($definition->middleware) > 0) { foreach ($definition->middleware as $middlewareClassName) { $reflection = new ReflectionMethod($middlewareClassName, 'handle'); diff --git a/src/Server/Server.php b/src/Server/Server.php index a4da5a7..12723db 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -4,6 +4,7 @@ use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; @@ -11,6 +12,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; +use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidOutputException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\OperationNotFoundException; use Le0daniel\PhpTsBindings\Server\Data\Operation; @@ -76,9 +78,6 @@ public function command(string $name, mixed $input, mixed $context, Client $clie return $this->execute($this->registry->get(OperationType::COMMAND, $name), $input, $context, $client); } - /** - * @throws ContainerExceptionInterface|NotFoundExceptionInterface - */ private function execute(Operation $operation, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { $middlewareClassNames = [ @@ -86,17 +85,6 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli ... $operation->definition->middleware, ]; - $middlewares = array_map( - fn(string $className) => $this->container - ? $this->container->get($className) - : new $className, - $middlewareClassNames - ); - - $controllerClass = $this->container - ? $this->container->get($operation->definition->fullyQualifiedClassName) - : new $operation->definition->fullyQualifiedClassName; - $resolveInfo = new ResolveInfo( $operation->definition->namespace, $operation->definition->name, @@ -106,9 +94,22 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli $middlewareClassNames, ); - return new ContextualPipeline($middlewares) - ->catchErrorsWith(fn(Throwable $throwable) => $this->produceError($throwable, $operation->definition, $resolveInfo)) - ->then(function (mixed $input) use ($controllerClass, $client, $operation, $context, $resolveInfo): RpcSuccess|RpcError { + // Resolving happens before the pipeline exists, so it needs its own guard to keep + // query()/command() total: a missing container binding or a class that is not a + // middleware must surface as an RpcError, not as an uncaught exception. + try { + $middlewares = array_map($this->resolveMiddleware(...), $middlewareClassNames); + $controllerClass = $this->container + ? $this->container->get($operation->definition->fullyQualifiedClassName) + : new $operation->definition->fullyQualifiedClassName; + } catch (Throwable $throwable) { + return $this->produceError($throwable, $operation->definition, $resolveInfo); + } + + return new ContextualPipeline( + middlewares: $middlewares, + onError: fn(Throwable $throwable): RpcError => $this->produceError($throwable, $operation->definition, $resolveInfo), + destination: function (mixed $input) use ($controllerClass, $client, $operation, $context, $resolveInfo): RpcSuccess|RpcError { try { $inputValidationResult = $this ->executor @@ -144,7 +145,26 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli } catch (Throwable $throwable) { return $this->produceError($throwable, $operation->definition, $resolveInfo); } - })->execute($input, $context, $resolveInfo, $client); + }, + )->execute($input, $context, $resolveInfo, $client); + } + + /** + * @param class-string> $className + * @return MiddlewareContract + * @throws ContainerExceptionInterface|NotFoundExceptionInterface + */ + private function resolveMiddleware(string $className): MiddlewareContract + { + $middleware = $this->container + ? $this->container->get($className) + : new $className; + + if (!$middleware instanceof MiddlewareContract) { + throw InvalidMiddlewareException::notAMiddleware($className); + } + + return $middleware; } /** diff --git a/tests/Feature/Mocks/NotAMiddleware.php b/tests/Feature/Mocks/NotAMiddleware.php new file mode 100644 index 0000000..104ca4b --- /dev/null +++ b/tests/Feature/Mocks/NotAMiddleware.php @@ -0,0 +1,11 @@ + + */ +final class NameCheckingMiddleware implements MiddlewareContract { #[Throws(InvalidNameException::class)] - public function handle(array $input, \Closure $next) + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { - if ($input['name'] === 'invalid') { + if (is_array($input) && ($input['name'] ?? null) === 'invalid') { throw new InvalidNameException(); } return $next($input); } -} \ No newline at end of file +} diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 8c21b61..5d20b61 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -2,14 +2,17 @@ use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\CachedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; use Le0daniel\PhpTsBindings\Server\Presenter\ExposedExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Server; +use Tests\Feature\Mocks\NotAMiddleware; function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { $registry = EagerlyLoadedRegistry::eagerlyDiscover(__DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator); @@ -51,6 +54,25 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { ]); }); +test("A middleware that does not implement the contract yields an RpcError", function () { + $server = new Server( + EagerlyLoadedRegistry::eagerlyDiscover( + __DIR__ . '/Operations', + keyGenerator: new PlainlyExposedKeyGenerator + ), + [ + new ExposedExceptionPresenter(), + ], + configuration: new ServerConfiguration()->withMiddlewares(NotAMiddleware::class), + ); + + $result = $server->command('test.run', ['name' => 'Leo'], null, new NullClient()); + + expect($result)->toBeInstanceOf(RpcError::class) + ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($result->cause)->toBeInstanceOf(InvalidMiddlewareException::class); +}); + test("Middleware emits typescript middleware", function () { $server = new Server( EagerlyLoadedRegistry::eagerlyDiscover( diff --git a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php index d98a96c..b96ee3c 100644 --- a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php +++ b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php @@ -1,94 +1,208 @@ -"; - } - }, - new class { - public function handle(string $input, Closure $next, string $context) - { - $result = $next($input, $context); - return "Second<{$result}>"; - } +/** + * Appends one entry to the result's `trace` metadata, so the order in which rings enter and leave + * is observable through the public RpcResult API instead of through a side channel. + */ +function trace(RpcSuccess|RpcError $result, string $entry): RpcSuccess|RpcError +{ + $existing = $result->metadata['trace'] ?? []; + assert(is_array($existing)); + + return $result->appendMetadata(['trace' => [...$existing, $entry]]); +} + +/** + * @param list> $middlewares + * @param Closure(mixed): (RpcSuccess|RpcError) $destination + * @param (Closure(Throwable): RpcError)|null $onError + */ +function runPipeline(array $middlewares, Closure $destination, ?Closure $onError = null): RpcSuccess|RpcError +{ + return new ContextualPipeline( + middlewares: $middlewares, + onError: $onError ?? fn(Throwable $throwable): RpcError => new RpcError( + ErrorType::INTERNAL_ERROR, + $throwable, + ['type' => 'PRESENTED'], + pipelineResolveInfo(), + ), + destination: $destination, + )->execute('input', 'context', pipelineResolveInfo(), new NullClient()); +} + +/** + * @param Closure(mixed, Closure(mixed): (RpcSuccess|RpcError), string, ResolveInfo, Client): (RpcSuccess|RpcError) $handle + * @return MiddlewareContract + */ +function middleware(Closure $handle): MiddlewareContract +{ + return new class($handle) implements MiddlewareContract { + /** + * @param Closure(mixed, Closure(mixed): (RpcSuccess|RpcError), string, ResolveInfo, Client): (RpcSuccess|RpcError) $handle + */ + public function __construct(private readonly Closure $handle) + { } - ])->then(function (string $input, string $context) { - return "Middle<{$context}>"; - }); - $result = $pipeline->execute('input', 'context'); - expect($result)->toBe('First>>'); + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + { + return ($this->handle)($input, $next, $context, $info, $client); + } + }; +} + +function succeed(mixed $data = 'ok'): RpcSuccess +{ + return new RpcSuccess($data, new NullClient(), pipelineResolveInfo()); +} + +test('the destination runs when there is no middleware', function () { + $result = runPipeline([], fn(mixed $input): RpcSuccess => succeed($input)); + + expect($result)->toBeInstanceOf(RpcSuccess::class) + ->and($result->data)->toBe('input'); }); -test('Error handling on throw or return of throwable', function () { - $pipeline = new ContextualPipeline([ - new class { - public function handle(string $input, Closure $next, string $context) - { - $result = $next($input, $context); - return new RuntimeException("first<{$result}>"); - } +test('middlewares wrap the destination as an onion', function () { + $result = runPipeline( + [ + middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit first')), + middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit second')), + ], + fn(mixed $input): RpcSuccess|RpcError => trace(succeed($input), 'destination'), + ); + + expect($result->metadata['trace'])->toBe(['destination', 'exit second', 'exit first']); +}); + +test('every middleware receives the context, the resolve info and the client', function () { + $seen = null; + + $result = runPipeline( + [ + middleware(function (mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client) use (&$seen): RpcSuccess|RpcError { + $seen = [$input, $context, $info->fullyQualifiedName, $client::class]; + return $next($input); + }), + ], + fn(mixed $input): RpcSuccess => succeed($input), + ); + + expect($result)->toBeInstanceOf(RpcSuccess::class) + ->and($seen)->toBe(['input', 'context', 'test.operation', NullClient::class]); +}); + +test('a middleware may short circuit without calling next', function () { + $destinationRan = false; + + $result = runPipeline( + [middleware(fn(): RpcSuccess => succeed('short circuited'))], + function () use (&$destinationRan): RpcSuccess { + $destinationRan = true; + return succeed(); }, - new class { - public function handle(string $input, Closure $next, string $context) - { - throw new Exception('second<' . $next($input) . '>'); - } - } - ])->then(function (string $input, string $context) { - return "Middle<{$context}>"; - })->catchErrorsWith(function (\Throwable $e) { - return $e->getMessage(); - }); - - $result = $pipeline->execute('input', 'context'); - expect($result)->toBe('first>>'); + ); + + expect($result->data)->toBe('short circuited') + ->and($destinationRan)->toBeFalse(); +}); + +test('a throwing middleware becomes an RpcError handed back to the enclosing middleware', function () { + $result = runPipeline( + [ + middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit outer')), + middleware(function (): RpcSuccess|RpcError { + throw new RuntimeException('inner exploded'); + }), + ], + fn(): RpcSuccess => succeed(), + ); + + // The outer ring keeps running: it saw an RpcError as the return value of $next(), not an exception. + expect($result)->toBeInstanceOf(RpcError::class) + ->and($result->metadata['trace'])->toBe(['exit outer']) + ->and($result->cause->getMessage())->toBe('inner exploded') + ->and($result->details)->toBe(['type' => 'PRESENTED']); }); -test('Test pipeline with array push', function () { - - $pipeline = new ContextualPipeline([ - new class { - public function handle(array $input, Closure $next, string $context) - { - $input[] = "Enter first"; - $result = $next($input, $context); - $result[] = "Exit first"; - return $result; - } +test('a throwing destination becomes an RpcError handed back to the innermost middleware', function () { + $result = runPipeline( + [middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit outer'))], + function (): RpcSuccess { + throw new RuntimeException('destination exploded'); }, - new class { - public function handle(array $input, Closure $next, string $context) - { - $input[] = "Enter second"; - $result = $next($input, $context); - $result[] = "Exit second"; - return $result; - } - } - ])->then(function (array $input, string $context) { - $input[] = "Middle<{$context}>"; - return $input; - }); - - $result = $pipeline->execute([], 'context'); - expect($result)->toBe([ - "Enter first", - "Enter second", - "Middle", - "Exit second", - "Exit first", - ]); -}); \ No newline at end of file + ); + + expect($result)->toBeInstanceOf(RpcError::class) + ->and($result->metadata['trace'])->toBe(['exit outer']) + ->and($result->cause->getMessage())->toBe('destination exploded'); +}); + +test('an RpcError returned by a middleware travels outward untouched', function () { + $presented = 0; + + $result = runPipeline( + [ + middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit outer')), + middleware(fn(): RpcError => new RpcError( + ErrorType::AUTHORIZATION_ERROR, + new RuntimeException('denied'), + ['type' => 'FORBIDDEN'], + pipelineResolveInfo(), + )), + ], + fn(): RpcSuccess => succeed(), + function (Throwable $throwable) use (&$presented): RpcError { + $presented++; + return new RpcError(ErrorType::INTERNAL_ERROR, $throwable, ['type' => 'PRESENTED'], pipelineResolveInfo()); + }, + ); + + expect($result)->toBeInstanceOf(RpcError::class) + ->and($result->type)->toBe(ErrorType::AUTHORIZATION_ERROR) + ->and($result->details)->toBe(['type' => 'FORBIDDEN']) + ->and($result->metadata['trace'])->toBe(['exit outer']) + ->and($presented)->toBe(0); +}); + +test('the pipeline still returns an RpcError when the error handler itself fails', function () { + $result = runPipeline( + [ + middleware(function (): RpcSuccess|RpcError { + throw new RuntimeException('inner exploded'); + }), + ], + fn(): RpcSuccess => succeed(), + function (): RpcError { + throw new RuntimeException('the presenter is broken too'); + }, + ); + + expect($result)->toBeInstanceOf(RpcError::class) + ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($result->details)->toBe(['type' => 'INTERNAL_SERVER_ERROR']) + ->and($result->cause->getMessage())->toBe('the presenter is broken too') + ->and($result->resolveInfo?->fullyQualifiedName)->toBe('test.operation'); +}); From 8d5dac764d10ddcf1d6727713ae899f02e49a10e Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 14:11:13 +0200 Subject: [PATCH 020/101] Refactor `TypedOperation` instantiation and error type generation; improve type safety with named parameters and static closures while enhancing error type serialization. --- src/CodeGen/TypescriptServerCodeGenerator.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index ad6c775..52fa3c7 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -99,10 +99,10 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore ); return new TypedOperation( - $input, - $output, - TypeScript::fromRawString($this->generateAllErrorTypes($server, $operation->definition)), - $operation, + inputDef: $input, + outputDef: $output, + errorDef: $this->generateAllErrorTypes($server, $operation->definition) |> TypeScript::fromRawString(...), + operation: $operation, ); }, $filteredDefinitions) ); @@ -123,10 +123,14 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore private function generateAllErrorTypes(Server $server, Definition $operation): string { - $possibleTypes = Lists::filterNullValues(array_map(function (ExceptionPresenter $presenter) use ($operation): null|string { + $possibleTypes = Lists::filterNullValues(array_map(static function (ExceptionPresenter $presenter) use ($operation): string { $code = $presenter::errorType(); + $codeName = json_encode($code->name, JSON_THROW_ON_ERROR); $details = $presenter->toTypeScriptDefinition($operation); - return $details === null ? null : "{code: {$code->value}, details: {$details}}"; + + return $details === null + ? "{code: {$code->value}, type: {$codeName}}" + : "{code: {$code->value}, type: {$codeName}, details: {$details}}"; }, [...$server->exceptionPresenters, $server->defaultPresenter])); return implode('|', $possibleTypes); From 9753347d6df5b233beaa132b0bebcb88966b3a03 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 14:34:59 +0200 Subject: [PATCH 021/101] Add `fullyQualifiedHandler` metadata to response in `LocalMetadataMiddleware` to include class and method info --- src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php index bb6e626..b4787ce 100644 --- a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php +++ b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php @@ -25,6 +25,7 @@ public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $durationMs = (int)ceil((microtime(true) - $startTime) * 1000); return $result->appendMetadata([ + 'fullyQualifiedHandler' => "{$info->className}@{$info->methodName}", 'durationMs' => $durationMs, 'client' => [ 'class' => $client::class, From 030df6d5dc324b7a845624c999b3966d87ee8e19 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 15:19:24 +0200 Subject: [PATCH 022/101] Add `idLength` support for operation caching; update configuration, commands, and registry handling for customizable cache key length. --- .../Laravel/Commands/OptimizeCommand.php | 19 ++++++++++++++++--- src/Adapters/Laravel/config/config.php | 4 ++++ .../Operations/CachedOperationRegistry.php | 13 +++++++++---- tests/Feature/ServerTest.php | 2 +- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/Adapters/Laravel/Commands/OptimizeCommand.php b/src/Adapters/Laravel/Commands/OptimizeCommand.php index 921de02..fbcf544 100644 --- a/src/Adapters/Laravel/Commands/OptimizeCommand.php +++ b/src/Adapters/Laravel/Commands/OptimizeCommand.php @@ -9,10 +9,11 @@ use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; use Le0daniel\PhpTsBindings\Server\Server; use RuntimeException; +use Throwable; final class OptimizeCommand extends Command { - protected $signature = 'operations:optimize'; + protected $signature = 'operations:optimize {--id-length=}'; protected $description = 'Optimize the schema operations for production use'; public function handle(#[Give(LaravelServiceProvider::DEFAULT_SERVER)] Server $server): int @@ -23,10 +24,22 @@ public function handle(#[Give(LaravelServiceProvider::DEFAULT_SERVER)] Server $s throw new RuntimeException('Cannot optimize a registry that is not a JustInTimeDiscoveryRegistry'); } + $idLength = $this->hasOption('id-length') + ? (int) $this->option('id-length') + : config('operations.cache.idLength'); + + if (!is_int($idLength) || $idLength < 1) { + throw new RuntimeException('Invalid id-length option'); + } + try { - CachedOperationRegistry::writeToCache($registry, base_path('bootstrap/cache/operations.php')); + CachedOperationRegistry::writeToCache( + $registry, + base_path('bootstrap/cache/operations.php'), + idLength: (int) $this->option('id-length'), + ); require base_path('bootstrap/cache/operations.php'); - } catch (\Throwable $e) { + } catch (Throwable $e) { unlink(base_path('bootstrap/cache/operations.php')); return 1; } diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index 4a99638..201d6f2 100644 --- a/src/Adapters/Laravel/config/config.php +++ b/src/Adapters/Laravel/config/config.php @@ -22,6 +22,10 @@ */ "context" => null, + "cache" => [ + "idLength" => 10, + ], + /** * Define the way to generate the key of the remote procedures. * This is used to limit the data that gets exposed to the client. diff --git a/src/Server/Operations/CachedOperationRegistry.php b/src/Server/Operations/CachedOperationRegistry.php index 09034cc..b3fd64d 100644 --- a/src/Server/Operations/CachedOperationRegistry.php +++ b/src/Server/Operations/CachedOperationRegistry.php @@ -50,7 +50,10 @@ public function all(): array return $this->instances; } - public static function toPhpCode(OperationRegistry $registry): string + public static function toPhpCode( + OperationRegistry $registry, + int $idLength, + ): string { $endpointClass = PHPExport::absolute(Operation::class); @@ -75,7 +78,9 @@ public static function toPhpCode(OperationRegistry $registry): string } // The ast optimizer deduplicates all the ASTs, minimizing the nodes required at runtime. - $optimizer = new AstOptimizer(); + $optimizer = new AstOptimizer( + idLength: $idLength, + ); $operationRegistryClass = PHPExport::absolute(CachedOperationRegistry::class); // Operation discovery order depends on the filesystem, so sorting by key is what makes the @@ -90,9 +95,9 @@ public static function toPhpCode(OperationRegistry $registry): string PHP; } - public static function writeToCache(OperationRegistry $registry, string $filePath): void + public static function writeToCache(OperationRegistry $registry, string $filePath, int $idLength,): void { - $code = self::toPhpCode($registry); + $code = self::toPhpCode($registry, $idLength); // The cached code binds both the Asts and operations together and creates a file // that can be required with fully compiled types. diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 5d20b61..fe5dbab 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -16,7 +16,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { $registry = EagerlyLoadedRegistry::eagerlyDiscover(__DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator); - $cachedRegistry = eval(CachedOperationRegistry::toPhpCode($registry)); + $cachedRegistry = eval(CachedOperationRegistry::toPhpCode($registry, idLength: 10)); $server = new Server($registry, [new ExposedExceptionPresenter(),],); $cachedServer = new Server($cachedRegistry, [new ExposedExceptionPresenter(),],); From 83df98f8acd0673d8093c35c03e5b43e30721782 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 20:24:04 +0200 Subject: [PATCH 023/101] Renamed Registry --- README.md | 10 ++----- .../EmitOperationClientBindings.php | 4 +-- src/CodeGen/CodeGenerators/EmitTypeMap.php | 4 +-- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 4 +-- src/CodeGen/CodeGenerators/EmitTypes.php | 4 +-- src/CodeGen/Contracts/GeneratesLibFiles.php | 6 ++-- src/CodeGen/TypescriptServerCodeGenerator.php | 8 +++--- src/Typescript/Data/EmissionContext.php | 6 ++-- src/Typescript/Data/TypeScript.php | 8 ++++-- .../AliasRegistry.php} | 4 +-- src/Typescript/TypescriptGenerator.php | 6 ++-- tests/Pest.php | 4 +-- tests/Unit/CodeGen/EmitQueryKeyTest.php | 8 +++--- tests/Unit/CodeGen/EmitTypeUtilsTest.php | 4 +-- tests/Unit/CodeGen/EmitTypesTest.php | 6 ++-- tests/Unit/Typescript/NamedTypesTest.php | 6 ++-- tests/Unit/Typescript/TypeRegistryTest.php | 28 +++++++++---------- .../Typescript/TypescriptGeneratorTest.php | 10 +++---- 18 files changed, 64 insertions(+), 66 deletions(-) rename src/Typescript/{Data/TypeRegistry.php => Helpers/AliasRegistry.php} (97%) diff --git a/README.md b/README.md index 19fc802..90c8dc1 100644 --- a/README.md +++ b/README.md @@ -89,13 +89,7 @@ customizations, including writing your very own code generation plugin. ## Type Parsing ```php -use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; -use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Reflection\TypeReflector; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; -use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; +use Le0daniel\PhpTsBindings\Executor\SchemaExecutor;use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext;use Le0daniel\PhpTsBindings\Parser\TypeParser;use Le0daniel\PhpTsBindings\Reflection\TypeReflector;use Le0daniel\PhpTsBindings\Typescript\Data\IO;use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry;use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; $typeString = TypeReflector::reflectParameter( new ReflectionParameter() @@ -131,7 +125,7 @@ $named->registry->usedAliases(); // => ['Token'] — every alias in the re // schema produced. Pass an optional shared registry and every call registers its aliases into it // at the end of the pass; that hand-over is where an alias meaning two different things across // several schemas is rejected. -$generator->toTypescript($ast, IO::INPUT, $shared = new TypeRegistry()); +$generator->toTypescript($ast, IO::INPUT, $shared = new AliasRegistry()); $executor = new SchemaExecutor() diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 41d23c4..064d97d 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -6,7 +6,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; final class EmitOperationClientBindings implements GeneratesLibFiles, DependsOn { @@ -21,7 +21,7 @@ public function dependsOnGenerator(): array /** * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array + public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { return [ "OperationClient" => new TypescriptFile(<<> $map diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index 68f5973..a20e259 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -9,7 +9,7 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; final class EmitTypeUtils implements GeneratesLibFiles, DependsOn { @@ -23,7 +23,7 @@ public function dependsOnGenerator(): array /** * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array + public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { $queryNamespaces = array_reduce($operations, function (array $carry, TypedOperation $operation) { if ($operation->operation->definition->type !== OperationType::QUERY) { diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 59481d3..01494b7 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -7,8 +7,8 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Utils\Arrays; final class EmitTypes implements GeneratesLibFiles @@ -35,7 +35,7 @@ final class EmitTypes implements GeneratesLibFiles /** * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array + public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { foreach ($registry->usedAliases() as $alias) { if (in_array($alias, self::RESERVED_ALIASES, true)) { diff --git a/src/CodeGen/Contracts/GeneratesLibFiles.php b/src/CodeGen/Contracts/GeneratesLibFiles.php index 824024b..b72a91a 100644 --- a/src/CodeGen/Contracts/GeneratesLibFiles.php +++ b/src/CodeGen/Contracts/GeneratesLibFiles.php @@ -5,7 +5,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; interface GeneratesLibFiles { @@ -18,8 +18,8 @@ interface GeneratesLibFiles * ] * * @param list $operations - * @param TypeRegistry $registry The run's shared registry: every alias any operation produced. + * @param AliasRegistry $registry The run's shared registry: every alias any operation produced. * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata, TypeRegistry $registry): array; + public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array; } \ No newline at end of file diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index ad6c775..f2af216 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -15,8 +15,8 @@ use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Le0daniel\PhpTsBindings\Utils\Lists; use RuntimeException; @@ -84,7 +84,7 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore // Cross-operation and cross-direction alias conflicts are only caught when every pass hands // its aliases into one shared registry, so the run always has one. It is also what the // generated types file declares. - $registry = new TypeRegistry(); + $registry = new AliasRegistry(); $definitions = array_values( array_map(function (Operation $operation) use ($server, $registry): TypedOperation { @@ -135,10 +135,10 @@ private function generateAllErrorTypes(Server $server, Definition $operation): s /** * @param list $definitions * @param ServerMetadata $metadata - * @param TypeRegistry $registry The run's shared registry, holding every alias any pass produced. + * @param AliasRegistry $registry The run's shared registry, holding every alias any pass produced. * @return array */ - private function generateLibFiles(array $definitions, ServerMetadata $metadata, TypeRegistry $registry): array + private function generateLibFiles(array $definitions, ServerMetadata $metadata, AliasRegistry $registry): array { return array_reduce( $this->generators, diff --git a/src/Typescript/Data/EmissionContext.php b/src/Typescript/Data/EmissionContext.php index 75d838d..c40e4e9 100644 --- a/src/Typescript/Data/EmissionContext.php +++ b/src/Typescript/Data/EmissionContext.php @@ -2,6 +2,8 @@ namespace Le0daniel\PhpTsBindings\Typescript\Data; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; + /** * Everything a single walk needs, threaded through the recursion. * @@ -10,8 +12,8 @@ final readonly class EmissionContext { public function __construct( - public IO $io, - public TypeRegistry $registry, + public IO $io, + public AliasRegistry $registry, ) { } diff --git a/src/Typescript/Data/TypeScript.php b/src/Typescript/Data/TypeScript.php index 481fb2b..76a2c37 100644 --- a/src/Typescript/Data/TypeScript.php +++ b/src/Typescript/Data/TypeScript.php @@ -2,6 +2,8 @@ namespace Le0daniel\PhpTsBindings\Typescript\Data; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; + /** * A generated TypeScript type together with the aliases it references. */ @@ -10,19 +12,19 @@ /** * @param string $type The type. Named types are referenced by their alias name, brands appear * inline as `(... & Brand<"...">)`. - * @param TypeRegistry $registry Every alias this emission produced, e.g. + * @param AliasRegistry $registry Every alias this emission produced, e.g. * ['Order' => '{id:(number & Brand<"orderId">);}']. A consumer emits each entry as * `export type {$alias} = {$definition}`. */ public function __construct( public string $type, - public TypeRegistry $registry + public AliasRegistry $registry ) { } public static function fromRawString(string $type): TypeScript { - return new TypeScript($type, new TypeRegistry()); + return new TypeScript($type, new AliasRegistry()); } } diff --git a/src/Typescript/Data/TypeRegistry.php b/src/Typescript/Helpers/AliasRegistry.php similarity index 97% rename from src/Typescript/Data/TypeRegistry.php rename to src/Typescript/Helpers/AliasRegistry.php index 4b4a7e6..c9f76fa 100644 --- a/src/Typescript/Data/TypeRegistry.php +++ b/src/Typescript/Helpers/AliasRegistry.php @@ -1,6 +1,6 @@ */ private array $definitions = []; diff --git a/src/Typescript/TypescriptGenerator.php b/src/Typescript/TypescriptGenerator.php index 68cdca1..768b768 100644 --- a/src/Typescript/TypescriptGenerator.php +++ b/src/Typescript/TypescriptGenerator.php @@ -29,9 +29,9 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Typescript\Data\EmissionContext; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; use UnitEnum; @@ -45,7 +45,7 @@ */ final readonly class TypescriptGenerator { - public function toTypescript(NodeInterface $node, IO $io, ?TypeRegistry $sharedRegistry = null): TypeScript + public function toTypescript(NodeInterface $node, IO $io, ?AliasRegistry $sharedRegistry = null): TypeScript { if ($io === IO::BOTH) { throw new InvalidArgumentException('Emit for IO::INPUT or IO::OUTPUT; IO::BOTH is only a #[Named] scope.'); @@ -55,7 +55,7 @@ public function toTypescript(NodeInterface $node, IO $io, ?TypeRegistry $sharedR // aliases this schema produced. When a shared registry is given, all of them are // registered into it after the pass — that hand-over is where an alias meaning two // different things across several schemas is rejected. - $localRegistry = new TypeRegistry(); + $localRegistry = new AliasRegistry(); $context = new EmissionContext($io, $localRegistry); $type = $this->emit($node, $context); diff --git a/tests/Pest.php b/tests/Pest.php index 29cc2b5..393742e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -22,8 +22,8 @@ use Le0daniel\PhpTsBindings\Parser\AstValidator; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; pest()->extend(Tests\TestCase::class)->in('Feature'); @@ -125,7 +125,7 @@ function compareToOptimizedAst(NodeInterface $node) { * MetadataNode is transparent in the string form, so the structural parity assertion holds for * every schema, metadata or not. */ -function typescriptFor(NodeInterface $node, IO $io, ?TypeRegistry $sharedRegistry = null): TypeScript +function typescriptFor(NodeInterface $node, IO $io, ?AliasRegistry $sharedRegistry = null): TypeScript { compareToOptimizedAst($node); diff --git a/tests/Unit/CodeGen/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php index 16e33b7..5fa47a0 100644 --- a/tests/Unit/CodeGen/EmitQueryKeyTest.php +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -9,8 +9,8 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Tests\Mocks\ValueObjects\Email; /** @@ -42,8 +42,8 @@ function queryOperation(): Operation test('imports the aliases the inlined input definition carries', function () { [$code, $rendered] = queryKeyCodeFor(new TypedOperation( - new TypeScript('{status:OrderStatus;}', new TypeRegistry(['OrderStatus' => '"OPEN"|"SHIPPED"'])), - new TypeScript('Order', new TypeRegistry(['Order' => '{id:number;}'])), + new TypeScript('{status:OrderStatus;}', new AliasRegistry(['OrderStatus' => '"OPEN"|"SHIPPED"'])), + new TypeScript('Order', new AliasRegistry(['Order' => '{id:number;}'])), TypeScript::fromRawString(''), queryOperation(), )); @@ -56,7 +56,7 @@ function queryOperation(): Operation test('always imports the Brand helper, whether the input renders an inline brand or not', function () { [, $withBrand] = queryKeyCodeFor(new TypedOperation( - new TypeScript('{id:number & Brand<"customerId">;}', new TypeRegistry()), + new TypeScript('{id:number & Brand<"customerId">;}', new AliasRegistry()), TypeScript::fromRawString('string'), TypeScript::fromRawString(''), queryOperation(), diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index 43c3718..bd1f6db 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -11,8 +11,8 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Mocks\ValueObjects\Email; @@ -27,7 +27,7 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp ); $generator = new TypescriptGenerator(); - $registry = new TypeRegistry(); + $registry = new AliasRegistry(); $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, $registry); $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 9171de9..d4a0da2 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -11,9 +11,9 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Mocks\Named\Order; use Tests\Mocks\Named\OrderStatus; @@ -36,7 +36,7 @@ function emitTypesFor(string $inputType, string $outputType): string ); $generator = new TypescriptGenerator(); - $registry = new TypeRegistry(); + $registry = new AliasRegistry(); $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, $registry); $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); @@ -50,7 +50,7 @@ function emitTypesFor(string $inputType, string $outputType): string } test('rejects an alias colliding with a declaration the types file always contains', function (string $alias) { - $registry = new TypeRegistry([$alias => '{a:string;}']); + $registry = new AliasRegistry([$alias => '{a:string;}']); expect(fn() => new EmitTypes()->emitFiles([], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), $registry)) ->toThrow(UnsupportedTypeException::class, 'collides with a declaration'); diff --git a/tests/Unit/Typescript/NamedTypesTest.php b/tests/Unit/Typescript/NamedTypesTest.php index 23a0725..e05588b 100644 --- a/tests/Unit/Typescript/NamedTypesTest.php +++ b/tests/Unit/Typescript/NamedTypesTest.php @@ -10,8 +10,8 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Mocks\Named\AsymmetricNamed; use Tests\Mocks\Named\BrandedPayload; @@ -35,7 +35,7 @@ test('a named class defaults to output only and is inlined on input', function () { $node = new TypeParser()->parse(Customer::class); - $shared = new TypeRegistry(); + $shared = new AliasRegistry(); $result = new TypescriptGenerator()->toTypescript($node, IO::INPUT, $shared); @@ -125,7 +125,7 @@ test('IO::BOTH fails hard when the input and output shapes differ', function () { $node = new TypeParser()->parse(AsymmetricNamed::class); $generator = new TypescriptGenerator(); - $shared = new TypeRegistry(); + $shared = new AliasRegistry(); expect($generator->toTypescript($node, IO::INPUT, $shared)->type)->toBe('AsymmetricNamed'); diff --git a/tests/Unit/Typescript/TypeRegistryTest.php b/tests/Unit/Typescript/TypeRegistryTest.php index 38d731d..4506931 100644 --- a/tests/Unit/Typescript/TypeRegistryTest.php +++ b/tests/Unit/Typescript/TypeRegistryTest.php @@ -1,11 +1,11 @@ isEmpty())->toBeTrue() ->and($registry->toArray())->toBe([]) @@ -13,7 +13,7 @@ }); test('is seeded from the constructor', function () { - $registry = new TypeRegistry(['Email' => 'string & Brand<"email">']); + $registry = new AliasRegistry(['Email' => 'string & Brand<"email">']); expect($registry->isEmpty())->toBeFalse() ->and($registry->has('Email'))->toBeTrue() @@ -21,7 +21,7 @@ }); test('stores and reads back a definition', function () { - $registry = new TypeRegistry(); + $registry = new AliasRegistry(); $registry->set('Email', 'string & Brand<"email">'); expect($registry->has('Email'))->toBeTrue() @@ -30,7 +30,7 @@ }); test('accepts the identical definition twice', function () { - $registry = new TypeRegistry(); + $registry = new AliasRegistry(); $registry->set('Email', 'string & Brand<"email">'); $registry->set('Email', 'string & Brand<"email">'); @@ -38,7 +38,7 @@ }); test('throws when an alias is rebound to a different definition', function () { - $registry = new TypeRegistry(); + $registry = new AliasRegistry(); $registry->set('Email', 'string & Brand<"email">'); expect(fn() => $registry->set('Email', 'number & Brand<"email">')) @@ -46,10 +46,10 @@ }); test('a seed array cannot conflict with itself, only a later set() can', function () { - $registry = new TypeRegistry(['Email' => 'string & Brand<"email">']); + $registry = new AliasRegistry(['Email' => 'string & Brand<"email">']); // Duplicate keys collapse inside an array literal, so the last one simply wins. - expect(fn() => new TypeRegistry([...$registry->toArray(), 'Email' => 'number'])) + expect(fn() => new AliasRegistry([...$registry->toArray(), 'Email' => 'number'])) ->not->toThrow(UnsupportedTypeException::class); expect(fn() => $registry->set('Email', 'number')) @@ -57,28 +57,28 @@ }); test('throws when reading an alias that was never defined', function () { - $registry = new TypeRegistry(['Email' => 'string & Brand<"email">']); + $registry = new AliasRegistry(['Email' => 'string & Brand<"email">']); expect(fn() => $registry->get('Missing')) ->toThrow(UnknownAliasException::class, "Unknown type alias 'Missing'. Call has() before get(). Known aliases: Email."); }); test('names no aliases when reading from an empty registry', function () { - expect(fn() => new TypeRegistry()->get('Missing')) + expect(fn() => new AliasRegistry()->get('Missing')) ->toThrow(UnknownAliasException::class, 'Known aliases: none.'); }); test('every stored alias counts as used, sorted', function () { - $registry = new TypeRegistry(); + $registry = new AliasRegistry(); $registry->set('Zulu', 'string'); $registry->set('Alpha', 'number'); expect($registry->usedAliases())->toBe(['Alpha', 'Zulu']) - ->and(new TypeRegistry()->usedAliases())->toBe([]); + ->and(new AliasRegistry()->usedAliases())->toBe([]); }); test('returns definitions sorted by alias', function () { - $registry = new TypeRegistry(); + $registry = new AliasRegistry(); $registry->set('Zulu', 'string'); $registry->set('Alpha', 'number'); $registry->set('Mike', 'boolean'); @@ -91,7 +91,7 @@ }); test('a clone does not share state with its original', function () { - $original = new TypeRegistry(['Email' => 'string & Brand<"email">']); + $original = new AliasRegistry(['Email' => 'string & Brand<"email">']); $copy = clone $original; $copy->set('Token', 'string & Brand<"token">'); diff --git a/tests/Unit/Typescript/TypescriptGeneratorTest.php b/tests/Unit/Typescript/TypescriptGeneratorTest.php index 10640a2..93a6ef3 100644 --- a/tests/Unit/Typescript/TypescriptGeneratorTest.php +++ b/tests/Unit/Typescript/TypescriptGeneratorTest.php @@ -10,9 +10,9 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeRegistry; use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Mocks\ResultEnum; use Tests\Mocks\ValueObjects\CreateAccountInput; @@ -29,7 +29,7 @@ function typescriptOf( string|NodeInterface $type, IO $io = IO::INPUT, - ?TypeRegistry $sharedRegistry = null, + ?AliasRegistry $sharedRegistry = null, ): TypeScript { $node = is_string($type) ? new TypeParser()->parse($type) : $type; @@ -208,7 +208,7 @@ function typescriptOfBoth(string|NodeInterface $type): string }); test('registers into the passed registry but returns only what the emission needs', function () { - $shared = new TypeRegistry(['Existing' => '(string & Brand<"existing">)']); + $shared = new AliasRegistry(['Existing' => '(string & Brand<"existing">)']); $result = typescriptOf("BrandedString<'email'>", IO::INPUT, $shared); @@ -221,7 +221,7 @@ function typescriptOfBoth(string|NodeInterface $type): string }); test('one shared registry accumulates aliases across emissions', function () { - $shared = new TypeRegistry(); + $shared = new AliasRegistry(); $first = typescriptOf("BrandedString<'email'>", IO::INPUT, $shared); $second = typescriptOf("BrandedInt<'customerId'>", IO::INPUT, $shared); @@ -235,7 +235,7 @@ function typescriptOfBoth(string|NodeInterface $type): string }); test('throws when the incoming registry already binds an alias to something else', function () { - $shared = new TypeRegistry(['Email' => '(number & Brand<"email">)']); + $shared = new AliasRegistry(['Email' => '(number & Brand<"email">)']); expect(fn() => typescriptOf("BrandedString<'email'>", IO::INPUT, $shared)) ->toThrow(UnsupportedTypeException::class, 'Email'); From d6065983230131eeba824bd67a4536f80bf60b4f Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 29 Jul 2026 20:31:27 +0200 Subject: [PATCH 024/101] Moved interfaces --- src/Executor/Contracts/Executor.php | 2 +- src/Executor/Contracts/Handler.php | 2 +- src/Executor/Handlers/CustomClassHandler.php | 3 +-- src/Executor/Handlers/IntersectionHandler.php | 2 +- src/Executor/Handlers/ListHandler.php | 2 +- src/Executor/Handlers/RecordHandler.php | 2 +- src/Executor/Handlers/StructHandler.php | 2 +- src/Executor/Handlers/TupleHandler.php | 2 +- src/Executor/Handlers/UnionHandler.php | 2 +- src/Executor/SchemaExecutor.php | 6 +++--- src/Parser/ASTOptimizer.php | 6 +++--- src/Parser/AstValidator.php | 6 +++--- src/Parser/Consumers/AliasConsumer.php | 4 ++-- src/Parser/Consumers/BuiltInLeafConsumer.php | 4 ++-- src/Parser/Consumers/DateTimeConsumer.php | 2 +- src/Parser/Consumers/EnumConsumer.php | 2 +- src/Parser/Consumers/IntConsumer.php | 4 ++-- src/Parser/Consumers/InteractsWithGenerics.php | 4 ++-- src/Parser/Consumers/LiteralConsumer.php | 2 +- src/Parser/Consumers/StructConsumer.php | 4 ++-- src/Parser/Consumers/UserDefinedObjectConsumer.php | 7 +++---- src/Parser/Consumers/UtilsConsumer.php | 2 +- src/Parser/Consumers/ValueObjectConsumer.php | 2 +- src/{ => Parser}/Contracts/Coercible.php | 2 +- src/{ => Parser}/Contracts/Constraint.php | 3 ++- src/{ => Parser}/Contracts/LeafNode.php | 2 +- src/{ => Parser}/Contracts/NodeInterface.php | 3 ++- src/Parser/Contracts/TypeConsumer.php | 1 - src/Parser/Contracts/TypeRegistry.php | 2 -- src/{ => Parser}/Contracts/ValidatableNode.php | 2 +- src/Parser/Data/GlobalTypeAliases.php | 2 +- src/Parser/Data/ParsingContext.php | 2 +- src/Parser/Nodes/ConstraintNode.php | 4 ++-- src/Parser/Nodes/CustomCastingNode.php | 6 +----- src/Parser/Nodes/IntersectionNode.php | 4 ++-- src/Parser/Nodes/Leaf/BoolNode.php | 6 +++--- src/Parser/Nodes/Leaf/DateTimeNode.php | 4 ++-- src/Parser/Nodes/Leaf/EnumNode.php | 5 ++--- src/Parser/Nodes/Leaf/FloatNode.php | 6 +++--- src/Parser/Nodes/Leaf/IntNode.php | 6 +++--- src/Parser/Nodes/Leaf/LiteralNode.php | 7 +++---- src/Parser/Nodes/Leaf/MixedNode.php | 4 ++-- src/Parser/Nodes/Leaf/NullNode.php | 4 ++-- src/Parser/Nodes/Leaf/StringNode.php | 6 +++--- src/Parser/Nodes/Leaf/ValueObjectNode.php | 6 +++--- src/Parser/Nodes/ListNode.php | 2 +- src/Parser/Nodes/MetadataNode.php | 4 ++-- src/Parser/Nodes/PropertyNode.php | 2 +- src/Parser/Nodes/RecordNode.php | 2 +- src/Parser/Nodes/ReferencedNode.php | 2 +- src/Parser/Nodes/StructNode.php | 5 ++--- src/Parser/Nodes/TupleNode.php | 4 ++-- src/Parser/Nodes/UnionNode.php | 4 ++-- src/Parser/Registry/CachedTypeRegistry.php | 2 +- src/Parser/TypeParser.php | 4 ++-- src/Reflection/MetadataAttributes.php | 2 +- src/Server/Data/Operation.php | 2 +- src/Typescript/Exceptions/UnsupportedTypeException.php | 2 +- src/Typescript/TypescriptGenerator.php | 2 +- src/Utils/Nodes.php | 2 +- src/Validators/Email.php | 2 +- src/Validators/LengthValidator.php | 2 +- src/Validators/NonEmptyString.php | 2 +- src/Validators/NonFalsyStringValidator.php | 2 +- tests/Pest.php | 2 +- tests/Unit/Parser/ASTOptimizerTest.php | 2 +- tests/Unit/Parser/MetadataEliminationTest.php | 2 +- tests/Unit/Typescript/TypescriptGeneratorTest.php | 2 +- tests/Unit/Validators/EmailTest.php | 2 +- 69 files changed, 105 insertions(+), 115 deletions(-) rename src/{ => Parser}/Contracts/Coercible.php (66%) rename src/{ => Parser}/Contracts/Constraint.php (67%) rename src/{ => Parser}/Contracts/LeafNode.php (94%) rename src/{ => Parser}/Contracts/NodeInterface.php (88%) rename src/{ => Parser}/Contracts/ValidatableNode.php (65%) diff --git a/src/Executor/Contracts/Executor.php b/src/Executor/Contracts/Executor.php index 095d6a5..d2b873c 100644 --- a/src/Executor/Contracts/Executor.php +++ b/src/Executor/Contracts/Executor.php @@ -2,8 +2,8 @@ namespace Le0daniel\PhpTsBindings\Executor\Contracts; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Executor\Data\Context; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; interface Executor { diff --git a/src/Executor/Contracts/Handler.php b/src/Executor/Contracts/Handler.php index 92606f0..257ebfe 100644 --- a/src/Executor/Contracts/Handler.php +++ b/src/Executor/Contracts/Handler.php @@ -2,8 +2,8 @@ namespace Le0daniel\PhpTsBindings\Executor\Contracts; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Executor\Data\Context; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; /** * @template-covariant T of NodeInterface diff --git a/src/Executor/Handlers/CustomClassHandler.php b/src/Executor/Handlers/CustomClassHandler.php index 5a84923..ae9f751 100644 --- a/src/Executor/Handlers/CustomClassHandler.php +++ b/src/Executor/Handlers/CustomClassHandler.php @@ -2,13 +2,12 @@ namespace Le0daniel\PhpTsBindings\Executor\Handlers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Data\Value; use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Executor\Data\Issue; -use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; use stdClass; diff --git a/src/Executor/Handlers/IntersectionHandler.php b/src/Executor/Handlers/IntersectionHandler.php index 458285f..e73db10 100644 --- a/src/Executor/Handlers/IntersectionHandler.php +++ b/src/Executor/Handlers/IntersectionHandler.php @@ -2,13 +2,13 @@ namespace Le0daniel\PhpTsBindings\Executor\Handlers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Data\Value; use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; use RuntimeException; use stdClass; diff --git a/src/Executor/Handlers/ListHandler.php b/src/Executor/Handlers/ListHandler.php index 9bfffea..3244659 100644 --- a/src/Executor/Handlers/ListHandler.php +++ b/src/Executor/Handlers/ListHandler.php @@ -2,11 +2,11 @@ namespace Le0daniel\PhpTsBindings\Executor\Handlers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Data\Value; use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; use Le0daniel\PhpTsBindings\Executor\Data\Context; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; /** diff --git a/src/Executor/Handlers/RecordHandler.php b/src/Executor/Handlers/RecordHandler.php index c9fca6d..4086e2a 100644 --- a/src/Executor/Handlers/RecordHandler.php +++ b/src/Executor/Handlers/RecordHandler.php @@ -2,13 +2,13 @@ namespace Le0daniel\PhpTsBindings\Executor\Handlers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Data\Value; use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use stdClass; diff --git a/src/Executor/Handlers/StructHandler.php b/src/Executor/Handlers/StructHandler.php index 9f9be35..c8a36b4 100644 --- a/src/Executor/Handlers/StructHandler.php +++ b/src/Executor/Handlers/StructHandler.php @@ -3,13 +3,13 @@ namespace Le0daniel\PhpTsBindings\Executor\Handlers; use ArrayAccess; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Data\Value; use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use stdClass; diff --git a/src/Executor/Handlers/TupleHandler.php b/src/Executor/Handlers/TupleHandler.php index 2f75954..891d2f3 100644 --- a/src/Executor/Handlers/TupleHandler.php +++ b/src/Executor/Handlers/TupleHandler.php @@ -3,11 +3,11 @@ namespace Le0daniel\PhpTsBindings\Executor\Handlers; use ArrayAccess; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Data\Value; use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; use Le0daniel\PhpTsBindings\Executor\Data\Context; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; /** diff --git a/src/Executor/Handlers/UnionHandler.php b/src/Executor/Handlers/UnionHandler.php index 911b758..34388bc 100644 --- a/src/Executor/Handlers/UnionHandler.php +++ b/src/Executor/Handlers/UnionHandler.php @@ -3,13 +3,13 @@ namespace Le0daniel\PhpTsBindings\Executor\Handlers; use ArrayAccess; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Data\Value; use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; /** diff --git a/src/Executor/SchemaExecutor.php b/src/Executor/SchemaExecutor.php index dc0d50f..9f5ef23 100644 --- a/src/Executor/SchemaExecutor.php +++ b/src/Executor/SchemaExecutor.php @@ -2,9 +2,6 @@ namespace Le0daniel\PhpTsBindings\Executor; -use Le0daniel\PhpTsBindings\Contracts\Coercible; -use Le0daniel\PhpTsBindings\Contracts\LeafNode; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Data\Value; use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; @@ -21,6 +18,9 @@ use Le0daniel\PhpTsBindings\Executor\Handlers\StructHandler; use Le0daniel\PhpTsBindings\Executor\Handlers\TupleHandler; use Le0daniel\PhpTsBindings\Executor\Handlers\UnionHandler; +use Le0daniel\PhpTsBindings\Parser\Contracts\Coercible; +use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; diff --git a/src/Parser/ASTOptimizer.php b/src/Parser/ASTOptimizer.php index 11c9f6e..04cde4b 100644 --- a/src/Parser/ASTOptimizer.php +++ b/src/Parser/ASTOptimizer.php @@ -3,9 +3,9 @@ namespace Le0daniel\PhpTsBindings\Parser; use Closure; -use Le0daniel\PhpTsBindings\Contracts\Constraint; -use Le0daniel\PhpTsBindings\Contracts\LeafNode; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\Constraint; +use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Exceptions\UnknownTypeKeyException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; diff --git a/src/Parser/AstValidator.php b/src/Parser/AstValidator.php index 7f2384a..ca804ba 100644 --- a/src/Parser/AstValidator.php +++ b/src/Parser/AstValidator.php @@ -2,9 +2,9 @@ namespace Le0daniel\PhpTsBindings\Parser; -use Le0daniel\PhpTsBindings\Contracts\LeafNode; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; -use Le0daniel\PhpTsBindings\Contracts\ValidatableNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; diff --git a/src/Parser/Consumers/AliasConsumer.php b/src/Parser/Consumers/AliasConsumer.php index f800a6f..5382d54 100644 --- a/src/Parser/Consumers/AliasConsumer.php +++ b/src/Parser/Consumers/AliasConsumer.php @@ -2,13 +2,13 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\TypeParser; use ReflectionException; diff --git a/src/Parser/Consumers/BuiltInLeafConsumer.php b/src/Parser/Consumers/BuiltInLeafConsumer.php index 4b2d493..79b794f 100644 --- a/src/Parser/Consumers/BuiltInLeafConsumer.php +++ b/src/Parser/Consumers/BuiltInLeafConsumer.php @@ -2,11 +2,11 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BoolNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\FloatNode; diff --git a/src/Parser/Consumers/DateTimeConsumer.php b/src/Parser/Consumers/DateTimeConsumer.php index 530deca..f6041cb 100644 --- a/src/Parser/Consumers/DateTimeConsumer.php +++ b/src/Parser/Consumers/DateTimeConsumer.php @@ -3,7 +3,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; use DateTimeInterface; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; diff --git a/src/Parser/Consumers/EnumConsumer.php b/src/Parser/Consumers/EnumConsumer.php index 67a3a18..a7448d3 100644 --- a/src/Parser/Consumers/EnumConsumer.php +++ b/src/Parser/Consumers/EnumConsumer.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; diff --git a/src/Parser/Consumers/IntConsumer.php b/src/Parser/Consumers/IntConsumer.php index 45733b9..cf5056b 100644 --- a/src/Parser/Consumers/IntConsumer.php +++ b/src/Parser/Consumers/IntConsumer.php @@ -2,12 +2,12 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\Lexemes; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; diff --git a/src/Parser/Consumers/InteractsWithGenerics.php b/src/Parser/Consumers/InteractsWithGenerics.php index ea199d2..34fff80 100644 --- a/src/Parser/Consumers/InteractsWithGenerics.php +++ b/src/Parser/Consumers/InteractsWithGenerics.php @@ -2,10 +2,10 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\TypeParser; trait InteractsWithGenerics diff --git a/src/Parser/Consumers/LiteralConsumer.php b/src/Parser/Consumers/LiteralConsumer.php index 90af3fd..ab6f225 100644 --- a/src/Parser/Consumers/LiteralConsumer.php +++ b/src/Parser/Consumers/LiteralConsumer.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\Lexemes; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; diff --git a/src/Parser/Consumers/StructConsumer.php b/src/Parser/Consumers/StructConsumer.php index 05ebfc4..c2581ef 100644 --- a/src/Parser/Consumers/StructConsumer.php +++ b/src/Parser/Consumers/StructConsumer.php @@ -2,12 +2,12 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\Lexemes; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; diff --git a/src/Parser/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Consumers/UserDefinedObjectConsumer.php index a788267..325cb06 100644 --- a/src/Parser/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Consumers/UserDefinedObjectConsumer.php @@ -4,13 +4,13 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Castable; use Le0daniel\PhpTsBindings\Contracts\Attributes\Optional; -use Le0daniel\PhpTsBindings\Contracts\Constraint; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\Constraint; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; @@ -22,7 +22,6 @@ use Le0daniel\PhpTsBindings\Reflection\AttributesReflector; use Le0daniel\PhpTsBindings\Reflection\MetadataAttributes; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; -use Le0daniel\PhpTsBindings\Utils\Arrays; use Le0daniel\PhpTsBindings\Utils\Lists; use ReflectionAttribute; use ReflectionClass; diff --git a/src/Parser/Consumers/UtilsConsumer.php b/src/Parser/Consumers/UtilsConsumer.php index 268c42a..7b155ae 100644 --- a/src/Parser/Consumers/UtilsConsumer.php +++ b/src/Parser/Consumers/UtilsConsumer.php @@ -3,7 +3,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; use DateTimeImmutable; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; diff --git a/src/Parser/Consumers/ValueObjectConsumer.php b/src/Parser/Consumers/ValueObjectConsumer.php index 7782475..76b61bc 100644 --- a/src/Parser/Consumers/ValueObjectConsumer.php +++ b/src/Parser/Consumers/ValueObjectConsumer.php @@ -2,9 +2,9 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Contracts\ValueObjects\IntValueObject; use Le0daniel\PhpTsBindings\Contracts\ValueObjects\StringValueObject; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; diff --git a/src/Contracts/Coercible.php b/src/Parser/Contracts/Coercible.php similarity index 66% rename from src/Contracts/Coercible.php rename to src/Parser/Contracts/Coercible.php index f3d706b..34d292e 100644 --- a/src/Contracts/Coercible.php +++ b/src/Parser/Contracts/Coercible.php @@ -1,6 +1,6 @@ Date: Wed, 29 Jul 2026 20:43:16 +0200 Subject: [PATCH 025/101] Moved interfaces --- .../Laravel/Commands/CodeGenCommand.php | 9 ++- .../Laravel/Commands/OptimizeCommand.php | 12 +++- .../Laravel/LaravelServiceProvider.php | 67 +++++++++++-------- src/Contracts/Attributes/Command.php | 1 + src/Parser/Data/GlobalTypeAliases.php | 5 ++ 5 files changed, 60 insertions(+), 34 deletions(-) diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index 5abbd1f..bb1ccbc 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -5,7 +5,6 @@ use Closure; use Generator; use Illuminate\Console\Command; -use Illuminate\Container\Attributes\Give; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Foundation\Application; use Illuminate\Routing\Router; @@ -25,7 +24,6 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; -use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use RecursiveDirectoryIterator; @@ -75,11 +73,16 @@ final class CodeGenCommand extends Command * @throws BindingResolutionException */ public function handle( - #[Give(LaravelServiceProvider::DEFAULT_SERVER)] Server $server, Router $router, Application $application, ): int { + // Always get a fresh server + $server = LaravelServiceProvider::serverFactory( + $application, + operations: null, + ); + try { $metadata = new ServerMetadata( $router->getRoutes()->getByName(LaravelHttpController::QUERY_NAME)->uri(), diff --git a/src/Adapters/Laravel/Commands/OptimizeCommand.php b/src/Adapters/Laravel/Commands/OptimizeCommand.php index 921de02..a6a373b 100644 --- a/src/Adapters/Laravel/Commands/OptimizeCommand.php +++ b/src/Adapters/Laravel/Commands/OptimizeCommand.php @@ -3,11 +3,10 @@ namespace Le0daniel\PhpTsBindings\Adapters\Laravel\Commands; use Illuminate\Console\Command; -use Illuminate\Container\Attributes\Give; +use Illuminate\Contracts\Foundation\Application; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider; use Le0daniel\PhpTsBindings\Server\Operations\CachedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; -use Le0daniel\PhpTsBindings\Server\Server; use RuntimeException; final class OptimizeCommand extends Command @@ -15,8 +14,15 @@ final class OptimizeCommand extends Command protected $signature = 'operations:optimize'; protected $description = 'Optimize the schema operations for production use'; - public function handle(#[Give(LaravelServiceProvider::DEFAULT_SERVER)] Server $server): int + public function handle(Application $application): int { + // Always use a fresh server with eagerly loaded schema. + // Otherwise the types + $server = LaravelServiceProvider::serverFactory( + $application, + operations: null, + ); + $registry = $server->registry; if (!$registry instanceof EagerlyLoadedRegistry) { diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 663caa7..737e710 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -11,6 +11,7 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\CodeGenCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\ListCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\OptimizeCommand; +use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\HashSha256KeyGenerator; @@ -45,6 +46,42 @@ public function provides(): array ]; } + public static function serverFactory( + Application $app, + ?OperationRegistry $operations, + ): Server + { + $config = $app->make('config'); + + $operations ??= EagerlyLoadedRegistry::eagerlyDiscover( + $config->get('operations.discovery_path', []), + $app->make(TypeParser::class), + match ($config->get('operations.key.mode', 'obfuscate')) { + 'plain' => new PlainlyExposedKeyGenerator(), + 'obfuscate' => new HashSha256KeyGenerator( + $config->get('operations.key.pepper', 'none') + ), + "custom" => $app->make($config->get('operations.key.className')), + default => new HashSha256KeyGenerator("default"), + }, + ); + + return new Server( + registry: $operations, + exceptionPresenters: [ + new InvalidInputPresenter(), + new UnauthorizedPresenter($config->get('operations.exceptions.unauthorized', [])), + new UnauthenticatedPresenter($config->get('operations.exceptions.unauthenticated', [])), + new NotFoundPresenter($config->get('operations.exceptions.not_found', [])), + new ExposedExceptionPresenter(), + ], + defaultPresenter: new CatchAllPresenter(), + container: $app, + configuration: new ServerConfiguration() + ->withMiddlewares(...config('operations.middleware', [])), + ); + } + /** * Register any application services. */ @@ -57,37 +94,11 @@ public function register(): void }); $this->app->singleton(self::DEFAULT_SERVER, function (Application $app): Server { - $config = $app->make('config'); $isRepositoryCached = !$this->app->runningInConsole() && file_exists(base_path('bootstrap/cache/operations.php')); - $repository = $isRepositoryCached - ? require(base_path('bootstrap/cache/operations.php')) - : EagerlyLoadedRegistry::eagerlyDiscover( - $config->get('operations.discovery_path', []), - $app->make(TypeParser::class), - match ($config->get('operations.key.mode', 'obfuscate')) { - 'plain' => new PlainlyExposedKeyGenerator(), - 'obfuscate' => new HashSha256KeyGenerator( - $config->get('operations.key.pepper', 'none') - ), - "custom" => $app->make($config->get('operations.key.className')), - default => new HashSha256KeyGenerator("default"), - }, - ); - - return new Server( - $repository, - [ - new InvalidInputPresenter(), - new UnauthorizedPresenter($config->get('operations.exceptions.unauthorized', [])), - new UnauthenticatedPresenter($config->get('operations.exceptions.unauthenticated', [])), - new NotFoundPresenter($config->get('operations.exceptions.not_found', [])), - new ExposedExceptionPresenter(), - ], - new CatchAllPresenter(), + return self::serverFactory( $app, - new ServerConfiguration() - ->withMiddlewares(...config('operations.middleware', [])), + $isRepositoryCached ? require(base_path('bootstrap/cache/operations.php')) : null ); }); diff --git a/src/Contracts/Attributes/Command.php b/src/Contracts/Attributes/Command.php index a782ffd..09cdb25 100644 --- a/src/Contracts/Attributes/Command.php +++ b/src/Contracts/Attributes/Command.php @@ -17,6 +17,7 @@ public function __construct( ) { } + public function namespaceAsString(): ?string { return $this->namespace ? Strings::toString($this->namespace) : null; diff --git a/src/Parser/Data/GlobalTypeAliases.php b/src/Parser/Data/GlobalTypeAliases.php index 10388e9..bbefba7 100644 --- a/src/Parser/Data/GlobalTypeAliases.php +++ b/src/Parser/Data/GlobalTypeAliases.php @@ -15,6 +15,11 @@ public function __construct( ) { } + public function isEmpty(): bool + { + return count($this->aliases) === 0; + } + public function isGlobalAlias(string $value): bool { return array_key_exists($value, $this->aliases); From a88cfb6436ae0f84bdc90f1e91c825bd23f59937 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 31 Jul 2026 09:44:43 +0200 Subject: [PATCH 026/101] Fixed PHPStan --- README.md | 4 +- phpstan.neon | 19 +--- phpunit.xml | 1 - .../Laravel/Commands/CodeGenCommand.php | 37 ++++--- src/Adapters/Laravel/Commands/ListCommand.php | 8 +- .../Laravel/Commands/OptimizeCommand.php | 10 +- .../Laravel/LaravelHttpController.php | 4 - .../Laravel/LaravelServiceProvider.php | 7 +- .../Middleware/LocalMetadataMiddleware.php | 4 +- src/Adapters/Laravel/Preloader.php | 6 +- src/Adapters/Laravel/Utils/ArtisanOptions.php | 50 +++++++--- .../EmitOperationClientBindings.php | 5 +- src/CodeGen/CodeGenerators/EmitOperations.php | 3 + src/CodeGen/CodeGenerators/EmitQueryKey.php | 3 + .../CodeGenerators/EmitTanstackQuery.php | 3 + src/CodeGen/CodeGenerators/EmitTypeMap.php | 4 +- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 22 +++-- src/CodeGen/CodeGenerators/EmitTypes.php | 19 ++-- src/CodeGen/Data/ServerMetadata.php | 14 +-- src/CodeGen/Data/TypedOperation.php | 8 +- src/CodeGen/Exceptions/CodeGenException.php | 17 ++++ .../InvalidGeneratorDependencies.php | 4 +- src/CodeGen/TypescriptServerCodeGenerator.php | 57 +++++------ src/CodeGen/Utils/Paths.php | 2 +- src/{Validators => Constraints}/Email.php | 7 +- .../Length.php} | 7 +- .../NonEmptyString.php | 15 ++- .../NonFalsyString.php} | 7 +- src/Contracts/Attributes/Castable.php | 2 +- src/Contracts/Attributes/Command.php | 3 +- src/Contracts/Attributes/Query.php | 4 +- src/Contracts/Attributes/Throws.php | 2 +- src/Contracts/Discoverer.php | 14 --- src/Contracts/ExceptionPresenter.php | 3 +- src/Contracts/PhpTsBindingsException.php | 20 ++++ src/Contracts/RpcResult.php | 4 + src/Data/Value.php | 3 + src/Executor/Contracts/Result.php | 26 +++++ src/Executor/Data/Context.php | 4 +- src/Executor/Data/Failure.php | 38 +++++++- src/Executor/Data/IssueMessage.php | 1 + src/Executor/Data/Issues.php | 17 ++-- src/Executor/Data/Success.php | 24 ++++- src/Executor/Exceptions/SchemaException.php | 18 ++++ src/Executor/Handlers/CustomClassHandler.php | 16 ++-- src/Executor/Handlers/IntersectionHandler.php | 19 ++-- src/Executor/Handlers/ListHandler.php | 11 ++- src/Executor/Handlers/RecordHandler.php | 12 ++- src/Executor/Handlers/StructHandler.php | 26 ++++- src/Executor/Handlers/TupleHandler.php | 17 ++-- src/Executor/Handlers/UnionHandler.php | 23 +++-- src/Executor/SchemaExecutor.php | 3 + src/PHPStan/UtilitiesNodeResolver.php | 7 +- src/Parser/ASTOptimizer.php | 45 +++++---- src/Parser/AstValidator.php | 8 +- src/Parser/Consumers/AliasConsumer.php | 3 + src/Parser/Consumers/ArrayConsumer.php | 13 ++- src/Parser/Consumers/BuiltInLeafConsumer.php | 21 ++-- src/Parser/Consumers/ClassConstConsumer.php | 8 +- src/Parser/Consumers/DateTimeConsumer.php | 5 +- src/Parser/Consumers/EnumConsumer.php | 5 +- src/Parser/Consumers/IntConsumer.php | 9 +- .../Consumers/InteractsWithGenerics.php | 2 +- src/Parser/Consumers/LiteralConsumer.php | 5 +- src/Parser/Consumers/StructConsumer.php | 5 +- .../Consumers/UserDefinedObjectConsumer.php | 70 ++++++++------ src/Parser/Consumers/UtilsConsumer.php | 31 ++++-- src/Parser/Consumers/ValueObjectConsumer.php | 9 +- src/Parser/Contracts/WrapsNode.php | 10 ++ src/Parser/Contracts/WrapsNodes.php | 13 +++ src/Parser/Data/ParsingContext.php | 40 +++++--- src/Parser/Definition/Lexemes.php | 25 +++-- src/Parser/Definition/ParserState.php | 33 ++----- .../Exceptions/InvalidSyntaxException.php | 4 +- src/Parser/Exceptions/ParserException.php | 17 ++++ .../Exceptions/UnknownTypeKeyException.php | 4 +- .../UnexpectedCharacterException.php | 4 +- src/Parser/Lexer/Lexer.php | 4 +- src/Parser/Lexer/Token.php | 2 + src/Parser/Nodes/ConstraintNode.php | 6 +- src/Parser/Nodes/CustomCastingNode.php | 6 +- src/Parser/Nodes/Data/LiteralType.php | 3 +- src/Parser/Nodes/IntersectionNode.php | 25 +++-- src/Parser/Nodes/Leaf/BoolNode.php | 6 ++ src/Parser/Nodes/Leaf/DateTimeNode.php | 5 + src/Parser/Nodes/Leaf/EnumNode.php | 5 + src/Parser/Nodes/Leaf/FloatNode.php | 6 ++ src/Parser/Nodes/Leaf/IntNode.php | 6 ++ src/Parser/Nodes/Leaf/LiteralNode.php | 68 +++++++++++-- src/Parser/Nodes/Leaf/MixedNode.php | 5 + src/Parser/Nodes/Leaf/NullNode.php | 5 + src/Parser/Nodes/Leaf/StringNode.php | 6 ++ src/Parser/Nodes/Leaf/ValueObjectNode.php | 12 ++- src/Parser/Nodes/ListNode.php | 6 +- src/Parser/Nodes/MetadataNode.php | 14 ++- src/Parser/Nodes/PropertyNode.php | 13 +-- src/Parser/Nodes/RecordNode.php | 6 +- src/Parser/Nodes/ReferencedNode.php | 3 + src/Parser/Nodes/StructNode.php | 63 +++++++++--- src/Parser/Nodes/TupleNode.php | 21 ++-- src/Parser/Nodes/UnionNode.php | 25 +++-- src/Parser/Registry/CachedTypeRegistry.php | 2 + src/Parser/TypeParser.php | 40 ++++++-- src/Reflection/AttributesReflector.php | 20 +--- src/Reflection/FileReflector.php | 96 ++++++++++--------- src/Reflection/MetadataAttributes.php | 2 +- src/Reflection/TypeReflector.php | 8 +- src/Server/Client/InteractsWithToasts.php | 6 ++ src/Server/Client/NullClient.php | 6 +- src/Server/Client/OperationSPAClient.php | 39 ++++---- src/Server/Data/Definition.php | 4 +- .../Data/Exceptions/InvalidInputException.php | 5 +- .../Exceptions/InvalidMiddlewareException.php | 4 +- .../Exceptions/InvalidOutputException.php | 6 +- .../Exceptions/OperationNotFoundException.php | 4 +- .../Exceptions/UnknownResultTypeException.php | 17 ---- src/Server/Data/RpcError.php | 6 ++ src/Server/Data/RpcSuccess.php | 6 ++ src/Server/Data/ServerConfiguration.php | 9 +- .../KeyGenerators/HashSha256KeyGenerator.php | 3 +- .../PlainlyExposedKeyGenerator.php | 5 +- .../Operations/CachedOperationRegistry.php | 6 +- src/Server/Operations/DiscoveryManager.php | 43 --------- ...php => EagerlyLoadedOperationRegistry.php} | 39 ++++++-- src/Server/Operations/OperationDiscovery.php | 20 ++-- src/Server/Presenter/CatchAllPresenter.php | 14 ++- .../Presenter/ExposedExceptionPresenter.php | 23 +++-- .../Presenter/InvalidInputPresenter.php | 13 ++- src/Server/Presenter/NotFoundPresenter.php | 13 ++- .../Presenter/UnauthenticatedPresenter.php | 13 ++- .../Presenter/UnauthorizedPresenter.php | 9 +- src/Typescript/Code/TypescriptFile.php | 7 +- src/Typescript/Code/TypescriptImport.php | 21 ++-- .../Data/{TypeScript.php => Typescript.php} | 6 +- .../InvalidStringLiteralException.php | 4 +- .../Exceptions/UnknownAliasException.php | 4 +- .../Exceptions/UnsupportedTypeException.php | 4 +- src/Typescript/TypescriptGenerator.php | 16 ++-- src/Typescript/Utils/Syntax.php | 25 ++++- src/Utils/Arrays.php | 2 +- src/Utils/Dicts.php | 3 + src/Utils/Hashs.php | 2 +- src/Utils/Lists.php | 5 + src/Utils/Namespaces.php | 23 ++--- src/Utils/Nodes.php | 4 +- src/Utils/PHPExport.php | 36 ++++--- src/Utils/PhpDoc.php | 2 +- src/Utils/Reflections.php | 10 +- src/Utils/Regexes.php | 2 +- src/Utils/Strings.php | 6 +- tests/Feature/Mocks/CreateUserInput.php | 3 +- tests/Feature/ServerTest.php | 10 +- tests/Pest.php | 4 +- tests/Unit/CodeGen/EmitQueryKeyTest.php | 20 ++-- tests/Unit/CodeGen/EmitTypeUtilsTest.php | 4 +- tests/Unit/CodeGen/EmitTypesTest.php | 4 +- .../TypescriptServerCodeGeneratorTest.php | 4 +- .../{Validators => Constraints}/EmailTest.php | 4 +- .../LengthTest.php} | 24 ++--- .../Constraints/StringConstraintsTest.php | 47 +++++++++ .../Attributes/NamespaceAsStringTest.php | 37 +++++++ .../Unit/Contracts/ExceptionHierarchyTest.php | 96 +++++++++++++++++++ tests/Unit/Executor/ResultTest.php | 56 +++++++++++ tests/Unit/Parser/ASTOptimizerTest.php | 2 +- tests/Unit/Parser/MetadataEliminationTest.php | 7 +- .../Unit/Parser/NodeDiagnosticStringTest.php | 4 +- .../Parser/OptimizeAndWriteToFileTest.php | 71 ++++++++++++++ tests/Unit/Parser/TypeParserTest.php | 66 ++++++------- ...RegistryTest.php => AliasRegistryTest.php} | 0 .../Typescript/Code/TypescriptImportTest.php | 10 +- tests/Unit/Typescript/NamedTypesTest.php | 3 +- .../Typescript/TypescriptGeneratorTest.php | 4 +- tests/Unit/Typescript/Utils/SyntaxTest.php | 27 ++++++ tests/Unit/Utils/PHPExportTest.php | 62 ++++++++++++ 174 files changed, 1809 insertions(+), 797 deletions(-) create mode 100644 src/CodeGen/Exceptions/CodeGenException.php rename src/{Validators => Constraints}/Email.php (89%) rename src/{Validators/LengthValidator.php => Constraints/Length.php} (95%) rename src/{Validators => Constraints}/NonEmptyString.php (71%) rename src/{Validators/NonFalsyStringValidator.php => Constraints/NonFalsyString.php} (89%) delete mode 100644 src/Contracts/Discoverer.php create mode 100644 src/Contracts/PhpTsBindingsException.php create mode 100644 src/Executor/Contracts/Result.php create mode 100644 src/Executor/Exceptions/SchemaException.php create mode 100644 src/Parser/Contracts/WrapsNode.php create mode 100644 src/Parser/Contracts/WrapsNodes.php create mode 100644 src/Parser/Exceptions/ParserException.php delete mode 100644 src/Server/Data/Exceptions/UnknownResultTypeException.php delete mode 100644 src/Server/Operations/DiscoveryManager.php rename src/Server/Operations/{EagerlyLoadedRegistry.php => EagerlyLoadedOperationRegistry.php} (76%) rename src/Typescript/Data/{TypeScript.php => Typescript.php} (83%) rename tests/Unit/{Validators => Constraints}/EmailTest.php (94%) rename tests/Unit/{Validators/LengthValidatorTest.php => Constraints/LengthTest.php} (86%) create mode 100644 tests/Unit/Constraints/StringConstraintsTest.php create mode 100644 tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php create mode 100644 tests/Unit/Contracts/ExceptionHierarchyTest.php create mode 100644 tests/Unit/Executor/ResultTest.php create mode 100644 tests/Unit/Parser/OptimizeAndWriteToFileTest.php rename tests/Unit/Typescript/{TypeRegistryTest.php => AliasRegistryTest.php} (100%) create mode 100644 tests/Unit/Utils/PHPExportTest.php diff --git a/README.md b/README.md index 90c8dc1..bc56cb9 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,14 @@ your types. The return type is also applied and serialized, allowing you to be r ```php use Le0daniel\PhpTsBindings\Server\Server; -use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; +use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\Attributes\Query; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Contracts\Attributes\Throws; $server = new Server( - EagerlyLoadedRegistry::eagerlyDiscover('your/directory', keyGenerator: new PlainlyExposedKeyGenerator()) + EagerlyLoadedOperationRegistry::eagerlyDiscover('your/directory', keyGenerator: new PlainlyExposedKeyGenerator()) ); $inputData = Request::fromGlobals()->jsonInput; diff --git a/phpstan.neon b/phpstan.neon index 4666d23..a81aae2 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,21 +5,10 @@ parameters: paths: - src/ - # ToDo: Enable this in the future + # Level 10 is the highest level + level: 8 + reportPossiblyNonexistentGeneralArrayOffset: false + reportPossiblyNonexistentConstantArrayOffset: true checkMissingCallableSignature: true checkBenevolentUnionTypes: true - reportPossiblyNonexistentConstantArrayOffset: true - - # Level 10 is the highest level - level: 6 - -# typeAliases: -# Email: 'non-empty-string' - -# ignoreErrors: -# - '#class-string#' - -# -# excludePaths: -# - ./src/Adapters/* diff --git a/phpunit.xml b/phpunit.xml index e6198e0..35cdfbb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,7 +11,6 @@ - app src diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index bb1ccbc..3595a3e 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -83,11 +83,18 @@ public function handle( operations: null, ); - try { - $metadata = new ServerMetadata( - $router->getRoutes()->getByName(LaravelHttpController::QUERY_NAME)->uri(), - $router->getRoutes()->getByName(LaravelHttpController::COMMAND_NAME)->uri(), + $queryRoute = $router->getRoutes()->getByName(LaravelHttpController::QUERY_NAME); + $commandRoute = $router->getRoutes()->getByName(LaravelHttpController::COMMAND_NAME); + if ($queryRoute === null || $commandRoute === null) { + $this->error( + 'The operation routes are not registered. Call LaravelHttpController::registerQueries() ' + . 'and ::registerCommands() from your route definitions.' ); + return 1; + } + + try { + $metadata = new ServerMetadata($queryRoute->uri(), $commandRoute->uri()); $codeGenerator = new TypescriptServerCodeGenerator( $this->getGeneratorsFromInput($application), @@ -111,9 +118,16 @@ public function handle( return 1; } - $directory = str_starts_with('/', $this->argument('directory')) - ? $this->argument('directory') - : base_path($this->argument('directory')); + $target = ArtisanOptions::asString($this->argument('directory')) ?? ''; + if ($target === '') { + $this->error('A target directory is required.'); + return 1; + } + + // Argument order matters: the haystack is the path. Reversed, this asked whether '/' starts + // with the path, which is false for every real input, so absolute paths were being prefixed + // with base_path(). + $directory = str_starts_with($target, '/') ? $target : base_path($target); if ($this->option('verify')) { $this->info("Verify generated code only."); @@ -155,14 +169,15 @@ private function getNamingGenerator(Application $application): Closure return $nameGenerator; } - $possibleClassNameAndMethod = $this->option('naming'); - $parts = explode('::', $possibleClassNameAndMethod, 2); + $naming = ArtisanOptions::asString($this->option('naming')) ?? ''; + $parts = explode('::', $naming, 2); if (count($parts) === 2 && class_exists($parts[0]) && method_exists($parts[0], $parts[1])) { - return Closure::fromCallable([$application->make($parts[0]), $parts[1]]); + $instance = $application->make($parts[0]); + return $instance->{$parts[1]}(...); } - $this->error("Unknown naming mode {$this->option('naming')}."); + $this->error("Unknown naming mode {$naming}."); exit(1); } diff --git a/src/Adapters/Laravel/Commands/ListCommand.php b/src/Adapters/Laravel/Commands/ListCommand.php index c603237..50300a9 100644 --- a/src/Adapters/Laravel/Commands/ListCommand.php +++ b/src/Adapters/Laravel/Commands/ListCommand.php @@ -7,10 +7,10 @@ use Illuminate\Routing\Router; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Server; -use RuntimeException; final class ListCommand extends Command { @@ -25,8 +25,10 @@ public function handle( $queryRoute = $router->getRoutes()->getByName(LaravelHttpController::QUERY_NAME); $commandRoute = $router->getRoutes()->getByName(LaravelHttpController::COMMAND_NAME); - if (!$commandRoute && !$queryRoute) { - throw new RuntimeException('Cannot list routes that are not registered'); + // Both are dereferenced below, so both must exist. This used to be `&&`, which only tripped + // when neither route was registered and left a null dereference when exactly one was. + if (!$commandRoute || !$queryRoute) { + throw new SchemaException('Cannot list routes that are not registered'); } $this->table([ diff --git a/src/Adapters/Laravel/Commands/OptimizeCommand.php b/src/Adapters/Laravel/Commands/OptimizeCommand.php index 4b71e28..fe7bbec 100644 --- a/src/Adapters/Laravel/Commands/OptimizeCommand.php +++ b/src/Adapters/Laravel/Commands/OptimizeCommand.php @@ -5,9 +5,9 @@ use Illuminate\Console\Command; use Illuminate\Contracts\Foundation\Application; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Server\Operations\CachedOperationRegistry; -use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; -use RuntimeException; +use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Throwable; final class OptimizeCommand extends Command @@ -26,8 +26,8 @@ public function handle(Application $application): int $registry = $server->registry; - if (!$registry instanceof EagerlyLoadedRegistry) { - throw new RuntimeException('Cannot optimize a registry that is not a JustInTimeDiscoveryRegistry'); + if (!$registry instanceof EagerlyLoadedOperationRegistry) { + throw new SchemaException('Cannot optimize a registry that is not a JustInTimeDiscoveryRegistry'); } $idLength = $this->hasOption('id-length') @@ -35,7 +35,7 @@ public function handle(Application $application): int : config('operations.cache.idLength'); if (!is_int($idLength) || $idLength < 1) { - throw new RuntimeException('Invalid id-length option'); + throw new SchemaException('Invalid id-length option'); } try { diff --git a/src/Adapters/Laravel/LaravelHttpController.php b/src/Adapters/Laravel/LaravelHttpController.php index b97dc3a..1a80cf5 100644 --- a/src/Adapters/Laravel/LaravelHttpController.php +++ b/src/Adapters/Laravel/LaravelHttpController.php @@ -3,16 +3,13 @@ namespace Le0daniel\PhpTsBindings\Adapters\Laravel; use Illuminate\Contracts\Debug\ExceptionHandler; -use Illuminate\Contracts\Foundation\Application; use Illuminate\Http; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Routing\Route; use Illuminate\Support\Facades; use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\SerializableClient; -use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Client\OperationSPAClient; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidOutputException; @@ -20,7 +17,6 @@ use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; use Le0daniel\PhpTsBindings\Server\Server; -use Le0daniel\PhpTsBindings\Utils\Arrays; use Le0daniel\PhpTsBindings\Utils\Dicts; use Throwable; diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 737e710..038b956 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -16,7 +16,7 @@ use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\HashSha256KeyGenerator; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; -use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; +use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Presenter\CatchAllPresenter; use Le0daniel\PhpTsBindings\Server\Presenter\ExposedExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Presenter\InvalidInputPresenter; @@ -24,6 +24,7 @@ use Le0daniel\PhpTsBindings\Server\Presenter\UnauthenticatedPresenter; use Le0daniel\PhpTsBindings\Server\Presenter\UnauthorizedPresenter; use Le0daniel\PhpTsBindings\Server\Server; +use Override; final class LaravelServiceProvider extends ServiceProvider implements DeferrableProvider { @@ -35,6 +36,7 @@ final class LaravelServiceProvider extends ServiceProvider implements Deferrable /** * @return class-string[] */ + #[Override] public function provides(): array { // @phpstan-ignore-next-line return.type -- allowed here. @@ -53,7 +55,7 @@ public static function serverFactory( { $config = $app->make('config'); - $operations ??= EagerlyLoadedRegistry::eagerlyDiscover( + $operations ??= EagerlyLoadedOperationRegistry::eagerlyDiscover( $config->get('operations.discovery_path', []), $app->make(TypeParser::class), match ($config->get('operations.key.mode', 'obfuscate')) { @@ -85,6 +87,7 @@ public static function serverFactory( /** * Register any application services. */ + #[Override] public function register(): void { $this->app->bind(TypeParser::class, function () { diff --git a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php index b4787ce..b188b43 100644 --- a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php +++ b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php @@ -8,12 +8,14 @@ use Le0daniel\PhpTsBindings\Server\Data\ResolveInfo; use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; +use Override; /** * @implements MiddlewareContract */ -final class LocalMetadataMiddleware implements MiddlewareContract +final readonly class LocalMetadataMiddleware implements MiddlewareContract { + #[Override] public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { if (config('app.debug') !== true) { diff --git a/src/Adapters/Laravel/Preloader.php b/src/Adapters/Laravel/Preloader.php index 1bcb56e..18cab2e 100644 --- a/src/Adapters/Laravel/Preloader.php +++ b/src/Adapters/Laravel/Preloader.php @@ -3,11 +3,11 @@ namespace Le0daniel\PhpTsBindings\Adapters\Laravel; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Utils\Strings; -use RuntimeException; use UnitEnum; final readonly class Preloader @@ -38,11 +38,11 @@ public function __construct( public function preload(string|UnitEnum $namespace, string $name, mixed $input, mixed $context): array { $namespaceAsString = Strings::toString($namespace); - $fqcn = $this->keyGenerator->generateKey(Strings::toString($namespaceAsString), $name); + $fqcn = $this->keyGenerator->generateKey($namespaceAsString, $name); $result = $this->server->query($fqcn, $input, $context, new NullClient()); if (!$result instanceof RpcSuccess) { - throw new RuntimeException("Failed to preload: {$namespaceAsString}.{$name}"); + throw new SchemaException("Failed to preload: {$namespaceAsString}.{$name}"); } return [ diff --git a/src/Adapters/Laravel/Utils/ArtisanOptions.php b/src/Adapters/Laravel/Utils/ArtisanOptions.php index c313698..1a91916 100644 --- a/src/Adapters/Laravel/Utils/ArtisanOptions.php +++ b/src/Adapters/Laravel/Utils/ArtisanOptions.php @@ -2,28 +2,50 @@ namespace Le0daniel\PhpTsBindings\Adapters\Laravel\Utils; -final class ArtisanOptions +final readonly class ArtisanOptions { /** - * @param string|array|null $options + * Expands an artisan option into a flat list of names, splitting on commas so that + * `--with=a,b --with=c` and `--with=a --with=b --with=c` mean the same thing. + * + * Takes mixed because that is what Command::option() returns: a repeatable option is an array, + * a value option a string, a flag a bool, and an absent option null. Anything that is not a + * string contributes nothing rather than being coerced into a name nobody typed. + * * @return list */ - public static function expandOptionsArrayCommaSeparated(string|array|null $options): array + public static function expandOptionsArrayCommaSeparated(mixed $options): array { - /** @var array $options */ $options = match (true) { is_array($options) => $options, is_string($options) => [$options], - default => [] + default => [], }; - return array_reduce($options, function (array $carry, string $option) { - $options = array_map(fn(string $option) => trim($option), explode(',', $option)); - $filteredOptions = array_values(array_filter($options, fn(string $option) => !empty($option))); - return array_values(array_unique([ - ... $carry, - ... $filteredOptions, - ])); - }, []); + /** @var list $expanded */ + $expanded = []; + foreach ($options as $option) { + if (!is_string($option)) { + continue; + } + + foreach (explode(',', $option) as $part) { + $part = trim($part); + if ($part !== '' && !in_array($part, $expanded, true)) { + $expanded[] = $part; + } + } + } + + return $expanded; + } + + /** + * An option that must be a single string. Anything else - a flag, a repeated option, an absent + * one - is not a value the caller can use, so it comes back as null rather than as "1" or "". + */ + public static function asString(mixed $option): ?string + { + return is_string($option) ? $option : null; } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 064d97d..adc9275 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -7,10 +7,12 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; +use Override; -final class EmitOperationClientBindings implements GeneratesLibFiles, DependsOn +final readonly class EmitOperationClientBindings implements GeneratesLibFiles, DependsOn { + #[Override] public function dependsOnGenerator(): array { return [ @@ -21,6 +23,7 @@ public function dependsOnGenerator(): array /** * @return array */ + #[Override] public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { return [ diff --git a/src/CodeGen/CodeGenerators/EmitOperations.php b/src/CodeGen/CodeGenerators/EmitOperations.php index 96eda2c..b2e3a5f 100644 --- a/src/CodeGen/CodeGenerators/EmitOperations.php +++ b/src/CodeGen/CodeGenerators/EmitOperations.php @@ -10,6 +10,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; +use Override; final readonly class EmitOperations implements GeneratesOperationCode, DependsOn { @@ -22,6 +23,7 @@ public function __construct( { } + #[Override] public function dependsOnGenerator(): array { return [ @@ -49,6 +51,7 @@ private function aliasImports(TypedOperation $operation): array ]; } + #[Override] public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): TypescriptFile { $definition = $operation->operation->definition; diff --git a/src/CodeGen/CodeGenerators/EmitQueryKey.php b/src/CodeGen/CodeGenerators/EmitQueryKey.php index d98c7b6..541f0d1 100644 --- a/src/CodeGen/CodeGenerators/EmitQueryKey.php +++ b/src/CodeGen/CodeGenerators/EmitQueryKey.php @@ -11,9 +11,11 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; +use Override; final readonly class EmitQueryKey implements DependsOn, GeneratesOperationCode { + #[Override] public function dependsOnGenerator(): array { return [ @@ -34,6 +36,7 @@ private function generateName(TypedOperation $operation): string } + #[Override] public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptFile { $definition = $operation->operation->definition; diff --git a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php index 8eba49b..832710f 100644 --- a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php +++ b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php @@ -11,9 +11,11 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; +use Override; final readonly class EmitTanstackQuery implements GeneratesOperationCode, DependsOn { + #[Override] public function dependsOnGenerator(): array { return [ @@ -33,6 +35,7 @@ private function generateName(TypedOperation $operation): string return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->operation->definition->name; } + #[Override] public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptFile { $definition = $operation->operation->definition; diff --git a/src/CodeGen/CodeGenerators/EmitTypeMap.php b/src/CodeGen/CodeGenerators/EmitTypeMap.php index 1d7a7e9..621ba00 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeMap.php +++ b/src/CodeGen/CodeGenerators/EmitTypeMap.php @@ -8,10 +8,12 @@ use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Utils\Arrays; +use Override; -final class EmitTypeMap implements GeneratesLibFiles +final readonly class EmitTypeMap implements GeneratesLibFiles { + #[Override] public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { /** diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index a20e259..f7ae582 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -10,9 +10,11 @@ use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; +use Override; -final class EmitTypeUtils implements GeneratesLibFiles, DependsOn +final readonly class EmitTypeUtils implements GeneratesLibFiles, DependsOn { + #[Override] public function dependsOnGenerator(): array { return [ @@ -23,21 +25,21 @@ public function dependsOnGenerator(): array /** * @return array */ + #[Override] public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { - $queryNamespaces = array_reduce($operations, function (array $carry, TypedOperation $operation) { + /** @var list $queryNamespaces */ + $queryNamespaces = []; + foreach ($operations as $operation) { if ($operation->operation->definition->type !== OperationType::QUERY) { - return $carry; + continue; } - if (!in_array($operation->operation->definition->namespace, $carry, true)) { - return [ - ...$carry, - $operation->operation->definition->namespace, - ]; + $namespace = $operation->operation->definition->namespace; + if (!in_array($namespace, $queryNamespaces, true)) { + $queryNamespaces[] = $namespace; } - return $carry; - }, []); + } // Derived from the enum, so the values the guard accepts can never drift from ToastType. $toastTypes = implode(', ', array_map( diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 01494b7..592dc88 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -10,8 +10,9 @@ use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Utils\Arrays; +use Override; -final class EmitTypes implements GeneratesLibFiles +final readonly class EmitTypes implements GeneratesLibFiles { /** * Declarations this file always contains. An alias claiming one of these names would generate @@ -35,6 +36,7 @@ final class EmitTypes implements GeneratesLibFiles /** * @return array */ + #[Override] public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { foreach ($registry->usedAliases() as $alias) { @@ -43,15 +45,14 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } } - $uniqueNamespaces = array_reduce($operations, function (array $carry, TypedOperation $operation) { - if (!in_array($operation->operation->definition->namespace, $carry, true)) { - return [ - ...$carry, - $operation->operation->definition->namespace, - ]; + /** @var list $uniqueNamespaces */ + $uniqueNamespaces = []; + foreach ($operations as $operation) { + $namespace = $operation->operation->definition->namespace; + if (!in_array($namespace, $uniqueNamespaces, true)) { + $uniqueNamespaces[] = $namespace; } - return $carry; - }, []); + } // The shared registry holds every alias any pass produced; the types file declares them // all, so every operation file can import any key of its own definitions' registries. diff --git a/src/CodeGen/Data/ServerMetadata.php b/src/CodeGen/Data/ServerMetadata.php index 1306665..a637343 100644 --- a/src/CodeGen/Data/ServerMetadata.php +++ b/src/CodeGen/Data/ServerMetadata.php @@ -2,9 +2,7 @@ namespace Le0daniel\PhpTsBindings\CodeGen\Data; -use InvalidArgumentException; -use Le0daniel\PhpTsBindings\Server\Data\Operation; -use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; final readonly class ServerMetadata { @@ -14,17 +12,11 @@ public function __construct( ) { if (!str_contains($this->queryUrl, '{fqn}')) { - throw new InvalidArgumentException('Query URL must contain {fqn} placeholder'); + throw new CodeGenException('Query URL must contain {fqn} placeholder'); } if (!str_contains($this->commandUrl, '{fqn}')) { - throw new InvalidArgumentException('Command URL must contain {fqn} placeholder'); + throw new CodeGenException('Command URL must contain {fqn} placeholder'); } } - public function getFullyQualifiedUrl(Operation $operation): string - { - return $operation->definition->type === OperationType::QUERY - ? str_replace('{fqn}', $operation->key, $this->queryUrl) - : str_replace('{fqn}', $operation->key, $this->commandUrl); - } } \ No newline at end of file diff --git a/src/CodeGen/Data/TypedOperation.php b/src/CodeGen/Data/TypedOperation.php index c416d09..25179c9 100644 --- a/src/CodeGen/Data/TypedOperation.php +++ b/src/CodeGen/Data/TypedOperation.php @@ -4,7 +4,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; final class TypedOperation { @@ -21,9 +21,9 @@ final class TypedOperation * file imports, and what the generated types file declares (via the run's shared registry). */ public function __construct( - public readonly TypeScript $inputDef, - public readonly TypeScript $outputDef, - public readonly TypeScript $errorDef, + public readonly Typescript $inputDef, + public readonly Typescript $outputDef, + public readonly Typescript $errorDef, public readonly Operation $operation, ) { diff --git a/src/CodeGen/Exceptions/CodeGenException.php b/src/CodeGen/Exceptions/CodeGenException.php new file mode 100644 index 0000000..efefb83 --- /dev/null +++ b/src/CodeGen/Exceptions/CodeGenException.php @@ -0,0 +1,17 @@ + $messages diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index ea9b477..9ec1d00 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -7,6 +7,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; use Le0daniel\PhpTsBindings\Parser\AstValidator; @@ -15,11 +16,10 @@ use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Le0daniel\PhpTsBindings\Utils\Lists; -use RuntimeException; final readonly class TypescriptServerCodeGenerator { @@ -72,40 +72,33 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore * Filter out some operations that are not needed. * @var array $filteredDefinitions */ - $filteredDefinitions = array_values( - array_filter($server->registry->all(), function (Operation $operation) use ($ignore): bool { - if (in_array($operation->definition->namespace, $ignore, true) || in_array($operation->definition->fullyQualifiedName(), $ignore, true)) { - return false; - } - return true; - }) - ); + $filteredDefinitions = array_filter( + $server->registry->all(), + fn(Operation $operation): bool => !in_array($operation->definition->namespace, $ignore, true) + && !in_array($operation->definition->fullyQualifiedName(), $ignore, true), + ) |> array_values(...); // Cross-operation and cross-direction alias conflicts are only caught when every pass hands // its aliases into one shared registry, so the run always has one. It is also what the // generated types file declares. $registry = new AliasRegistry(); - $definitions = array_values( - array_map(function (Operation $operation) use ($server, $registry): TypedOperation { - AstValidator::validate($operation->inputNode()); - AstValidator::validate($operation->outputNode()); - - $input = $this->typescriptGenerator->toTypescript( - $operation->inputNode(), IO::INPUT, $registry, - ); - $output = $this->typescriptGenerator->toTypescript( - $operation->outputNode(), IO::OUTPUT, $registry, - ); - - return new TypedOperation( - inputDef: $input, - outputDef: $output, - errorDef: $this->generateAllErrorTypes($server, $operation->definition) |> TypeScript::fromRawString(...), - operation: $operation, - ); - }, $filteredDefinitions) - ); + // Bound once: inputNode()/outputNode() run the parse closure on every call, so asking twice + // parses every schema in the run twice. + $definitions = array_map(function (Operation $operation) use ($server, $registry): TypedOperation { + $inputNode = $operation->inputNode(); + $outputNode = $operation->outputNode(); + + AstValidator::validate($inputNode); + AstValidator::validate($outputNode); + + return new TypedOperation( + inputDef: $this->typescriptGenerator->toTypescript($inputNode, IO::INPUT, $registry), + outputDef: $this->typescriptGenerator->toTypescript($outputNode, IO::OUTPUT, $registry), + errorDef: $this->generateAllErrorTypes($server, $operation->definition) |> Typescript::fromRawString(...), + operation: $operation, + ); + }, $filteredDefinitions); // Deterministically sort for consistency between systems usort($definitions, function (TypedOperation $a, TypedOperation $b): int { @@ -126,7 +119,7 @@ private function generateAllErrorTypes(Server $server, Definition $operation): s $possibleTypes = Lists::filterNullValues(array_map(static function (ExceptionPresenter $presenter) use ($operation): string { $code = $presenter::errorType(); $codeName = json_encode($code->name, JSON_THROW_ON_ERROR); - $details = $presenter->toTypeScriptDefinition($operation); + $details = $presenter->toTypescriptDefinition($operation); return $details === null ? "{code: {$code->value}, type: {$codeName}}" @@ -157,7 +150,7 @@ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry) foreach ($codeGenerator->emitFiles($definitions, $metadata, $registry) as $fileName => $fileContent) { if (preg_match('/^[a-zA-Z0-9_\-]+$/', $fileName) !== 1) { - throw new RuntimeException("Invalid file name '{$fileName}' for lib file. File names must only contain a-z, A-Z, 0-9, - and _."); + throw new CodeGenException("Invalid file name '{$fileName}' for lib file. File names must only contain a-z, A-Z, 0-9, - and _."); } // Several generators may contribute to one lib file, so they accumulate rather diff --git a/src/CodeGen/Utils/Paths.php b/src/CodeGen/Utils/Paths.php index 049fd5a..da81133 100644 --- a/src/CodeGen/Utils/Paths.php +++ b/src/CodeGen/Utils/Paths.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\CodeGen\Utils; -final class Paths +final readonly class Paths { public static function libImport(string $name): string { diff --git a/src/Validators/Email.php b/src/Constraints/Email.php similarity index 89% rename from src/Validators/Email.php rename to src/Constraints/Email.php index cc0c87d..30fd150 100644 --- a/src/Validators/Email.php +++ b/src/Constraints/Email.php @@ -1,6 +1,6 @@ addIssue(new Issue( - 'validation.not_empty_string', + IssueMessage::NOT_EMPTY_STRING, [ - "message" => "Expected non-empty string, got: '{$value}'", + "message" => "Expected non-empty string, got an empty string.", ] )); return false; @@ -36,6 +40,7 @@ public function validate(mixed $value, ExecutionContext $context): bool return true; } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); diff --git a/src/Validators/NonFalsyStringValidator.php b/src/Constraints/NonFalsyString.php similarity index 89% rename from src/Validators/NonFalsyStringValidator.php rename to src/Constraints/NonFalsyString.php index e12509d..e471af9 100644 --- a/src/Validators/NonFalsyStringValidator.php +++ b/src/Constraints/NonFalsyString.php @@ -1,6 +1,6 @@ namespace ? Strings::toString($this->namespace) : null; + return $this->namespace !== null ? Strings::toString($this->namespace) : null; } } \ No newline at end of file diff --git a/src/Contracts/Attributes/Query.php b/src/Contracts/Attributes/Query.php index 36268cd..18b93c2 100644 --- a/src/Contracts/Attributes/Query.php +++ b/src/Contracts/Attributes/Query.php @@ -3,9 +3,7 @@ namespace Le0daniel\PhpTsBindings\Contracts\Attributes; use Attribute; -use BackedEnum; use Le0daniel\PhpTsBindings\Utils\Strings; -use StringBackedEnum; use UnitEnum; #[Attribute(Attribute::TARGET_METHOD)] @@ -21,6 +19,6 @@ public function __construct( public function namespaceAsString(): ?string { - return $this->namespace ? Strings::toString($this->namespace) : null; + return $this->namespace !== null ? Strings::toString($this->namespace) : null; } } \ No newline at end of file diff --git a/src/Contracts/Attributes/Throws.php b/src/Contracts/Attributes/Throws.php index ea17238..7cacd05 100644 --- a/src/Contracts/Attributes/Throws.php +++ b/src/Contracts/Attributes/Throws.php @@ -12,7 +12,7 @@ * Declared exceptions are only exposed to the client if their class is marked with the ExposeAs attribute. */ #[Attribute(Attribute::TARGET_METHOD|Attribute::IS_REPEATABLE)] -final class Throws +final readonly class Throws { /** * @param class-string $exceptionClass diff --git a/src/Contracts/Discoverer.php b/src/Contracts/Discoverer.php deleted file mode 100644 index eadfba1..0000000 --- a/src/Contracts/Discoverer.php +++ /dev/null @@ -1,14 +0,0 @@ - $class - * @return void - */ - public function discover(ReflectionClass $class): void; -} \ No newline at end of file diff --git a/src/Contracts/ExceptionPresenter.php b/src/Contracts/ExceptionPresenter.php index be19881..d301e2a 100644 --- a/src/Contracts/ExceptionPresenter.php +++ b/src/Contracts/ExceptionPresenter.php @@ -4,7 +4,6 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; -use Le0daniel\PhpTsBindings\Server\Data\Operation; use Throwable; interface ExceptionPresenter @@ -23,7 +22,7 @@ public function matches(Throwable $throwable, Definition $definition): bool; * @param Definition $definition * @return string|null */ - public function toTypeScriptDefinition(Definition $definition): ?string; + public function toTypescriptDefinition(Definition $definition): ?string; /** * Render a response compatible with the current definition diff --git a/src/Contracts/PhpTsBindingsException.php b/src/Contracts/PhpTsBindingsException.php new file mode 100644 index 0000000..e2b6d62 --- /dev/null +++ b/src/Contracts/PhpTsBindingsException.php @@ -0,0 +1,20 @@ + $metadata */ + #[NoDiscard] public function withMetadata(array $metadata): static; /** * Append metadata to the result. * @param array $metadata */ + #[NoDiscard] public function appendMetadata(array $metadata): static; } diff --git a/src/Data/Value.php b/src/Data/Value.php index 3dd2099..4699279 100644 --- a/src/Data/Value.php +++ b/src/Data/Value.php @@ -2,11 +2,14 @@ namespace Le0daniel\PhpTsBindings\Data; +use NoDiscard; + enum Value { case INVALID; case UNDEFINED; + #[NoDiscard] public static function toNull(mixed $value): mixed { return $value instanceof Value ? null : $value; diff --git a/src/Executor/Contracts/Result.php b/src/Executor/Contracts/Result.php new file mode 100644 index 0000000..0b8e5d2 --- /dev/null +++ b/src/Executor/Contracts/Result.php @@ -0,0 +1,26 @@ + = Success | Failure` that + * EmitTypes generates, so both sides of the binding describe the outcome the same way. + */ +interface Result +{ + public function isSuccess(): bool; + + /** + * Present on both arms: a Success carries issues when it was parsed with partialFailures + * enabled, so an empty Issues is not the same as success. + */ + public function issues(): Issues; +} diff --git a/src/Executor/Data/Context.php b/src/Executor/Data/Context.php index d31c63f..d182461 100644 --- a/src/Executor/Data/Context.php +++ b/src/Executor/Data/Context.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\Executor\Data; use Le0daniel\PhpTsBindings\Executor\Contracts\ExecutionContext; +use Override; final class Context implements ExecutionContext { @@ -20,7 +21,7 @@ public function __construct( private array $path = []; /** - * @var array + * @var array> */ private(set) array $issues = []; @@ -41,6 +42,7 @@ private function pathAsString(): string : Issues::ROOT_PATH; } + #[Override] public function addIssue(Issue $issue): void { $this->issues[$this->pathAsString()][] = $issue; diff --git a/src/Executor/Data/Failure.php b/src/Executor/Data/Failure.php index 282ee55..5a20aa0 100644 --- a/src/Executor/Data/Failure.php +++ b/src/Executor/Data/Failure.php @@ -2,14 +2,44 @@ namespace Le0daniel\PhpTsBindings\Executor\Data; -use Exception; +use Le0daniel\PhpTsBindings\Executor\Contracts\Result; +use Override; -final class Failure extends Exception +/** + * A value did not validate. Returned from the executor, never thrown - a value the caller supplied + * being wrong is an outcome, not an exceptional condition. + * + * Deliberately not a Throwable: while it was one, a consumer with a broad catch around executor + * code could swallow a Failure that had leaked out of a return value. Where the failure does need + * to travel as an exception - across the RPC boundary - InvalidInputException and + * InvalidOutputException wrap it. + */ +final readonly class Failure implements Result { public function __construct( public Issues $issues, ) { - parent::__construct("Validation failed: {$this->issues->serializeToCompleteString()}.", 422); } -} \ No newline at end of file + + #[Override] + public function isSuccess(): false + { + return false; + } + + #[Override] + public function issues(): Issues + { + return $this->issues; + } + + /** + * The message the wrapping exceptions report, kept here so both of them describe a failure the + * same way. + */ + public function describe(): string + { + return "Validation failed: {$this->issues->serializeToCompleteString()}."; + } +} diff --git a/src/Executor/Data/IssueMessage.php b/src/Executor/Data/IssueMessage.php index 26e3337..cc89224 100644 --- a/src/Executor/Data/IssueMessage.php +++ b/src/Executor/Data/IssueMessage.php @@ -8,6 +8,7 @@ enum IssueMessage: string case INVALID_KEY_TYPE = 'validation.invalid_key_type'; case MISSING_PROPERTY = 'validation.missing_property'; case FALSY_STRING = 'validation.falsy_string'; + case NOT_EMPTY_STRING = 'validation.not_empty_string'; case INVALID_EMAIL = 'validation.invalid_email'; case INTERNAL_ERROR = 'internal_error'; case INVALID_MIN = 'validation.invalid_min'; diff --git a/src/Executor/Data/Issues.php b/src/Executor/Data/Issues.php index f3df4aa..6b0cef0 100644 --- a/src/Executor/Data/Issues.php +++ b/src/Executor/Data/Issues.php @@ -2,12 +2,12 @@ namespace Le0daniel\PhpTsBindings\Executor\Data; -final class Issues +final readonly class Issues { public const string ROOT_PATH = '__root'; /** - * @param array $issuesMap + * @param array> $issuesMap */ public function __construct( public readonly array $issuesMap = [], @@ -23,7 +23,9 @@ public static function fromMessages(array $issuesMap): self { return new self( array_map( - fn(string|array $issues) => Issue::fromMessageArray(is_array($issues) ? $issues : [$issues]), + fn(string|array $issues) => Issue::fromMessageArray( + is_array($issues) ? array_values($issues) : [$issues], + ), $issuesMap ) ); @@ -44,7 +46,9 @@ public function at(?string $path): array /** @return list */ public function allFlat(): array { - return array_merge(...array_values($this->issuesMap)); + return $this->issuesMap === [] + ? [] + : array_merge(...array_values($this->issuesMap)); } /** @@ -62,9 +66,8 @@ public function serializeToFieldsArray(): array */ public function serializeToDebugFields(): array { - /** @phpstan-ignore-next-line return.type */ - return array_map(function ($issues) { - return array_map(fn(Issue $issue) => [ + return array_map(function (array $issues): array { + return array_map(fn(Issue $issue): array => [ 'message' => $issue->messageOrLocalizationKey, 'debugInfo' => $issue->debugInfo, 'exception' => $issue->exception ? [ diff --git a/src/Executor/Data/Success.php b/src/Executor/Data/Success.php index 3ddf968..1b67495 100644 --- a/src/Executor/Data/Success.php +++ b/src/Executor/Data/Success.php @@ -2,16 +2,34 @@ namespace Le0daniel\PhpTsBindings\Executor\Data; -final readonly class Success +use Le0daniel\PhpTsBindings\Executor\Contracts\Result; +use Override; + +final readonly class Success implements Result { public function __construct( public mixed $value, public Issues $issues = new Issues(), ) {} + #[Override] + public function isSuccess(): true + { + return true; + } + + #[Override] + public function issues(): Issues + { + return $this->issues; + } + + /** + * A success that still collected issues: parsing ran with partialFailures enabled and kept + * going past the parts that did not validate. + */ public function isPartial(): bool { return !$this->issues->isEmpty(); } - -} \ No newline at end of file +} diff --git a/src/Executor/Exceptions/SchemaException.php b/src/Executor/Exceptions/SchemaException.php new file mode 100644 index 0000000..f2a0488 --- /dev/null +++ b/src/Executor/Exceptions/SchemaException.php @@ -0,0 +1,18 @@ + */ -final class CustomClassHandler implements Handler +final readonly class CustomClassHandler implements Handler { - - - /** @param CustomCastingNode $node - * @return stdClass|Value + /** + * @return stdClass|Value */ + #[Override] public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): stdClass|Value { + assert($node instanceof CustomCastingNode); + $object = $executor->executeSerialize($node->node, $value, $context); if ($object === Value::INVALID) { @@ -48,9 +50,11 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E return $object; } - /** @param CustomCastingNode $node */ + #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): mixed { + assert($node instanceof CustomCastingNode); + if ($node->strategy === ObjectCastStrategy::NEVER) { return Value::INVALID; } diff --git a/src/Executor/Handlers/IntersectionHandler.php b/src/Executor/Handlers/IntersectionHandler.php index e73db10..0ad50ea 100644 --- a/src/Executor/Handlers/IntersectionHandler.php +++ b/src/Executor/Handlers/IntersectionHandler.php @@ -8,23 +8,26 @@ use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; -use RuntimeException; +use Override; use stdClass; /** * @implements Handler */ -final class IntersectionHandler implements Handler +final readonly class IntersectionHandler implements Handler { - /** @param IntersectionNode $node */ + #[Override] public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): stdClass|Value { + assert($node instanceof IntersectionNode); + /** @var array $intersectionValues */ $intersectionValues = []; - foreach ($node->types as $type) { + foreach ($node->nodes as $type) { $partialObject = $executor->executeSerialize($type, $value, $context); if ($partialObject === Value::INVALID) { return Value::INVALID; @@ -36,16 +39,18 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E return (object) array_merge(...$intersectionValues); } - /** @param IntersectionNode $node */ + #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): mixed { + assert($node instanceof IntersectionNode); + /** @var array $intersectionValues */ $intersectionValues = []; /** @var 'array'|'object'|null $mode */ $mode = null; - foreach ($node->types as $type) { + foreach ($node->nodes as $type) { $partialObject = $executor->executeParse($type, $value, $context); if ($partialObject === Value::INVALID) { return Value::INVALID; @@ -73,7 +78,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu return match ($mode) { 'array' => array_merge(...$intersectionValues), 'object' => (object) array_merge(...$intersectionValues), - default => throw new RuntimeException("Invalid mode {$mode}"), + default => throw new SchemaException("Invalid mode {$mode}"), }; } } \ No newline at end of file diff --git a/src/Executor/Handlers/ListHandler.php b/src/Executor/Handlers/ListHandler.php index 3244659..0eaf4db 100644 --- a/src/Executor/Handlers/ListHandler.php +++ b/src/Executor/Handlers/ListHandler.php @@ -8,19 +8,22 @@ use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; +use Override; /** * @implements Handler */ -final class ListHandler implements Handler +final readonly class ListHandler implements Handler { /** - * @param ListNode $node * @return Value|array */ + #[Override] public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): mixed { + assert($node instanceof ListNode); + if (!is_iterable($value)) { return Value::INVALID; } @@ -46,11 +49,13 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E } /** - * @param ListNode $node * @return Value|array */ + #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): array|Value { + assert($node instanceof ListNode); + if (!is_array($value) || !array_is_list($value)) { return Value::INVALID; } diff --git a/src/Executor/Handlers/RecordHandler.php b/src/Executor/Handlers/RecordHandler.php index 4086e2a..0333e7e 100644 --- a/src/Executor/Handlers/RecordHandler.php +++ b/src/Executor/Handlers/RecordHandler.php @@ -10,17 +10,19 @@ use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; +use Override; use stdClass; /** * @implements Handler */ -final class RecordHandler implements Handler +final readonly class RecordHandler implements Handler { - - /** @param RecordNode $node */ + #[Override] public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): stdClass|Value { + assert($node instanceof RecordNode); + if (!is_iterable($value)) { return Value::INVALID; } @@ -51,11 +53,13 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E } /** - * @param RecordNode $node * @return array|Value::INVALID */ + #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): array|Value { + assert($node instanceof RecordNode); + if (!is_array($value)) { return Value::INVALID; } diff --git a/src/Executor/Handlers/StructHandler.php b/src/Executor/Handlers/StructHandler.php index c8a36b4..a90120b 100644 --- a/src/Executor/Handlers/StructHandler.php +++ b/src/Executor/Handlers/StructHandler.php @@ -10,20 +10,34 @@ use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; +use Override; use stdClass; /** * @implements Handler */ -final class StructHandler implements Handler +final readonly class StructHandler implements Handler { - - /** @param StructNode $node */ + /** + * StructNode::$properties is typed to admit ReferencedNode because the ASTOptimizer builds + * structs out of interned references on its way to exportPhpCode(). Those structs are only ever + * exported, never executed: loading the generated file resolves every reference back through the + * registry, so a struct reaching a handler always holds real PropertyNodes. Asserted rather than + * branched on because the check is free in production and a failure would be a library bug. + */ + private const string REFERENCE_INVARIANT = 'A ReferencedNode must be resolved before execution.'; + + #[Override] public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): Value|stdClass { + assert($node instanceof StructNode); + $struct = []; foreach ($node->properties as $propertyNode) { + assert($propertyNode instanceof PropertyNode, self::REFERENCE_INVARIANT); + if (!$propertyNode->propertyType->isOutput()) { continue; } @@ -70,9 +84,11 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E return (object) $struct; } - /** @param StructNode $node */ + #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): mixed { + assert($node instanceof StructNode); + if (!is_array($value) && !$value instanceof stdClass) { $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, @@ -86,6 +102,8 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu $struct = []; foreach ($node->properties as $propertyNode) { + assert($propertyNode instanceof PropertyNode, self::REFERENCE_INVARIANT); + if (!$propertyNode->propertyType->isInput()) { continue; } diff --git a/src/Executor/Handlers/TupleHandler.php b/src/Executor/Handlers/TupleHandler.php index 891d2f3..91fb04d 100644 --- a/src/Executor/Handlers/TupleHandler.php +++ b/src/Executor/Handlers/TupleHandler.php @@ -9,25 +9,28 @@ use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; +use Override; /** * @implements Handler */ -final class TupleHandler implements Handler +final readonly class TupleHandler implements Handler { /** - * @param TupleNode $node * @return Value|array */ + #[Override] public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): Value|array { + assert($node instanceof TupleNode); + if (!is_array($value) && !$value instanceof ArrayAccess) { return Value::INVALID; } $tupleValues = []; - foreach ($node->types as $index => $type) { + foreach ($node->nodes as $index => $type) { $context->enterPath($index); $result = $executor->executeSerialize($type, $value[$index], $context); if ($result === Value::INVALID) { @@ -42,22 +45,24 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E } /** - * @param TupleNode $node * @return Value|array */ + #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): array|Value { + assert($node instanceof TupleNode); + if (!is_array($value) || !array_is_list($value)) { return Value::INVALID; } - $expectedCount = count($node->types); + $expectedCount = count($node->nodes); if (count($value) !== $expectedCount) { return Value::INVALID; } $tupleValues = []; - foreach ($node->types as $index => $type) { + foreach ($node->nodes as $index => $type) { $context->enterPath($index); $result = $executor->executeParse($type, $value[$index], $context); if ($result === Value::INVALID) { diff --git a/src/Executor/Handlers/UnionHandler.php b/src/Executor/Handlers/UnionHandler.php index 34388bc..3e5fad5 100644 --- a/src/Executor/Handlers/UnionHandler.php +++ b/src/Executor/Handlers/UnionHandler.php @@ -11,14 +11,16 @@ use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; +use Override; /** * @implements Handler> */ -final class UnionHandler implements Handler +final readonly class UnionHandler implements Handler { /** @param UnionNode $node */ + #[Override] public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): mixed { // Quick check for nullability. @@ -27,15 +29,16 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E } // Using discriminator for better performance. - if ($node->isDiscriminated()) { - $valueToCheck = $this->extractKeyedValue($node->discriminator, $value); + $discriminator = $node->discriminator; + if ($discriminator !== null) { + $valueToCheck = $this->extractKeyedValue($discriminator, $value); if ($valueToCheck instanceof Value) { $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ 'message' => 'Invalid type for union discriminated type.', 'value' => $value, - 'discriminator' => $node->discriminator, + 'discriminator' => $discriminator, ] )); return Value::INVALID; @@ -53,7 +56,7 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E return Value::INVALID; } - foreach ($node->types as $type) { + foreach ($node->nodes as $type) { $result = $executor->executeSerialize($type, $value, $context); if ($result !== Value::INVALID) { $context->removeCurrentIssues(); @@ -64,21 +67,23 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E } /** @param UnionNode $node */ + #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): mixed { if ($value === null && $node->acceptsNull()) { return null; } - if ($node->isDiscriminated()) { - $valueToCheck = $this->extractKeyedValue($node->discriminator, $value); + $discriminator = $node->discriminator; + if ($discriminator !== null) { + $valueToCheck = $this->extractKeyedValue($discriminator, $value); if ($valueToCheck instanceof Value) { $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ 'message' => 'Invalid type for union discriminated type.', 'value' => $value, - 'discriminator' => $node->discriminator, + 'discriminator' => $discriminator, ] )); return Value::INVALID; @@ -92,7 +97,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu } // ToDo Handle probing context. - foreach ($node->types as $type) { + foreach ($node->nodes as $type) { $result = $executor->executeParse($type, $value, $context); if ($result !== Value::INVALID) { $context->removeCurrentIssues(); diff --git a/src/Executor/SchemaExecutor.php b/src/Executor/SchemaExecutor.php index 9f5ef23..5d97173 100644 --- a/src/Executor/SchemaExecutor.php +++ b/src/Executor/SchemaExecutor.php @@ -30,6 +30,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; +use Override; final readonly class SchemaExecutor implements Executor { @@ -86,6 +87,7 @@ public function serialize(NodeInterface $node, mixed $output, SerializationOptio /** * @internal */ + #[Override] public function executeSerialize(NodeInterface $node, mixed $data, Context $context): mixed { // Constraints are ignored when serializing. @@ -115,6 +117,7 @@ public function executeSerialize(NodeInterface $node, mixed $data, Context $cont /** * @internal */ + #[Override] public function executeParse(NodeInterface $node, mixed $data, Context $context): mixed { if ($node instanceof ConstraintNode) { diff --git a/src/PHPStan/UtilitiesNodeResolver.php b/src/PHPStan/UtilitiesNodeResolver.php index 7105b0b..51401f6 100644 --- a/src/PHPStan/UtilitiesNodeResolver.php +++ b/src/PHPStan/UtilitiesNodeResolver.php @@ -4,6 +4,7 @@ use DateTimeImmutable; +use Override; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -14,7 +15,6 @@ use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Type; use PHPStan\Type\ObjectType; -use PHPStan\Type\ObjectShape; use PHPStan\Type\ObjectShapeType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Constant\ConstantArrayType; @@ -31,11 +31,13 @@ public function __construct(ReflectionProvider $reflectionProvider) $this->reflectionProvider = $reflectionProvider; } + #[Override] public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void { $this->typeNodeResolver = $typeNodeResolver; } + #[Override] public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type { // DateTimeString is the one utility type usable without generics, so it is the only @@ -100,6 +102,7 @@ private function resolveBrandedTypes(string $typeName, GenericTypeNode $typeNode }; } + /** @param 'Omit'|'Pick' $typeName */ private function resolvePickAndOmitUtil(string $typeName, GenericTypeNode $typeNode, NameScope $nameScope): ?Type { $arguments = $typeNode->genericTypes; @@ -214,7 +217,7 @@ private function resolveObjectShapeType(string $type, ObjectShapeType $structTyp $optionalProperties = []; foreach ($structType->getProperties() as $propertyName => $propertyType) { - $keyType = new ConstantStringType($propertyName, false); + $keyType = new ConstantStringType((string)$propertyName, false); $isPropertyInNewObject = match ($type) { 'Pick' => $keysType->isSuperTypeOf($keyType)->yes(), 'Omit' => !$keysType->isSuperTypeOf($keyType)->yes(), diff --git a/src/Parser/ASTOptimizer.php b/src/Parser/ASTOptimizer.php index 04cde4b..1dbf010 100644 --- a/src/Parser/ASTOptimizer.php +++ b/src/Parser/ASTOptimizer.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\Constraint; use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Exceptions\UnknownTypeKeyException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; @@ -21,7 +22,6 @@ use Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry; use Le0daniel\PhpTsBindings\Utils\Arrays; use Le0daniel\PhpTsBindings\Utils\PHPExport; -use RuntimeException; final class ASTOptimizer { @@ -42,7 +42,7 @@ public function __construct( ) { if ($this->registryVariableName === self::KEY_VARIABLE_NAME) { - throw new RuntimeException( + throw new ParserException( "The registry variable cannot be named '" . self::KEY_VARIABLE_NAME . "'; it would collide with the generated factory's key parameter.", ); @@ -63,7 +63,7 @@ private function intern(string $prefix, NodeInterface $node, string $originalTyp $identifier = '#' . $prefix . substr(sha1($exported), 0, $this->idLength); if (isset($this->dedupedNodes[$identifier]) && $this->dedupedNodes[$identifier][1] !== $exported) { - throw new RuntimeException( + throw new ParserException( "Identity hash collision on '{$identifier}'. Increase the idLength of the ASTOptimizer.", ); } @@ -90,7 +90,7 @@ public function optimizeAndWriteToFile(string $fileName, array $nodes): void public function generateOptimizedCode(array $nodes): string { if (array_any(array_keys($nodes), fn(string $key) => str_starts_with($key, '#'))) { - throw new RuntimeException('The keys of the nodes MUST not start with a # character'); + throw new ParserException('The keys of the nodes MUST not start with a # character'); } $this->dedupedNodes = []; @@ -123,10 +123,21 @@ public function generateOptimizedCode(array $nodes): string . "return match (\${$key}) { {$arms} default => throw {$unknownKeyException}::forKey(\${$key}) }; })"; } + private static function asCastableNode(NodeInterface $node): StructNode|ListNode|RecordNode|ReferencedNode + { + assert( + $node instanceof StructNode + || $node instanceof ListNode + || $node instanceof RecordNode + || $node instanceof ReferencedNode + ); + return $node; + } + /** - * @template T of NodeInterface - * @param T $node - * @return T|ReferencedNode + * Returns either an interned reference to the node or a rebuilt node whose children have been + * interned. Not the same concrete type as the input: a PropertyNode comes back as a + * ReferencedNode, and a composite comes back rebuilt. */ private function dedupeNode(NodeInterface $node): NodeInterface { @@ -157,10 +168,10 @@ private function dedupeNode(NodeInterface $node): NodeInterface // Deep optimization if ($node instanceof StructNode) { - return $this->intern('s', new StructNode( - $node->phpType, - array_map($this->dedupeNode(...), $node->properties), - ), (string)$node); + /** @var non-empty-list $properties */ + $properties = array_map($this->dedupeNode(...), $node->properties); + + return $this->intern('s', new StructNode($node->phpType, $properties), (string)$node); } // Composite nodes are rebuilt inline rather than interned: a single use composite costs @@ -168,7 +179,9 @@ private function dedupeNode(NodeInterface $node): NodeInterface return match ($node::class) { ConstraintNode::class => $this->flattenConstraintNode($node), CustomCastingNode::class => new CustomCastingNode( - $this->dedupeNode($node->node), + // A custom cast wraps a struct, list or record, and dedupe returns a reference to + // whichever it was - all four are what CustomCastingNode accepts. + self::asCastableNode($this->dedupeNode($node->node)), $node->fullyQualifiedCastingClass, $node->strategy, ), @@ -179,17 +192,17 @@ private function dedupeNode(NodeInterface $node): NodeInterface $this->dedupeNode($node->node), ), TupleNode::class => new TupleNode( - array_map($this->dedupeNode(...), $node->types), + array_map($this->dedupeNode(...), $node->nodes), ), UnionNode::class => new UnionNode( - array_map($this->dedupeNode(...), $node->types), + array_map($this->dedupeNode(...), $node->nodes), $node->discriminator, $node->discriminatorMap, ), IntersectionNode::class => new IntersectionNode( - array_map($this->dedupeNode(...), $node->types), + array_map($this->dedupeNode(...), $node->nodes), ), - default => throw new RuntimeException('Unknown node type: ' . $node::class), + default => throw new ParserException('Unknown node type: ' . $node::class), }; } diff --git a/src/Parser/AstValidator.php b/src/Parser/AstValidator.php index ca804ba..e6124b5 100644 --- a/src/Parser/AstValidator.php +++ b/src/Parser/AstValidator.php @@ -5,6 +5,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; @@ -15,9 +16,8 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; -use RuntimeException; -final class AstValidator +final readonly class AstValidator { public static function validate(NodeInterface $node): void { @@ -35,9 +35,9 @@ public static function validate(NodeInterface $node): void match ($current::class) { ConstraintNode::class, CustomCastingNode::class, ListNode::class, MetadataNode::class, PropertyNode::class, RecordNode::class => $stack[] = $current->node, - TupleNode::class, IntersectionNode::class, UnionNode::class => array_push($stack, ...$current->types), + TupleNode::class, IntersectionNode::class, UnionNode::class => array_push($stack, ...$current->nodes), StructNode::class => array_push($stack, ... $current->properties), - default => throw new RuntimeException("Unexpected node: " . $current::class), + default => throw new ParserException("Unexpected node: " . $current::class), }; } } diff --git a/src/Parser/Consumers/AliasConsumer.php b/src/Parser/Consumers/AliasConsumer.php index 5382d54..4e62a7c 100644 --- a/src/Parser/Consumers/AliasConsumer.php +++ b/src/Parser/Consumers/AliasConsumer.php @@ -10,6 +10,7 @@ use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Override; use ReflectionException; final readonly class AliasConsumer implements TypeConsumer @@ -20,6 +21,7 @@ public function __construct( { } + #[Override] public function canConsume(ParserState $state): bool { if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { @@ -36,6 +38,7 @@ public function canConsume(ParserState $state): bool /** * @throws InvalidSyntaxException|ReflectionException */ + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $token = $state->current(); diff --git a/src/Parser/Consumers/ArrayConsumer.php b/src/Parser/Consumers/ArrayConsumer.php index e0a879c..71c42d5 100644 --- a/src/Parser/Consumers/ArrayConsumer.php +++ b/src/Parser/Consumers/ArrayConsumer.php @@ -6,8 +6,6 @@ use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; -use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\MixedNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; @@ -16,6 +14,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Utils\Nodes; +use Override; /** * Most complex consumer. It consumes the php array type which is a bit of everything: @@ -32,6 +31,7 @@ public function __construct() { } + #[Override] public function canConsume(ParserState $state): bool { if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { @@ -44,6 +44,7 @@ public function canConsume(ParserState $state): bool /** * @throws InvalidSyntaxException */ + #[Override] public function consume(ParserState $state, TypeParser $parser): RecordNode|ListNode|TupleNode { $type = match ($state->current()->value) { @@ -143,6 +144,10 @@ private function consumeIntegerDeterminedTuple(ParserState $state, TypeParser $p } $state->advance(); + if ($types === []) { + $state->produceSyntaxError('A tuple must declare at least one type.'); + } + return new TupleNode($types); } @@ -181,6 +186,10 @@ private function consumeTuple(ParserState $state, TypeParser $parser): TupleNode } $state->advance(); + if ($types === []) { + $state->produceSyntaxError('A tuple must declare at least one type.'); + } + return new TupleNode($types); } } \ No newline at end of file diff --git a/src/Parser/Consumers/BuiltInLeafConsumer.php b/src/Parser/Consumers/BuiltInLeafConsumer.php index 79b794f..37dffba 100644 --- a/src/Parser/Consumers/BuiltInLeafConsumer.php +++ b/src/Parser/Consumers/BuiltInLeafConsumer.php @@ -16,13 +16,15 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Validators\LengthValidator; -use Le0daniel\PhpTsBindings\Validators\NonEmptyString; -use Le0daniel\PhpTsBindings\Validators\NonFalsyStringValidator; +use Le0daniel\PhpTsBindings\Constraints\Length; +use Le0daniel\PhpTsBindings\Constraints\NonEmptyString; +use Le0daniel\PhpTsBindings\Constraints\NonFalsyString; +use Override; -final class BuiltInLeafConsumer implements TypeConsumer +final readonly class BuiltInLeafConsumer implements TypeConsumer { + #[Override] public function canConsume(ParserState $state): bool { if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { @@ -50,6 +52,7 @@ public function canConsume(ParserState $state): bool /** * @throws InvalidSyntaxException */ + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $token = $state->current(); @@ -64,7 +67,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface 'truthy-string', 'non-falsy-string' => new ConstraintNode( new StringNode(), - [new NonFalsyStringValidator()], + [new NonFalsyString()], ), 'non-empty-string' => new ConstraintNode( new StringNode(), @@ -78,19 +81,19 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface ]), 'positive-int' => new ConstraintNode( new IntNode(), - [new LengthValidator(min: 1, including: true)] + [new Length(min: 1, including: true)] ), 'negative-int' => new ConstraintNode( new IntNode(), - [new LengthValidator(max: -1, including: true)] + [new Length(max: -1, including: true)] ), "non-negative-int" => new ConstraintNode( new IntNode(), - [new LengthValidator(min: 0, including: true)] + [new Length(min: 0, including: true)] ), 'non-positive-int' => new ConstraintNode( new IntNode(), - [new LengthValidator(max: 0, including: true)] + [new Length(max: 0, including: true)] ), 'numeric' => new UnionNode([ new IntNode(), diff --git a/src/Parser/Consumers/ClassConstConsumer.php b/src/Parser/Consumers/ClassConstConsumer.php index 4a081b9..f26f499 100644 --- a/src/Parser/Consumers/ClassConstConsumer.php +++ b/src/Parser/Consumers/ClassConstConsumer.php @@ -9,11 +9,12 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Override; use ReflectionClass; use Throwable; use UnitEnum; -final class ClassConstConsumer implements TypeConsumer +final readonly class ClassConstConsumer implements TypeConsumer { /** @@ -22,6 +23,7 @@ final class ClassConstConsumer implements TypeConsumer * trailing `Foo::` from being claimed here, and keeps this consumer — which runs ahead * of the alias, enum and object consumers — from stealing plain identifiers. */ + #[Override] public function canConsume(ParserState $state): bool { return $state->currentTokenIs(TokenType::IDENTIFIER) @@ -30,6 +32,7 @@ public function canConsume(ParserState $state): bool } /** @throws InvalidSyntaxException */ + #[Override] public function consume(ParserState $state, TypeParser $parser): LiteralNode { $className = $state->current()->value; @@ -37,6 +40,9 @@ public function consume(ParserState $state, TypeParser $parser): LiteralNode $constOrEnumCase = $state->current()->value; $fqcn = $state->context->toFullyQualifiedClassName($className); + if (!class_exists($fqcn) && !interface_exists($fqcn)) { + $state->produceSyntaxError("Class {$fqcn} does not exist."); + } try { $reflection = new ReflectionClass($fqcn); diff --git a/src/Parser/Consumers/DateTimeConsumer.php b/src/Parser/Consumers/DateTimeConsumer.php index f6041cb..4b89e4b 100644 --- a/src/Parser/Consumers/DateTimeConsumer.php +++ b/src/Parser/Consumers/DateTimeConsumer.php @@ -9,9 +9,11 @@ use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\DateTimeNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Override; -final class DateTimeConsumer implements TypeConsumer +final readonly class DateTimeConsumer implements TypeConsumer { + #[Override] public function canConsume(ParserState $state): bool { if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { @@ -30,6 +32,7 @@ public function canConsume(ParserState $state): bool return class_exists($token->value, false) && is_a($token->value, DateTimeInterface::class, true); } + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $token = $state->current(); diff --git a/src/Parser/Consumers/EnumConsumer.php b/src/Parser/Consumers/EnumConsumer.php index a7448d3..c043071 100644 --- a/src/Parser/Consumers/EnumConsumer.php +++ b/src/Parser/Consumers/EnumConsumer.php @@ -10,11 +10,13 @@ use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\MetadataAttributes; use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use Override; use ReflectionClass; use UnitEnum; -final class EnumConsumer implements TypeConsumer +final readonly class EnumConsumer implements TypeConsumer { + #[Override] public function canConsume(ParserState $state): bool { if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { @@ -24,6 +26,7 @@ public function canConsume(ParserState $state): bool return enum_exists($state->context->toFullyQualifiedClassName($state->current()->value)); } + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($state->current()->value); diff --git a/src/Parser/Consumers/IntConsumer.php b/src/Parser/Consumers/IntConsumer.php index cf5056b..949da17 100644 --- a/src/Parser/Consumers/IntConsumer.php +++ b/src/Parser/Consumers/IntConsumer.php @@ -11,10 +11,12 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Validators\LengthValidator; +use Le0daniel\PhpTsBindings\Constraints\Length; +use Override; -final class IntConsumer implements TypeConsumer +final readonly class IntConsumer implements TypeConsumer { + #[Override] public function canConsume(ParserState $state): bool { return $state->currentTokenIs(TokenType::IDENTIFIER, 'int'); @@ -23,6 +25,7 @@ public function canConsume(ParserState $state): bool /** * @throws InvalidSyntaxException */ + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $state->advance(); @@ -59,7 +62,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface return new ConstraintNode( new IntNode(), - [new LengthValidator(min: $min, max: $max, including: true)] + [new Length(min: $min, max: $max, including: true)] ); } } \ No newline at end of file diff --git a/src/Parser/Consumers/InteractsWithGenerics.php b/src/Parser/Consumers/InteractsWithGenerics.php index 34fff80..4dbac34 100644 --- a/src/Parser/Consumers/InteractsWithGenerics.php +++ b/src/Parser/Consumers/InteractsWithGenerics.php @@ -13,7 +13,7 @@ trait InteractsWithGenerics /** * @throws InvalidSyntaxException - * @return NodeInterface[] + * @return list */ private function consumeGenerics(ParserState $state, TypeParser $parser, ?int $min = null, ?int $max = null): array { diff --git a/src/Parser/Consumers/LiteralConsumer.php b/src/Parser/Consumers/LiteralConsumer.php index ab6f225..2128f15 100644 --- a/src/Parser/Consumers/LiteralConsumer.php +++ b/src/Parser/Consumers/LiteralConsumer.php @@ -10,11 +10,13 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Override; -final class LiteralConsumer implements TypeConsumer +final readonly class LiteralConsumer implements TypeConsumer { private const array BOOLEANS = ['true', 'false']; + #[Override] public function canConsume(ParserState $state): bool { $token = $state->current(); @@ -29,6 +31,7 @@ public function canConsume(ParserState $state): bool return $token->isAnyTypeOf(TokenType::STRING, TokenType::FLOAT, TokenType::INT); } + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $token = $state->current(); diff --git a/src/Parser/Consumers/StructConsumer.php b/src/Parser/Consumers/StructConsumer.php index c2581ef..e59d77c 100644 --- a/src/Parser/Consumers/StructConsumer.php +++ b/src/Parser/Consumers/StructConsumer.php @@ -12,10 +12,12 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Override; -final class StructConsumer implements TypeConsumer +final readonly class StructConsumer implements TypeConsumer { + #[Override] public function canConsume(ParserState $state): bool { if ($state->currentTokenIs(TokenType::IDENTIFIER, 'object')) { @@ -32,6 +34,7 @@ public function canConsume(ParserState $state): bool /** * @throws InvalidSyntaxException */ + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $structType = StructPhpType::from($state->current()->value); diff --git a/src/Parser/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Consumers/UserDefinedObjectConsumer.php index 325cb06..08eb6b2 100644 --- a/src/Parser/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Consumers/UserDefinedObjectConsumer.php @@ -10,6 +10,7 @@ use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; @@ -23,14 +24,14 @@ use Le0daniel\PhpTsBindings\Reflection\MetadataAttributes; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; use Le0daniel\PhpTsBindings\Utils\Lists; +use Override; use ReflectionAttribute; use ReflectionClass; use ReflectionException; use ReflectionParameter; use ReflectionProperty; -use RuntimeException; -final class UserDefinedObjectConsumer implements TypeConsumer +final readonly class UserDefinedObjectConsumer implements TypeConsumer { use InteractsWithGenerics; @@ -40,6 +41,7 @@ public function __construct( { } + #[Override] public function canConsume(ParserState $state): bool { if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { @@ -47,14 +49,11 @@ public function canConsume(ParserState $state): bool } $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($state->current()->value); - - try { - $reflectionClass = new ReflectionClass($fullyQualifiedClassName); - } catch (ReflectionException) { + if (!class_exists($fullyQualifiedClassName) && !interface_exists($fullyQualifiedClassName)) { return false; } - return $reflectionClass->isUserDefined(); + return new ReflectionClass($fullyQualifiedClassName)->isUserDefined(); } /** @param ReflectionClass $class */ @@ -96,9 +95,12 @@ private function findCastingStrategy(ReflectionClass $class): ObjectCastStrategy * @throws ReflectionException * @throws InvalidSyntaxException */ + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($state->current()->value); + // canConsume() ran first and only claims names that resolve to a user defined class. + assert(class_exists($fullyQualifiedClassName) || interface_exists($fullyQualifiedClassName)); $state->advance(); $reflectionClass = new ReflectionClass($fullyQualifiedClassName); @@ -131,8 +133,9 @@ private function allowsOptional(ReflectionProperty|ReflectionParameter $param): return true; } - if (!$param->getType()->allowsNull()) { - throw new RuntimeException("Optional parameter must allow null or provide a default value. PHP does not difference between null and undefined."); + $type = $param->getType(); + if ($type === null || !$type->allowsNull()) { + throw new ParserException("Optional parameter must allow null or provide a default value. PHP does not difference between null and undefined."); } return true; @@ -141,24 +144,26 @@ private function allowsOptional(ReflectionProperty|ReflectionParameter $param): /** @param ReflectionClass $reflectionClass */ private function parseNeverStrategy(ReflectionClass $reflectionClass, TypeParser $parser, ParsingContext $context): CustomCastingNode { + $properties = array_map( + fn(ReflectionProperty $property) => new PropertyNode( + $property->getName(), + $this->applyConstraints( + $property, + $parser->parse( + TypeReflector::reflectProperty($property), + $context->descendIntoDeclaringClass($property) + ) + ), + false, + PropertyType::OUTPUT, + ), + $reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC), + ); + return new CustomCastingNode( new StructNode( StructPhpType::ARRAY, - array_map( - fn(ReflectionProperty $property) => new PropertyNode( - $property->getName(), - $this->applyConstraints( - $property, - $parser->parse( - TypeReflector::reflectProperty($property), - $context->descendIntoDeclaringClass($property) - ) - ), - false, - PropertyType::OUTPUT, - ), - $reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC), - ), + $properties, ), $reflectionClass->getName(), ObjectCastStrategy::NEVER, @@ -171,7 +176,7 @@ private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, Ty $properties = []; foreach ($reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { if ($property->isReadOnly() || $property->hasHooks()) { - throw new RuntimeException("Property {$property->name} is not writable"); + throw new ParserException("Property {$property->name} is not writable"); } $properties[] = new PropertyNode( @@ -222,7 +227,14 @@ private function parseConstructorStrategy(ReflectionClass $reflectionClass, Type /** @var array $structProperties */ $structProperties = []; - foreach ($reflectionClass->getConstructor()->getParameters() as $parameter) { + $constructor = $reflectionClass->getConstructor(); + if ($constructor === null) { + throw new ParserException( + "Cannot build {$reflectionClass->getName()} from its constructor: it declares none." + ); + } + + foreach ($constructor->getParameters() as $parameter) { $structProperties[] = new PropertyNode( $parameter->name, $this->applyConstraints( @@ -240,7 +252,9 @@ private function parseConstructorStrategy(ReflectionClass $reflectionClass, Type foreach ($reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { if ($property->isPromoted()) { $index = array_find_key($structProperties, fn(PropertyNode $propertyNode) => $propertyNode->name === $property->getName()); - $structProperties[$index] = $structProperties[$index]->changePropertyType(PropertyType::BOTH); + if ($index !== null) { + $structProperties[$index] = $structProperties[$index]->changePropertyType(PropertyType::BOTH); + } continue; } @@ -259,7 +273,7 @@ private function parseConstructorStrategy(ReflectionClass $reflectionClass, Type } return new CustomCastingNode( - new StructNode(StructPhpType::ARRAY, $structProperties), + new StructNode(StructPhpType::ARRAY, array_values($structProperties)), $reflectionClass->getName(), ObjectCastStrategy::CONSTRUCTOR, ); diff --git a/src/Parser/Consumers/UtilsConsumer.php b/src/Parser/Consumers/UtilsConsumer.php index 7b155ae..e14595a 100644 --- a/src/Parser/Consumers/UtilsConsumer.php +++ b/src/Parser/Consumers/UtilsConsumer.php @@ -26,17 +26,20 @@ use Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; use Le0daniel\PhpTsBindings\Utils\Nodes; +use Override; -final class UtilsConsumer implements TypeConsumer +final readonly class UtilsConsumer implements TypeConsumer { use InteractsWithGenerics; + #[Override] public function canConsume(ParserState $state): bool { return $state->currentTokenIs(TokenType::IDENTIFIER) && in_array($state->current()->value, ['Pick', 'Omit', 'BrandedString', 'BrandedInt', 'DateTimeString'], true); } + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $type = $state->current()->value; @@ -84,13 +87,23 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->produceSyntaxError("Expected struct or custom casting node for picking or omitting"); } - $structNode = $nodeToPickFrom instanceof CustomCastingNode - // For a custom casting node, we pick from the object and create a new struct from it. - ? $nodeToPickFrom->node + if ($nodeToPickFrom instanceof CustomCastingNode) { + // Only a struct has properties to pick from; a custom cast over a list or a record has + // no named shape to narrow. + $castFrom = $nodeToPickFrom->node; + if (!$castFrom instanceof StructNode) { + $state->produceSyntaxError("Cannot pick or omit from a custom casting node that does not wrap a struct"); + } + + // Picking from a castable object produces a new shape, so it is rebuilt as a plain + // object struct with both directions enabled. + $structNode = $castFrom ->filter(fn(PropertyNode $propertyNode): bool => $propertyNode->propertyType->isOutput()) ->map(fn(PropertyNode $propertyType) => $propertyType->changePropertyType(PropertyType::BOTH)) - ->ofType(StructPhpType::OBJECT) - : $nodeToPickFrom; + ->ofType(StructPhpType::OBJECT); + } else { + $structNode = $nodeToPickFrom; + } return $structNode->filter( fn(PropertyNode $property): bool => match ($type) { @@ -128,7 +141,7 @@ private function literalStringValue(ParserState $state, NodeInterface $node, str private function propertiesToPickOrOmit(ParserState $state, NodeInterface $node): array { if ($node instanceof LiteralNode && $node->type === LiteralType::STRING) { - return [(string)$node->value]; + return [$node->stringValue()]; } if (!$node instanceof UnionNode) { @@ -137,11 +150,11 @@ private function propertiesToPickOrOmit(ParserState $state, NodeInterface $node) return array_map(function (NodeInterface $node) use ($state): string { if ($node instanceof LiteralNode && $node->type === LiteralType::STRING) { - return (string)$node->value; + return $node->stringValue(); } $type = $node::class; $state->produceSyntaxError("Expected string literal for picking or omitting, got: {$type}"); - }, $node->types); + }, $node->nodes); } } \ No newline at end of file diff --git a/src/Parser/Consumers/ValueObjectConsumer.php b/src/Parser/Consumers/ValueObjectConsumer.php index 76b61bc..942d905 100644 --- a/src/Parser/Consumers/ValueObjectConsumer.php +++ b/src/Parser/Consumers/ValueObjectConsumer.php @@ -13,6 +13,7 @@ use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\MetadataAttributes; use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use Override; use ReflectionClass; use ReflectionException; @@ -22,8 +23,9 @@ * Registered ahead of EnumConsumer, DateTimeConsumer and UserDefinedObjectConsumer, all of which * would otherwise claim the class first. */ -final class ValueObjectConsumer implements TypeConsumer +final readonly class ValueObjectConsumer implements TypeConsumer { + #[Override] public function canConsume(ParserState $state): bool { if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { @@ -39,6 +41,7 @@ public function canConsume(ParserState $state): bool /** * @throws ReflectionException */ + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($state->current()->value); @@ -53,6 +56,10 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface ); } + if (!class_exists($fullyQualifiedClassName) && !interface_exists($fullyQualifiedClassName)) { + $state->produceSyntaxError("Value object {$fullyQualifiedClassName} does not exist."); + } + $reflectionClass = new ReflectionClass($fullyQualifiedClassName); if ($reflectionClass->isAbstract() || $reflectionClass->isInterface()) { $state->produceSyntaxError( diff --git a/src/Parser/Contracts/WrapsNode.php b/src/Parser/Contracts/WrapsNode.php new file mode 100644 index 0000000..5abd5aa --- /dev/null +++ b/src/Parser/Contracts/WrapsNode.php @@ -0,0 +1,10 @@ + + */ + public array $nodes { + get; + } +} \ No newline at end of file diff --git a/src/Parser/Data/ParsingContext.php b/src/Parser/Data/ParsingContext.php index cdaf4fc..9bc42fa 100644 --- a/src/Parser/Data/ParsingContext.php +++ b/src/Parser/Data/ParsingContext.php @@ -3,13 +3,13 @@ namespace Le0daniel\PhpTsBindings\Parser\Data; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Reflection\FileReflector; use Le0daniel\PhpTsBindings\Utils; use ReflectionClass; use ReflectionException; use ReflectionParameter; use ReflectionProperty; -use RuntimeException; /** * @phpstan-type ImportedType = array{className: string, typeName: string} @@ -18,7 +18,7 @@ { /** * @param string|null $namespace - * @param array $usedNamespaceMap + * @param array $usedNamespaceMap * @param array $localTypes * @param array $importedTypes * @param array $generics @@ -34,6 +34,11 @@ public function __construct( { } + /** + * Given an identifier, returns the fully qualified class name without leading backslash. + * @param string $className + * @return string + */ public function toFullyQualifiedClassName(string $className): string { return Utils\Namespaces::toFullyQualifiedClassName($className, $this->namespace, $this->usedNamespaceMap); @@ -55,12 +60,12 @@ public function isLocalType(string $typeName): bool } /** - * @throws RuntimeException + * @throws ParserException */ public function getLocalTypeDefinition(string $typeName): string { if (!$this->isLocalType($typeName)) { - throw new RuntimeException("Type definition for {$typeName} not found"); + throw new ParserException("Type definition for {$typeName} not found"); } return $this->localTypes[$typeName]; @@ -78,7 +83,7 @@ public function isImportedType(string $typeName): bool public function getImportedTypeInfo(string $typeName): array { if (!$this->isImportedType($typeName)) { - throw new RuntimeException("Type definition for {$typeName} not found"); + throw new ParserException("Type definition for {$typeName} not found"); } return $this->importedTypes[$typeName]; @@ -86,13 +91,15 @@ public function getImportedTypeInfo(string $typeName): array public function descendIntoDeclaringClass(ReflectionProperty|ReflectionParameter $property): self { - // Declaration is in the same class file. - if ($this->declaredInClass === $property->getDeclaringClass()->getName()) { + // A ReflectionParameter belonging to a closure has no declaring class, so there is nothing + // to descend into and the current context is already the right one. + $declaringClass = $property->getDeclaringClass(); + if ($declaringClass === null || $this->declaredInClass === $declaringClass->getName()) { return $this; } // ToDo: Identify the generics that should be passed down. Currently ignored. - return self::fromReflectionClass($property->getDeclaringClass()); + return self::fromReflectionClass($declaringClass); } /** @@ -101,6 +108,10 @@ public function descendIntoDeclaringClass(ReflectionProperty|ReflectionParameter */ public static function fromClassString(string $classString, array $generics = []): self { + if (!class_exists($classString) && !interface_exists($classString)) { + throw new ParserException("Cannot build a parsing context for unknown class {$classString}."); + } + return self::fromReflectionClass(new ReflectionClass($classString), $generics); } @@ -111,7 +122,14 @@ public static function fromClassString(string $classString, array $generics = [] */ public static function fromReflectionClass(ReflectionClass $class, array $generics = []): self { - $reflector = new FileReflector($class->getFileName()); + $fileName = $class->getFileName(); + if ($fileName === false) { + throw new ParserException( + "Cannot build a parsing context for {$class->getName()}: it is not defined in a file." + ); + } + + $reflector = new FileReflector($fileName); $namespace = $reflector->getNamespace(); $useNamespaceMap = Utils\Namespaces::buildNamespaceAliasMap($reflector->getUsedNamespaces()); @@ -149,7 +167,7 @@ public static function fromFilePath(string $filePath, array $generics = []): sel /** * @param false|string|null $docBlock * @param string|null $namespace - * @param array $usedNamespaces + * @param array $usedNamespaces * @return array */ private static function findFullyQualifiedImportedTypes(null|false|string $docBlock, ?string $namespace, array $usedNamespaces): array @@ -172,7 +190,7 @@ private static function assignGenerics(null|false|string $docBlock, array $gener $expectedCount = count($declaredGenerics); $actualCount = count($generics); - throw new RuntimeException("Number of generics does not match. Expected {$expectedCount} <{$declaredGenericNames}>, got {$actualCount}."); + throw new ParserException("Number of generics does not match. Expected {$expectedCount} <{$declaredGenericNames}>, got {$actualCount}."); } $assignedGenerics = []; diff --git a/src/Parser/Definition/Lexemes.php b/src/Parser/Definition/Lexemes.php index 539de70..8ea6f2e 100644 --- a/src/Parser/Definition/Lexemes.php +++ b/src/Parser/Definition/Lexemes.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Definition; -use RuntimeException; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; /** * Decodes raw lexemes produced by the Lexer into PHP values. @@ -12,7 +12,7 @@ * class is where that raw text becomes a value, and it is the only place in the parser * allowed to make that decision. */ -final class Lexemes +final readonly class Lexemes { private const array ESCAPE_SEQUENCES = [ '\\' => '\\', @@ -97,29 +97,40 @@ static function (array $matches): string { } if ($sequence[0] === 'x' || $sequence[0] === 'X') { - return chr((int)hexdec(substr($sequence, 1))); + return chr(self::toByte((int)hexdec(substr($sequence, 1)))); } if ($sequence[0] === 'u') { - return self::codePointToUtf8((int)hexdec($matches[2])); + return self::codePointToUtf8((int)hexdec($matches[2] ?? '')); } - return chr((int)octdec($sequence)); + // Three octal digits reach 511, which PHP itself truncates to a byte. + return chr(self::toByte((int)octdec($sequence))); }, $string, ); if ($resolved === null) { - throw new RuntimeException('Failed to resolve escape sequences: ' . preg_last_error_msg()); + throw new ParserException('Failed to resolve escape sequences: ' . preg_last_error_msg()); } return $resolved; } + /** + * @return int<0, 255> + */ + private static function toByte(int $value): int + { + /** @var int<0, 255> $byte */ + $byte = $value & 0xFF; + return $byte; + } + private static function codePointToUtf8(int $codePoint): string { if ($codePoint <= 0x7F) { - return chr($codePoint); + return chr(self::toByte($codePoint)); } if ($codePoint <= 0x7FF) { diff --git a/src/Parser/Definition/ParserState.php b/src/Parser/Definition/ParserState.php index 4a5e62a..7699b1b 100644 --- a/src/Parser/Definition/ParserState.php +++ b/src/Parser/Definition/ParserState.php @@ -2,13 +2,12 @@ namespace Le0daniel\PhpTsBindings\Parser\Definition; -use Iterator; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Lexer\SourceLocation; use Le0daniel\PhpTsBindings\Parser\Lexer\Token; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; -use RuntimeException; use Throwable; /** @@ -19,9 +18,11 @@ * meaningful tokens only. Tokens keep their absolute byte offsets into $input, so dropping * whitespace does not disturb error rendering. * - * @implements Iterator + * Deliberately not an Iterator: consumers drive the cursor explicitly through advance(), peek() and + * currentTokenIs(). An Iterator would have offered a second way to move that does not enforce + * canAdvance(), so a foreach could walk off the end of a stream the parser considers exhausted. */ -final class ParserState implements Iterator +final class ParserState { private int $currentIndex = 0; private readonly int $count; @@ -46,7 +47,7 @@ public function __construct( // The Lexer always terminates the stream with EOF, which is never whitespace. if ($significant === []) { - throw new RuntimeException('The token stream must contain at least one significant token.'); + throw new ParserException('The token stream must contain at least one significant token.'); } $this->tokens = $significant; @@ -95,31 +96,11 @@ public function canAdvance(int $amount = 1): bool public function advance(int $amount = 1): void { if (!$this->canAdvance($amount)) { - throw new RuntimeException('Cannot advance past end of token'); + throw new ParserException('Cannot advance past end of token'); } $this->currentIndex += $amount; } - public function next(): void - { - $this->currentIndex++; - } - - public function key(): int - { - return $this->currentIndex; - } - - public function valid(): bool - { - return $this->currentIndex < $this->count; - } - - public function rewind(): void - { - $this->currentIndex = 0; - } - public function highlightCurrentToken(): string { $token = $this->current(); diff --git a/src/Parser/Exceptions/InvalidSyntaxException.php b/src/Parser/Exceptions/InvalidSyntaxException.php index 7607775..898ce56 100644 --- a/src/Parser/Exceptions/InvalidSyntaxException.php +++ b/src/Parser/Exceptions/InvalidSyntaxException.php @@ -2,9 +2,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Exceptions; -use Exception; - -final class InvalidSyntaxException extends Exception +final class InvalidSyntaxException extends ParserException { } \ No newline at end of file diff --git a/src/Parser/Exceptions/ParserException.php b/src/Parser/Exceptions/ParserException.php new file mode 100644 index 0000000..5716245 --- /dev/null +++ b/src/Parser/Exceptions/ParserException.php @@ -0,0 +1,17 @@ +type, $types, true); } + #[Override] public function __toString(): string { return $this->value; diff --git a/src/Parser/Nodes/ConstraintNode.php b/src/Parser/Nodes/ConstraintNode.php index afd32d2..d67b993 100644 --- a/src/Parser/Nodes/ConstraintNode.php +++ b/src/Parser/Nodes/ConstraintNode.php @@ -5,9 +5,11 @@ use Le0daniel\PhpTsBindings\Executor\Contracts\ExecutionContext; use Le0daniel\PhpTsBindings\Parser\Contracts\Constraint; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNode; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; -final readonly class ConstraintNode implements NodeInterface +final readonly class ConstraintNode implements NodeInterface, WrapsNode { /** * @param NodeInterface $node @@ -25,6 +27,7 @@ public function areConstraintsFulfilled(mixed $value, ExecutionContext $context) return array_all($this->constraints, fn(Constraint $constraint) => $constraint->validate($value, $context)); } + #[Override] public function __toString(): string { if (empty($this->constraints)) { @@ -39,6 +42,7 @@ public function __toString(): string return "{$this->node} & {$names}"; } + #[Override] public function exportPhpCode(): string { if (empty($this->constraints)) { diff --git a/src/Parser/Nodes/CustomCastingNode.php b/src/Parser/Nodes/CustomCastingNode.php index f7dda19..5b6b4e7 100644 --- a/src/Parser/Nodes/CustomCastingNode.php +++ b/src/Parser/Nodes/CustomCastingNode.php @@ -3,10 +3,12 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; -final readonly class CustomCastingNode implements NodeInterface +final readonly class CustomCastingNode implements NodeInterface, WrapsNode { public function __construct( public StructNode|ListNode|RecordNode|ReferencedNode $node, @@ -16,11 +18,13 @@ public function __construct( { } + #[Override] public function __toString(): string { return "{$this->fullyQualifiedCastingClass}@{$this->strategy->name}({$this->node})"; } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); diff --git a/src/Parser/Nodes/Data/LiteralType.php b/src/Parser/Nodes/Data/LiteralType.php index 07341c2..d21369b 100644 --- a/src/Parser/Nodes/Data/LiteralType.php +++ b/src/Parser/Nodes/Data/LiteralType.php @@ -2,6 +2,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes\Data; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; enum LiteralType: string { case ENUM_CASE = 'enum-case'; @@ -20,7 +21,7 @@ public static function identifyPrimitiveTypeValue(mixed $value): LiteralType 'boolean' => LiteralType::BOOL, 'NULL' => LiteralType::NULL, 'string' => LiteralType::STRING, - default => throw new \InvalidArgumentException("Unsupported type: {$nativeGetType}"), + default => throw new ParserException("Unsupported type: {$nativeGetType}"), }; } } diff --git a/src/Parser/Nodes/IntersectionNode.php b/src/Parser/Nodes/IntersectionNode.php index a544082..ba04cad 100644 --- a/src/Parser/Nodes/IntersectionNode.php +++ b/src/Parser/Nodes/IntersectionNode.php @@ -2,46 +2,51 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; -use InvalidArgumentException; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNodes; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Utils\Nodes; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; -final readonly class IntersectionNode implements NodeInterface, ValidatableNode +final readonly class IntersectionNode implements NodeInterface, ValidatableNode, WrapsNodes { /** - * @param list $types + * @param list $nodes */ public function __construct( - public array $types, + public array $nodes, ) { } + #[Override] public function __toString(): string { return implode( ',', - $this->types + $this->nodes ); } + #[Override] public function validate(): void { - if (!Nodes::areAllNodesOfSameStructType($this->types)) { - throw new InvalidArgumentException("All nodes need to be of the same struct type."); + if (!Nodes::areAllNodesOfSameStructType($this->nodes)) { + throw new ParserException("All nodes need to be of the same struct type."); } - if (count($this->types) < 2) { - throw new InvalidArgumentException("An intersection must be between at least two struct nodes."); + if (count($this->nodes) < 2) { + throw new ParserException("An intersection must be between at least two struct nodes."); } } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute($this::class); - $nodes = PHPExport::exportArray($this->types); + $nodes = PHPExport::exportArray($this->nodes); return "new {$className}({$nodes})"; } } \ No newline at end of file diff --git a/src/Parser/Nodes/Leaf/BoolNode.php b/src/Parser/Nodes/Leaf/BoolNode.php index 6dc67e7..9f73bb0 100644 --- a/src/Parser/Nodes/Leaf/BoolNode.php +++ b/src/Parser/Nodes/Leaf/BoolNode.php @@ -7,31 +7,37 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; final readonly class BoolNode implements NodeInterface, LeafNode, Coercible { use RejectsInvalidType; + #[Override] public function __toString(): string { return 'bool'; } + #[Override] public function exportPhpCode(): string { return 'new ' . PHPExport::absolute(self::class) . '()'; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): mixed { return is_bool($value) ? $value : $this->invalidType('bool', $value, $context); } + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { return is_bool($value) ? $value : $this->invalidType('bool', $value, $context); } + #[Override] public function coerce(mixed $value): mixed { return match ($value) { diff --git a/src/Parser/Nodes/Leaf/DateTimeNode.php b/src/Parser/Nodes/Leaf/DateTimeNode.php index b55ac13..27a366f 100644 --- a/src/Parser/Nodes/Leaf/DateTimeNode.php +++ b/src/Parser/Nodes/Leaf/DateTimeNode.php @@ -11,6 +11,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; use Throwable; final readonly class DateTimeNode implements NodeInterface, LeafNode @@ -26,11 +27,13 @@ public function __construct( { } + #[Override] public function __toString(): string { return $this->dateTimeClass . "<{$this->format}>"; } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); @@ -41,6 +44,7 @@ public function exportPhpCode(): string return "new {$className}({$dateTimeClass}::class{$format})"; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): DateTimeInterface|Value { if (!is_string($value)) { @@ -82,6 +86,7 @@ public function parseValue(mixed $value, ExecutionContext $context): DateTimeInt /** * @return string|Value::INVALID */ + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): string|Value { if (!$value instanceof DateTimeInterface) { diff --git a/src/Parser/Nodes/Leaf/EnumNode.php b/src/Parser/Nodes/Leaf/EnumNode.php index 5fe1496..120127d 100644 --- a/src/Parser/Nodes/Leaf/EnumNode.php +++ b/src/Parser/Nodes/Leaf/EnumNode.php @@ -9,6 +9,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; use UnitEnum; final class EnumNode implements NodeInterface, LeafNode @@ -25,11 +26,13 @@ public function __construct( { } + #[Override] public function __toString(): string { return "enum<{$this->enumClassName}>"; } + #[Override] public function exportPhpCode(): string { $enumClass = PHPExport::absolute($this->enumClassName); @@ -37,6 +40,7 @@ public function exportPhpCode(): string return "new {$className}({$enumClass}::class)"; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): UnitEnum|Value { /** ToDo: Error handling */ @@ -71,6 +75,7 @@ public function parseValue(mixed $value, ExecutionContext $context): UnitEnum|Va return Value::INVALID; } + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { if (!is_a($value, $this->enumClassName)) { diff --git a/src/Parser/Nodes/Leaf/FloatNode.php b/src/Parser/Nodes/Leaf/FloatNode.php index 81a0173..9402ab5 100644 --- a/src/Parser/Nodes/Leaf/FloatNode.php +++ b/src/Parser/Nodes/Leaf/FloatNode.php @@ -7,21 +7,25 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; final readonly class FloatNode implements NodeInterface, LeafNode, Coercible { use RejectsInvalidType; + #[Override] public function __toString(): string { return 'float'; } + #[Override] public function exportPhpCode(): string { return 'new ' . PHPExport::absolute(self::class) . '()'; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): mixed { return is_float($value) || is_int($value) @@ -29,6 +33,7 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed : $this->invalidType('float', $value, $context); } + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { return is_numeric($value) @@ -36,6 +41,7 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed : $this->invalidType('float', $value, $context); } + #[Override] public function coerce(mixed $value): mixed { return filter_var($value, FILTER_VALIDATE_INT) !== false || filter_var($value, FILTER_VALIDATE_FLOAT) !== false diff --git a/src/Parser/Nodes/Leaf/IntNode.php b/src/Parser/Nodes/Leaf/IntNode.php index 4835390..184d9bc 100644 --- a/src/Parser/Nodes/Leaf/IntNode.php +++ b/src/Parser/Nodes/Leaf/IntNode.php @@ -7,31 +7,37 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; final readonly class IntNode implements NodeInterface, LeafNode, Coercible { use RejectsInvalidType; + #[Override] public function __toString(): string { return 'int'; } + #[Override] public function exportPhpCode(): string { return 'new ' . PHPExport::absolute(self::class) . '()'; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): mixed { return is_int($value) ? $value : $this->invalidType('int', $value, $context); } + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { return is_int($value) ? $value : $this->invalidType('int', $value, $context); } + #[Override] public function coerce(mixed $value): mixed { return filter_var($value, FILTER_VALIDATE_INT) !== false diff --git a/src/Parser/Nodes/Leaf/LiteralNode.php b/src/Parser/Nodes/Leaf/LiteralNode.php index 2efa761..ad6694a 100644 --- a/src/Parser/Nodes/Leaf/LiteralNode.php +++ b/src/Parser/Nodes/Leaf/LiteralNode.php @@ -9,13 +9,19 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\Coercible; use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; use UnitEnum; final readonly class LiteralNode implements NodeInterface, LeafNode, Coercible { /** + * $type and $value must agree; every method below reads one to interpret the other. Checked here + * rather than trusted, because a mismatch is constructible - `new LiteralNode(ENUM_CASE, 'x')` + * used to build fine and then fail much later, while reading ->name off a string. + * * @param string|bool|int|float|null|UnitEnum $value */ public function __construct( @@ -23,29 +29,70 @@ public function __construct( public mixed $value, ) { + $agrees = match ($type) { + LiteralType::ENUM_CASE => $value instanceof UnitEnum, + LiteralType::STRING => is_string($value), + LiteralType::INT => is_int($value), + LiteralType::FLOAT => is_float($value), + LiteralType::BOOL => is_bool($value), + LiteralType::NULL => $value === null, + }; + + if (!$agrees) { + throw new ParserException( + "Literal of type {$type->value} cannot hold a " . get_debug_type($value) . '.' + ); + } + } + + /** + * The value as the ENUM_CASE branch knows it to be. The constructor guarantees the correlation; + * this only makes it visible to the type checker. + */ + private function enumValue(): UnitEnum + { + assert($this->value instanceof UnitEnum); + return $this->value; + } + + /** + * The value of a LiteralType::STRING literal. Callers that have checked $type can read the + * string without re-deriving that fact. + */ + public function stringValue(): string + { + assert($this->type === LiteralType::STRING && is_string($this->value)); + return $this->value; + } + + private function scalarValue(): string|int|float + { + assert(is_string($this->value) || is_int($this->value) || is_float($this->value)); + return $this->value; } + #[Override] public function __toString(): string { return match ($this->type) { LiteralType::BOOL => $this->value ? 'literal' : 'literal', - LiteralType::STRING => "literal<'{$this->value}'>", - // @phpstan-ignore-next-line classConstant.nonObject - LiteralType::ENUM_CASE => "enum-value<{$this->value->name}@" . $this->value::class . ">", + LiteralType::STRING => "literal<'{$this->scalarValue()}'>", + LiteralType::ENUM_CASE => 'enum-value<' . $this->enumValue()->name . '@' . $this->enumValue()::class . '>', LiteralType::NULL => 'literal', - LiteralType::INT => "literal<{$this->value}>", + LiteralType::INT => "literal<{$this->scalarValue()}>", // Rendered via var_export so 1.0 stays distinguishable from 1. LiteralType::FLOAT => 'literal<' . var_export($this->value, true) . '>', }; } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); $type = PHPExport::exportEnumCase($this->type); if ($this->type === LiteralType::ENUM_CASE) { - $enumCase = PHPExport::exportEnumCase($this->value); + $enumCase = PHPExport::exportEnumCase($this->enumValue()); return "new {$className}({$type}, {$enumCase})"; } @@ -53,6 +100,7 @@ public function exportPhpCode(): string return "new {$className}({$type}, {$value})"; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): mixed { if ($this->type !== LiteralType::ENUM_CASE) { @@ -60,7 +108,8 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => "Expected literal value: {$this->value}, got: {$value}", + 'message' => 'Expected literal value: ' . var_export($this->value, true) + . ', got: ' . get_debug_type($value), ] )); return Value::INVALID; @@ -69,19 +118,20 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed return $this->value; } - $name = $this->value->name; - return $value === $name ? $this->value : Value::INVALID; + return $value === $this->enumValue()->name ? $this->value : Value::INVALID; } + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { if ($this->type === LiteralType::ENUM_CASE) { - return $value === $this->value ? $this->value->name : Value::INVALID; + return $value === $this->value ? $this->enumValue()->name : Value::INVALID; } return $value === $this->value ? $this->value : Value::INVALID; } + #[Override] public function coerce(mixed $value): mixed { return match ($this->type) { diff --git a/src/Parser/Nodes/Leaf/MixedNode.php b/src/Parser/Nodes/Leaf/MixedNode.php index c1e1606..c2e6f6b 100644 --- a/src/Parser/Nodes/Leaf/MixedNode.php +++ b/src/Parser/Nodes/Leaf/MixedNode.php @@ -6,24 +6,29 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; final readonly class MixedNode implements NodeInterface, LeafNode { + #[Override] public function __toString(): string { return 'mixed'; } + #[Override] public function exportPhpCode(): string { return 'new ' . PHPExport::absolute(self::class) . '()'; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): mixed { return $value; } + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { return $value; diff --git a/src/Parser/Nodes/Leaf/NullNode.php b/src/Parser/Nodes/Leaf/NullNode.php index de83a6e..3ff88e0 100644 --- a/src/Parser/Nodes/Leaf/NullNode.php +++ b/src/Parser/Nodes/Leaf/NullNode.php @@ -6,26 +6,31 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; final readonly class NullNode implements NodeInterface, LeafNode { use RejectsInvalidType; + #[Override] public function __toString(): string { return 'null'; } + #[Override] public function exportPhpCode(): string { return 'new ' . PHPExport::absolute(self::class) . '()'; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): mixed { return is_null($value) ? $value : $this->invalidType('null', $value, $context); } + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { return is_null($value) ? null : $this->invalidType('null', $value, $context); diff --git a/src/Parser/Nodes/Leaf/StringNode.php b/src/Parser/Nodes/Leaf/StringNode.php index 288bdc0..733206e 100644 --- a/src/Parser/Nodes/Leaf/StringNode.php +++ b/src/Parser/Nodes/Leaf/StringNode.php @@ -9,6 +9,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; use Stringable; use Throwable; @@ -16,21 +17,25 @@ { use RejectsInvalidType; + #[Override] public function __toString(): string { return 'string'; } + #[Override] public function exportPhpCode(): string { return 'new ' . PHPExport::absolute(self::class) . '()'; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): mixed { return is_string($value) ? $value : $this->invalidType('string', $value, $context); } + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { try { @@ -47,6 +52,7 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed } } + #[Override] public function coerce(mixed $value): mixed { return (string) $value; diff --git a/src/Parser/Nodes/Leaf/ValueObjectNode.php b/src/Parser/Nodes/Leaf/ValueObjectNode.php index 18e8345..ff2f666 100644 --- a/src/Parser/Nodes/Leaf/ValueObjectNode.php +++ b/src/Parser/Nodes/Leaf/ValueObjectNode.php @@ -13,6 +13,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BackingType; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; use Throwable; /** @@ -34,11 +35,13 @@ public function __construct( { } + #[Override] public function __toString(): string { return "valueObject<{$this->className},{$this->backingType->value}>"; } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); @@ -48,6 +51,7 @@ public function exportPhpCode(): string return "new {$className}({$valueObjectClass}::class, {$backingType})"; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): mixed { if ($this->backingType === BackingType::STRING) { @@ -81,6 +85,7 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed } } + #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { if ($this->backingType === BackingType::STRING) { @@ -110,6 +115,7 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed } } + #[Override] public function coerce(mixed $value): mixed { if ($this->backingType === BackingType::STRING) { @@ -127,7 +133,7 @@ private function invalidBackingTypeIssue(mixed $value): Issue { return new Issue( IssueMessage::INVALID_TYPE, - [ + debugInfo: [ 'message' => "Expected value of type {$this->backingType->value} for {$this->className}, got: " . get_debug_type($value), 'node' => self::class, ], @@ -143,7 +149,7 @@ private function rejectedByFactoryIssue(mixed $value, Throwable $throwable): Iss { return new Issue( IssueMessage::INVALID_TYPE, - [ + debugInfo: [ 'message' => "Value rejected by {$this->className}: {$throwable->getMessage()}", 'node' => self::class, 'value' => $value, @@ -156,7 +162,7 @@ private function notAnInstanceIssue(mixed $value): Issue { return new Issue( IssueMessage::INVALID_TYPE, - [ + debugInfo: [ 'message' => "Expected instance of {$this->className}, got: " . get_debug_type($value), 'node' => self::class, ], diff --git a/src/Parser/Nodes/ListNode.php b/src/Parser/Nodes/ListNode.php index 200a924..e0f22dd 100644 --- a/src/Parser/Nodes/ListNode.php +++ b/src/Parser/Nodes/ListNode.php @@ -3,9 +3,11 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNode; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; -final readonly class ListNode implements NodeInterface +final readonly class ListNode implements NodeInterface, WrapsNode { public function __construct( public NodeInterface $node @@ -13,11 +15,13 @@ public function __construct( { } + #[Override] public function __toString(): string { return "list<{$this->node}>"; } + #[Override] public function exportPhpCode(): string { $classname = PHPExport::absolute(self::class); diff --git a/src/Parser/Nodes/MetadataNode.php b/src/Parser/Nodes/MetadataNode.php index 9f409ef..c48df2f 100644 --- a/src/Parser/Nodes/MetadataNode.php +++ b/src/Parser/Nodes/MetadataNode.php @@ -2,10 +2,13 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; -use InvalidArgumentException; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNodes; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\NamedType; +use Override; /** * Codegen metadata attached to any node: an exported type name and/or a TypeScript brand. @@ -17,7 +20,7 @@ * metadata into a cache, and a metadata-carrying tree stays string-identical to its optimized * form. */ -final readonly class MetadataNode implements NodeInterface, ValidatableNode +final readonly class MetadataNode implements NodeInterface, ValidatableNode, WrapsNode { public function __construct( public NodeInterface $node, @@ -27,26 +30,29 @@ public function __construct( { } + #[Override] public function __toString(): string { return (string) $this->node; } + #[Override] public function exportPhpCode(): string { return $this->node->exportPhpCode(); } + #[Override] public function validate(): void { if ($this->name === null && $this->brand === null) { - throw new InvalidArgumentException( + throw new ParserException( 'MetadataNode without a name or brand is meaningless; use the inner node directly.' ); } if ($this->node instanceof MetadataNode) { - throw new InvalidArgumentException( + throw new ParserException( 'MetadataNode should not be nested.' ); } diff --git a/src/Parser/Nodes/PropertyNode.php b/src/Parser/Nodes/PropertyNode.php index d4c2d07..e996758 100644 --- a/src/Parser/Nodes/PropertyNode.php +++ b/src/Parser/Nodes/PropertyNode.php @@ -3,10 +3,13 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\PropertyType; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use NoDiscard; +use Override; -final readonly class PropertyNode implements NodeInterface +final readonly class PropertyNode implements NodeInterface, WrapsNode { public function __construct( public string $name, @@ -15,22 +18,20 @@ public function __construct( public PropertyType $propertyType = PropertyType::BOTH, ) {} + #[NoDiscard] public function changePropertyType(PropertyType $propertyType): self { return new self($this->name, $this->node, $this->isOptional, $propertyType); } + #[Override] public function __toString(): string { $optional = $this->isOptional ? '?' : ''; return "{$this->name}{$optional}: {$this->node}{$this->propertyType->asString()}"; } - public function changeType(PropertyType $type): self - { - return new self($this->name, $this->node, $this->isOptional, $type); - } - + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); diff --git a/src/Parser/Nodes/RecordNode.php b/src/Parser/Nodes/RecordNode.php index c06deac..1f39cfa 100644 --- a/src/Parser/Nodes/RecordNode.php +++ b/src/Parser/Nodes/RecordNode.php @@ -3,9 +3,11 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNode; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; -final readonly class RecordNode implements NodeInterface +final readonly class RecordNode implements NodeInterface, WrapsNode { /** * @param NodeInterface $node @@ -16,11 +18,13 @@ public function __construct( { } + #[Override] public function __toString(): string { return "arraynode}>"; } + #[Override] public function exportPhpCode(): string { $classname = PHPExport::absolute(self::class); diff --git a/src/Parser/Nodes/ReferencedNode.php b/src/Parser/Nodes/ReferencedNode.php index ce22a9d..0fe2940 100644 --- a/src/Parser/Nodes/ReferencedNode.php +++ b/src/Parser/Nodes/ReferencedNode.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Override; /** * This is used during ast optimization to replace references to other nodes with the actual node. @@ -18,11 +19,13 @@ public function __construct( { } + #[Override] public function exportPhpCode(): string { return "\${$this->registryVariableName}->get('{$this->referenceNode}')"; } + #[Override] public function __toString(): string { return $this->originalTypeString; diff --git a/src/Parser/Nodes/StructNode.php b/src/Parser/Nodes/StructNode.php index c97ea72..d1e8fd6 100644 --- a/src/Parser/Nodes/StructNode.php +++ b/src/Parser/Nodes/StructNode.php @@ -3,16 +3,26 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; use Closure; -use InvalidArgumentException; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNodes; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use NoDiscard; +use Override; -final readonly class StructNode implements NodeInterface, ValidatableNode +final class StructNode implements NodeInterface, ValidatableNode, WrapsNodes { - /** @var non-empty-list */ - public array $properties; + /** @var list */ + public readonly array $properties; + + /** + * Properties are exposed as a node. + */ + public array $nodes { + get => $this->properties; + } /** * Properties are canonically ordered here rather than by a separate pass, so there is only @@ -20,11 +30,11 @@ * parsed one, and lets two declarations of the same shape in different orders share a single * interned registry entry. * - * @param non-empty-list $properties + * @param list $properties */ public function __construct( - public StructPhpType $phpType, - array $properties, + public readonly StructPhpType $phpType, + array $properties, ) { $this->properties = self::canonicalise($properties); @@ -32,8 +42,8 @@ public function __construct( /** - * @param non-empty-list $properties - * @return non-empty-list + * @param list $properties + * @return list */ private static function canonicalise(array $properties): array { @@ -54,37 +64,58 @@ private static function canonicalise(array $properties): array return $properties; } + #[Override] public function validate(): void { if (empty($this->properties)) { - throw new InvalidArgumentException("Cannot create object type with no properties or properties that are not keyed by strings (e.g. ['foo' => 'bar'] is fine, but ['foo'] is not"); + throw new ParserException("Cannot create object type with no properties or properties that are not keyed by strings (e.g. ['foo' => 'bar'] is fine, but ['foo'] is not"); } } /** * @param Closure(PropertyNode): bool $closure - * @return self */ + #[NoDiscard] public function filter(Closure $closure): self { return new self( $this->phpType, - array_values(array_filter($this->properties, $closure)) + array_filter($this->propertyNodes(), $closure) |> array_values(...), ); } + /** + * The properties as everything outside the optimizer sees them. ReferencedNode is admitted by + * $properties because the ASTOptimizer builds structs out of interned references on its way to + * exportPhpCode(); those structs are exported, never reshaped or executed. + * + * @return list + */ + private function propertyNodes(): array + { + $properties = $this->properties; + assert( + array_all($properties, static fn($property) => $property instanceof PropertyNode), + 'A struct holding references cannot be reshaped.', + ); + + /** @var list $properties */ + return $properties; + } + /** * @param Closure(PropertyNode): PropertyNode $closure - * @return self */ + #[NoDiscard] public function map(Closure $closure): self { return new self( $this->phpType, - array_map($closure, $this->properties), + array_map($closure, $this->propertyNodes()), ); } + #[NoDiscard] public function ofType(StructPhpType $type): self { return new self($type, $this->properties); @@ -102,13 +133,15 @@ public function hasProperty(string $name): bool return $this->getProperty($name) !== null; } + #[Override] public function __toString(): string { - $properties = array_map(fn(PropertyNode|ReferencedNode $property) => (string) $property, $this->properties); + $properties = array_map(fn(PropertyNode|ReferencedNode $property) => (string)$property, $this->properties); $imploded = implode(', ', $properties); return "{$this->phpType->value}{{$imploded}}"; } + #[Override] public function exportPhpCode(): string { $exportedProperties = PHPExport::exportArray($this->properties); diff --git a/src/Parser/Nodes/TupleNode.php b/src/Parser/Nodes/TupleNode.php index 78ed646..640a3ed 100644 --- a/src/Parser/Nodes/TupleNode.php +++ b/src/Parser/Nodes/TupleNode.php @@ -2,40 +2,45 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; -use InvalidArgumentException; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNodes; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Utils\Arrays; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; -final readonly class TupleNode implements NodeInterface, ValidatableNode +final readonly class TupleNode implements NodeInterface, ValidatableNode, WrapsNodes { /** - * @param non-empty-list $types + * @param non-empty-list $nodes */ - public function __construct(public array $types) + public function __construct(public array $nodes) { } + #[Override] public function __toString(): string { - $typeString = Arrays::mapWithKeys($this->types, fn(int $key, NodeInterface $type) => "{$key}: {$type}"); + $typeString = Arrays::mapWithKeys($this->nodes, fn(int $key, NodeInterface $type) => "{$key}: {$type}"); $imploded = implode(', ', $typeString); return 'array{' . $imploded . '}'; } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); - $types = array_map(fn(NodeInterface $type) => $type->exportPhpCode(), $this->types); + $types = array_map(fn(NodeInterface $type) => $type->exportPhpCode(), $this->nodes); $imploded = implode(', ', $types); return "new {$className}([{$imploded}])"; } + #[Override] public function validate(): void { - if (empty($this->types)) { - throw new InvalidArgumentException("TupleNode must have at least one type"); + if (empty($this->nodes)) { + throw new ParserException("TupleNode must have at least one type"); } } } \ No newline at end of file diff --git a/src/Parser/Nodes/UnionNode.php b/src/Parser/Nodes/UnionNode.php index 7cf2977..84caa6a 100644 --- a/src/Parser/Nodes/UnionNode.php +++ b/src/Parser/Nodes/UnionNode.php @@ -2,32 +2,34 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; -use InvalidArgumentException; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; +use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNodes; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; /** * @template T of NodeInterface */ -final class UnionNode implements NodeInterface, ValidatableNode +final class UnionNode implements NodeInterface, ValidatableNode, WrapsNodes { private bool $acceptsNull; // Improves the performance of nullable Unions. public function acceptsNull(): bool { - return $this->acceptsNull ??= array_any($this->types, fn(NodeInterface $type) => $type instanceof NullNode); + return $this->acceptsNull ??= array_any($this->nodes, fn(NodeInterface $type) => $type instanceof NullNode); } /** - * @param list $types + * @param list $nodes * @param string|null $discriminator * @param list|null $discriminatorMap */ public function __construct( - public readonly array $types, + public readonly array $nodes, public readonly ?string $discriminator = null, public readonly ?array $discriminatorMap = null, ) @@ -35,16 +37,18 @@ public function __construct( } + #[Override] public function validate(): void { - if (count($this->types) < 2) { - throw new InvalidArgumentException('Cannot create union type with less than 2 types'); + if (count($this->nodes) < 2) { + throw new ParserException('Cannot create union type with less than 2 types'); } } + #[Override] public function __toString(): string { - $types = implode('|', array_map(fn(NodeInterface $type) => (string)$type, $this->types)); + $types = implode('|', array_map(fn(NodeInterface $type) => (string)$type, $this->nodes)); return $this->discriminator === null ? $types @@ -64,15 +68,16 @@ public function getDiscriminatedType(mixed $value): ?NodeInterface $index = array_find_key($this->discriminatorMap, static fn(mixed $typeValue) => $typeValue === $value); if ($index !== null) { - return $this->types[$index]; + return $this->nodes[$index]; } return null; } + #[Override] public function exportPhpCode(): string { $classname = PHPExport::absolute(self::class); - $types = PHPExport::export($this->types); + $types = PHPExport::export($this->nodes); $discriminator = $this->discriminator ? PHPExport::export($this->discriminator) : 'null'; $discriminatorMap = $this->discriminatorMap ? PHPExport::export($this->discriminatorMap) : 'null'; return "new {$classname}({$types}, {$discriminator}, {$discriminatorMap})"; diff --git a/src/Parser/Registry/CachedTypeRegistry.php b/src/Parser/Registry/CachedTypeRegistry.php index 3bbe9f0..87dc4d2 100644 --- a/src/Parser/Registry/CachedTypeRegistry.php +++ b/src/Parser/Registry/CachedTypeRegistry.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeRegistry; use Le0daniel\PhpTsBindings\Parser\Exceptions\UnknownTypeKeyException; +use Override; /** * Lazily instantiates schemas from generated code, memoizing each one. @@ -42,6 +43,7 @@ public function __construct( $this->factory = $factory; } + #[Override] public function get(string $key): NodeInterface { // An unknown key throws before the assignment, so misses are never memoized. diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index da2ceb4..f1ca59f 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -27,6 +27,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; @@ -59,7 +60,7 @@ public function __construct( /** * @param GlobalTypeAliases $globalTypeAliases * @param bool $allowAllObjectCasting - * @return TypeConsumer[] + * @return list */ public static function defaultConsumers( GlobalTypeAliases $globalTypeAliases = new GlobalTypeAliases(), @@ -241,8 +242,8 @@ public function consume(ParserState $state, TokenType ...$stopAt): NodeInterface } /** - * @param list $types - * @return list + * @param non-empty-list $types + * @return non-empty-list */ private function flattenNestedUnionTypes(array $types): array { @@ -250,16 +251,29 @@ private function flattenNestedUnionTypes(array $types): array foreach ($types as $type) { if ($type instanceof UnionNode) { - array_push($flattened, ... $type->types); + array_push($flattened, ... $type->nodes); continue; } $flattened[] = $type; } + /** @var non-empty-list $flattened */ return $flattened; } + /** + * A discriminator has to survive a strict comparison against a value decoded from JSON, which + * rules out floats (precision), enum cases (not wire values) and null (indistinguishable from + * an absent field). + * + * @phpstan-assert-if-true bool|int|string $value + */ + private static function canDiscriminate(mixed $value): bool + { + return is_bool($value) || is_int($value) || is_string($value); + } + /** * @param non-empty-list $types * @return UnionNode @@ -276,8 +290,15 @@ private function checkForDiscriminatedUnion(array $types): UnionNode // Step 1: Find candidate fields from the first type foreach ($firstType->properties as $property) { - if ($property->node instanceof LiteralNode) { - $candidateFields[$property->name] = $property->node->value; + // Discrimination runs on freshly parsed structs; the optimizer's reference holding + // structs are exported, never unioned. + if (!$property instanceof PropertyNode || !$property->node instanceof LiteralNode) { + continue; + } + + $value = $property->node->value; + if (self::canDiscriminate($value)) { + $candidateFields[$property->name] = $value; } } @@ -293,14 +314,15 @@ private function checkForDiscriminatedUnion(array $types): UnionNode $otherProperty = $otherType->getProperty($fieldName); // Check for presence, type, and uniqueness + $otherValue = $otherProperty?->node instanceof LiteralNode ? $otherProperty->node->value : null; if ( - !$otherProperty?->node instanceof LiteralNode || - in_array($otherProperty->node->value, $values, true) + !self::canDiscriminate($otherValue) || + in_array($otherValue, $values, true) ) { $isDiscriminator = false; break; // This is not the discriminator field } - $values[] = $otherProperty->node->value; + $values[] = $otherValue; } if ($isDiscriminator) { diff --git a/src/Reflection/AttributesReflector.php b/src/Reflection/AttributesReflector.php index 2a0cca3..ae68e8d 100644 --- a/src/Reflection/AttributesReflector.php +++ b/src/Reflection/AttributesReflector.php @@ -2,8 +2,8 @@ namespace Le0daniel\PhpTsBindings\Reflection; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use ReflectionAttribute; -use RuntimeException; final readonly class AttributesReflector { @@ -32,27 +32,11 @@ public function getSingleInstance(string $attributeClass): object { $reflection = array_find($this->attributes, fn(ReflectionAttribute $attribute) => $attribute->name === $attributeClass); if (!$reflection) { - throw new RuntimeException("Attribute {$attributeClass} not found"); + throw new ParserException("Attribute {$attributeClass} not found"); } /** @var T */ return $reflection->newInstance(); } - /** - * @template T of object - * @param class-string $attributeClass - * @return list - */ - public function getInstances(string $attributeClass): array - { - $reflections = array_filter( - $this->attributes, - fn(ReflectionAttribute $attribute) => $attribute->name === $attributeClass - ); - - return array_values( - array_map(fn(ReflectionAttribute $attribute) => $attribute->newInstance(), $reflections) - ); - } } \ No newline at end of file diff --git a/src/Reflection/FileReflector.php b/src/Reflection/FileReflector.php index 2c6c1f9..4c76a18 100644 --- a/src/Reflection/FileReflector.php +++ b/src/Reflection/FileReflector.php @@ -2,10 +2,9 @@ namespace Le0daniel\PhpTsBindings\Reflection; -use InvalidArgumentException; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use ReflectionClass; use ReflectionException; -use RuntimeException; final class FileReflector { @@ -13,7 +12,10 @@ final class FileReflector private ?array $tokens = null; /** - * @var array|null + * Keys and values are whatever the file's `use` statements spell; they are not verified to name + * anything that exists, so this is not class-string. + * + * @var array|null */ private ?array $usedNamespaces = null; private ?string $namespace = null; @@ -26,14 +28,14 @@ final class FileReflector /** * @param string $filePath - * @throws InvalidArgumentException + * @throws ParserException */ public function __construct( public readonly string $filePath ) { $realPath = realpath($this->filePath); if ($realPath === false || !is_file($realPath) || !is_readable($realPath)) { - throw new InvalidArgumentException( + throw new ParserException( "File does not exist or is not readable: {$this->filePath}" ); } @@ -56,24 +58,24 @@ public function getUsedNamespaces(): array return $this->usedNamespaces; } - $this->ensureTokensAreParsed(); + $tokens = $this->tokens(); $namespaces = []; - $numTokens = count($this->tokens); + $numTokens = count($tokens); for ($i = 0; $i < $numTokens; $i++) { - $token = $this->tokens[$i]; + $token = $tokens[$i]; if (!is_array($token) || $token[0] !== T_USE) { continue; } // Skip `use function` and `use const` - $nextToken = $this->peekNextSignificantToken($i, $numTokens); + $nextToken = self::peekNextSignificantToken($tokens, $i, $numTokens); if ($nextToken && in_array($nextToken[0], [T_FUNCTION, T_CONST], true)) { continue; } - [$fullyQualifiedClassName, $alias, $i] = $this->parseUseStatement($i, $numTokens); + [$fullyQualifiedClassName, $alias, $i] = self::parseUseStatement($tokens, $i, $numTokens); if ($fullyQualifiedClassName) { if ($alias) { @@ -98,8 +100,7 @@ public function getNamespace(): ?string return $this->namespace; } - $this->ensureTokensAreParsed(); - $this->namespace = $this->findNamespaceInTokens(); + $this->namespace = self::findNamespaceInTokens($this->tokens()); $this->namespaceParsed = true; return $this->namespace; @@ -110,7 +111,7 @@ public function getNamespace(): ?string * and returns a ReflectionClass instance for it. * * @return ReflectionClass|never - * @throws RuntimeException If no class-like structure is found or if the class cannot be loaded. + * @throws ParserException If no class-like structure is found or if the class cannot be loaded. * @throws ReflectionException If the class is loaded but cannot be reflected. */ public function getDeclaredClass(): ReflectionClass @@ -119,13 +120,11 @@ public function getDeclaredClass(): ReflectionClass return $this->declaredClass; } - $this->ensureTokensAreParsed(); - $namespace = $this->getNamespace(); - $className = $this->findClassNameInTokens(); + $className = self::findClassNameInTokens($this->tokens()); if ($className === null) { - throw new RuntimeException( + throw new ParserException( "No class, interface, trait, or enum found in file: {$this->filePath}" ); } @@ -139,34 +138,44 @@ public function getDeclaredClass(): ReflectionClass } if (!class_exists($fullyQualifiedClassName, false) && !interface_exists($fullyQualifiedClassName, false) && !trait_exists($fullyQualifiedClassName, false)) { - throw new RuntimeException("Failed to load class {$fullyQualifiedClassName} from file {$this->filePath}"); + throw new ParserException("Failed to load class {$fullyQualifiedClassName} from file {$this->filePath}"); } return $this->declaredClass = new ReflectionClass($fullyQualifiedClassName); } - private function ensureTokensAreParsed(): void + /** + * Returns the tokens rather than only populating $tokens, so callers hold a non-null list and + * every read below is provably safe instead of relying on having called this first. + * + * @return list + */ + private function tokens(): array { - if ($this->tokens === null) { - $content = file_get_contents($this->filePath); - if ($content === false) { - throw new RuntimeException( - "Could not read file content: {$this->filePath}" - ); - } - $this->tokens = token_get_all($content); + if ($this->tokens !== null) { + return $this->tokens; } + + $content = file_get_contents($this->filePath); + if ($content === false) { + throw new ParserException( + "Could not read file content: {$this->filePath}" + ); + } + + return $this->tokens = token_get_all($content); } /** + * @param list $tokens * @return string|null The found namespace name or null. */ - private function findNamespaceInTokens(): ?string + private static function findNamespaceInTokens(array $tokens): ?string { - $count = count($this->tokens); + $count = count($tokens); for ($i = 0; $i < $count; $i++) { - if ($this->tokens[$i][0] === T_NAMESPACE) { - $nextToken = $this->peekNextSignificantToken($i, $count); + if ($tokens[$i][0] === T_NAMESPACE) { + $nextToken = self::peekNextSignificantToken($tokens, $i, $count); if ($nextToken && in_array($nextToken[0], [T_STRING, T_NAME_QUALIFIED], true)) { return $nextToken[1]; } @@ -176,19 +185,20 @@ private function findNamespaceInTokens(): ?string } /** + * @param list $tokens * @return string|null The found class name or null. */ - private function findClassNameInTokens(): ?string + private static function findClassNameInTokens(array $tokens): ?string { - $count = count($this->tokens); + $count = count($tokens); for ($i = 0; $i < $count; $i++) { - $token = $this->tokens[$i]; + $token = $tokens[$i]; if (!is_array($token)) { continue; } if (in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM])) { - $nextToken = $this->peekNextSignificantToken($i, $count); + $nextToken = self::peekNextSignificantToken($tokens, $i, $count); if ($nextToken && $nextToken[0] === T_STRING) { return $nextToken[1]; } @@ -198,14 +208,13 @@ private function findClassNameInTokens(): ?string } /** - * @param int $currentIndex - * @param int $maxIndex + * @param list $tokens * @return (array{int, string, int})|null */ - private function peekNextSignificantToken(int $currentIndex, int $maxIndex): ?array + private static function peekNextSignificantToken(array $tokens, int $currentIndex, int $maxIndex): ?array { for ($i = $currentIndex + 1; $i < $maxIndex; $i++) { - $token = $this->tokens[$i]; + $token = $tokens[$i]; if (is_array($token) && $token[0] !== T_WHITESPACE) { return $token; } @@ -214,18 +223,17 @@ private function peekNextSignificantToken(int $currentIndex, int $maxIndex): ?ar } /** - * @param int $startIndex - * @param int $maxIndex + * @param list $tokens * @return array{string, string|null, int} */ - private function parseUseStatement(int $startIndex, int $maxIndex): array + private static function parseUseStatement(array $tokens, int $startIndex, int $maxIndex): array { $fullyQualifiedClassname = ''; $alias = null; $i = $startIndex + 1; while ($i < $maxIndex) { - $token = $this->tokens[$i]; + $token = $tokens[$i]; if ($token === ';') { break; } @@ -236,7 +244,7 @@ private function parseUseStatement(int $startIndex, int $maxIndex): array $fullyQualifiedClassname = $token[1]; break; case T_AS: - $aliasToken = $this->peekNextSignificantToken($i, $maxIndex); + $aliasToken = self::peekNextSignificantToken($tokens, $i, $maxIndex); if ($aliasToken && $aliasToken[0] === T_STRING) { $alias = $aliasToken[1]; } diff --git a/src/Reflection/MetadataAttributes.php b/src/Reflection/MetadataAttributes.php index c7786f1..df15071 100644 --- a/src/Reflection/MetadataAttributes.php +++ b/src/Reflection/MetadataAttributes.php @@ -15,7 +15,7 @@ * MetadataNode when any is present. See MetadataNode: pure code generation metadata, zero * runtime effect. */ -final class MetadataAttributes +final readonly class MetadataAttributes { /** * @param ReflectionClass $reflectionClass diff --git a/src/Reflection/TypeReflector.php b/src/Reflection/TypeReflector.php index 977ce6c..436ac10 100644 --- a/src/Reflection/TypeReflector.php +++ b/src/Reflection/TypeReflector.php @@ -2,19 +2,19 @@ namespace Le0daniel\PhpTsBindings\Reflection; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Utils\Regexes; use ReflectionFunction; use ReflectionMethod; use ReflectionParameter; use ReflectionProperty; -use RuntimeException; final readonly class TypeReflector { public static function reflectProperty(ReflectionProperty $property): string { if (!$property->getType()) { - throw new RuntimeException("No type defined."); + throw new ParserException("No type defined."); } if ($property->getDocComment() && $type = Regexes::findFirstVarDeclaration($property->getDocComment())) { @@ -36,7 +36,7 @@ public static function reflectProperty(ReflectionProperty $property): string public static function reflectParameter(ReflectionParameter $parameter): string { if (!$parameter->getType()) { - throw new RuntimeException("No type defined."); + throw new ParserException("No type defined."); } $declaringDocBlock = $parameter->getDeclaringFunction()->getDocComment(); @@ -52,7 +52,7 @@ public static function reflectParameter(ReflectionParameter $parameter): string public static function reflectReturnType(ReflectionFunction|ReflectionMethod $returnable): string { if (!$returnable->hasReturnType()) { - throw new RuntimeException("No return type defined."); + throw new ParserException("No return type defined."); } $docBlock = $returnable->getDocComment(); diff --git a/src/Server/Client/InteractsWithToasts.php b/src/Server/Client/InteractsWithToasts.php index 38d3b1e..dea8380 100644 --- a/src/Server/Client/InteractsWithToasts.php +++ b/src/Server/Client/InteractsWithToasts.php @@ -4,6 +4,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Toast; use Le0daniel\PhpTsBindings\Server\Data\ToastType; +use Override; /** * Implements the per level toast helpers of the Client contract in terms of toast(), so that @@ -13,26 +14,31 @@ trait InteractsWithToasts { abstract public function toast(Toast $toast): void; + #[Override] public function success(string $message): void { $this->toast(new Toast(ToastType::SUCCESS, $message)); } + #[Override] public function error(string $message): void { $this->toast(new Toast(ToastType::ERROR, $message)); } + #[Override] public function warning(string $message): void { $this->toast(new Toast(ToastType::WARNING, $message)); } + #[Override] public function alert(string $message): void { $this->toast(new Toast(ToastType::ALERT, $message)); } + #[Override] public function info(string $message): void { $this->toast(new Toast(ToastType::INFO, $message)); diff --git a/src/Server/Client/NullClient.php b/src/Server/Client/NullClient.php index 4c03e10..0669a81 100644 --- a/src/Server/Client/NullClient.php +++ b/src/Server/Client/NullClient.php @@ -4,22 +4,26 @@ use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Server\Data\Toast; +use Override; use UnitEnum; -final class NullClient implements Client +final readonly class NullClient implements Client { use InteractsWithToasts; + #[Override] public function toast(Toast $toast): void { } + #[Override] public function redirect(string $url, bool $reload = false): void { } + #[Override] public function invalidate(UnitEnum|string $namespace, ...$key): void { diff --git a/src/Server/Client/OperationSPAClient.php b/src/Server/Client/OperationSPAClient.php index 929ec01..97228cf 100644 --- a/src/Server/Client/OperationSPAClient.php +++ b/src/Server/Client/OperationSPAClient.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Toast; use Le0daniel\PhpTsBindings\Utils\Dicts; use Le0daniel\PhpTsBindings\Utils\Strings; +use Override; use UnitEnum; /** @@ -25,12 +26,14 @@ final class OperationSPAClient implements SerializableClient /** @var list>|null */ private ?array $invalidations = null; + #[Override] public function toast(Toast $toast): void { $this->toasts ??= []; $this->toasts[] = $toast; } + #[Override] public function redirect(string $url, bool $reload = false): void { $this->redirect = [ @@ -39,35 +42,37 @@ public function redirect(string $url, bool $reload = false): void ]; } + #[Override] public function invalidate(UnitEnum|string $namespace, ...$key): void { $this->invalidations ??= []; - $this->invalidations[] = [ - Strings::toString($namespace), - ... $key, - ]; + $this->invalidations[] = [Strings::toString($namespace), ...$key] |> array_values(...); } /** * @return array{redirect?: Redirect, toasts?: list, invalidations?: list>, type: 'operations-spa'}|null */ + #[Override] public function serializeToArray(): array|null { - $data = Dicts::filterNullValues([ - 'redirect' => $this->redirect, - 'toasts' => $this->toasts === null - ? null - : array_map(fn(Toast $toast): array => $toast->toArray(), $this->toasts), - 'invalidations' => $this->invalidations, - ]); - - if (empty($data)) { + if ($this->redirect === null && $this->toasts === null && $this->invalidations === null) { return null; } - return [ - ...$data, - 'type' => 'operations-spa', - ]; + // Assembled key by key, in the order the wire shape declares, so the emitted payload is + // byte comparable and the shape stays provable. + $payload = []; + if ($this->redirect !== null) { + $payload['redirect'] = $this->redirect; + } + if ($this->toasts !== null) { + $payload['toasts'] = array_map(fn(Toast $toast): array => $toast->toArray(), $this->toasts); + } + if ($this->invalidations !== null) { + $payload['invalidations'] = $this->invalidations; + } + + $payload['type'] = 'operations-spa'; + return $payload; } } diff --git a/src/Server/Data/Definition.php b/src/Server/Data/Definition.php index 0ab526c..f427bc9 100644 --- a/src/Server/Data/Definition.php +++ b/src/Server/Data/Definition.php @@ -5,8 +5,9 @@ use Le0daniel\PhpTsBindings\Contracts\ExportableToPhpCode; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; -final class Definition implements ExportableToPhpCode +final readonly class Definition implements ExportableToPhpCode { /** * @param OperationType $type @@ -32,6 +33,7 @@ public function fullyQualifiedName(): string return "{$this->namespace}.{$this->name}"; } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); diff --git a/src/Server/Data/Exceptions/InvalidInputException.php b/src/Server/Data/Exceptions/InvalidInputException.php index 3430d3d..ab7dd65 100644 --- a/src/Server/Data/Exceptions/InvalidInputException.php +++ b/src/Server/Data/Exceptions/InvalidInputException.php @@ -4,12 +4,13 @@ use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\Issues; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; -final class InvalidInputException extends \Exception +final class InvalidInputException extends SchemaException { public function __construct(public readonly Failure $failure) { - parent::__construct("Input validation failed", 422, $this->failure); + parent::__construct("Input validation failed", 422); } /** diff --git a/src/Server/Data/Exceptions/InvalidMiddlewareException.php b/src/Server/Data/Exceptions/InvalidMiddlewareException.php index f8a766a..9ae6016 100644 --- a/src/Server/Data/Exceptions/InvalidMiddlewareException.php +++ b/src/Server/Data/Exceptions/InvalidMiddlewareException.php @@ -3,7 +3,7 @@ namespace Le0daniel\PhpTsBindings\Server\Data\Exceptions; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; -use RuntimeException; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; /** * Thrown when a class registered as middleware does not implement MiddlewareContract. @@ -12,7 +12,7 @@ * configuration - so the mistake only becomes visible when the operation runs. Saying which class * is at fault beats the "call to undefined method handle()" this used to produce. */ -final class InvalidMiddlewareException extends RuntimeException +final class InvalidMiddlewareException extends SchemaException { private function __construct(string $message) { diff --git a/src/Server/Data/Exceptions/InvalidOutputException.php b/src/Server/Data/Exceptions/InvalidOutputException.php index d958f39..3371c0e 100644 --- a/src/Server/Data/Exceptions/InvalidOutputException.php +++ b/src/Server/Data/Exceptions/InvalidOutputException.php @@ -2,11 +2,11 @@ namespace Le0daniel\PhpTsBindings\Server\Data\Exceptions; -use Exception; use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\Issues; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; -final class InvalidOutputException extends Exception +final class InvalidOutputException extends SchemaException { public Issues $issues { get => $this->failure->issues; @@ -14,6 +14,6 @@ final class InvalidOutputException extends Exception public function __construct(private readonly Failure $failure) { - parent::__construct($failure->message, 500, $failure); + parent::__construct($failure->describe(), 500); } } \ No newline at end of file diff --git a/src/Server/Data/Exceptions/OperationNotFoundException.php b/src/Server/Data/Exceptions/OperationNotFoundException.php index c63ee96..79f3cd1 100644 --- a/src/Server/Data/Exceptions/OperationNotFoundException.php +++ b/src/Server/Data/Exceptions/OperationNotFoundException.php @@ -2,9 +2,9 @@ namespace Le0daniel\PhpTsBindings\Server\Data\Exceptions; -use RuntimeException; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; -final class OperationNotFoundException extends RuntimeException +final class OperationNotFoundException extends SchemaException { } \ No newline at end of file diff --git a/src/Server/Data/Exceptions/UnknownResultTypeException.php b/src/Server/Data/Exceptions/UnknownResultTypeException.php deleted file mode 100644 index 2a43208..0000000 --- a/src/Server/Data/Exceptions/UnknownResultTypeException.php +++ /dev/null @@ -1,17 +0,0 @@ -type, $this->cause, $this->details, $this->resolveInfo, $metadata); @@ -35,6 +39,8 @@ public function withMetadata(array $metadata): static * @return static * @api */ + #[Override] + #[NoDiscard] public function appendMetadata(array $metadata): static { return new self($this->type, $this->cause, $this->details, $this->resolveInfo, [ diff --git a/src/Server/Data/RpcSuccess.php b/src/Server/Data/RpcSuccess.php index e82c71f..adf7f96 100644 --- a/src/Server/Data/RpcSuccess.php +++ b/src/Server/Data/RpcSuccess.php @@ -4,6 +4,8 @@ use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\RpcResult; +use NoDiscard; +use Override; final readonly class RpcSuccess implements RpcResult { @@ -26,6 +28,8 @@ public function __construct( * @return static * @api */ + #[Override] + #[NoDiscard] public function withMetadata(array $metadata): static { return new self($this->data, $this->client, $this->resolveInfo, $metadata); @@ -37,6 +41,8 @@ public function withMetadata(array $metadata): static * @return static * @api */ + #[Override] + #[NoDiscard] public function appendMetadata(array $metadata): static { return new self($this->data, $this->client, $this->resolveInfo, [ diff --git a/src/Server/Data/ServerConfiguration.php b/src/Server/Data/ServerConfiguration.php index bee66e7..1dc59eb 100644 --- a/src/Server/Data/ServerConfiguration.php +++ b/src/Server/Data/ServerConfiguration.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\Server\Data; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; +use NoDiscard; final readonly class ServerConfiguration { @@ -21,14 +22,14 @@ public function __construct( * @param class-string> ...$middlewares * @return self */ + #[NoDiscard] public function withMiddlewares(string ...$middlewares): self { + // array_values is redundant at runtime - spreading two lists yields a list - but it is + // what tells the type checker so, and $middleware is declared as a list. return new self( $this->coerceQueryInput, - [ - ...$this->middleware, - ...array_values($middlewares), - ], + [...$this->middleware, ...$middlewares] |> array_values(...), ); } } \ No newline at end of file diff --git a/src/Server/KeyGenerators/HashSha256KeyGenerator.php b/src/Server/KeyGenerators/HashSha256KeyGenerator.php index 5e998fd..a11859a 100644 --- a/src/Server/KeyGenerators/HashSha256KeyGenerator.php +++ b/src/Server/KeyGenerators/HashSha256KeyGenerator.php @@ -3,8 +3,8 @@ namespace Le0daniel\PhpTsBindings\Server\KeyGenerators; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; -use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Utils\Hashs; +use Override; final readonly class HashSha256KeyGenerator implements OperationKeyGenerator { @@ -16,6 +16,7 @@ public function __construct( { } + #[Override] public function generateKey(string $namespace, string $name): string { $namespaceHash = Hashs::base64UrlEncodedSha256("{$namespace}|{$this->pepper}"); diff --git a/src/Server/KeyGenerators/PlainlyExposedKeyGenerator.php b/src/Server/KeyGenerators/PlainlyExposedKeyGenerator.php index f59ef6d..2ffa3bb 100644 --- a/src/Server/KeyGenerators/PlainlyExposedKeyGenerator.php +++ b/src/Server/KeyGenerators/PlainlyExposedKeyGenerator.php @@ -3,11 +3,12 @@ namespace Le0daniel\PhpTsBindings\Server\KeyGenerators; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; -use Le0daniel\PhpTsBindings\Server\Data\Definition; +use Override; -final class PlainlyExposedKeyGenerator implements OperationKeyGenerator +final readonly class PlainlyExposedKeyGenerator implements OperationKeyGenerator { + #[Override] public function generateKey(string $namespace, string $name): string { return "{$namespace}.{$name}"; diff --git a/src/Server/Operations/CachedOperationRegistry.php b/src/Server/Operations/CachedOperationRegistry.php index b3fd64d..78e344d 100644 --- a/src/Server/Operations/CachedOperationRegistry.php +++ b/src/Server/Operations/CachedOperationRegistry.php @@ -8,6 +8,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Utils\PHPExport; +use Override; final class CachedOperationRegistry implements OperationRegistry { @@ -23,6 +24,7 @@ public function __construct(private readonly array $operations) { } + #[Override] public function has(OperationType $type, string $fullyQualifiedKey): bool { return array_key_exists( @@ -31,6 +33,7 @@ public function has(OperationType $type, string $fullyQualifiedKey): bool ); } + #[Override] public function get(OperationType $type, string $fullyQualifiedKey): Operation { $key = self::key($type, $fullyQualifiedKey); @@ -42,6 +45,7 @@ private static function key(OperationType $type, string $fullyQualifiedKey): str return "{$type->name}:{$fullyQualifiedKey}"; } + #[Override] public function all(): array { foreach ($this->operations as $key => $factory) { @@ -78,7 +82,7 @@ public static function toPhpCode( } // The ast optimizer deduplicates all the ASTs, minimizing the nodes required at runtime. - $optimizer = new AstOptimizer( + $optimizer = new ASTOptimizer( idLength: $idLength, ); $operationRegistryClass = PHPExport::absolute(CachedOperationRegistry::class); diff --git a/src/Server/Operations/DiscoveryManager.php b/src/Server/Operations/DiscoveryManager.php deleted file mode 100644 index 7c7e795..0000000 --- a/src/Server/Operations/DiscoveryManager.php +++ /dev/null @@ -1,43 +0,0 @@ - $discoverers - */ - public function __construct( - private array $discoverers, - ) - { - - } - - public function discover(string $directory): void { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory) - ); - - /** @var SplFileInfo $file */ - foreach ($iterator as $file) { - if (!$file->isFile() || $file->getExtension() !== 'php' || !$file->getRealPath()) { - continue; - } - - $reflection = new FileReflector($file->getRealPath()); - $class = $reflection->getDeclaredClass(); - - foreach ($this->discoverers as $discoverer) { - $discoverer->discover($class); - } - } - } -} \ No newline at end of file diff --git a/src/Server/Operations/EagerlyLoadedRegistry.php b/src/Server/Operations/EagerlyLoadedOperationRegistry.php similarity index 76% rename from src/Server/Operations/EagerlyLoadedRegistry.php rename to src/Server/Operations/EagerlyLoadedOperationRegistry.php index 9e46123..4236675 100644 --- a/src/Server/Operations/EagerlyLoadedRegistry.php +++ b/src/Server/Operations/EagerlyLoadedOperationRegistry.php @@ -7,14 +7,19 @@ use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Reflection\FileReflector; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\KeyGenerators\HashSha256KeyGenerator; +use Override; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use ReflectionClass; use ReflectionException; +use SplFileInfo; -final class EagerlyLoadedRegistry implements OperationRegistry +final class EagerlyLoadedOperationRegistry implements OperationRegistry { /** * @var array @@ -44,15 +49,34 @@ public static function eagerlyDiscover( ): self { $directories = is_array($directories) ? $directories : [$directories]; - $discoverer = new DiscoveryManager([$discovery]); foreach ($directories as $directory) { - $discoverer->discover($directory); + self::discoverDirectory($directory, $discovery); } - return self::readDiscoverer($parser, $keyGenerator, $discovery); + return self::registryFromDiscovery($parser, $keyGenerator, $discovery); } - private static function readDiscoverer( + /** + * Every .php file under $directory is reflected and offered to the discovery, which keeps the + * ones carrying #[Query] or #[Command]. + */ + private static function discoverDirectory(string $directory, OperationDiscovery $discovery): void + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory) + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php' || !$file->getRealPath()) { + continue; + } + + $discovery->discover(new FileReflector($file->getRealPath())->getDeclaredClass()); + } + } + + private static function registryFromDiscovery( TypeParser $parser, OperationKeyGenerator $keyGenerator, OperationDiscovery $discovery, @@ -93,7 +117,7 @@ public static function withClasses( foreach ($classes as $className) { $discovery->discover(new ReflectionClass($className)); } - return self::readDiscoverer($parser, $keyGenerator, $discovery); + return self::registryFromDiscovery($parser, $keyGenerator, $discovery); } private static function key(OperationType $type, string $fullyQualifiedKey): string @@ -101,6 +125,7 @@ private static function key(OperationType $type, string $fullyQualifiedKey): str return "{$type->name}@{$fullyQualifiedKey}"; } + #[Override] public function has(OperationType $type, string $fullyQualifiedKey): bool { $key = self::key($type, $fullyQualifiedKey); @@ -110,6 +135,7 @@ public function has(OperationType $type, string $fullyQualifiedKey): bool /** * @throws ReflectionException */ + #[Override] public function get(OperationType $type, string $fullyQualifiedKey): Operation { $key = self::key($type, $fullyQualifiedKey); @@ -119,6 +145,7 @@ public function get(OperationType $type, string $fullyQualifiedKey): Operation /** * @return Operation[] */ + #[Override] public function all(): array { foreach ($this->factories as $key => $factory) { diff --git a/src/Server/Operations/OperationDiscovery.php b/src/Server/Operations/OperationDiscovery.php index 26da367..37c5184 100644 --- a/src/Server/Operations/OperationDiscovery.php +++ b/src/Server/Operations/OperationDiscovery.php @@ -6,15 +6,14 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Command; use Le0daniel\PhpTsBindings\Contracts\Attributes\Middleware; use Le0daniel\PhpTsBindings\Contracts\Attributes\Query; -use Le0daniel\PhpTsBindings\Contracts\Discoverer; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use ReflectionClass; use ReflectionMethod; -use RuntimeException; -final class OperationDiscovery implements Discoverer +final class OperationDiscovery { private const string DEFAULT_NAMESPACE = 'global'; @@ -29,15 +28,12 @@ public function __construct(private readonly Closure|null $filterFn = null) } /** - * Used for extensibility. - * Return false to filter the item out and have your own custom rules + * The extension point is the $filterFn closure, not a subclass - this class is final. Return + * false from it to keep an operation out of the registry. * * @param ReflectionClass $class - * @param ReflectionMethod $method - * @param Query|Command $attribute - * @return bool */ - protected function filter(ReflectionClass $class, ReflectionMethod $method, Query|Command $attribute): bool + private function filter(ReflectionClass $class, ReflectionMethod $method, Query|Command $attribute): bool { if ($this->filterFn) { return ($this->filterFn)($class, $method, $attribute); @@ -47,7 +43,7 @@ protected function filter(ReflectionClass $class, ReflectionMethod $method, Quer } /** @param ReflectionClass $class */ - final public function discover(ReflectionClass $class): void + public function discover(ReflectionClass $class): void { foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { $attributes = $method->getAttributes(); @@ -68,7 +64,7 @@ final public function discover(ReflectionClass $class): void $fullKey = "{$definition->type->name}@{$definition->fullyQualifiedName()}"; if (array_key_exists($fullKey, $this->operations)) { - throw new RuntimeException("Name collision for: {$definition->fullyQualifiedName()} defined in {$definition->fullyQualifiedClassName} -> {$definition->methodName}."); + throw new SchemaException("Name collision for: {$definition->fullyQualifiedName()} defined in {$definition->fullyQualifiedClassName} -> {$definition->methodName}."); } $this->operations[$fullKey] = $definition; @@ -92,7 +88,7 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, $parameters = $method->getParameters(); if (count($parameters) < 1) { - throw new RuntimeException("Method {$method->name} must have at least one parameter."); + throw new SchemaException("Method {$method->name} must have at least one parameter."); } // Collect all middlewares, on the class and the method itself. diff --git a/src/Server/Presenter/CatchAllPresenter.php b/src/Server/Presenter/CatchAllPresenter.php index cd34a6a..b9754c7 100644 --- a/src/Server/Presenter/CatchAllPresenter.php +++ b/src/Server/Presenter/CatchAllPresenter.php @@ -5,24 +5,27 @@ use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use Override; use Throwable; -final class CatchAllPresenter implements ExceptionPresenter +final readonly class CatchAllPresenter implements ExceptionPresenter { - + #[Override] public function matches(Throwable $throwable, Definition $definition): bool { return true; } - public function toTypeScriptDefinition(Definition $definition): string + #[Override] + public function toTypescriptDefinition(Definition $definition): string { return '{type: "INTERNAL_SERVER_ERROR"}'; } - /* - * @return array{status: 500, type: "INTERNAL_SERVER_ERROR"} + /** + * @return array{type: "INTERNAL_SERVER_ERROR"} */ + #[Override] public function details(Throwable $throwable): array { return [ @@ -30,6 +33,7 @@ public function details(Throwable $throwable): array ]; } + #[Override] public static function errorType(): ErrorType { return ErrorType::INTERNAL_ERROR; diff --git a/src/Server/Presenter/ExposedExceptionPresenter.php b/src/Server/Presenter/ExposedExceptionPresenter.php index 505d42f..10cf24b 100644 --- a/src/Server/Presenter/ExposedExceptionPresenter.php +++ b/src/Server/Presenter/ExposedExceptionPresenter.php @@ -7,13 +7,15 @@ use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use Le0daniel\PhpTsBindings\Utils\Lists; +use Override; use ReflectionAttribute; use ReflectionClass; use ReflectionException; use ReflectionMethod; use Throwable; -final class ExposedExceptionPresenter implements ExceptionPresenter +final readonly class ExposedExceptionPresenter implements ExceptionPresenter { /** @@ -61,18 +63,20 @@ private function exposedTypeOf(string $exceptionClass): ?string /** * @throws ReflectionException */ + #[Override] public function matches(Throwable $throwable, Definition $definition): bool { return $this->exposedTypeOf($throwable::class) !== null && in_array($throwable::class, $this->extractDeclaredExceptions($definition), true); } - public function toTypeScriptDefinition(Definition $definition): ?string + #[Override] + public function toTypescriptDefinition(Definition $definition): ?string { - $exposedTypes = array_filter(array_map( + $exposedTypes = array_map( $this->exposedTypeOf(...), $this->extractDeclaredExceptions($definition), - )); + ) |> Lists::filterNullValues(...); if (empty($exposedTypes)) { return null; @@ -87,13 +91,18 @@ public function toTypeScriptDefinition(Definition $definition): ?string /** * @return array{type: string} */ + #[Override] public function details(Throwable $throwable): array { - return [ - 'type' => $this->exposedTypeOf($throwable::class), - ]; + // matches() already established that this exception carries an ExposeAs; a presenter is + // only ever asked for details after it claimed the throwable. + $type = $this->exposedTypeOf($throwable::class); + assert($type !== null, 'details() called for a throwable this presenter does not match.'); + + return ['type' => $type]; } + #[Override] public static function errorType(): ErrorType { return ErrorType::DOMAIN_ERROR; diff --git a/src/Server/Presenter/InvalidInputPresenter.php b/src/Server/Presenter/InvalidInputPresenter.php index e2c6432..f178c8a 100644 --- a/src/Server/Presenter/InvalidInputPresenter.php +++ b/src/Server/Presenter/InvalidInputPresenter.php @@ -6,24 +6,28 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; +use Override; use Throwable; -final class InvalidInputPresenter implements ExceptionPresenter +final readonly class InvalidInputPresenter implements ExceptionPresenter { + #[Override] public function matches(Throwable $throwable, Definition $definition): bool { return $throwable instanceof InvalidInputException; } - public function toTypeScriptDefinition(Definition $definition): string + #[Override] + public function toTypescriptDefinition(Definition $definition): string { return '{type:"INVALID_INPUT"; fields: Record;}'; } - /* - * @return array{status: 422, type: "INVALID_INPUT", fields: array} + /** + * @return array{type: "INVALID_INPUT", fields: array} */ + #[Override] public function details(Throwable $throwable): array { /** @var InvalidInputException $throwable */ @@ -34,6 +38,7 @@ public function details(Throwable $throwable): array ]; } + #[Override] public static function errorType(): ErrorType { return ErrorType::INVALID_INPUT; diff --git a/src/Server/Presenter/NotFoundPresenter.php b/src/Server/Presenter/NotFoundPresenter.php index bd1e57d..85ee434 100644 --- a/src/Server/Presenter/NotFoundPresenter.php +++ b/src/Server/Presenter/NotFoundPresenter.php @@ -5,9 +5,10 @@ use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use Override; use Throwable; -final class NotFoundPresenter implements ExceptionPresenter +final readonly class NotFoundPresenter implements ExceptionPresenter { /** * @param list> $classNames @@ -18,19 +19,22 @@ public function __construct( { } + #[Override] public function matches(Throwable $throwable, Definition $definition): bool { return in_array(get_class($throwable), $this->classNames, true); } - public function toTypeScriptDefinition(Definition $definition): string + #[Override] + public function toTypescriptDefinition(Definition $definition): string { return '{type: "NOT_FOUND";}'; } - /* - * @return array{status: 404, type: "NOT_FOUND"} + /** + * @return array{type: "NOT_FOUND"} */ + #[Override] public function details(Throwable $throwable): array { return [ @@ -38,6 +42,7 @@ public function details(Throwable $throwable): array ]; } + #[Override] public static function errorType(): ErrorType { return ErrorType::NOT_FOUND; diff --git a/src/Server/Presenter/UnauthenticatedPresenter.php b/src/Server/Presenter/UnauthenticatedPresenter.php index 6f2b8d2..8660b80 100644 --- a/src/Server/Presenter/UnauthenticatedPresenter.php +++ b/src/Server/Presenter/UnauthenticatedPresenter.php @@ -5,9 +5,10 @@ use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use Override; use Throwable; -final class UnauthenticatedPresenter implements ExceptionPresenter +final readonly class UnauthenticatedPresenter implements ExceptionPresenter { /** * @param list> $unauthenticatedClassNames @@ -18,19 +19,22 @@ public function __construct( { } + #[Override] public function matches(Throwable $throwable, Definition $definition): bool { return in_array(get_class($throwable), $this->unauthenticatedClassNames, true); } - public function toTypeScriptDefinition(Definition $definition): string + #[Override] + public function toTypescriptDefinition(Definition $definition): string { return '{type: "UNAUTHENTICATED";}'; } - /* - * @return array{status: 401, type: "UNAUTHENTICATED"} + /** + * @return array{type: "UNAUTHENTICATED"} */ + #[Override] public function details(Throwable $throwable): array { return [ @@ -38,6 +42,7 @@ public function details(Throwable $throwable): array ]; } + #[Override] public static function errorType(): ErrorType { return ErrorType::AUTHENTICATION_ERROR; diff --git a/src/Server/Presenter/UnauthorizedPresenter.php b/src/Server/Presenter/UnauthorizedPresenter.php index 7ff95b8..55df8a0 100644 --- a/src/Server/Presenter/UnauthorizedPresenter.php +++ b/src/Server/Presenter/UnauthorizedPresenter.php @@ -5,9 +5,10 @@ use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use Override; use Throwable; -final class UnauthorizedPresenter implements ExceptionPresenter +final readonly class UnauthorizedPresenter implements ExceptionPresenter { /** * @param list> $unauthenticatedClassNames @@ -18,12 +19,14 @@ public function __construct( { } + #[Override] public function matches(Throwable $throwable, Definition $definition): bool { return in_array(get_class($throwable), $this->unauthenticatedClassNames, true); } - public function toTypeScriptDefinition(Definition $definition): string + #[Override] + public function toTypescriptDefinition(Definition $definition): string { return '{type: "UNAUTHORIZED";}'; } @@ -31,6 +34,7 @@ public function toTypeScriptDefinition(Definition $definition): string /** * @return array{type: "UNAUTHORIZED"} */ + #[Override] public function details(Throwable $throwable): array { return [ @@ -38,6 +42,7 @@ public function details(Throwable $throwable): array ]; } + #[Override] public static function errorType(): ErrorType { return ErrorType::AUTHORIZATION_ERROR; diff --git a/src/Typescript/Code/TypescriptFile.php b/src/Typescript/Code/TypescriptFile.php index b5e2f2d..4f12363 100644 --- a/src/Typescript/Code/TypescriptFile.php +++ b/src/Typescript/Code/TypescriptFile.php @@ -4,6 +4,8 @@ use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; use Le0daniel\PhpTsBindings\Utils\Lists; +use NoDiscard; +use Override; use Stringable; /** @@ -49,15 +51,17 @@ public function __construct(string $code = '', array $imports = []) $this->imports = self::mergeByModule($imports); } + #[NoDiscard] public function withImports(TypescriptImport ...$imports): self { - return new self($this->code, [...$this->imports, ...$imports]); + return new self($this->code, [...$this->imports, ...$imports] |> array_values(...)); } /** * Appends a block of code. A string carries no imports; a file carries its own, merged into * this one. Blocks are separated by a blank line, and appending nothing changes nothing. */ + #[NoDiscard] public function append(string|self $block): self { $code = $block instanceof self ? $block->code : self::normalizeBlock($block); @@ -86,6 +90,7 @@ public function toString(): string return implode(PHP_EOL, $importLines) . PHP_EOL . ($body === '' ? '' : PHP_EOL . $body); } + #[Override] public function __toString(): string { return $this->toString(); diff --git a/src/Typescript/Code/TypescriptImport.php b/src/Typescript/Code/TypescriptImport.php index 173879d..1465e52 100644 --- a/src/Typescript/Code/TypescriptImport.php +++ b/src/Typescript/Code/TypescriptImport.php @@ -2,10 +2,11 @@ namespace Le0daniel\PhpTsBindings\Typescript\Code; -use InvalidArgumentException; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; use Le0daniel\PhpTsBindings\Utils\Lists; +use NoDiscard; /** * What one module contributes to a file: the names taken for their runtime value and the names @@ -42,7 +43,7 @@ * * @param list $values * @param list $types - * @throws InvalidArgumentException When $from cannot be written as a module specifier. + * @throws CodeGenException When $from cannot be written as a module specifier. * @throws InvalidStringLiteralException When a name is not a valid TypeScript identifier. */ public function __construct( @@ -95,12 +96,13 @@ public static function mixed(string $from, string|array $valuesOrNames): self } /** - * @throws InvalidArgumentException When the two imports name different modules. + * @throws CodeGenException When the two imports name different modules. */ + #[NoDiscard] public function merge(self $other): self { if ($this->from !== $other->from) { - throw new InvalidArgumentException( + throw new CodeGenException( "Cannot merge imports of '{$this->from}' and '{$other->from}': they are different modules." ); } @@ -137,13 +139,14 @@ private static function canonical(array $names, string $from): array return $names |> Lists::unique(...) |> Lists::sorted(...); } + /** + * Validated here rather than at render time so a bad specifier fails where it was introduced. + * The rule itself belongs to Syntax, which owns what can be written into a TypeScript file. + */ private static function assertUsableSpecifier(string $from): void { - // The specifier is written verbatim inside a single quoted string literal. Whitespace, - // quotes and backslashes would either break the literal or silently name a module that - // does not exist, so reject them rather than escape them into something plausible. - if ($from === '' || preg_match('/[\s\'"\\\\]/', $from) === 1) { - throw new InvalidArgumentException( + if (!Syntax::isValidModuleSpecifier($from)) { + throw new CodeGenException( "'{$from}' cannot be written as a TypeScript module specifier." ); } diff --git a/src/Typescript/Data/TypeScript.php b/src/Typescript/Data/Typescript.php similarity index 83% rename from src/Typescript/Data/TypeScript.php rename to src/Typescript/Data/Typescript.php index 76a2c37..5123f85 100644 --- a/src/Typescript/Data/TypeScript.php +++ b/src/Typescript/Data/Typescript.php @@ -7,7 +7,7 @@ /** * A generated TypeScript type together with the aliases it references. */ -final readonly class TypeScript +final readonly class Typescript { /** * @param string $type The type. Named types are referenced by their alias name, brands appear @@ -23,8 +23,8 @@ public function __construct( { } - public static function fromRawString(string $type): TypeScript + public static function fromRawString(string $type): Typescript { - return new TypeScript($type, new AliasRegistry()); + return new Typescript($type, new AliasRegistry()); } } diff --git a/src/Typescript/Exceptions/InvalidStringLiteralException.php b/src/Typescript/Exceptions/InvalidStringLiteralException.php index 6e0c9df..7387953 100644 --- a/src/Typescript/Exceptions/InvalidStringLiteralException.php +++ b/src/Typescript/Exceptions/InvalidStringLiteralException.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Typescript\Exceptions; -use RuntimeException; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; /** * A user supplied string literal (a #[Brand] tag, a #[Named] alias, a BrandedString/BrandedInt @@ -10,7 +10,7 @@ * there. Every invalid-identifier failure throws this, regardless of which attribute or utility * carried the literal. */ -final class InvalidStringLiteralException extends RuntimeException +final class InvalidStringLiteralException extends CodeGenException { public static function notAValidTypescriptIdentifier(string $literal, string $useSite): self { diff --git a/src/Typescript/Exceptions/UnknownAliasException.php b/src/Typescript/Exceptions/UnknownAliasException.php index d950c02..230729a 100644 --- a/src/Typescript/Exceptions/UnknownAliasException.php +++ b/src/Typescript/Exceptions/UnknownAliasException.php @@ -2,12 +2,12 @@ namespace Le0daniel\PhpTsBindings\Typescript\Exceptions; -use RuntimeException; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; /** * Thrown when a type alias is read out of the registry without being defined in it. */ -final class UnknownAliasException extends RuntimeException +final class UnknownAliasException extends CodeGenException { private function __construct(string $message) { diff --git a/src/Typescript/Exceptions/UnsupportedTypeException.php b/src/Typescript/Exceptions/UnsupportedTypeException.php index 1c4839c..847c053 100644 --- a/src/Typescript/Exceptions/UnsupportedTypeException.php +++ b/src/Typescript/Exceptions/UnsupportedTypeException.php @@ -2,9 +2,9 @@ namespace Le0daniel\PhpTsBindings\Typescript\Exceptions; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; -use RuntimeException; /** * Thrown when a schema describes something that has no honest TypeScript representation. @@ -12,7 +12,7 @@ * Emitting a placeholder instead would push the problem into the generated client, where it shows * up as a type error far away from the schema that caused it. */ -final class UnsupportedTypeException extends RuntimeException +final class UnsupportedTypeException extends CodeGenException { private function __construct(string $message) { diff --git a/src/Typescript/TypescriptGenerator.php b/src/Typescript/TypescriptGenerator.php index 1cadbca..f3a31d9 100644 --- a/src/Typescript/TypescriptGenerator.php +++ b/src/Typescript/TypescriptGenerator.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Typescript; -use InvalidArgumentException; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; @@ -29,7 +29,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Typescript\Data\EmissionContext; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; @@ -45,10 +45,10 @@ */ final readonly class TypescriptGenerator { - public function toTypescript(NodeInterface $node, IO $io, ?AliasRegistry $sharedRegistry = null): TypeScript + public function toTypescript(NodeInterface $node, IO $io, ?AliasRegistry $sharedRegistry = null): Typescript { if ($io === IO::BOTH) { - throw new InvalidArgumentException('Emit for IO::INPUT or IO::OUTPUT; IO::BOTH is only a #[Named] scope.'); + throw new CodeGenException('Emit for IO::INPUT or IO::OUTPUT; IO::BOTH is only a #[Named] scope.'); } // Every pass emits into its own local registry, so the result always carries exactly the @@ -63,7 +63,7 @@ public function toTypescript(NodeInterface $node, IO $io, ?AliasRegistry $shared $sharedRegistry?->set($alias, $definition); } - return new TypeScript($type, $localRegistry); + return new Typescript($type, $localRegistry); } private function emit(NodeInterface $node, EmissionContext $context): string @@ -204,7 +204,7 @@ private function union(UnionNode $node, EmissionContext $context): string { $members = array_map( fn($member): string => $this->emit($member, $context), - $node->types, + $node->nodes, ); // Distinct schema nodes can render to the same type: `int|float` is one `number`. @@ -215,7 +215,7 @@ private function intersection(IntersectionNode $node, EmissionContext $context): { $members = array_map( fn($member): string => $this->emit($member, $context), - $node->types, + $node->nodes, ); return implode('&', $members) |> Syntax::wrapInParentheses(...); @@ -225,7 +225,7 @@ private function tuple(TupleNode $node, EmissionContext $context): string { $members = array_map( fn(NodeInterface $member): string => $this->emit($member, $context), - $node->types, + $node->nodes, ); return '[' . implode(',', $members) . ']'; diff --git a/src/Typescript/Utils/Syntax.php b/src/Typescript/Utils/Syntax.php index 330639a..52c1dc4 100644 --- a/src/Typescript/Utils/Syntax.php +++ b/src/Typescript/Utils/Syntax.php @@ -2,13 +2,15 @@ namespace Le0daniel\PhpTsBindings\Typescript\Utils; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; + /** * TypeScript syntax primitives. * * Everything the generator needs to write is spelled out here, so this package depends on nothing * outside itself and CodeGen depends on it rather than the other way round. */ -final class Syntax +final readonly class Syntax { public static function isValidIdentifier(string $name): bool { @@ -20,7 +22,7 @@ public static function isValidIdentifier(string $name): bool */ public static function objectKey(string $key, bool $optional = false): string { - $encoded = preg_match('/^[a-zA-Z_][a-zA-Z\d_]*$/', $key) + $encoded = self::isValidIdentifier($key) ? $key : self::stringLiteral($key); @@ -40,15 +42,28 @@ public static function wrapInParentheses(string $value): string return "({$value})"; } + /** + * A specifier is written verbatim inside a single quoted string literal. Whitespace, quotes and + * backslashes would either break the literal or silently name a module that does not exist, so + * they are rejected rather than escaped into something plausible. + */ + public static function isValidModuleSpecifier(string $specifier): bool + { + return $specifier !== '' && preg_match('/[\s\'"\\\\]/', $specifier) !== 1; + } + /** * A module specifier as it appears after `from`. Single quoted, matching the rest of the * generated output — unlike stringLiteral(), which is JSON and therefore double quotes. - * The specifier is written verbatim, so the caller vouches for it being writable. + * + * @throws CodeGenException When the specifier cannot be written verbatim. */ public static function moduleSpecifier(string $specifier): string { - if (str_contains($specifier, "'")) { - throw new \RuntimeException("Invalid path specified: '{$specifier}'"); + if (!self::isValidModuleSpecifier($specifier)) { + throw new CodeGenException( + "'{$specifier}' cannot be written as a TypeScript module specifier." + ); } return "'{$specifier}'"; diff --git a/src/Utils/Arrays.php b/src/Utils/Arrays.php index 7cefcd8..1163e84 100644 --- a/src/Utils/Arrays.php +++ b/src/Utils/Arrays.php @@ -4,7 +4,7 @@ use Closure; -final class Arrays +final readonly class Arrays { /** * @template TArrayKey of array-key diff --git a/src/Utils/Dicts.php b/src/Utils/Dicts.php index da750cc..2e4343f 100644 --- a/src/Utils/Dicts.php +++ b/src/Utils/Dicts.php @@ -2,6 +2,8 @@ namespace Le0daniel\PhpTsBindings\Utils; +use NoDiscard; + final readonly class Dicts { /** @@ -9,6 +11,7 @@ * @param array $dict * @return array */ + #[NoDiscard] public static function filterNullValues(array $dict): array { return array_filter($dict, fn($value) => $value !== null); diff --git a/src/Utils/Hashs.php b/src/Utils/Hashs.php index f417f08..bce095f 100644 --- a/src/Utils/Hashs.php +++ b/src/Utils/Hashs.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Utils; -final class Hashs +final readonly class Hashs { public static function base64UrlEncodedSha256(string $message): string diff --git a/src/Utils/Lists.php b/src/Utils/Lists.php index 57f90aa..558498a 100644 --- a/src/Utils/Lists.php +++ b/src/Utils/Lists.php @@ -2,6 +2,8 @@ namespace Le0daniel\PhpTsBindings\Utils; +use NoDiscard; + final readonly class Lists { /** @@ -9,6 +11,7 @@ * @param list $list * @return list */ + #[NoDiscard] public static function filterNullValues(array $list): array { return array_filter($list, fn($value) => $value !== null) |> array_values(...); @@ -21,6 +24,7 @@ public static function filterNullValues(array $list): array * @param list $list * @return list */ + #[NoDiscard] public static function unique(array $list): array { return array_unique($list) |> array_values(...); @@ -30,6 +34,7 @@ public static function unique(array $list): array * @param list $list * @return list */ + #[NoDiscard] public static function sorted(array $list): array { usort($list, strcmp(...)); diff --git a/src/Utils/Namespaces.php b/src/Utils/Namespaces.php index e77a2cb..00b28de 100644 --- a/src/Utils/Namespaces.php +++ b/src/Utils/Namespaces.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Utils; -final class Namespaces +final readonly class Namespaces { /** * Example Namespaces: @@ -24,42 +24,37 @@ final class Namespaces * ] * ``` * - * @param array|array $namespaces - * @return array + * Names come from a file's parsed `use` statements, so they are strings that look like class + * names but are not verified to name anything. Typing them class-string would be a guarantee + * this cannot make - resolution happens later, against the consumers that actually need a class. + * + * @param array $namespaces + * @return array */ public static function buildNamespaceAliasMap(array $namespaces): array { $map = []; foreach ($namespaces as $namespace => $alias) { if (is_int($namespace)) { - /** @var class-string $alias */ $map[Strings::classBaseName($alias)] = self::withoutLeadingSlash($alias); } else { - /** @var class-string $namespace */ $map[$alias] = self::withoutLeadingSlash($namespace); } } return $map; } - /** - * @param class-string $className - * @return class-string - */ private static function withoutLeadingSlash(string $className): string { - /** @var class-string $value */ - $value = str_starts_with($className, '\\') ? substr($className, 1) : $className; - return $value; + return str_starts_with($className, '\\') ? substr($className, 1) : $className; } /** - * @param array $namespacesMap + * @param array $namespacesMap */ public static function toFullyQualifiedClassName(string $className, ?string $namespace, array $namespacesMap): string { if (str_starts_with($className, '\\')) { - /** @var class-string $className */ return self::withoutLeadingSlash($className); } diff --git a/src/Utils/Nodes.php b/src/Utils/Nodes.php index 42e6e68..9353f33 100644 --- a/src/Utils/Nodes.php +++ b/src/Utils/Nodes.php @@ -9,7 +9,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; -final class Nodes +final readonly class Nodes { public static function getDeclaringNode(NodeInterface $node): NodeInterface { @@ -44,7 +44,7 @@ public static function areAllNodesOfSameStructType(array $nodes): bool $stack = $nodes; while ($node = array_pop($stack)) { if ($node instanceof UnionNode) { - array_push($stack, ... $node->types); + array_push($stack, ... $node->nodes); continue; } diff --git a/src/Utils/PHPExport.php b/src/Utils/PHPExport.php index 7258ff7..721823c 100644 --- a/src/Utils/PHPExport.php +++ b/src/Utils/PHPExport.php @@ -3,26 +3,40 @@ namespace Le0daniel\PhpTsBindings\Utils; use Le0daniel\PhpTsBindings\Contracts\ExportableToPhpCode; -use RuntimeException; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use UnitEnum; -final class PHPExport +final readonly class PHPExport { /** - * Writes a file to disk atomically and throws on failure. - * @throws RuntimeException + * Writes through a temporary file in the same directory and renames it into place. + * + * The generated caches this produces are require()d while the application is serving traffic, + * so a half written file would be loaded as valid PHP and fail far from here. rename() is + * atomic within a filesystem, which makes a reader see either the whole old file or the whole + * new one - hence the temporary alongside the target rather than in the system temp directory, + * which may be a different filesystem. + * + * @throws ParserException */ public static function writeFileAtomically(string $filePath, string $contents): void { - $written = file_put_contents($filePath, $contents); - if ($written !== false) { - return; + $directory = dirname($filePath); + if (!is_dir($directory) || !is_writable($directory)) { + throw new ParserException("Failed to write file to {$filePath}: {$directory} is not a writable directory."); } - if (file_exists($filePath)) { - unlink($filePath); + $temporaryPath = $filePath . '.' . getmypid() . '.tmp'; + + if (file_put_contents($temporaryPath, $contents) !== strlen($contents)) { + @unlink($temporaryPath); + throw new ParserException("Failed to write file to {$filePath}"); + } + + if (!@rename($temporaryPath, $filePath)) { + @unlink($temporaryPath); + throw new ParserException("Failed to write file to {$filePath}"); } - throw new RuntimeException("Failed to write file to {$filePath}"); } public static function absolute(string $className): string @@ -48,7 +62,7 @@ public static function exportArray(array $array): string } if (!array_is_list($array)) { - throw new \InvalidArgumentException('Array must be a list'); + throw new ParserException('Array must be a list'); } $imploded = implode(',', array_map(self::export(...), $array)); diff --git a/src/Utils/PhpDoc.php b/src/Utils/PhpDoc.php index c094cf9..edc0d4d 100644 --- a/src/Utils/PhpDoc.php +++ b/src/Utils/PhpDoc.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Utils; -final class PhpDoc +final readonly class PhpDoc { private const array REGEX_PARTS = [ '{cn}' => '[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*', diff --git a/src/Utils/Reflections.php b/src/Utils/Reflections.php index 9a8fb1c..0033a2b 100644 --- a/src/Utils/Reflections.php +++ b/src/Utils/Reflections.php @@ -2,18 +2,18 @@ namespace Le0daniel\PhpTsBindings\Utils; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use ReflectionFunction; use ReflectionMethod; use ReflectionParameter; use ReflectionProperty; -use RuntimeException; -final class Reflections +final readonly class Reflections { public static function getDocBlockExtendedType(ReflectionProperty|ReflectionParameter $propertyOrParameter): string { if (!$propertyOrParameter->getType()) { - throw new RuntimeException("No type defined."); + throw new ParserException("No type defined."); } $typeString = match (true) { @@ -26,7 +26,7 @@ public static function getDocBlockExtendedType(ReflectionProperty|ReflectionPara private static function getParameterTypeString(ReflectionParameter $parameter): string { if (!$parameter->getType()) { - throw new RuntimeException("No type defined."); + throw new ParserException("No type defined."); } $declaringFnDoc = $parameter->getDeclaringFunction()->getDocComment(); @@ -40,7 +40,7 @@ private static function getParameterTypeString(ReflectionParameter $parameter): private static function getPropertyTypeString(ReflectionProperty $property): string { if (!$property->hasType()) { - throw new RuntimeException("No type defined."); + throw new ParserException("No type defined."); } if ($property->getDocComment()) { diff --git a/src/Utils/Regexes.php b/src/Utils/Regexes.php index 8628a0d..851a6b9 100644 --- a/src/Utils/Regexes.php +++ b/src/Utils/Regexes.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Utils; -final class Regexes +final readonly class Regexes { public static function findFirstVarDeclaration(string $docBlocks): ?string { diff --git a/src/Utils/Strings.php b/src/Utils/Strings.php index d50e6e5..f4c894b 100644 --- a/src/Utils/Strings.php +++ b/src/Utils/Strings.php @@ -4,11 +4,11 @@ use UnitEnum; -final class Strings +final readonly class Strings { /** - * @param class-string $className - * @return string + * The last segment of a backslash separated name. Not restricted to class-string: it is also + * used on namespaces and on names parsed out of `use` statements, which are unverified. */ public static function classBaseName(string $className): string { diff --git a/tests/Feature/Mocks/CreateUserInput.php b/tests/Feature/Mocks/CreateUserInput.php index 7825d01..12ed010 100644 --- a/tests/Feature/Mocks/CreateUserInput.php +++ b/tests/Feature/Mocks/CreateUserInput.php @@ -3,8 +3,7 @@ namespace Tests\Feature\Mocks; use Le0daniel\PhpTsBindings\Contracts\Attributes\Castable; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; -use Le0daniel\PhpTsBindings\Validators\Email; +use Le0daniel\PhpTsBindings\Constraints\Email; #[Castable] final class CreateUserInput diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index fe5dbab..fc30c1f 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -9,13 +9,13 @@ use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\CachedOperationRegistry; -use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; +use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Presenter\ExposedExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Server; use Tests\Feature\Mocks\NotAMiddleware; function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { - $registry = EagerlyLoadedRegistry::eagerlyDiscover(__DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator); + $registry = EagerlyLoadedOperationRegistry::eagerlyDiscover(__DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator); $cachedRegistry = eval(CachedOperationRegistry::toPhpCode($registry, idLength: 10)); $server = new Server($registry, [new ExposedExceptionPresenter(),],); @@ -56,7 +56,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { test("A middleware that does not implement the contract yields an RpcError", function () { $server = new Server( - EagerlyLoadedRegistry::eagerlyDiscover( + EagerlyLoadedOperationRegistry::eagerlyDiscover( __DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator ), @@ -75,7 +75,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { test("Middleware emits typescript middleware", function () { $server = new Server( - EagerlyLoadedRegistry::eagerlyDiscover( + EagerlyLoadedOperationRegistry::eagerlyDiscover( __DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator ), @@ -86,7 +86,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { $operation = $server->registry->get(OperationType::COMMAND, 'test.run'); $errorPresenter = new ExposedExceptionPresenter(); - $definition = $errorPresenter->toTypeScriptDefinition($operation->definition); + $definition = $errorPresenter->toTypescriptDefinition($operation->definition); expect($definition)->toEqual('{type: "invalid_name"}'); }); /** diff --git a/tests/Pest.php b/tests/Pest.php index 777f8e3..4fc9575 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -22,7 +22,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; @@ -125,7 +125,7 @@ function compareToOptimizedAst(NodeInterface $node) { * MetadataNode is transparent in the string form, so the structural parity assertion holds for * every schema, metadata or not. */ -function typescriptFor(NodeInterface $node, IO $io, ?AliasRegistry $sharedRegistry = null): TypeScript +function typescriptFor(NodeInterface $node, IO $io, ?AliasRegistry $sharedRegistry = null): Typescript { compareToOptimizedAst($node); diff --git a/tests/Unit/CodeGen/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php index 5fa47a0..aa5fbb9 100644 --- a/tests/Unit/CodeGen/EmitQueryKeyTest.php +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -9,7 +9,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Tests\Mocks\ValueObjects\Email; @@ -42,9 +42,9 @@ function queryOperation(): Operation test('imports the aliases the inlined input definition carries', function () { [$code, $rendered] = queryKeyCodeFor(new TypedOperation( - new TypeScript('{status:OrderStatus;}', new AliasRegistry(['OrderStatus' => '"OPEN"|"SHIPPED"'])), - new TypeScript('Order', new AliasRegistry(['Order' => '{id:number;}'])), - TypeScript::fromRawString(''), + new Typescript('{status:OrderStatus;}', new AliasRegistry(['OrderStatus' => '"OPEN"|"SHIPPED"'])), + new Typescript('Order', new AliasRegistry(['Order' => '{id:number;}'])), + Typescript::fromRawString(''), queryOperation(), )); @@ -56,16 +56,16 @@ function queryOperation(): Operation test('always imports the Brand helper, whether the input renders an inline brand or not', function () { [, $withBrand] = queryKeyCodeFor(new TypedOperation( - new TypeScript('{id:number & Brand<"customerId">;}', new AliasRegistry()), - TypeScript::fromRawString('string'), - TypeScript::fromRawString(''), + new Typescript('{id:number & Brand<"customerId">;}', new AliasRegistry()), + Typescript::fromRawString('string'), + Typescript::fromRawString(''), queryOperation(), )); [, $withoutBrand] = queryKeyCodeFor(new TypedOperation( - TypeScript::fromRawString('{id:number;}'), - TypeScript::fromRawString('string'), - TypeScript::fromRawString(''), + Typescript::fromRawString('{id:number;}'), + Typescript::fromRawString('string'), + Typescript::fromRawString(''), queryOperation(), )); diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index bd1f6db..72c76eb 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -11,7 +11,7 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Mocks\ValueObjects\Email; @@ -32,7 +32,7 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); $files = new EmitTypeUtils()->emitFiles( - [new TypedOperation($input, $output, TypeScript::fromRawString(''), $operation)], + [new TypedOperation($input, $output, Typescript::fromRawString(''), $operation)], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), $registry, ); diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index d4a0da2..4c06fdb 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -11,7 +11,7 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; @@ -41,7 +41,7 @@ function emitTypesFor(string $inputType, string $outputType): string $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); $files = new EmitTypes()->emitFiles( - [new TypedOperation($input, $output, TypeScript::fromRawString(''), $operation)], + [new TypedOperation($input, $output, Typescript::fromRawString(''), $operation)], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), $registry, ); diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index f1cd63d..69413bb 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -11,7 +11,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; -use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; +use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; @@ -28,7 +28,7 @@ function generateFor(array $classes, ?array $generators = null): array { $server = new Server( - EagerlyLoadedRegistry::withClasses($classes, keyGenerator: new PlainlyExposedKeyGenerator()), + EagerlyLoadedOperationRegistry::withClasses($classes, keyGenerator: new PlainlyExposedKeyGenerator()), [], ); diff --git a/tests/Unit/Validators/EmailTest.php b/tests/Unit/Constraints/EmailTest.php similarity index 94% rename from tests/Unit/Validators/EmailTest.php rename to tests/Unit/Constraints/EmailTest.php index 40a51e3..038bdde 100644 --- a/tests/Unit/Validators/EmailTest.php +++ b/tests/Unit/Constraints/EmailTest.php @@ -1,13 +1,13 @@ context = new Context(); }); it('validates string length correctly', function () { - $validator = new LengthValidator(min: 2, max: 5); + $validator = new Length(min: 2, max: 5); expect($validator->validate('a', $this->context))->toBeFalse() ->and($validator->validate('ab', $this->context))->toBeTrue() @@ -20,7 +20,7 @@ }); it('validates array count correctly', function () { - $validator = new LengthValidator(min: 1, max: 3); + $validator = new Length(min: 1, max: 3); expect($validator->validate([], $this->context))->toBeFalse() ->and($validator->validate([1], $this->context))->toBeTrue() @@ -29,7 +29,7 @@ }); it('validates integer values directly', function () { - $validator = new LengthValidator(min: 5, max: 10); + $validator = new Length(min: 5, max: 10); expect($validator->validate(4, $this->context))->toBeFalse() ->and($validator->validate(5, $this->context))->toBeTrue() @@ -39,7 +39,7 @@ }); it('handles non-including boundaries correctly', function () { - $validator = new LengthValidator(min: 5, max: 10, including: false); + $validator = new Length(min: 5, max: 10, including: false); expect($validator->validate(5, $this->context))->toBeFalse() ->and($validator->validate(6, $this->context))->toBeTrue() @@ -48,7 +48,7 @@ }); it('handles null min correctly', function () { - $validator = new LengthValidator(max: 5); + $validator = new Length(max: 5); expect($validator->validate(1, $this->context))->toBeTrue() ->and($validator->validate(5, $this->context))->toBeTrue() @@ -56,7 +56,7 @@ }); it('handles null max correctly', function () { - $validator = new LengthValidator(min: 5); + $validator = new Length(min: 5); expect($validator->validate(4, $this->context))->toBeFalse() ->and($validator->validate(5, $this->context))->toBeTrue() @@ -64,7 +64,7 @@ }); it('returns false for invalid types', function () { - $validator = new LengthValidator(min: 1, max: 5); + $validator = new Length(min: 1, max: 5); expect($validator->validate(null, $this->context))->toBeFalse() ->and($validator->validate(new \stdClass(), $this->context))->toBeFalse() @@ -72,13 +72,13 @@ }); it('exports PHP code correctly', function () { - $validator = new LengthValidator(min: 5, max: 10, including: false); - $expected = 'new \\' . LengthValidator::class . '(5, 10, false)'; + $validator = new Length(min: 5, max: 10, including: false); + $expected = 'new \\' . Length::class . '(5, 10, false)'; expect($validator->exportPhpCode())->toBe($expected); }); it('adds correct validation issues to context', function () { - $validator = new LengthValidator(min: 2, max: 4); + $validator = new Length(min: 2, max: 4); $context = new Context(); // Test invalid type diff --git a/tests/Unit/Constraints/StringConstraintsTest.php b/tests/Unit/Constraints/StringConstraintsTest.php new file mode 100644 index 0000000..33a5199 --- /dev/null +++ b/tests/Unit/Constraints/StringConstraintsTest.php @@ -0,0 +1,47 @@ +toBeSuccess(); +}); + +test('non-falsy-string rejects "0"', function () { + expect(executeParse('non-falsy-string', '0'))->toBeFailure(); + expect(executeParse('truthy-string', '0'))->toBeFailure(); +}); + +test('both reject the empty string', function (string $type) { + expect(executeParse($type, ''))->toBeFailure(); +})->with(['non-empty-string', 'non-falsy-string', 'truthy-string']); + +test('both accept an ordinary string', function (string $type) { + expect(executeParse($type, 'hello'))->toBeSuccess(); +})->with(['non-empty-string', 'non-falsy-string', 'truthy-string']); + +test('both reject a non-string', function (string $type) { + expect(executeParse($type, 42))->toBeFailure(); +})->with(['non-empty-string', 'non-falsy-string', 'truthy-string']); + +// Every other constraint reports through the IssueMessage enum; a raw string key leaks a +// translation key that consumers cannot discover from the contract. +test('the non-empty-string failure reports an IssueMessage, not a raw key', function () { + $result = executeParse('non-empty-string', ''); + $keys = array_map( + fn($issue) => $issue->messageOrLocalizationKey, + $result->issues()->allFlat(), + ); + + expect($keys)->toContain(IssueMessage::NOT_EMPTY_STRING->value); +}); diff --git a/tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php b/tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php new file mode 100644 index 0000000..18dfe48 --- /dev/null +++ b/tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php @@ -0,0 +1,37 @@ +namespaceAsString())->toBeNull() + ->and(new Command()->namespaceAsString())->toBeNull(); +}); + +test('namespaceAsString resolves a string namespace verbatim', function () { + expect(new Query(namespace: 'users')->namespaceAsString())->toBe('users') + ->and(new Command(namespace: 'users')->namespaceAsString())->toBe('users'); +}); + +test('namespaceAsString resolves a backed enum to its value', function () { + expect(new Query(namespace: StatusEnum::ACTIVE)->namespaceAsString())->toBe('active') + ->and(new Command(namespace: StatusEnum::ACTIVE)->namespaceAsString())->toBe('active'); +}); + +test('namespaceAsString resolves a pure enum to its case name', function () { + expect(new Query(namespace: ResultEnum::SUCCESS)->namespaceAsString())->toBe('SUCCESS'); +}); + +/** + * "0" is falsy in PHP but is a perfectly legal namespace. A truthiness check silently dropped it, + * so OperationDiscovery fell back to the default namespace and the operation was registered under + * a key the author never wrote. + */ +test('namespaceAsString keeps a namespace that happens to be falsy', function (string $namespace) { + expect(new Query(namespace: $namespace)->namespaceAsString())->toBe($namespace) + ->and(new Command(namespace: $namespace)->namespaceAsString())->toBe($namespace); +})->with(['zero string' => ['0'], 'empty string' => ['']]); diff --git a/tests/Unit/Contracts/ExceptionHierarchyTest.php b/tests/Unit/Contracts/ExceptionHierarchyTest.php new file mode 100644 index 0000000..32161ee --- /dev/null +++ b/tests/Unit/Contracts/ExceptionHierarchyTest.php @@ -0,0 +1,96 @@ +toBeTrue(); +})->with([ + InvalidSyntaxException::class, + UnknownTypeKeyException::class, + UnexpectedCharacterException::class, + ParserException::class, + InvalidStringLiteralException::class, + UnknownAliasException::class, + UnsupportedTypeException::class, + InvalidGeneratorDependencies::class, + CodeGenException::class, + InvalidInputException::class, + InvalidOutputException::class, + OperationNotFoundException::class, + InvalidMiddlewareException::class, + SchemaException::class, +]); + +test('parser failures are catchable as ParserException', function (string $class) { + expect(is_a($class, ParserException::class, true))->toBeTrue(); +})->with([ + InvalidSyntaxException::class, + UnknownTypeKeyException::class, + UnexpectedCharacterException::class, +]); + +test('code generation failures are catchable as CodeGenException', function (string $class) { + expect(is_a($class, CodeGenException::class, true))->toBeTrue(); +})->with([ + InvalidStringLiteralException::class, + UnknownAliasException::class, + UnsupportedTypeException::class, + InvalidGeneratorDependencies::class, +]); + +test('operation failures are catchable as SchemaException', function (string $class) { + expect(is_a($class, SchemaException::class, true))->toBeTrue(); +})->with([ + InvalidInputException::class, + InvalidOutputException::class, + OperationNotFoundException::class, + InvalidMiddlewareException::class, +]); + +test('the subsystem bases are distinct so a catch cannot over-capture', function () { + expect(is_a(ParserException::class, CodeGenException::class, true))->toBeFalse() + ->and(is_a(ParserException::class, SchemaException::class, true))->toBeFalse() + ->and(is_a(CodeGenException::class, SchemaException::class, true))->toBeFalse(); +}); + +/** + * The headline case: a consumer wrapping the parser had nothing to catch but \Throwable. + * + * Written as a real catch rather than toThrow() because Pest treats an interface name as a message + * to match against, so toThrow(PhpTsBindingsException::class) would pass for the wrong reason. + */ +test('a malformed type reaches the consumer as a PhpTsBindingsException', function (string $type) { + try { + new TypeParser()->parse($type, new ParsingContext()); + $this->fail("Expected '{$type}' to be rejected."); + } catch (PhpTsBindingsException) { + expect(true)->toBeTrue(); + } +})->with([ + 'unterminated generic' => ['array<'], + 'unknown identifier' => ['ThisTypeDoesNotExistAnywhere'], + 'stray character' => ['string %'], +]); diff --git a/tests/Unit/Executor/ResultTest.php b/tests/Unit/Executor/ResultTest.php new file mode 100644 index 0000000..33c6ce7 --- /dev/null +++ b/tests/Unit/Executor/ResultTest.php @@ -0,0 +1,56 @@ +not->toBeInstanceOf(\Throwable::class); +}); + +test('both arms of a result implement Result', function () { + expect(new Success('value'))->toBeInstanceOf(Result::class) + ->and(new Failure(new Issues()))->toBeInstanceOf(Result::class); +}); + +test('isSuccess distinguishes the two arms without instanceof', function () { + expect(new Success('value')->isSuccess())->toBeTrue() + ->and(new Failure(new Issues())->isSuccess())->toBeFalse(); +}); + +test('issues are reachable through the Result contract on both arms', function (Result $result) { + expect($result->issues())->toBeInstanceOf(Issues::class); +})->with([ + 'success' => [fn() => new Success('value')], + 'failure' => [fn() => new Failure(new Issues())], +]); + +test('a returned failure cannot be caught as an exception', function () { + $executor = new SchemaExecutor(); + + try { + $result = $executor->parse(new StringNode(), 42); + } catch (\Exception $e) { + $this->fail('A failed parse must be returned, not thrown: ' . $e::class); + } + + expect($result)->toBeInstanceOf(Failure::class) + ->and($result->isSuccess())->toBeFalse(); +}); + +test('the executor still narrows to the concrete arms', function () { + $executor = new SchemaExecutor(); + + expect($executor->parse(new StringNode(), 'ok'))->toBeInstanceOf(Success::class) + ->and($executor->parse(new StringNode(), 42))->toBeInstanceOf(Failure::class); +}); diff --git a/tests/Unit/Parser/ASTOptimizerTest.php b/tests/Unit/Parser/ASTOptimizerTest.php index 3379a20..0eb5ba8 100644 --- a/tests/Unit/Parser/ASTOptimizerTest.php +++ b/tests/Unit/Parser/ASTOptimizerTest.php @@ -124,7 +124,7 @@ function assertPooledParity(array $schemas, array $probes): void test('C3: unions differing only in discriminator do not share an entry', function () { $discriminated = new TypeParser()->parse("array{kind: 'a', v: string}|array{kind: 'b', v: int}"); - $plain = new Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode($discriminated->types); + $plain = new Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode($discriminated->nodes); expect($discriminated->exportPhpCode())->not->toBe($plain->exportPhpCode()); diff --git a/tests/Unit/Parser/MetadataEliminationTest.php b/tests/Unit/Parser/MetadataEliminationTest.php index 087cb4d..a2dd20f 100644 --- a/tests/Unit/Parser/MetadataEliminationTest.php +++ b/tests/Unit/Parser/MetadataEliminationTest.php @@ -2,6 +2,7 @@ use Le0daniel\PhpTsBindings\Parser\ASTOptimizer; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\NamedType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; @@ -118,12 +119,12 @@ function containsMetadataNode(NodeInterface $node): bool $node = new MetadataNode(new MetadataNode(new StringNode(), null, 'inner'), null, 'outer'); expect(fn() => $node->validate()) - ->toThrow(InvalidArgumentException::class, 'should not be nested'); + ->toThrow(ParserException::class, 'should not be nested'); }); test('MetadataNode rejects carrying neither a name nor a brand', function () { expect(fn() => new MetadataNode(new StringNode())->validate()) - ->toThrow(InvalidArgumentException::class, 'meaningless'); + ->toThrow(ParserException::class, 'meaningless'); }); test('unwrapMetadata strips the wrapper and leaves everything else alone', function () { @@ -136,7 +137,7 @@ function containsMetadataNode(NodeInterface $node): bool test('unwrapMetadata keeps constraints attached, unlike getDeclaringNode', function () { $constrained = new Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode( new StringNode(), - [new Le0daniel\PhpTsBindings\Validators\NonEmptyString()], + [new Le0daniel\PhpTsBindings\Constraints\NonEmptyString()], ); $wrapped = new MetadataNode($constrained, null, 'tag'); diff --git a/tests/Unit/Parser/NodeDiagnosticStringTest.php b/tests/Unit/Parser/NodeDiagnosticStringTest.php index 33cf480..fe32023 100644 --- a/tests/Unit/Parser/NodeDiagnosticStringTest.php +++ b/tests/Unit/Parser/NodeDiagnosticStringTest.php @@ -7,7 +7,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Validators\NonEmptyString; +use Le0daniel\PhpTsBindings\Constraints\NonEmptyString; /** * __toString() is the label a developer sees in error messages and debug output. It no longer @@ -39,7 +39,7 @@ test('a discriminated union names its discriminator', function () { $discriminated = new TypeParser()->parse("array{kind: 'a', v: string}|array{kind: 'b', v: int}"); - $plain = new Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode($discriminated->types); + $plain = new Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode($discriminated->nodes); expect((string)$discriminated)->toContain('kind') ->and((string)$discriminated)->not->toBe((string)$plain); diff --git a/tests/Unit/Parser/OptimizeAndWriteToFileTest.php b/tests/Unit/Parser/OptimizeAndWriteToFileTest.php new file mode 100644 index 0000000..98a4856 --- /dev/null +++ b/tests/Unit/Parser/OptimizeAndWriteToFileTest.php @@ -0,0 +1,71 @@ +file = sys_get_temp_dir() . '/php-ts-bindings-asts-' . getmypid() . '.php'; +}); + +afterEach(function () { + if (is_file($this->file)) { + unlink($this->file); + } +}); + +test('the written file round-trips: optimize, require, execute', function () { + $parser = new TypeParser(); + + new ASTOptimizer()->optimizeAndWriteToFile($this->file, [ + 'account@output' => $parser->parse('\\' . AccountData::class), + 'scalar@input' => $parser->parse('int'), + ]); + + $registry = require $this->file; + expect($registry)->toBeInstanceOf(CachedTypeRegistry::class); + + $executor = new SchemaExecutor(); + expect($executor->parse($registry->get('scalar@input'), 42))->toBeSuccess() + ->and($executor->parse($registry->get('scalar@input'), 'nope'))->toBeFailure(); + + // A schema loaded from the cache behaves exactly like the one it was built from. + $serialized = $executor->serialize($registry->get('account@output'), new AccountData(1, 'Ada')); + expect($serialized)->toBeSuccess() + ->and($serialized->value->id)->toBe(1) + ->and($serialized->value->name)->toBe('Ada'); +}); + +test('the written file is valid PHP that returns a registry', function () { + new ASTOptimizer()->optimizeAndWriteToFile($this->file, [ + 'scalar@input' => new TypeParser()->parse('string'), + ]); + + $contents = file_get_contents($this->file); + expect($contents)->toStartWith('and($contents)->toContain('return new '); +}); + +// Byte-for-byte reproducibility is what lets a build compare a regenerated cache against the +// committed one to decide whether it is stale. +test('writing the same schemas twice produces identical bytes', function () { + $second = $this->file . '.second'; + + foreach ([$this->file, $second] as $path) { + new ASTOptimizer()->optimizeAndWriteToFile($path, [ + 'account@input' => new TypeParser()->parse('\\' . AccountData::class), + ]); + } + + expect(file_get_contents($this->file))->toBe(file_get_contents($second)); + unlink($second); +}); diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index f918132..67e7400 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -29,7 +29,7 @@ use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; -use Le0daniel\PhpTsBindings\Validators\Email; +use Le0daniel\PhpTsBindings\Constraints\Email; use Tests\Feature\Mocks\Paginated; use Tests\Mocks\ResultEnum; use Tests\Unit\Parser\Data\Stubs\Address; @@ -64,7 +64,7 @@ * @var int $index * @var LiteralNode $type */ - foreach ($node->types as $index => $type) { + foreach ($node->nodes as $index => $type) { match ($index) { 0 => expect($type)->toBeInstanceOf(LiteralNode::class) ->and($type->value)->toBe(7) @@ -91,11 +91,11 @@ $node = $parser->parse('?'.FullAccount::class); expect($node)->toBeInstanceOf(UnionNode::class) - ->and($node->types[0])->toBeInstanceOf(NullNode::class) - ->and($node->types[1])->toBeInstanceOf(CustomCastingNode::class) - ->and($node->types[1]->node)->toBeInstanceOf(StructNode::class) - ->and($node->types[1]->node->phpType)->toEqual(StructPhpType::ARRAY) - ->and($node->types[1]->strategy)->toEqual(ObjectCastStrategy::NEVER); + ->and($node->nodes[0])->toBeInstanceOf(NullNode::class) + ->and($node->nodes[1])->toBeInstanceOf(CustomCastingNode::class) + ->and($node->nodes[1]->node)->toBeInstanceOf(StructNode::class) + ->and($node->nodes[1]->node->phpType)->toEqual(StructPhpType::ARRAY) + ->and($node->nodes[1]->strategy)->toEqual(ObjectCastStrategy::NEVER); }); test('test scalar', function () { @@ -105,7 +105,7 @@ $node = $parser->parse("scalar"); expect($node)->toBeInstanceOf(UnionNode::class); - foreach ($node->types as $index => $type) { + foreach ($node->nodes as $index => $type) { match ($index) { 0 => expect($type)->toBeInstanceOf(IntNode::class), 1 => expect($type)->toBeInstanceOf(FloatNode::class), @@ -124,8 +124,8 @@ expect($node)->toBeInstanceOf(UnionNode::class); - expect($node->types[0])->toBeInstanceOf(NullNode::class); - expect($node->types[1])->toBeInstanceOf(FloatNode::class); + expect($node->nodes[0])->toBeInstanceOf(NullNode::class); + expect($node->nodes[1])->toBeInstanceOf(FloatNode::class); compareToOptimizedAst($node); }); @@ -144,9 +144,9 @@ expect($node)->toBeInstanceOf(UnionNode::class); - expect($node->types[0])->toBeInstanceOf(NullNode::class); - expect($node->types[1])->toBeInstanceOf(FloatNode::class); - expect($node->types[2])->toBeInstanceOf(StringNode::class); + expect($node->nodes[0])->toBeInstanceOf(NullNode::class); + expect($node->nodes[1])->toBeInstanceOf(FloatNode::class); + expect($node->nodes[2])->toBeInstanceOf(StringNode::class); compareToOptimizedAst($node); }); @@ -230,7 +230,7 @@ /** @var UnionNode $node */ $node = $parser->parse("numeric"); - foreach ($node->types as $index => $type) { + foreach ($node->nodes as $index => $type) { match ($index) { 0 => expect($type)->toBeInstanceOf(IntNode::class), 1 => expect($type)->toBeInstanceOf(FloatNode::class), @@ -355,8 +355,8 @@ $node = $parser->parse("array{string, int}"); expect($node)->toBeInstanceOf(TupleNode::class); - expect($node->types[0])->toBeInstanceOf(StringNode::class); - expect($node->types[1])->toBeInstanceOf(IntNode::class); + expect($node->nodes[0])->toBeInstanceOf(StringNode::class); + expect($node->nodes[1])->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -367,8 +367,8 @@ $node = $parser->parse("array{0:string, 1: int}"); expect($node)->toBeInstanceOf(TupleNode::class); - expect($node->types[0])->toBeInstanceOf(StringNode::class); - expect($node->types[1])->toBeInstanceOf(IntNode::class); + expect($node->nodes[0])->toBeInstanceOf(StringNode::class); + expect($node->nodes[1])->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -403,8 +403,8 @@ expect($node->node)->toBeInstanceOf(UnionNode::class); - expect($node->node->types[0])->toBeInstanceOf(StringNode::class); - expect($node->node->types[1])->toBeInstanceOf(IntNode::class); + expect($node->node->nodes[0])->toBeInstanceOf(StringNode::class); + expect($node->node->nodes[1])->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); @@ -445,7 +445,7 @@ $node = $parser->parse("1|-2|true|false|'string'"); expect($node)->toBeInstanceOf(UnionNode::class); - foreach ($node->types as $index => $type) { + foreach ($node->nodes as $index => $type) { match ($index) { 0 => expect($type->value)->toBe(1), 1 => expect($type->value)->toBe(-2), @@ -487,7 +487,7 @@ ); expect($node)->toBeInstanceOf(UnionNode::class); - foreach ($node->types as $index => $type) { + foreach ($node->nodes as $index => $type) { match ($index) { 0 => expect($type->value)->toBe(ResultEnum::SUCCESS), 1 => expect($type->value)->toBe(ResultEnum::FAILURE), @@ -761,8 +761,8 @@ expect($node)->toBeInstanceOf(UnionNode::class) ->and($node->acceptsNull())->toBeTrue() - ->and($node->types[0])->toBeInstanceOf(NullNode::class) - ->and($node->types[1])->toBeInstanceOf(BoolNode::class); + ->and($node->nodes[0])->toBeInstanceOf(NullNode::class) + ->and($node->nodes[1])->toBeInstanceOf(BoolNode::class); compareToOptimizedAst($node); }); @@ -908,10 +908,10 @@ $node = new TypeParser()->parse("'it\\'s'|\"say \\\"hi\\\"\""); expect($node)->toBeInstanceOf(UnionNode::class) - ->and($node->types[0])->toBeInstanceOf(LiteralNode::class) - ->and($node->types[0]->type)->toBe(LiteralType::STRING) - ->and($node->types[0]->value)->toBe("it's") - ->and($node->types[1]->value)->toBe('say "hi"'); + ->and($node->nodes[0])->toBeInstanceOf(LiteralNode::class) + ->and($node->nodes[0]->type)->toBe(LiteralType::STRING) + ->and($node->nodes[0]->value)->toBe("it's") + ->and($node->nodes[1]->value)->toBe('say "hi"'); }); test('Whitespace between brackets is allowed', function () { @@ -1000,11 +1000,11 @@ /** @var UnionNode $node */ $node = new TypeParser()->parse('true|false'); - expect($node->types[0])->toBeInstanceOf(LiteralNode::class) - ->and($node->types[0]->type)->toBe(LiteralType::BOOL) - ->and($node->types[0]->value)->toBeTrue() - ->and($node->types[1]->type)->toBe(LiteralType::BOOL) - ->and($node->types[1]->value)->toBeFalse() + expect($node->nodes[0])->toBeInstanceOf(LiteralNode::class) + ->and($node->nodes[0]->type)->toBe(LiteralType::BOOL) + ->and($node->nodes[0]->value)->toBeTrue() + ->and($node->nodes[1]->type)->toBe(LiteralType::BOOL) + ->and($node->nodes[1]->value)->toBeFalse() ->and(new TypeParser()->parse('null'))->toBeInstanceOf(NullNode::class); }); diff --git a/tests/Unit/Typescript/TypeRegistryTest.php b/tests/Unit/Typescript/AliasRegistryTest.php similarity index 100% rename from tests/Unit/Typescript/TypeRegistryTest.php rename to tests/Unit/Typescript/AliasRegistryTest.php diff --git a/tests/Unit/Typescript/Code/TypescriptImportTest.php b/tests/Unit/Typescript/Code/TypescriptImportTest.php index de9c054..f2e706b 100644 --- a/tests/Unit/Typescript/Code/TypescriptImportTest.php +++ b/tests/Unit/Typescript/Code/TypescriptImportTest.php @@ -1,5 +1,6 @@ new TypescriptImport('')) - ->toThrow(InvalidArgumentException::class, 'cannot be written as a TypeScript module specifier'); + ->toThrow(CodeGenException::class, 'cannot be written as a TypeScript module specifier'); }); test('rejects a module specifier that could not be written as a string literal', function (string $from) { expect(fn() => TypescriptImport::values($from, 'a')) - ->toThrow(InvalidArgumentException::class, 'cannot be written as a TypeScript module specifier'); + ->toThrow(CodeGenException::class, 'cannot be written as a TypeScript module specifier'); })->with([ 'single quote' => ["./li'b"], 'double quote' => ['./li"b'], @@ -148,14 +149,15 @@ test('refuses to merge imports of different modules', function () { expect(fn() => TypescriptImport::types('./lib/types', 'Brand') ->merge(TypescriptImport::types('./lib/utils', 'Brand'))) - ->toThrow(InvalidArgumentException::class, 'different modules'); + ->toThrow(CodeGenException::class, 'different modules'); }); test('merging leaves both operands untouched', function () { $one = TypescriptImport::types('./lib/types', 'Brand'); $two = TypescriptImport::values('./lib/types', 'queryKey'); - $one->merge($two); + // Discarding the result is the point of this test, hence the explicit (void). + (void) $one->merge($two); expect($one->types)->toBe(['Brand']) ->and($one->values)->toBe([]) diff --git a/tests/Unit/Typescript/NamedTypesTest.php b/tests/Unit/Typescript/NamedTypesTest.php index e05588b..ad135af 100644 --- a/tests/Unit/Typescript/NamedTypesTest.php +++ b/tests/Unit/Typescript/NamedTypesTest.php @@ -1,5 +1,6 @@ new TypescriptGenerator()->toTypescript(new StringNode(), IO::BOTH)) - ->toThrow(InvalidArgumentException::class, 'IO::BOTH'); + ->toThrow(CodeGenException::class, 'IO::BOTH'); }); test('two named nodes claiming one alias with different shapes are rejected', function () { diff --git a/tests/Unit/Typescript/TypescriptGeneratorTest.php b/tests/Unit/Typescript/TypescriptGeneratorTest.php index 217a92c..3ed690d 100644 --- a/tests/Unit/Typescript/TypescriptGeneratorTest.php +++ b/tests/Unit/Typescript/TypescriptGeneratorTest.php @@ -10,7 +10,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\Data\IO; -use Le0daniel\PhpTsBindings\Typescript\Data\TypeScript; +use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; @@ -30,7 +30,7 @@ function typescriptOf( string|NodeInterface $type, IO $io = IO::INPUT, ?AliasRegistry $sharedRegistry = null, -): TypeScript +): Typescript { $node = is_string($type) ? new TypeParser()->parse($type) : $type; return new TypescriptGenerator()->toTypescript($node, $io, $sharedRegistry); diff --git a/tests/Unit/Typescript/Utils/SyntaxTest.php b/tests/Unit/Typescript/Utils/SyntaxTest.php index 955c349..b3241cd 100644 --- a/tests/Unit/Typescript/Utils/SyntaxTest.php +++ b/tests/Unit/Typescript/Utils/SyntaxTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Typescript\Utils; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; test('object key', function () { @@ -10,3 +11,29 @@ expect(Syntax::objectKey('foo a'))->toBe('"foo a"'); expect(Syntax::objectKey('foo a', true))->toBe('"foo a"?'); }); + +// objectKey() used to carry its own identifier regex that rejected `$`, while isValidIdentifier() +// accepted it. One question must have one answer. +test('object key agrees with isValidIdentifier on every input', function (string $key) { + expect(Syntax::objectKey($key) === $key)->toBe(Syntax::isValidIdentifier($key)); +})->with(['foo', '$foo', 'foo$bar', '_foo', 'Foo1', 'foo a', '1foo', '', 'foo-bar']); + +test('module specifier is single quoted', function () { + expect(Syntax::moduleSpecifier('./types'))->toBe("'./types'"); + expect(Syntax::moduleSpecifier('@scope/pkg'))->toBe("'@scope/pkg'"); +}); + +// TypescriptImport rejects these before ever reaching here, but Syntax is public and must not +// emit a specifier that silently names a module that does not exist. +test('module specifier rejects anything that would not survive the string literal', function (string $specifier) { + expect(fn() => Syntax::moduleSpecifier($specifier)) + ->toThrow(CodeGenException::class, 'cannot be written as a TypeScript module specifier'); +})->with([ + 'empty' => [''], + 'single quote' => ["./ty'pes"], + 'double quote' => ['./ty"pes'], + 'backslash' => ['.\\types'], + 'space' => ['./my types'], + 'newline' => ["./types\n"], + 'tab' => ["./ty\tpes"], +]); diff --git a/tests/Unit/Utils/PHPExportTest.php b/tests/Unit/Utils/PHPExportTest.php new file mode 100644 index 0000000..26cf33e --- /dev/null +++ b/tests/Unit/Utils/PHPExportTest.php @@ -0,0 +1,62 @@ +dir = sys_get_temp_dir() . '/php-ts-bindings-export-' . getmypid(); + if (!is_dir($this->dir)) { + mkdir($this->dir, 0o777, true); + } +}); + +afterEach(function () { + foreach (glob($this->dir . '/*') ?: [] as $file) { + unlink($file); + } + @rmdir($this->dir); +}); + +test('writes the file', function () { + $target = $this->dir . '/out.php'; + PHPExport::writeFileAtomically($target, 'toBe('dir . '/out.php'; + file_put_contents($target, 'old'); + PHPExport::writeFileAtomically($target, 'new'); + + expect(file_get_contents($target))->toBe('new'); +}); + +/** + * The cache writers require() what this produces while the application is serving traffic, so a + * reader must see either the whole old file or the whole new one - never a partial write. + */ +test('leaves no temporary file behind', function () { + $target = $this->dir . '/out.php'; + PHPExport::writeFileAtomically($target, str_repeat('x', 200_000)); + + expect(glob($this->dir . '/*'))->toBe([$target]); +}); + +test('a reader never observes a partially written file', function () { + $target = $this->dir . '/out.php'; + $complete = 'toBe($complete); +}); + +test('throws when the target directory is not writable', function () { + expect(fn() => PHPExport::writeFileAtomically($this->dir . '/missing/out.php', 'x')) + ->toThrow(ParserException::class, 'not a writable directory'); +}); From a05b491a305c30bb9a179fde57b110bfc022bc8f Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 31 Jul 2026 10:27:23 +0200 Subject: [PATCH 027/101] Consolidate error presenters into a single `ErrorPresenter` class; refactor error handling and TypeScript generation; add comprehensive unit tests. --- .../Laravel/LaravelHttpController.php | 3 + .../Laravel/LaravelServiceProvider.php | 21 +- src/Adapters/Laravel/config/config.php | 5 +- .../EmitOperationClientBindings.php | 2 +- src/CodeGen/TypescriptServerCodeGenerator.php | 22 +-- src/CodeGen/Utils/ErrorTypescript.php | 76 +++++++ src/Contracts/ExceptionPresenter.php | 39 ---- src/Server/Data/ServerConfiguration.php | 43 +++- src/Server/Errors/ErrorPresenter.php | 115 +++++++++++ src/Server/Errors/ExposedExceptions.php | 80 ++++++++ src/Server/Pipeline/ContextualPipeline.php | 9 +- src/Server/Presenter/CatchAllPresenter.php | 41 ---- .../Presenter/ExposedExceptionPresenter.php | 110 ----------- .../Presenter/InvalidInputPresenter.php | 46 ----- src/Server/Presenter/NotFoundPresenter.php | 50 ----- .../Presenter/UnauthenticatedPresenter.php | 50 ----- .../Presenter/UnauthorizedPresenter.php | 50 ----- src/Server/Server.php | 65 ++---- .../Laravel/LaravelHttpControllerTest.php | 19 +- tests/Feature/ServerTest.php | 18 +- tests/Mocks/Errors/ErrorOperations.php | 22 +++ tests/Mocks/Errors/ExposedDomainException.php | 11 ++ .../Errors/MiddlewareDomainException.php | 11 ++ tests/Mocks/Errors/RecordMissingException.php | 9 + tests/Mocks/Errors/ThrowingMiddleware.php | 23 +++ .../Errors/UndeclaredExposedException.php | 14 ++ tests/Mocks/Errors/UnexposedException.php | 12 ++ tests/Mocks/Errors/UserMissingException.php | 10 + tests/Unit/CodeGen/ErrorTypescriptTest.php | 93 +++++++++ .../TypescriptServerCodeGeneratorTest.php | 1 - .../Unit/Server/Errors/ErrorPresenterTest.php | 185 ++++++++++++++++++ 31 files changed, 747 insertions(+), 508 deletions(-) create mode 100644 src/CodeGen/Utils/ErrorTypescript.php delete mode 100644 src/Contracts/ExceptionPresenter.php create mode 100644 src/Server/Errors/ErrorPresenter.php create mode 100644 src/Server/Errors/ExposedExceptions.php delete mode 100644 src/Server/Presenter/CatchAllPresenter.php delete mode 100644 src/Server/Presenter/ExposedExceptionPresenter.php delete mode 100644 src/Server/Presenter/InvalidInputPresenter.php delete mode 100644 src/Server/Presenter/NotFoundPresenter.php delete mode 100644 src/Server/Presenter/UnauthenticatedPresenter.php delete mode 100644 src/Server/Presenter/UnauthorizedPresenter.php create mode 100644 tests/Mocks/Errors/ErrorOperations.php create mode 100644 tests/Mocks/Errors/ExposedDomainException.php create mode 100644 tests/Mocks/Errors/MiddlewareDomainException.php create mode 100644 tests/Mocks/Errors/RecordMissingException.php create mode 100644 tests/Mocks/Errors/ThrowingMiddleware.php create mode 100644 tests/Mocks/Errors/UndeclaredExposedException.php create mode 100644 tests/Mocks/Errors/UnexposedException.php create mode 100644 tests/Mocks/Errors/UserMissingException.php create mode 100644 tests/Unit/CodeGen/ErrorTypescriptTest.php create mode 100644 tests/Unit/Server/Errors/ErrorPresenterTest.php diff --git a/src/Adapters/Laravel/LaravelHttpController.php b/src/Adapters/Laravel/LaravelHttpController.php index 1a80cf5..a288355 100644 --- a/src/Adapters/Laravel/LaravelHttpController.php +++ b/src/Adapters/Laravel/LaravelHttpController.php @@ -157,6 +157,9 @@ private function produceJsonResponse(RpcSuccess|RpcError $result, Client $client $content = $this->appendClientDirectives([ 'success' => false, 'code' => $result->type->value, + // The discriminant the generated error union is narrowed on. The status code carries the + // same information, but only the client that reads the body can rely on it. + 'type' => $result->type->name, 'details' => $result->details ], $client); diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 038b956..564f515 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -17,12 +17,6 @@ use Le0daniel\PhpTsBindings\Server\KeyGenerators\HashSha256KeyGenerator; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; -use Le0daniel\PhpTsBindings\Server\Presenter\CatchAllPresenter; -use Le0daniel\PhpTsBindings\Server\Presenter\ExposedExceptionPresenter; -use Le0daniel\PhpTsBindings\Server\Presenter\InvalidInputPresenter; -use Le0daniel\PhpTsBindings\Server\Presenter\NotFoundPresenter; -use Le0daniel\PhpTsBindings\Server\Presenter\UnauthenticatedPresenter; -use Le0daniel\PhpTsBindings\Server\Presenter\UnauthorizedPresenter; use Le0daniel\PhpTsBindings\Server\Server; use Override; @@ -70,17 +64,14 @@ public static function serverFactory( return new Server( registry: $operations, - exceptionPresenters: [ - new InvalidInputPresenter(), - new UnauthorizedPresenter($config->get('operations.exceptions.unauthorized', [])), - new UnauthenticatedPresenter($config->get('operations.exceptions.unauthenticated', [])), - new NotFoundPresenter($config->get('operations.exceptions.not_found', [])), - new ExposedExceptionPresenter(), - ], - defaultPresenter: new CatchAllPresenter(), container: $app, configuration: new ServerConfiguration() - ->withMiddlewares(...config('operations.middleware', [])), + ->withMiddlewares(...$config->get('operations.middleware', [])) + ->withExceptions( + notFound: $config->get('operations.exceptions.not_found', []), + unauthenticated: $config->get('operations.exceptions.unauthenticated', []), + unauthorized: $config->get('operations.exceptions.unauthorized', []), + ), ); } diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index 201d6f2..56f8840 100644 --- a/src/Adapters/Laravel/config/config.php +++ b/src/Adapters/Laravel/config/config.php @@ -72,7 +72,10 @@ "middleware" => [], /** - * Map your exceptions to framework-specific exceptions. + * Map your exceptions onto the server's built-in error categories. Anything not listed here and + * not marked with #[ExposeAs] is reported to the client as an internal error. + * + * Matching is instanceof: listing a base class covers every subclass of it. */ "exceptions" => [ "unauthenticated" => [ diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index adc9275..268d16d 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -135,7 +135,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi ...json, success: false, code: json?.code ?? response.status, - type: response.type ?? 'INTERNAL_ERROR' + type: json?.type ?? 'INTERNAL_ERROR' } as WithClientDirectives>); } diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 9ec1d00..825f85e 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -9,9 +9,8 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; -use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; +use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\Parser\AstValidator; -use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; @@ -19,7 +18,6 @@ use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; -use Le0daniel\PhpTsBindings\Utils\Lists; final readonly class TypescriptServerCodeGenerator { @@ -95,7 +93,8 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore return new TypedOperation( inputDef: $this->typescriptGenerator->toTypescript($inputNode, IO::INPUT, $registry), outputDef: $this->typescriptGenerator->toTypescript($outputNode, IO::OUTPUT, $registry), - errorDef: $this->generateAllErrorTypes($server, $operation->definition) |> Typescript::fromRawString(...), + errorDef: ErrorTypescript::forOperation($server->configuration, $operation->definition) + |> Typescript::fromRawString(...), operation: $operation, ); }, $filteredDefinitions); @@ -114,21 +113,6 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore ]; } - private function generateAllErrorTypes(Server $server, Definition $operation): string - { - $possibleTypes = Lists::filterNullValues(array_map(static function (ExceptionPresenter $presenter) use ($operation): string { - $code = $presenter::errorType(); - $codeName = json_encode($code->name, JSON_THROW_ON_ERROR); - $details = $presenter->toTypescriptDefinition($operation); - - return $details === null - ? "{code: {$code->value}, type: {$codeName}}" - : "{code: {$code->value}, type: {$codeName}, details: {$details}}"; - }, [...$server->exceptionPresenters, $server->defaultPresenter])); - - return implode('|', $possibleTypes); - } - /** * @param list $definitions * @param ServerMetadata $metadata diff --git a/src/CodeGen/Utils/ErrorTypescript.php b/src/CodeGen/Utils/ErrorTypescript.php new file mode 100644 index 0000000..84bd35f --- /dev/null +++ b/src/CodeGen/Utils/ErrorTypescript.php @@ -0,0 +1,76 @@ +}'; + private const string UNAUTHENTICATED_DETAILS = '{type: "UNAUTHENTICATED"}'; + private const string UNAUTHORIZED_DETAILS = '{type: "UNAUTHORIZED"}'; + private const string NOT_FOUND_DETAILS = '{type: "NOT_FOUND"}'; + private const string INTERNAL_ERROR_DETAILS = '{type: "INTERNAL_SERVER_ERROR"}'; + + /** + * @throws ReflectionException + */ + public static function forOperation(ServerConfiguration $configuration, Definition $definition): string + { + $branches = [ + self::branch(ErrorType::INVALID_INPUT, self::INVALID_INPUT_DETAILS), + ]; + + if (!empty($configuration->unauthenticatedExceptions)) { + $branches[] = self::branch(ErrorType::AUTHENTICATION_ERROR, self::UNAUTHENTICATED_DETAILS); + } + + if (!empty($configuration->unauthorizedExceptions)) { + $branches[] = self::branch(ErrorType::AUTHORIZATION_ERROR, self::UNAUTHORIZED_DETAILS); + } + + $branches[] = self::branch(ErrorType::NOT_FOUND, self::NOT_FOUND_DETAILS); + + if ($domainDetails = self::domainDetails($definition)) { + $branches[] = self::branch(ErrorType::DOMAIN_ERROR, $domainDetails); + } + + $branches[] = self::branch(ErrorType::INTERNAL_ERROR, self::INTERNAL_ERROR_DETAILS); + + return implode('|', $branches); + } + + /** + * @throws ReflectionException + */ + private static function domainDetails(Definition $definition): ?string + { + $exposedTypes = ExposedExceptions::exposedTypesFor($definition); + if (empty($exposedTypes)) { + return null; + } + + return implode('|', array_map(static function (string $exposedType): string { + $type = json_encode($exposedType, JSON_THROW_ON_ERROR); + return "{type: {$type}}"; + }, $exposedTypes)); + } + + private static function branch(ErrorType $type, string $details): string + { + $name = json_encode($type->name, JSON_THROW_ON_ERROR); + return "{code: {$type->value}, type: {$name}, details: {$details}}"; + } +} diff --git a/src/Contracts/ExceptionPresenter.php b/src/Contracts/ExceptionPresenter.php deleted file mode 100644 index d301e2a..0000000 --- a/src/Contracts/ExceptionPresenter.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ - public function details(Throwable $throwable): array; - - /** - * Transport layer status code. - * @return ErrorType - */ - public static function errorType(): ErrorType; -} \ No newline at end of file diff --git a/src/Server/Data/ServerConfiguration.php b/src/Server/Data/ServerConfiguration.php index 1dc59eb..ce706a4 100644 --- a/src/Server/Data/ServerConfiguration.php +++ b/src/Server/Data/ServerConfiguration.php @@ -4,16 +4,26 @@ use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use NoDiscard; +use Throwable; final readonly class ServerConfiguration { /** + * The exception lists map application exceptions onto the server's finite error catalogue. + * Matching is instanceof, so listing a base class covers every subclass of it. + * * @param bool $coerceQueryInput * @param list>> $middleware + * @param list> $notFoundExceptions + * @param list> $unauthenticatedExceptions + * @param list> $unauthorizedExceptions */ public function __construct( public bool $coerceQueryInput = false, public array $middleware = [], + public array $notFoundExceptions = [], + public array $unauthenticatedExceptions = [], + public array $unauthorizedExceptions = [], ) { } @@ -28,8 +38,35 @@ public function withMiddlewares(string ...$middlewares): self // array_values is redundant at runtime - spreading two lists yields a list - but it is // what tells the type checker so, and $middleware is declared as a list. return new self( - $this->coerceQueryInput, - [...$this->middleware, ...$middlewares] |> array_values(...), + coerceQueryInput: $this->coerceQueryInput, + middleware: [...$this->middleware, ...$middlewares] |> array_values(...), + notFoundExceptions: $this->notFoundExceptions, + unauthenticatedExceptions: $this->unauthenticatedExceptions, + unauthorizedExceptions: $this->unauthorizedExceptions, ); } -} \ No newline at end of file + + /** + * Appends to the existing lists. An omitted category is left untouched. + * + * @param list> $notFound + * @param list> $unauthenticated + * @param list> $unauthorized + * @return self + */ + #[NoDiscard] + public function withExceptions( + array $notFound = [], + array $unauthenticated = [], + array $unauthorized = [], + ): self + { + return new self( + coerceQueryInput: $this->coerceQueryInput, + middleware: $this->middleware, + notFoundExceptions: [...$this->notFoundExceptions, ...$notFound], + unauthenticatedExceptions: [...$this->unauthenticatedExceptions, ...$unauthenticated], + unauthorizedExceptions: [...$this->unauthorizedExceptions, ...$unauthorized], + ); + } +} diff --git a/src/Server/Errors/ErrorPresenter.php b/src/Server/Errors/ErrorPresenter.php new file mode 100644 index 0000000..8d6d213 --- /dev/null +++ b/src/Server/Errors/ErrorPresenter.php @@ -0,0 +1,115 @@ +resolve($throwable, $definition); + return new RpcError($type, $throwable, $details, $info); + } catch (Throwable) { + return self::internalError($throwable, $info); + } + } + + /** + * The last resort shape, for when presenting itself fails. + */ + public static function internalError(Throwable $throwable, ?ResolveInfo $info): RpcError + { + return new RpcError( + ErrorType::INTERNAL_ERROR, + $throwable, + ['type' => 'INTERNAL_SERVER_ERROR'], + $info, + ); + } + + /** + * @return array{ErrorType, array} + */ + private function resolve(Throwable $throwable, ?Definition $definition): array + { + if ($throwable instanceof InvalidInputException) { + return [ErrorType::INVALID_INPUT, [ + 'type' => 'INVALID_INPUT', + 'fields' => $throwable->failure->issues->serializeToFieldsArray(), + ]]; + } + + if ($this->matchesAny($throwable, $this->configuration->unauthenticatedExceptions)) { + return [ErrorType::AUTHENTICATION_ERROR, ['type' => 'UNAUTHENTICATED']]; + } + + if ($this->matchesAny($throwable, $this->configuration->unauthorizedExceptions)) { + return [ErrorType::AUTHORIZATION_ERROR, ['type' => 'UNAUTHORIZED']]; + } + + if ($throwable instanceof OperationNotFoundException || $this->matchesAny($throwable, $this->configuration->notFoundExceptions)) { + return [ErrorType::NOT_FOUND, ['type' => 'NOT_FOUND']]; + } + + if ($definition && $exposedType = $this->exposedTypeOf($throwable, $definition)) { + return [ErrorType::DOMAIN_ERROR, ['type' => $exposedType]]; + } + + return [ErrorType::INTERNAL_ERROR, ['type' => 'INTERNAL_SERVER_ERROR']]; + } + + /** + * @param list> $classNames + */ + private function matchesAny(Throwable $throwable, array $classNames): bool + { + return array_any($classNames, static fn(string $className): bool => $throwable instanceof $className); + } + + /** + * An exception is a domain error only if the operation declares it via #[Throws] and the + * exception itself opts into being shown via #[ExposeAs]. + */ + private function exposedTypeOf(Throwable $throwable, Definition $definition): ?string + { + return in_array($throwable::class, ExposedExceptions::declaredFor($definition), true) + ? ExposedExceptions::exposedTypeOf($throwable::class) + : null; + } +} diff --git a/src/Server/Errors/ExposedExceptions.php b/src/Server/Errors/ExposedExceptions.php new file mode 100644 index 0000000..1a8bf18 --- /dev/null +++ b/src/Server/Errors/ExposedExceptions.php @@ -0,0 +1,80 @@ +> + * @throws ReflectionException + */ + public static function declaredFor(Definition $definition): array + { + $attributes = new ReflectionMethod($definition->fullyQualifiedClassName, $definition->methodName) + ->getAttributes(Throws::class); + + foreach ($definition->middleware as $middlewareClassName) { + $middlewareAttributes = new ReflectionMethod($middlewareClassName, 'handle') + ->getAttributes(Throws::class); + + if (count($middlewareAttributes) > 0) { + array_push($attributes, ...$middlewareAttributes); + } + } + + return array_map(function (ReflectionAttribute $attribute): string { + /** @var Throws $instance */ + $instance = $attribute->newInstance(); + return $instance->exceptionClass; + }, $attributes); + } + + /** + * @param class-string $exceptionClass + * @return string|null + */ + public static function exposedTypeOf(string $exceptionClass): ?string + { + $attributes = new ReflectionClass($exceptionClass)->getAttributes(ExposeAs::class); + return count($attributes) === 0 + ? null + : $attributes[0]->newInstance()->type; + } + + /** + * The exposed names of every exception the operation declares, in declaration order. + * + * @param Definition $definition + * @return list + * @throws ReflectionException + */ + public static function exposedTypesFor(Definition $definition): array + { + return array_map(self::exposedTypeOf(...), self::declaredFor($definition)) + |> Lists::filterNullValues(...) + |> Lists::unique(...); + } +} diff --git a/src/Server/Pipeline/ContextualPipeline.php b/src/Server/Pipeline/ContextualPipeline.php index a8d71b2..4941cd7 100644 --- a/src/Server/Pipeline/ContextualPipeline.php +++ b/src/Server/Pipeline/ContextualPipeline.php @@ -5,10 +5,10 @@ use Closure; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; -use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Server\Data\ResolveInfo; use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; +use Le0daniel\PhpTsBindings\Server\Errors\ErrorPresenter; use Throwable; /** @@ -83,12 +83,7 @@ private function toRpcError(Throwable $throwable, ResolveInfo $info): RpcError try { return ($this->onError)($throwable); } catch (Throwable $failedToPresent) { - return new RpcError( - ErrorType::INTERNAL_ERROR, - $failedToPresent, - ['type' => 'INTERNAL_SERVER_ERROR'], - $info, - ); + return ErrorPresenter::internalError($failedToPresent, $info); } } } diff --git a/src/Server/Presenter/CatchAllPresenter.php b/src/Server/Presenter/CatchAllPresenter.php deleted file mode 100644 index b9754c7..0000000 --- a/src/Server/Presenter/CatchAllPresenter.php +++ /dev/null @@ -1,41 +0,0 @@ - 'INTERNAL_SERVER_ERROR', - ]; - } - - #[Override] - public static function errorType(): ErrorType - { - return ErrorType::INTERNAL_ERROR; - } -} \ No newline at end of file diff --git a/src/Server/Presenter/ExposedExceptionPresenter.php b/src/Server/Presenter/ExposedExceptionPresenter.php deleted file mode 100644 index 10cf24b..0000000 --- a/src/Server/Presenter/ExposedExceptionPresenter.php +++ /dev/null @@ -1,110 +0,0 @@ -> - * @throws ReflectionException - */ - private function extractDeclaredExceptions(Definition $definition): array - { - $reflection = new ReflectionMethod($definition->fullyQualifiedClassName, $definition->methodName); - $attributes = $reflection->getAttributes(Throws::class); - - // We go through all middleware and extract their throws attributes. - // handle() is guaranteed to exist: every middleware implements MiddlewareContract. - if (count($definition->middleware) > 0) { - foreach ($definition->middleware as $middlewareClassName) { - $reflection = new ReflectionMethod($middlewareClassName, 'handle'); - $middlewareAttributes = $reflection->getAttributes(Throws::class); - if (count($middlewareAttributes) > 0) { - array_push($attributes, ...$middlewareAttributes); - } - } - } - - return array_map(function (ReflectionAttribute $attribute) { - /** @var Throws $instance */ - $instance = $attribute->newInstance(); - return $instance->exceptionClass; - }, $attributes); - } - - /** - * @param class-string $exceptionClass - */ - private function exposedTypeOf(string $exceptionClass): ?string - { - $attributes = new ReflectionClass($exceptionClass)->getAttributes(ExposeAs::class); - if (count($attributes) === 0) { - return null; - } - - return $attributes[0]->newInstance()->type; - } - - /** - * @throws ReflectionException - */ - #[Override] - public function matches(Throwable $throwable, Definition $definition): bool - { - return $this->exposedTypeOf($throwable::class) !== null - && in_array($throwable::class, $this->extractDeclaredExceptions($definition), true); - } - - #[Override] - public function toTypescriptDefinition(Definition $definition): ?string - { - $exposedTypes = array_map( - $this->exposedTypeOf(...), - $this->extractDeclaredExceptions($definition), - ) |> Lists::filterNullValues(...); - - if (empty($exposedTypes)) { - return null; - } - - return implode('|', array_map(function (string $exposedType): string { - $type = json_encode($exposedType, JSON_THROW_ON_ERROR); - return "{type: {$type}}"; - }, $exposedTypes)); - } - - /** - * @return array{type: string} - */ - #[Override] - public function details(Throwable $throwable): array - { - // matches() already established that this exception carries an ExposeAs; a presenter is - // only ever asked for details after it claimed the throwable. - $type = $this->exposedTypeOf($throwable::class); - assert($type !== null, 'details() called for a throwable this presenter does not match.'); - - return ['type' => $type]; - } - - #[Override] - public static function errorType(): ErrorType - { - return ErrorType::DOMAIN_ERROR; - } -} diff --git a/src/Server/Presenter/InvalidInputPresenter.php b/src/Server/Presenter/InvalidInputPresenter.php deleted file mode 100644 index f178c8a..0000000 --- a/src/Server/Presenter/InvalidInputPresenter.php +++ /dev/null @@ -1,46 +0,0 @@ -;}'; - } - - /** - * @return array{type: "INVALID_INPUT", fields: array} - */ - #[Override] - public function details(Throwable $throwable): array - { - /** @var InvalidInputException $throwable */ - - return [ - 'type' => 'INVALID_INPUT', - 'fields' => $throwable->failure->issues->serializeToFieldsArray(), - ]; - } - - #[Override] - public static function errorType(): ErrorType - { - return ErrorType::INVALID_INPUT; - } -} \ No newline at end of file diff --git a/src/Server/Presenter/NotFoundPresenter.php b/src/Server/Presenter/NotFoundPresenter.php deleted file mode 100644 index 85ee434..0000000 --- a/src/Server/Presenter/NotFoundPresenter.php +++ /dev/null @@ -1,50 +0,0 @@ -> $classNames - */ - public function __construct( - private readonly array $classNames - ) - { - } - - #[Override] - public function matches(Throwable $throwable, Definition $definition): bool - { - return in_array(get_class($throwable), $this->classNames, true); - } - - #[Override] - public function toTypescriptDefinition(Definition $definition): string - { - return '{type: "NOT_FOUND";}'; - } - - /** - * @return array{type: "NOT_FOUND"} - */ - #[Override] - public function details(Throwable $throwable): array - { - return [ - 'type' => 'NOT_FOUND', - ]; - } - - #[Override] - public static function errorType(): ErrorType - { - return ErrorType::NOT_FOUND; - } -} \ No newline at end of file diff --git a/src/Server/Presenter/UnauthenticatedPresenter.php b/src/Server/Presenter/UnauthenticatedPresenter.php deleted file mode 100644 index 8660b80..0000000 --- a/src/Server/Presenter/UnauthenticatedPresenter.php +++ /dev/null @@ -1,50 +0,0 @@ -> $unauthenticatedClassNames - */ - public function __construct( - private readonly array $unauthenticatedClassNames - ) - { - } - - #[Override] - public function matches(Throwable $throwable, Definition $definition): bool - { - return in_array(get_class($throwable), $this->unauthenticatedClassNames, true); - } - - #[Override] - public function toTypescriptDefinition(Definition $definition): string - { - return '{type: "UNAUTHENTICATED";}'; - } - - /** - * @return array{type: "UNAUTHENTICATED"} - */ - #[Override] - public function details(Throwable $throwable): array - { - return [ - 'type' => 'UNAUTHENTICATED', - ]; - } - - #[Override] - public static function errorType(): ErrorType - { - return ErrorType::AUTHENTICATION_ERROR; - } -} \ No newline at end of file diff --git a/src/Server/Presenter/UnauthorizedPresenter.php b/src/Server/Presenter/UnauthorizedPresenter.php deleted file mode 100644 index 55df8a0..0000000 --- a/src/Server/Presenter/UnauthorizedPresenter.php +++ /dev/null @@ -1,50 +0,0 @@ -> $unauthenticatedClassNames - */ - public function __construct( - private readonly array $unauthenticatedClassNames - ) - { - } - - #[Override] - public function matches(Throwable $throwable, Definition $definition): bool - { - return in_array(get_class($throwable), $this->unauthenticatedClassNames, true); - } - - #[Override] - public function toTypescriptDefinition(Definition $definition): string - { - return '{type: "UNAUTHORIZED";}'; - } - - /** - * @return array{type: "UNAUTHORIZED"} - */ - #[Override] - public function details(Throwable $throwable): array - { - return [ - 'type' => 'UNAUTHORIZED', - ]; - } - - #[Override] - public static function errorType(): ErrorType - { - return ErrorType::AUTHORIZATION_ERROR; - } -} \ No newline at end of file diff --git a/src/Server/Server.php b/src/Server/Server.php index 12723db..9b5ae47 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -3,14 +3,11 @@ namespace Le0daniel\PhpTsBindings\Server; use Le0daniel\PhpTsBindings\Contracts\Client; -use Le0daniel\PhpTsBindings\Contracts\ExceptionPresenter; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; -use Le0daniel\PhpTsBindings\Server\Data\Definition; -use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidOutputException; @@ -21,8 +18,8 @@ use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; +use Le0daniel\PhpTsBindings\Server\Errors\ErrorPresenter; use Le0daniel\PhpTsBindings\Server\Pipeline\ContextualPipeline; -use Le0daniel\PhpTsBindings\Server\Presenter\CatchAllPresenter; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; @@ -33,30 +30,29 @@ public SchemaExecutor $executor; /** - * @param OperationRegistry $registry - * @param list $exceptionPresenters - * @param ExceptionPresenter $defaultPresenter - * @param ContainerInterface|null $container - * @param ServerConfiguration $configuration + * Error presentation is not an extension point: the catalogue is finite and the server needs it + * to run. What an application configures is which of its exceptions belong in which category. + * + * @see ErrorPresenter */ + private ErrorPresenter $errorPresenter; + public function __construct( public OperationRegistry $registry, - public array $exceptionPresenters, - public ExceptionPresenter $defaultPresenter = new CatchAllPresenter(), private null|ContainerInterface $container = null, public ServerConfiguration $configuration = new ServerConfiguration(), ) { $this->executor = new SchemaExecutor(); + $this->errorPresenter = new ErrorPresenter($configuration); } public function query(string $name, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { if (!$this->registry->has(OperationType::QUERY, $name)) { - return new RpcError( - ErrorType::NOT_FOUND, + return $this->errorPresenter->present( new OperationNotFoundException("Operation with name: {$name} was not found."), - ['type' => 'NOT_FOUND'], + null, null, ); } @@ -67,10 +63,9 @@ public function query(string $name, mixed $input, mixed $context, Client $client public function command(string $name, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { if (!$this->registry->has(OperationType::COMMAND, $name)) { - return new RpcError( - ErrorType::NOT_FOUND, + return $this->errorPresenter->present( new OperationNotFoundException("Operation with name: {$name} was not found."), - ['type' => 'NOT_FOUND'], + null, null, ); } @@ -103,12 +98,12 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli ? $this->container->get($operation->definition->fullyQualifiedClassName) : new $operation->definition->fullyQualifiedClassName; } catch (Throwable $throwable) { - return $this->produceError($throwable, $operation->definition, $resolveInfo); + return $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo); } return new ContextualPipeline( middlewares: $middlewares, - onError: fn(Throwable $throwable): RpcError => $this->produceError($throwable, $operation->definition, $resolveInfo), + onError: fn(Throwable $throwable): RpcError => $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo), destination: function (mixed $input) use ($controllerClass, $client, $operation, $context, $resolveInfo): RpcSuccess|RpcError { try { $inputValidationResult = $this @@ -120,7 +115,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli )); if ($inputValidationResult instanceof Failure) { - return $this->produceError( + return $this->errorPresenter->present( new InvalidInputException($inputValidationResult), $operation->definition, $resolveInfo, @@ -134,7 +129,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli ); if ($serializedResult instanceof Failure) { - return $this->produceError( + return $this->errorPresenter->present( new InvalidOutputException($serializedResult), $operation->definition, $resolveInfo, @@ -143,7 +138,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli return new RpcSuccess($serializedResult->value, $client, $resolveInfo); } catch (Throwable $throwable) { - return $this->produceError($throwable, $operation->definition, $resolveInfo); + return $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo); } }, )->execute($input, $context, $resolveInfo, $client); @@ -166,30 +161,4 @@ private function resolveMiddleware(string $className): MiddlewareContract return $middleware; } - - /** - * @param Throwable $exception - * @param Definition $definition - * @return RpcError - */ - private function produceError(Throwable $exception, Definition $definition, ?ResolveInfo $info): RpcError - { - foreach ($this->exceptionPresenters as $presenter) { - if ($presenter->matches($exception, $definition)) { - return new RpcError( - $presenter::errorType(), - $exception, - $presenter->details($exception), - $info - ); - } - } - - return new RpcError( - $this->defaultPresenter::errorType(), - $exception, - $this->defaultPresenter->details($exception), - $info - ); - } } \ No newline at end of file diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index 5ead819..7c3a940 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -16,8 +16,6 @@ use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Server\Presenter\CatchAllPresenter; -use Le0daniel\PhpTsBindings\Server\Presenter\InvalidInputPresenter; use Le0daniel\PhpTsBindings\Server\Server; use Mockery; use Symfony\Component\HttpFoundation\InputBag; @@ -67,12 +65,7 @@ public function someMethod(array $input, null $context, Client $client): array $request->shouldReceive('header')->with(LaravelHttpController::CLIENT_ID_HEADER)->andReturnNull(); $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); - $server = new Server( - $operationRegistry, - [], - new CatchAllPresenter(), - $app, - ); + $server = new Server($operationRegistry, $app); $controller = new LaravelHttpController( $server, @@ -136,7 +129,7 @@ public function someMethod(array $input, null $context, Client $client): array $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); $controller = new LaravelHttpController( - new Server($operationRegistry, [], new CatchAllPresenter(), $app), + new Server($operationRegistry, $app), $exceptionHandler, null, ); @@ -206,12 +199,7 @@ public function someMethod(array $input, null $context, Client $client): array $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); $repository->shouldReceive('get')->with('app.debug')->andReturn(false); - $server = new Server( - $operationRegistry, - [new InvalidInputPresenter()], - new CatchAllPresenter(), - $app, - ); + $server = new Server($operationRegistry, $app); $controller = new LaravelHttpController( $server, @@ -234,5 +222,6 @@ public function someMethod(array $input, null $context, Client $client): array ], ], 'code' => 422, + 'type' => 'INVALID_INPUT', ]); }); \ No newline at end of file diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index fc30c1f..febb4f7 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -9,8 +9,8 @@ use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\CachedOperationRegistry; +use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; -use Le0daniel\PhpTsBindings\Server\Presenter\ExposedExceptionPresenter; use Le0daniel\PhpTsBindings\Server\Server; use Tests\Feature\Mocks\NotAMiddleware; @@ -18,8 +18,8 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { $registry = EagerlyLoadedOperationRegistry::eagerlyDiscover(__DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator); $cachedRegistry = eval(CachedOperationRegistry::toPhpCode($registry, idLength: 10)); - $server = new Server($registry, [new ExposedExceptionPresenter(),],); - $cachedServer = new Server($cachedRegistry, [new ExposedExceptionPresenter(),],); + $server = new Server($registry); + $cachedServer = new Server($cachedRegistry); $regularResponse = $server->command($name, $input, null, new NullClient()); $cachedResponse = $cachedServer->command($name, $input, null, new NullClient()); @@ -60,9 +60,6 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { __DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator ), - [ - new ExposedExceptionPresenter(), - ], configuration: new ServerConfiguration()->withMiddlewares(NotAMiddleware::class), ); @@ -79,15 +76,12 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { __DIR__ . '/Operations', keyGenerator: new PlainlyExposedKeyGenerator ), - [ - new ExposedExceptionPresenter(), - ], ); $operation = $server->registry->get(OperationType::COMMAND, 'test.run'); - $errorPresenter = new ExposedExceptionPresenter(); - $definition = $errorPresenter->toTypescriptDefinition($operation->definition); - expect($definition)->toEqual('{type: "invalid_name"}'); + $union = ErrorTypescript::forOperation($server->configuration, $operation->definition); + + expect($union)->toContain('{code: 400, type: "DOMAIN_ERROR", details: {type: "invalid_name"}}'); }); /** * The cached registry pools every operation's schemas together, so these cases only mean anything diff --git a/tests/Mocks/Errors/ErrorOperations.php b/tests/Mocks/Errors/ErrorOperations.php new file mode 100644 index 0000000..74acd57 --- /dev/null +++ b/tests/Mocks/Errors/ErrorOperations.php @@ -0,0 +1,22 @@ + + */ +final class ThrowingMiddleware implements MiddlewareContract +{ + #[Throws(MiddlewareDomainException::class)] + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + { + return $next($input); + } +} diff --git a/tests/Mocks/Errors/UndeclaredExposedException.php b/tests/Mocks/Errors/UndeclaredExposedException.php new file mode 100644 index 0000000..441a666 --- /dev/null +++ b/tests/Mocks/Errors/UndeclaredExposedException.php @@ -0,0 +1,14 @@ + $middleware + */ +function typescriptDefinition(string $methodName = 'declaresThrows', array $middleware = []): Definition +{ + return new Definition( + OperationType::COMMAND, + ErrorOperations::class, + $methodName, + 'test', + 'errors', + // @phpstan-ignore-next-line -- tests intentionally pass unresolvable class names. + $middleware, + ); +} + +const INVALID_INPUT_BRANCH = '{code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}'; +const UNAUTHENTICATED_BRANCH = '{code: 401, type: "AUTHENTICATION_ERROR", details: {type: "UNAUTHENTICATED"}}'; +const UNAUTHORIZED_BRANCH = '{code: 403, type: "AUTHORIZATION_ERROR", details: {type: "UNAUTHORIZED"}}'; +const NOT_FOUND_BRANCH = '{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}'; +const INTERNAL_BRANCH = '{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}'; + +test('an unconfigured server only emits the branches it can actually produce', function () { + $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresNothing')); + + expect($union)->toBe(implode('|', [ + INVALID_INPUT_BRANCH, + NOT_FOUND_BRANCH, + INTERNAL_BRANCH, + ])); +}); + +test('the authentication branch appears once unauthenticated exceptions are configured', function () { + $configuration = new ServerConfiguration()->withExceptions(unauthenticated: [RecordMissingException::class]); + + $union = ErrorTypescript::forOperation($configuration, typescriptDefinition('declaresNothing')); + + expect($union)->toBe(implode('|', [ + INVALID_INPUT_BRANCH, + UNAUTHENTICATED_BRANCH, + NOT_FOUND_BRANCH, + INTERNAL_BRANCH, + ])); +}); + +test('the authorization branch appears once unauthorized exceptions are configured', function () { + $configuration = new ServerConfiguration()->withExceptions(unauthorized: [RecordMissingException::class]); + + $union = ErrorTypescript::forOperation($configuration, typescriptDefinition('declaresNothing')); + + expect($union)->toBe(implode('|', [ + INVALID_INPUT_BRANCH, + UNAUTHORIZED_BRANCH, + NOT_FOUND_BRANCH, + INTERNAL_BRANCH, + ])); +}); + +test('the domain branch lists every exposed exception the operation declares, just before the catch all', function () { + $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresThrows', [ThrowingMiddleware::class])); + + expect($union)->toBe(implode('|', [ + INVALID_INPUT_BRANCH, + NOT_FOUND_BRANCH, + '{code: 400, type: "DOMAIN_ERROR", details: {type: "domain_failure"}|{type: "middleware_failure"}}', + INTERNAL_BRANCH, + ])); +}); + +test('an operation declaring nothing exposable emits no domain branch', function () { + $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresNothing')); + + expect($union)->not->toContain('DOMAIN_ERROR'); +}); + +test('an operation whose only #[Throws] lacks ExposeAs emits no domain branch', function () { + // declaresThrows declares UnexposedException alongside ExposedDomainException, so the branch + // must list only the exposed one. + $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresThrows')); + + expect($union)->toContain('{code: 400, type: "DOMAIN_ERROR", details: {type: "domain_failure"}}') + ->and($union)->not->toContain('UnexposedException'); +}); diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 69413bb..646de04 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -29,7 +29,6 @@ function generateFor(array $classes, ?array $generators = null): array { $server = new Server( EagerlyLoadedOperationRegistry::withClasses($classes, keyGenerator: new PlainlyExposedKeyGenerator()), - [], ); return new TypescriptServerCodeGenerator( diff --git a/tests/Unit/Server/Errors/ErrorPresenterTest.php b/tests/Unit/Server/Errors/ErrorPresenterTest.php new file mode 100644 index 0000000..a4a370c --- /dev/null +++ b/tests/Unit/Server/Errors/ErrorPresenterTest.php @@ -0,0 +1,185 @@ + $middleware + */ +function errorDefinition(string $methodName = 'declaresThrows', array $middleware = []): Definition +{ + return new Definition( + OperationType::COMMAND, + ErrorOperations::class, + $methodName, + 'test', + 'errors', + // @phpstan-ignore-next-line -- tests intentionally pass unresolvable class names. + $middleware, + ); +} + +function errorResolveInfo(): ResolveInfo +{ + return new ResolveInfo('errors', 'test', OperationType::COMMAND, ErrorOperations::class, 'declaresThrows', []); +} + +test('invalid input yields a 422 carrying the field issues', function () { + $exception = InvalidInputException::createFromMessages(['name' => 'Is required']); + + $error = new ErrorPresenter(new ServerConfiguration()) + ->present($exception, errorDefinition(), errorResolveInfo()); + + expect($error->type)->toBe(ErrorType::INVALID_INPUT) + ->and($error->cause)->toBe($exception) + ->and($error->details)->toEqual([ + 'type' => 'INVALID_INPUT', + 'fields' => $exception->failure->issues->serializeToFieldsArray(), + ]); +}); + +test('a configured unauthenticated exception yields a 401', function () { + $configuration = new ServerConfiguration()->withExceptions(unauthenticated: [RecordMissingException::class]); + + $error = new ErrorPresenter($configuration) + ->present(new RecordMissingException(), errorDefinition(), null); + + expect($error->type)->toBe(ErrorType::AUTHENTICATION_ERROR) + ->and($error->details)->toEqual(['type' => 'UNAUTHENTICATED']); +}); + +test('a configured unauthorized exception yields a 403', function () { + $configuration = new ServerConfiguration()->withExceptions(unauthorized: [RecordMissingException::class]); + + $error = new ErrorPresenter($configuration) + ->present(new RecordMissingException(), errorDefinition(), null); + + expect($error->type)->toBe(ErrorType::AUTHORIZATION_ERROR) + ->and($error->details)->toEqual(['type' => 'UNAUTHORIZED']); +}); + +test('a configured not found exception yields a 404', function () { + $configuration = new ServerConfiguration()->withExceptions(notFound: [RecordMissingException::class]); + + $error = new ErrorPresenter($configuration) + ->present(new RecordMissingException(), errorDefinition(), null); + + expect($error->type)->toBe(ErrorType::NOT_FOUND) + ->and($error->details)->toEqual(['type' => 'NOT_FOUND']); +}); + +test('subclasses of a configured exception match, matching is instanceof and not exact class', function () { + $configuration = new ServerConfiguration()->withExceptions(notFound: [RecordMissingException::class]); + + $error = new ErrorPresenter($configuration) + ->present(new UserMissingException(), errorDefinition(), null); + + expect($error->type)->toBe(ErrorType::NOT_FOUND); +}); + +test('an unknown operation yields a 404 without a definition to reflect on', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new OperationNotFoundException('nope'), null, null); + + expect($error->type)->toBe(ErrorType::NOT_FOUND) + ->and($error->details)->toEqual(['type' => 'NOT_FOUND']) + ->and($error->resolveInfo)->toBeNull(); +}); + +test('an exposed exception declared on the operation yields a 400 named after the ExposeAs type', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new ExposedDomainException(), errorDefinition(), null); + + expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->details)->toEqual(['type' => 'domain_failure']); +}); + +test('an exposed exception declared on a middleware yields a 400', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new MiddlewareDomainException(), errorDefinition('declaresNothing', [ThrowingMiddleware::class]), null); + + expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->details)->toEqual(['type' => 'middleware_failure']); +}); + +test('a declared exception without ExposeAs falls through to the catch all', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new UnexposedException(), errorDefinition(), null); + + expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($error->details)->toEqual(['type' => 'INTERNAL_SERVER_ERROR']); +}); + +test('an ExposeAs exception the operation never declares falls through to the catch all', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new UndeclaredExposedException(), errorDefinition(), null); + + expect($error->type)->toBe(ErrorType::INTERNAL_ERROR); +}); + +test('an unmapped exception yields a 500 and keeps its cause and resolve info', function () { + $exception = new RuntimeException('boom'); + $info = errorResolveInfo(); + + $error = new ErrorPresenter(new ServerConfiguration())->present($exception, errorDefinition(), $info); + + expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($error->details)->toEqual(['type' => 'INTERNAL_SERVER_ERROR']) + ->and($error->cause)->toBe($exception) + ->and($error->resolveInfo)->toBe($info); +}); + +test('the configured categories are resolved before the exposed domain error', function () { + $configuration = new ServerConfiguration()->withExceptions(notFound: [ExposedDomainException::class]); + + $error = new ErrorPresenter($configuration) + ->present(new ExposedDomainException(), errorDefinition(), null); + + expect($error->type)->toBe(ErrorType::NOT_FOUND); +}); + +test('authentication and authorization are resolved before not found', function () { + $configuration = new ServerConfiguration()->withExceptions( + notFound: [RecordMissingException::class], + unauthorized: [RecordMissingException::class], + ); + + $error = new ErrorPresenter($configuration) + ->present(new RecordMissingException(), errorDefinition(), null); + + expect($error->type)->toBe(ErrorType::AUTHORIZATION_ERROR); +}); + +test('a definition that cannot be reflected yields a 500 instead of escaping', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new ExposedDomainException(), errorDefinition('declaresNothing', ['Tests\Mocks\Errors\DoesNotExist']), null); + + expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($error->details)->toEqual(['type' => 'INTERNAL_SERVER_ERROR']); +}); + +test('internalError produces the last resort shape', function () { + $exception = new RuntimeException('presenter blew up'); + $info = errorResolveInfo(); + + $error = ErrorPresenter::internalError($exception, $info); + + expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($error->details)->toEqual(['type' => 'INTERNAL_SERVER_ERROR']) + ->and($error->cause)->toBe($exception) + ->and($error->resolveInfo)->toBe($info); +}); From 56aafd6903542d3ad18170d81c701c8842342f58 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 31 Jul 2026 10:51:00 +0200 Subject: [PATCH 028/101] Refactor constraints by replacing `Email`, `Length`, `NonEmptyString`, and `NonFalsyString` with specialized constraint classes; add `IntRange`, `ListLength`, `LowercaseString`, `NonEmptyString`, `NonFalsyString`, and `NumericString`. --- README.md | 48 ++++++- .../Laravel/LaravelServiceProvider.php | 6 +- src/Constraints/Email.php | 48 ------- src/Constraints/Length.php | 108 ---------------- src/Executor/Data/Context.php | 1 - src/Executor/Data/IssueMessage.php | 4 +- src/Executor/Data/SerializationOptions.php | 6 +- src/Executor/SchemaExecutor.php | 8 +- src/Parser/Constraints/IntRange.php | 84 +++++++++++++ src/Parser/Constraints/ListLength.php | 84 +++++++++++++ src/Parser/Constraints/LowercaseString.php | 54 ++++++++ .../Constraints/NonEmptyString.php | 30 +++-- .../Constraints/NonFalsyString.php | 29 ++--- src/Parser/Constraints/NumericString.php | 52 ++++++++ src/Parser/Constraints/UppercaseString.php | 50 ++++++++ src/Parser/Constraints/ValidatesString.php | 33 +++++ src/Parser/Consumers/AliasConsumer.php | 4 +- src/Parser/Consumers/ArrayConsumer.php | 32 ++++- src/Parser/Consumers/BuiltInLeafConsumer.php | 54 ++++++-- src/Parser/Consumers/IntConsumer.php | 12 +- .../Consumers/UserDefinedObjectConsumer.php | 68 +++------- src/Parser/Contracts/Constraint.php | 20 ++- src/Parser/Definition/ParserState.php | 10 +- .../ParsingScope.php} | 4 +- src/Parser/Nodes/ConstraintNode.php | 4 +- src/Parser/TypeParser.php | 4 +- .../EagerlyLoadedOperationRegistry.php | 4 +- tests/Feature/FullSchemaTest.php | 10 +- tests/Feature/Mocks/CreateUserInput.php | 9 +- tests/Unit/Constraints/EmailTest.php | 57 --------- tests/Unit/Constraints/LengthTest.php | 118 ------------------ .../Unit/Contracts/ExceptionHierarchyTest.php | 4 +- .../Unit/Parser/Constraints/IntRangeTest.php | 80 ++++++++++++ .../Parser/Constraints/ListLengthTest.php | 65 ++++++++++ .../Constraints/PhpstanRefinementsTest.php | 83 ++++++++++++ .../Constraints/StringConstraintsTest.php | 4 +- tests/Unit/Parser/Data/ParsingContextTest.php | 8 +- tests/Unit/Parser/MetadataEliminationTest.php | 2 +- .../Unit/Parser/NodeDiagnosticStringTest.php | 2 +- tests/Unit/Parser/TypeParserTest.php | 101 ++++++++++++--- tests/Unit/Parser/ValueObjectConsumerTest.php | 6 +- 41 files changed, 920 insertions(+), 490 deletions(-) delete mode 100644 src/Constraints/Email.php delete mode 100644 src/Constraints/Length.php create mode 100644 src/Parser/Constraints/IntRange.php create mode 100644 src/Parser/Constraints/ListLength.php create mode 100644 src/Parser/Constraints/LowercaseString.php rename src/{ => Parser}/Constraints/NonEmptyString.php (69%) rename src/{ => Parser}/Constraints/NonFalsyString.php (63%) create mode 100644 src/Parser/Constraints/NumericString.php create mode 100644 src/Parser/Constraints/UppercaseString.php create mode 100644 src/Parser/Constraints/ValidatesString.php rename src/Parser/{Data/ParsingContext.php => Helpers/ParsingScope.php} (98%) delete mode 100644 tests/Unit/Constraints/EmailTest.php delete mode 100644 tests/Unit/Constraints/LengthTest.php create mode 100644 tests/Unit/Parser/Constraints/IntRangeTest.php create mode 100644 tests/Unit/Parser/Constraints/ListLengthTest.php create mode 100644 tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php rename tests/Unit/{ => Parser}/Constraints/StringConstraintsTest.php (92%) diff --git a/README.md b/README.md index bc56cb9..7d5507b 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ customizations, including writing your very own code generation plugin. ## Type Parsing ```php -use Le0daniel\PhpTsBindings\Executor\SchemaExecutor;use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext;use Le0daniel\PhpTsBindings\Parser\TypeParser;use Le0daniel\PhpTsBindings\Reflection\TypeReflector;use Le0daniel\PhpTsBindings\Typescript\Data\IO;use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry;use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; +use Le0daniel\PhpTsBindings\Executor\SchemaExecutor;use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope;use Le0daniel\PhpTsBindings\Parser\TypeParser;use Le0daniel\PhpTsBindings\Reflection\TypeReflector;use Le0daniel\PhpTsBindings\Typescript\Data\IO;use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry;use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; $typeString = TypeReflector::reflectParameter( new ReflectionParameter() @@ -99,7 +99,7 @@ $parser = new TypeParser(); $ast = $parser->parse( $typeString, // The parsing context is needed for Type Imports and used classes. - ParsingContext::fromClassString(MyClassDeclaringThisParameter::class) + ParsingScope::fromClassString(MyClassDeclaringThisParameter::class) ); $generator = new TypescriptGenerator(); @@ -134,6 +134,50 @@ $parsed = $executor->parse($node, ['key' => 'value']); $serialized = $executor->serialize($node, "my string"); ``` +### Refinement types + +Some PHPStan types narrow a PHP type further than PHP itself can express: `positive-int` is an +`int` to PHP, `non-empty-list` is an `array`. Those refinements are checked at runtime. + +| PHPStan type | PHP type | Checked | +| --- | --- | --- | +| `int` | `int` | inclusive bounds; `min` / `max` mean unbounded | +| `positive-int` | `int` | `>= 1` | +| `non-negative-int` | `int` | `>= 0` | +| `negative-int` | `int` | `<= -1` | +| `non-positive-int` | `int` | `<= 0` | +| `non-empty-string` | `string` | `!== ''` — note `"0"` is valid | +| `non-falsy-string`, `truthy-string` | `string` | truthy — `"0"` is not | +| `numeric-string` | `string` | `is_numeric()` | +| `lowercase-string` | `string` | `strtolower($v) === $v` | +| `uppercase-string` | `string` | `strtoupper($v) === $v` | +| `non-empty-lowercase-string` | `string` | both of the above | +| `non-empty-uppercase-string` | `string` | both of the above | +| `non-empty-list` | `array` | at least one element | +| `non-empty-array` | `array` | at least one element | + +`int-mask<…>`, `int-mask-of<…>` and `class-string` are **not** supported. Integer refinement is +`int` and the four shorthands above, nothing else. + +There is no attribute or annotation for attaching a check of your own: a property is refined by +its PHPStan type or not at all. That is what keeps a parsed schema equal to the type it was +parsed from, and it is why this library validates types rather than data — "is a valid email +address" is not something PHPStan can express, so it is not something this library checks. + +### Refinements run on input, never on output + +`$executor->parse()` checks every refinement. `$executor->serialize()` checks none of them, and +`SerializationOptions` has no knob to change that. + +Input arrives from a client and is untrusted, so every claim its type makes has to be proven. +Output comes out of your own code, which PHPStan already analysed against the very return type +being serialized — if your method says it returns `positive-int`, static analysis has established +that. Re-checking it at runtime would cost you something for a guarantee you already have. This +library assumes static analysis does its job. + +Serialization still enforces *types*: a `string` where an `int` is declared fails either way. Only +the PHPStan refinement on top of the type is skipped. + ## Utility types A handful of type names are understood in docblocks even though no such PHP class exists. They are diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 564f515..f15b1bc 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -23,7 +23,7 @@ final class LaravelServiceProvider extends ServiceProvider implements DeferrableProvider { /** - * Resolves the default configured server via the laravel service provider + * Resolves the default-configured server via the laravel service provider */ public const string DEFAULT_SERVER = 'operations.default_server'; @@ -101,8 +101,8 @@ public function register(): void $config = $app->make('config'); return new Preloader( - $app->make(self::DEFAULT_SERVER), - match ($config->get('operations.key.mode', 'obfuscate')) { + server: $app->make(self::DEFAULT_SERVER), + keyGenerator: match ($config->get('operations.key.mode', 'obfuscate')) { 'plain' => new PlainlyExposedKeyGenerator(), 'obfuscate' => new HashSha256KeyGenerator( $config->get('operations.key.pepper', 'none') diff --git a/src/Constraints/Email.php b/src/Constraints/Email.php deleted file mode 100644 index 30fd150..0000000 --- a/src/Constraints/Email.php +++ /dev/null @@ -1,48 +0,0 @@ -addIssue(new Issue( - IssueMessage::INVALID_TYPE, - [ - "message" => "Expected string, got: " . gettype($value), - ], - )); - return false; - } - - if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) { - $context->addIssue(new Issue( - IssueMessage::INVALID_EMAIL, - [ - "message" => "Expected valid email address, got: '{$value}'", - ] - )); - return false; - } - - return true; - } - - #[Override] - public function exportPhpCode(): string - { - $className = PHPExport::absolute(self::class); - return "new {$className}()"; - } -} \ No newline at end of file diff --git a/src/Constraints/Length.php b/src/Constraints/Length.php deleted file mode 100644 index 72f82d0..0000000 --- a/src/Constraints/Length.php +++ /dev/null @@ -1,108 +0,0 @@ -min); - $max = PHPExport::export($this->max); - $including = PHPExport::export($this->including); - return "new {$className}({$min}, {$max}, {$including})"; - } - - #[Override] - public function validate(mixed $value, ExecutionContext $context): bool - { - $valueToValidate = match (gettype($value)) { - 'array' => count($value), - 'string' => strlen($value), - 'integer' => $value, - default => null, - }; - - if (is_null($valueToValidate)) { - $context->addIssue(new Issue( - IssueMessage::INVALID_TYPE, - [ - 'message' => "Wrong type for length validation. Expected string, array or integer, got: " . gettype($value), - 'value' => $value, - ], - )); - return false; - } - - if (!$this->validateMin($valueToValidate)) { - $context->addIssue(new Issue( - IssueMessage::INVALID_MIN, - [ - 'message' => "Expected value to be at least {$this->min} characters long, got: {$valueToValidate}.", - 'including' => $this->including, - 'type' => gettype($value), - 'value' => $value, - ], - )); - return false; - } - - if (!$this->validateMax($valueToValidate)) { - $context->addIssue(new Issue( - IssueMessage::INVALID_MAX, - [ - 'message' => "Expected value to be at most {$this->max} characters long, got: {$valueToValidate}.", - 'including' => $this->including, - 'type' => gettype($value), - 'value' => $value, - ], - )); - return false; - } - - return true; - } - - private function validateMin(int $value): bool - { - if (!isset($this->min)) { - return true; - } - return ( - $this->including - ? $value >= $this->min - : $value > $this->min - ); - } - - private function validateMax(int $value): bool - { - if (!isset($this->max)) { - return true; - } - - return ( - $this->including - ? $value <= $this->max - : $value < $this->max - ); - } -} \ No newline at end of file diff --git a/src/Executor/Data/Context.php b/src/Executor/Data/Context.php index d182461..21e41b3 100644 --- a/src/Executor/Data/Context.php +++ b/src/Executor/Data/Context.php @@ -9,7 +9,6 @@ final class Context implements ExecutionContext { public function __construct( public bool $partialFailures = false, - public bool $runConstraints = true, public bool $coercePrimitives = false, ) { diff --git a/src/Executor/Data/IssueMessage.php b/src/Executor/Data/IssueMessage.php index cc89224..5961fbe 100644 --- a/src/Executor/Data/IssueMessage.php +++ b/src/Executor/Data/IssueMessage.php @@ -9,7 +9,9 @@ enum IssueMessage: string case MISSING_PROPERTY = 'validation.missing_property'; case FALSY_STRING = 'validation.falsy_string'; case NOT_EMPTY_STRING = 'validation.not_empty_string'; - case INVALID_EMAIL = 'validation.invalid_email'; + case NOT_NUMERIC_STRING = 'validation.not_numeric_string'; + case NOT_LOWERCASE_STRING = 'validation.not_lowercase_string'; + case NOT_UPPERCASE_STRING = 'validation.not_uppercase_string'; case INTERNAL_ERROR = 'internal_error'; case INVALID_MIN = 'validation.invalid_min'; case INVALID_MAX = 'validation.invalid_max'; diff --git a/src/Executor/Data/SerializationOptions.php b/src/Executor/Data/SerializationOptions.php index 72f1726..5840cfe 100644 --- a/src/Executor/Data/SerializationOptions.php +++ b/src/Executor/Data/SerializationOptions.php @@ -2,11 +2,15 @@ namespace Le0daniel\PhpTsBindings\Executor\Data; +/** + * There is deliberately no constraint toggle here. Constraints prove refinements that PHPStan + * expresses about untrusted INPUT; output has already been through static analysis. See + * SchemaExecutor::executeSerialize(). + */ final readonly class SerializationOptions { public function __construct( public bool $partialFailures = true, - public bool $runConstraints = false, ) { } diff --git a/src/Executor/SchemaExecutor.php b/src/Executor/SchemaExecutor.php index 5d97173..dd009bc 100644 --- a/src/Executor/SchemaExecutor.php +++ b/src/Executor/SchemaExecutor.php @@ -56,7 +56,6 @@ public function parse(NodeInterface $node, mixed $input, ParsingOptions $options { $context = new Context( partialFailures: $options->partialFailures, - runConstraints: true, coercePrimitives: $options->coercePrimitives, ); $result = $this->executeParse($node, $input, $context); @@ -72,7 +71,6 @@ public function serialize(NodeInterface $node, mixed $output, SerializationOptio { $context = new Context( partialFailures: $options->partialFailures, - runConstraints: $options->runConstraints, ); $result = $this->executeSerialize($node, $output, $context); @@ -90,7 +88,11 @@ public function serialize(NodeInterface $node, mixed $output, SerializationOptio #[Override] public function executeSerialize(NodeInterface $node, mixed $data, Context $context): mixed { - // Constraints are ignored when serializing. + // Constraints are never run when serializing, and there is no option to turn them on. + // Parsing proves refinements about input the application did not produce and cannot + // trust. Output came out of the application's own code, which PHPStan already analysed + // against the very return type being serialized here - re-checking it would pay at + // runtime for a guarantee static analysis has already given. if ($node instanceof ConstraintNode) { return $this->executeSerialize($node->node, $data, $context); } diff --git a/src/Parser/Constraints/IntRange.php b/src/Parser/Constraints/IntRange.php new file mode 100644 index 0000000..b18c82a --- /dev/null +++ b/src/Parser/Constraints/IntRange.php @@ -0,0 +1,84 @@ +`, `positive-int`, `negative-int`, `non-negative-int` and + * `non-positive-int`. + * + * PHPStan ranges are inclusive at both ends, so there is no exclusive variant to configure. An + * open end is null rather than PHP_INT_MIN/PHP_INT_MAX: `int` states that there is no + * lower bound, which is not the same claim as "the lower bound happens to be the smallest int + * this platform can hold". + */ +final readonly class IntRange implements Constraint +{ + public function __construct( + public ?int $min = null, + public ?int $max = null, + ) + { + } + + #[Override] + public function validate(mixed $value, ExecutionContext $context): bool + { + if (!is_int($value)) { + $context->addIssue(new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => "Expected int, got: " . gettype($value), + ], + )); + return false; + } + + if ($this->min !== null && $value < $this->min) { + $context->addIssue(new Issue( + IssueMessage::INVALID_MIN, + [ + 'message' => "Expected an int of at least {$this->min}, got: {$value}.", + 'min' => $this->min, + 'value' => $value, + ], + )); + return false; + } + + if ($this->max !== null && $value > $this->max) { + $context->addIssue(new Issue( + IssueMessage::INVALID_MAX, + [ + 'message' => "Expected an int of at most {$this->max}, got: {$value}.", + 'max' => $this->max, + 'value' => $value, + ], + )); + return false; + } + + return true; + } + + #[Override] + public function exportPhpCode(): string + { + $className = PHPExport::absolute(self::class); + $min = PHPExport::export($this->min); + $max = PHPExport::export($this->max); + return "new {$className}({$min},{$max})"; + } + + #[Override] + public function __toString(): string + { + return 'IntRange(' . ($this->min ?? 'min') . ', ' . ($this->max ?? 'max') . ')'; + } +} diff --git a/src/Parser/Constraints/ListLength.php b/src/Parser/Constraints/ListLength.php new file mode 100644 index 0000000..1c0adb2 --- /dev/null +++ b/src/Parser/Constraints/ListLength.php @@ -0,0 +1,84 @@ +` and `non-empty-array`, both of which PHPStan expresses as a + * minimum of one element. + * + * It counts records as readily as lists: `non-empty-array` parses to a RecordNode, + * and both a record and a list are a plain PHP array on the wire, so one count() covers them. + */ +final readonly class ListLength implements Constraint +{ + public function __construct( + public ?int $min = null, + public ?int $max = null, + ) + { + } + + #[Override] + public function validate(mixed $value, ExecutionContext $context): bool + { + if (!is_array($value)) { + $context->addIssue(new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => "Expected array, got: " . gettype($value), + ], + )); + return false; + } + + $count = count($value); + + if ($this->min !== null && $count < $this->min) { + $context->addIssue(new Issue( + IssueMessage::INVALID_MIN, + [ + 'message' => "Expected at least {$this->min} elements, got: {$count}.", + 'min' => $this->min, + 'count' => $count, + ], + )); + return false; + } + + if ($this->max !== null && $count > $this->max) { + $context->addIssue(new Issue( + IssueMessage::INVALID_MAX, + [ + 'message' => "Expected at most {$this->max} elements, got: {$count}.", + 'max' => $this->max, + 'count' => $count, + ], + )); + return false; + } + + return true; + } + + #[Override] + public function exportPhpCode(): string + { + $className = PHPExport::absolute(self::class); + $min = PHPExport::export($this->min); + $max = PHPExport::export($this->max); + return "new {$className}({$min},{$max})"; + } + + #[Override] + public function __toString(): string + { + return 'ListLength(' . ($this->min ?? 'min') . ', ' . ($this->max ?? 'max') . ')'; + } +} diff --git a/src/Parser/Constraints/LowercaseString.php b/src/Parser/Constraints/LowercaseString.php new file mode 100644 index 0000000..e5027b6 --- /dev/null +++ b/src/Parser/Constraints/LowercaseString.php @@ -0,0 +1,54 @@ +isString($value, $context)) { + return false; + } + + if (strtolower($value) !== $value) { + $context->addIssue(new Issue( + IssueMessage::NOT_LOWERCASE_STRING, + [ + "message" => "Expected lowercase string, got: '{$value}'", + ] + )); + return false; + } + + return true; + } + + #[Override] + public function exportPhpCode(): string + { + return 'new ' . PHPExport::absolute(self::class) . '()'; + } + + #[Override] + public function __toString(): string + { + return 'LowercaseString'; + } +} diff --git a/src/Constraints/NonEmptyString.php b/src/Parser/Constraints/NonEmptyString.php similarity index 69% rename from src/Constraints/NonEmptyString.php rename to src/Parser/Constraints/NonEmptyString.php index 031bdc6..26b3a79 100644 --- a/src/Constraints/NonEmptyString.php +++ b/src/Parser/Constraints/NonEmptyString.php @@ -1,8 +1,7 @@ addIssue(new Issue( - IssueMessage::INVALID_TYPE, - [ - "message" => "Expected string, got: " . gettype($value), - ] - )); + if (!$this->isString($value, $context)) { return false; } @@ -37,13 +35,19 @@ public function validate(mixed $value, ExecutionContext $context): bool )); return false; } + return true; } #[Override] public function exportPhpCode(): string { - $className = PHPExport::absolute(self::class); - return "new {$className}()"; + return 'new ' . PHPExport::absolute(self::class) . '()'; + } + + #[Override] + public function __toString(): string + { + return 'NonEmptyString'; } -} \ No newline at end of file +} diff --git a/src/Constraints/NonFalsyString.php b/src/Parser/Constraints/NonFalsyString.php similarity index 63% rename from src/Constraints/NonFalsyString.php rename to src/Parser/Constraints/NonFalsyString.php index e471af9..d538251 100644 --- a/src/Constraints/NonFalsyString.php +++ b/src/Parser/Constraints/NonFalsyString.php @@ -1,8 +1,7 @@ addIssue(new Issue( - IssueMessage::INVALID_TYPE, - [ - "message" => "Expected string, got: " . gettype($value), - "value" => $value, - ] - )); + if (!$this->isString($value, $context)) { return false; } @@ -44,7 +40,12 @@ public function validate(mixed $value, ExecutionContext $context): bool #[Override] public function exportPhpCode(): string { - $className = PHPExport::absolute(self::class); - return "new {$className}()"; + return 'new ' . PHPExport::absolute(self::class) . '()'; + } + + #[Override] + public function __toString(): string + { + return 'NonFalsyString'; } -} \ No newline at end of file +} diff --git a/src/Parser/Constraints/NumericString.php b/src/Parser/Constraints/NumericString.php new file mode 100644 index 0000000..e5acaac --- /dev/null +++ b/src/Parser/Constraints/NumericString.php @@ -0,0 +1,52 @@ +isString($value, $context)) { + return false; + } + + if (!is_numeric($value)) { + $context->addIssue(new Issue( + IssueMessage::NOT_NUMERIC_STRING, + [ + "message" => "Expected numeric string, got: '{$value}'", + ] + )); + return false; + } + + return true; + } + + #[Override] + public function exportPhpCode(): string + { + return 'new ' . PHPExport::absolute(self::class) . '()'; + } + + #[Override] + public function __toString(): string + { + return 'NumericString'; + } +} diff --git a/src/Parser/Constraints/UppercaseString.php b/src/Parser/Constraints/UppercaseString.php new file mode 100644 index 0000000..aa307a4 --- /dev/null +++ b/src/Parser/Constraints/UppercaseString.php @@ -0,0 +1,50 @@ +isString($value, $context)) { + return false; + } + + if (strtoupper($value) !== $value) { + $context->addIssue(new Issue( + IssueMessage::NOT_UPPERCASE_STRING, + [ + "message" => "Expected uppercase string, got: '{$value}'", + ] + )); + return false; + } + + return true; + } + + #[Override] + public function exportPhpCode(): string + { + return 'new ' . PHPExport::absolute(self::class) . '()'; + } + + #[Override] + public function __toString(): string + { + return 'UppercaseString'; + } +} diff --git a/src/Parser/Constraints/ValidatesString.php b/src/Parser/Constraints/ValidatesString.php new file mode 100644 index 0000000..6640683 --- /dev/null +++ b/src/Parser/Constraints/ValidatesString.php @@ -0,0 +1,33 @@ +addIssue(new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => "Expected string, got: " . gettype($value), + ], + )); + return false; + } +} diff --git a/src/Parser/Consumers/AliasConsumer.php b/src/Parser/Consumers/AliasConsumer.php index 4e62a7c..30d74ba 100644 --- a/src/Parser/Consumers/AliasConsumer.php +++ b/src/Parser/Consumers/AliasConsumer.php @@ -5,9 +5,9 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Override; @@ -69,7 +69,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $importDefinition = $state->context->getImportedTypeInfo($token->value); return $parser->parse( $importDefinition['typeName'], - ParsingContext::fromClassString($importDefinition['className']), + ParsingScope::fromClassString($importDefinition['className']), ); } diff --git a/src/Parser/Consumers/ArrayConsumer.php b/src/Parser/Consumers/ArrayConsumer.php index 71c42d5..bd33231 100644 --- a/src/Parser/Consumers/ArrayConsumer.php +++ b/src/Parser/Consumers/ArrayConsumer.php @@ -2,10 +2,13 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; +use Le0daniel\PhpTsBindings\Parser\Constraints\ListLength; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\MixedNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; @@ -42,15 +45,21 @@ public function canConsume(ParserState $state): bool } /** + * `non-empty-list` and `non-empty-array` are the same shape as their plain counterparts plus + * a minimum element count, so the keyword is split into the shape it describes and the + * refinement it adds rather than being normalised away. + * * @throws InvalidSyntaxException */ #[Override] - public function consume(ParserState $state, TypeParser $parser): RecordNode|ListNode|TupleNode + public function consume(ParserState $state, TypeParser $parser): NodeInterface { - $type = match ($state->current()->value) { + $keyword = $state->current()->value; + $type = match ($keyword) { 'list', 'non-empty-list' => 'list', default => 'array', }; + $isNonEmpty = $keyword === 'non-empty-list' || $keyword === 'non-empty-array'; if (!$state->current()->is(TokenType::IDENTIFIER)) { $state->produceSyntaxError("Expected Array Type Identifier: array or list"); @@ -78,24 +87,37 @@ public function consume(ParserState $state, TypeParser $parser): RecordNode|List // No generics if (!$state->currentTokenIs(TokenType::LT)) { - return new ListNode(new MixedNode()); + return $this->applyEmptiness(new ListNode(new MixedNode()), $isNonEmpty); } $generics = $this->consumeGenerics($state, $parser, min: 1, max: $maxGenerics); if (count($generics) === 1) { - return new ListNode($generics[0]); + return $this->applyEmptiness(new ListNode($generics[0]), $isNonEmpty); } // A branded key (array, V>) is still a string key on the wire. // Constraints are deliberately NOT unwrapped: a constrained key (array) // could never be validated at runtime, so it is rejected instead of silently loosened. $keyType = Nodes::unwrapMetadata($generics[0]); - return match (true) { + $node = match (true) { $keyType instanceof StringNode => new RecordNode($generics[1]), $keyType instanceof IntNode => new ListNode($generics[1]), default => $state->produceSyntaxError("Array key type must be 'string' or 'int'. Got: {$keyType}"), }; + + return $this->applyEmptiness($node, $isNonEmpty); + } + + /** + * ListLength counts a RecordNode as readily as a ListNode - `non-empty-array` is a + * record, and both are a plain PHP array by the time the executor sees them. + */ + private function applyEmptiness(RecordNode|ListNode $node, bool $isNonEmpty): NodeInterface + { + return $isNonEmpty + ? new ConstraintNode($node, [new ListLength(min: 1)]) + : $node; } /** diff --git a/src/Parser/Consumers/BuiltInLeafConsumer.php b/src/Parser/Consumers/BuiltInLeafConsumer.php index 37dffba..6d6ad9d 100644 --- a/src/Parser/Consumers/BuiltInLeafConsumer.php +++ b/src/Parser/Consumers/BuiltInLeafConsumer.php @@ -2,6 +2,12 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; +use Le0daniel\PhpTsBindings\Parser\Constraints\IntRange; +use Le0daniel\PhpTsBindings\Parser\Constraints\LowercaseString; +use Le0daniel\PhpTsBindings\Parser\Constraints\NonEmptyString; +use Le0daniel\PhpTsBindings\Parser\Constraints\NonFalsyString; +use Le0daniel\PhpTsBindings\Parser\Constraints\NumericString; +use Le0daniel\PhpTsBindings\Parser\Constraints\UppercaseString; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; @@ -16,11 +22,15 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Constraints\Length; -use Le0daniel\PhpTsBindings\Constraints\NonEmptyString; -use Le0daniel\PhpTsBindings\Constraints\NonFalsyString; use Override; +/** + * Every keyword here that is not a plain PHP type is a PHPStan refinement: the leaf node proves + * the PHP type, the constraint proves what PHPStan narrowed it to. + * + * `int-mask` and `int-mask-of` are deliberately absent. Integer refinement is `int` + * (IntConsumer) plus the four named shorthands below, nothing else. + */ final readonly class BuiltInLeafConsumer implements TypeConsumer { @@ -40,6 +50,11 @@ public function canConsume(ParserState $state): bool 'truthy-string', 'non-falsy-string', 'non-empty-string', + 'numeric-string', + 'lowercase-string', + 'uppercase-string', + 'non-empty-lowercase-string', + 'non-empty-uppercase-string', 'scalar', 'positive-int', 'negative-int', @@ -73,6 +88,29 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface new StringNode(), [new NonEmptyString()], ), + 'numeric-string' => new ConstraintNode( + new StringNode(), + [new NumericString()], + ), + 'lowercase-string' => new ConstraintNode( + new StringNode(), + [new LowercaseString()], + ), + 'uppercase-string' => new ConstraintNode( + new StringNode(), + [new UppercaseString()], + ), + + // Two refinements over one string. ConstraintNode already carries a list, so the pair + // needs no nesting; list order is the order the failures are reported in. + 'non-empty-lowercase-string' => new ConstraintNode( + new StringNode(), + [new NonEmptyString(), new LowercaseString()], + ), + 'non-empty-uppercase-string' => new ConstraintNode( + new StringNode(), + [new NonEmptyString(), new UppercaseString()], + ), 'scalar' => new UnionNode([ new IntNode(), new FloatNode(), @@ -81,19 +119,19 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface ]), 'positive-int' => new ConstraintNode( new IntNode(), - [new Length(min: 1, including: true)] + [new IntRange(min: 1)] ), 'negative-int' => new ConstraintNode( new IntNode(), - [new Length(max: -1, including: true)] + [new IntRange(max: -1)] ), "non-negative-int" => new ConstraintNode( new IntNode(), - [new Length(min: 0, including: true)] + [new IntRange(min: 0)] ), 'non-positive-int' => new ConstraintNode( new IntNode(), - [new Length(max: 0, including: true)] + [new IntRange(max: 0)] ), 'numeric' => new UnionNode([ new IntNode(), @@ -102,4 +140,4 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface default => $state->produceSyntaxError('Expected valid built-in type, got ' . $token->value), }; } -} \ No newline at end of file +} diff --git a/src/Parser/Consumers/IntConsumer.php b/src/Parser/Consumers/IntConsumer.php index 949da17..6e0c52c 100644 --- a/src/Parser/Consumers/IntConsumer.php +++ b/src/Parser/Consumers/IntConsumer.php @@ -11,7 +11,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Constraints\Length; +use Le0daniel\PhpTsBindings\Parser\Constraints\IntRange; use Override; final readonly class IntConsumer implements TypeConsumer @@ -34,10 +34,14 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface return new IntNode(); } + // `min` and `max` become null, not PHP_INT_MIN/PHP_INT_MAX: `int` says there is + // no lower bound, which is a different claim from "the bound is this platform's smallest + // int". Both validate identically; null keeps the exported cache and the diagnostic label + // readable. $state->advance(); $min = match (true) { $state->currentTokenIs(TokenType::INT) => Lexemes::decodeInt($state->current()->value), - $state->currentTokenIs(TokenType::IDENTIFIER, 'min') => PHP_INT_MIN, + $state->currentTokenIs(TokenType::IDENTIFIER, 'min') => null, default => $state->produceSyntaxError('Expected int or min'), }; @@ -49,7 +53,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $max = match (true) { $state->currentTokenIs(TokenType::INT) => Lexemes::decodeInt($state->current()->value), - $state->currentTokenIs(TokenType::IDENTIFIER, 'max') => PHP_INT_MAX, + $state->currentTokenIs(TokenType::IDENTIFIER, 'max') => null, default => $state->produceSyntaxError('Expected int or max'), }; @@ -62,7 +66,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface return new ConstraintNode( new IntNode(), - [new Length(min: $min, max: $max, including: true)] + [new IntRange($min, $max)] ); } } \ No newline at end of file diff --git a/src/Parser/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Consumers/UserDefinedObjectConsumer.php index 08eb6b2..463783b 100644 --- a/src/Parser/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Consumers/UserDefinedObjectConsumer.php @@ -4,15 +4,13 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Castable; use Le0daniel\PhpTsBindings\Contracts\Attributes\Optional; -use Le0daniel\PhpTsBindings\Parser\Contracts\Constraint; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; -use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\PropertyType; @@ -23,9 +21,7 @@ use Le0daniel\PhpTsBindings\Reflection\AttributesReflector; use Le0daniel\PhpTsBindings\Reflection\MetadataAttributes; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; -use Le0daniel\PhpTsBindings\Utils\Lists; use Override; -use ReflectionAttribute; use ReflectionClass; use ReflectionException; use ReflectionParameter; @@ -106,7 +102,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $reflectionClass = new ReflectionClass($fullyQualifiedClassName); $castingStrategy = $this->determineCastingStrategy($reflectionClass); - $context = ParsingContext::fromReflectionClass($reflectionClass, $this->consumeGenerics($state, $parser)); + $context = ParsingScope::fromReflectionClass($reflectionClass, $this->consumeGenerics($state, $parser)); $node = match ($castingStrategy) { ObjectCastStrategy::NEVER => $this->parseNeverStrategy($reflectionClass, $parser, $context), @@ -142,17 +138,14 @@ private function allowsOptional(ReflectionProperty|ReflectionParameter $param): } /** @param ReflectionClass $reflectionClass */ - private function parseNeverStrategy(ReflectionClass $reflectionClass, TypeParser $parser, ParsingContext $context): CustomCastingNode + private function parseNeverStrategy(ReflectionClass $reflectionClass, TypeParser $parser, ParsingScope $context): CustomCastingNode { $properties = array_map( fn(ReflectionProperty $property) => new PropertyNode( $property->getName(), - $this->applyConstraints( - $property, - $parser->parse( - TypeReflector::reflectProperty($property), - $context->descendIntoDeclaringClass($property) - ) + $parser->parse( + TypeReflector::reflectProperty($property), + $context->descendIntoDeclaringClass($property) ), false, PropertyType::OUTPUT, @@ -171,7 +164,7 @@ private function parseNeverStrategy(ReflectionClass $reflectionClass, TypeParser } /** @param ReflectionClass $reflectionClass */ - private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, TypeParser $parser, ParsingContext $context): CustomCastingNode + private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, TypeParser $parser, ParsingScope $context): CustomCastingNode { $properties = []; foreach ($reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { @@ -181,12 +174,9 @@ private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, Ty $properties[] = new PropertyNode( $property->getName(), - $this->applyConstraints( - $property, - $parser->parse( - TypeReflector::reflectProperty($property), - $context->descendIntoDeclaringClass($property) - ) + $parser->parse( + TypeReflector::reflectProperty($property), + $context->descendIntoDeclaringClass($property) ), isOptional: $this->allowsOptional($property), propertyType: PropertyType::BOTH, @@ -200,29 +190,11 @@ private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, Ty ); } - private function applyConstraints(ReflectionProperty|ReflectionParameter $reflection, NodeInterface $node): NodeInterface - { - $constraints = Lists::filterNullValues( - array_map( - static function (ReflectionAttribute $attribute): null|Constraint { - $instance = $attribute->newInstance(); - return $instance instanceof Constraint ? $instance : null; - }, - $reflection->getAttributes() - ) - ); - - return empty($constraints) ? $node : new ConstraintNode( - $node, - $constraints, - ); - } - /** * @param ReflectionClass $reflectionClass * @throws InvalidSyntaxException */ - private function parseConstructorStrategy(ReflectionClass $reflectionClass, TypeParser $parser, ParsingContext $context): CustomCastingNode + private function parseConstructorStrategy(ReflectionClass $reflectionClass, TypeParser $parser, ParsingScope $context): CustomCastingNode { /** @var array $structProperties */ $structProperties = []; @@ -237,12 +209,9 @@ private function parseConstructorStrategy(ReflectionClass $reflectionClass, Type foreach ($constructor->getParameters() as $parameter) { $structProperties[] = new PropertyNode( $parameter->name, - $this->applyConstraints( - $parameter, - $parser->parse( - TypeReflector::reflectParameter($parameter), - $context->descendIntoDeclaringClass($parameter) - ) + $parser->parse( + TypeReflector::reflectParameter($parameter), + $context->descendIntoDeclaringClass($parameter) ), isOptional: $this->allowsOptional($parameter), propertyType: PropertyType::INPUT, @@ -260,12 +229,9 @@ private function parseConstructorStrategy(ReflectionClass $reflectionClass, Type $structProperties[] = new PropertyNode( $property->name, - $this->applyConstraints( - $property, - $parser->parse( - TypeReflector::reflectProperty($property), - $context->descendIntoDeclaringClass($property) - ) + $parser->parse( + TypeReflector::reflectProperty($property), + $context->descendIntoDeclaringClass($property) ), isOptional: $this->allowsOptional($property), propertyType: PropertyType::OUTPUT, diff --git a/src/Parser/Contracts/Constraint.php b/src/Parser/Contracts/Constraint.php index 4339002..a6f52d0 100644 --- a/src/Parser/Contracts/Constraint.php +++ b/src/Parser/Contracts/Constraint.php @@ -4,8 +4,24 @@ use Le0daniel\PhpTsBindings\Contracts\ExportableToPhpCode; use Le0daniel\PhpTsBindings\Executor\Contracts\ExecutionContext; +use Stringable; -interface Constraint extends ExportableToPhpCode +/** + * A refinement that a PHPStan type expresses but the PHP type system does not: `positive-int` + * is an `int` to PHP, `non-empty-list` is an `array`. The leaf node proves the PHP type; the + * constraint proves what PHPStan added on top of it. + * + * Constraints are constructed by the consumers in Le0daniel\PhpTsBindings\Parser\Consumers from + * the type string alone. There is deliberately no way to attach one to a property that its type + * does not declare, so the AST always corresponds to the type it claims to represent. + * + * They run when PARSING untrusted input, never when serializing output. See + * SchemaExecutor::executeSerialize(). + * + * __toString() is the label that appears in ConstraintNode's diagnostics, so it must name the + * bounds a constraint carries: `IntRange(1, max)`, not just `IntRange`. + */ +interface Constraint extends ExportableToPhpCode, Stringable { public function validate(mixed $value, ExecutionContext $context): bool; -} \ No newline at end of file +} diff --git a/src/Parser/Definition/ParserState.php b/src/Parser/Definition/ParserState.php index 7699b1b..2317998 100644 --- a/src/Parser/Definition/ParserState.php +++ b/src/Parser/Definition/ParserState.php @@ -2,9 +2,9 @@ namespace Le0daniel\PhpTsBindings\Parser\Definition; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\Lexer\SourceLocation; use Le0daniel\PhpTsBindings\Parser\Lexer\Token; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; @@ -33,12 +33,12 @@ final class ParserState /** * @param string $input * @param non-empty-list $tokens The raw, lossless token stream. - * @param ParsingContext $context + * @param ParsingScope $context */ public function __construct( - public readonly string $input, - array $tokens, - public readonly ParsingContext $context, + public readonly string $input, + array $tokens, + public readonly ParsingScope $context, ) { $significant = array_values( diff --git a/src/Parser/Data/ParsingContext.php b/src/Parser/Helpers/ParsingScope.php similarity index 98% rename from src/Parser/Data/ParsingContext.php rename to src/Parser/Helpers/ParsingScope.php index 9bc42fa..d47755e 100644 --- a/src/Parser/Data/ParsingContext.php +++ b/src/Parser/Helpers/ParsingScope.php @@ -1,6 +1,6 @@ node->__toString(); } + // Each constraint names its own bounds, so `int<0, 100>` reads as `int & IntRange(0, 100)` + // rather than losing the numbers to a bare class name. $names = implode(', ', array_map( - static fn(Constraint $constraint) => new \ReflectionClass($constraint)->getShortName(), + static fn(Constraint $constraint): string => (string)$constraint, $this->constraints, )); diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index f1ca59f..133b9d8 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -17,9 +17,9 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\Lexer\Exceptions\UnexpectedCharacterException; use Le0daniel\PhpTsBindings\Parser\Lexer\Lexer; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; @@ -94,7 +94,7 @@ public static function defaultConsumers( * * @throws InvalidSyntaxException */ - public function parse(string $typeString, ParsingContext $context = new ParsingContext()): NodeInterface + public function parse(string $typeString, ParsingScope $context = new ParsingScope()): NodeInterface { try { $tokens = new Lexer()->tokenize($typeString); diff --git a/src/Server/Operations/EagerlyLoadedOperationRegistry.php b/src/Server/Operations/EagerlyLoadedOperationRegistry.php index 4236675..5ca7e65 100644 --- a/src/Server/Operations/EagerlyLoadedOperationRegistry.php +++ b/src/Server/Operations/EagerlyLoadedOperationRegistry.php @@ -5,7 +5,7 @@ use Closure; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\FileReflector; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; @@ -92,7 +92,7 @@ private static function registryFromDiscovery( $classReflection = new ReflectionClass($definition->fullyQualifiedClassName); $inputParameter = $classReflection->getMethod($definition->methodName)->getParameters()[0]; - $parsingContext = ParsingContext::fromReflectionClass($classReflection); + $parsingContext = ParsingScope::fromReflectionClass($classReflection); $input = fn() => $parser->parse(TypeReflector::reflectParameter($inputParameter), $parsingContext); $output = fn() => $parser->parse(TypeReflector::reflectReturnType($classReflection->getMethod($definition->methodName)), $parsingContext); diff --git a/tests/Feature/FullSchemaTest.php b/tests/Feature/FullSchemaTest.php index eaf5f2e..5bc74af 100644 --- a/tests/Feature/FullSchemaTest.php +++ b/tests/Feature/FullSchemaTest.php @@ -77,11 +77,17 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu 'age' => 123, 'email' => 'my@mail.test', ]))->toBeSuccess() + // Both refinements come from the property's PHPStan type, nothing else. ->and($schema([ 'username' => 'my username', 'age' => 123, - 'email' => 'my mail', - ]))->toBeFailureAt('email', 'validation.invalid_email'); + 'email' => '', + ]))->toBeFailureAt('email', 'validation.not_empty_string') + ->and($schema([ + 'username' => 'my username', + 'age' => 0, + 'email' => 'my@mail.test', + ]))->toBeFailureAt('age', 'validation.invalid_min'); $createUser = $schema([ 'username' => 'my username', diff --git a/tests/Feature/Mocks/CreateUserInput.php b/tests/Feature/Mocks/CreateUserInput.php index 12ed010..4c11599 100644 --- a/tests/Feature/Mocks/CreateUserInput.php +++ b/tests/Feature/Mocks/CreateUserInput.php @@ -3,7 +3,6 @@ namespace Tests\Feature\Mocks; use Le0daniel\PhpTsBindings\Contracts\Attributes\Castable; -use Le0daniel\PhpTsBindings\Constraints\Email; #[Castable] final class CreateUserInput @@ -15,6 +14,10 @@ final class CreateUserInput */ public int $age; - #[Email] + /** + * A property is refined by its PHPStan type or not at all - there is no attribute channel. + * + * @var non-empty-string + */ public string $email; -} \ No newline at end of file +} diff --git a/tests/Unit/Constraints/EmailTest.php b/tests/Unit/Constraints/EmailTest.php deleted file mode 100644 index 038bdde..0000000 --- a/tests/Unit/Constraints/EmailTest.php +++ /dev/null @@ -1,57 +0,0 @@ -validate($value, $context); - return [$result, new Issues($context->issues)->at(Issues::ROOT_PATH)]; -} - -test('validate invalid string email', function () { - $email = new Email(); - - [$result, $issues] = validate('some value', $email); - - expect($result)->toBeFalse(); - expect($issues)->toHaveCount(1); - expect($issues[0]->messageOrLocalizationKey)->toBe(IssueMessage::INVALID_EMAIL->value); -}); - -test('validate valid string email', function () { - $email = new Email(); - - [$result, $issues] = validate('leo@test.test', $email); - - expect($result)->toBeTrue(); - expect($issues)->toHaveCount(0); -}); - -test('invalid data type', function (mixed $value) { - $email = new Email(); - - [$result, $issues] = validate($value, $email); - - expect($result)->toBeFalse(); - expect($issues)->toHaveCount(1); - expect($issues[0]->messageOrLocalizationKey)->toBe(IssueMessage::INVALID_TYPE->value); -})->with([ - [123,], - [-0.34,], - [[],], - [['value'],], - [(object) [],], - [(object) ['key' => 'value'],], - [false,], - [true,], - [null,], -]); \ No newline at end of file diff --git a/tests/Unit/Constraints/LengthTest.php b/tests/Unit/Constraints/LengthTest.php deleted file mode 100644 index d4598bf..0000000 --- a/tests/Unit/Constraints/LengthTest.php +++ /dev/null @@ -1,118 +0,0 @@ -context = new Context(); -}); - -it('validates string length correctly', function () { - $validator = new Length(min: 2, max: 5); - - expect($validator->validate('a', $this->context))->toBeFalse() - ->and($validator->validate('ab', $this->context))->toBeTrue() - ->and($validator->validate('abcd', $this->context))->toBeTrue() - ->and($validator->validate('abcdef', $this->context))->toBeFalse(); -}); - -it('validates array count correctly', function () { - $validator = new Length(min: 1, max: 3); - - expect($validator->validate([], $this->context))->toBeFalse() - ->and($validator->validate([1], $this->context))->toBeTrue() - ->and($validator->validate([1, 2, 3], $this->context))->toBeTrue() - ->and($validator->validate([1, 2, 3, 4], $this->context))->toBeFalse(); -}); - -it('validates integer values directly', function () { - $validator = new Length(min: 5, max: 10); - - expect($validator->validate(4, $this->context))->toBeFalse() - ->and($validator->validate(5, $this->context))->toBeTrue() - ->and($validator->validate(7, $this->context))->toBeTrue() - ->and($validator->validate(10, $this->context))->toBeTrue() - ->and($validator->validate(11, $this->context))->toBeFalse(); -}); - -it('handles non-including boundaries correctly', function () { - $validator = new Length(min: 5, max: 10, including: false); - - expect($validator->validate(5, $this->context))->toBeFalse() - ->and($validator->validate(6, $this->context))->toBeTrue() - ->and($validator->validate(9, $this->context))->toBeTrue() - ->and($validator->validate(10, $this->context))->toBeFalse(); -}); - -it('handles null min correctly', function () { - $validator = new Length(max: 5); - - expect($validator->validate(1, $this->context))->toBeTrue() - ->and($validator->validate(5, $this->context))->toBeTrue() - ->and($validator->validate(6, $this->context))->toBeFalse(); -}); - -it('handles null max correctly', function () { - $validator = new Length(min: 5); - - expect($validator->validate(4, $this->context))->toBeFalse() - ->and($validator->validate(5, $this->context))->toBeTrue() - ->and($validator->validate(100, $this->context))->toBeTrue(); -}); - -it('returns false for invalid types', function () { - $validator = new Length(min: 1, max: 5); - - expect($validator->validate(null, $this->context))->toBeFalse() - ->and($validator->validate(new \stdClass(), $this->context))->toBeFalse() - ->and($validator->validate(true, $this->context))->toBeFalse(); -}); - -it('exports PHP code correctly', function () { - $validator = new Length(min: 5, max: 10, including: false); - $expected = 'new \\' . Length::class . '(5, 10, false)'; - expect($validator->exportPhpCode())->toBe($expected); -}); - -it('adds correct validation issues to context', function () { - $validator = new Length(min: 2, max: 4); - $context = new Context(); - - // Test invalid type - $validator->validate(null, $context); - expect($context->issues[Issues::ROOT_PATH])->toHaveCount(1) - ->and($context->issues[Issues::ROOT_PATH][0]->messageOrLocalizationKey)->toBe('validation.invalid_type') - ->and($context->issues[Issues::ROOT_PATH][0]->debugInfo)->toHaveKey('message') - ->and($context->issues[Issues::ROOT_PATH][0]->debugInfo['message'])->toContain('Wrong type for length validation'); - - // Reset context - $context = new Context(); - - // Test min validation - $validator->validate('a', $context); - expect($context->issues[Issues::ROOT_PATH])->toHaveCount(1) - ->and($context->issues[Issues::ROOT_PATH][0]->messageOrLocalizationKey)->toBe('validation.invalid_min') - ->and($context->issues[Issues::ROOT_PATH][0]->debugInfo)->toHaveKey('message') - ->and($context->issues[Issues::ROOT_PATH][0]->debugInfo['message'])->toContain('Expected value to be at least 2 characters long'); - - // Reset context - $context = new Context(); - - // Test max validation - $validator->validate('abcde', $context); - expect($context->issues[Issues::ROOT_PATH])->toHaveCount(1) - ->and($context->issues[Issues::ROOT_PATH][0]->messageOrLocalizationKey)->toBe('validation.invalid_max') - ->and($context->issues[Issues::ROOT_PATH][0]->debugInfo)->toHaveKey('message') - ->and($context->issues[Issues::ROOT_PATH][0]->debugInfo['message'])->toContain('Expected value to be at most 4 characters long'); - - // Reset context - $context = new Context(); - - // Test valid input (should not add any issues) - $validator->validate('abc', $context); - expect($context->issues)->toBeEmpty(); -}); - diff --git a/tests/Unit/Contracts/ExceptionHierarchyTest.php b/tests/Unit/Contracts/ExceptionHierarchyTest.php index 32161ee..1e282df 100644 --- a/tests/Unit/Contracts/ExceptionHierarchyTest.php +++ b/tests/Unit/Contracts/ExceptionHierarchyTest.php @@ -9,7 +9,7 @@ use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Exceptions\UnknownTypeKeyException; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\Lexer\Exceptions\UnexpectedCharacterException; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; @@ -84,7 +84,7 @@ */ test('a malformed type reaches the consumer as a PhpTsBindingsException', function (string $type) { try { - new TypeParser()->parse($type, new ParsingContext()); + new TypeParser()->parse($type, new ParsingScope()); $this->fail("Expected '{$type}' to be rejected."); } catch (PhpTsBindingsException) { expect(true)->toBeTrue(); diff --git a/tests/Unit/Parser/Constraints/IntRangeTest.php b/tests/Unit/Parser/Constraints/IntRangeTest.php new file mode 100644 index 0000000..bd401ba --- /dev/null +++ b/tests/Unit/Parser/Constraints/IntRangeTest.php @@ -0,0 +1,80 @@ +context = new Context(); +}); + +it('validates an inclusive range', function () { + $constraint = new IntRange(min: 5, max: 10); + + expect($constraint->validate(4, $this->context))->toBeFalse() + ->and($constraint->validate(5, $this->context))->toBeTrue() + ->and($constraint->validate(7, $this->context))->toBeTrue() + ->and($constraint->validate(10, $this->context))->toBeTrue() + ->and($constraint->validate(11, $this->context))->toBeFalse(); +}); + +it('treats a null bound as unbounded', function () { + expect(new IntRange(max: 5)->validate(PHP_INT_MIN, $this->context))->toBeTrue() + ->and(new IntRange(max: 5)->validate(6, $this->context))->toBeFalse() + ->and(new IntRange(min: 5)->validate(PHP_INT_MAX, $this->context))->toBeTrue() + ->and(new IntRange(min: 5)->validate(4, $this->context))->toBeFalse(); +}); + +/** + * The Length constraint this replaced dispatched on gettype(), so an int range happily accepted a + * string of the right character count and an array of the right element count. A constraint now + * owns exactly one PHP type. + */ +it('rejects everything that is not an int', function (mixed $value) { + $constraint = new IntRange(min: 1, max: 5); + + expect($constraint->validate($value, $this->context))->toBeFalse(); +})->with([ + ['abc'], + ['5'], + [[1, 2, 3]], + [3.0], + [true], + [null], + [new \stdClass()], +]); + +it('reports the bound that failed', function () { + $constraint = new IntRange(min: 2, max: 4); + + $context = new Context(); + $constraint->validate(1, $context); + expect($context->issues[Issues::ROOT_PATH][0]->messageOrLocalizationKey)->toBe('validation.invalid_min'); + + $context = new Context(); + $constraint->validate(5, $context); + expect($context->issues[Issues::ROOT_PATH][0]->messageOrLocalizationKey)->toBe('validation.invalid_max'); + + $context = new Context(); + $constraint->validate('nope', $context); + expect($context->issues[Issues::ROOT_PATH][0]->messageOrLocalizationKey)->toBe('validation.invalid_type'); + + $context = new Context(); + $constraint->validate(3, $context); + expect($context->issues)->toBeEmpty(); +}); + +it('exports PHP code correctly', function () { + expect(new IntRange(min: 5, max: 10)->exportPhpCode()) + ->toBe('new \\' . IntRange::class . '(5,10)') + ->and(new IntRange(min: 1)->exportPhpCode()) + ->toBe('new \\' . IntRange::class . '(1,NULL)'); +}); + +it('names its bounds in diagnostics', function () { + expect((string)new IntRange(0, 100))->toBe('IntRange(0, 100)') + ->and((string)new IntRange(min: 1))->toBe('IntRange(1, max)') + ->and((string)new IntRange(max: -1))->toBe('IntRange(min, -1)'); +}); diff --git a/tests/Unit/Parser/Constraints/ListLengthTest.php b/tests/Unit/Parser/Constraints/ListLengthTest.php new file mode 100644 index 0000000..5b45a09 --- /dev/null +++ b/tests/Unit/Parser/Constraints/ListLengthTest.php @@ -0,0 +1,65 @@ +context = new Context(); +}); + +it('counts elements against an inclusive range', function () { + $constraint = new ListLength(min: 1, max: 3); + + expect($constraint->validate([], $this->context))->toBeFalse() + ->and($constraint->validate([1], $this->context))->toBeTrue() + ->and($constraint->validate([1, 2, 3], $this->context))->toBeTrue() + ->and($constraint->validate([1, 2, 3, 4], $this->context))->toBeFalse(); +}); + +// non-empty-array parses to a RecordNode, which is a string keyed PHP array. +it('counts a string keyed array the same way', function () { + $constraint = new ListLength(min: 1); + + expect($constraint->validate([], $this->context))->toBeFalse() + ->and($constraint->validate(['a' => 1], $this->context))->toBeTrue(); +}); + +it('rejects everything that is not an array', function (mixed $value) { + $constraint = new ListLength(min: 1, max: 5); + + expect($constraint->validate($value, $this->context))->toBeFalse(); +})->with([ + ['ab'], + [3], + [true], + [null], + [new \stdClass()], +]); + +it('reports the bound that failed', function () { + $constraint = new ListLength(min: 2, max: 3); + + $context = new Context(); + $constraint->validate([1], $context); + expect($context->issues[Issues::ROOT_PATH][0]->messageOrLocalizationKey)->toBe('validation.invalid_min'); + + $context = new Context(); + $constraint->validate([1, 2, 3, 4], $context); + expect($context->issues[Issues::ROOT_PATH][0]->messageOrLocalizationKey)->toBe('validation.invalid_max'); + + $context = new Context(); + $constraint->validate('nope', $context); + expect($context->issues[Issues::ROOT_PATH][0]->messageOrLocalizationKey)->toBe('validation.invalid_type'); +}); + +it('exports PHP code correctly', function () { + expect(new ListLength(min: 1)->exportPhpCode()) + ->toBe('new \\' . ListLength::class . '(1,NULL)'); +}); + +it('names its bounds in diagnostics', function () { + expect((string)new ListLength(min: 1))->toBe('ListLength(1, max)'); +}); diff --git a/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php b/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php new file mode 100644 index 0000000..48e03f9 --- /dev/null +++ b/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php @@ -0,0 +1,83 @@ +', []))->toBeFailure(IssueMessage::INVALID_MIN->value); + expect(executeParse('non-empty-list', [1]))->toBeSuccess(); +}); + +test('non-empty-array rejects the empty array', function (string $type) { + expect(executeParse($type, []))->toBeFailure(IssueMessage::INVALID_MIN->value); +})->with([ + 'non-empty-array', + 'non-empty-array', + 'non-empty-array', +]); + +test('non-empty-array accepts a populated array', function () { + expect(executeParse('non-empty-array', ['a' => 1]))->toBeSuccess(); + expect(executeParse('non-empty-array', [1]))->toBeSuccess(); +}); + +/** + * Constraints prove untrusted input. Output has already been through static analysis, so + * serialization trusts it — there is deliberately no option to change that. + */ +test('serialization does not run constraints', function () { + expect(executeSerialize('non-empty-list', []))->toBeSuccess(); + expect(executeSerialize('positive-int', -5))->toBeSuccess(); + expect(executeSerialize('non-empty-string', ''))->toBeSuccess(); +}); + +test('numeric-string', function () { + expect(executeParse('numeric-string', '42'))->toBeSuccess(); + expect(executeParse('numeric-string', '-1.5e3'))->toBeSuccess(); + expect(executeParse('numeric-string', 'abc'))->toBeFailure(IssueMessage::NOT_NUMERIC_STRING->value); + expect(executeParse('numeric-string', 42))->toBeFailure(); +}); + +test('lowercase-string', function () { + expect(executeParse('lowercase-string', 'abc'))->toBeSuccess(); + expect(executeParse('lowercase-string', ''))->toBeSuccess(); + expect(executeParse('lowercase-string', '123-.'))->toBeSuccess(); + expect(executeParse('lowercase-string', 'Abc'))->toBeFailure(IssueMessage::NOT_LOWERCASE_STRING->value); +}); + +test('uppercase-string', function () { + expect(executeParse('uppercase-string', 'ABC'))->toBeSuccess(); + expect(executeParse('uppercase-string', ''))->toBeSuccess(); + expect(executeParse('uppercase-string', 'aBC'))->toBeFailure(IssueMessage::NOT_UPPERCASE_STRING->value); +}); + +test('non-empty-lowercase-string', function () { + expect(executeParse('non-empty-lowercase-string', 'abc'))->toBeSuccess(); + expect(executeParse('non-empty-lowercase-string', ''))->toBeFailure(IssueMessage::NOT_EMPTY_STRING->value); + expect(executeParse('non-empty-lowercase-string', 'Abc'))->toBeFailure(IssueMessage::NOT_LOWERCASE_STRING->value); +}); + +test('non-empty-uppercase-string', function () { + expect(executeParse('non-empty-uppercase-string', 'ABC'))->toBeSuccess(); + expect(executeParse('non-empty-uppercase-string', ''))->toBeFailure(IssueMessage::NOT_EMPTY_STRING->value); + expect(executeParse('non-empty-uppercase-string', 'aBC'))->toBeFailure(IssueMessage::NOT_UPPERCASE_STRING->value); +}); + +/** + * The old shared Length constraint dispatched on gettype(), so an int range accepted a string of + * the right length and a list of the right count. Each constraint now owns one PHP type. + */ +test('an int range rejects everything that is not an int', function (mixed $value) { + expect(executeParse('int<1, 10>', $value))->toBeFailure(); +})->with([['5'], [[1, 2, 3]], [5.0], [null], [true]]); + +test('a list length rejects everything that is not an array', function (mixed $value) { + expect(executeParse('non-empty-list', $value))->toBeFailure(); +})->with([['ab'], [5], [null]]); diff --git a/tests/Unit/Constraints/StringConstraintsTest.php b/tests/Unit/Parser/Constraints/StringConstraintsTest.php similarity index 92% rename from tests/Unit/Constraints/StringConstraintsTest.php rename to tests/Unit/Parser/Constraints/StringConstraintsTest.php index 33a5199..8c51c9a 100644 --- a/tests/Unit/Constraints/StringConstraintsTest.php +++ b/tests/Unit/Parser/Constraints/StringConstraintsTest.php @@ -1,10 +1,8 @@ toBe(serialize($fromFileContext)) @@ -39,7 +39,7 @@ }); test("Extensive PHP Doc type declaration", function () { - $fromFileContext = ParsingContext::fromClassString(ComplexPhpDoc::class); + $fromFileContext = ParsingScope::fromClassString(ComplexPhpDoc::class); expect($fromFileContext->localTypes)->toBe([ 'ReadyToOrderInput' => 'array{ id: positive-int, status: OrderStatus::READY_TO_ORDER, fileId?: positive-int }', diff --git a/tests/Unit/Parser/MetadataEliminationTest.php b/tests/Unit/Parser/MetadataEliminationTest.php index a2dd20f..78924e4 100644 --- a/tests/Unit/Parser/MetadataEliminationTest.php +++ b/tests/Unit/Parser/MetadataEliminationTest.php @@ -137,7 +137,7 @@ function containsMetadataNode(NodeInterface $node): bool test('unwrapMetadata keeps constraints attached, unlike getDeclaringNode', function () { $constrained = new Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode( new StringNode(), - [new Le0daniel\PhpTsBindings\Constraints\NonEmptyString()], + [new Le0daniel\PhpTsBindings\Parser\Constraints\NonEmptyString()], ); $wrapped = new MetadataNode($constrained, null, 'tag'); diff --git a/tests/Unit/Parser/NodeDiagnosticStringTest.php b/tests/Unit/Parser/NodeDiagnosticStringTest.php index fe32023..6593484 100644 --- a/tests/Unit/Parser/NodeDiagnosticStringTest.php +++ b/tests/Unit/Parser/NodeDiagnosticStringTest.php @@ -7,7 +7,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Constraints\NonEmptyString; +use Le0daniel\PhpTsBindings\Parser\Constraints\NonEmptyString; /** * __toString() is the label a developer sees in error messages and debug output. It no longer diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 67e7400..960def4 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -2,14 +2,20 @@ namespace Tests\Unit\Parser; +use Le0daniel\PhpTsBindings\Parser\Constraints\IntRange; +use Le0daniel\PhpTsBindings\Parser\Constraints\ListLength; +use Le0daniel\PhpTsBindings\Parser\Constraints\LowercaseString; +use Le0daniel\PhpTsBindings\Parser\Constraints\NonEmptyString; +use Le0daniel\PhpTsBindings\Parser\Constraints\NumericString; +use Le0daniel\PhpTsBindings\Parser\Constraints\UppercaseString; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; -use Le0daniel\PhpTsBindings\Parser\Nodes\Data\PropertyType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; +use Le0daniel\PhpTsBindings\Parser\Nodes\Data\PropertyType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BoolNode; @@ -19,8 +25,8 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; @@ -29,7 +35,6 @@ use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; -use Le0daniel\PhpTsBindings\Constraints\Email; use Tests\Feature\Mocks\Paginated; use Tests\Mocks\ResultEnum; use Tests\Unit\Parser\Data\Stubs\Address; @@ -175,9 +180,9 @@ $node = $parser->parse("int<0, 100>"); expect($node)->toBeInstanceOf(ConstraintNode::class) + ->and($node->constraints[0])->toBeInstanceOf(IntRange::class) ->and($node->constraints[0]->min)->toBe(0) ->and($node->constraints[0]->max)->toBe(100) - ->and($node->constraints[0]->including)->toBe(true) ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); @@ -188,10 +193,10 @@ $node = $parser->parse("int"); + // `min` is an absent bound, not PHP_INT_MIN: the type says there is no lower limit. expect($node)->toBeInstanceOf(ConstraintNode::class) - ->and($node->constraints[0]->min)->toBe(PHP_INT_MIN) + ->and($node->constraints[0]->min)->toBeNull() ->and($node->constraints[0]->max)->toBe(100) - ->and($node->constraints[0]->including)->toBe(true) ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); @@ -204,8 +209,7 @@ expect($node)->toBeInstanceOf(ConstraintNode::class) ->and($node->constraints[0]->min)->toBe(-1) - ->and($node->constraints[0]->max)->toBe(PHP_INT_MAX) - ->and($node->constraints[0]->including)->toBe(true) + ->and($node->constraints[0]->max)->toBeNull() ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); @@ -219,7 +223,6 @@ expect($node)->toBeInstanceOf(ConstraintNode::class) ->and($node->constraints[0]->min)->toBe(-100) ->and($node->constraints[0]->max)->toBe(-3) - ->and($node->constraints[0]->including)->toBe(true) ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); @@ -243,17 +246,17 @@ test('Global aliases', function () { $parser = new TypeParser( TypeParser::defaultConsumers(new GlobalTypeAliases([ - 'Email' => fn() => new ConstraintNode( + 'Slug' => fn() => new ConstraintNode( new StringNode(), - [new Email()], + [new NonEmptyString()], ), ])) ); /** @var ConstraintNode $node */ - $node = $parser->parse("Email"); + $node = $parser->parse("Slug"); expect($node)->toBeInstanceOf(ConstraintNode::class) - ->and($node->constraints[0])->toBeInstanceOf(Email::class) + ->and($node->constraints[0])->toBeInstanceOf(NonEmptyString::class) ->and(count($node->constraints))->toBe(1); compareToOptimizedAst($node); @@ -273,7 +276,7 @@ test('Local type resolution', function () { $parser = new TypeParser(); /** @var ConstraintNode $node */ - $node = $parser->parse("AddressInput", ParsingContext::fromClassString(Address::class)); + $node = $parser->parse("AddressInput", ParsingScope::fromClassString(Address::class)); compareToOptimizedAst($node); expect($node)->toBeInstanceOf(StructNode::class); @@ -283,7 +286,7 @@ test('Local imported resolution', function () { $parser = new TypeParser(); /** @var ConstraintNode $node */ - $node = $parser->parse("AddressInputData", ParsingContext::fromClassString(MyUserClass::class)); + $node = $parser->parse("AddressInputData", ParsingScope::fromClassString(MyUserClass::class)); compareToOptimizedAst($node); expect($node)->toBeInstanceOf(StructNode::class); @@ -323,6 +326,68 @@ compareToOptimizedAst($node); }); +test('string refinements constrain a StringNode', function (string $type, array $expectedConstraints) { + $parser = new TypeParser(); + /** @var ConstraintNode $node */ + $node = $parser->parse($type); + + expect($node)->toBeInstanceOf(ConstraintNode::class) + ->and($node->node)->toBeInstanceOf(StringNode::class) + ->and(array_map(fn($constraint) => $constraint::class, $node->constraints)) + ->toBe($expectedConstraints); + + compareToOptimizedAst($node); +})->with([ + ['non-empty-string', [NonEmptyString::class]], + ['numeric-string', [NumericString::class]], + ['lowercase-string', [LowercaseString::class]], + ['uppercase-string', [UppercaseString::class]], + ['non-empty-lowercase-string', [NonEmptyString::class, LowercaseString::class]], + ['non-empty-uppercase-string', [NonEmptyString::class, UppercaseString::class]], +]); + +/** + * The `non-empty-` prefix used to be normalised away, so `non-empty-list` parsed to a bare + * ListNode and accepted the empty list it forbids. + */ +test('non-empty-list keeps its minimum', function () { + $parser = new TypeParser(); + /** @var ConstraintNode $node */ + $node = $parser->parse("non-empty-list"); + + expect($node)->toBeInstanceOf(ConstraintNode::class) + ->and($node->constraints[0])->toBeInstanceOf(ListLength::class) + ->and($node->constraints[0]->min)->toBe(1) + ->and($node->node)->toBeInstanceOf(ListNode::class); + + compareToOptimizedAst($node); +}); + +test('non-empty-array keeps its minimum over both key types', function (string $type, string $expectedNode) { + $parser = new TypeParser(); + /** @var ConstraintNode $node */ + $node = $parser->parse($type); + + expect($node)->toBeInstanceOf(ConstraintNode::class) + ->and($node->constraints[0])->toBeInstanceOf(ListLength::class) + ->and($node->constraints[0]->min)->toBe(1) + ->and($node->node)->toBeInstanceOf($expectedNode); + + compareToOptimizedAst($node); +})->with([ + ['non-empty-array', RecordNode::class], + ['non-empty-array', ListNode::class], + ['non-empty-array', ListNode::class], +]); + +test('the plain list and array types carry no constraint', function (string $type) { + $node = new TypeParser()->parse($type); + + expect($node)->not->toBeInstanceOf(ConstraintNode::class); + + compareToOptimizedAst($node); +})->with(['list', 'array', 'array', 'array']); + test('object struct', function () { $parser = new TypeParser(); /** @var StructNode $node */ @@ -470,7 +535,7 @@ test('Test date time with a namespace', function () { $parser = new TypeParser(); /** @var UnionNode $node */ - $node = $parser->parse(\DateTime::class, new ParsingContext('SomeName\\Space')); + $node = $parser->parse(\DateTime::class, new ParsingScope('SomeName\\Space')); expect($node)->toBeInstanceOf(DateTimeNode::class); compareToOptimizedAst($node); }); @@ -480,7 +545,7 @@ /** @var UnionNode $node */ $node = $parser->parse( "ResultEnumBase::SUCCESS|ResultEnumBase::FAILURE|ResultEnum::OTHER", - new ParsingContext('SomeName\\Space', [ + new ParsingScope('SomeName\\Space', [ 'ResultEnumBase' => ResultEnum::class, 'ResultEnum' => ResultEnum::class, ]), diff --git a/tests/Unit/Parser/ValueObjectConsumerTest.php b/tests/Unit/Parser/ValueObjectConsumerTest.php index 358a975..ccaf84e 100644 --- a/tests/Unit/Parser/ValueObjectConsumerTest.php +++ b/tests/Unit/Parser/ValueObjectConsumerTest.php @@ -1,6 +1,6 @@ parse('Email', new ParsingContext('Tests\\Mocks\\ValueObjects')); + $node = new TypeParser()->parse('Email', new ParsingScope('Tests\\Mocks\\ValueObjects')); expect($node)->toBeInstanceOf(MetadataNode::class) ->and($node->node->className)->toBe(Email::class); }); test('resolves value objects through a use-statement alias', function () { - $node = new TypeParser()->parse('Mail', new ParsingContext('Some\\Space', ['Mail' => Email::class])); + $node = new TypeParser()->parse('Mail', new ParsingScope('Some\\Space', ['Mail' => Email::class])); expect($node)->toBeInstanceOf(MetadataNode::class) ->and($node->node->className)->toBe(Email::class); From 8a0505736c2c363b120fb24771c4d8c81e953384 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 31 Jul 2026 13:57:46 +0200 Subject: [PATCH 029/101] Fix typo in `ValueObjectConsumer` class docblock. --- src/Parser/Consumers/ValueObjectConsumer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Parser/Consumers/ValueObjectConsumer.php b/src/Parser/Consumers/ValueObjectConsumer.php index 942d905..7e4877e 100644 --- a/src/Parser/Consumers/ValueObjectConsumer.php +++ b/src/Parser/Consumers/ValueObjectConsumer.php @@ -18,7 +18,7 @@ use ReflectionException; /** - * Consumes user defined value objects: classes implementing StringValueObject or IntValueObject. + * Consumes user-defined value objects: classes implementing StringValueObject or IntValueObject. * * Registered ahead of EnumConsumer, DateTimeConsumer and UserDefinedObjectConsumer, all of which * would otherwise claim the class first. From ed5d5e60ce8b78d73980101b26e879aaa4bdb752 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 31 Jul 2026 14:05:15 +0200 Subject: [PATCH 030/101] Remove `Result` interface and its implementations; refactor usages to simplify success and failure handling. --- src/Contracts/ExportableToPhpCode.php | 3 +++ src/Executor/Contracts/ExecutionContext.php | 3 +++ src/Executor/Contracts/Result.php | 26 ------------------- src/Executor/Data/Failure.php | 22 +++++----------- src/Executor/Data/Success.php | 18 ++++--------- tests/Unit/Executor/ResultTest.php | 15 +---------- .../Constraints/StringConstraintsTest.php | 2 +- 7 files changed, 19 insertions(+), 70 deletions(-) delete mode 100644 src/Executor/Contracts/Result.php diff --git a/src/Contracts/ExportableToPhpCode.php b/src/Contracts/ExportableToPhpCode.php index 2945474..c07f599 100644 --- a/src/Contracts/ExportableToPhpCode.php +++ b/src/Contracts/ExportableToPhpCode.php @@ -2,6 +2,9 @@ namespace Le0daniel\PhpTsBindings\Contracts; +/** + * @internal + */ interface ExportableToPhpCode { public function exportPhpCode(): string; diff --git a/src/Executor/Contracts/ExecutionContext.php b/src/Executor/Contracts/ExecutionContext.php index ee47f54..6f3de2c 100644 --- a/src/Executor/Contracts/ExecutionContext.php +++ b/src/Executor/Contracts/ExecutionContext.php @@ -4,6 +4,9 @@ use Le0daniel\PhpTsBindings\Executor\Data\Issue; +/** + * @internal + */ interface ExecutionContext { public function addIssue(Issue $issue): void; diff --git a/src/Executor/Contracts/Result.php b/src/Executor/Contracts/Result.php deleted file mode 100644 index 0b8e5d2..0000000 --- a/src/Executor/Contracts/Result.php +++ /dev/null @@ -1,26 +0,0 @@ - = Success | Failure` that - * EmitTypes generates, so both sides of the binding describe the outcome the same way. - */ -interface Result -{ - public function isSuccess(): bool; - - /** - * Present on both arms: a Success carries issues when it was parsed with partialFailures - * enabled, so an empty Issues is not the same as success. - */ - public function issues(): Issues; -} diff --git a/src/Executor/Data/Failure.php b/src/Executor/Data/Failure.php index 5a20aa0..5f8abb7 100644 --- a/src/Executor/Data/Failure.php +++ b/src/Executor/Data/Failure.php @@ -2,9 +2,6 @@ namespace Le0daniel\PhpTsBindings\Executor\Data; -use Le0daniel\PhpTsBindings\Executor\Contracts\Result; -use Override; - /** * A value did not validate. Returned from the executor, never thrown - a value the caller supplied * being wrong is an outcome, not an exceptional condition. @@ -14,7 +11,7 @@ * to travel as an exception - across the RPC boundary - InvalidInputException and * InvalidOutputException wrap it. */ -final readonly class Failure implements Result +final readonly class Failure { public function __construct( public Issues $issues, @@ -22,18 +19,6 @@ public function __construct( { } - #[Override] - public function isSuccess(): false - { - return false; - } - - #[Override] - public function issues(): Issues - { - return $this->issues; - } - /** * The message the wrapping exceptions report, kept here so both of them describe a failure the * same way. @@ -42,4 +27,9 @@ public function describe(): string { return "Validation failed: {$this->issues->serializeToCompleteString()}."; } + + public function isSuccess(): false + { + return false; + } } diff --git a/src/Executor/Data/Success.php b/src/Executor/Data/Success.php index 1b67495..211645e 100644 --- a/src/Executor/Data/Success.php +++ b/src/Executor/Data/Success.php @@ -2,26 +2,18 @@ namespace Le0daniel\PhpTsBindings\Executor\Data; -use Le0daniel\PhpTsBindings\Executor\Contracts\Result; -use Override; - -final readonly class Success implements Result +final readonly class Success { public function __construct( - public mixed $value, + public mixed $value, public Issues $issues = new Issues(), - ) {} - - #[Override] - public function isSuccess(): true + ) { - return true; } - #[Override] - public function issues(): Issues + public function isSuccess(): true { - return $this->issues; + return true; } /** diff --git a/tests/Unit/Executor/ResultTest.php b/tests/Unit/Executor/ResultTest.php index 33c6ce7..0bf6cbd 100644 --- a/tests/Unit/Executor/ResultTest.php +++ b/tests/Unit/Executor/ResultTest.php @@ -18,23 +18,11 @@ expect(new Failure(new Issues()))->not->toBeInstanceOf(\Throwable::class); }); -test('both arms of a result implement Result', function () { - expect(new Success('value'))->toBeInstanceOf(Result::class) - ->and(new Failure(new Issues()))->toBeInstanceOf(Result::class); -}); - test('isSuccess distinguishes the two arms without instanceof', function () { expect(new Success('value')->isSuccess())->toBeTrue() ->and(new Failure(new Issues())->isSuccess())->toBeFalse(); }); -test('issues are reachable through the Result contract on both arms', function (Result $result) { - expect($result->issues())->toBeInstanceOf(Issues::class); -})->with([ - 'success' => [fn() => new Success('value')], - 'failure' => [fn() => new Failure(new Issues())], -]); - test('a returned failure cannot be caught as an exception', function () { $executor = new SchemaExecutor(); @@ -44,8 +32,7 @@ $this->fail('A failed parse must be returned, not thrown: ' . $e::class); } - expect($result)->toBeInstanceOf(Failure::class) - ->and($result->isSuccess())->toBeFalse(); + expect($result)->toBeInstanceOf(Failure::class); }); test('the executor still narrows to the concrete arms', function () { diff --git a/tests/Unit/Parser/Constraints/StringConstraintsTest.php b/tests/Unit/Parser/Constraints/StringConstraintsTest.php index 8c51c9a..32d39e7 100644 --- a/tests/Unit/Parser/Constraints/StringConstraintsTest.php +++ b/tests/Unit/Parser/Constraints/StringConstraintsTest.php @@ -38,7 +38,7 @@ $result = executeParse('non-empty-string', ''); $keys = array_map( fn($issue) => $issue->messageOrLocalizationKey, - $result->issues()->allFlat(), + $result->issues->allFlat(), ); expect($keys)->toContain(IssueMessage::NOT_EMPTY_STRING->value); From 42f8f95b15dbffcc3afa0ff89197586aa72e1105 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 31 Jul 2026 14:50:30 +0200 Subject: [PATCH 031/101] Add mocks for value objects with inherited metadata to support comprehensive unit tests; update parser logic to resolve attributes across class hierarchies and interfaces. --- README.md | 73 ++++++++ src/Contracts/Attributes/Brand.php | 30 ++- src/Contracts/Attributes/Named.php | 37 +++- src/Parser/Consumers/ValueObjectConsumer.php | 5 + src/Reflection/AttributesReflector.php | 22 ++- src/Reflection/MetadataAttributes.php | 147 ++++++++++++++- tests/Mocks/Named/ArticleResource.php | 17 ++ .../Inherited/AbstractBrandedId.php | 30 +++ .../ValueObjects/Inherited/AccountId.php | 27 +++ .../ValueObjects/Inherited/AlsoBranded.php | 14 ++ .../ValueObjects/Inherited/AmbiguousId.php | 24 +++ .../ValueObjects/Inherited/BadClosureId.php | 27 +++ tests/Mocks/ValueObjects/Inherited/BaseId.php | 30 +++ .../Mocks/ValueObjects/Inherited/BrandId.php | 20 ++ .../Mocks/ValueObjects/Inherited/ChildId.php | 7 + .../ValueObjects/Inherited/ComputedId.php | 17 ++ .../Inherited/ComputedLocally.php | 29 +++ tests/Mocks/ValueObjects/Inherited/DeepId.php | 20 ++ .../ValueObjects/Inherited/DeepIntId.php | 11 ++ .../Inherited/DisambiguatedId.php | 26 +++ .../ValueObjects/Inherited/GrandChildId.php | 10 + tests/Mocks/ValueObjects/Inherited/IntId.php | 17 ++ .../ValueObjects/Inherited/InvoiceId.php | 20 ++ .../Mocks/ValueObjects/Inherited/LegacyId.php | 7 + .../Inherited/LocallyOverriddenId.php | 28 +++ tests/Mocks/ValueObjects/Inherited/Naming.php | 41 +++++ .../ValueObjects/Inherited/ParentWinsBase.php | 29 +++ .../Inherited/ParentWinsContract.php | 12 ++ .../ValueObjects/Inherited/ParentWinsId.php | 10 + .../Inherited/PartiallyOverriddenId.php | 26 +++ .../ValueObjects/Inherited/PlainContract.php | 12 ++ .../Mocks/ValueObjects/Inherited/PlainId.php | 20 ++ .../ValueObjects/Inherited/ReceiptId.php | 20 ++ .../Inherited/SharedExplicitBrand.php | 15 ++ .../Inherited/SharedExplicitBrandId.php | 20 ++ tests/Unit/Parser/MetadataEliminationTest.php | 1 + tests/Unit/Parser/NamedTypeTest.php | 171 ++++++++++++++++++ tests/Unit/Typescript/NamedTypesTest.php | 18 ++ 38 files changed, 1073 insertions(+), 17 deletions(-) create mode 100644 tests/Mocks/Named/ArticleResource.php create mode 100644 tests/Mocks/ValueObjects/Inherited/AbstractBrandedId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/AccountId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/AlsoBranded.php create mode 100644 tests/Mocks/ValueObjects/Inherited/AmbiguousId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/BadClosureId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/BaseId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/BrandId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/ChildId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/ComputedId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/ComputedLocally.php create mode 100644 tests/Mocks/ValueObjects/Inherited/DeepId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/DeepIntId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/DisambiguatedId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/GrandChildId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/IntId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/InvoiceId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/LegacyId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/LocallyOverriddenId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/Naming.php create mode 100644 tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php create mode 100644 tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php create mode 100644 tests/Mocks/ValueObjects/Inherited/ParentWinsId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/PartiallyOverriddenId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/PlainContract.php create mode 100644 tests/Mocks/ValueObjects/Inherited/PlainId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/ReceiptId.php create mode 100644 tests/Mocks/ValueObjects/Inherited/SharedExplicitBrand.php create mode 100644 tests/Mocks/ValueObjects/Inherited/SharedExplicitBrandId.php diff --git a/README.md b/README.md index 7d5507b..4f4c70c 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,79 @@ docblock utilities are the shorthand for brand + name in one, since docblocks ca attributes: `BrandedString<'token'>` is referenced as `Token` and declared as `export type Token = (string & Brand<"token">)`. +### Sharing one declaration across value objects + +A family of ids usually shares an interface or a base class. Declare the attributes once there and +every value object in the family picks them up: + +```php +#[Brand] +#[Named] +interface IntId extends IntValueObject {} + +final readonly class AccountId implements IntId { /* ... */ } +final readonly class BrandId implements IntId { /* ... */ } +``` + +```typescript +export type AccountId = (number & Brand<"accountId">); +export type BrandId = (number & Brand<"brandId">); +``` + +The brand and the alias are derived from the **concrete** class, not from the one carrying the +attribute — which is the whole point: `AccountId` and `BrandId` share a declaration but stay +mutually unassignable in TypeScript. + +Each attribute is resolved on its own, in this order: + +1. **The class itself.** A local declaration always wins, and declaring both attributes locally + means nothing else is inspected. A local `#[Brand]` combines fine with an inherited `#[Named]`. +2. **The direct parent class,** abstract or concrete. +3. **The directly declared interfaces.** Two of them declaring the same attribute is an ambiguity + the library refuses to resolve — it fails instead of picking one. Declare the attribute on the + class itself to say which applies. + +The remaining caveats are worth reading, because each is silent otherwise: + +- **Value objects only.** Enums and plain classes read the attributes from the class itself and + nothing else, so implementing a `#[Named]` interface names nothing. +- **One level up, and no further.** `interface DeepId extends IntId` does not pass `IntId`'s + attributes on to *its* implementors, and neither does `class GrandChild extends Child extends + Base`. Redeclare them on the intermediate type when you want them to keep travelling. +- **An inherited declaration cannot carry a fixed name.** `#[Brand('id')]` on `IntId` would give + every implementor the brand `"id"` and collapse them into one type, so it is rejected at parse + time. Drop the name to derive it per class, or compute one with a closure — see below. +- **A concrete parent keeps a brand of its own.** `#[Brand]` on a non-abstract `BaseId` brands + `BaseId` as `baseId` *and* its children after their own names — one declaration, distinct types. + +### Computing the name yourself + +Both attributes accept a closure instead of a string, called with the class being emitted. It works +anywhere, but it is what makes a *shared* declaration flexible: an inherited attribute cannot carry +a fixed name, yet it can carry a rule each implementor runs against its own class name. + +```php +final class Naming +{ + public static function alias(string $className): string + { + return explode('\\', $className) |> array_last(...) |> ucfirst(...); + } +} + +#[Brand] +#[Named(name: Naming::alias(...))] +interface IntId extends IntValueObject {} +``` + +> **PHP only accepts first-class callable syntax here.** A closure literal in an attribute argument +> — `#[Named(name: static fn(string $c) => ucfirst($c))]` — does not compile: PHP reports +> *"Constant expression contains invalid operations"*. Point the closure at a named function or +> static method instead, as above. + +The closure runs at parse time, never at runtime, and its result still has to be a valid TypeScript +identifier — an invalid one fails generation the same way a bad string literal does. + ## Validating AST By default, the parsed AST is not validated. This means, the AST itself can be invalid. For example Intersection types diff --git a/src/Contracts/Attributes/Brand.php b/src/Contracts/Attributes/Brand.php index 24bafdc..0f199ce 100644 --- a/src/Contracts/Attributes/Brand.php +++ b/src/Contracts/Attributes/Brand.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\Contracts\Attributes; use Attribute; +use Closure; use Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; @@ -12,7 +13,21 @@ * type becomes `(... & Brand<"name">)`, INLINE at every use site — a brand alone declares no alias. * Combine with #[Named] to export it once by name: `export type UserId = (number & Brand<"userId">)`. * - * Without a name, the brand is lcfirst() of the base class name: UserId becomes "userId". + * The tag comes from one of three sources: + * - no name: lcfirst() of the base class name, so UserId becomes "userId"; + * - a string: used verbatim; + * - a Closure(string $className): string, called with the class being emitted. PHP only accepts + * first-class callable syntax here, never a closure literal: + * #[Brand(name: BrandNaming::prefixed(...))] + * + * On a VALUE OBJECT the attribute may also be declared one level up, on the interface or parent + * class a family of ids shares, and every child picks it up — deriving its own tag from its own + * name, so siblings stay distinct types. Resolution order is: the class itself, then its direct + * parent class, then its directly declared interfaces; two interfaces declaring the same attribute + * are ambiguous and rejected. An inherited declaration may not carry a plain string name (every + * child would share the one tag) — pass a Closure to compute one per class instead. One level + * only: a grandparent, or an interface reached through another interface, is not consulted. Plain + * classes and enums read the attribute from the class itself only. * * Brands are code generation metadata only: they have zero runtime impact, values travel the wire * in their plain shape, and the metadata never enters a cached AST. @@ -20,15 +35,24 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class Brand { + /** + * @param string|Closure(string): string|null $name + */ public function __construct( - public ?string $name = null, + public string|Closure|null $name = null, ) { } public function brandName(string $classString): string { - $name = $this->name ?? lcfirst(explode('\\', $classString) |> array_last(...)); + $name = match (true) { + $this->name === null => explode('\\', $classString) + |> array_last(...) + |> lcfirst(...), + $this->name instanceof Closure => ($this->name)($classString), + default => $this->name, + }; if (!Syntax::isValidIdentifier($name)) { throw InvalidStringLiteralException::notAValidTypescriptIdentifier($name, "#[Brand] on {$classString}"); diff --git a/src/Contracts/Attributes/Named.php b/src/Contracts/Attributes/Named.php index 42e2ba6..9e68a99 100644 --- a/src/Contracts/Attributes/Named.php +++ b/src/Contracts/Attributes/Named.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\Contracts\Attributes; use Attribute; +use Closure; use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; @@ -13,8 +14,14 @@ * once and references it by name — recursively, so a named type may contain other named or * branded types. Combine with #[Brand] for an aliased branded type. * - * Without a name, the class base name is used verbatim: App\Data\Order becomes `Order`. Two - * classes resolving to the same name with different shapes fail generation with a conflicting + * The alias comes from one of three sources: + * - no name: the class base name, used verbatim, so App\Data\Order becomes `Order`; + * - a string: used verbatim; + * - a Closure(string $className): string, called with the class being emitted. PHP only accepts + * first-class callable syntax here, never a closure literal: + * #[Named(name: AliasNaming::suffixed(...))] + * + * Two classes resolving to the same name with different shapes fail generation with a conflicting * alias error, as does a name colliding with a declaration the generated types file always * contains (Brand, Result, ...). * @@ -25,6 +32,16 @@ * generation fails hard instead of emitting a lying type. On value objects and enums the default * is IO::BOTH instead: their input and output shapes are always identical. * + * On a VALUE OBJECT the attribute may also be declared one level up, on the interface or parent + * class a family of ids shares, and every child picks it up — deriving its own alias from its own + * name, so siblings stay distinct types. Resolution order is: the class itself, then its direct + * parent class, then its directly declared interfaces; two interfaces declaring the same attribute + * are ambiguous and rejected. An inherited declaration may not carry a plain string name (every + * child would claim the one alias) — pass a Closure to compute one per class instead. One level + * only: a grandparent, or an interface reached through another interface, is not consulted. Plain + * classes and enums read the attribute from the class itself only, so implementing a #[Named] + * interface names nothing. + * * Names are code generation metadata only: they have zero runtime impact and never enter a * cached AST. Generic collection classes cannot be named — their alias would collide across * element types. @@ -32,16 +49,26 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class Named { + /** + * @param string|Closure(string): string|null $name + */ public function __construct( - public ?string $name = null, - public ?IO $io = null, + public string|Closure|null $name = null, + public ?IO $io = null, ) { } + /** + * @internal + */ public function typeName(string $classString): string { - $name = $this->name ?? (explode('\\', $classString) |> array_last(...)); + $name = match (true) { + $this->name === null => explode('\\', $classString) |> array_last(...), + $this->name instanceof Closure => ($this->name)($classString), + default => $this->name, + }; if (!Syntax::isValidIdentifier($name)) { throw InvalidStringLiteralException::notAValidTypescriptIdentifier($name, "#[Named] on {$classString}"); diff --git a/src/Parser/Consumers/ValueObjectConsumer.php b/src/Parser/Consumers/ValueObjectConsumer.php index 7e4877e..16dc19d 100644 --- a/src/Parser/Consumers/ValueObjectConsumer.php +++ b/src/Parser/Consumers/ValueObjectConsumer.php @@ -77,6 +77,11 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface ), $reflectionClass, defaultIo: IO::BOTH, + // A family of ids usually shares one interface or base class; let it carry the + // declaration for all of them. Only value objects opt in: an interface or abstract + // parent is never parseable on its own here, so the attributes cannot mean anything + // other than "apply to my children". + inheritFromParents: true, ); } } diff --git a/src/Reflection/AttributesReflector.php b/src/Reflection/AttributesReflector.php index ae68e8d..a863c61 100644 --- a/src/Reflection/AttributesReflector.php +++ b/src/Reflection/AttributesReflector.php @@ -29,14 +29,24 @@ public function has(string $attributeClass): bool * @return T */ public function getSingleInstance(string $attributeClass): object + { + return $this->firstInstanceOrNull($attributeClass) + ?? throw new ParserException("Attribute {$attributeClass} not found"); + } + + /** + * A single scan for callers that treat an absent attribute as a valid outcome, instead of + * has() followed by getSingleInstance() walking the list twice. + * + * @template T of object + * @param class-string $attributeClass + * @return T|null + */ + public function firstInstanceOrNull(string $attributeClass): ?object { $reflection = array_find($this->attributes, fn(ReflectionAttribute $attribute) => $attribute->name === $attributeClass); - if (!$reflection) { - throw new ParserException("Attribute {$attributeClass} not found"); - } - /** @var T */ - return $reflection->newInstance(); + /** @var T|null */ + return $reflection?->newInstance(); } - } \ No newline at end of file diff --git a/src/Reflection/MetadataAttributes.php b/src/Reflection/MetadataAttributes.php index df15071..4b542e1 100644 --- a/src/Reflection/MetadataAttributes.php +++ b/src/Reflection/MetadataAttributes.php @@ -5,6 +5,7 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Brand; use Le0daniel\PhpTsBindings\Contracts\Attributes\Named; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\NamedType; use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; use Le0daniel\PhpTsBindings\Typescript\Data\IO; @@ -21,13 +22,28 @@ * @param ReflectionClass $reflectionClass * @param IO $defaultIo The direction a #[Named] without an explicit io applies to. Value * objects and enums pass IO::BOTH — their input and output shapes are always identical. + * @param bool $inheritFromParents Also accept an attribute declared one level up, on the direct + * parent class or a directly declared interface. Value objects opt in so a family of ids + * can share one declaration; see wrap()'s callers. */ - public static function wrap(NodeInterface $node, ReflectionClass $reflectionClass, IO $defaultIo = IO::OUTPUT): NodeInterface + public static function wrap( + NodeInterface $node, + ReflectionClass $reflectionClass, + IO $defaultIo = IO::OUTPUT, + bool $inheritFromParents = false, + ): NodeInterface { $attributes = new AttributesReflector($reflectionClass->getAttributes()); - $named = $attributes->has(Named::class) ? $attributes->getSingleInstance(Named::class) : null; - $brand = $attributes->has(Brand::class) ? $attributes->getSingleInstance(Brand::class) : null; + // 1. The class itself. A local declaration always wins, and declaring both skips the + // lookup entirely. + $named = $attributes->firstInstanceOrNull(Named::class); + $brand = $attributes->firstInstanceOrNull(Brand::class); + + if ($inheritFromParents && ($named === null || $brand === null)) { + // 2. The direct parent class, then 3. the directly declared interfaces. + [$named, $brand] = self::inheritMissing($reflectionClass, $named, $brand); + } if ($named === null && $brand === null) { return $node; @@ -41,4 +57,129 @@ public static function wrap(NodeInterface $node, ReflectionClass $reflectionClas $brand?->brandName($className), ); } + + /** + * Fills in whichever of the two the class did not declare itself, one level up. The parent + * class is a single unambiguous candidate and is consulted first; the interfaces are a set, so + * two of them declaring the same attribute is an ambiguity we refuse to resolve silently. + * + * @param ReflectionClass $reflectionClass + * @return array{Named|null, Brand|null} + */ + private static function inheritMissing(ReflectionClass $reflectionClass, ?Named $named, ?Brand $brand): array + { + $target = $reflectionClass->getName(); + + // Only what the class left undeclared is looked up, so a candidate carrying an attribute + // that is already resolved is never read and never validated. + $parent = $reflectionClass->getParentClass(); + if ($parent !== false) { + $parentAttributes = new AttributesReflector($parent->getAttributes()); + + if ($named === null) { + $named = $parentAttributes->firstInstanceOrNull(Named::class); + self::assertInheritable($named, $parent->getName(), $target); + } + + if ($brand === null) { + $brand = $parentAttributes->firstInstanceOrNull(Brand::class); + self::assertInheritable($brand, $parent->getName(), $target); + } + } + + if ($named !== null && $brand !== null) { + return [$named, $brand]; + } + + $named ??= self::fromInterfaces($reflectionClass, Named::class, $target); + $brand ??= self::fromInterfaces($reflectionClass, Brand::class, $target); + + return [$named, $brand]; + } + + /** + * The interfaces are unordered as far as intent goes, so the first match is not a decision the + * library gets to make on the author's behalf. Two of them carrying the attribute is rejected. + * + * @template T of Named|Brand + * @param ReflectionClass $reflectionClass + * @param class-string $attributeClass + * @return T|null + */ + private static function fromInterfaces(ReflectionClass $reflectionClass, string $attributeClass, string $target): Named|Brand|null + { + $found = null; + $declaredOn = null; + + foreach (self::directlyDeclaredInterfaces($reflectionClass) as $interface) { + $instance = new AttributesReflector(new ReflectionClass($interface)->getAttributes()) + ->firstInstanceOrNull($attributeClass); + + if ($instance === null) { + continue; + } + + if ($found !== null) { + $attributeName = $attributeClass === Named::class ? 'Named' : 'Brand'; + throw new ParserException( + "{$target} inherits #[{$attributeName}] from more than one interface ({$declaredOn} and {$interface}). " + . "Declare it on {$target} itself to say which one applies." + ); + } + + $found = $instance; + $declaredOn = $interface; + } + + self::assertInheritable($found, (string)$declaredOn, $target); + + return $found; + } + + /** + * An inherited declaration has to produce a name per concrete class. A plain string would hand + * every child the identical tag and collapse siblings into a single TypeScript type — silently + * for #[Brand], and as a conflicting alias error far from the cause for #[Named]. A naming + * Closure is exactly how you opt out of the default derivation while keeping them distinct. + */ + private static function assertInheritable(Named|Brand|null $attribute, string $declaredOn, string $target): void + { + if ($attribute === null || !is_string($attribute->name)) { + return; + } + + $attributeName = $attribute instanceof Named ? 'Named' : 'Brand'; + throw new ParserException( + "#[{$attributeName}] on {$declaredOn} is inherited by {$target} and cannot carry a fixed name: " + . "every child would share \"{$attribute->name}\". Drop the name to derive it per class, " + . "or pass a closure: #[{$attributeName}(name: Naming::method(...))]." + ); + } + + /** + * One level up and no further: a grandparent, or an interface reached through another + * interface, is never consulted. + * + * ReflectionClass::getInterfaceNames() is transitive — it also reports interfaces reached + * through another interface or through the parent class, which sit two or more levels up. + * Those are subtracted here, so only what the class itself declares remains. + * + * @param ReflectionClass $reflectionClass + * @return list + */ + private static function directlyDeclaredInterfaces(ReflectionClass $reflectionClass): array + { + $all = $reflectionClass->getInterfaceNames(); + if ($all === []) { + return []; + } + + $parent = $reflectionClass->getParentClass(); + $indirect = $parent === false ? [] : $parent->getInterfaceNames(); + foreach ($all as $interface) { + array_push($indirect, ...new ReflectionClass($interface)->getInterfaceNames()); + } + + return array_values(array_diff($all, $indirect)); + } } diff --git a/tests/Mocks/Named/ArticleResource.php b/tests/Mocks/Named/ArticleResource.php new file mode 100644 index 0000000..77ddfd9 --- /dev/null +++ b/tests/Mocks/Named/ArticleResource.php @@ -0,0 +1,17 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/AccountId.php b/tests/Mocks/ValueObjects/Inherited/AccountId.php new file mode 100644 index 0000000..7e5d6dc --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/AccountId.php @@ -0,0 +1,27 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/AlsoBranded.php b/tests/Mocks/ValueObjects/Inherited/AlsoBranded.php new file mode 100644 index 0000000..4fb82cd --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/AlsoBranded.php @@ -0,0 +1,14 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/BadClosureId.php b/tests/Mocks/ValueObjects/Inherited/BadClosureId.php new file mode 100644 index 0000000..92af12f --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/BadClosureId.php @@ -0,0 +1,27 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/BaseId.php b/tests/Mocks/ValueObjects/Inherited/BaseId.php new file mode 100644 index 0000000..2c13189 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/BaseId.php @@ -0,0 +1,30 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/BrandId.php b/tests/Mocks/ValueObjects/Inherited/BrandId.php new file mode 100644 index 0000000..d713085 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/BrandId.php @@ -0,0 +1,20 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/ChildId.php b/tests/Mocks/ValueObjects/Inherited/ChildId.php new file mode 100644 index 0000000..8a8e8da --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/ChildId.php @@ -0,0 +1,7 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/DeepId.php b/tests/Mocks/ValueObjects/Inherited/DeepId.php new file mode 100644 index 0000000..34bae87 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/DeepId.php @@ -0,0 +1,20 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/DeepIntId.php b/tests/Mocks/ValueObjects/Inherited/DeepIntId.php new file mode 100644 index 0000000..8ca881b --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/DeepIntId.php @@ -0,0 +1,11 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/GrandChildId.php b/tests/Mocks/ValueObjects/Inherited/GrandChildId.php new file mode 100644 index 0000000..e8db4cf --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/GrandChildId.php @@ -0,0 +1,10 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/LegacyId.php b/tests/Mocks/ValueObjects/Inherited/LegacyId.php new file mode 100644 index 0000000..f03583c --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/LegacyId.php @@ -0,0 +1,7 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/Naming.php b/tests/Mocks/ValueObjects/Inherited/Naming.php new file mode 100644 index 0000000..45f30e2 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/Naming.php @@ -0,0 +1,41 @@ + array_last(...); + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php b/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php new file mode 100644 index 0000000..9f8aea9 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php @@ -0,0 +1,29 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php b/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php new file mode 100644 index 0000000..3fb5f3b --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php @@ -0,0 +1,12 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/PlainContract.php b/tests/Mocks/ValueObjects/Inherited/PlainContract.php new file mode 100644 index 0000000..e555f8c --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/PlainContract.php @@ -0,0 +1,12 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/ReceiptId.php b/tests/Mocks/ValueObjects/Inherited/ReceiptId.php new file mode 100644 index 0000000..2c3a7f1 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/ReceiptId.php @@ -0,0 +1,20 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/SharedExplicitBrand.php b/tests/Mocks/ValueObjects/Inherited/SharedExplicitBrand.php new file mode 100644 index 0000000..11983ce --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/SharedExplicitBrand.php @@ -0,0 +1,15 @@ +value; + } +} diff --git a/tests/Unit/Parser/MetadataEliminationTest.php b/tests/Unit/Parser/MetadataEliminationTest.php index 78924e4..62b79cb 100644 --- a/tests/Unit/Parser/MetadataEliminationTest.php +++ b/tests/Unit/Parser/MetadataEliminationTest.php @@ -72,6 +72,7 @@ function containsMetadataNode(NodeInterface $node): bool 'deeply nested' => "array{a: array{b: list>}}", 'named class' => Customer::class, 'branded value object' => Email::class, + 'value object with inherited metadata' => \Tests\Mocks\ValueObjects\Inherited\AccountId::class, 'value object in struct' => 'array{e: ' . Email::class . '}', 'named class in list' => 'list<' . Customer::class . '>', 'named class in union' => Customer::class . '|null', diff --git a/tests/Unit/Parser/NamedTypeTest.php b/tests/Unit/Parser/NamedTypeTest.php index 81c9370..02ca18b 100644 --- a/tests/Unit/Parser/NamedTypeTest.php +++ b/tests/Unit/Parser/NamedTypeTest.php @@ -1,6 +1,7 @@ parse(Customer::class); @@ -88,3 +108,154 @@ expect(fn() => new TypeParser()->parse(InvalidlyBranded::class)) ->toThrow(InvalidStringLiteralException::class, 'not a valid TypeScript identifier'); }); + +/** + * Inherited metadata: a value object may declare #[Brand] / #[Named] once on the interface or + * parent it shares with its siblings. The lookup reaches exactly one level up and derives the + * brand and alias from the concrete class, so siblings stay distinct types. + */ +test('a value object inherits both attributes from its interface and derives them per class', function ( + string $type, + string $expectedBrand, + string $expectedName, +) { + $node = new TypeParser()->parse($type); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->brand)->toBe($expectedBrand) + ->and($node->name?->name)->toBe($expectedName) + ->and($node->name?->io)->toBe(IO::BOTH) + ->and($node->node)->toBeInstanceOf(ValueObjectNode::class) + ->and($node->node->className)->toBe($type); + + compareToOptimizedAst($node); + validateAst($node); +})->with([ + 'from an interface' => [AccountId::class, 'accountId', 'AccountId'], + 'sibling of the same interface' => [BrandId::class, 'brandId', 'BrandId'], + 'from an abstract parent class' => [LegacyId::class, 'legacyId', 'LegacyId'], + 'from a concrete parent class' => [ChildId::class, 'childId', 'ChildId'], +]); + +test('a concrete parent keeps a brand of its own, distinct from its children', function () { + $parent = new TypeParser()->parse(BaseId::class); + $child = new TypeParser()->parse(ChildId::class); + + expect($parent)->toBeInstanceOf(MetadataNode::class) + ->and($parent->brand)->toBe('baseId') + ->and($parent->name?->name)->toBe('BaseId') + ->and($child->brand)->toBe('childId') + ->and($child->name?->name)->toBe('ChildId'); + + compareToOptimizedAst($parent); + validateAst($parent); +}); + +test('a locally declared attribute wins over the inherited one', function () { + $node = new TypeParser()->parse(LocallyOverriddenId::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->brand)->toBe('explicitBrand') + ->and($node->name?->name)->toBe('ExplicitName'); + + compareToOptimizedAst($node); + validateAst($node); +}); + +test('a local #[Brand] combines with an inherited #[Named]', function () { + $node = new TypeParser()->parse(PartiallyOverriddenId::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->brand)->toBe('partialBrand') + ->and($node->name?->name)->toBe('PartiallyOverriddenId'); + + compareToOptimizedAst($node); + validateAst($node); +}); + +test('the parent class is consulted before the interfaces', function () { + // Both carry #[Named]; only the io tells them apart. + $node = new TypeParser()->parse(ParentWinsId::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->name?->io)->toBe(IO::OUTPUT); +}); + +test('the lookup stops after one level', function (string $type) { + $node = new TypeParser()->parse($type); + + expect($node)->toBeInstanceOf(ValueObjectNode::class); + + compareToOptimizedAst($node); +})->with([ + // DeepIntId extends IntId, so IntId's attributes are two levels from the implementor. + 'two levels through interfaces' => DeepId::class, + // GrandChildId extends ChildId extends BaseId. + 'two levels through classes' => GrandChildId::class, +]); + +test('a value object implementing an attribute free interface stays a bare node', function () { + $node = new TypeParser()->parse(PlainId::class); + + expect($node)->toBeInstanceOf(ValueObjectNode::class); + + compareToOptimizedAst($node); +}); + +test('rejects a fixed name on an inherited declaration', function () { + // Every implementor would share the brand "sharedId" and collapse into one TypeScript type. + expect(fn() => new TypeParser()->parse(SharedExplicitBrandId::class)) + ->toThrow(ParserException::class, 'cannot carry a fixed name'); +}); + +test('inheritance is scoped to value objects: a plain class implementing a #[Named] interface is not named', function () { + $node = new TypeParser()->parse(ArticleResource::class); + + expect($node)->not->toBeInstanceOf(MetadataNode::class); +}); + +test('two interfaces declaring the same attribute are ambiguous and rejected', function () { + expect(fn() => new TypeParser()->parse(AmbiguousId::class)) + ->toThrow(ParserException::class, 'inherits #[Brand] from more than one interface'); +}); + +test('declaring the attribute locally settles an otherwise ambiguous pair of interfaces', function () { + $node = new TypeParser()->parse(DisambiguatedId::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->brand)->toBe('disambiguatedId'); + + compareToOptimizedAst($node); + validateAst($node); +}); + +/** + * A naming closure is how an inherited declaration opts out of the default derivation without + * handing every child the same tag. PHP rejects a closure literal in an attribute argument, so the + * form is first-class callable syntax: #[Named(name: Naming::suffixedAlias(...))]. + */ +test('a naming closure computes the brand and alias from the concrete class', function ( + string $type, + string $expectedBrand, + string $expectedName, +) { + $node = new TypeParser()->parse($type); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->brand)->toBe($expectedBrand) + ->and($node->name?->name)->toBe($expectedName); + + compareToOptimizedAst($node); + validateAst($node); +})->with([ + // Inherited from ComputedId: each implementor runs the rule against its own name. + 'inherited closure' => [InvoiceId::class, 'appInvoiceId', 'InvoiceIdAlias'], + 'inherited closure, sibling' => [ReceiptId::class, 'appReceiptId', 'ReceiptIdAlias'], + // The same closures declared directly on a class. + 'local closure' => [ComputedLocally::class, 'appComputedLocally', 'ComputedLocallyAlias'], +]); + +test('a naming closure still has to produce a valid TypeScript identifier', function () { + expect(fn() => new TypeParser()->parse(BadClosureId::class)) + ->toThrow(InvalidStringLiteralException::class, 'not a valid TypeScript identifier'); +}); \ No newline at end of file diff --git a/tests/Unit/Typescript/NamedTypesTest.php b/tests/Unit/Typescript/NamedTypesTest.php index ad135af..0b57e05 100644 --- a/tests/Unit/Typescript/NamedTypesTest.php +++ b/tests/Unit/Typescript/NamedTypesTest.php @@ -22,6 +22,8 @@ use Tests\Mocks\Named\OrderStatus; use Tests\Mocks\Named\PublicResource; use Tests\Mocks\Named\RenamedThing; +use Tests\Mocks\ValueObjects\Inherited\AccountId; +use Tests\Mocks\ValueObjects\Inherited\BrandId; test('a named class is referenced by its alias on output and carries its definition in the registry', function () { $node = new TypeParser()->parse(Customer::class); @@ -173,6 +175,22 @@ ->toThrow(UnsupportedTypeException::class, 'Cycle'); }); +test('siblings inheriting one declaration emit distinct aliases in both directions', function () { + $node = new TypeParser()->parse( + 'array{account: \\' . AccountId::class . ', brand: \\' . BrandId::class . '}', + ); + + foreach ([IO::INPUT, IO::OUTPUT] as $io) { + $result = typescriptFor($node, $io); + + expect($result->type)->toBe('{account:AccountId;brand:BrandId;}') + ->and($result->registry->toArray())->toBe([ + 'AccountId' => '(number & Brand<"accountId">)', + 'BrandId' => '(number & Brand<"brandId">)', + ]); + } +}); + test('cached ASTs are metadata free and emit the plain structural type', function () { $node = new TypeParser()->parse(Order::class); $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $node]); From 93a765f4d04b9658cfef2eb61d7e37f800cd3a16 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 31 Jul 2026 15:00:20 +0200 Subject: [PATCH 032/101] Add support for naming aliases per direction; update parsing and code generation logic to handle asymmetric shapes and distinct aliases comprehensively. --- README.md | 68 ++++++++++++++---- src/CodeGen/TypescriptServerCodeGenerator.php | 2 +- src/Contracts/Attributes/Named.php | 29 ++++---- src/{Typescript => }/Data/IO.php | 7 +- src/Parser/Consumers/EnumConsumer.php | 2 - src/Parser/Consumers/UtilsConsumer.php | 3 +- src/Parser/Consumers/ValueObjectConsumer.php | 2 - src/Parser/Nodes/Data/NamedType.php | 33 +++++++-- src/Parser/Nodes/MetadataNode.php | 56 ++++++++++++++- src/Reflection/MetadataAttributes.php | 13 ++-- src/Typescript/Data/EmissionContext.php | 1 + .../Exceptions/UnsupportedTypeException.php | 10 ++- src/Typescript/TypescriptGenerator.php | 21 +++--- tests/Mocks/Named/AliasNaming.php | 18 +++++ tests/Mocks/Named/AsymmetricNamed.php | 7 +- tests/Mocks/Named/NamedValueObject.php | 4 +- tests/Mocks/Named/OrderStatus.php | 4 +- tests/Mocks/Named/PerDirectionNamed.php | 22 ++++++ tests/Mocks/ValueObjects/Inherited/Naming.php | 13 ++++ .../ValueObjects/Inherited/ParentWinsBase.php | 7 +- .../Inherited/ParentWinsContract.php | 3 +- tests/Pest.php | 2 +- tests/Unit/CodeGen/EmitTypeUtilsTest.php | 2 +- tests/Unit/CodeGen/EmitTypesTest.php | 2 +- .../Mocks/AsymmetricNamedOperations.php | 23 ++++++ .../Mocks/PerDirectionNamedOperations.php | 34 +++++++++ .../TypescriptServerCodeGeneratorTest.php | 27 +++++++ tests/Unit/Parser/MetadataEliminationTest.php | 2 +- tests/Unit/Parser/NamedTypeTest.php | 70 ++++++++++++++----- .../Unit/Parser/NodeDiagnosticStringTest.php | 2 +- tests/Unit/Parser/TypeParserTest.php | 4 +- tests/Unit/Parser/ValueObjectConsumerTest.php | 2 +- tests/Unit/Typescript/NamedTypesTest.php | 59 ++++++++++------ tests/Unit/Typescript/OptimizedAstTest.php | 2 +- .../Typescript/TypescriptGeneratorTest.php | 2 +- 35 files changed, 430 insertions(+), 128 deletions(-) rename src/{Typescript => }/Data/IO.php (62%) create mode 100644 tests/Mocks/Named/AliasNaming.php create mode 100644 tests/Mocks/Named/PerDirectionNamed.php create mode 100644 tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php create mode 100644 tests/Unit/CodeGen/Mocks/PerDirectionNamedOperations.php diff --git a/README.md b/README.md index 4f4c70c..4186fe3 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ customizations, including writing your very own code generation plugin. ## Type Parsing ```php -use Le0daniel\PhpTsBindings\Executor\SchemaExecutor;use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope;use Le0daniel\PhpTsBindings\Parser\TypeParser;use Le0daniel\PhpTsBindings\Reflection\TypeReflector;use Le0daniel\PhpTsBindings\Typescript\Data\IO;use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry;use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; +use Le0daniel\PhpTsBindings\Data\IO;use Le0daniel\PhpTsBindings\Executor\SchemaExecutor;use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope;use Le0daniel\PhpTsBindings\Parser\TypeParser;use Le0daniel\PhpTsBindings\Reflection\TypeReflector;use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry;use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; $typeString = TypeReflector::reflectParameter( new ReflectionParameter() @@ -315,7 +315,7 @@ getUser(1); // Type error: number is not assignable to the brand `({...} & Brand<"...">)`. Combine it with `#[Named]` to export the branded type once by name: ```php -#[Brand] #[Named(io: IO::BOTH)] +#[Brand] #[Named] final readonly class UserId implements IntValueObject { /* ... */ } ``` @@ -329,9 +329,9 @@ export type UserId = (number & Brand<"userId">); inlining the structure at every use site, the generator declares it once and references it by name. ```php -#[Named] // alias defaults to the class base name: App\Data\Order => Order -#[Named('CustomOrder')] // or name it yourself -#[Named(io: IO::BOTH)] // name input and output alike (see below) +#[Named] // alias defaults to the class base name: App\Data\Order => Order +#[Named('CustomOrder')] // or name it yourself +#[Named(name: Naming::alias(...))] // or compute it, per direction (see below) final class Order { public Customer $customer; // Customer may itself be #[Named] — aliases nest recursively @@ -344,11 +344,31 @@ export type Customer = {email:(string & Brand<"email">);name:string;}; export type Order = {customer:Customer;id:(number & Brand<"customerId">);}; ``` -Because a class can legitimately have a different input shape than output shape (constructor-only -parameters, output-only properties), the name applies to **output only by default**; on input the -structure is inlined as if the attribute were absent. Opt into `IO::BOTH` when both directions are -identical — if they are not, generation fails hard with a conflicting alias error instead of -emitting a lying type. The same error protects against two classes resolving to the same alias with +**One name covers both directions.** A class can legitimately have a different input shape than +output shape — constructor-only parameters, output-only properties — and one alias cannot describe +both, because the generated types file declares each alias exactly once. A `#[Castable]` class whose +shapes diverge under a single name is rejected during schema generation, naming the property that +made them differ: + +```php +#[Named] #[Castable] +final class Article +{ + public string $slug; // output only + public function __construct(public string $title, string $draft) { /* ... */ } +} // $draft is input only +``` + +> `#[Named]` on `App\Data\Article` resolves to one alias `Article` for both directions, but its input +> and output shapes differ: `draft` is input only. + +Give each shape its own alias with a naming closure, which receives the direction: + +```php +#[Named(name: Naming::perDirection(...))] // => ArticleInput on the way in, Article on the way out +``` + +The same conflicting-alias error protects against two classes resolving to the same alias with different shapes anywhere in a run, and a handful of names the generated types file always declares (`Brand`, `Result`, `Success`, `Failure`, ...) are rejected outright. @@ -406,9 +426,11 @@ The remaining caveats are worth reading, because each is silent otherwise: ### Computing the name yourself -Both attributes accept a closure instead of a string, called with the class being emitted. It works -anywhere, but it is what makes a *shared* declaration flexible: an inherited attribute cannot carry -a fixed name, yet it can carry a rule each implementor runs against its own class name. +Both attributes accept a closure instead of a string, called with the class being emitted. It earns +its keep twice over. + +First, it is what makes a *shared* declaration flexible: an inherited attribute cannot carry a fixed +name, yet it can carry a rule each implementor runs against its own class name. ```php final class Naming @@ -424,6 +446,26 @@ final class Naming interface IntId extends IntValueObject {} ``` +Second, `#[Named]` calls its closure **once per direction** and hands it the `IO`, which is the only +way to give a class with two shapes two aliases: + +```php +public static function perDirection(string $className, IO $io): string +{ + $base = explode('\\', $className) |> array_last(...); + return $io === IO::INPUT ? "{$base}Input" : $base; +} +``` + +```typescript +export type Article = {slug:string;title:string;}; +export type ArticleInput = {draft:string;title:string;}; +``` + +A closure that ignores its second argument — like `Naming::alias()` above — simply names both +directions the same, which is what almost every type wants. `#[Brand]`'s closure takes only the +class name: a brand tags one wire value, so it is the same in both directions by construction. + > **PHP only accepts first-class callable syntax here.** A closure literal in an attribute argument > — `#[Named(name: static fn(string $c) => ucfirst($c))]` — does not compile: PHP reports > *"Constant expression contains invalid operations"*. Point the closure at a named function or diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 825f85e..ef4dd61 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -10,11 +10,11 @@ use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\AstValidator; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; diff --git a/src/Contracts/Attributes/Named.php b/src/Contracts/Attributes/Named.php index 9e68a99..7fd0d1e 100644 --- a/src/Contracts/Attributes/Named.php +++ b/src/Contracts/Attributes/Named.php @@ -4,7 +4,7 @@ use Attribute; use Closure; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; @@ -17,21 +17,20 @@ * The alias comes from one of three sources: * - no name: the class base name, used verbatim, so App\Data\Order becomes `Order`; * - a string: used verbatim; - * - a Closure(string $className): string, called with the class being emitted. PHP only accepts - * first-class callable syntax here, never a closure literal: + * - a Closure(string $className, IO $io): string, called once per direction with the class being + * emitted. PHP only accepts first-class callable syntax here, never a closure literal: * #[Named(name: AliasNaming::suffixed(...))] * + * One name covers input and output alike. A class can legitimately have a different input shape + * than output shape (constructor-only parameters, output-only properties), and one alias cannot + * describe both honestly — every alias is declared exactly once in the generated types file. That + * combination is rejected by MetadataNode::validate(), which runs at schema generation. The way out + * is a Closure returning a distinct name per IO, so each shape gets its own alias. + * * Two classes resolving to the same name with different shapes fail generation with a conflicting * alias error, as does a name colliding with a declaration the generated types file always * contains (Brand, Result, ...). * - * $io decides which direction the name applies to and defaults to IO::OUTPUT, because a class can - * legitimately have a different input shape than output shape (constructor-only parameters, - * output-only properties). On the other direction the structure is inlined as if the attribute - * were absent. IO::BOTH names both directions under the one alias — if the two shapes differ, - * generation fails hard instead of emitting a lying type. On value objects and enums the default - * is IO::BOTH instead: their input and output shapes are always identical. - * * On a VALUE OBJECT the attribute may also be declared one level up, on the interface or parent * class a family of ids shares, and every child picks it up — deriving its own alias from its own * name, so siblings stay distinct types. Resolution order is: the class itself, then its direct @@ -50,23 +49,25 @@ final readonly class Named { /** - * @param string|Closure(string): string|null $name + * @param string|Closure(string, IO): string|null $name */ public function __construct( public string|Closure|null $name = null, - public ?IO $io = null, ) { } /** + * Called once per direction. Only the Closure form can tell them apart; a derived or explicit + * name is the same string both ways. + * * @internal */ - public function typeName(string $classString): string + public function typeName(string $classString, IO $io): string { $name = match (true) { $this->name === null => explode('\\', $classString) |> array_last(...), - $this->name instanceof Closure => ($this->name)($classString), + $this->name instanceof Closure => ($this->name)($classString, $io), default => $this->name, }; diff --git a/src/Typescript/Data/IO.php b/src/Data/IO.php similarity index 62% rename from src/Typescript/Data/IO.php rename to src/Data/IO.php index 30340b3..3e9fe40 100644 --- a/src/Typescript/Data/IO.php +++ b/src/Data/IO.php @@ -1,6 +1,6 @@ io === IO::BOTH || $this->io === $io; + return new self($name, $name); + } + + public function isSameForBothDirections(): bool + { + return $this->inputName === $this->outputName; + } + + public function nameFor(IO $io): string + { + return match ($io) { + IO::INPUT => $this->inputName, + IO::OUTPUT => $this->outputName, + }; } } diff --git a/src/Parser/Nodes/MetadataNode.php b/src/Parser/Nodes/MetadataNode.php index c48df2f..5dea952 100644 --- a/src/Parser/Nodes/MetadataNode.php +++ b/src/Parser/Nodes/MetadataNode.php @@ -5,9 +5,10 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNode; -use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNodes; use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\NamedType; +use Le0daniel\PhpTsBindings\Parser\Nodes\Data\ObjectCastStrategy; +use Le0daniel\PhpTsBindings\Parser\Nodes\Data\PropertyType; use Override; /** @@ -56,5 +57,58 @@ public function validate(): void 'MetadataNode should not be nested.' ); } + + $this->assertOneAliasFitsBothDirections(); + } + + /** + * A single alias has to describe the same type in both directions, because the generated types + * file declares it exactly once. A castable class whose constructor takes something it does not + * expose (or exposes something its constructor does not take) has two shapes, and naming both + * of them the one thing would emit a lying type. + * + * Validation is opt-in (AstValidator), so this costs nothing at parse time and surfaces at + * schema generation, which is the last moment it can. + */ + private function assertOneAliasFitsBothDirections(): void + { + // Two distinct aliases were computed, one per direction — the shapes are free to differ. + if ($this->name === null || !$this->name->isSameForBothDirections()) { + return; + } + + $node = $this->node; + + // NEVER has no input type at all — TypescriptGenerator throws before the alias is reached — + // so its properties being output only says nothing about the alias. + if (!$node instanceof CustomCastingNode || $node->strategy === ObjectCastStrategy::NEVER) { + return; + } + + // Only a struct has per-direction properties; a cast over a list or a record does not. + if (!$node->node instanceof StructNode) { + return; + } + + $asymmetric = array_find( + $node->node->properties, + fn(NodeInterface $property): bool => $property instanceof PropertyNode + && $property->propertyType !== PropertyType::BOTH, + ); + + if (!$asymmetric instanceof PropertyNode) { + return; + } + + $direction = $asymmetric->propertyType === PropertyType::INPUT ? 'input' : 'output'; + + throw new ParserException( + "#[Named] on {$node->fullyQualifiedCastingClass} resolves to one alias \"{$this->name->outputName}\" " + . "for both directions, but its input and output shapes differ: \"{$asymmetric->name}\" is " + . "{$direction} only. Every alias is declared once in the generated types file, so one name " + . "cannot describe both. Compute a name per direction with a closure — " + . "#[Named(name: Naming::alias(...))], Closure(string \$className, IO \$io): string — or align " + . "the shapes." + ); } } diff --git a/src/Reflection/MetadataAttributes.php b/src/Reflection/MetadataAttributes.php index 4b542e1..b2b9099 100644 --- a/src/Reflection/MetadataAttributes.php +++ b/src/Reflection/MetadataAttributes.php @@ -4,11 +4,11 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Brand; use Le0daniel\PhpTsBindings\Contracts\Attributes\Named; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\NamedType; use Le0daniel\PhpTsBindings\Parser\Nodes\MetadataNode; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; use ReflectionClass; /** @@ -20,8 +20,6 @@ { /** * @param ReflectionClass $reflectionClass - * @param IO $defaultIo The direction a #[Named] without an explicit io applies to. Value - * objects and enums pass IO::BOTH — their input and output shapes are always identical. * @param bool $inheritFromParents Also accept an attribute declared one level up, on the direct * parent class or a directly declared interface. Value objects opt in so a family of ids * can share one declaration; see wrap()'s callers. @@ -29,7 +27,6 @@ public static function wrap( NodeInterface $node, ReflectionClass $reflectionClass, - IO $defaultIo = IO::OUTPUT, bool $inheritFromParents = false, ): NodeInterface { @@ -51,9 +48,15 @@ public static function wrap( $className = $reflectionClass->getName(); + // Both directions are resolved here, so no user closure ever travels in the node tree. Only + // the Closure form can return two different names; everything else lands on one alias, and + // MetadataNode::validate() is what rejects that over a shape that differs per direction. return new MetadataNode( $node, - $named === null ? null : new NamedType($named->typeName($className), $named->io ?? $defaultIo), + $named === null ? null : new NamedType( + inputName: $named->typeName($className, IO::INPUT), + outputName: $named->typeName($className, IO::OUTPUT), + ), $brand?->brandName($className), ); } diff --git a/src/Typescript/Data/EmissionContext.php b/src/Typescript/Data/EmissionContext.php index c40e4e9..250f0e7 100644 --- a/src/Typescript/Data/EmissionContext.php +++ b/src/Typescript/Data/EmissionContext.php @@ -2,6 +2,7 @@ namespace Le0daniel\PhpTsBindings\Typescript\Data; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; /** diff --git a/src/Typescript/Exceptions/UnsupportedTypeException.php b/src/Typescript/Exceptions/UnsupportedTypeException.php index 847c053..84a9c12 100644 --- a/src/Typescript/Exceptions/UnsupportedTypeException.php +++ b/src/Typescript/Exceptions/UnsupportedTypeException.php @@ -40,12 +40,18 @@ public static function emptyEnum(string $enumClassName): self ); } + /** + * A #[Named] class whose own shape differs per direction is caught earlier and more precisely by + * MetadataNode::validate(). What reaches here is either two different types claiming one alias, + * or a named type that is symmetric itself but wraps something asymmetric further down. + */ public static function conflictingAlias(string $alias, string $existing, string $conflicting): self { return new self( "Type alias {$alias} has conflicting definitions: '{$existing}' and '{$conflicting}'." - . " If {$alias} comes from #[Named(io: IO::BOTH)], its input and output shapes differ:" - . " align the shapes or name only one direction." + . " Either two types claim the same alias — rename one — or {$alias} contains something whose" + . " input and output shapes differ, in which case it needs a name per direction:" + . " #[Named(name: Naming::alias(...))]." ); } diff --git a/src/Typescript/TypescriptGenerator.php b/src/Typescript/TypescriptGenerator.php index f3a31d9..a1d0512 100644 --- a/src/Typescript/TypescriptGenerator.php +++ b/src/Typescript/TypescriptGenerator.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Typescript; -use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; @@ -28,7 +28,6 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Typescript\Data\EmissionContext; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; @@ -47,10 +46,6 @@ { public function toTypescript(NodeInterface $node, IO $io, ?AliasRegistry $sharedRegistry = null): Typescript { - if ($io === IO::BOTH) { - throw new CodeGenException('Emit for IO::INPUT or IO::OUTPUT; IO::BOTH is only a #[Named] scope.'); - } - // Every pass emits into its own local registry, so the result always carries exactly the // aliases this schema produced. When a shared registry is given, all of them are // registered into it after the pass — that hand-over is where an alias meaning two @@ -132,9 +127,10 @@ private static function enum(EnumNode $node): string * * A brand intersects the inner type with Brand<"..."> and is always parenthesised * (`(string & Brand<"email">)`), so the result composes into any surrounding type unchanged. - * A name applying to the direction registers the result as an alias; the use site references - * the bare identifier. The registry accepts the identical re-registration a second use site - * produces and rejects a contradicting one. + * A name registers the result as an alias and the use site references the bare identifier. The + * alias applies to both directions; which of the two names it is comes from the node, which + * resolved them per direction at parse time. The registry accepts the identical + * re-registration a second use site produces and rejects a contradicting one. */ private function metadata(MetadataNode $node, EmissionContext $context): string { @@ -144,9 +140,10 @@ private function metadata(MetadataNode $node, EmissionContext $context): string $inner = Syntax::branded($inner, $node->brand) |> Syntax::wrapInParentheses(...); } - if ($node->name?->appliesTo($context->io)) { - $context->registry->set($node->name->name, $inner); - return $node->name->name; + if ($node->name !== null) { + $alias = $node->name->nameFor($context->io); + $context->registry->set($alias, $inner); + return $alias; } return $inner; diff --git a/tests/Mocks/Named/AliasNaming.php b/tests/Mocks/Named/AliasNaming.php new file mode 100644 index 0000000..004dea9 --- /dev/null +++ b/tests/Mocks/Named/AliasNaming.php @@ -0,0 +1,18 @@ + array_last(...); + return $io === IO::INPUT ? "{$base}Input" : $base; + } +} diff --git a/tests/Mocks/Named/AsymmetricNamed.php b/tests/Mocks/Named/AsymmetricNamed.php index fee7d2f..b1bfb78 100644 --- a/tests/Mocks/Named/AsymmetricNamed.php +++ b/tests/Mocks/Named/AsymmetricNamed.php @@ -4,13 +4,12 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Castable; use Le0daniel\PhpTsBindings\Contracts\Attributes\Named; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; /** - * Input ({secret:string;}) and output ({visible:string;}) differ, so naming it for IO::BOTH must - * fail generation with a conflicting alias error. + * Input ({secret:string;}) and output ({visible:string;}) differ, so the one alias #[Named] derives + * cannot describe both and validation must reject it. */ -#[Named(io: IO::BOTH)] +#[Named] #[Castable] final class AsymmetricNamed { diff --git a/tests/Mocks/Named/NamedValueObject.php b/tests/Mocks/Named/NamedValueObject.php index 89e3a3e..5684739 100644 --- a/tests/Mocks/Named/NamedValueObject.php +++ b/tests/Mocks/Named/NamedValueObject.php @@ -8,8 +8,8 @@ /** * Brand and Named combined: exported once as `export type AccountId = (string & Brand<"accountId">)` - * and referenced by name at every use site. No explicit io — on a value object #[Named] defaults - * to IO::BOTH. + * and referenced by name at every use site. A value object's shape never differs per direction, so + * the one alias covers both. */ #[Brand('accountId')] #[Named('AccountId')] diff --git a/tests/Mocks/Named/OrderStatus.php b/tests/Mocks/Named/OrderStatus.php index 78088aa..0acc44c 100644 --- a/tests/Mocks/Named/OrderStatus.php +++ b/tests/Mocks/Named/OrderStatus.php @@ -5,8 +5,8 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Named; /** - * An enum's case union is identical in both directions, so #[Named] defaults to IO::BOTH here — - * no explicit io needed. + * An enum's case union is identical in both directions, so the one alias #[Named] derives describes + * both honestly. */ #[Named] enum OrderStatus diff --git a/tests/Mocks/Named/PerDirectionNamed.php b/tests/Mocks/Named/PerDirectionNamed.php new file mode 100644 index 0000000..ddc6d24 --- /dev/null +++ b/tests/Mocks/Named/PerDirectionNamed.php @@ -0,0 +1,22 @@ +visible = strrev($secret); + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/Naming.php b/tests/Mocks/ValueObjects/Inherited/Naming.php index 45f30e2..8c5e8b5 100644 --- a/tests/Mocks/ValueObjects/Inherited/Naming.php +++ b/tests/Mocks/ValueObjects/Inherited/Naming.php @@ -14,6 +14,19 @@ public static function suffixedAlias(string $className): string return self::baseName($className) . 'Alias'; } + /** + * Paired with contractAlias() so a test can tell which of two competing declarations won. + */ + public static function parentAlias(string $className): string + { + return self::baseName($className) . 'FromParent'; + } + + public static function contractAlias(string $className): string + { + return self::baseName($className) . 'FromContract'; + } + public static function prefixedBrand(string $className): string { return 'app' . self::baseName($className); diff --git a/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php b/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php index 9f8aea9..c256a0a 100644 --- a/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php +++ b/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php @@ -4,13 +4,12 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Named; use Le0daniel\PhpTsBindings\Contracts\ValueObjects\IntValueObject; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; /** - * Paired with ParentWinsContract: both carry #[Named], distinguishable only by their io, so a test - * can tell which candidate the resolver picked. + * Paired with ParentWinsContract: both carry #[Named], each deriving a differently suffixed alias, + * so a test can tell which candidate the resolver picked. */ -#[Named(io: IO::OUTPUT)] +#[Named(name: Naming::parentAlias(...))] abstract readonly class ParentWinsBase implements IntValueObject { protected function __construct(public int $value) diff --git a/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php b/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php index 3fb5f3b..08cfae5 100644 --- a/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php +++ b/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php @@ -4,9 +4,8 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Named; use Le0daniel\PhpTsBindings\Contracts\ValueObjects\IntValueObject; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; -#[Named(io: IO::INPUT)] +#[Named(name: Naming::contractAlias(...))] interface ParentWinsContract extends IntValueObject { } diff --git a/tests/Pest.php b/tests/Pest.php index 4fc9575..63699fd 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -11,6 +11,7 @@ | */ +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; @@ -21,7 +22,6 @@ use Le0daniel\PhpTsBindings\Parser\AstValidator; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index 72c76eb..33181ae 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -5,12 +5,12 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ToastType; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 4c06fdb..239ffe7 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -5,12 +5,12 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ToastType; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; diff --git a/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php b/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php new file mode 100644 index 0000000..261d3ae --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php @@ -0,0 +1,23 @@ + generateFor([UnrepresentableOperations::class])) ->toThrow(UnsupportedTypeException::class, 'SomeFileInterface'); }); + +test('fails the run when one alias would have to describe two shapes', function () { + // AstValidator runs before any pass, so this never reaches the emitter. + expect(fn() => generateFor([AsymmetricNamedOperations::class])) + ->toThrow(ParserException::class, 'resolves to one alias "AsymmetricNamed" for both directions'); +}); + +test('a name per direction declares both shapes; a single shape is referenced both ways', function () { + $files = generateFor([PerDirectionNamedOperations::class]); + $types = $files['lib/types.ts']->toString(); + $operations = $files['articles.ts']->toString(); + + expect($types) + ->toContain('export type PerDirectionNamed = {visible:string;}') + ->toContain('export type PerDirectionNamedInput = {secret:string;}') + ->toContain('export type Customer = {email:(string & Brand<"email">);name:string;}'); + + // The symmetric class is its alias in both directions — no inlined duplicate of the same shape. + expect($operations) + ->toContain('export type RoundtripInput = PerDirectionNamedInput;') + ->toContain('export type RoundtripResult = PerDirectionNamed;') + ->toContain('export type CustomerInput = Customer;') + ->toContain('export type CustomerResult = Customer;'); +}); diff --git a/tests/Unit/Parser/MetadataEliminationTest.php b/tests/Unit/Parser/MetadataEliminationTest.php index 62b79cb..a4ba512 100644 --- a/tests/Unit/Parser/MetadataEliminationTest.php +++ b/tests/Unit/Parser/MetadataEliminationTest.php @@ -109,7 +109,7 @@ function containsMetadataNode(NodeInterface $node): bool }); test('MetadataNode cannot serialize itself even outside the optimizer', function () { - $node = new MetadataNode(new StringNode(), new NamedType('Token'), 'token'); + $node = new MetadataNode(new StringNode(), NamedType::same('Token'), 'token'); expect($node->exportPhpCode())->not->toContain('MetadataNode') ->and($node->exportPhpCode())->toBe(new StringNode()->exportPhpCode()) diff --git a/tests/Unit/Parser/NamedTypeTest.php b/tests/Unit/Parser/NamedTypeTest.php index 02ca18b..ed9928c 100644 --- a/tests/Unit/Parser/NamedTypeTest.php +++ b/tests/Unit/Parser/NamedTypeTest.php @@ -1,16 +1,18 @@ parse(Customer::class); expect($node)->toBeInstanceOf(MetadataNode::class) - ->and($node->name?->name)->toBe('Customer') - ->and($node->name?->io)->toBe(IO::OUTPUT) + ->and($node->name?->inputName)->toBe('Customer') + ->and($node->name?->outputName)->toBe('Customer') ->and($node->brand)->toBeNull() ->and($node->node)->toBeInstanceOf(CustomCastingNode::class); @@ -52,7 +54,8 @@ test('an explicit name wins over the base name', function () { $node = new TypeParser()->parse(RenamedThing::class); - expect($node->name?->name)->toBe('CustomThing'); + expect($node->name?->inputName)->toBe('CustomThing') + ->and($node->name?->outputName)->toBe('CustomThing'); }); test('a class without codegen attributes carries no metadata wrapper', function () { @@ -61,12 +64,12 @@ expect($node)->toBeInstanceOf(CustomCastingNode::class); }); -test('#[Named] on an enum defaults to IO::BOTH, its shape is identical in both directions', function () { +test('#[Named] on an enum names both directions; its shape is identical either way', function () { $node = new TypeParser()->parse(OrderStatus::class); expect($node)->toBeInstanceOf(MetadataNode::class) - ->and($node->name?->name)->toBe('OrderStatus') - ->and($node->name?->io)->toBe(IO::BOTH) + ->and($node->name?->inputName)->toBe('OrderStatus') + ->and($node->name?->outputName)->toBe('OrderStatus') ->and($node->node)->toBeInstanceOf(EnumNode::class); compareToOptimizedAst($node); @@ -76,8 +79,8 @@ $node = new TypeParser()->parse(NamedValueObject::class); expect($node)->toBeInstanceOf(MetadataNode::class) - ->and($node->name?->name)->toBe('AccountId') - ->and($node->name?->io)->toBe(IO::BOTH) + ->and($node->name?->inputName)->toBe('AccountId') + ->and($node->name?->outputName)->toBe('AccountId') ->and($node->brand)->toBe('accountId') ->and($node->node)->toBeInstanceOf(ValueObjectNode::class); }); @@ -123,8 +126,8 @@ expect($node)->toBeInstanceOf(MetadataNode::class) ->and($node->brand)->toBe($expectedBrand) - ->and($node->name?->name)->toBe($expectedName) - ->and($node->name?->io)->toBe(IO::BOTH) + ->and($node->name?->outputName)->toBe($expectedName) + ->and($node->name?->inputName)->toBe($expectedName) ->and($node->node)->toBeInstanceOf(ValueObjectNode::class) ->and($node->node->className)->toBe($type); @@ -143,9 +146,9 @@ expect($parent)->toBeInstanceOf(MetadataNode::class) ->and($parent->brand)->toBe('baseId') - ->and($parent->name?->name)->toBe('BaseId') + ->and($parent->name?->outputName)->toBe('BaseId') ->and($child->brand)->toBe('childId') - ->and($child->name?->name)->toBe('ChildId'); + ->and($child->name?->outputName)->toBe('ChildId'); compareToOptimizedAst($parent); validateAst($parent); @@ -156,7 +159,7 @@ expect($node)->toBeInstanceOf(MetadataNode::class) ->and($node->brand)->toBe('explicitBrand') - ->and($node->name?->name)->toBe('ExplicitName'); + ->and($node->name?->outputName)->toBe('ExplicitName'); compareToOptimizedAst($node); validateAst($node); @@ -167,18 +170,18 @@ expect($node)->toBeInstanceOf(MetadataNode::class) ->and($node->brand)->toBe('partialBrand') - ->and($node->name?->name)->toBe('PartiallyOverriddenId'); + ->and($node->name?->outputName)->toBe('PartiallyOverriddenId'); compareToOptimizedAst($node); validateAst($node); }); test('the parent class is consulted before the interfaces', function () { - // Both carry #[Named]; only the io tells them apart. + // Both carry #[Named]; only the suffix their closure adds tells them apart. $node = new TypeParser()->parse(ParentWinsId::class); expect($node)->toBeInstanceOf(MetadataNode::class) - ->and($node->name?->io)->toBe(IO::OUTPUT); + ->and($node->name?->outputName)->toBe('ParentWinsIdFromParent'); }); test('the lookup stops after one level', function (string $type) { @@ -243,7 +246,7 @@ expect($node)->toBeInstanceOf(MetadataNode::class) ->and($node->brand)->toBe($expectedBrand) - ->and($node->name?->name)->toBe($expectedName); + ->and($node->name?->outputName)->toBe($expectedName); compareToOptimizedAst($node); validateAst($node); @@ -258,4 +261,33 @@ test('a naming closure still has to produce a valid TypeScript identifier', function () { expect(fn() => new TypeParser()->parse(BadClosureId::class)) ->toThrow(InvalidStringLiteralException::class, 'not a valid TypeScript identifier'); +}); + +/** + * The naming closure is also handed the direction, which is the only way to get two aliases out of + * one declaration — and the only way to name a class whose two shapes differ. + */ +test('a naming closure receives the direction and may return a name per direction', function () { + $node = new TypeParser()->parse(PerDirectionNamed::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->name?->inputName)->toBe('PerDirectionNamedInput') + ->and($node->name?->outputName)->toBe('PerDirectionNamed') + ->and($node->name?->isSameForBothDirections())->toBeFalse(); + + validateAst($node); +}); + +test('one alias over a class whose input and output shapes differ is rejected', function () { + // Parsing stays cheap and permissive: only validation, which the code generator runs, refuses it. + $node = new TypeParser()->parse(AsymmetricNamed::class); + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->name?->isSameForBothDirections())->toBeTrue(); + + expect(fn() => AstValidator::validate($node)) + ->toThrow(ParserException::class, 'resolves to one alias "AsymmetricNamed" for both directions'); +}); + +test('a named class whose properties are all bidirectional validates cleanly', function () { + validateAst(new TypeParser()->parse(Customer::class)); }); \ No newline at end of file diff --git a/tests/Unit/Parser/NodeDiagnosticStringTest.php b/tests/Unit/Parser/NodeDiagnosticStringTest.php index 6593484..5d28c2e 100644 --- a/tests/Unit/Parser/NodeDiagnosticStringTest.php +++ b/tests/Unit/Parser/NodeDiagnosticStringTest.php @@ -47,7 +47,7 @@ test('metadata stays transparent, which the elimination guarantee depends on', function () { $inner = new StringNode(); - $node = new MetadataNode($inner, new NamedType('Token'), 'token'); + $node = new MetadataNode($inner, NamedType::same('Token'), 'token'); expect((string)$node)->toBe((string)$inner); }); diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 960def4..9ba9037 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Parser; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\Constraints\IntRange; use Le0daniel\PhpTsBindings\Parser\Constraints\ListLength; use Le0daniel\PhpTsBindings\Parser\Constraints\LowercaseString; @@ -32,7 +33,6 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Feature\Mocks\Paginated; @@ -809,7 +809,7 @@ expect($string)->toBeInstanceOf(MetadataNode::class) ->and($string->brand)->toBe('wow') - ->and($string->name?->name)->toBe('Wow') + ->and($string->name?->outputName)->toBe('Wow') ->and($string->node)->toBeInstanceOf(StringNode::class) ->and((string)$string)->toBe('string') ->and($string->exportPhpCode())->not->toContain('wow') diff --git a/tests/Unit/Parser/ValueObjectConsumerTest.php b/tests/Unit/Parser/ValueObjectConsumerTest.php index ccaf84e..ecb56bd 100644 --- a/tests/Unit/Parser/ValueObjectConsumerTest.php +++ b/tests/Unit/Parser/ValueObjectConsumerTest.php @@ -1,5 +1,6 @@ parse(Customer::class); $shared = new AliasRegistry(); $result = new TypescriptGenerator()->toTypescript($node, IO::INPUT, $shared); - expect($result->type)->toBe('{email:(string & Brand<"email">);name:string;}') - ->and($result->registry->isEmpty())->toBeTrue() - // The name never registers for a direction it does not apply to. - ->and($shared->has('Customer'))->toBeFalse(); + expect($result->type)->toBe('Customer') + ->and($result->registry->toArray())->toBe([ + 'Customer' => '{email:(string & Brand<"email">);name:string;}', + ]) + // One name, one declaration: the input pass hands the very same alias to the shared registry. + ->and($shared->get('Customer'))->toBe('{email:(string & Brand<"email">);name:string;}'); }); test('named types nest recursively: the outer definition references the inner alias', function () { @@ -60,12 +62,15 @@ ]); }); -test('on input a named-by-default tree is fully inlined and registers nothing', function () { +test('nested aliases are referenced on input exactly as they are on output', function () { $node = new TypeParser()->parse(Order::class); $result = typescriptFor($node, IO::INPUT); - expect($result->type)->toBe('{customer:{email:(string & Brand<"email">);name:string;};id:(number & Brand<"customerId">);}') - ->and($result->registry->isEmpty())->toBeTrue(); + expect($result->type)->toBe('Order') + ->and($result->registry->toArray())->toBe([ + 'Customer' => '{email:(string & Brand<"email">);name:string;}', + 'Order' => '{customer:Customer;id:(number & Brand<"customerId">);}', + ]); }); test('a use site inside a struct references the alias and carries its dependencies as used', function () { @@ -104,7 +109,7 @@ ->and($result->registry->toArray())->toBe(['CustomThing' => '{value:string;}']); }); -test('a named enum with IO::BOTH is aliased identically in both directions', function () { +test('a named enum is aliased identically in both directions', function () { $node = new TypeParser()->parse(OrderStatus::class); foreach ([IO::INPUT, IO::OUTPUT] as $io) { @@ -114,7 +119,11 @@ } }); -test('without a shared registry each pass stands alone and never conflicts with another', function () { +/** + * The generator emits one direction at a time and never compares the two — which is precisely why + * AstValidator refuses a single alias over two shapes before generation gets this far. + */ +test('a single pass cannot see the other direction, so nothing catches the divergence here', function () { $node = new TypeParser()->parse(AsymmetricNamed::class); $generator = new TypescriptGenerator(); @@ -125,7 +134,7 @@ ->and($output->registry->toArray())->toBe(['AsymmetricNamed' => '{visible:string;}']); }); -test('IO::BOTH fails hard when the input and output shapes differ', function () { +test('the shared registry is the backstop when the two passes do meet', function () { $node = new TypeParser()->parse(AsymmetricNamed::class); $generator = new TypescriptGenerator(); $shared = new AliasRegistry(); @@ -133,7 +142,20 @@ expect($generator->toTypescript($node, IO::INPUT, $shared)->type)->toBe('AsymmetricNamed'); expect(fn() => $generator->toTypescript($node, IO::OUTPUT, $shared)) - ->toThrow(UnsupportedTypeException::class, 'IO::BOTH'); + ->toThrow(UnsupportedTypeException::class, 'AsymmetricNamed'); +}); + +test('a name per direction declares both shapes under their own aliases', function () { + $node = new TypeParser()->parse(PerDirectionNamed::class); + $generator = new TypescriptGenerator(); + $shared = new AliasRegistry(); + + expect($generator->toTypescript($node, IO::INPUT, $shared)->type)->toBe('PerDirectionNamedInput') + ->and($generator->toTypescript($node, IO::OUTPUT, $shared)->type)->toBe('PerDirectionNamed') + ->and($shared->toArray())->toBe([ + 'PerDirectionNamed' => '{visible:string;}', + 'PerDirectionNamedInput' => '{secret:string;}', + ]); }); test('a named interface works on output and stays uncastable on input', function () { @@ -157,18 +179,13 @@ ->and($result->registry->isEmpty())->toBeTrue(); }); -test('emitting for IO::BOTH is rejected', function () { - expect(fn() => new TypescriptGenerator()->toTypescript(new StringNode(), IO::BOTH)) - ->toThrow(CodeGenException::class, 'IO::BOTH'); -}); - test('two named nodes claiming one alias with different shapes are rejected', function () { - $inner = new MetadataNode(new StringNode(), new NamedType('Cycle', IO::BOTH)); + $inner = new MetadataNode(new StringNode(), NamedType::same('Cycle')); $outer = new MetadataNode( new StructNode(StructPhpType::ARRAY, [ new PropertyNode('self', $inner, false, PropertyType::BOTH), ]), - new NamedType('Cycle', IO::BOTH), + NamedType::same('Cycle'), ); expect(fn() => new TypescriptGenerator()->toTypescript($outer, IO::OUTPUT)) diff --git a/tests/Unit/Typescript/OptimizedAstTest.php b/tests/Unit/Typescript/OptimizedAstTest.php index 45b3793..5c4135d 100644 --- a/tests/Unit/Typescript/OptimizedAstTest.php +++ b/tests/Unit/Typescript/OptimizedAstTest.php @@ -2,9 +2,9 @@ namespace Tests\Unit\Typescript; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\ASTOptimizer; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Typescript\Data\IO; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Unit\Executor\Mocks\UserSchema; diff --git a/tests/Unit/Typescript/TypescriptGeneratorTest.php b/tests/Unit/Typescript/TypescriptGeneratorTest.php index 3ed690d..4db5ff7 100644 --- a/tests/Unit/Typescript/TypescriptGeneratorTest.php +++ b/tests/Unit/Typescript/TypescriptGeneratorTest.php @@ -1,5 +1,6 @@ Date: Fri, 31 Jul 2026 16:21:09 +0200 Subject: [PATCH 033/101] Add ArtisanOptions helper and unit tests; update `OptimizeCommand` to improve `--id-length` validation and fallback handling; enhance documentation and composer metadata. --- README.md | 809 ++++++++++-------- composer.json | 18 +- composer.lock | 111 +-- docs/types.md | 505 +++++++++++ src/Adapters/Laravel/Commands/ListCommand.php | 2 +- .../Laravel/Commands/OptimizeCommand.php | 35 +- src/Adapters/Laravel/Utils/ArtisanOptions.php | 25 + src/Contracts/Attributes/Command.php | 8 +- src/Contracts/Attributes/Query.php | 8 +- src/Reflection/AttributesReflector.php | 2 +- src/Server/Client/OperationSPAClient.php | 1 - .../Laravel/LaravelHttpControllerTest.php | 14 +- .../Adapters/Laravel/ArtisanOptionsTest.php | 47 + 13 files changed, 1128 insertions(+), 457 deletions(-) create mode 100644 docs/types.md create mode 100644 tests/Unit/Adapters/Laravel/ArtisanOptionsTest.php diff --git a/README.md b/README.md index 4186fe3..38c6a49 100644 --- a/README.md +++ b/README.md @@ -1,513 +1,578 @@ # PHP-TS Bindings -This library is an RPC-style library. In comparison to other libraries, it leverages PHP Stan types for input and output -definition, together with attributes to declare commands and queries. +Type-safe RPC between a PHP backend and a TypeScript frontend, driven by the types you have already +written. -This Library might be for you if you have a well-typed modern PHP Project and want to seamlessly communicate with the -Backend, while enjoying full stack type safety between PHP and Typescript. +You annotate a method with `#[Query]` or `#[Command]` and type its input and output with PHPStan +annotations. From that, this library validates every incoming request against those types, +serializes the response to match them, and generates a TypeScript client whose call signatures are +those same types. There is no schema to declare, no resource class to maintain, and no second source +of truth to keep in sync — the PHPStan type *is* the contract. -## Motivation +```php +/** + * @param array{id: UserId} $input + * @return array{email: Email, slug: Slug} + */ +#[Query('users')] +public function get(array $input): array { /* ... */ } +``` -Writing modern and statically analysable PHP is great. It provides type safety with tools like PHPStan, catching a whole -class of errors before you even deploy your application. This is great. The big issue arises at the boundary between a -modern client using TypeScript, where you loose type safety at the api level between PHP and TS. +```typescript +export type GetInput = {id:(number & Brand<"customerId">);}; +export type GetResult = {email:(string & Brand<"email">);slug:string;}; + +const result = await get({id: userId}); +``` -Writing a lot of client side code with frameworks like Next.js, I really fell in love with full stack type safety. This -is a bit a challenge when using PHP, as the type system can be quite limiting at times. PHPstan comes to the help here, -but creating API resources is painful compared to Next.js server actions. +**What it is not.** Not a validator — it proves the types your code declares, and nothing beyond +them. Not an ORM serializer. Not a schema DSL. If a rule cannot be expressed as a PHPStan type, this +library will not check it for you; [value objects](docs/types.md#value-objects) are where such rules +belong. -This made me think, why is there no such thing in PHP? +Requires **PHP 8.5**. The core has one dependency (`psr/container`) and no framework coupling. A +first-party Laravel adapter ships in the box and is entirely optional. -This library aims to provide you a similar experience for your whole stack, by leveraging modern PHP and PHPStan type -annotations, providing a clear contract between your frontend and backend. It doesn't require you to add specific code, -rather expects you to strictly type your PHP input and output types – thats it. From that, it will generate you strict -contracts and easy to use server actions and queries. As simple as that. +--- -## Installation +- [Install](#install) +- [Quickstart](#quickstart) +- [Core concepts](#core-concepts) +- [Defining operations](#defining-operations) +- [Middleware](#middleware) +- [Types](#types) +- [Errors](#errors) +- [The generated TypeScript client](#the-generated-typescript-client) +- [Client directives](#client-directives) +- [Laravel setup](#laravel-setup) +- [Production](#production) +- [Without a framework](#without-a-framework) -``` +## Install + +```bash composer require le0daniel/php-ts-bindings ``` -## Usage +Then register the PHPStan extension, so static analysis understands the same utility types the +generator does — without it `Pick`, `Omit`, `BrandedString`, `BrandedInt` and `DateTimeString` do +not resolve: -Get the type definition either for the PHP type system or in combination with the PHPDoc type annotations. Especially -phpstan is supported quite well, including locally defined types or imported types. +```neon +# phpstan.neon +includes: + - vendor/le0daniel/php-ts-bindings/extension.neon +``` -At its core, this library provides a Server class, taking a Registry of registered Operations (Commands and Queries). -They can then be run with unvalidated input. The server takes care of input validation based on your types, running -specified middlewares and returning structured output as defined in your output types. That's it. It requires your -methods to have at least an input parameter which is typed and a typed return type. +On Laravel the service provider is auto-discovered; there is nothing else to register. See +[Laravel setup](#laravel-setup). -The definitions from PHPStan are parsed, applied to the provided input, guaranteeing that the input is valid based on -your types. The return type is also applied and serialized, allowing you to be really specific about what is exposed. +## Quickstart -```php -use Le0daniel\PhpTsBindings\Server\Server; -use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; -use Le0daniel\PhpTsBindings\Contracts\Client; -use Le0daniel\PhpTsBindings\Contracts\Attributes\Query; -use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; -use Le0daniel\PhpTsBindings\Contracts\Attributes\Throws; +Write a class of operations. The first parameter is the input, and its PHPStan type is what the +client must send. The return type is what the client receives. -$server = new Server( - EagerlyLoadedOperationRegistry::eagerlyDiscover('your/directory', keyGenerator: new PlainlyExposedKeyGenerator()) -); +```php +namespace App\Operations; -$inputData = Request::fromGlobals()->jsonInput; -$result = $server->query('users.getUser', $inputData, new MyCustomContext); -renderResponse($result); +use Le0daniel\PhpTsBindings\Contracts\Attributes\Command; +use Le0daniel\PhpTsBindings\Contracts\Attributes\Query; -# Class in your/directory -class MySuperClass { +final class UserOperations +{ + /** + * @param array{id: UserId} $input + * @return array{email: Email, slug: Slug} + */ + #[Query('users')] + public function get(array $input): array + { + return [ + 'email' => Email::fromStringValue('user@example.com'), + 'slug' => Slug::fromStringValue("user-{$input['id']->toIntValue()}"), + ]; + } - #[Query(namespace: "users")] - #[Throws(UserNotFoundException::class)] /** - * @param array{id: positive-int} $input - * @return object{id: int, name: string, email: string} + * @param array{name: string} $input + * @return array{id: UserId} */ - public final getUser(array $input, MyCustomContext $context, Client $client): object { - return User::findOrFail($input['id']); + #[Command('users')] + public function create(array $input): array + { + return ['id' => UserId::fromIntValue(strlen($input['name']))]; } } ``` -This provides you full type safety without any additional code. Your PHP code is fully analysable by PHPStan. - -### Laravel Default Integration +`UserId`, `Email` and `Slug` are [value objects](docs/types.md#value-objects) — classes backed by a +single primitive. `UserId` and `Email` carry a `#[Brand]`, so they are not interchangeable with a +plain `number` or `string` on the TypeScript side. -We provide a first-party integration with laravel. By default, we discover remotely called functions in -`App/Operations/(.*)`. This is configurable via the config file exposed (run: `php artisan vendor:publish`) to see all -options. This lets you configure how exceptions are mapped to different buckets. Configure key generation. +Generate the client: -Additionally, we provide code generation out of the box for laravel and your typescript project. To do so, run -`php artisan operations:codegen frontend/directory`, this will directly generate you a good starter kit for operations, -so that ou can seamlessly bridge the gap between your FE and BE. See more below for detailed codegen examples and -customizations, including writing your very own code generation plugin. +```bash +php artisan operations:codegen resources/js/operations +``` -## Type Parsing +You get a `users.ts` module, matching the namespace: -```php -use Le0daniel\PhpTsBindings\Data\IO;use Le0daniel\PhpTsBindings\Executor\SchemaExecutor;use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope;use Le0daniel\PhpTsBindings\Parser\TypeParser;use Le0daniel\PhpTsBindings\Reflection\TypeReflector;use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry;use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; +```typescript +export type GetResult = {email:(string & Brand<"email">);slug:string;}; +export type GetInput = {id:(number & Brand<"customerId">);}; +export type GetError = /* the operation's error union */; -$typeString = TypeReflector::reflectParameter( - new ReflectionParameter() -); // string|array|object{name: string} +export async function get(input: GetInput, options?: OperationOptions) { /* ... */ } +``` -$parser = new TypeParser(); -$ast = $parser->parse( - $typeString, - // The parsing context is needed for Type Imports and used classes. - ParsingScope::fromClassString(MyClassDeclaringThisParameter::class) -); +and call it: -$generator = new TypescriptGenerator(); +```typescript +import {get} from './operations/users'; -$input = $generator->toTypescript($ast, IO::INPUT); -$input->type; // => string|Record|{name:string;} +const result = await get({id: userId}); +if (result.success) { + result.data.email; // (string & Brand<"email">) +} else { + result.type; // "INVALID_INPUT" | "NOT_FOUND" | "INTERNAL_ERROR" | ... +} +``` -$output = $generator->toTypescript($ast, IO::OUTPUT); -$output->type; // => string|Record|{name:string;} +Passing `{id: 1}` is a compile error: `number` is not assignable to `UserId`'s branded type. Sending +it anyway is a 422 at runtime, because the server proves the same type it published. -// A #[Brand] renders inline at every use site, always parenthesised; it declares no alias. -$branded = $generator->toTypescript($parser->parse(Email::class), IO::INPUT); -$branded->type; // => (string & Brand<"email">) +## Core concepts -// Named types are referenced by their alias; each definition comes back in the registry, so you -// can emit `export type Token = (string & Brand<"token">)` once and reference it everywhere. -$named = $generator->toTypescript($parser->parse("BrandedString<'token'>"), IO::INPUT); -$named->type; // => Token -$named->registry->toArray(); // => ['Token' => '(string & Brand<"token">)'] -$named->registry->usedAliases(); // => ['Token'] — every alias in the registry counts as used +**`Server`** takes a registry of operations and runs one. Both methods are total — every +`Throwable`, including a failure resolving your handler, comes back as an `RpcError`: -// Each call emits into its own registry — the result always carries exactly the aliases that -// schema produced. Pass an optional shared registry and every call registers its aliases into it -// at the end of the pass; that hand-over is where an alias meaning two different things across -// several schemas is rejected. -$generator->toTypescript($ast, IO::INPUT, $shared = new AliasRegistry()); +```php +public function query(string $name, mixed $input, mixed $context, Client $client): RpcSuccess|RpcError +public function command(string $name, mixed $input, mixed $context, Client $client): RpcSuccess|RpcError +``` -$executor = new SchemaExecutor() +**`$name` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns +`namespace` + `name` into what the client calls. The default is `HashSha256KeyGenerator`, which +hashes both, so a discovered `users.get` is reachable as an opaque key rather than as `users.get`. +Use `PlainlyExposedKeyGenerator` for literal keys. The generated TypeScript always embeds whichever +key the server produced, so this only matters when you call the server by hand. -// Execute against some input or output. -$parsed = $executor->parse($node, ['key' => 'value']); -$serialized = $executor->serialize($node, "my string"); -``` +**`OperationRegistry`** holds the operations. `EagerlyLoadedOperationRegistry` discovers them by +scanning directories; schemas are parsed lazily, per operation, on first use. +`CachedOperationRegistry` is the compiled form for production — see [Production](#production). -### Refinement types - -Some PHPStan types narrow a PHP type further than PHP itself can express: `positive-int` is an -`int` to PHP, `non-empty-list` is an `array`. Those refinements are checked at runtime. - -| PHPStan type | PHP type | Checked | -| --- | --- | --- | -| `int` | `int` | inclusive bounds; `min` / `max` mean unbounded | -| `positive-int` | `int` | `>= 1` | -| `non-negative-int` | `int` | `>= 0` | -| `negative-int` | `int` | `<= -1` | -| `non-positive-int` | `int` | `<= 0` | -| `non-empty-string` | `string` | `!== ''` — note `"0"` is valid | -| `non-falsy-string`, `truthy-string` | `string` | truthy — `"0"` is not | -| `numeric-string` | `string` | `is_numeric()` | -| `lowercase-string` | `string` | `strtolower($v) === $v` | -| `uppercase-string` | `string` | `strtoupper($v) === $v` | -| `non-empty-lowercase-string` | `string` | both of the above | -| `non-empty-uppercase-string` | `string` | both of the above | -| `non-empty-list` | `array` | at least one element | -| `non-empty-array` | `array` | at least one element | - -`int-mask<…>`, `int-mask-of<…>` and `class-string` are **not** supported. Integer refinement is -`int` and the four shorthands above, nothing else. - -There is no attribute or annotation for attaching a check of your own: a property is refined by -its PHPStan type or not at all. That is what keeps a parsed schema equal to the type it was -parsed from, and it is why this library validates types rather than data — "is a valid email -address" is not something PHPStan can express, so it is not something this library checks. - -### Refinements run on input, never on output - -`$executor->parse()` checks every refinement. `$executor->serialize()` checks none of them, and -`SerializationOptions` has no knob to change that. - -Input arrives from a client and is untrusted, so every claim its type makes has to be proven. -Output comes out of your own code, which PHPStan already analysed against the very return type -being serialized — if your method says it returns `positive-int`, static analysis has established -that. Re-checking it at runtime would cost you something for a guarantee you already have. This -library assumes static analysis does its job. - -Serialization still enforces *types*: a `string` where an `int` is declared fails either way. Only -the PHPStan refinement on top of the type is skipped. - -## Utility types - -A handful of type names are understood in docblocks even though no such PHP class exists. They are -resolved by the bundled PHPStan extension too, so static analysis agrees with the generated types. - -| Type | PHP / PHPStan | TypeScript | -| --- | --- | --- | -| `Pick` | struct with only those properties | `{a: …; b: …;}` | -| `Omit` | struct without those properties | `{…}` | -| `BrandedString<'name'>` | `string` | `Name`, declared as `(string & Brand<"name">)` | -| `BrandedInt<'name'>` | `int` | `Name`, declared as `(number & Brand<"name">)` | -| `DateTimeString<'format'>` | `DateTimeImmutable` | `string` | - -### DateTimeString - -`DateTimeString` is a date that travels as a string and arrives as a `DateTimeImmutable`. The -optional generic is the [PHP date format](https://www.php.net/manual/en/datetime.format.php); it -defaults to `DateTimeInterface::ATOM`. +**The handler contract.** Your method is called with three arguments and may declare as few of them +as it needs: ```php -/** - * @param DateTimeString $createdAt // 2025-09-10T12:09:01+00:00 - * @param DateTimeString<'Y-m-d'> $birthday // 2025-01-01 - */ -public function __construct( - public DateTimeImmutable $createdAt, - public DateTimeImmutable $birthday, -) {} +public function get(array $input, MyContext $context, Client $client): array ``` -Both are `string` in TypeScript. On input the string is parsed with the format, on output the -`DateTimeInterface` is formatted back with it. - -**Prefer single quotes for the format.** Date formats escape literal characters with a backslash, -and the parser applies PHP's own string semantics: single quotes leave `\T` alone, while double -quotes resolve the full escape set. `"H:i\t"` is a tab, `'H:i\t'` is an escaped `t`. +`$input` is the parsed, validated input — already hydrated into whatever your type declares. +`$context` is whatever you passed to `Server::query()`; the library never touches it. `$client` is +the [side channel](#client-directives) back to the frontend. -**Parsing is strict.** The value has to match the format exactly — the parsed date is formatted -again and compared to the input. Fields the format does not cover are zeroed rather than taken -from the current clock, so `DateTimeString<'Y-m-d'>` gives you midnight, not "today at 14:32". +**Input is parsed, output is serialized.** Input is untrusted, so every claim its type makes is +proven. Output is checked against its declared type too, but the PHPStan *refinements* on top of it +are not re-checked — static analysis already established those. See +[refinements run on input, never on output](docs/types.md#refinements-run-on-input-never-on-output). -```php -DateTimeString<'Y-m-d'> - '2025-01-01' // 2025-01-01 00:00:00 - '2025-1-1' // rejected, single digit month and day - '2025-02-30' // rejected, would silently roll over to March 2nd - '2025-01-01T10:00:00' // rejected, trailing data -``` +## Defining operations -This also applies to `DateTimeImmutable`, `DateTime` and any other `DateTimeInterface` written -directly as a type. +| Attribute | Target | What it does | +|---|---|---| +| `#[Query(namespace, name)]` | method | A read operation, served over GET. | +| `#[Command(namespace, name)]` | method | A write operation, served over POST. | +| `#[Middleware(class or list)]` | class, method | Middleware to run around this operation. | +| `#[Throws(ExceptionClass)]` | method, repeatable | Declares an exception the operation may throw. | +| `#[ExposeAs(type)]` | exception class | Opts that exception into being shown to the client. | +| `#[Optional]` | property, parameter | The field may be absent from input. | +| `#[Castable(strategy)]` | class | A plain class may be built from input. | +| `#[Brand(name)]` | class | Makes the generated TypeScript type opaque. | +| `#[Named(name)]` | class | Exports the type once by name instead of inlining it. | -One consequence worth knowing: ATOM renders UTC as `+00:00`, so the `Z` suffix that -`Date.toISOString()` produces is *not* accepted by the default. Use the lowercase `p` specifier, -which renders UTC as `Z`: +`namespace` defaults to `global` and becomes the generated TypeScript module. `name` defaults to the +method name. Both accept a `UnitEnum` as well as a string, so you can keep namespaces in an enum. +Two operations of the same type resolving to the same `namespace.name` fail discovery. -```php -/** @param DateTimeString<'Y-m-d\TH:i:sp'> $when */ // accepts 2025-09-10T12:09:01Z -``` +`#[Brand]`, `#[Named]`, `#[Castable]` and `#[Optional]` are covered in +[the type reference](docs/types.md). -## Value Objects +## Middleware -Wrapping an id or an email in its own class usually costs you the type on the wire: a plain class is -reflected property by property, so `UserId` would show up in TypeScript as `{value: number}`. Value -objects avoid that. A class implementing `StringValueObject` or `IntValueObject` is treated as its -backing primitive — a bare `string` or `number` in JSON — and hydrated back into the class on input. +A middleware wraps the operation. Implement `MiddlewareContract`: ```php -use Le0daniel\PhpTsBindings\Contracts\Attributes\Brand; -use Le0daniel\PhpTsBindings\Contracts\ValueObjects\IntValueObject; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; -#[Brand] -final readonly class UserId implements IntValueObject +/** + * @implements MiddlewareContract + */ +final class NameCheckingMiddleware implements MiddlewareContract { - private function __construct(public int $value) {} - - public static function fromIntValue(int $value): static + #[Throws(InvalidNameException::class)] + public function handle( + mixed $input, + Closure $next, + mixed $context, + ResolveInfo $info, + Client $client, + ): RpcSuccess|RpcError { - if ($value < 1) { - throw new InvalidArgumentException("UserId must be positive, got {$value}"); + if (is_array($input) && ($input['name'] ?? null) === 'invalid') { + throw new InvalidNameException(); } - return new self($value); - } - public function toIntValue(): int - { - return $this->value; + return $next($input); } } ``` -The two interfaces are: +**`$next()` never throws.** A failure deeper in the pipeline is converted to an `RpcError` at the +ring where it happened and handed back to you as `$next()`'s return value, so post-processing runs +whether the operation succeeded or not. -| Interface | Methods | JSON type | -|---|---|---| -| `StringValueObject` | `static fromStringValue(string): static`, `toStringValue(): string` | `string` | -| `IntValueObject` | `static fromIntValue(int): static`, `toIntValue(): int` | `number` | +Attach it per operation or per class: -The methods carry the `...Value` suffix so the interfaces stay safe to add to a class that already -implements `Stringable` or declares its own `toString()`. +```php +#[Command('users')] +#[Middleware(NameCheckingMiddleware::class)] +public function create(array $input): array { /* ... */ } +``` -Implementing the interface *is* the opt-in: unlike a plain class, a value object needs no `#[Castable]` -attribute and works for both input and output. Use it anywhere a type is parsed: +or globally, for every operation on the server: ```php -/** @return object{id: UserId, email: Email, tags: list} */ +new ServerConfiguration()->withMiddlewares(AuthMiddleware::class, LoggingMiddleware::class) ``` -**Rejecting values.** `fromStringValue()` / `fromIntValue()` may throw to reject input. The exception is -caught and reported as a validation issue on that field, with the original exception attached for -debugging — it never reaches the client as an internal error, and never escapes the executor. +`#[Throws]` on a middleware's `handle()` contributes to the error union of every operation it wraps, +so the generated TypeScript knows about middleware failures too. -### Branded types +## Types -Without a brand, `UserId` and any other int are interchangeable in TypeScript. Add `#[Brand]` and the -generated type becomes opaque. A brand is an **inline** intersection at every use site — it declares -no alias of its own: +Anything PHPStan can express about a shape, this library can parse, serialize and emit: -```php -#[Brand] // brand name defaults to lcfirst('UserId') => "userId" -#[Brand('customerId')] // or name it yourself -``` +| PHPStan | TypeScript | +|---|---| +| `string`, `int`, `float`, `bool`, `null`, `mixed` | `string`, `number`, `number`, `boolean`, `null`, `unknown` | +| `'foo'`, `123`, `true`, `MyEnum::CASE` | `"foo"`, `123`, `true`, `"CASE"` | +| `array{name: string, age?: int}` | `{name:string;age?:number;}` | +| `list`, `T[]`, `array` | `Array` | +| `array` | `Record` | +| `array{string, int}` | `[string,number]` | +| `A\|B`, `?T` | `(A\|B)`, `(null\|T)` | +| `MyEnum` | `("OPEN"\|"SHIPPED")` | +| `DateTimeImmutable`, `DateTimeString<'Y-m-d'>` | `string` | +| `positive-int`, `non-empty-string`, … | `number`, `string` — refinement enforced server-side | -```typescript -// declared once in the generated types file: -declare const __brand: unique symbol; -export type Brand = {readonly [__brand]: TBrand;}; +Local and imported types work too: `@phpstan-type` and `@phpstan-import-type` are resolved against +the declaring class, as are `use` statements and generics. -// at every use site: -declare function getUser(id: (number & Brand<"userId">)): void; -getUser(1); // Type error: number is not assignable to the branded type -``` +**[→ Full type reference](docs/types.md)** — refinements, utility types (`Pick`, `Omit`, +`BrandedString`, `DateTimeString`), value objects, `#[Castable]`, brands and named types. + +## Errors -`#[Brand]` works on any class, interface, enum or value object — an object shape simply becomes -`({...} & Brand<"...">)`. Combine it with `#[Named]` to export the branded type once by name: +Every failure the client can see is one of six categories: + +| Code | `type` | When | +|---|---|---| +| 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* marked `#[ExposeAs]` | +| 401 | `AUTHENTICATION_ERROR` | An exception you mapped as unauthenticated | +| 403 | `AUTHORIZATION_ERROR` | An exception you mapped as unauthorized | +| 404 | `NOT_FOUND` | Unknown operation, or an exception you mapped as not-found | +| 422 | `INVALID_INPUT` | The input did not match its type | +| 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | + +The first match wins, in that order. Anything unrecognised is a 500 — an exception is never exposed +by accident. + +**Exposing a domain error takes two keys.** The operation declares that it can throw it, and the +exception itself declares what the client should see: ```php -#[Brand] #[Named] -final readonly class UserId implements IntValueObject { /* ... */ } +#[ExposeAs('invalid_name')] +final class InvalidNameException extends Exception {} + +#[Command('users')] +#[Throws(InvalidNameException::class)] +public function create(array $input): array { /* ... */ } ``` +```json +{"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"type": "invalid_name"}} +``` + +An exception declared with `#[Throws]` but not marked `#[ExposeAs]` stays a 500, and so does one +marked `#[ExposeAs]` that no operation declares. + +Because both the runtime and the code generator read those attributes from the same place, the +generated error union cannot drift from the responses it describes. An operation that declares +nothing gets: + ```typescript -export type UserId = (number & Brand<"userId">); +export type CreateError = + {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}} + | {code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}} + | {code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; ``` -### Named types +The 401 and 403 branches appear only once you have actually mapped exceptions onto them, so the +union describes what this server can really produce. Validation failures carry `fields`, keyed by +dotted path (`__root` for the top level) with localization keys as values, e.g. +`{"email": ["validation.not_empty_string"]}`. -`#[Named]` exports a class, interface, enum or value object as a named type alias: instead of -inlining the structure at every use site, the generator declares it once and references it by name. +## The generated TypeScript client + +`operations:codegen ` writes a self-contained client. Nothing is published to npm; the +code lives in your repo. -```php -#[Named] // alias defaults to the class base name: App\Data\Order => Order -#[Named('CustomOrder')] // or name it yourself -#[Named(name: Naming::alias(...))] // or compute it, per direction (see below) -final class Order -{ - public Customer $customer; // Customer may itself be #[Named] — aliases nest recursively - public UserId $id; // and mix freely with brands -} ``` +/ + lib/types.ts Success/Failure/Result, Brand, every #[Named] alias + lib/OperationClient.ts the transport interface + lib/DefaultClient.ts a fetch implementation of it + lib/OperationException.ts + lib/bindings.ts createDefaultClient, setClient, executeOperation, throwOnFailure + lib/utils.ts queryKey and the client-directive type guards + .ts one module per namespace, one function per operation +``` + +The envelope every call resolves to: ```typescript -export type Customer = {email:(string & Brand<"email">);name:string;}; -export type Order = {customer:Customer;id:(number & Brand<"customerId">);}; +export type Success = {success: true, data: T} +export type Failure = {success: false} & E; +export type Result = Success | Failure; ``` -**One name covers both directions.** A class can legitimately have a different input shape than -output shape — constructor-only parameters, output-only properties — and one alias cannot describe -both, because the generated types file declares each alias exactly once. A `#[Castable]` class whose -shapes diverge under a single name is rejected during schema generation, naming the property that -made them differ: +Wire it up once: -```php -#[Named] #[Castable] -final class Article -{ - public string $slug; // output only - public function __construct(public string $title, string $draft) { /* ... */ } -} // $draft is input only +```typescript +import {createDefaultClient, setClient} from './operations/lib/bindings'; + +setClient(createDefaultClient()); ``` -> `#[Named]` on `App\Data\Article` resolves to one alias `Article` for both directions, but its input -> and output shapes differ: `draft` is input only. +`DefaultClient` sends queries as GET with each input value JSON-encoded into a query parameter, and +commands as POST with a JSON body. It supports `AbortSignal`, timeouts, and `registerHook()` for +global response handling. Swap it for your own by implementing `OperationClient` — `setClient()` and +the per-call `options.client` both take one. -Give each shape its own alias with a naming closure, which receives the direction: +`throwOnFailure(result)` narrows a `Result` to its success branch and throws an `OperationException` +otherwise, for call sites that would rather not branch. -```php -#[Named(name: Naming::perDirection(...))] // => ArticleInput on the way in, Article on the way out +**Optional generators**, off by default: + +```bash +php artisan operations:codegen resources/js/operations --with=tanstack-query,query-key,type-map ``` -The same conflicting-alias error protects against two classes resolving to the same alias with -different shapes anywhere in a run, and a handful of names the generated types file always declares -(`Brand`, `Result`, `Success`, `Failure`, ...) are rejected outright. +`tanstack-query` emits `QueryOptions()` and `useQuery()` for `@tanstack/react-query`; +`query-key` emits standalone query keys; `type-map` emits a `TYPE_MAP` of every operation. Use +`--without=` to drop a default generator, and `--naming=` to choose how functions are named +(`name`, `fqn`, `operation-prefix`, `namespace-postfix`, or `Class::method` for your own rule). -Brands and names are pure code generation metadata with zero runtime impact: values travel the wire -in their plain shape, and the metadata is stripped from cached ASTs entirely — TypeScript -generation always runs on freshly parsed schemas. The `BrandedString<'x'>` / `BrandedInt<'x'>` -docblock utilities are the shorthand for brand + name in one, since docblocks cannot carry -attributes: `BrandedString<'token'>` is referenced as `Token` and declared as -`export type Token = (string & Brand<"token">)`. +Write your own generator by implementing `GeneratesLibFiles` (gets every operation, writes shared +lib files) or `GeneratesOperationCode` (gets one operation, writes its code) and passing it with +`--custom=My\Generator`. -### Sharing one declaration across value objects +## Client directives -A family of ids usually shares an interface or a base class. Declare the attributes once there and -every value object in the family picks them up: +Optional, and unrelated to type safety: the `Client` passed to every handler is a side channel for +telling a single-page app what to do alongside the data. ```php -#[Brand] -#[Named] -interface IntId extends IntValueObject {} +public function create(array $input, mixed $context, Client $client): array +{ + $client->success('Saved'); + $client->redirect('/docs/123', reload: true); + $client->invalidate('users', $input['id']); -final readonly class AccountId implements IntId { /* ... */ } -final readonly class BrandId implements IntId { /* ... */ } + return ['id' => '123']; +} ``` -```typescript -export type AccountId = (number & Brand<"accountId">); -export type BrandId = (number & Brand<"brandId">); +When the request carries `X-Client-Id: operations-spa`, those land in a `__client` key next to the +data: + +```json +{ + "success": true, + "data": {"id": "123"}, + "__client": { + "redirect": {"url": "/docs/123", "reload": true}, + "toasts": [{"type": "success", "message": "Saved"}], + "type": "operations-spa" + } +} ``` -The brand and the alias are derived from the **concrete** class, not from the one carrying the -attribute — which is the whole point: `AccountId` and `BrandId` share a declaration but stay -mutually unassignable in TypeScript. +Otherwise a `NullClient` is used and every call is a no-op, so handlers never need to know which kind +of client is on the other end. `lib/utils.ts` ships `isSpaClientDirectives()`, `isClientToast()` and +`isClientRedirect()` for reading them back. -Each attribute is resolved on its own, in this order: +## Laravel setup -1. **The class itself.** A local declaration always wins, and declaring both attributes locally - means nothing else is inspected. A local `#[Brand]` combines fine with an inherited `#[Named]`. -2. **The direct parent class,** abstract or concrete. -3. **The directly declared interfaces.** Two of them declaring the same attribute is an ambiguity - the library refuses to resolve — it fails instead of picking one. Declare the attribute on the - class itself to say which applies. +**1. The provider is auto-discovered.** Nothing to register. -The remaining caveats are worth reading, because each is silent otherwise: +**2. Publish the config.** -- **Value objects only.** Enums and plain classes read the attributes from the class itself and - nothing else, so implementing a `#[Named]` interface names nothing. -- **One level up, and no further.** `interface DeepId extends IntId` does not pass `IntId`'s - attributes on to *its* implementors, and neither does `class GrandChild extends Child extends - Base`. Redeclare them on the intermediate type when you want them to keep travelling. -- **An inherited declaration cannot carry a fixed name.** `#[Brand('id')]` on `IntId` would give - every implementor the brand `"id"` and collapse them into one type, so it is rejected at parse - time. Drop the name to derive it per class, or compute one with a closure — see below. -- **A concrete parent keeps a brand of its own.** `#[Brand]` on a non-abstract `BaseId` brands - `BaseId` as `baseId` *and* its children after their own names — one declaration, distinct types. +```bash +php artisan vendor:publish --provider="Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider" +``` -### Computing the name yourself +`config/operations.php`: -Both attributes accept a closure instead of a string, called with the class being emitted. It earns -its keep twice over. +| Key | Default | Purpose | +|---|---|---| +| `discovery_path` | `app_path('Operations')` | Where operations are discovered. | +| `context` | `null` | A `ContextFactory` class, building the `$context` every handler receives from the request. | +| `key.mode` | `obfuscate` | `obfuscate`, `plain`, or `custom` with `key.className`. | +| `key.pepper` | `none` | Salt for `obfuscate`. | +| `middleware` | `[]` | Global `MiddlewareContract` classes, run on every operation. | +| `exceptions.not_found` | Laravel's model-not-found exceptions | Mapped to 404. | +| `exceptions.unauthenticated` | `AuthenticationException` | Mapped to 401. | +| `exceptions.unauthorized` | `AuthorizationException`, `TokenMismatchException` | Mapped to 403. | +| `cache.idLength` | `10` | Id length used by the production cache. | -First, it is what makes a *shared* declaration flexible: an inherited attribute cannot carry a fixed -name, yet it can carry a rule each implementor runs against its own class name. +Exception matching is `instanceof`, so listing a base class covers its subclasses. + +**3. Register the routes.** Nothing is registered for you — put this in your routes file, inside +whatever middleware group the operations belong to: ```php -final class Naming -{ - public static function alias(string $className): string - { - return explode('\\', $className) |> array_last(...) |> ucfirst(...); - } -} +use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; -#[Brand] -#[Named(name: Naming::alias(...))] -interface IntId extends IntValueObject {} +Route::middleware('web')->group(function () { + LaravelHttpController::registerQueries(); // GET /query/{fqn} + LaravelHttpController::registerCommands(); // POST /command/{fqn} +}); ``` -Second, `#[Named]` calls its closure **once per direction** and hands it the `IO`, which is the only -way to give a class with two shapes two aliases: +Both take a route prefix. `operations:codegen` reads the registered URIs to build the client, and +fails with *"The operation routes are not registered"* if you skip this step. -```php -public static function perDirection(string $className, IO $io): string -{ - $base = explode('\\', $className) |> array_last(...); - return $io === IO::INPUT ? "{$base}Input" : $base; -} -``` +**4. Write an operation** in `app/Operations`, as in the [quickstart](#quickstart). -```typescript -export type Article = {slug:string;title:string;}; -export type ArticleInput = {draft:string;title:string;}; +**5. Generate the client.** + +```bash +php artisan operations:codegen resources/js/operations ``` -A closure that ignores its second argument — like `Naming::alias()` above — simply names both -directions the same, which is what almost every type wants. `#[Brand]`'s closure takes only the -class name: a brand tags one wire value, so it is the same in both directions by construction. +### Commands -> **PHP only accepts first-class callable syntax here.** A closure literal in an attribute argument -> — `#[Named(name: static fn(string $c) => ucfirst($c))]` — does not compile: PHP reports -> *"Constant expression contains invalid operations"*. Point the closure at a named function or -> static method instead, as above. +| Command | Purpose | +|---|---| +| `operations:list` | Every registered operation with its URI, method and handler. | +| `operations:codegen {directory}` | Generate the TypeScript client. `--verify` checks for drift instead of writing — use it in CI. | +| `operations:optimize` | Compile the registry to `bootstrap/cache/operations.php`. | +| `operations:clear-optimize` | Remove it. | -The closure runs at parse time, never at runtime, and its result still has to be a valid TypeScript -identifier — an invalid one fails generation the same way a bad string literal does. +The last two are wired into `php artisan optimize` and `optimize:clear`. -## Validating AST +> `operations:codegen` removes every `.ts` file under the target directory before writing. Point it +> at a directory it owns, not at a shared frontend folder. -By default, the parsed AST is not validated. This means, the AST itself can be invalid. For example Intersection types -intersecting wrong types. -You can validate the ast using the `AstValidator::validate($node)` method. This will walk through the AST and validate -each node. +## Production -## Running in Production +Reflecting and parsing every schema on every request is real overhead. Compile the whole registry +once, at deploy time: -As with reflection class, there is quite some overhead for running the parser in production on every request. -To increase performance, you can cache and optimize your ASTs easily. The optimizer does deeper analysis on multiple -asts, reduces the object creation by splitting reused structs and types and optimizing unions for better performance. +```bash +php artisan operations:optimize +``` + +This writes `bootstrap/cache/operations.php` with every schema pre-parsed, deduplicated and pooled — +shared structs are emitted once and referenced, and unions are reordered for faster dispatch. The +service provider picks the file up automatically when it exists. Run `operations:codegen --verify` in +CI to catch a frontend that has drifted from the backend. + +Outside Laravel, or for schemas that are not operations, the same optimizer is available directly: ```php use Le0daniel\PhpTsBindings\Parser\ASTOptimizer; +use Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry; -$optimizer = new ASTOptimizer(); -$optimizer->optimizeAndWriteToFile( - 'asts.php', - [ - 'MyClass@methodname@input' => $ast, - 'MyClass@methodname@output' => $otherAst, - ], +new ASTOptimizer()->optimizeAndWriteToFile('asts.php', [ + 'MyClass@method@input' => $inputAst, + 'MyClass@method@output' => $outputAst, +]); + +/** @var CachedTypeRegistry $registry */ +$registry = require 'asts.php'; +$ast = $registry->get('MyClass@method@input'); +``` + +## Without a framework + +The core knows nothing about Laravel. Build a server, run an operation, and shape the response +however you like: + +```php +use Le0daniel\PhpTsBindings\Server\Client\NullClient; +use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; +use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; +use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; +use Le0daniel\PhpTsBindings\Server\Server; + +$server = new Server( + EagerlyLoadedOperationRegistry::eagerlyDiscover( + __DIR__ . '/src/Operations', + keyGenerator: new PlainlyExposedKeyGenerator(), + ), + container: $psrContainer, // optional; without it handlers are instantiated with `new` ); + +$result = $server->command('users.create', $input, $myContext, new NullClient()); + +if ($result instanceof RpcSuccess) { + respondJson(200, ['success' => true, 'data' => $result->data]); +} else { + respondJson($result->type->value, [ + 'success' => false, + 'code' => $result->type->value, + 'type' => $result->type->name, + 'details' => $result->details, + ]); +} ``` -To use the optimized ASTs, you can simply require the file in your project and use the optimized ASTs. +`$result->cause` is the underlying `Throwable` on every error, ready to hand to your reporter. + +To generate the client, hand the same `Server` to `TypescriptServerCodeGenerator` with the URL +patterns your router uses: ```php -use Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; +use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; +use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; + +$files = new TypescriptServerCodeGenerator([ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations(), +])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); + +foreach ($files as $path => $file) { + // $path is e.g. 'lib/types.ts' or 'users.ts' + file_put_contents("resources/js/operations/{$path}", $file->toString()); +} +``` -/** @var CachedTypeRegistry $registry */ -$registry = require 'asts.php'; +The lower-level `TypeParser`, `SchemaExecutor` and `TypescriptGenerator` are public too, if you want +to parse and emit types without the RPC layer at all — see [the type reference](docs/types.md). + +## Contributing -$ast = $registry->get('MyClass@methodname@input'); -$otherAst = $registry->get('MyClass@methodname@output'); -``` \ No newline at end of file +```bash +composer test # pest +composer check:types # phpstan, level 8 +composer check:all +``` diff --git a/composer.json b/composer.json index 925e5fd..8ca90a6 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,19 @@ { "name": "le0daniel/php-ts-bindings", - "description": "Library to create type bindings between PHP8 and TS, supporting parsing, serialization and emitting of TS types for PHP objects/input strongly typed", + "description": "Type-safe RPC between a PHP 8.5 backend and a TypeScript frontend, driven by your PHPStan types.", "type": "library", + "license": "MIT", + "keywords": [ + "rpc", + "typescript", + "phpstan", + "code-generation", + "type-safety", + "laravel" + ], "require": { - "php": "^8.5" + "php": "^8.5", + "psr/container": "^2.0" }, "require-dev": { "pestphp/pest": "^v4.7.0", @@ -12,6 +22,10 @@ "mockery/mockery": "^1.6", "phpstan/phpstan-strict-rules": "^2.0" }, + "suggest": { + "laravel/framework": "Enables the first-party Laravel adapter: config, routes, and the operations:* artisan commands.", + "phpstan/phpstan": "Include vendor/le0daniel/php-ts-bindings/extension.neon so Pick, Omit, BrandedString, BrandedInt and DateTimeString resolve." + }, "autoload": { "psr-4": { "Le0daniel\\PhpTsBindings\\": "src/" diff --git a/composer.lock b/composer.lock index aad59ce..0265219 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,62 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6d5c07534be4b25cb6bd3067aad1b73f", - "packages": [], + "content-hash": "891be19d03ef851cf36b3d050c103c27", + "packages": [ + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + } + ], "packages-dev": [ { "name": "brianium/paratest", @@ -4645,59 +4699,6 @@ }, "time": "2022-11-25T14:36:26+00:00" }, - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - }, { "name": "psr/event-dispatcher", "version": "1.0.0", diff --git a/docs/types.md b/docs/types.md new file mode 100644 index 0000000..eeac003 --- /dev/null +++ b/docs/types.md @@ -0,0 +1,505 @@ +# Type reference + +Everything this library knows how to parse, serialize and emit. The short version lives in the +[README](../README.md); this is the full picture. + +- [PHP to TypeScript](#php-to-typescript) +- [Refinement types](#refinement-types) +- [Utility types](#utility-types) +- [DateTimeString](#datetimestring) +- [Value objects](#value-objects) +- [Plain classes and `#[Castable]`](#plain-classes-and-castable) +- [Branded types](#branded-types) +- [Named types](#named-types) +- [Sharing one declaration across value objects](#sharing-one-declaration-across-value-objects) +- [Computing the name yourself](#computing-the-name-yourself) +- [Validating the AST](#validating-the-ast) + +## PHP to TypeScript + +| PHPStan type | TypeScript | +| --- | --- | +| `string` | `string` | +| `int`, `float` | `number` | +| `bool` | `boolean` | +| `null` | `null` | +| `mixed` | `unknown` | +| `numeric` | `(number)` | +| `scalar` | `(number\|boolean\|string)` | +| `'foo'` | `"foo"` | +| `123`, `1.5`, `true`, `false` | `123`, `1.5`, `true`, `false` | +| `MyEnum::SUCCESS` | `"SUCCESS"` | +| `MyEnum` | `("SUCCESS"\|"FAILURE")` | +| `array{name: string}`, `object{name: string}` | `{name:string;}` | +| `array{name?: string}` | `{name?:string;}` | +| `array{a: array{b: string}}` | `{a:{b:string;};}` | +| `list`, `string[]`, `array` | `Array` | +| `array` | `Array` | +| `array` | `Record` | +| `array{string, int}` | `[string,number]` | +| `array{name: string}\|string` | `({name:string;}\|string)` | +| `?string` | `(null\|string)` | +| `DateTimeImmutable`, `DateTimeString<…>` | `string` | + +Unions and intersections are **always** parenthesised, so a union nested inside another type can +never be misread. Members are deduplicated by their rendered form: `int\|string\|int` emits +`(number|string)`. + +Enums emit as a union of their **case names**, not their backing values. A backed enum that should +travel as its backing value opts in by implementing `StringValueObject` — see +[value objects](#value-objects). + +An enum with no cases has no TypeScript representation and fails generation. + +## Refinement types + +Some PHPStan types narrow a PHP type further than PHP itself can express: `positive-int` is an +`int` to PHP, `non-empty-list` is an `array`. Those refinements are checked at runtime. + +| PHPStan type | PHP type | Checked | +| --- | --- | --- | +| `int` | `int` | inclusive bounds; `min` / `max` mean unbounded | +| `positive-int` | `int` | `>= 1` | +| `non-negative-int` | `int` | `>= 0` | +| `negative-int` | `int` | `<= -1` | +| `non-positive-int` | `int` | `<= 0` | +| `non-empty-string` | `string` | `!== ''` — note `"0"` is valid | +| `non-falsy-string`, `truthy-string` | `string` | truthy — `"0"` is not | +| `numeric-string` | `string` | `is_numeric()` | +| `lowercase-string` | `string` | `strtolower($v) === $v` | +| `uppercase-string` | `string` | `strtoupper($v) === $v` | +| `non-empty-lowercase-string` | `string` | both of the above | +| `non-empty-uppercase-string` | `string` | both of the above | +| `non-empty-list` | `array` | at least one element | +| `non-empty-array` | `array` | at least one element | + +A refinement disappears in TypeScript — `positive-int` is `number` — because TypeScript cannot +express it either. It is enforced on the server. + +`int-mask<…>`, `int-mask-of<…>` and `class-string` are **not** supported. Integer refinement is +`int` and the four shorthands above, nothing else. + +There is no attribute or annotation for attaching a check of your own: a property is refined by +its PHPStan type or not at all. That is what keeps a parsed schema equal to the type it was +parsed from, and it is why this library validates types rather than data — "is a valid email +address" is not something PHPStan can express, so it is not something this library checks. When +you need that, reach for a [value object](#value-objects), whose factory may reject whatever it +likes. + +### Refinements run on input, never on output + +`$executor->parse()` checks every refinement. `$executor->serialize()` checks none of them, and +`SerializationOptions` has no knob to change that. + +Input arrives from a client and is untrusted, so every claim its type makes has to be proven. +Output comes out of your own code, which PHPStan already analysed against the very return type +being serialized — if your method says it returns `positive-int`, static analysis has established +that. Re-checking it at runtime would cost you something for a guarantee you already have. This +library assumes static analysis does its job. + +Serialization still enforces *types*: a `string` where an `int` is declared fails either way. Only +the PHPStan refinement on top of the type is skipped. + +## Utility types + +A handful of type names are understood in docblocks even though no such PHP class exists. They are +resolved by the bundled PHPStan extension too, so static analysis agrees with the generated types — +[install it](../README.md#install) or these will not typecheck. + +| Type | PHP / PHPStan | TypeScript | +| --- | --- | --- | +| `Pick` | struct with only those properties | `{a: …; b: …;}` | +| `Omit` | struct without those properties | `{…}` | +| `BrandedString<'name'>` | `string` | `Name`, declared as `(string & Brand<"name">)` | +| `BrandedInt<'name'>` | `int` | `Name`, declared as `(number & Brand<"name">)` | +| `DateTimeString<'format'>` | `DateTimeImmutable` | `string` | + +`BrandedString` / `BrandedInt` are the shorthand for brand *and* name in one, because a docblock +cannot carry attributes. The same tag used twice collects one alias; the same tag resolving to two +different definitions fails the run. + +## DateTimeString + +`DateTimeString` is a date that travels as a string and arrives as a `DateTimeImmutable`. The +optional generic is the [PHP date format](https://www.php.net/manual/en/datetime.format.php); it +defaults to `DateTimeInterface::ATOM`. + +```php +/** + * @param DateTimeString $createdAt // 2025-09-10T12:09:01+00:00 + * @param DateTimeString<'Y-m-d'> $birthday // 2025-01-01 + */ +public function __construct( + public DateTimeImmutable $createdAt, + public DateTimeImmutable $birthday, +) {} +``` + +Both are `string` in TypeScript. On input the string is parsed with the format, on output the +`DateTimeInterface` is formatted back with it. + +**Prefer single quotes for the format.** Date formats escape literal characters with a backslash, +and the parser applies PHP's own string semantics: single quotes leave `\T` alone, while double +quotes resolve the full escape set. `"H:i\t"` is a tab, `'H:i\t'` is an escaped `t`. + +**Parsing is strict.** The value has to match the format exactly — the parsed date is formatted +again and compared to the input. Fields the format does not cover are zeroed rather than taken +from the current clock, so `DateTimeString<'Y-m-d'>` gives you midnight, not "today at 14:32". + +``` +DateTimeString<'Y-m-d'> + '2025-01-01' // 2025-01-01 00:00:00 + '2025-1-1' // rejected, single digit month and day + '2025-02-30' // rejected, would silently roll over to March 2nd + '2025-01-01T10:00:00' // rejected, trailing data +``` + +This also applies to `DateTimeImmutable`, `DateTime` and any other `DateTimeInterface` written +directly as a type. + +One consequence worth knowing: ATOM renders UTC as `+00:00`, so the `Z` suffix that +`Date.toISOString()` produces is *not* accepted by the default. Use the lowercase `p` specifier, +which renders UTC as `Z`: + +```php +/** @param DateTimeString<'Y-m-d\TH:i:sp'> $when */ // accepts 2025-09-10T12:09:01Z +``` + +## Value objects + +Wrapping an id or an email in its own class usually costs you the type on the wire: a plain class is +reflected property by property, so `UserId` would show up in TypeScript as `{value: number}`. Value +objects avoid that. A class implementing `StringValueObject` or `IntValueObject` is treated as its +backing primitive — a bare `string` or `number` in JSON — and hydrated back into the class on input. + +```php +use Le0daniel\PhpTsBindings\Contracts\Attributes\Brand; +use Le0daniel\PhpTsBindings\Contracts\ValueObjects\IntValueObject; + +#[Brand] +final readonly class UserId implements IntValueObject +{ + private function __construct(public int $value) {} + + public static function fromIntValue(int $value): static + { + if ($value < 1) { + throw new InvalidArgumentException("UserId must be positive, got {$value}"); + } + return new self($value); + } + + public function toIntValue(): int + { + return $this->value; + } +} +``` + +The two interfaces are: + +| Interface | Methods | JSON type | +|---|---|---| +| `StringValueObject` | `static fromStringValue(string): static`, `toStringValue(): string` | `string` | +| `IntValueObject` | `static fromIntValue(int): static`, `toIntValue(): int` | `number` | + +The methods carry the `...Value` suffix so the interfaces stay safe to add to a class that already +implements `Stringable` or declares its own `toString()`. + +Implementing the interface *is* the opt-in: unlike a plain class, a value object needs no +`#[Castable]` attribute and works for both input and output. Use it anywhere a type is parsed: + +```php +/** @return object{id: UserId, email: Email, tags: list} */ +``` + +**Rejecting values.** `fromStringValue()` / `fromIntValue()` may throw to reject input. The exception +is caught and reported as a validation issue on that field, with the original exception attached for +debugging — it never reaches the client as an internal error, and never escapes the executor. This +is where "is a valid email address" belongs, since no PHPStan type can say it. + +**Backed enums may opt in too.** A backed enum implementing `StringValueObject` serializes by its +backing value instead of the case-name default: + +```php +enum StatusEnum: string implements StringValueObject +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; + + public static function fromStringValue(string $value): static + { + return self::from($value); + } + + public function toStringValue(): string + { + return $this->value; + } +} +``` + +## Plain classes and `#[Castable]` + +A class that is *not* a value object is reflected property by property. That works for output with +no ceremony. For **input** the parser has to construct it, which it will only do when you say so: + +```php +use Le0daniel\PhpTsBindings\Contracts\Attributes\Castable; + +#[Castable] +final class CreateUserInput +{ + public string $username; + + /** @var positive-int */ + public int $age; + + /** @var non-empty-string */ + public string $email; +} +``` + +Without `#[Castable]`, using the class in an input position fails generation with an +`UnsupportedTypeException` rather than emitting a type the client could never satisfy. Interfaces +and abstract classes can never be input, whatever they are annotated with. + +`#[Castable]` takes an optional `ObjectCastStrategy` — `CONSTRUCTOR`, `ASSIGN_PROPERTIES` or +`NEVER`. Leave it out and the strategy is detected from the class. + +**Which to reach for.** Use a value object when the type *is* one primitive with rules attached (an +id, an email, a slug) — it stays a `string` or `number` on the wire and can reject values. Use +`#[Castable]` when the type is a record of several fields you want handed to you as an object rather +than an array. + +### Input and output shapes differ + +The same class does not always have the same shape in both directions, which is why every schema is +emitted twice: + +```php +#[Castable] +final readonly class UserSchema +{ + public function __construct( + public int $age, + protected string $email, + public string $username, + ) {} +} +``` + +```typescript +// IO::INPUT — email is a constructor parameter, so it must be supplied +{age:number;email:string;username:string;} +// IO::OUTPUT — email is protected, so it is not readable +{age:number;username:string;} +``` + +Use `#[Optional]` on a property or promoted parameter to let input omit it. It needs a default value +or a nullable type to fall back on. + +## Branded types + +Without a brand, `UserId` and any other int are interchangeable in TypeScript. Add `#[Brand]` and the +generated type becomes opaque. A brand is an **inline** intersection at every use site — it declares +no alias of its own: + +```php +#[Brand] // brand name defaults to lcfirst('UserId') => "userId" +#[Brand('customerId')] // or name it yourself +``` + +```typescript +// declared once in the generated types file: +declare const __brand: unique symbol; +export type Brand = {readonly [__brand]: TBrand;}; + +// at every use site: +declare function getUser(id: (number & Brand<"userId">)): void; +getUser(1); // Type error: number is not assignable to the branded type +``` + +`#[Brand]` works on any class, interface, enum or value object — an object shape simply becomes +`({...} & Brand<"...">)`. Combine it with `#[Named]` to export the branded type once by name: + +```php +#[Brand] #[Named] +final readonly class UserId implements IntValueObject { /* ... */ } +``` + +```typescript +export type UserId = (number & Brand<"userId">); +``` + +## Named types + +`#[Named]` exports a class, interface, enum or value object as a named type alias: instead of +inlining the structure at every use site, the generator declares it once and references it by name. + +```php +#[Named] // alias defaults to the class base name: App\Data\Order => Order +#[Named('CustomOrder')] // or name it yourself +#[Named(name: Naming::alias(...))] // or compute it, per direction (see below) +final class Order +{ + public Customer $customer; // Customer may itself be #[Named] — aliases nest recursively + public UserId $id; // and mix freely with brands +} +``` + +```typescript +export type Customer = {email:(string & Brand<"email">);name:string;}; +export type Order = {customer:Customer;id:(number & Brand<"customerId">);}; +``` + +**One name covers both directions.** A class can legitimately have a different input shape than +output shape — constructor-only parameters, output-only properties — and one alias cannot describe +both, because every alias is declared exactly once in the generated types file. A class whose shapes +diverge under a single name is rejected during schema generation, naming the property that made them +differ: + +```php +#[Named] #[Castable] +final class Article +{ + public string $slug; // output only + public function __construct(public string $title, string $draft) { /* ... */ } +} // $draft is input only +``` + +> `#[Named]` on `App\Data\Article` resolves to one alias `Article` for both directions, but its input +> and output shapes differ: `draft` is input only. + +Give each shape its own alias with a naming closure, which receives the direction — see +[computing the name yourself](#computing-the-name-yourself). + +The same conflicting-alias error protects against two classes resolving to the same alias with +different shapes anywhere in a run. A handful of names the generated types file always declares are +rejected outright: + +`Brand`, `Success`, `Failure`, `Result`, `OperationNamespaces`, `WithClientDirectives`, +`SPAClientDirectives`, `ClientDirectives`, `ClientToast`, `ClientRedirect`, `ClientInvalidation`, +`TYPE_MAP`. + +Brands and names are pure code generation metadata with zero runtime impact: values travel the wire +in their plain shape, and the metadata is stripped from cached ASTs entirely — TypeScript +generation always runs on freshly parsed schemas. + +## Sharing one declaration across value objects + +A family of ids usually shares an interface or a base class. Declare the attributes once there and +every value object in the family picks them up: + +```php +#[Brand] +#[Named] +interface IntId extends IntValueObject {} + +final readonly class AccountId implements IntId { /* ... */ } +final readonly class BrandId implements IntId { /* ... */ } +``` + +```typescript +export type AccountId = (number & Brand<"accountId">); +export type BrandId = (number & Brand<"brandId">); +``` + +The brand and the alias are derived from the **concrete** class, not from the one carrying the +attribute — which is the whole point: `AccountId` and `BrandId` share a declaration but stay +mutually unassignable in TypeScript. + +Each attribute is resolved on its own, in this order: + +1. **The class itself.** A local declaration always wins, and declaring both attributes locally + means nothing else is inspected. A local `#[Brand]` combines fine with an inherited `#[Named]`. +2. **The direct parent class,** abstract or concrete. +3. **The directly declared interfaces.** Two of them declaring the same attribute is an ambiguity + the library refuses to resolve — it fails instead of picking one. Declare the attribute on the + class itself to say which applies. + +The remaining caveats are worth reading, because each is silent otherwise: + +- **Value objects only.** Enums and plain classes read the attributes from the class itself and + nothing else, so implementing a `#[Named]` interface names nothing. +- **One level up, and no further.** `interface DeepId extends IntId` does not pass `IntId`'s + attributes on to *its* implementors, and neither does a grandparent class. Redeclare them on the + intermediate type when you want them to keep travelling. +- **An inherited declaration cannot carry a fixed name.** `#[Brand('id')]` on `IntId` would give + every implementor the brand `"id"` and collapse them into one type, so it is rejected at parse + time. Drop the name to derive it per class, or compute one with a closure — see below. +- **A concrete parent keeps a brand of its own.** `#[Brand]` on a non-abstract `BaseId` brands + `BaseId` as `baseId` *and* its children after their own names — one declaration, distinct types. + +## Computing the name yourself + +Both attributes accept a closure instead of a string, called with the class being emitted. It earns +its keep twice over. + +First, it is what makes a *shared* declaration flexible: an inherited attribute cannot carry a fixed +name, yet it can carry a rule each implementor runs against its own class name. + +```php +final class Naming +{ + public static function alias(string $className): string + { + return explode('\\', $className) |> array_last(...) |> ucfirst(...); + } +} + +#[Brand] +#[Named(name: Naming::alias(...))] +interface IntId extends IntValueObject {} +``` + +Second, `#[Named]` calls its closure **once per direction** and hands it the `IO`, which is the only +way to give a class with two shapes two aliases: + +```php +use Le0daniel\PhpTsBindings\Data\IO; + +final class AliasNaming +{ + public static function perDirection(string $className, IO $io): string + { + $base = explode('\\', $className) |> array_last(...); + return $io === IO::INPUT ? "{$base}Input" : $base; + } +} + +#[Named(name: AliasNaming::perDirection(...))] +#[Castable] +final class Article { /* ... */ } +``` + +```typescript +export type Article = {slug:string;title:string;}; +export type ArticleInput = {draft:string;title:string;}; +``` + +A closure that ignores its second argument — like `Naming::alias()` above — simply names both +directions the same, which is what almost every type wants. `#[Brand]`'s closure takes only the +class name: a brand tags one wire value, so it is the same in both directions by construction. + +> **PHP only accepts first-class callable syntax here.** A closure literal in an attribute argument +> — `#[Named(name: static fn(string $c) => ucfirst($c))]` — does not compile: PHP reports +> *"Constant expression contains invalid operations"*. Point the closure at a named function or +> static method instead, as above. + +The closure runs at parse time, never at runtime, and its result still has to be a valid TypeScript +identifier — an invalid one fails generation the same way a bad string literal does. + +## Validating the AST + +By default the parsed AST is not validated, so it is possible to build one that is internally +invalid — an intersection of types that cannot intersect, for example. Walk and check it with: + +```php +use Le0daniel\PhpTsBindings\Parser\AstValidator; + +AstValidator::validate($node); +``` + +Code generation does this for every operation already, so a schema that survives +`operations:codegen` is valid. Call it yourself when you parse types outside the server. diff --git a/src/Adapters/Laravel/Commands/ListCommand.php b/src/Adapters/Laravel/Commands/ListCommand.php index 50300a9..2cb85d2 100644 --- a/src/Adapters/Laravel/Commands/ListCommand.php +++ b/src/Adapters/Laravel/Commands/ListCommand.php @@ -15,7 +15,7 @@ final class ListCommand extends Command { protected $signature = 'operations:list'; - protected $description = 'Send a marketing email to a user'; + protected $description = 'List all registered queries and commands'; public function handle( #[Give(LaravelServiceProvider::DEFAULT_SERVER)] Server $server, diff --git a/src/Adapters/Laravel/Commands/OptimizeCommand.php b/src/Adapters/Laravel/Commands/OptimizeCommand.php index fe7bbec..f3dac50 100644 --- a/src/Adapters/Laravel/Commands/OptimizeCommand.php +++ b/src/Adapters/Laravel/Commands/OptimizeCommand.php @@ -5,6 +5,7 @@ use Illuminate\Console\Command; use Illuminate\Contracts\Foundation\Application; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider; +use Le0daniel\PhpTsBindings\Adapters\Laravel\Utils\ArtisanOptions; use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Server\Operations\CachedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; @@ -27,26 +28,34 @@ public function handle(Application $application): int $registry = $server->registry; if (!$registry instanceof EagerlyLoadedOperationRegistry) { - throw new SchemaException('Cannot optimize a registry that is not a JustInTimeDiscoveryRegistry'); + throw new SchemaException('Cannot optimize a registry that is not an EagerlyLoadedOperationRegistry'); } - $idLength = $this->hasOption('id-length') - ? (int) $this->option('id-length') - : config('operations.cache.idLength'); + $idLength = ArtisanOptions::asPositiveInt( + $this->option('id-length'), + config('operations.cache.idLength'), + ); - if (!is_int($idLength) || $idLength < 1) { - throw new SchemaException('Invalid id-length option'); + if ($idLength === null) { + $this->error('The id-length must be a positive integer. Pass --id-length or set operations.cache.idLength.'); + return 1; } + $cacheFile = base_path('bootstrap/cache/operations.php'); + try { - CachedOperationRegistry::writeToCache( - $registry, - base_path('bootstrap/cache/operations.php'), - idLength: (int) $this->option('id-length'), - ); - require base_path('bootstrap/cache/operations.php'); + CachedOperationRegistry::writeToCache($registry, $cacheFile, idLength: $idLength); + + // Requiring the file proves it parses and that the ids it generated do not collide, + // rather than leaving a broken cache behind for the next request to trip over. + require $cacheFile; } catch (Throwable $e) { - unlink(base_path('bootstrap/cache/operations.php')); + $this->error("Failed to optimize operations: {$e->getMessage()}"); + + // The write may never have happened, in which case there is nothing to clean up. + if (file_exists($cacheFile)) { + unlink($cacheFile); + } return 1; } diff --git a/src/Adapters/Laravel/Utils/ArtisanOptions.php b/src/Adapters/Laravel/Utils/ArtisanOptions.php index 1a91916..ca65fdd 100644 --- a/src/Adapters/Laravel/Utils/ArtisanOptions.php +++ b/src/Adapters/Laravel/Utils/ArtisanOptions.php @@ -48,4 +48,29 @@ public static function asString(mixed $option): ?string { return is_string($option) ? $option : null; } + + /** + * An option that must be a positive integer, falling back to a configured default when it was + * not passed. + * + * Absence is read off the value rather than from Command::hasOption(), which is true whenever an + * option is *declared* and so can never tell you whether the user typed it. Getting that wrong + * is what made the fallback unreachable and turned a plain `operations:optimize` into a failure. + * + * Null means "no usable value", never a silently coerced one: an absent option, a flag, a + * repeated option, a float and a non-positive number all come back as null so the caller can say + * so instead of writing a cache with an id length of 0. + */ + public static function asPositiveInt(mixed $option, mixed $fallback): ?int + { + $value = $option ?? $fallback; + + $int = match (true) { + is_int($value) => $value, + is_string($value) && preg_match('/^-?\d+$/', $value) === 1 => (int)$value, + default => null, + }; + + return $int !== null && $int > 0 ? $int : null; + } } diff --git a/src/Contracts/Attributes/Command.php b/src/Contracts/Attributes/Command.php index 89ef733..575870e 100644 --- a/src/Contracts/Attributes/Command.php +++ b/src/Contracts/Attributes/Command.php @@ -6,12 +6,18 @@ use Le0daniel\PhpTsBindings\Utils\Strings; use UnitEnum; +/** + * Exposes a method as a write operation, reachable over POST. + * + * The namespace groups operations and becomes the generated TypeScript module; it defaults to + * 'global'. The name defaults to the method name. Together they form the operation's fully + * qualified name, which the OperationKeyGenerator turns into the key the client actually calls. + */ #[Attribute(Attribute::TARGET_METHOD)] final readonly class Command { public function __construct( public string|UnitEnum|null $namespace = null, - public ?string $description = null, public ?string $name = null, ) { diff --git a/src/Contracts/Attributes/Query.php b/src/Contracts/Attributes/Query.php index 18b93c2..e51c3ad 100644 --- a/src/Contracts/Attributes/Query.php +++ b/src/Contracts/Attributes/Query.php @@ -6,12 +6,18 @@ use Le0daniel\PhpTsBindings\Utils\Strings; use UnitEnum; +/** + * Exposes a method as a read operation, reachable over GET. + * + * The namespace groups operations and becomes the generated TypeScript module; it defaults to + * 'global'. The name defaults to the method name. Together they form the operation's fully + * qualified name, which the OperationKeyGenerator turns into the key the client actually calls. + */ #[Attribute(Attribute::TARGET_METHOD)] final readonly class Query { public function __construct( public string|UnitEnum|null $namespace = null, - public ?string $description = null, public ?string $name = null, ) { diff --git a/src/Reflection/AttributesReflector.php b/src/Reflection/AttributesReflector.php index a863c61..f0d3cde 100644 --- a/src/Reflection/AttributesReflector.php +++ b/src/Reflection/AttributesReflector.php @@ -49,4 +49,4 @@ public function firstInstanceOrNull(string $attributeClass): ?object /** @var T|null */ return $reflection?->newInstance(); } -} \ No newline at end of file +} diff --git a/src/Server/Client/OperationSPAClient.php b/src/Server/Client/OperationSPAClient.php index 97228cf..8d76d75 100644 --- a/src/Server/Client/OperationSPAClient.php +++ b/src/Server/Client/OperationSPAClient.php @@ -4,7 +4,6 @@ use Le0daniel\PhpTsBindings\Contracts\SerializableClient; use Le0daniel\PhpTsBindings\Server\Data\Toast; -use Le0daniel\PhpTsBindings\Utils\Dicts; use Le0daniel\PhpTsBindings\Utils\Strings; use Override; use UnitEnum; diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index 7c3a940..29c6ae5 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -18,7 +18,6 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Server; use Mockery; -use Symfony\Component\HttpFoundation\InputBag; test('handle successful http query request', function () { // Arrange @@ -29,8 +28,7 @@ $operationRegistry = Mockery::mock(OperationRegistry::class); $exceptionHandler = Mockery::mock(ExceptionHandler::class); $app = Mockery::mock(Application::class); - $request = Mockery::mock(Request::class); - $request->query = new InputBag($inputData); + $request = Request::create('/query/docs.method', 'GET', $inputData); $operationDefinition = new Definition( OperationType::QUERY, @@ -62,7 +60,6 @@ public function someMethod(array $input, null $context, Client $client): array $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); - $request->shouldReceive('header')->with(LaravelHttpController::CLIENT_ID_HEADER)->andReturnNull(); $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); $server = new Server($operationRegistry, $app); @@ -94,8 +91,7 @@ public function someMethod(array $input, null $context, Client $client): array $operationRegistry = Mockery::mock(OperationRegistry::class); $exceptionHandler = Mockery::mock(ExceptionHandler::class); $app = Mockery::mock(Application::class); - $request = Mockery::mock(Request::class); - $request->query = new InputBag($inputData); + $request = Request::create('/query/docs.method', 'GET', $inputData); $operationDefinition = new Definition( OperationType::QUERY, @@ -125,7 +121,7 @@ public function someMethod(array $input, null $context, Client $client): array $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); - $request->shouldReceive('header')->with(LaravelHttpController::CLIENT_ID_HEADER)->andReturn('operations-spa'); + $request->headers->set(LaravelHttpController::CLIENT_ID_HEADER, 'operations-spa'); $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); $controller = new LaravelHttpController( @@ -161,8 +157,7 @@ public function someMethod(array $input, null $context, Client $client): array $operationRegistry = Mockery::mock(OperationRegistry::class); $exceptionHandler = Mockery::mock(ExceptionHandler::class); $app = Mockery::mock(Application::class); - $request = Mockery::mock(Request::class); - $request->query = new InputBag($inputData); + $request = Request::create('/query/docs.method', 'GET', $inputData); $operationDefinition = new Definition( OperationType::QUERY, @@ -195,7 +190,6 @@ public function someMethod(array $input, null $context, Client $client): array $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); $exceptionHandler->shouldReceive('report')->with(InvalidInputException::class); - $request->shouldReceive('header')->with(LaravelHttpController::CLIENT_ID_HEADER)->andReturnNull(); $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); $repository->shouldReceive('get')->with('app.debug')->andReturn(false); diff --git a/tests/Unit/Adapters/Laravel/ArtisanOptionsTest.php b/tests/Unit/Adapters/Laravel/ArtisanOptionsTest.php new file mode 100644 index 0000000..8c66511 --- /dev/null +++ b/tests/Unit/Adapters/Laravel/ArtisanOptionsTest.php @@ -0,0 +1,47 @@ +toBe(['a', 'b', 'c']) + ->and(ArtisanOptions::expandOptionsArrayCommaSeparated('a, b ,a'))->toBe(['a', 'b']) + ->and(ArtisanOptions::expandOptionsArrayCommaSeparated(null))->toBe([]) + ->and(ArtisanOptions::expandOptionsArrayCommaSeparated(true))->toBe([]); +}); + +test('asString only accepts a single string', function () { + expect(ArtisanOptions::asString('value'))->toBe('value') + ->and(ArtisanOptions::asString(null))->toBeNull() + ->and(ArtisanOptions::asString(true))->toBeNull() + ->and(ArtisanOptions::asString(['a']))->toBeNull(); +}); + +/** + * Command::hasOption() is true whenever an option is *declared*, so it cannot answer "did the user + * pass this?". Absence has to be read off the value, which is what makes the fallback reachable. + */ +test('an absent option falls back to the configured default', function () { + expect(ArtisanOptions::asPositiveInt(null, 10))->toBe(10) + ->and(ArtisanOptions::asPositiveInt(null, '10'))->toBe(10); +}); + +test('a passed option overrides the configured default', function () { + expect(ArtisanOptions::asPositiveInt('4', 10))->toBe(4) + ->and(ArtisanOptions::asPositiveInt(4, 10))->toBe(4); +}); + +test('anything that is not a positive integer is rejected rather than coerced', function (mixed $option, mixed $fallback) { + expect(ArtisanOptions::asPositiveInt($option, $fallback))->toBeNull(); +})->with([ + 'both absent' => [null, null], + 'zero' => ['0', null], + 'negative' => ['-1', null], + 'not numeric' => ['abc', null], + 'float' => ['1.5', null], + 'a flag rather than a value' => [true, null], + 'repeated option' => [['4'], null], + 'unusable fallback' => [null, 'abc'], + 'zero fallback' => [null, 0], +]); From 6c7c0e07447cf0da09030edbc747b7e4ec1d5278 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 3 Aug 2026 12:15:02 +0200 Subject: [PATCH 034/101] Move `Parser\Exceptions` to `Parser\Data\Exceptions`; update references across project and unit tests. --- src/CodeGen/Utils/Paths.php | 7 ++++++- src/Parser/ASTOptimizer.php | 4 ++-- src/Parser/AstValidator.php | 2 +- src/Parser/Consumers/AliasConsumer.php | 2 +- src/Parser/Consumers/ArrayConsumer.php | 2 +- src/Parser/Consumers/BuiltInLeafConsumer.php | 2 +- src/Parser/Consumers/ClassConstConsumer.php | 2 +- src/Parser/Consumers/IntConsumer.php | 4 ++-- src/Parser/Consumers/InteractsWithGenerics.php | 2 +- src/Parser/Consumers/StructConsumer.php | 2 +- src/Parser/Consumers/UserDefinedObjectConsumer.php | 4 ++-- src/Parser/Consumers/UtilsConsumer.php | 2 +- .../{ => Data}/Exceptions/InvalidSyntaxException.php | 2 +- src/Parser/{ => Data}/Exceptions/ParserException.php | 2 +- .../Exceptions/UnknownTypeKeyException.php | 2 +- src/Parser/Definition/Lexemes.php | 2 +- src/Parser/Definition/ParserState.php | 4 ++-- src/Parser/Helpers/ParsingScope.php | 2 +- .../Exceptions/UnexpectedCharacterException.php | 2 +- src/Parser/Lexer/Lexer.php | 2 +- src/Parser/Nodes/Data/LiteralType.php | 3 ++- src/Parser/Nodes/IntersectionNode.php | 2 +- src/Parser/Nodes/Leaf/LiteralNode.php | 2 +- src/Parser/Nodes/MetadataNode.php | 2 +- src/Parser/Nodes/StructNode.php | 2 +- src/Parser/Nodes/TupleNode.php | 2 +- src/Parser/Nodes/UnionNode.php | 2 +- src/Parser/Registry/CachedTypeRegistry.php | 2 +- src/Parser/TypeParser.php | 2 +- src/Reflection/AttributesReflector.php | 2 +- src/Reflection/FileReflector.php | 2 +- src/Reflection/MetadataAttributes.php | 2 +- src/Reflection/TypeReflector.php | 2 +- src/Utils/PHPExport.php | 2 +- src/Utils/Reflections.php | 2 +- .../CodeGen/TypescriptServerCodeGeneratorTest.php | 2 +- tests/Unit/Contracts/ExceptionHierarchyTest.php | 6 +++--- tests/Unit/Parser/MetadataEliminationTest.php | 2 +- tests/Unit/Parser/NamedTypeTest.php | 12 ++++++------ tests/Unit/Parser/OptimizedCodeShapeTest.php | 2 +- tests/Unit/Parser/TypeParserTest.php | 2 +- tests/Unit/Utils/PHPExportTest.php | 2 +- 42 files changed, 59 insertions(+), 53 deletions(-) rename src/Parser/{ => Data}/Exceptions/InvalidSyntaxException.php (62%) rename src/Parser/{ => Data}/Exceptions/ParserException.php (90%) rename src/Parser/{ => Data}/Exceptions/UnknownTypeKeyException.php (94%) diff --git a/src/CodeGen/Utils/Paths.php b/src/CodeGen/Utils/Paths.php index da81133..7473ebe 100644 --- a/src/CodeGen/Utils/Paths.php +++ b/src/CodeGen/Utils/Paths.php @@ -4,8 +4,13 @@ final readonly class Paths { + public static function relative(string $path): string + { + return "./{$path}"; + } + public static function libImport(string $name): string { - return "./lib/{$name}"; + return self::relative("lib/{$name}"); } } \ No newline at end of file diff --git a/src/Parser/ASTOptimizer.php b/src/Parser/ASTOptimizer.php index 1dbf010..aaec93a 100644 --- a/src/Parser/ASTOptimizer.php +++ b/src/Parser/ASTOptimizer.php @@ -6,8 +6,8 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\Constraint; use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; -use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; -use Le0daniel\PhpTsBindings\Parser\Exceptions\UnknownTypeKeyException; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\ParserException; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\UnknownTypeKeyException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; diff --git a/src/Parser/AstValidator.php b/src/Parser/AstValidator.php index e6124b5..a9c84c8 100644 --- a/src/Parser/AstValidator.php +++ b/src/Parser/AstValidator.php @@ -5,7 +5,7 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; -use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\IntersectionNode; diff --git a/src/Parser/Consumers/AliasConsumer.php b/src/Parser/Consumers/AliasConsumer.php index 30d74ba..040c361 100644 --- a/src/Parser/Consumers/AliasConsumer.php +++ b/src/Parser/Consumers/AliasConsumer.php @@ -4,9 +4,9 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\TypeParser; diff --git a/src/Parser/Consumers/ArrayConsumer.php b/src/Parser/Consumers/ArrayConsumer.php index bd33231..27e0b45 100644 --- a/src/Parser/Consumers/ArrayConsumer.php +++ b/src/Parser/Consumers/ArrayConsumer.php @@ -5,9 +5,9 @@ use Le0daniel\PhpTsBindings\Parser\Constraints\ListLength; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; -use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\MixedNode; diff --git a/src/Parser/Consumers/BuiltInLeafConsumer.php b/src/Parser/Consumers/BuiltInLeafConsumer.php index 6d6ad9d..2c3b0f4 100644 --- a/src/Parser/Consumers/BuiltInLeafConsumer.php +++ b/src/Parser/Consumers/BuiltInLeafConsumer.php @@ -10,8 +10,8 @@ use Le0daniel\PhpTsBindings\Parser\Constraints\UppercaseString; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\BoolNode; diff --git a/src/Parser/Consumers/ClassConstConsumer.php b/src/Parser/Consumers/ClassConstConsumer.php index f26f499..cc07ace 100644 --- a/src/Parser/Consumers/ClassConstConsumer.php +++ b/src/Parser/Consumers/ClassConstConsumer.php @@ -3,9 +3,9 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; -use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; diff --git a/src/Parser/Consumers/IntConsumer.php b/src/Parser/Consumers/IntConsumer.php index 6e0c52c..a230dcf 100644 --- a/src/Parser/Consumers/IntConsumer.php +++ b/src/Parser/Consumers/IntConsumer.php @@ -2,16 +2,16 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; +use Le0daniel\PhpTsBindings\Parser\Constraints\IntRange; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Definition\Lexemes; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Parser\Constraints\IntRange; use Override; final readonly class IntConsumer implements TypeConsumer diff --git a/src/Parser/Consumers/InteractsWithGenerics.php b/src/Parser/Consumers/InteractsWithGenerics.php index 4dbac34..452d795 100644 --- a/src/Parser/Consumers/InteractsWithGenerics.php +++ b/src/Parser/Consumers/InteractsWithGenerics.php @@ -3,8 +3,8 @@ namespace Le0daniel\PhpTsBindings\Parser\Consumers; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\TypeParser; diff --git a/src/Parser/Consumers/StructConsumer.php b/src/Parser/Consumers/StructConsumer.php index e59d77c..eba22b5 100644 --- a/src/Parser/Consumers/StructConsumer.php +++ b/src/Parser/Consumers/StructConsumer.php @@ -4,9 +4,9 @@ use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Definition\Lexemes; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\StructPhpType; use Le0daniel\PhpTsBindings\Parser\Nodes\PropertyNode; diff --git a/src/Parser/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Consumers/UserDefinedObjectConsumer.php index 463783b..966d992 100644 --- a/src/Parser/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Consumers/UserDefinedObjectConsumer.php @@ -6,9 +6,9 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Optional; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; -use Le0daniel\PhpTsBindings\Parser\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; diff --git a/src/Parser/Consumers/UtilsConsumer.php b/src/Parser/Consumers/UtilsConsumer.php index 0fda2eb..cb5c8c1 100644 --- a/src/Parser/Consumers/UtilsConsumer.php +++ b/src/Parser/Consumers/UtilsConsumer.php @@ -5,8 +5,8 @@ use DateTimeImmutable; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Contracts\TypeConsumer; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; -use Le0daniel\PhpTsBindings\Parser\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; diff --git a/src/Parser/Exceptions/InvalidSyntaxException.php b/src/Parser/Data/Exceptions/InvalidSyntaxException.php similarity index 62% rename from src/Parser/Exceptions/InvalidSyntaxException.php rename to src/Parser/Data/Exceptions/InvalidSyntaxException.php index 898ce56..c83d9b4 100644 --- a/src/Parser/Exceptions/InvalidSyntaxException.php +++ b/src/Parser/Data/Exceptions/InvalidSyntaxException.php @@ -1,6 +1,6 @@ Date: Mon, 3 Aug 2026 12:15:18 +0200 Subject: [PATCH 035/101] Move `Parser\Consumers` to `Parser\Helpers\Consumers`; update namespace references across project. --- .../{ => Helpers}/Consumers/AliasConsumer.php | 2 +- .../{ => Helpers}/Consumers/ArrayConsumer.php | 2 +- .../Consumers/BuiltInLeafConsumer.php | 2 +- .../Consumers/ClassConstConsumer.php | 2 +- .../Consumers/DateTimeConsumer.php | 2 +- .../{ => Helpers}/Consumers/EnumConsumer.php | 2 +- .../{ => Helpers}/Consumers/IntConsumer.php | 2 +- .../Consumers/InteractsWithGenerics.php | 2 +- .../Consumers/LiteralConsumer.php | 2 +- .../Consumers/StructConsumer.php | 2 +- .../Consumers/UserDefinedObjectConsumer.php | 2 +- .../{ => Helpers}/Consumers/UtilsConsumer.php | 2 +- .../Consumers/ValueObjectConsumer.php | 2 +- src/Parser/TypeParser.php | 24 +++++++++---------- 14 files changed, 25 insertions(+), 25 deletions(-) rename src/Parser/{ => Helpers}/Consumers/AliasConsumer.php (97%) rename src/Parser/{ => Helpers}/Consumers/ArrayConsumer.php (99%) rename src/Parser/{ => Helpers}/Consumers/BuiltInLeafConsumer.php (98%) rename src/Parser/{ => Helpers}/Consumers/ClassConstConsumer.php (97%) rename src/Parser/{ => Helpers}/Consumers/DateTimeConsumer.php (96%) rename src/Parser/{ => Helpers}/Consumers/EnumConsumer.php (95%) rename src/Parser/{ => Helpers}/Consumers/IntConsumer.php (97%) rename src/Parser/{ => Helpers}/Consumers/InteractsWithGenerics.php (96%) rename src/Parser/{ => Helpers}/Consumers/LiteralConsumer.php (96%) rename src/Parser/{ => Helpers}/Consumers/StructConsumer.php (98%) rename src/Parser/{ => Helpers}/Consumers/UserDefinedObjectConsumer.php (99%) rename src/Parser/{ => Helpers}/Consumers/UtilsConsumer.php (99%) rename src/Parser/{ => Helpers}/Consumers/ValueObjectConsumer.php (98%) diff --git a/src/Parser/Consumers/AliasConsumer.php b/src/Parser/Helpers/Consumers/AliasConsumer.php similarity index 97% rename from src/Parser/Consumers/AliasConsumer.php rename to src/Parser/Helpers/Consumers/AliasConsumer.php index 040c361..b53cb11 100644 --- a/src/Parser/Consumers/AliasConsumer.php +++ b/src/Parser/Helpers/Consumers/AliasConsumer.php @@ -1,6 +1,6 @@ Date: Mon, 3 Aug 2026 12:18:42 +0200 Subject: [PATCH 036/101] Restructure namespaces by moving `Parser` components to `Parser\Helpers`; update all references and unit tests accordingly. --- README.md | 3 +-- docs/types.md | 2 +- src/CodeGen/TypescriptServerCodeGenerator.php | 2 +- src/Parser/Contracts/TypeConsumer.php | 2 +- src/Parser/{ => Helpers}/ASTOptimizer.php | 4 ++-- src/Parser/{ => Helpers}/AstValidator.php | 2 +- src/Parser/{ => Helpers}/Constraints/IntRange.php | 2 +- .../{ => Helpers}/Constraints/ListLength.php | 2 +- .../{ => Helpers}/Constraints/LowercaseString.php | 2 +- .../{ => Helpers}/Constraints/NonEmptyString.php | 2 +- .../{ => Helpers}/Constraints/NonFalsyString.php | 2 +- .../{ => Helpers}/Constraints/NumericString.php | 2 +- .../{ => Helpers}/Constraints/UppercaseString.php | 2 +- .../{ => Helpers}/Constraints/ValidatesString.php | 2 +- src/Parser/Helpers/Consumers/AliasConsumer.php | 2 +- src/Parser/Helpers/Consumers/ArrayConsumer.php | 4 ++-- .../Helpers/Consumers/BuiltInLeafConsumer.php | 14 +++++++------- .../Helpers/Consumers/ClassConstConsumer.php | 2 +- src/Parser/Helpers/Consumers/DateTimeConsumer.php | 2 +- src/Parser/Helpers/Consumers/EnumConsumer.php | 2 +- src/Parser/Helpers/Consumers/IntConsumer.php | 6 +++--- .../Helpers/Consumers/InteractsWithGenerics.php | 2 +- src/Parser/Helpers/Consumers/LiteralConsumer.php | 4 ++-- src/Parser/Helpers/Consumers/StructConsumer.php | 4 ++-- .../Consumers/UserDefinedObjectConsumer.php | 2 +- src/Parser/Helpers/Consumers/UtilsConsumer.php | 2 +- .../Helpers/Consumers/ValueObjectConsumer.php | 2 +- src/Parser/{Definition => Helpers}/ParserState.php | 3 +-- .../{ => Helpers}/Registry/CachedTypeRegistry.php | 2 +- src/Parser/TypeParser.php | 2 +- src/Parser/{Definition => Utils}/Lexemes.php | 2 +- src/Server/Operations/CachedOperationRegistry.php | 2 +- tests/Feature/FullSchemaTest.php | 4 ++-- tests/Pest.php | 12 ++++++------ tests/Unit/Parser/ASTOptimizerTest.php | 4 ++-- tests/Unit/Parser/Constraints/IntRangeTest.php | 2 +- tests/Unit/Parser/Constraints/ListLengthTest.php | 2 +- tests/Unit/Parser/Data/ParsingContextTest.php | 2 +- tests/Unit/Parser/Data/Stubs/MyUserClass.php | 2 +- tests/Unit/Parser/Definition/LexemesTest.php | 2 +- tests/Unit/Parser/MetadataEliminationTest.php | 6 +++--- tests/Unit/Parser/NamedTypeTest.php | 6 +++--- tests/Unit/Parser/NodeDiagnosticStringTest.php | 2 +- tests/Unit/Parser/OptimizeAndWriteToFileTest.php | 4 ++-- tests/Unit/Parser/OptimizedCodeShapeTest.php | 4 ++-- tests/Unit/Parser/StructNodeOrderTest.php | 4 ++-- tests/Unit/Parser/TypeParserTest.php | 12 ++++++------ tests/Unit/Typescript/NamedTypesTest.php | 4 ++-- tests/Unit/Typescript/OptimizedAstTest.php | 4 ++-- 49 files changed, 82 insertions(+), 84 deletions(-) rename src/Parser/{ => Helpers}/ASTOptimizer.php (98%) rename src/Parser/{ => Helpers}/AstValidator.php (97%) rename src/Parser/{ => Helpers}/Constraints/IntRange.php (97%) rename src/Parser/{ => Helpers}/Constraints/ListLength.php (97%) rename src/Parser/{ => Helpers}/Constraints/LowercaseString.php (95%) rename src/Parser/{ => Helpers}/Constraints/NonEmptyString.php (95%) rename src/Parser/{ => Helpers}/Constraints/NonFalsyString.php (95%) rename src/Parser/{ => Helpers}/Constraints/NumericString.php (95%) rename src/Parser/{ => Helpers}/Constraints/UppercaseString.php (95%) rename src/Parser/{ => Helpers}/Constraints/ValidatesString.php (93%) rename src/Parser/{Definition => Helpers}/ParserState.php (97%) rename src/Parser/{ => Helpers}/Registry/CachedTypeRegistry.php (96%) rename src/Parser/{Definition => Utils}/Lexemes.php (98%) diff --git a/README.md b/README.md index 38c6a49..0ca402d 100644 --- a/README.md +++ b/README.md @@ -493,8 +493,7 @@ CI to catch a frontend that has drifted from the backend. Outside Laravel, or for schemas that are not operations, the same optimizer is available directly: ```php -use Le0daniel\PhpTsBindings\Parser\ASTOptimizer; -use Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry; +use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer;use Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry; new ASTOptimizer()->optimizeAndWriteToFile('asts.php', [ 'MyClass@method@input' => $inputAst, diff --git a/docs/types.md b/docs/types.md index eeac003..d1cb51d 100644 --- a/docs/types.md +++ b/docs/types.md @@ -496,7 +496,7 @@ By default the parsed AST is not validated, so it is possible to build one that invalid — an intersection of types that cannot intersect, for example. Walk and check it with: ```php -use Le0daniel\PhpTsBindings\Parser\AstValidator; +use Le0daniel\PhpTsBindings\Parser\Helpers\AstValidator; AstValidator::validate($node); ``` diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index ef4dd61..0d121f4 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -11,7 +11,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\Data\IO; -use Le0daniel\PhpTsBindings\Parser\AstValidator; +use Le0daniel\PhpTsBindings\Parser\Helpers\AstValidator; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; diff --git a/src/Parser/Contracts/TypeConsumer.php b/src/Parser/Contracts/TypeConsumer.php index dc5a16e..c8fa3ce 100644 --- a/src/Parser/Contracts/TypeConsumer.php +++ b/src/Parser/Contracts/TypeConsumer.php @@ -2,7 +2,7 @@ namespace Le0daniel\PhpTsBindings\Parser\Contracts; -use Le0daniel\PhpTsBindings\Parser\Definition\ParserState; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParserState; use Le0daniel\PhpTsBindings\Parser\TypeParser; interface TypeConsumer diff --git a/src/Parser/ASTOptimizer.php b/src/Parser/Helpers/ASTOptimizer.php similarity index 98% rename from src/Parser/ASTOptimizer.php rename to src/Parser/Helpers/ASTOptimizer.php index aaec93a..e5d19b8 100644 --- a/src/Parser/ASTOptimizer.php +++ b/src/Parser/Helpers/ASTOptimizer.php @@ -1,6 +1,6 @@ generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); expect( @@ -138,7 +138,7 @@ function executeParse(NodeInterface|string $node, mixed $data, ParsingOptions $o $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $optimizedAst = $registry->get('node'); @@ -169,7 +169,7 @@ function executeSerialize(NodeInterface|string $node, mixed $data, Serialization $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $optimizedAst = $registry->get('node'); @@ -199,7 +199,7 @@ function validateAst(NodeInterface $node): void $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $optimizedAst = $registry->get('node'); diff --git a/tests/Unit/Parser/ASTOptimizerTest.php b/tests/Unit/Parser/ASTOptimizerTest.php index 0eb5ba8..fad0179 100644 --- a/tests/Unit/Parser/ASTOptimizerTest.php +++ b/tests/Unit/Parser/ASTOptimizerTest.php @@ -3,11 +3,11 @@ use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\Success; use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; -use Le0daniel\PhpTsBindings\Parser\ASTOptimizer; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer; +use Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\LiteralNode; -use Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry; use Le0daniel\PhpTsBindings\Parser\TypeParser; /** diff --git a/tests/Unit/Parser/Constraints/IntRangeTest.php b/tests/Unit/Parser/Constraints/IntRangeTest.php index bd401ba..6739cdc 100644 --- a/tests/Unit/Parser/Constraints/IntRangeTest.php +++ b/tests/Unit/Parser/Constraints/IntRangeTest.php @@ -4,7 +4,7 @@ use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Executor\Data\Issues; -use Le0daniel\PhpTsBindings\Parser\Constraints\IntRange; +use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\IntRange; beforeEach(function () { $this->context = new Context(); diff --git a/tests/Unit/Parser/Constraints/ListLengthTest.php b/tests/Unit/Parser/Constraints/ListLengthTest.php index 5b45a09..50fb5b8 100644 --- a/tests/Unit/Parser/Constraints/ListLengthTest.php +++ b/tests/Unit/Parser/Constraints/ListLengthTest.php @@ -4,7 +4,7 @@ use Le0daniel\PhpTsBindings\Executor\Data\Context; use Le0daniel\PhpTsBindings\Executor\Data\Issues; -use Le0daniel\PhpTsBindings\Parser\Constraints\ListLength; +use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\ListLength; beforeEach(function () { $this->context = new Context(); diff --git a/tests/Unit/Parser/Data/ParsingContextTest.php b/tests/Unit/Parser/Data/ParsingContextTest.php index 3a4e38a..8274565 100644 --- a/tests/Unit/Parser/Data/ParsingContextTest.php +++ b/tests/Unit/Parser/Data/ParsingContextTest.php @@ -18,7 +18,7 @@ ->toBe('Tests\\Unit\\Parser\\Data\\Stubs') ->and($context->usedNamespaceMap) ->toBe([ - 'Optimizer' => 'Le0daniel\PhpTsBindings\Parser\ASTOptimizer', + 'Optimizer' => 'Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer', 'TypeParser' => 'Le0daniel\PhpTsBindings\Parser\TypeParser', ]) ->and($context->localTypes) diff --git a/tests/Unit/Parser/Data/Stubs/MyUserClass.php b/tests/Unit/Parser/Data/Stubs/MyUserClass.php index 17551d4..8a8e2b3 100644 --- a/tests/Unit/Parser/Data/Stubs/MyUserClass.php +++ b/tests/Unit/Parser/Data/Stubs/MyUserClass.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Parser\Data\Stubs; -use Le0daniel\PhpTsBindings\Parser\ASTOptimizer as Optimizer; +use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer as Optimizer; use Le0daniel\PhpTsBindings\Parser\TypeParser; /** diff --git a/tests/Unit/Parser/Definition/LexemesTest.php b/tests/Unit/Parser/Definition/LexemesTest.php index b2e329e..6f656ed 100644 --- a/tests/Unit/Parser/Definition/LexemesTest.php +++ b/tests/Unit/Parser/Definition/LexemesTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Parser\Definition; -use Le0daniel\PhpTsBindings\Parser\Definition\Lexemes; +use Le0daniel\PhpTsBindings\Parser\Utils\Lexemes; test('single quoted literals resolve only backslash and quote escapes', function () { expect(Lexemes::decodeString("'hello'"))->toBe('hello') diff --git a/tests/Unit/Parser/MetadataEliminationTest.php b/tests/Unit/Parser/MetadataEliminationTest.php index fcfbf2e..26d07a2 100644 --- a/tests/Unit/Parser/MetadataEliminationTest.php +++ b/tests/Unit/Parser/MetadataEliminationTest.php @@ -1,13 +1,13 @@ generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); expect($optimizedCode)->not->toContain('MetadataNode') diff --git a/tests/Unit/Parser/NodeDiagnosticStringTest.php b/tests/Unit/Parser/NodeDiagnosticStringTest.php index 5d28c2e..c09c621 100644 --- a/tests/Unit/Parser/NodeDiagnosticStringTest.php +++ b/tests/Unit/Parser/NodeDiagnosticStringTest.php @@ -1,5 +1,6 @@ parse(Order::class); $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $result = new TypescriptGenerator()->toTypescript($registry->get('node'), IO::OUTPUT); diff --git a/tests/Unit/Typescript/OptimizedAstTest.php b/tests/Unit/Typescript/OptimizedAstTest.php index 5c4135d..ef7cf2f 100644 --- a/tests/Unit/Typescript/OptimizedAstTest.php +++ b/tests/Unit/Typescript/OptimizedAstTest.php @@ -3,7 +3,7 @@ namespace Tests\Unit\Typescript; use Le0daniel\PhpTsBindings\Data\IO; -use Le0daniel\PhpTsBindings\Parser\ASTOptimizer; +use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Unit\Executor\Mocks\UserSchema; @@ -21,7 +21,7 @@ function toDefinition(string $typeString, ?IO $io = null): string $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $ast]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $generator = new TypescriptGenerator(); From c4290f13b64e0cc03e6c5aee95750074fecfc4e9 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 3 Aug 2026 23:05:18 +0200 Subject: [PATCH 037/101] Refactor `EmitTanstackQuery` to use `EmitOperations` for consistent naming; update dependencies, tests, and related generators to ensure single source of truth for operation names. --- README.md | 5 +- .../Laravel/Commands/CodeGenCommand.php | 6 +- .../EmitOperationClientBindings.php | 27 ++++- src/CodeGen/CodeGenerators/EmitOperations.php | 52 +++++++-- src/CodeGen/CodeGenerators/EmitQueryKey.php | 51 ++++----- .../CodeGenerators/EmitTanstackQuery.php | 46 ++++---- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 28 ++++- src/CodeGen/Contracts/DependsOn.php | 10 ++ src/CodeGen/Data/TypedOperation.php | 8 ++ src/CodeGen/TypescriptServerCodeGenerator.php | 29 ++++- src/Utils/Assertions.php | 25 ++++ tests/Unit/CodeGen/EmitQueryKeyTest.php | 52 +++++---- tests/Unit/CodeGen/EmitTanstackQueryTest.php | 107 ++++++++++++++++++ .../TypescriptServerCodeGeneratorTest.php | 31 +++++ 14 files changed, 386 insertions(+), 91 deletions(-) create mode 100644 src/Utils/Assertions.php create mode 100644 tests/Unit/CodeGen/EmitTanstackQueryTest.php diff --git a/README.md b/README.md index 0ca402d..2af7907 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,10 @@ php artisan operations:codegen resources/js/operations --with=tanstack-query,que Write your own generator by implementing `GeneratesLibFiles` (gets every operation, writes shared lib files) or `GeneratesOperationCode` (gets one operation, writes its code) and passing it with -`--custom=My\Generator`. +`--custom=My\Generator`. Add `DependsOn` to declare the generators yours needs: the run fails early +if one is missing, and hands you the resolved instances through `setDependencies()`. That is how to +reference what another generator emitted — ask `EmitOperations` for `inputTypeName($operation)` +rather than rebuilding the name and hoping it matches. ## Client directives diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index 3595a3e..192efa9 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -271,8 +271,10 @@ private function getGeneratorsFromInput(Application $application): array $includeGenerator('utils', true) ? new EmitTypeUtils() : null, $includeGenerator('operations', true) ? new EmitOperations($namingGenerator) : null, $includeGenerator('type-map', false) ? new EmitTypeMap() : null, - $includeGenerator('tanstack-query', false) ? new EmitTanstackQuery($namingGenerator) : null, - $includeGenerator('query-key', false) ? new EmitQueryKey($namingGenerator) : null, + // Only EmitOperations is given the naming rule: it declares the names, the other two + // are handed it as a dependency and ask for them. + $includeGenerator('tanstack-query', false) ? new EmitTanstackQuery() : null, + $includeGenerator('query-key', false) ? new EmitQueryKey() : null, ], fn($value) => $value !== null); $customGenerators = array_map( diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 268d16d..6663f65 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -5,12 +5,29 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\DependsOn; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; +use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Override; final readonly class EmitOperationClientBindings implements GeneratesLibFiles, DependsOn { + private const string BINDINGS_FILE = "bindings"; + + /** + * @param list $values + * @param list $types + * @return TypescriptImport + */ + public static function importFromBindings(array $values = [], array $types = []): TypescriptImport + { + return new TypescriptImport( + Paths::libImport(self::BINDINGS_FILE), + values: $values, + types: $types, + ); + } #[Override] public function dependsOnGenerator(): array @@ -20,6 +37,14 @@ public function dependsOnGenerator(): array ]; } + /** + * Depends on the types for ordering only, so there is nothing to hold on to. + */ + #[Override] + public function setDependencies(array $dependencies): void + { + } + /** * @return array */ @@ -173,7 +198,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } } TypeScript), - "bindings" => new TypescriptFile(<< new TypescriptFile(<<nameGenerator ? ($this->nameGenerator)($operation) : $operation->operation->definition->name; + } + + /** + * The names below are what this generator writes into the operation's module, and the only + * place they are defined. Whatever else references them — a query key, a hook — asks here + * rather than re-deriving them: the naming rule lives in this instance, so a second derivation + * is a second rule waiting to disagree with this one. + */ + public function operationName(TypedOperation $operation): string + { + return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->definition->name; + } + + public function baseTypeName(TypedOperation $operation): string + { + return ucfirst($this->operationName($operation)); + } + + public function inputTypeName(TypedOperation $operation): string + { + return $this->baseTypeName($operation) . 'Input'; + } + + public function resultTypeName(TypedOperation $operation): string + { + return $this->baseTypeName($operation) . 'Result'; + } + + public function errorTypeName(TypedOperation $operation): string + { + return $this->baseTypeName($operation) . 'Error'; } /** @@ -54,13 +88,11 @@ private function aliasImports(TypedOperation $operation): array #[Override] public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): TypescriptFile { - $definition = $operation->operation->definition; - $name = $this->generateName($operation); - - $operationBaseTypeName = ucfirst($name); - $resultTypeName = $operationBaseTypeName . "Result"; - $resultInputTypeName = $operationBaseTypeName . "Input"; - $errorTypeName = $operationBaseTypeName . "Error"; + $definition = $operation->definition; + $name = $this->operationName($operation); + $resultTypeName = $this->resultTypeName($operation); + $resultInputTypeName = $this->inputTypeName($operation); + $errorTypeName = $this->errorTypeName($operation); $imports = [ TypescriptImport::values(Paths::libImport("bindings"), "executeOperation"), @@ -76,7 +108,7 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata */ TypeScript; - if ($operation->inputDef->type === 'null') { + if (!$operation->hasInput) { return new TypescriptFile( <<outputDef->type}; diff --git a/src/CodeGen/CodeGenerators/EmitQueryKey.php b/src/CodeGen/CodeGenerators/EmitQueryKey.php index 541f0d1..ea17e59 100644 --- a/src/CodeGen/CodeGenerators/EmitQueryKey.php +++ b/src/CodeGen/CodeGenerators/EmitQueryKey.php @@ -2,7 +2,6 @@ namespace Le0daniel\PhpTsBindings\CodeGen\CodeGenerators; -use Closure; use Le0daniel\PhpTsBindings\CodeGen\Contracts\DependsOn; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; @@ -11,10 +10,17 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; +use Le0daniel\PhpTsBindings\Utils\Assertions; use Override; -final readonly class EmitQueryKey implements DependsOn, GeneratesOperationCode +/** + * Not readonly: the EmitOperations it takes its names from is injected after construction, which is + * the only way it can be the same instance the generator runs. + */ +final class EmitQueryKey implements DependsOn, GeneratesOperationCode { + private EmitOperations $operations; + #[Override] public function dependsOnGenerator(): array { @@ -23,49 +29,38 @@ public function dependsOnGenerator(): array ]; } - /** - * @param (Closure(TypedOperation):string)|null $nameGenerator - */ - public function __construct(private ?Closure $nameGenerator = null) - { - } - - private function generateName(TypedOperation $operation): string + #[Override] + public function setDependencies(array $dependencies): void { - return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->operation->definition->name; + $this->operations = Assertions::instanceOf( + EmitOperations::class, + $dependencies[EmitOperations::class] ?? null, + ); } - #[Override] public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptFile { - $definition = $operation->operation->definition; + $definition = $operation->definition; if ($definition->type !== OperationType::QUERY) { return null; } - $name = $this->generateName($operation); - - // The input definition is inlined verbatim, so the aliases its registry carries must be - // imported here as well — plus Brand, unconditionally, for inline brands. The file level - // import merge dedupes them with EmitOperations' imports. - $imports = [ - TypescriptImport::values(Paths::libImport("utils"), 'queryKey'), - TypescriptImport::types( - Paths::libImport("types"), - ['Brand', ...$operation->inputDef->registry->usedAliases()], - ), - ]; + // The input type EmitOperations exports is referenced rather than inlined, so this needs no + // import of its own: the alias it may be built from is already imported by the module that + // declares it. + $name = $this->operations->operationName($operation); + $inputTypeName = $this->operations->inputTypeName($operation); return new TypescriptFile( <<inputDef->type}) { +export function {$name}QueryKey(input: {$inputTypeName}) { return queryKey('{$definition->namespace}', '{$definition->name}', input); } TypeScript , - $imports, + [TypescriptImport::values(Paths::libImport("utils"), 'queryKey')], ); } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php index 832710f..9b1e01f 100644 --- a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php +++ b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php @@ -2,7 +2,6 @@ namespace Le0daniel\PhpTsBindings\CodeGen\CodeGenerators; -use Closure; use Le0daniel\PhpTsBindings\CodeGen\Contracts\DependsOn; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; @@ -11,10 +10,17 @@ use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; +use Le0daniel\PhpTsBindings\Utils\Assertions; use Override; -final readonly class EmitTanstackQuery implements GeneratesOperationCode, DependsOn +/** + * Not readonly: the EmitOperations it hangs everything off is injected after construction, which is + * the only way it can be the same instance the generator runs. + */ +final class EmitTanstackQuery implements GeneratesOperationCode, DependsOn { + private EmitOperations $operations; + #[Override] public function dependsOnGenerator(): array { @@ -23,30 +29,30 @@ public function dependsOnGenerator(): array ]; } - /** - * @param (Closure(TypedOperation):string)|null $nameGenerator - */ - public function __construct(private ?Closure $nameGenerator = null) - { - } - - private function generateName(TypedOperation $operation): string + #[Override] + public function setDependencies(array $dependencies): void { - return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->operation->definition->name; + $this->operations = Assertions::instanceOf( + EmitOperations::class, + $dependencies[EmitOperations::class] ?? null, + ); } #[Override] public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptFile { - $definition = $operation->operation->definition; + $definition = $operation->definition; if ($definition->type !== OperationType::QUERY) { return null; } - $name = $this->generateName($operation); - $operationBaseTypeName = ucfirst($name); - $resultTypeName = $operationBaseTypeName . "Result"; - $resultInputTypeName = $operationBaseTypeName . "Input"; + // Everything the emitted hook calls or annotates itself with is declared by EmitOperations + // in the same module, so the names come from there. + $name = $this->operations->operationName($operation); + $operationBaseTypeName = $this->operations->baseTypeName($operation); + $resultTypeName = $this->operations->resultTypeName($operation); + $resultInputTypeName = $this->operations->inputTypeName($operation); + $queryName = "use" . $operationBaseTypeName . "Query"; $queryOptionsName = lcfirst($operationBaseTypeName) . "QueryOptions"; $optionsTypeName = $operationBaseTypeName . "Options"; @@ -57,11 +63,11 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata values: ['useQuery', 'queryOptions'], types: ['UseQueryOptions'], ), - TypescriptImport::values(Paths::libImport("utils"), 'queryKey'), - TypescriptImport::values(Paths::libImport("bindings"), 'throwOnFailure'), + EmitTypeUtils::importFromUtils(values: ['queryKey']), + EmitOperationClientBindings::importFromBindings(values: ['throwOnFailure']), ]; - if ($operation->inputDef->type === 'null') { + if (!$operation->hasInput) { return new TypescriptFile( <<, 'queryKey' | 'queryFn'>; @@ -105,4 +111,4 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata } TypeScript, $imports); } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index f7ae582..e91a54e 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -6,14 +6,32 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Override; final readonly class EmitTypeUtils implements GeneratesLibFiles, DependsOn { + private const string UTILS_FILE = 'utils'; + + /** + * @param list $values + * @param list $types + * @return TypescriptImport + */ + public static function importFromUtils(array $values = [], array $types = []): TypescriptImport + { + return new TypescriptImport( + Paths::libImport(self::UTILS_FILE), + values: $values, + types: $types, + ); + } + #[Override] public function dependsOnGenerator(): array { @@ -22,6 +40,14 @@ public function dependsOnGenerator(): array ]; } + /** + * Depends on the types for ordering only, so there is nothing to hold on to. + */ + #[Override] + public function setDependencies(array $dependencies): void + { + } + /** * @return array */ @@ -48,7 +74,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi )); return [ - "utils" => new TypescriptFile(<< new TypescriptFile(<<generateLiteralUnion($queryNamespaces)}; diff --git a/src/CodeGen/Contracts/DependsOn.php b/src/CodeGen/Contracts/DependsOn.php index 61ceabf..4629e2b 100644 --- a/src/CodeGen/Contracts/DependsOn.php +++ b/src/CodeGen/Contracts/DependsOn.php @@ -8,4 +8,14 @@ interface DependsOn * @return list> */ public function dependsOnGenerator(): array; + + /** + * Receives the resolved instance of every generator dependsOnGenerator() declared, keyed by + * class name, before a single file is generated. Reach for the public API of those instances + * instead of re-deriving what they emit: the type names EmitOperations declares, for example, + * depend on the naming rule it was built with and cannot be recomputed from the outside. + * + * @param array, GeneratesOperationCode|GeneratesLibFiles> $dependencies + */ + public function setDependencies(array $dependencies): void; } \ No newline at end of file diff --git a/src/CodeGen/Data/TypedOperation.php b/src/CodeGen/Data/TypedOperation.php index 25179c9..10e694f 100644 --- a/src/CodeGen/Data/TypedOperation.php +++ b/src/CodeGen/Data/TypedOperation.php @@ -16,6 +16,14 @@ final class TypedOperation get => $this->operation->key; } + /** + * An operation without an input renders as the null type, and every generator that emits a + * signature for it has to drop the argument. + */ + public bool $hasInput { + get => $this->inputDef->type !== 'null'; + } + /** * Each definition carries its own registry with every alias it relies on: what the operation's * file imports, and what the generated types file declares (via the run's shared registry). diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 0d121f4..5fa3fb1 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -30,16 +30,25 @@ public function __construct( private TypescriptGenerator $typescriptGenerator = new TypescriptGenerator(), ) { - $this->verifyGeneratorDependencies(); + $this->resolveGeneratorDependencies(); } /** + * Every declared dependency is verified before any instance is handed out: a generator asked to + * resolve a dependency that is not registered would fail on the missing instance instead of the + * message naming what to register. + * * @throws InvalidGeneratorDependencies */ - private function verifyGeneratorDependencies(): void + private function resolveGeneratorDependencies(): void { $issues = []; - $generatorClassNames = array_map(fn(object $generator): string => $generator::class, $this->generators); + + /** @var array, GeneratesLibFiles|GeneratesOperationCode> $instances */ + $instances = []; + foreach ($this->generators as $generator) { + $instances[$generator::class] = $generator; + } foreach ($this->generators as $generator) { if (!$generator instanceof DependsOn) { @@ -47,7 +56,7 @@ private function verifyGeneratorDependencies(): void } foreach ($generator->dependsOnGenerator() as $className) { - if (!in_array($className, $generatorClassNames, true)) { + if (!array_key_exists($className, $instances)) { $issues[] = "Generator " . $generator::class . " depends on {$className} which is not registered."; } } @@ -56,6 +65,18 @@ private function verifyGeneratorDependencies(): void if (!empty($issues)) { throw new InvalidGeneratorDependencies($issues); } + + foreach ($this->generators as $generator) { + if (!$generator instanceof DependsOn) { + continue; + } + + // Each generator sees what it declared and nothing else, so a dependency it never asked + // for cannot quietly become one it relies on. + $generator->setDependencies( + array_intersect_key($instances, array_flip($generator->dependsOnGenerator())), + ); + } } /** diff --git a/src/Utils/Assertions.php b/src/Utils/Assertions.php new file mode 100644 index 0000000..eb535e6 --- /dev/null +++ b/src/Utils/Assertions.php @@ -0,0 +1,25 @@ + $className + * @param mixed $value + * @phpstan-assert TInstance $value + * @return TInstance + */ + public static function instanceOf(string $className, mixed $value): mixed + { + if (!$value instanceof $className) { + throw new InvalidArgumentException(\sprintf('Expected instance of %s, got %s', $className, gettype($value))); + } + + return $value; + } +} \ No newline at end of file diff --git a/tests/Unit/CodeGen/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php index aa5fbb9..80d0a67 100644 --- a/tests/Unit/CodeGen/EmitQueryKeyTest.php +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -2,6 +2,8 @@ namespace Tests\Unit\CodeGen; +use Closure; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; @@ -15,13 +17,18 @@ /** * The code block and the file it renders to — rendering imports is the file's business, so the - * import statements are only observable through the rendered output. + * import statements are only observable through the rendered output. The input type it references + * belongs to EmitOperations, so the dependency is wired up the way the generator does it. * + * @param (Closure(TypedOperation): string)|null $nameGenerator * @return array{string, string} */ -function queryKeyCodeFor(TypedOperation $typedOperation): array +function queryKeyCodeFor(TypedOperation $typedOperation, ?Closure $nameGenerator = null): array { - $file = new EmitQueryKey()->generateOperationCode( + $emitter = new EmitQueryKey(); + $emitter->setDependencies([EmitOperations::class => new EmitOperations($nameGenerator)]); + + $file = $emitter->generateOperationCode( $typedOperation, new ServerMetadata('/query/{fqn}', '/command/{fqn}'), ); @@ -40,7 +47,7 @@ function queryOperation(): Operation ); } -test('imports the aliases the inlined input definition carries', function () { +test('references the input type EmitOperations exports instead of inlining the definition', function () { [$code, $rendered] = queryKeyCodeFor(new TypedOperation( new Typescript('{status:OrderStatus;}', new AliasRegistry(['OrderStatus' => '"OPEN"|"SHIPPED"'])), new Typescript('Order', new AliasRegistry(['Order' => '{id:number;}'])), @@ -48,27 +55,24 @@ function queryOperation(): Operation queryOperation(), )); - expect($code)->toContain('export function getQueryKey(input: {status:OrderStatus;})') - ->and($rendered)->toContain("import type {Brand, OrderStatus} from './lib/types';") - // The output-only alias is not referenced by the query key. - ->and($rendered)->not->toContain('Order,'); + // The alias lives in the type the same module already declares, so nothing has to be imported + // for it here — EmitOperations owns that import. + expect($code)->toContain('export function getQueryKey(input: GetInput)') + ->and($rendered)->toContain("import {queryKey} from './lib/utils';") + ->and($rendered)->not->toContain('./lib/types') + ->and($rendered)->not->toContain('OrderStatus'); }); -test('always imports the Brand helper, whether the input renders an inline brand or not', function () { - [, $withBrand] = queryKeyCodeFor(new TypedOperation( - new Typescript('{id:number & Brand<"customerId">;}', new AliasRegistry()), - Typescript::fromRawString('string'), - Typescript::fromRawString(''), - queryOperation(), - )); - - [, $withoutBrand] = queryKeyCodeFor(new TypedOperation( - Typescript::fromRawString('{id:number;}'), - Typescript::fromRawString('string'), - Typescript::fromRawString(''), - queryOperation(), - )); +test('follows the naming rule of the EmitOperations it depends on', function () { + [$code] = queryKeyCodeFor( + new TypedOperation( + Typescript::fromRawString('{id:number;}'), + Typescript::fromRawString('string'), + Typescript::fromRawString(''), + queryOperation(), + ), + fn(TypedOperation $operation): string => "orders" . ucfirst($operation->definition->name), + ); - expect($withBrand)->toContain("import type {Brand} from './lib/types';") - ->and($withoutBrand)->toContain("import type {Brand} from './lib/types';"); + expect($code)->toContain('export function ordersGetQueryKey(input: OrdersGetInput)'); }); diff --git a/tests/Unit/CodeGen/EmitTanstackQueryTest.php b/tests/Unit/CodeGen/EmitTanstackQueryTest.php new file mode 100644 index 0000000..4f5a048 --- /dev/null +++ b/tests/Unit/CodeGen/EmitTanstackQueryTest.php @@ -0,0 +1,107 @@ +setDependencies([EmitOperations::class => new EmitOperations($nameGenerator)]); + + return $emitter->generateOperationCode( + $typedOperation, + new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + ); +} + +function tanstackOperation(OperationType $type = OperationType::QUERY): Operation +{ + $parser = new TypeParser(); + return new Operation( + key: 'orders.get', + definition: new Definition($type, Email::class, 'getOrder', 'get', 'orders', []), + input: $parser->parse('array{id: int}'), + output: $parser->parse('string'), + ); +} + +test('references the types and the function EmitOperations declared', function () { + $code = tanstackCodeFor(new TypedOperation( + Typescript::fromRawString('{id:number;}'), + Typescript::fromRawString('string'), + Typescript::fromRawString(''), + tanstackOperation(), + ))->code; + + expect($code) + ->toContain('export function getQueryOptions(input: GetInput, options?: GetOptions)') + ->toContain('type GetOptions = Omit, ') + ->toContain('queryFn: async ({signal}): Promise =>') + ->toContain('const result = await get(input, {signal});') + ->toContain('export function useGetQuery(input: GetInput, queryOptions?: Partial)'); +}); + +test('follows the naming rule of the EmitOperations it depends on', function () { + // The closure lives on EmitOperations alone. Anything this emitter references has to come from + // there, or it would emit calls to types and functions no file declares. + $code = tanstackCodeFor( + new TypedOperation( + Typescript::fromRawString('{id:number;}'), + Typescript::fromRawString('string'), + Typescript::fromRawString(''), + tanstackOperation(), + ), + fn(TypedOperation $operation): string => "orders" . ucfirst($operation->definition->name), + )->code; + + expect($code) + ->toContain('export function ordersGetQueryOptions(input: OrdersGetInput, options?: OrdersGetOptions)') + ->toContain('const result = await ordersGet(input, {signal});') + ->toContain('export function useOrdersGetQuery(input: OrdersGetInput,') + // Nothing falls back to the operation's own name. + ->not->toContain('await get(') + ->not->toContain('useGetQuery'); +}); + +test('drops the input argument when the operation takes none', function () { + $code = tanstackCodeFor(new TypedOperation( + Typescript::fromRawString('null'), + Typescript::fromRawString('string'), + Typescript::fromRawString(''), + tanstackOperation(), + ))->code; + + expect($code) + ->toContain('export function getQueryOptions(options?: GetOptions)') + ->toContain("queryKey: queryKey('orders', 'get'),") + ->toContain('const result = await get({signal});') + ->toContain('export function useGetQuery(queryOptions?: Partial)') + ->not->toContain('GetInput'); +}); + +test('emits nothing for a command', function () { + expect(tanstackCodeFor(new TypedOperation( + Typescript::fromRawString('{id:number;}'), + Typescript::fromRawString('string'), + Typescript::fromRawString(''), + tanstackOperation(OperationType::COMMAND), + )))->toBeNull(); +}); diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 59f3d78..f586213 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -9,6 +9,8 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; +use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; @@ -118,6 +120,35 @@ function generateFor(array $classes, ?array $generators = null): array TypeScript); }); +test('every generator names an operation the way the one that declares it does', function () { + // The naming rule is handed to EmitOperations only. The other two ask it for the names, so a + // rule set in one place cannot leave them referencing types and functions no file declares. + $operations = generateFor([NamedOperations::class], [ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations(fn(TypedOperation $operation): string => "orders" . ucfirst($operation->definition->name)), + new EmitQueryKey(), + new EmitTanstackQuery(), + ])['orders.ts']->toString(); + + expect($operations) + ->toContain('export type OrdersGetInput = {status:OrderStatus;};') + ->toContain('export async function ordersGet(input: OrdersGetInput, options?: OperationOptions)') + ->toContain('export function ordersGetQueryKey(input: OrdersGetInput)') + ->toContain('export function ordersGetQueryOptions(input: OrdersGetInput, options?: OrdersGetOptions)') + ->toContain('export function useOrdersGetQuery(input: OrdersGetInput,') + ->toContain('const result = await ordersGet(input, {signal});'); +}); + +test('fails the run when a generator depends on one that is not registered', function () { + expect(fn() => generateFor([NamedOperations::class], [ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTanstackQuery(), + ]))->toThrow(InvalidGeneratorDependencies::class); +}); + test('fails the run when two classes resolve to the same name with different shapes', function () { expect(fn() => generateFor([ConflictingNamedOperations::class])) ->toThrow(UnsupportedTypeException::class, 'Customer'); From c5dbe4672d2940c4f3af12865b33998d6d393e41 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 3 Aug 2026 23:05:35 +0200 Subject: [PATCH 038/101] Remove unused `Paths` import from `EmitTanstackQuery`. --- src/CodeGen/CodeGenerators/EmitTanstackQuery.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php index 9b1e01f..d19fd45 100644 --- a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php +++ b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php @@ -6,7 +6,6 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; -use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; From 2bbf9ffd37df96496005330a38f9a8edf5609c1c Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 3 Aug 2026 23:24:32 +0200 Subject: [PATCH 039/101] Refactor imports in `EmitOperations` and `EmitQueryKey` to use dedicated utility classes for improved readability and consistency. --- src/CodeGen/CodeGenerators/EmitOperations.php | 2 +- src/CodeGen/CodeGenerators/EmitQueryKey.php | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/CodeGen/CodeGenerators/EmitOperations.php b/src/CodeGen/CodeGenerators/EmitOperations.php index 6423f23..0185ed9 100644 --- a/src/CodeGen/CodeGenerators/EmitOperations.php +++ b/src/CodeGen/CodeGenerators/EmitOperations.php @@ -95,7 +95,7 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata $errorTypeName = $this->errorTypeName($operation); $imports = [ - TypescriptImport::values(Paths::libImport("bindings"), "executeOperation"), + EmitOperationClientBindings::importFromBindings(values: ['executeOperation']), TypescriptImport::types(Paths::libImport("OperationClient"), "OperationOptions"), ...$this->aliasImports($operation), ]; diff --git a/src/CodeGen/CodeGenerators/EmitQueryKey.php b/src/CodeGen/CodeGenerators/EmitQueryKey.php index ea17e59..accb482 100644 --- a/src/CodeGen/CodeGenerators/EmitQueryKey.php +++ b/src/CodeGen/CodeGenerators/EmitQueryKey.php @@ -60,7 +60,9 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata } TypeScript , - [TypescriptImport::values(Paths::libImport("utils"), 'queryKey')], + imports: [ + EmitTypeUtils::importFromUtils(values: ['queryKey']), + ], ); } } From 6daad433bb6f82085f9fe03ff9117f441b7e27d1 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 09:31:08 +0200 Subject: [PATCH 040/101] Add dependency injection for EmitTypes and EmitOperationClientBindings across generators; refactor imports for consistency and maintainability; update unit tests to cover new file resolution logic. --- README.md | 7 +- .../EmitOperationClientBindings.php | 107 +++++++++++---- src/CodeGen/CodeGenerators/EmitOperations.php | 31 +++-- src/CodeGen/CodeGenerators/EmitQueryKey.php | 14 +- .../CodeGenerators/EmitTanstackQuery.php | 20 ++- src/CodeGen/CodeGenerators/EmitTypeMap.php | 30 ++++- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 34 +++-- src/CodeGen/CodeGenerators/EmitTypes.php | 32 ++++- src/CodeGen/TypescriptServerCodeGenerator.php | 8 +- src/CodeGen/Utils/Paths.php | 23 +++- src/Typescript/Code/TypescriptFile.php | 24 ++++ .../EmitOperationClientBindingsTest.php | 123 ++++++++++++++++++ tests/Unit/CodeGen/EmitQueryKeyTest.php | 6 +- tests/Unit/CodeGen/EmitTanstackQueryTest.php | 8 +- tests/Unit/CodeGen/EmitTypeUtilsTest.php | 13 +- tests/Unit/CodeGen/PathsTest.php | 24 ++++ .../TypescriptServerCodeGeneratorTest.php | 68 ++++++++++ .../Typescript/Code/TypescriptFileTest.php | 32 +++++ 18 files changed, 542 insertions(+), 62 deletions(-) create mode 100644 tests/Unit/CodeGen/EmitOperationClientBindingsTest.php create mode 100644 tests/Unit/CodeGen/PathsTest.php diff --git a/README.md b/README.md index 2af7907..09f82a5 100644 --- a/README.md +++ b/README.md @@ -379,7 +379,12 @@ lib files) or `GeneratesOperationCode` (gets one operation, writes its code) and `--custom=My\Generator`. Add `DependsOn` to declare the generators yours needs: the run fails early if one is missing, and hands you the resolved instances through `setDependencies()`. That is how to reference what another generator emitted — ask `EmitOperations` for `inputTypeName($operation)` -rather than rebuilding the name and hoping it matches. +rather than rebuilding the name and hoping it matches, and ask `EmitTypes` for +`importFromTypes(types: ['Order'])` rather than writing the module specifier yourself. Because those +methods are not static, an import can only ever name a file a registered generator actually writes. +Hand your imports to `TypescriptFile` instead of writing `import` lines into the code: only then are +they merged with what the other generators contribute to the same file, and only then is the path +resolved for a file that lands in `lib/`. ## Client directives diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 6663f65..3f99420 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -9,18 +9,31 @@ use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; +use Le0daniel\PhpTsBindings\Utils\Assertions; use Override; -final readonly class EmitOperationClientBindings implements GeneratesLibFiles, DependsOn +/** + * Not readonly: the EmitTypes its files import from is injected after construction, which is the + * only way it can be the same instance the generator runs. + */ +final class EmitOperationClientBindings implements GeneratesLibFiles, DependsOn { private const string BINDINGS_FILE = "bindings"; + private const string OPERATION_CLIENT_FILE = "OperationClient"; + private const string DEFAULT_CLIENT_FILE = "DefaultClient"; + private const string OPERATION_EXCEPTION_FILE = "OperationException"; + + private EmitTypes $types; /** + * One method per file this generator writes, so nothing outside spells a file name it does not + * own. Not static: reaching them means declaring the dependency, and a declared dependency that + * is not registered fails the run before a line is generated. + * * @param list $values * @param list $types - * @return TypescriptImport */ - public static function importFromBindings(array $values = [], array $types = []): TypescriptImport + public function importFromBindings(array $values = [], array $types = []): TypescriptImport { return new TypescriptImport( Paths::libImport(self::BINDINGS_FILE), @@ -29,6 +42,45 @@ public static function importFromBindings(array $values = [], array $types = []) ); } + /** + * @param list $values + * @param list $types + */ + public function importFromOperationClient(array $values = [], array $types = []): TypescriptImport + { + return new TypescriptImport( + Paths::libImport(self::OPERATION_CLIENT_FILE), + values: $values, + types: $types, + ); + } + + /** + * @param list $values + * @param list $types + */ + public function importFromDefaultClient(array $values = [], array $types = []): TypescriptImport + { + return new TypescriptImport( + Paths::libImport(self::DEFAULT_CLIENT_FILE), + values: $values, + types: $types, + ); + } + + /** + * @param list $values + * @param list $types + */ + public function importFromOperationException(array $values = [], array $types = []): TypescriptImport + { + return new TypescriptImport( + Paths::libImport(self::OPERATION_EXCEPTION_FILE), + values: $values, + types: $types, + ); + } + #[Override] public function dependsOnGenerator(): array { @@ -37,12 +89,13 @@ public function dependsOnGenerator(): array ]; } - /** - * Depends on the types for ordering only, so there is nothing to hold on to. - */ #[Override] public function setDependencies(array $dependencies): void { + $this->types = Assertions::instanceOf( + EmitTypes::class, + $dependencies[EmitTypes::class] ?? null, + ); } /** @@ -52,9 +105,7 @@ public function setDependencies(array $dependencies): void public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { return [ - "OperationClient" => new TypescriptFile(<< new TypescriptFile(<<>>; } -TypeScript), - "DefaultClient" => new TypescriptFile(<<types->importFromTypes(types: ['Result', 'WithClientDirectives']), + ]), + self::DEFAULT_CLIENT_FILE => new TypescriptFile(<<>) => Promise | void; export class DefaultClient implements OperationClient { @@ -172,10 +222,13 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } } -TypeScript), - "OperationException" => new TypescriptFile(<<importFromOperationClient(types: ['OperationClient', 'OperationOptions']), + $this->types->importFromTypes( + types: ['Failure', 'Result', 'Success', 'WithClientDirectives'], + ), + ]), + self::OPERATION_EXCEPTION_FILE => new TypescriptFile(<<; @@ -197,13 +250,10 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi return e instanceof OperationException; } } -TypeScript), +TypeScript, [ + $this->types->importFromTypes(types: ['Failure']), + ]), self::BINDINGS_FILE => new TypescriptFile(<<types->importFromTypes(types: ['Result', 'Success', 'WithClientDirectives']), + $this->importFromOperationClient(types: ['OperationClient', 'OperationOptions']), + // Both are constructed, not just annotated: a type only import would leave + // `new DefaultClient(...)` referencing nothing at runtime. + $this->importFromDefaultClient(values: ['DefaultClient']), + $this->importFromOperationException(values: ['OperationException']), + ]), ]; } } \ No newline at end of file diff --git a/src/CodeGen/CodeGenerators/EmitOperations.php b/src/CodeGen/CodeGenerators/EmitOperations.php index 0185ed9..05e2fee 100644 --- a/src/CodeGen/CodeGenerators/EmitOperations.php +++ b/src/CodeGen/CodeGenerators/EmitOperations.php @@ -7,18 +7,25 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; -use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; +use Le0daniel\PhpTsBindings\Utils\Assertions; use Override; -final readonly class EmitOperations implements GeneratesOperationCode, DependsOn +/** + * Not readonly: the generators it imports from are injected after construction, which is the only + * way they can be the same instances the generator runs. + */ +final class EmitOperations implements GeneratesOperationCode, DependsOn { + private EmitTypes $types; + private EmitOperationClientBindings $bindings; + /** * @param (Closure(TypedOperation):string)|null $nameGenerator */ public function __construct( - private ?Closure $nameGenerator = null, + private readonly ?Closure $nameGenerator = null, ) { } @@ -27,16 +34,22 @@ public function __construct( public function dependsOnGenerator(): array { return [ + EmitTypes::class, EmitOperationClientBindings::class, ]; } - /** - * Depends on the bindings for ordering only, so there is nothing to hold on to. - */ #[Override] public function setDependencies(array $dependencies): void { + $this->types = Assertions::instanceOf( + EmitTypes::class, + $dependencies[EmitTypes::class] ?? null, + ); + $this->bindings = Assertions::instanceOf( + EmitOperationClientBindings::class, + $dependencies[EmitOperationClientBindings::class] ?? null, + ); } /** @@ -81,7 +94,7 @@ public function errorTypeName(TypedOperation $operation): string private function aliasImports(TypedOperation $operation): array { return [ - TypescriptImport::types(Paths::libImport("types"), ['Brand', ...$operation->usedAliases()]), + $this->types->importFromTypes(types: ['Brand', ...$operation->usedAliases()]), ]; } @@ -95,8 +108,8 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata $errorTypeName = $this->errorTypeName($operation); $imports = [ - EmitOperationClientBindings::importFromBindings(values: ['executeOperation']), - TypescriptImport::types(Paths::libImport("OperationClient"), "OperationOptions"), + $this->bindings->importFromBindings(values: ['executeOperation']), + $this->bindings->importFromOperationClient(types: ['OperationOptions']), ...$this->aliasImports($operation), ]; $docBlock = <<utils = Assertions::instanceOf( + EmitTypeUtils::class, + $dependencies[EmitTypeUtils::class] ?? null, + ); } #[Override] @@ -61,7 +65,7 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata TypeScript , imports: [ - EmitTypeUtils::importFromUtils(values: ['queryKey']), + $this->utils->importFromUtils(values: ['queryKey']), ], ); } diff --git a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php index d19fd45..8600269 100644 --- a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php +++ b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php @@ -13,18 +13,22 @@ use Override; /** - * Not readonly: the EmitOperations it hangs everything off is injected after construction, which is - * the only way it can be the same instance the generator runs. + * Not readonly: the generators it hangs everything off are injected after construction, which is the + * only way they can be the same instances the generator runs. */ final class EmitTanstackQuery implements GeneratesOperationCode, DependsOn { private EmitOperations $operations; + private EmitTypeUtils $utils; + private EmitOperationClientBindings $bindings; #[Override] public function dependsOnGenerator(): array { return [ EmitOperations::class, + EmitTypeUtils::class, + EmitOperationClientBindings::class, ]; } @@ -35,6 +39,14 @@ public function setDependencies(array $dependencies): void EmitOperations::class, $dependencies[EmitOperations::class] ?? null, ); + $this->utils = Assertions::instanceOf( + EmitTypeUtils::class, + $dependencies[EmitTypeUtils::class] ?? null, + ); + $this->bindings = Assertions::instanceOf( + EmitOperationClientBindings::class, + $dependencies[EmitOperationClientBindings::class] ?? null, + ); } #[Override] @@ -62,8 +74,8 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata values: ['useQuery', 'queryOptions'], types: ['UseQueryOptions'], ), - EmitTypeUtils::importFromUtils(values: ['queryKey']), - EmitOperationClientBindings::importFromBindings(values: ['throwOnFailure']), + $this->utils->importFromUtils(values: ['queryKey']), + $this->bindings->importFromBindings(values: ['throwOnFailure']), ]; if (!$operation->hasInput) { diff --git a/src/CodeGen/CodeGenerators/EmitTypeMap.php b/src/CodeGen/CodeGenerators/EmitTypeMap.php index 621ba00..76ef03c 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeMap.php +++ b/src/CodeGen/CodeGenerators/EmitTypeMap.php @@ -2,16 +2,40 @@ namespace Le0daniel\PhpTsBindings\CodeGen\CodeGenerators; +use Le0daniel\PhpTsBindings\CodeGen\Contracts\DependsOn; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Utils\Arrays; +use Le0daniel\PhpTsBindings\Utils\Assertions; use Override; -final readonly class EmitTypeMap implements GeneratesLibFiles +/** + * Not readonly: the EmitTypes whose file it writes into is injected after construction, which is the + * only way it can be the same instance the generator runs. + */ +final class EmitTypeMap implements GeneratesLibFiles, DependsOn { + private EmitTypes $types; + + #[Override] + public function dependsOnGenerator(): array + { + return [ + EmitTypes::class, + ]; + } + + #[Override] + public function setDependencies(array $dependencies): void + { + $this->types = Assertions::instanceOf( + EmitTypes::class, + $dependencies[EmitTypes::class] ?? null, + ); + } #[Override] public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array @@ -35,8 +59,10 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi return "{$type}: {{$typeString}}"; })) . '}'; + // Written into the types file rather than one of its own: the map inlines the aliases + // EmitTypes declares, and they only resolve while it sits next to them. return [ - 'types' => new TypescriptFile(<<types->fileName() => new TypescriptFile(<< $values * @param list $types - * @return TypescriptImport */ - public static function importFromUtils(array $values = [], array $types = []): TypescriptImport + public function importFromUtils(array $values = [], array $types = []): TypescriptImport { return new TypescriptImport( Paths::libImport(self::UTILS_FILE), @@ -40,12 +49,13 @@ public function dependsOnGenerator(): array ]; } - /** - * Depends on the types for ordering only, so there is nothing to hold on to. - */ #[Override] public function setDependencies(array $dependencies): void { + $this->types = Assertions::instanceOf( + EmitTypes::class, + $dependencies[EmitTypes::class] ?? null, + ); } /** @@ -75,8 +85,6 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi return [ self::UTILS_FILE => new TypescriptFile(<<generateLiteralUnion($queryNamespaces)}; const TOAST_TYPES = [{$toastTypes}] as const; @@ -132,7 +140,15 @@ function isClientInvalidation(value: unknown): value is [string, ...unknown[]] { && (directives.toasts === undefined || isArrayOf(directives.toasts, isClientToast)) && (directives.invalidations === undefined || isArrayOf(directives.invalidations, isClientInvalidation)); } -TypeScript) +TypeScript, [ + $this->types->importFromTypes(types: [ + 'ClientDirectives', + 'ClientRedirect', + 'ClientToast', + 'SPAClientDirectives', + 'WithClientDirectives', + ]), + ]) ]; } diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 592dc88..40f8dbe 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -5,8 +5,10 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; +use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Utils\Arrays; @@ -14,6 +16,8 @@ final readonly class EmitTypes implements GeneratesLibFiles { + private const string TYPES_FILE = 'types'; + /** * Declarations this file always contains. An alias claiming one of these names would generate * a second, conflicting declaration right next to them. @@ -33,6 +37,32 @@ 'TYPE_MAP', ]; + /** + * The name of the file this generator writes, for whoever contributes to it rather than to one + * of their own — EmitTypeMap declares TYPE_MAP next to the aliases it inlines. + */ + public function fileName(): string + { + return self::TYPES_FILE; + } + + /** + * Every declaration above lives in this file, so importing one is asking here for it. Not + * static: a generator can only reach this through a dependency it declared, which is what makes + * an import of a file no registered generator writes impossible. + * + * @param list $values + * @param list $types + */ + public function importFromTypes(array $values = [], array $types = []): TypescriptImport + { + return new TypescriptImport( + Paths::libImport(self::TYPES_FILE), + values: $values, + types: $types, + ); + } + /** * @return array */ @@ -68,7 +98,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi )); return [ - "types" => new TypescriptFile(<< new TypescriptFile(<<generateNamespaceUnion($uniqueNamespaces)}; export type Success = {success: true, data: T} diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 5fa3fb1..563b5de 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -10,6 +10,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; +use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\Helpers\AstValidator; use Le0daniel\PhpTsBindings\Server\Data\Operation; @@ -160,8 +161,13 @@ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry) // Several generators may contribute to one lib file, so they accumulate rather // than overwrite. + // + // An emitter names a lib file the way a module at the output root reaches it, + // because it cannot know where its own output lands. Here it is known: this one + // goes into lib/, one directory deeper, where a sibling is reached directly. $fileKey = "lib/{$fileName}.ts"; - $carry[$fileKey] = ($carry[$fileKey] ?? new TypescriptFile())->append($fileContent); + $carry[$fileKey] = ($carry[$fileKey] ?? new TypescriptFile()) + ->append($fileContent->withModulesResolvedBy(Paths::fromInsideLib(...))); } return $carry; }, diff --git a/src/CodeGen/Utils/Paths.php b/src/CodeGen/Utils/Paths.php index 7473ebe..fdcb218 100644 --- a/src/CodeGen/Utils/Paths.php +++ b/src/CodeGen/Utils/Paths.php @@ -2,15 +2,36 @@ namespace Le0daniel\PhpTsBindings\CodeGen\Utils; +/** + * Where the generated files sit relative to each other. The tree is two levels — lib files under + * lib/, operation modules at the output root — and both halves of the rule live here. + */ final readonly class Paths { + private const string LIB_PREFIX = './lib/'; + public static function relative(string $path): string { return "./{$path}"; } + /** + * How a lib file is named, always: an emitter has no idea where its own output lands, so it + * writes the specifier a module at the output root would. + */ public static function libImport(string $name): string { return self::relative("lib/{$name}"); } -} \ No newline at end of file + + /** + * The same module named from inside lib/, where a sibling is reached directly. Only the + * orchestrator knows which files landed there, so it is the only caller. + */ + public static function fromInsideLib(string $specifier): string + { + return str_starts_with($specifier, self::LIB_PREFIX) + ? self::relative(substr($specifier, strlen(self::LIB_PREFIX))) + : $specifier; + } +} diff --git a/src/Typescript/Code/TypescriptFile.php b/src/Typescript/Code/TypescriptFile.php index 4f12363..96b2dfc 100644 --- a/src/Typescript/Code/TypescriptFile.php +++ b/src/Typescript/Code/TypescriptFile.php @@ -2,6 +2,7 @@ namespace Le0daniel\PhpTsBindings\Typescript\Code; +use Closure; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; use Le0daniel\PhpTsBindings\Utils\Lists; use NoDiscard; @@ -57,6 +58,29 @@ public function withImports(TypescriptImport ...$imports): self return new self($this->code, [...$this->imports, ...$imports] |> array_values(...)); } + /** + * The same file with every module specifier rewritten. Imports are re-merged afterwards, so two + * specifiers that resolve onto one module become one import rather than two lines naming the + * same file. + * + * A specifier is written before it is known where the file writing it ends up, and what it has + * to say depends on that. Whoever does know hands the rule in here. + * + * @param Closure(string): string $resolve + */ + #[NoDiscard] + public function withModulesResolvedBy(Closure $resolve): self + { + return new self($this->code, array_map( + fn(TypescriptImport $import): TypescriptImport => new TypescriptImport( + $resolve($import->from), + $import->values, + $import->types, + ), + $this->imports, + )); + } + /** * Appends a block of code. A string carries no imports; a file carries its own, merged into * this one. Blocks are separated by a blank line, and appending nothing changes nothing. diff --git a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php new file mode 100644 index 0000000..5e05d73 --- /dev/null +++ b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php @@ -0,0 +1,123 @@ + + */ +function bindingFiles(): array +{ + $emitter = new EmitOperationClientBindings(); + $emitter->setDependencies([EmitTypes::class => new EmitTypes()]); + + return $emitter->emitFiles( + [], + new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + new AliasRegistry(), + ); +} + +test('emits the four client files', function () { + expect(array_keys(bindingFiles())) + ->toBe(['OperationClient', 'DefaultClient', 'OperationException', 'bindings']); +}); + +test('declares exactly the imports its body needs', function (string $file, array $expected) { + $imports = []; + foreach (bindingFiles()[$file]->imports as $import) { + $imports[$import->from] = ['values' => $import->values, 'types' => $import->types]; + } + + expect($imports)->toBe($expected); +})->with([ + 'OperationClient' => ['OperationClient', [ + './lib/types' => ['values' => [], 'types' => ['Result', 'WithClientDirectives']], + ]], + 'DefaultClient' => ['DefaultClient', [ + './lib/OperationClient' => ['values' => [], 'types' => ['OperationClient', 'OperationOptions']], + './lib/types' => ['values' => [], 'types' => ['Failure', 'Result', 'Success', 'WithClientDirectives']], + ]], + 'OperationException' => ['OperationException', [ + './lib/types' => ['values' => [], 'types' => ['Failure']], + ]], + // DefaultClient and OperationException are constructed, so they are value imports; a type only + // import of either would leave `new DefaultClient(...)` referencing nothing at runtime. + 'bindings' => ['bindings', [ + './lib/DefaultClient' => ['values' => ['DefaultClient'], 'types' => []], + './lib/OperationClient' => ['values' => [], 'types' => ['OperationClient', 'OperationOptions']], + './lib/OperationException' => ['values' => ['OperationException'], 'types' => []], + './lib/types' => ['values' => [], 'types' => ['Result', 'Success', 'WithClientDirectives']], + ]], +]); + +test('imports nothing its body does not reference', function () { + $unused = []; + foreach (bindingFiles() as $name => $file) { + foreach ($file->imports as $import) { + foreach ([...$import->values, ...$import->types] as $imported) { + if (!str_contains($file->code, $imported)) { + $unused[] = "{$name} imports {$imported} from {$import->from}"; + } + } + } + } + + expect($unused)->toBe([]); +}); + +/** + * A raw import line inside a body is invisible to TypescriptFile: it is neither merged with what the + * other generators contribute to the same file nor rewritten once the file lands in lib/. Imports go + * through TypescriptImport, which is what makes both of those work. + */ +test('no body hand-writes an import line', function () { + $raw = []; + foreach (bindingFiles() as $name => $file) { + if (str_contains($file->code, 'import ')) { + $raw[] = $name; + } + } + + expect($raw)->toBe([]); +}); + +test('every import names a file this generator emits, or the types file', function () { + $emitted = array_keys(bindingFiles()); + + $unknown = []; + foreach (bindingFiles() as $file) { + foreach ($file->imports as $import) { + $name = str_replace('./lib/', '', $import->from); + if ($name !== 'types' && !in_array($name, $emitted, true)) { + $unknown[] = $import->from; + } + } + } + + expect($unknown)->toBe([]); +}); + +test('the import methods name the files it emits', function () { + $emitter = new EmitOperationClientBindings(); + + expect($emitter->importFromBindings(values: ['executeOperation'])) + ->toEqual(TypescriptImport::values('./lib/bindings', 'executeOperation')) + ->and($emitter->importFromOperationClient(types: ['OperationOptions'])) + ->toEqual(TypescriptImport::types('./lib/OperationClient', 'OperationOptions')) + ->and($emitter->importFromDefaultClient(values: ['DefaultClient'])) + ->toEqual(TypescriptImport::values('./lib/DefaultClient', 'DefaultClient')) + ->and($emitter->importFromOperationException(values: ['OperationException'])) + ->toEqual(TypescriptImport::values('./lib/OperationException', 'OperationException')); +}); diff --git a/tests/Unit/CodeGen/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php index 80d0a67..c2ba5fc 100644 --- a/tests/Unit/CodeGen/EmitQueryKeyTest.php +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -5,6 +5,7 @@ use Closure; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\Parser\TypeParser; @@ -26,7 +27,10 @@ function queryKeyCodeFor(TypedOperation $typedOperation, ?Closure $nameGenerator = null): array { $emitter = new EmitQueryKey(); - $emitter->setDependencies([EmitOperations::class => new EmitOperations($nameGenerator)]); + $emitter->setDependencies([ + EmitOperations::class => new EmitOperations($nameGenerator), + EmitTypeUtils::class => new EmitTypeUtils(), + ]); $file = $emitter->generateOperationCode( $typedOperation, diff --git a/tests/Unit/CodeGen/EmitTanstackQueryTest.php b/tests/Unit/CodeGen/EmitTanstackQueryTest.php index 4f5a048..70352a8 100644 --- a/tests/Unit/CodeGen/EmitTanstackQueryTest.php +++ b/tests/Unit/CodeGen/EmitTanstackQueryTest.php @@ -3,8 +3,10 @@ namespace Tests\Unit\CodeGen; use Closure; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTanstackQuery; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\Parser\TypeParser; @@ -24,7 +26,11 @@ function tanstackCodeFor(TypedOperation $typedOperation, ?Closure $nameGenerator = null): ?TypescriptFile { $emitter = new EmitTanstackQuery(); - $emitter->setDependencies([EmitOperations::class => new EmitOperations($nameGenerator)]); + $emitter->setDependencies([ + EmitOperations::class => new EmitOperations($nameGenerator), + EmitTypeUtils::class => new EmitTypeUtils(), + EmitOperationClientBindings::class => new EmitOperationClientBindings(), + ]); return $emitter->generateOperationCode( $typedOperation, diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index 33181ae..890a39b 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\CodeGen; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; @@ -31,7 +32,12 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, $registry); $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); - $files = new EmitTypeUtils()->emitFiles( + // The directive types it narrows to are declared by EmitTypes, so the dependency is wired up + // the way the generator does it. + $emitter = new EmitTypeUtils(); + $emitter->setDependencies([EmitTypes::class => new EmitTypes()]); + + $files = $emitter->emitFiles( [new TypedOperation($input, $output, Typescript::fromRawString(''), $operation)], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), $registry, @@ -70,6 +76,9 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp }); test('the guard imports the named directive types instead of restating their shape', function () { + // './lib/types' is what an emitter writes — the way a module at the output root reaches the + // types file. utils.ts lands inside lib/ and reaches it as './types', which the orchestrator + // resolves; that form is pinned in TypescriptServerCodeGeneratorTest. expect(emitUtilsFor()) - ->toContain('import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from "./types";'); + ->toContain("import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from './lib/types';"); }); diff --git a/tests/Unit/CodeGen/PathsTest.php b/tests/Unit/CodeGen/PathsTest.php new file mode 100644 index 0000000..bc6b80c --- /dev/null +++ b/tests/Unit/CodeGen/PathsTest.php @@ -0,0 +1,24 @@ +toBe('./lib/types'); +}); + +test('names the same lib file the way a sibling inside lib reaches it', function () { + expect(Paths::fromInsideLib(Paths::libImport('types')))->toBe('./types') + ->and(Paths::fromInsideLib(Paths::libImport('OperationClient')))->toBe('./OperationClient'); +}); + +test('leaves a specifier that names no lib file alone', function (string $specifier) { + expect(Paths::fromInsideLib($specifier))->toBe($specifier); +})->with([ + 'a package' => ['@tanstack/react-query'], + 'a module already reached directly' => ['./types'], + 'a name that merely starts with lib' => ['./library'], +]); diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index f586213..96316f8 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTanstackQuery; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeMap; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; @@ -141,6 +142,59 @@ function generateFor(array $classes, ?array $generators = null): array ->toContain('const result = await ordersGet(input, {signal});'); }); +test('a lib file reaches its siblings directly instead of through lib/', function () { + // What an emitter writes is './lib/x' — the way a module at the output root reaches it. A file + // that lands in lib/ itself is one directory deeper, so the orchestrator resolves the specifier + // once it knows where the file went, and the emitters never learn where that is. + $files = generateFor([NamedOperations::class]); + + expect($files['lib/bindings.ts']->toString())->toStartWith(<<toString())->toStartWith( + "import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from './types';" + ); + + expect($files['lib/types.ts']->toString())->not->toContain('import '); +}); + +test('no lib file names a module through lib/', function () { + $files = generateFor([NamedOperations::class], [ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations(), + new EmitTypeMap(), + new EmitQueryKey(), + new EmitTanstackQuery(), + ]); + + $wrong = []; + foreach ($files as $path => $file) { + if (str_starts_with($path, 'lib/') && str_contains($file->toString(), "'./lib/")) { + $wrong[] = $path; + } + } + + expect($wrong)->toBe([]); +}); + +test('the type map is written into the types file the types generator owns', function () { + $files = generateFor([NamedOperations::class], [new EmitTypes(), new EmitTypeMap()]); + + // One file, not two: TYPE_MAP inlines the aliases EmitTypes declares, so it only resolves while + // it sits next to them. + expect($files)->not->toHaveKey('lib/type-map.ts') + ->and($files['lib/types.ts']->toString()) + ->toContain('export type Brand') + ->toContain('export type TYPE_MAP = {'); +}); + test('fails the run when a generator depends on one that is not registered', function () { expect(fn() => generateFor([NamedOperations::class], [ new EmitTypes(), @@ -149,6 +203,20 @@ function generateFor(array $classes, ?array $generators = null): array ]))->toThrow(InvalidGeneratorDependencies::class); }); +test('fails the run when a generator imports from one that is not registered', function () { + // Nothing declares './lib/types' by hand any more: the import comes from EmitTypes, so a run + // without it cannot silently emit an operation module pointing at a file no one writes. + expect(fn() => generateFor([NamedOperations::class], [ + new EmitOperationClientBindings(), + new EmitOperations(), + ]))->toThrow(InvalidGeneratorDependencies::class); +}); + +test('fails the run when the type map has no types file to write into', function () { + expect(fn() => generateFor([NamedOperations::class], [new EmitTypeMap()])) + ->toThrow(InvalidGeneratorDependencies::class); +}); + test('fails the run when two classes resolve to the same name with different shapes', function () { expect(fn() => generateFor([ConflictingNamedOperations::class])) ->toThrow(UnsupportedTypeException::class, 'Customer'); diff --git a/tests/Unit/Typescript/Code/TypescriptFileTest.php b/tests/Unit/Typescript/Code/TypescriptFileTest.php index 72da265..fbb84ca 100644 --- a/tests/Unit/Typescript/Code/TypescriptFileTest.php +++ b/tests/Unit/Typescript/Code/TypescriptFileTest.php @@ -143,6 +143,38 @@ ); }); +test('resolves every module specifier and merges what collapses onto one module', function () { + $file = new TypescriptFile('const a = 1;', [ + TypescriptImport::types('./lib/types', 'Order'), + TypescriptImport::values('./types', 'isOrder'), + TypescriptImport::values('@tanstack/react-query', 'useQuery'), + ])->withModulesResolvedBy( + fn(string $from): string => str_starts_with($from, './lib/') ? './' . substr($from, 6) : $from, + ); + + // The two specifiers name one module once resolved, so they render as one import and not as a + // duplicated line the resolver would otherwise have introduced. + expect($file->imports)->toHaveCount(2) + ->and($file->toString())->toBe( + "import type {Order} from './types';\n" + . "import {isOrder} from './types';\n" + . "import {useQuery} from '@tanstack/react-query';\n" + . "\nconst a = 1;\n" + ); +}); + +test('withModulesResolvedBy returns a new file and leaves the original untouched', function () { + $original = new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')]); + + $resolved = $original->withModulesResolvedBy(fn(string $from): string => './types'); + + expect($resolved)->not->toBe($original) + ->and($original->imports[0]->from)->toBe('./lib/types') + ->and($resolved->imports[0]->from)->toBe('./types') + ->and($resolved->imports[0]->types)->toBe(['Brand']) + ->and($resolved->code)->toBe('const a = 1;'); +}); + test('renders the same bytes whatever order the imports arrived in', function () { $imports = [ TypescriptImport::values('@tanstack/react-query', 'useQuery'), From 7b0445f931ba0da06eea92c56f7fc07ecc398ad7 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 09:54:59 +0200 Subject: [PATCH 041/101] Remove `TYPE_MAP` and dependency injection from `EmitTypeMap`; update file resolution and related tests accordingly. --- src/CodeGen/CodeGenerators/EmitTypeMap.php | 24 +++---------------- src/CodeGen/CodeGenerators/EmitTypes.php | 10 -------- tests/Unit/CodeGen/EmitTypesTest.php | 1 - .../TypescriptServerCodeGeneratorTest.php | 14 ++++------- 4 files changed, 7 insertions(+), 42 deletions(-) diff --git a/src/CodeGen/CodeGenerators/EmitTypeMap.php b/src/CodeGen/CodeGenerators/EmitTypeMap.php index 76ef03c..51465ea 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeMap.php +++ b/src/CodeGen/CodeGenerators/EmitTypeMap.php @@ -16,26 +16,8 @@ * Not readonly: the EmitTypes whose file it writes into is injected after construction, which is the * only way it can be the same instance the generator runs. */ -final class EmitTypeMap implements GeneratesLibFiles, DependsOn +final class EmitTypeMap implements GeneratesLibFiles { - private EmitTypes $types; - - #[Override] - public function dependsOnGenerator(): array - { - return [ - EmitTypes::class, - ]; - } - - #[Override] - public function setDependencies(array $dependencies): void - { - $this->types = Assertions::instanceOf( - EmitTypes::class, - $dependencies[EmitTypes::class] ?? null, - ); - } #[Override] public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array @@ -62,11 +44,11 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi // Written into the types file rather than one of its own: the map inlines the aliases // EmitTypes declares, and they only resolve while it sits next to them. return [ - $this->types->fileName() => new TypescriptFile(<< new TypescriptFile(<<with([ 'the Brand helper generic' => ['Brand'], 'the Result envelope' => ['Result'], - 'the TYPE_MAP constant' => ['TYPE_MAP'], 'the client directive wrapper' => ['WithClientDirectives'], 'the SPA client directives' => ['SPAClientDirectives'], 'the directive payload' => ['ClientDirectives'], diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 96316f8..6aa7527 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -187,12 +187,11 @@ function generateFor(array $classes, ?array $generators = null): array test('the type map is written into the types file the types generator owns', function () { $files = generateFor([NamedOperations::class], [new EmitTypes(), new EmitTypeMap()]); - // One file, not two: TYPE_MAP inlines the aliases EmitTypes declares, so it only resolves while + // Two files: typemap inlines the aliases EmitTypes declares, so it only resolves while // it sits next to them. - expect($files)->not->toHaveKey('lib/type-map.ts') - ->and($files['lib/types.ts']->toString()) - ->toContain('export type Brand') - ->toContain('export type TYPE_MAP = {'); + expect($files)->toHaveKey('lib/type-map.ts') + ->and($files['lib/type-map.ts']->toString()) + ->toContain('export type TypeMap = {'); }); test('fails the run when a generator depends on one that is not registered', function () { @@ -212,11 +211,6 @@ function generateFor(array $classes, ?array $generators = null): array ]))->toThrow(InvalidGeneratorDependencies::class); }); -test('fails the run when the type map has no types file to write into', function () { - expect(fn() => generateFor([NamedOperations::class], [new EmitTypeMap()])) - ->toThrow(InvalidGeneratorDependencies::class); -}); - test('fails the run when two classes resolve to the same name with different shapes', function () { expect(fn() => generateFor([ConflictingNamedOperations::class])) ->toThrow(UnsupportedTypeException::class, 'Customer'); From 7bad11917970906a900f5b537b125cf09768513f Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 09:58:06 +0200 Subject: [PATCH 042/101] Add dependency management for `EmitTypes` in `EmitTypeMap`; implement `DependsOn` interface and refactor to inject dependencies. --- src/CodeGen/CodeGenerators/EmitTypeMap.php | 33 +++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/CodeGen/CodeGenerators/EmitTypeMap.php b/src/CodeGen/CodeGenerators/EmitTypeMap.php index 51465ea..2ba8761 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeMap.php +++ b/src/CodeGen/CodeGenerators/EmitTypeMap.php @@ -16,8 +16,9 @@ * Not readonly: the EmitTypes whose file it writes into is injected after construction, which is the * only way it can be the same instance the generator runs. */ -final class EmitTypeMap implements GeneratesLibFiles +final class EmitTypeMap implements GeneratesLibFiles, DependsOn { + private EmitTypes $emitTypes; #[Override] public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array @@ -35,11 +36,11 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi }, []); $mapAsTsTypeString = '{' . implode(';', Arrays::mapWithKeys($map, function (string $type, array $operations) { - $typeString = implode(';', Arrays::mapWithKeys($operations, function (string $operation, array $definition) { - return "'{$operation}': {input: {$definition['input']}, output: {$definition['output']}, errors: {$definition['errors']}}"; - })); - return "{$type}: {{$typeString}}"; - })) . '}'; + $typeString = implode(';', Arrays::mapWithKeys($operations, function (string $operation, array $definition) { + return "'{$operation}': {input: {$definition['input']}, output: {$definition['output']}, errors: {$definition['errors']}}"; + })); + return "{$type}: {{$typeString}}"; + })) . '}'; // Written into the types file rather than one of its own: the map inlines the aliases // EmitTypes declares, and they only resolve while it sits next to them. @@ -49,8 +50,26 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi * Full type map of all operations, input and output types. */ export type TypeMap = {$mapAsTsTypeString}; -TypeScript +TypeScript, + imports: [ + $this->emitTypes->importFromTypes(types: $registry->usedAliases()) + ] ) ]; } + + #[Override] + public function dependsOnGenerator(): array + { + return [EmitTypes::class]; + } + + #[Override] + public function setDependencies(array $dependencies): void + { + $this->emitTypes = Assertions::instanceOf( + EmitTypes::class, + $dependencies[EmitTypes::class] ?? null, + ); + } } \ No newline at end of file From 130e5a572b7471b2a89e3e399769b1d1c6314289 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 10:01:33 +0200 Subject: [PATCH 043/101] Update `EmitQueryKey` to handle operations with and without input; modify generated query key functions for flexibility and consistency. --- src/CodeGen/CodeGenerators/EmitQueryKey.php | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/CodeGen/CodeGenerators/EmitQueryKey.php b/src/CodeGen/CodeGenerators/EmitQueryKey.php index ad46e4e..b23298d 100644 --- a/src/CodeGen/CodeGenerators/EmitQueryKey.php +++ b/src/CodeGen/CodeGenerators/EmitQueryKey.php @@ -56,12 +56,28 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata $name = $this->operations->operationName($operation); $inputTypeName = $this->operations->inputTypeName($operation); - return new TypescriptFile( - <<hasInput) { + return new TypescriptFile( + <<namespace}', '{$definition->name}', input); } +TypeScript + , + imports: [ + $this->utils->importFromUtils(values: ['queryKey']), + ], + ); + } + + return new TypescriptFile( + <<namespace}', '{$definition->name}'); +} TypeScript , imports: [ From 827414586a361ca923f868358dbac53ffcdb64d7 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 10:27:40 +0200 Subject: [PATCH 044/101] Refactor `LaravelServiceProvider` to simplify caching logic; update `EmitTypeMap` comments and imports for clarity and consistency. --- src/Adapters/Laravel/LaravelServiceProvider.php | 2 +- src/CodeGen/CodeGenerators/EmitTypeMap.php | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index f15b1bc..4f897b7 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -88,7 +88,7 @@ public function register(): void }); $this->app->singleton(self::DEFAULT_SERVER, function (Application $app): Server { - $isRepositoryCached = !$this->app->runningInConsole() && file_exists(base_path('bootstrap/cache/operations.php')); + $isRepositoryCached = file_exists(base_path('bootstrap/cache/operations.php')); return self::serverFactory( $app, diff --git a/src/CodeGen/CodeGenerators/EmitTypeMap.php b/src/CodeGen/CodeGenerators/EmitTypeMap.php index 2ba8761..8919ca9 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeMap.php +++ b/src/CodeGen/CodeGenerators/EmitTypeMap.php @@ -42,8 +42,10 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi return "{$type}: {{$typeString}}"; })) . '}'; - // Written into the types file rather than one of its own: the map inlines the aliases - // EmitTypes declares, and they only resolve while it sits next to them. + // Written next to the types file rather than standing on its own: the map inlines the + // aliases EmitTypes declares, and they only resolve while it sits next to them. Brand is + // imported unconditionally — an inlined brand references it, yet it is never a registry + // key — and a linter drops it where unused. return [ 'type-map' => new TypescriptFile(<<emitTypes->importFromTypes(types: $registry->usedAliases()) + $this->emitTypes->importFromTypes(types: ['Brand', ...$registry->usedAliases()]) ] ) ]; From 729ded52f91102e9677fd2eaec38b7fc0c7059f3 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 10:30:17 +0200 Subject: [PATCH 045/101] Add comprehensive unit tests and improve multiline declaration parsing in `Regexes`; refactor to make type extraction from docblocks more robust and consistent. --- src/Utils/Regexes.php | 181 +++++++++++++-- tests/Unit/Parser/TypeParserTest.php | 4 +- tests/Unit/Reflection/Mocks/UserClassMock.php | 26 +++ tests/Unit/Reflection/TypeReflectionTest.php | 13 ++ .../Unit/Utils/Mocks/ReflectionsUtilMock.php | 20 +- tests/Unit/Utils/ReflectionsTest.php | 21 ++ tests/Unit/Utils/RegexesTest.php | 210 ++++++++++++++++++ tests/ts-output/.gitignore | 1 + 8 files changed, 456 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/Utils/RegexesTest.php create mode 100644 tests/ts-output/.gitignore diff --git a/src/Utils/Regexes.php b/src/Utils/Regexes.php index 851a6b9..5d88d27 100644 --- a/src/Utils/Regexes.php +++ b/src/Utils/Regexes.php @@ -4,38 +4,185 @@ final readonly class Regexes { + private const array OPENING_BRACKETS = ['{' => true, '[' => true, '(' => true, '<' => true]; + private const array CLOSING_BRACKETS = ['}' => true, ']' => true, ')' => true, '>' => true]; + + /** Characters that leave a type expression unfinished, so it continues after a line break. */ + private const array CONTINUING_CHARS = ['|' => true, '&' => true, ':' => true, ',' => true]; + public static function findFirstVarDeclaration(string $docBlocks): ?string { - $lines = explode(PHP_EOL, $docBlocks); - foreach ($lines as $line) { - if (preg_match('/@var\s+(?[^$]+)/', $line, $matches) === 1) { - return $matches['type']; + return self::findTypeOfTag($docBlocks, 'var'); + } + + public static function findReturnTypeDeclaration(string $docBlocks): ?string + { + return self::findTypeOfTag($docBlocks, 'return'); + } + + public static function findParamWithNameDeclaration(string $docBlocks, string $paramName): ?string + { + $variableRegex = '/^(?:[&.]|\s)*\$' . preg_quote($paramName, '/') . '(?![a-zA-Z0-9_\x80-\xff])/'; + + foreach (self::tags($docBlocks) as [$tagName, $body]) { + if ($tagName !== 'param') { + continue; } + + [$type, $rest] = self::splitLeadingType($body); + if ($type === null || preg_match($variableRegex, $rest) !== 1) { + continue; + } + + return $type; } return null; } - public static function findReturnTypeDeclaration(string $docBlocks): ?string + private static function findTypeOfTag(string $docBlocks, string $tagName): ?string { - $lines = explode(PHP_EOL, $docBlocks); - foreach ($lines as $line) { - if (preg_match('/@return\s+(?[^$]+)/', $line, $matches) === 1) { - return $matches['type']; + foreach (self::tags($docBlocks) as [$name, $body]) { + if ($name !== $tagName) { + continue; + } + + if ($type = self::splitLeadingType($body)[0]) { + return $type; } } return null; } - public static function findParamWithNameDeclaration(string $docBlocks, string $paramName): ?string + /** + * Splits a doc block into its tags, joining continuation lines with a single space so that + * multiline declarations (array shapes spanning multiple lines) stay intact. + * + * @return list Tuples of tag name and tag body. + */ + private static function tags(string $docBlock): array { - $lines = explode(PHP_EOL, $docBlocks); - $paramName = preg_quote('$' . $paramName); + $tags = []; + $current = null; + + foreach (self::lines($docBlock) as $line) { + $matches = []; + if (preg_match('/^@(?[a-zA-Z][a-zA-Z0-9-]*)\s*(?.*)$/', $line, $matches) === 1) { + if ($current) { + $tags[] = $current; + } + $current = [$matches['name'], $matches['body']]; + continue; + } + + // A blank line ends the current tag, everything after it is free-form documentation. + if ($line === '') { + if ($current) { + $tags[] = $current; + } + $current = null; + continue; + } - foreach ($lines as $line) { - if (preg_match("/@param\s+(?[^$]+){$paramName}/", $line, $matches) === 1) { - return $matches['type']; + if ($current) { + $current[1] = trim("{$current[1]} {$line}"); } } - return null; + + if ($current) { + $tags[] = $current; + } + + return $tags; + } + + /** @return list The doc block lines, stripped of their comment delimiters. */ + private static function lines(string $docBlock): array + { + $withoutDelimiters = preg_replace('#^\s*/\*\*?|\*/\s*$#', '', $docBlock) ?? $docBlock; + return array_map( + static fn(string $line): string => trim(preg_replace('/^\s*\*/', '', $line) ?? $line), + preg_split('/\R/', $withoutDelimiters) ?: [$withoutDelimiters], + ); + } + + /** + * Takes the leading type expression off a tag body, honoring nesting and string literals, and + * returns it together with the remainder (the variable name and/or the description). + * + * @return array{0: string|null, 1: string} + */ + private static function splitLeadingType(string $body): array + { + $length = strlen($body); + $quote = null; + $depth = 0; + + for ($index = 0; $index < $length; $index++) { + $char = $body[$index]; + + if ($quote) { + match (true) { + $char === '\\' => $index++, + $char === $quote => $quote = null, + default => null, + }; + continue; + } + + if ($char === "'" || $char === '"') { + $quote = $char; + continue; + } + + if (isset(self::OPENING_BRACKETS[$char])) { + $depth++; + continue; + } + + if (isset(self::CLOSING_BRACKETS[$char])) { + $depth = max(0, $depth - 1); + continue; + } + + if ($depth > 0 || !ctype_space($char)) { + continue; + } + + $nextIndex = $index + strspn($body, " \t\n\r\v\f", $index); + if (self::typeContinues($body, $index, $nextIndex)) { + $index = $nextIndex - 1; + continue; + } + + $type = rtrim(substr($body, 0, $index)); + return [$type === '' ? null : $type, ltrim(substr($body, $nextIndex))]; + } + + $type = trim($body); + return [$type === '' ? null : $type, '']; + } + + /** + * Decides whether the whitespace at $index is part of the type or ends it. Whitespace is only + * ever internal to a type when a union or intersection is being built up. + */ + private static function typeContinues(string $body, int $index, int $nextIndex): bool + { + if ($nextIndex >= strlen($body)) { + return false; + } + + if ($index > 0 && isset(self::CONTINUING_CHARS[$body[$index - 1]])) { + return true; + } + + $next = $body[$nextIndex]; + if ($next !== '|' && $next !== '&') { + return false; + } + + // "string &$reference" and "string &...$references" are by-reference parameters, not + // intersection types. + return ($body[$nextIndex + 1] ?? '') !== '$' && ($body[$nextIndex + 1] ?? '') !== '.'; } -} \ No newline at end of file +} diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 9ea61a6..f2c87c3 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -1005,8 +1005,8 @@ }); test('Illegal characters raise InvalidSyntaxException, not a lexer exception', function () { - // Regexes::findFirstVarDeclaration() leaks the closing */ out of single line - // docblocks, so this exact string reaches the parser in the wild. + // Regexes::findFirstVarDeclaration() used to leak the closing */ out of single line + // docblocks. It no longer does, but the parser stays defensive about it. expect(fn() => new TypeParser()->parse('array{id: string} */')) ->toThrow(InvalidSyntaxException::class) ->and(fn() => new TypeParser()->parse('a#b')) diff --git a/tests/Unit/Reflection/Mocks/UserClassMock.php b/tests/Unit/Reflection/Mocks/UserClassMock.php index 4bd574a..6d928f0 100644 --- a/tests/Unit/Reflection/Mocks/UserClassMock.php +++ b/tests/Unit/Reflection/Mocks/UserClassMock.php @@ -9,12 +9,27 @@ final class UserClassMock */ public readonly array $options; + /** + * @var array{ + * street: string, + * city: non-empty-string, + * } + */ + public readonly array $address; + /** * @param non-empty-string $name + * @param array{ + * theme: string, + * notifications: array{ + * email: bool, + * }, + * } $settings */ public function __construct( public readonly string $name, public \DateTimeInterface $birthdate, + public readonly array $settings = [], ) { } @@ -31,4 +46,15 @@ public function toArray(): array { throw new \Exception(); } + + /** + * @return array{ + * id: non-empty-string, + * roles: list, + * } + */ + public function serialize(): array + { + throw new \Exception(); + } } \ No newline at end of file diff --git a/tests/Unit/Reflection/TypeReflectionTest.php b/tests/Unit/Reflection/TypeReflectionTest.php index a0729a6..b370d57 100644 --- a/tests/Unit/Reflection/TypeReflectionTest.php +++ b/tests/Unit/Reflection/TypeReflectionTest.php @@ -35,4 +35,17 @@ ->toBe('non-empty-string') ->and(TypeReflector::reflectReturnType($classReflection->getMethod('toArray'))) ->toBe('array'); +}); + +test('multiline declarations from reflection', function () { + $reflection = new ReflectionClass(UserClassMock::class); + + expect(TypeReflector::reflectProperty($reflection->getProperty('address'))) + ->toBe('array{ street: string, city: non-empty-string, }') + ->and(TypeReflector::reflectProperty($reflection->getProperty('settings'))) + ->toBe('array{ theme: string, notifications: array{ email: bool, }, }') + ->and(TypeReflector::reflectParameter($reflection->getConstructor()->getParameters()[2])) + ->toBe('array{ theme: string, notifications: array{ email: bool, }, }') + ->and(TypeReflector::reflectReturnType($reflection->getMethod('serialize'))) + ->toBe('array{ id: non-empty-string, roles: list, }'); }); \ No newline at end of file diff --git a/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php b/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php index 461a0d8..ad02bc5 100644 --- a/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php +++ b/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php @@ -8,11 +8,18 @@ final class ReflectionsUtilMock * @param string $name * @param array{amount: string, birthdate: \DateTime} $age * @param object{name: string, other: string} $others + * @param array{ + * theme: string, + * notifications: array{ + * email: bool, + * }, + * } $settings */ public function __construct( public string $name, public array $age, - object $others + object $others, + public array $settings = [], ) { } @@ -24,4 +31,15 @@ public function serialize(): array { return ["", 1]; } + + /** + * @return array{ + * id: non-empty-string, + * roles: list, + * } + */ + public function serializeDeeply(): array + { + return ['id' => 'id', 'roles' => []]; + } } \ No newline at end of file diff --git a/tests/Unit/Utils/ReflectionsTest.php b/tests/Unit/Utils/ReflectionsTest.php index d73d295..faafd21 100644 --- a/tests/Unit/Utils/ReflectionsTest.php +++ b/tests/Unit/Utils/ReflectionsTest.php @@ -29,4 +29,25 @@ $reflectionClass->getMethod('serialize') ) )->toBe('array{string, int}'); +}); + +test('get doc block extended type of multiline declarations', function () { + + $reflectionClass = new ReflectionClass(ReflectionsUtilMock::class); + + expect( + Reflections::getDocBlockExtendedType($reflectionClass->getProperty('settings')) + )->toBe('array{ theme: string, notifications: array{ email: bool, }, }'); + + expect( + Reflections::getDocBlockExtendedType( + $reflectionClass->getConstructor()->getParameters()[3] + ) + )->toBe('array{ theme: string, notifications: array{ email: bool, }, }'); + + expect( + Reflections::getReturnType( + $reflectionClass->getMethod('serializeDeeply') + ) + )->toBe('array{ id: non-empty-string, roles: list, }'); }); \ No newline at end of file diff --git a/tests/Unit/Utils/RegexesTest.php b/tests/Unit/Utils/RegexesTest.php new file mode 100644 index 0000000..f9fadff --- /dev/null +++ b/tests/Unit/Utils/RegexesTest.php @@ -0,0 +1,210 @@ +toBe('non-empty-string') + ->and(Regexes::findParamWithNameDeclaration($docBlock, 'age')) + ->toBe('array{amount: string, birthdate: \DateTime}') + ->and(Regexes::findReturnTypeDeclaration($docBlock)) + ->toBe('array{string, int}'); +}); + +test('multiline param declarations are joined into a single type', function () { + $docBlock = <<<'DOC' + /** + * @param array{ + * key: string + * } $input + */ + DOC; + + expect(Regexes::findParamWithNameDeclaration($docBlock, 'input')) + ->toBe('array{ key: string }'); +}); + +test('multiline return declarations are joined into a single type', function () { + $docBlock = <<<'DOC' + /** + * @return array{ + * key: string + * } + */ + DOC; + + expect(Regexes::findReturnTypeDeclaration($docBlock)) + ->toBe('array{ key: string }'); +}); + +test('multiline var declarations are joined into a single type', function () { + $docBlock = <<<'DOC' + /** + * @var array{ + * key: string + * } + */ + DOC; + + expect(Regexes::findFirstVarDeclaration($docBlock)) + ->toBe('array{ key: string }'); +}); + +test('multiline declarations may nest and carry trailing commas', function () { + $docBlock = <<<'DOC' + /** + * @param array{ + * nested: array{ + * deep: bool, + * }, + * list: list, + * } $input + * @return array{ + * id: non-empty-string, + * tags: list< + * non-empty-string + * >, + * } + */ + DOC; + + expect(Regexes::findParamWithNameDeclaration($docBlock, 'input')) + ->toBe('array{ nested: array{ deep: bool, }, list: list, }') + ->and(Regexes::findReturnTypeDeclaration($docBlock)) + ->toBe('array{ id: non-empty-string, tags: list< non-empty-string >, }'); +}); + +test('multiline declarations remain parsable', function () { + $docBlock = <<<'DOC' + /** + * @param array{ + * key: string, + * nested: array{ + * deep: bool, + * }, + * } $input + * @return array{ + * id: string, + * } + */ + DOC; + + $parser = new TypeParser(); + + expect((string) $parser->parse(Regexes::findParamWithNameDeclaration($docBlock, 'input'))) + ->toBe('array{key: string, nested: array{deep: bool}}') + ->and((string) $parser->parse(Regexes::findReturnTypeDeclaration($docBlock))) + ->toBe('array{id: string}'); +}); + +test('a multiline declaration does not bleed into the following tag', function () { + $docBlock = <<<'DOC' + /** + * Creates something. + * + * @param array{ + * key: string + * } $input + * @param non-empty-string $name + * @param int $count + * @return array{ + * id: string + * } + */ + DOC; + + expect(Regexes::findParamWithNameDeclaration($docBlock, 'input')) + ->toBe('array{ key: string }') + ->and(Regexes::findParamWithNameDeclaration($docBlock, 'name')) + ->toBe('non-empty-string') + ->and(Regexes::findParamWithNameDeclaration($docBlock, 'count')) + ->toBe('int') + ->and(Regexes::findReturnTypeDeclaration($docBlock)) + ->toBe('array{ id: string }'); +}); + +test('descriptions are not part of the type', function () { + $docBlock = <<<'DOC' + /** + * @param array{ + * key: string + * } $input The input of the operation. + * @return non-empty-string The identifier. + */ + DOC; + + expect(Regexes::findParamWithNameDeclaration($docBlock, 'input')) + ->toBe('array{ key: string }') + ->and(Regexes::findReturnTypeDeclaration($docBlock)) + ->toBe('non-empty-string'); +}); + +test('single line declarations do not leak the closing comment delimiter', function () { + expect(Regexes::findFirstVarDeclaration('/** @var array{id: string} */')) + ->toBe('array{id: string}') + ->and(Regexes::findReturnTypeDeclaration('/** @return array{id: string} */')) + ->toBe('array{id: string}') + ->and(Regexes::findParamWithNameDeclaration('/** @param array{id: string} $input */', 'input')) + ->toBe('array{id: string}'); +}); + +test('param names must match exactly', function () { + $docBlock = <<<'DOC' + /** + * @param array{ + * key: string + * } $inputData + */ + DOC; + + expect(Regexes::findParamWithNameDeclaration($docBlock, 'input')) + ->toBeNull() + ->and(Regexes::findParamWithNameDeclaration($docBlock, 'inputData')) + ->toBe('array{ key: string }'); +}); + +test('unions and variadics are handled at the top level', function () { + $docBlock = <<<'DOC' + /** + * @param array{ + * key: string + * }|null $input + * @param non-empty-string ...$rest + * @return array{a: int} + * | array{b: int} + */ + DOC; + + expect(Regexes::findParamWithNameDeclaration($docBlock, 'input')) + ->toBe('array{ key: string }|null') + ->and(Regexes::findParamWithNameDeclaration($docBlock, 'rest')) + ->toBe('non-empty-string') + ->and(Regexes::findReturnTypeDeclaration($docBlock)) + ->toBe('array{a: int} | array{b: int}'); +}); + +test('missing declarations return null', function () { + $docBlock = <<<'DOC' + /** + * Just a description, nothing else. + */ + DOC; + + expect(Regexes::findParamWithNameDeclaration($docBlock, 'input')) + ->toBeNull() + ->and(Regexes::findReturnTypeDeclaration($docBlock)) + ->toBeNull() + ->and(Regexes::findFirstVarDeclaration($docBlock)) + ->toBeNull(); +}); diff --git a/tests/ts-output/.gitignore b/tests/ts-output/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/tests/ts-output/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file From 6931cfbd137df4b7a84eb4477ed7b577ad747fea Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 10:50:35 +0200 Subject: [PATCH 046/101] Add comprehensive unit tests and improve multiline declaration parsing in `Regexes`; refactor to make type extraction from docblocks more robust and consistent. --- README.md | 11 ++ composer.json | 8 + .../Laravel/Commands/CodeGenCommand.php | 66 +------ src/CodeGen/Utils/OutputDirectory.php | 107 +++++++++++ .../Mocks/TsOutput/AccountOperations.php | 51 ++++++ .../Mocks/TsOutput/CatalogOperations.php | 63 +++++++ .../Mocks/TsOutput/ShapeOperations.php | 89 +++++++++ .../Mocks/TsOutput/Types/AccountFilter.php | 19 ++ .../TsOutput/Types/AccountLockedException.php | 14 ++ .../Mocks/TsOutput/Types/AliasNaming.php | 18 ++ .../Mocks/TsOutput/Types/Availability.php | 17 ++ .../CodeGen/Mocks/TsOutput/Types/Draft.php | 22 +++ .../CodeGen/Mocks/TsOutput/Types/Money.php | 21 +++ .../CodeGen/Mocks/TsOutput/Types/Product.php | 24 +++ .../Mocks/TsOutput/Types/ProductId.php | 33 ++++ .../TsOutput/Types/ProvisioningException.php | 13 ++ .../TsOutput/Types/QuotaExceededException.php | 15 ++ .../Unit/CodeGen/Mocks/TsOutput/Types/Sku.php | 35 ++++ tests/Unit/CodeGen/TsOutputFixture.php | 67 +++++++ tests/Unit/CodeGen/TsOutputFixtureTest.php | 43 +++++ tests/ts-output/.gitignore | 2 +- tests/ts-output/generate.php | 24 +++ tests/ts-output/generated/accounts.ts | 86 +++++++++ tests/ts-output/generated/catalog.ts | 151 ++++++++++++++++ .../ts-output/generated/lib/DefaultClient.ts | 105 +++++++++++ .../generated/lib/OperationClient.ts | 12 ++ .../generated/lib/OperationException.ts | 23 +++ tests/ts-output/generated/lib/bindings.ts | 36 ++++ tests/ts-output/generated/lib/type-map.ts | 6 + tests/ts-output/generated/lib/types.ts | 27 +++ tests/ts-output/generated/lib/utils.ts | 57 ++++++ tests/ts-output/generated/shapes.ts | 109 +++++++++++ tests/ts-output/package-lock.json | 87 +++++++++ tests/ts-output/package.json | 16 ++ tests/ts-output/src/usage.ts | 171 ++++++++++++++++++ tests/ts-output/tsconfig.json | 25 +++ 36 files changed, 1609 insertions(+), 64 deletions(-) create mode 100644 src/CodeGen/Utils/OutputDirectory.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/AccountOperations.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/CatalogOperations.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountFilter.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountLockedException.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/AliasNaming.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/Availability.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/Draft.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/Money.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/Product.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/ProductId.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/ProvisioningException.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/QuotaExceededException.php create mode 100644 tests/Unit/CodeGen/Mocks/TsOutput/Types/Sku.php create mode 100644 tests/Unit/CodeGen/TsOutputFixture.php create mode 100644 tests/Unit/CodeGen/TsOutputFixtureTest.php create mode 100644 tests/ts-output/generate.php create mode 100644 tests/ts-output/generated/accounts.ts create mode 100644 tests/ts-output/generated/catalog.ts create mode 100644 tests/ts-output/generated/lib/DefaultClient.ts create mode 100644 tests/ts-output/generated/lib/OperationClient.ts create mode 100644 tests/ts-output/generated/lib/OperationException.ts create mode 100644 tests/ts-output/generated/lib/bindings.ts create mode 100644 tests/ts-output/generated/lib/type-map.ts create mode 100644 tests/ts-output/generated/lib/types.ts create mode 100644 tests/ts-output/generated/lib/utils.ts create mode 100644 tests/ts-output/generated/shapes.ts create mode 100644 tests/ts-output/package-lock.json create mode 100644 tests/ts-output/package.json create mode 100644 tests/ts-output/src/usage.ts create mode 100644 tests/ts-output/tsconfig.json diff --git a/README.md b/README.md index 09f82a5..d07f378 100644 --- a/README.md +++ b/README.md @@ -583,3 +583,14 @@ composer test # pest composer check:types # phpstan, level 8 composer check:all ``` + +`tests/ts-output/` holds a committed sample of generated client code plus a hand-written consumer of +it, and `composer test` verifies that the generators still produce exactly those bytes. After +changing a code generator, regenerate it: + +```bash +composer codegen:fixture # regenerate tests/ts-output/generated, then tsc --noEmit over it +``` + +That step needs node; it is the only one that does. Commit the regenerated files — a change that +compiles is the point of the fixture. diff --git a/composer.json b/composer.json index 8ca90a6..c7e4dee 100644 --- a/composer.json +++ b/composer.json @@ -54,8 +54,16 @@ "check:all": [ "@test", "@check:types" + ], + "codegen:fixture": [ + "@php tests/ts-output/generate.php", + "npm --prefix tests/ts-output install --no-audit --no-fund", + "npm --prefix tests/ts-output run typecheck" ] }, + "scripts-descriptions": { + "codegen:fixture": "Regenerate tests/ts-output/generated and typecheck it with tsc --noEmit. Run after changing a code generator; commit the result." + }, "extra": { "laravel": { "providers": [ diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index 192efa9..02d60bb 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -3,7 +3,6 @@ namespace Le0daniel\PhpTsBindings\Adapters\Laravel\Commands; use Closure; -use Generator; use Illuminate\Console\Command; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Foundation\Application; @@ -24,11 +23,9 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; +use Le0daniel\PhpTsBindings\CodeGen\Utils\OutputDirectory; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; -use SplFileInfo; final class CodeGenCommand extends Command { @@ -134,7 +131,7 @@ public function handle( return $this->verifyContentOnly($directory, $files); } - $this->writeFiles($directory, $files); + OutputDirectory::write($directory, $files); return 0; } @@ -181,19 +178,6 @@ private function getNamingGenerator(Application $application): Closure exit(1); } - /** - * @param string $directory - * @param array $files - * @return Generator - */ - private function iterateFiles(string $directory, array $files): Generator - { - foreach ($files as $fileName => $file) { - $filePath = "{$directory}/{$fileName}"; - yield $filePath => $file; - } - } - /** * @param string $directory * @param array $files @@ -201,16 +185,7 @@ private function iterateFiles(string $directory, array $files): Generator */ private function verifyContentOnly(string $directory, array $files): int { - $issues = []; - foreach ($this->iterateFiles($directory, $files) as $filePath => $file) { - if (!file_exists($filePath)) { - $issues[] = "File {$filePath} does not exist"; - continue; - } - if (file_get_contents($filePath) !== $file->toString()) { - $issues[] = "File {$filePath} does not match"; - } - } + $issues = OutputDirectory::verify($directory, $files); if (!empty($issues)) { $count = count($issues); @@ -226,24 +201,6 @@ private function verifyContentOnly(string $directory, array $files): int return 0; } - /** - * @param string $directory - * @param array $files - * @return void - */ - private function writeFiles(string $directory, array $files): void - { - $this->clearDirectory($directory); - - if (!file_exists("{$directory}/lib") && is_dir("{$directory}/lib") === false) { - mkdir("{$directory}/lib", 0777, true); - } - - foreach ($this->iterateFiles($directory, $files) as $filePath => $file) { - file_put_contents($filePath, $file->toString()); - } - } - /** * @return list * @throws BindingResolutionException @@ -289,21 +246,4 @@ private function getGeneratorsFromInput(Application $application): array ]); } - private function clearDirectory(string $directory): void - { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory) - ); - - /** @var SplFileInfo $file */ - foreach ($iterator as $file) { - if ($file->isDir() || !str_ends_with($file->getBasename(), '.ts')) { - continue; - } - - if ($file->getRealPath()) { - unlink($file->getRealPath()); - } - } - } } \ No newline at end of file diff --git a/src/CodeGen/Utils/OutputDirectory.php b/src/CodeGen/Utils/OutputDirectory.php new file mode 100644 index 0000000..3aa3a04 --- /dev/null +++ b/src/CodeGen/Utils/OutputDirectory.php @@ -0,0 +1,107 @@ + $files Keys are paths relative to the directory. + */ + public static function write(string $directory, array $files): void + { + // Everything generated is rewritten, so a module left over from an operation that no longer + // exists would otherwise keep importing types that are gone. + foreach (self::existingFileNames($directory) as $fileName) { + unlink("{$directory}/{$fileName}"); + } + + if (!is_dir("{$directory}/lib")) { + mkdir("{$directory}/lib", 0777, true); + } + + foreach ($files as $fileName => $file) { + file_put_contents("{$directory}/{$fileName}", $file->toString()); + } + } + + /** + * The check the writer makes unnecessary, without touching the directory: every named file + * exists with exactly the generated bytes, and no other TypeScript file is present. + * + * @param array $files Keys are paths relative to the directory. + * @return list Empty when the directory matches. One human readable issue per problem. + */ + public static function verify(string $directory, array $files): array + { + $issues = []; + + foreach ($files as $fileName => $file) { + $filePath = "{$directory}/{$fileName}"; + if (!file_exists($filePath)) { + $issues[] = "File {$fileName} is missing."; + continue; + } + + if (file_get_contents($filePath) !== $file->toString()) { + $issues[] = "File {$fileName} does not match the generated output."; + } + } + + // A removed operation leaves its module behind. Comparing content alone reports that + // directory as correct, which is exactly the drift this is meant to catch. + foreach (self::existingFileNames($directory) as $fileName) { + if (!array_key_exists($fileName, $files)) { + $issues[] = "File {$fileName} is not generated anymore and should be deleted."; + } + } + + sort($issues); + return $issues; + } + + /** + * Relative, not absolute: the caller's directory may be a relative or symlinked path, and only + * the part below it can be compared to what the generators named. + * + * @return list Every .ts file below the directory, relative to it. + */ + private static function existingFileNames(string $directory): array + { + $root = realpath($directory); + if ($root === false) { + return []; + } + + $fileNames = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->isDir() || !str_ends_with($file->getBasename(), '.ts')) { + continue; + } + + $realPath = $file->getRealPath(); + if ($realPath === false) { + continue; + } + + $fileNames[] = substr($realPath, strlen($root) + 1); + } + + sort($fileNames); + return $fileNames; + } +} diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/AccountOperations.php b/tests/Unit/CodeGen/Mocks/TsOutput/AccountOperations.php new file mode 100644 index 0000000..4ab4402 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/AccountOperations.php @@ -0,0 +1,51 @@ + 1, 'term' => $input->term]; + } + + /** + * @param array{id: positive-int} $input + * @return array{locked: true} + */ + #[Command('accounts')] + #[Throws(AccountLockedException::class)] + #[Throws(QuotaExceededException::class)] + #[Throws(ProvisioningException::class)] + public function lock(array $input): array + { + return ['locked' => true]; + } + + /** + * @param array{id: positive-int} $input + * @return array{unlocked: true} + */ + #[Command('accounts')] + public function unlock(array $input): array + { + return ['unlocked' => true]; + } +} diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/CatalogOperations.php b/tests/Unit/CodeGen/Mocks/TsOutput/CatalogOperations.php new file mode 100644 index 0000000..61ba8b9 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/CatalogOperations.php @@ -0,0 +1,63 @@ +} $input + * @return array{results: list, total: non-negative-int} + */ + #[Query('catalog')] + public function search(array $input): array + { + return ['results' => [], 'total' => 0]; + } + + /** + * Two names for one class: the input carries the title the constructor takes, the output the + * slug the class exposes. + * + * Not called `draft`: an operation declares {Name}Input and {Name}Result in its own module, and + * `draft` would collide there with the imported Draft/DraftInput aliases. That collision is + * intentionally left to the TypeScript compiler rather than guarded in PHP, so the fixture + * simply does not write it. + */ + #[Query('catalog')] + public function prepare(Draft $input): Draft + { + return $input; + } + + /** + * @param array{sku: Sku, amount: positive-int, price: Money} $input + * @return array{product: Product, restockedAt: DateTimeImmutable} + */ + #[Command('catalog')] + public function restock(array $input): array + { + return ['product' => new Product(), 'restockedAt' => new DateTimeImmutable()]; + } +} diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php b/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php new file mode 100644 index 0000000..d1a0bb1 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php @@ -0,0 +1,89 @@ +, + * lookup: array, + * pair: array{string, int}, + * either: string|int, + * maybe: ?Availability, + * createdAt: DateTimeImmutable, + * day: DateTimeString<'Y-m-d'>, + * nested: array{deep: array{value: non-empty-string}}, + * products: list, + * } + */ + #[Query('shapes')] + public function defaults(null $input): array + { + return [ + 'text' => '', + 'count' => 0, + 'ratio' => 0.0, + 'enabled' => false, + 'nothing' => null, + 'anything' => null, + 'literal' => 'fixed', + 'answer' => 42, + 'always' => true, + 'tags' => [], + 'lookup' => [], + 'pair' => ['', 0], + 'either' => 0, + 'maybe' => null, + 'createdAt' => new DateTimeImmutable(), + 'day' => new DateTimeImmutable(), + 'nested' => ['deep' => ['value' => 'a']], + 'products' => [], + ]; + } + + /** + * Optional keys on the way in and out, so the generated types carry `?:` in both directions. + * + * @param array{term: non-empty-string, page?: positive-int, filters: array>} $input + * @return array{term: string, page?: int, filters: array>} + */ + #[Query('shapes')] + public function roundtrip(array $input): array + { + return ['term' => $input['term'], 'filters' => $input['filters']]; + } + + /** + * @param array{payload: array{id: ProductId, when: DateTimeString<'Y-m-d'>}, dryRun?: bool} $input + * @return array{accepted: bool, id: ProductId} + */ + #[Command('shapes')] + public function submit(array $input): array + { + return ['accepted' => true, 'id' => $input['payload']['id']]; + } +} diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountFilter.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountFilter.php new file mode 100644 index 0000000..ff57206 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountFilter.php @@ -0,0 +1,19 @@ + array_last(...); + return $io === IO::INPUT ? "{$base}Input" : $base; + } +} diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/Availability.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/Availability.php new file mode 100644 index 0000000..77567f5 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/Availability.php @@ -0,0 +1,17 @@ +slug = strtolower(str_replace(' ', '-', $title)); + } +} diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/Money.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/Money.php new file mode 100644 index 0000000..397ce22 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/Money.php @@ -0,0 +1,21 @@ + */ + public array $tags; +} diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProductId.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProductId.php new file mode 100644 index 0000000..32559c6 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProductId.php @@ -0,0 +1,33 @@ +value; + } +} diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProvisioningException.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProvisioningException.php new file mode 100644 index 0000000..74fa93b --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProvisioningException.php @@ -0,0 +1,13 @@ +). + */ +#[Brand] +#[Named] +final readonly class Sku implements StringValueObject +{ + private function __construct(public string $value) + { + } + + public static function fromStringValue(string $value): static + { + if ($value === '') { + throw new InvalidArgumentException('A Sku may not be empty.'); + } + + return new self($value); + } + + public function toStringValue(): string + { + return $this->value; + } +} diff --git a/tests/Unit/CodeGen/TsOutputFixture.php b/tests/Unit/CodeGen/TsOutputFixture.php new file mode 100644 index 0000000..da6825f --- /dev/null +++ b/tests/Unit/CodeGen/TsOutputFixture.php @@ -0,0 +1,67 @@ + + */ + public const array OPERATION_CLASSES = [ + AccountOperations::class, + CatalogOperations::class, + ShapeOperations::class, + ]; + + public static function directory(): string + { + return __DIR__ . '/../../ts-output/generated'; + } + + /** + * @return array + */ + public static function generate(): array + { + $server = new Server( + EagerlyLoadedOperationRegistry::withClasses( + self::OPERATION_CLASSES, + keyGenerator: new PlainlyExposedKeyGenerator(), + ), + ); + + return new TypescriptServerCodeGenerator([ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations(), + new EmitTypeMap(), + new EmitTanstackQuery(), + new EmitQueryKey(), + ])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); + } +} diff --git a/tests/Unit/CodeGen/TsOutputFixtureTest.php b/tests/Unit/CodeGen/TsOutputFixtureTest.php new file mode 100644 index 0000000..784df9a --- /dev/null +++ b/tests/Unit/CodeGen/TsOutputFixtureTest.php @@ -0,0 +1,43 @@ +toBe([], implode(PHP_EOL, [ + 'The generated TypeScript fixture is out of date:', + ...array_map(fn(string $issue): string => " - {$issue}", $issues), + '', + 'Run `composer codegen:fixture` to regenerate it and verify it still compiles.', + ])); +}); + +test('the fixture covers every generated file kind', function () { + expect(array_keys(TsOutputFixture::generate())) + ->toContain('lib/types.ts') + ->toContain('lib/OperationClient.ts') + ->toContain('lib/DefaultClient.ts') + ->toContain('lib/OperationException.ts') + ->toContain('lib/bindings.ts') + ->toContain('lib/utils.ts') + ->toContain('lib/type-map.ts') + // One module per namespace, so cross-module imports of the shared aliases are compiled too. + ->toContain('accounts.ts') + ->toContain('catalog.ts') + ->toContain('shapes.ts'); +}); diff --git a/tests/ts-output/.gitignore b/tests/ts-output/.gitignore index b512c09..3c3629e 100644 --- a/tests/ts-output/.gitignore +++ b/tests/ts-output/.gitignore @@ -1 +1 @@ -node_modules \ No newline at end of file +node_modules diff --git a/tests/ts-output/generate.php b/tests/ts-output/generate.php new file mode 100644 index 0000000..5e8b76c --- /dev/null +++ b/tests/ts-output/generate.php @@ -0,0 +1,24 @@ +}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: QUERY + * Name: accounts.find + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\AccountOperations::find + */ +export async function find(input: FindInput, options?: OperationOptions) { + return await executeOperation( + 'query', + 'accounts.find', + input, + options + ) +} + +type FindOptions = Omit, 'queryKey' | 'queryFn'>; + +export function findQueryOptions(input: FindInput, options?: FindOptions) { + return queryOptions({ + queryKey: queryKey('accounts', 'find', input), + queryFn: async ({signal}): Promise => { + const result = await find(input, {signal}); + throwOnFailure(result); + return result.data; + }, + ... options, + }); +} + +export function useFindQuery(input: FindInput, queryOptions?: Partial) { + return useQuery(findQueryOptions(input, queryOptions)); +} + +/** @pure */ +export function findQueryKey(input: FindInput) { + return queryKey('accounts', 'find', input); +} + +export type LockResult = {locked:true;}; +export type LockInput = {id:number;}; +export type LockError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}|{type: "quota_exceeded"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: COMMAND + * Name: accounts.lock + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\AccountOperations::lock + */ +export async function lock(input: LockInput, options?: OperationOptions) { + return await executeOperation( + 'command', + 'accounts.lock', + input, + options + ) +} + +export type UnlockResult = {unlocked:true;}; +export type UnlockInput = {id:number;}; +export type UnlockError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: COMMAND + * Name: accounts.unlock + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\AccountOperations::unlock + */ +export async function unlock(input: UnlockInput, options?: OperationOptions) { + return await executeOperation( + 'command', + 'accounts.unlock', + input, + options + ) +} diff --git a/tests/ts-output/generated/catalog.ts b/tests/ts-output/generated/catalog.ts new file mode 100644 index 0000000..414d54e --- /dev/null +++ b/tests/ts-output/generated/catalog.ts @@ -0,0 +1,151 @@ +import type {OperationOptions} from './lib/OperationClient'; +import {executeOperation, throwOnFailure} from './lib/bindings'; +import type {Availability, Brand, Draft, DraftInput, Money, Product, Sku} from './lib/types'; +import {queryKey} from './lib/utils'; +import type {UseQueryOptions} from '@tanstack/react-query'; +import {queryOptions, useQuery} from '@tanstack/react-query'; + +export type PrepareResult = Draft; +export type PrepareInput = DraftInput; +export type PrepareError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: QUERY + * Name: catalog.prepare + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\CatalogOperations::prepare + */ +export async function prepare(input: PrepareInput, options?: OperationOptions) { + return await executeOperation( + 'query', + 'catalog.prepare', + input, + options + ) +} + +type PrepareOptions = Omit, 'queryKey' | 'queryFn'>; + +export function prepareQueryOptions(input: PrepareInput, options?: PrepareOptions) { + return queryOptions({ + queryKey: queryKey('catalog', 'prepare', input), + queryFn: async ({signal}): Promise => { + const result = await prepare(input, {signal}); + throwOnFailure(result); + return result.data; + }, + ... options, + }); +} + +export function usePrepareQuery(input: PrepareInput, queryOptions?: Partial) { + return useQuery(prepareQueryOptions(input, queryOptions)); +} + +/** @pure */ +export function prepareQueryKey(input: PrepareInput) { + return queryKey('catalog', 'prepare', input); +} + +export type ProductResult = Product; +export type ProductInput = {id:(number & Brand<"productId">);}; +export type ProductError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: QUERY + * Name: catalog.product + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\CatalogOperations::product + */ +export async function product(input: ProductInput, options?: OperationOptions) { + return await executeOperation( + 'query', + 'catalog.product', + input, + options + ) +} + +type ProductOptions = Omit, 'queryKey' | 'queryFn'>; + +export function productQueryOptions(input: ProductInput, options?: ProductOptions) { + return queryOptions({ + queryKey: queryKey('catalog', 'product', input), + queryFn: async ({signal}): Promise => { + const result = await product(input, {signal}); + throwOnFailure(result); + return result.data; + }, + ... options, + }); +} + +export function useProductQuery(input: ProductInput, queryOptions?: Partial) { + return useQuery(productQueryOptions(input, queryOptions)); +} + +/** @pure */ +export function productQueryKey(input: ProductInput) { + return queryKey('catalog', 'product', input); +} + +export type RestockResult = {product:Product;restockedAt:string;}; +export type RestockInput = {amount:number;price:Money;sku:Sku;}; +export type RestockError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: COMMAND + * Name: catalog.restock + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\CatalogOperations::restock + */ +export async function restock(input: RestockInput, options?: OperationOptions) { + return await executeOperation( + 'command', + 'catalog.restock', + input, + options + ) +} + +export type SearchResult = {results:Array;total:number;}; +export type SearchInput = {availability?:Availability;limit?:number;term:string;}; +export type SearchError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: QUERY + * Name: catalog.search + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\CatalogOperations::search + */ +export async function search(input: SearchInput, options?: OperationOptions) { + return await executeOperation( + 'query', + 'catalog.search', + input, + options + ) +} + +type SearchOptions = Omit, 'queryKey' | 'queryFn'>; + +export function searchQueryOptions(input: SearchInput, options?: SearchOptions) { + return queryOptions({ + queryKey: queryKey('catalog', 'search', input), + queryFn: async ({signal}): Promise => { + const result = await search(input, {signal}); + throwOnFailure(result); + return result.data; + }, + ... options, + }); +} + +export function useSearchQuery(input: SearchInput, queryOptions?: Partial) { + return useQuery(searchQueryOptions(input, queryOptions)); +} + +/** @pure */ +export function searchQueryKey(input: SearchInput) { + return queryKey('catalog', 'search', input); +} diff --git a/tests/ts-output/generated/lib/DefaultClient.ts b/tests/ts-output/generated/lib/DefaultClient.ts new file mode 100644 index 0000000..24ec908 --- /dev/null +++ b/tests/ts-output/generated/lib/DefaultClient.ts @@ -0,0 +1,105 @@ +import type {OperationClient, OperationOptions} from './OperationClient'; +import type {Failure, Result, Success, WithClientDirectives} from './types'; + +export type Hook = (result: WithClientDirectives>) => Promise | void; + +export class DefaultClient implements OperationClient { + + private hooks: Hook[] = []; + + constructor( + private readonly fetcher: typeof window.fetch, + private readonly options: { + paths: { query: string; command: string; }; + baseUrl?: string; + timeoutMs?: number; + }, + ) { + } + + private joinSignals(signals: (AbortSignal | null | undefined)[]): AbortSignal | undefined { + const filtered = signals.filter((value: AbortSignal | null | undefined): value is AbortSignal => !!value); + if (filtered.length === 0) { + return undefined; + } + + return filtered.length === 1 ? filtered[0] : AbortSignal.any(filtered); + } + + private createJsonEncodedQueryParams(input: unknown): string { + if (!input || typeof input !== 'object') { + return ''; + } + + return Object.entries(input) + .filter(([key, value]) => value !== undefined) + .map(([key, value]) => { + return `${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(value))}`; + }).join('&'); + } + + private async callHooks>(result: WithClientDirectives) { + try { + await Promise.all(this.hooks.map(hook => hook(result))); + return result; + } catch (error) { + console.error('Error while calling hooks', error); + return result; + } + } + + async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise>> { + const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; + const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{fqn}', key)}`; + + const timeoutInMs = this.options?.timeoutMs ?? options?.timeoutMs; + const signal = this.joinSignals([ + options?.signal, + timeoutInMs ? new AbortController().signal : undefined + ]); + + const headers: Record = { + Accept: 'application/json', + "X-Client-ID": "operations-spa" + }; + + if (type === 'command') { + headers['Content-Type'] = 'application/json'; + } + + const queryParams = type === 'query' && input && typeof input === 'object' + ? `?${this.createJsonEncodedQueryParams(input)}` + : ''; + + const response = await this.fetcher(`${fullPath}${queryParams}`, { + method: type === 'query' ? 'GET' : 'POST', + signal, + headers, + body: type === 'command' ? JSON.stringify(input) : undefined, + }); + + const json = await response.json(); + if (!json || typeof json !== 'object') { + throw new Error('Invalid response body. Could not parse json correctly.'); + } + + if (response.ok) { + return await this.callHooks({...json, success: true} as WithClientDirectives>); + } + + return await this.callHooks({ + ...json, + success: false, + code: json?.code ?? response.status, + type: json?.type ?? 'INTERNAL_ERROR' + } as WithClientDirectives>); + } + + registerHook(hook: Hook): () => void { + this.hooks.push(hook); + return () => { + this.hooks = this.hooks.filter(h => h !== hook); + } + } + +} diff --git a/tests/ts-output/generated/lib/OperationClient.ts b/tests/ts-output/generated/lib/OperationClient.ts new file mode 100644 index 0000000..664d168 --- /dev/null +++ b/tests/ts-output/generated/lib/OperationClient.ts @@ -0,0 +1,12 @@ +import type {Result, WithClientDirectives} from './types'; + +export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; + +export interface OperationClient { + execute( + type: "command"|"query", + key: string, + input: unknown, + options?: OperationOptions + ): Promise>>; +} diff --git a/tests/ts-output/generated/lib/OperationException.ts b/tests/ts-output/generated/lib/OperationException.ts new file mode 100644 index 0000000..b0c9a6d --- /dev/null +++ b/tests/ts-output/generated/lib/OperationException.ts @@ -0,0 +1,23 @@ +import type {Failure} from './types'; + +export class OperationException extends Error { + public readonly cause: Failure; + + get code(): number { + const code = this.cause.code; + if (!code || typeof code !== 'number' || Number.isNaN(code)) { + return 500; + } + + return code; + } + + constructor(cause: Failure) { + super(`Operation failed with code ${cause.code}`); + this.cause = cause; + } + + public static is(e: unknown): e is OperationException { + return e instanceof OperationException; + } +} diff --git a/tests/ts-output/generated/lib/bindings.ts b/tests/ts-output/generated/lib/bindings.ts new file mode 100644 index 0000000..14d39d7 --- /dev/null +++ b/tests/ts-output/generated/lib/bindings.ts @@ -0,0 +1,36 @@ +import {DefaultClient} from './DefaultClient'; +import type {OperationClient, OperationOptions} from './OperationClient'; +import {OperationException} from './OperationException'; +import type {Result, Success, WithClientDirectives} from './types'; + +let client: OperationClient|null; + +export function createDefaultClient(fetcher?: typeof window.fetch): DefaultClient { + return new DefaultClient(fetcher ?? fetch, { + paths: {query: '/query/{fqn}', command: '/command/{fqn}'}, + baseUrl: '', + timeoutMs: 10000, + }); +} + +export function setClient(operationClient: OperationClient|null): void { + client = operationClient; +} + +export function throwOnFailure(result: Result): asserts result is Success { + if (!result.success) { + throw new OperationException(result); + } +} + +export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise>> { + if (options?.client) { + return await options.client.execute(type, key, input, options); + } + + if (client) { + return await client.execute(type, key, input, options); + } + + throw new Error('No client set'); +} diff --git a/tests/ts-output/generated/lib/type-map.ts b/tests/ts-output/generated/lib/type-map.ts new file mode 100644 index 0000000..effb080 --- /dev/null +++ b/tests/ts-output/generated/lib/type-map.ts @@ -0,0 +1,6 @@ +import type {Availability, Brand, Draft, DraftInput, Money, Product, Sku} from './types'; + +/** + * Full type map of all operations, input and output types. + */ +export type TypeMap = {query: {'accounts.find': {input: {availability?:(null|Availability);term:string;}, output: {id:number;term:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'catalog.prepare': {input: DraftInput, output: Draft, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'catalog.product': {input: {id:(number & Brand<"productId">);}, output: Product, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'catalog.search': {input: {availability?:Availability;limit?:number;term:string;}, output: {results:Array;total:number;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'shapes.defaults': {input: null, output: {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'shapes.roundtrip': {input: {filters:Record>;page?:number;term:string;}, output: {filters:Record>;page?:number;term:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}}};command: {'accounts.lock': {input: {id:number;}, output: {locked:true;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}|{type: "quota_exceeded"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'accounts.unlock': {input: {id:number;}, output: {unlocked:true;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'catalog.restock': {input: {amount:number;price:Money;sku:Sku;}, output: {product:Product;restockedAt:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'shapes.submit': {input: {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}, output: {accepted:boolean;id:(number & Brand<"productId">);}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}}}}; diff --git a/tests/ts-output/generated/lib/types.ts b/tests/ts-output/generated/lib/types.ts new file mode 100644 index 0000000..837966e --- /dev/null +++ b/tests/ts-output/generated/lib/types.ts @@ -0,0 +1,27 @@ +export type OperationNamespaces = 'accounts'|'catalog'|'shapes'; + +export type Success = {success: true, data: T} +export type Failure = {success: false} & E; +export type Result = Success | Failure; +export type ClientToast = {type: 'success'|'error'|'warning'|'alert'|'info'; message: string;}; +export type ClientRedirect = {url: string; reload: boolean;}; +export type ClientInvalidation = [string, ...unknown[]]; +export type ClientDirectives = { + type: "operations-spa"; + redirect?: ClientRedirect; + toasts?: ClientToast[]; + invalidations?: ClientInvalidation[]; +}; +export type WithClientDirectives = T & {__client?: unknown} +export type SPAClientDirectives = T & {__client: ClientDirectives}; + +declare const __brand: unique symbol; +export type Brand = {readonly [__brand]: TBrand;}; + +/* All branded and named types exported */ +export type Availability = ("IN_STOCK"|"SOLD_OUT"|"PREORDER") +export type Draft = {slug:string;} +export type DraftInput = {title:string;} +export type Money = {amount:number;currency:string;} +export type Product = {availability:Availability;id:(number & Brand<"productId">);price:Money;sku:Sku;tags:Array;title:string;} +export type Sku = (string & Brand<"sku">) diff --git a/tests/ts-output/generated/lib/utils.ts b/tests/ts-output/generated/lib/utils.ts new file mode 100644 index 0000000..ddec305 --- /dev/null +++ b/tests/ts-output/generated/lib/utils.ts @@ -0,0 +1,57 @@ +import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from './types'; + +type QueryNamespaces = 'accounts'|'catalog'|'shapes'; + +const TOAST_TYPES = ['success', 'error', 'warning', 'alert', 'info'] as const; + +export function queryKey(ns: QueryNamespaces, ...args: unknown[]): [string, ...unknown[]] { + return [ns, ...args]; +} + +function isArrayOf(value: unknown, predicate: (item: unknown) => item is V): value is V[] { + return Array.isArray(value) && value.every(predicate); +} + +export function isClientToast(value: unknown): value is ClientToast { + if (!value || typeof value !== 'object') { + return false; + } + + const toast = value as Partial; + return typeof toast.message === 'string' + && typeof toast.type === 'string' + && (TOAST_TYPES as readonly string[]).includes(toast.type); +} + +export function isClientRedirect(value: unknown): value is ClientRedirect { + if (!value || typeof value !== 'object') { + return false; + } + + const redirect = value as Partial; + return typeof redirect.url === 'string' && typeof redirect.reload === 'boolean'; +} + +function isClientInvalidation(value: unknown): value is [string, ...unknown[]] { + return Array.isArray(value) && typeof value[0] === 'string'; +} + +/** + * Narrows to the full directive payload, so it verifies every directive it claims and not just + * the discriminator: a server on an older format would otherwise be narrowed to a shape it does + * not have. Unknown directive keys are ignored, adding one stays backwards compatible. + */ +export function isSpaClientDirectives(result: WithClientDirectives): result is SPAClientDirectives { + if (!result.__client || typeof result.__client !== 'object') { + return false; + } + + const directives = result.__client as Partial; + if (directives.type !== 'operations-spa') { + return false; + } + + return (directives.redirect === undefined || isClientRedirect(directives.redirect)) + && (directives.toasts === undefined || isArrayOf(directives.toasts, isClientToast)) + && (directives.invalidations === undefined || isArrayOf(directives.invalidations, isClientInvalidation)); +} diff --git a/tests/ts-output/generated/shapes.ts b/tests/ts-output/generated/shapes.ts new file mode 100644 index 0000000..a77bca6 --- /dev/null +++ b/tests/ts-output/generated/shapes.ts @@ -0,0 +1,109 @@ +import type {OperationOptions} from './lib/OperationClient'; +import {executeOperation, throwOnFailure} from './lib/bindings'; +import type {Availability, Brand, Money, Product, Sku} from './lib/types'; +import {queryKey} from './lib/utils'; +import type {UseQueryOptions} from '@tanstack/react-query'; +import {queryOptions, useQuery} from '@tanstack/react-query'; + +export type DefaultsResult = {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}; +export type DefaultsInput = null; +export type DefaultsError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: QUERY + * Name: shapes.defaults + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\ShapeOperations::defaults + */ +export async function defaults(options?: OperationOptions) { + return await executeOperation( + 'query', + 'shapes.defaults', + null, + options + ) +} + +type DefaultsOptions = Omit, 'queryKey' | 'queryFn'>; + +export function defaultsQueryOptions(options?: DefaultsOptions) { + return queryOptions({ + queryKey: queryKey('shapes', 'defaults'), + queryFn: async ({signal}): Promise => { + const result = await defaults({signal}); + throwOnFailure(result); + return result.data; + }, + ... options, + }); +} + +export function useDefaultsQuery(queryOptions?: Partial) { + return useQuery(defaultsQueryOptions(queryOptions)); +} + +/** @pure */ +export function defaultsQueryKey() { + return queryKey('shapes', 'defaults'); +} + +export type RoundtripResult = {filters:Record>;page?:number;term:string;}; +export type RoundtripInput = {filters:Record>;page?:number;term:string;}; +export type RoundtripError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: QUERY + * Name: shapes.roundtrip + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\ShapeOperations::roundtrip + */ +export async function roundtrip(input: RoundtripInput, options?: OperationOptions) { + return await executeOperation( + 'query', + 'shapes.roundtrip', + input, + options + ) +} + +type RoundtripOptions = Omit, 'queryKey' | 'queryFn'>; + +export function roundtripQueryOptions(input: RoundtripInput, options?: RoundtripOptions) { + return queryOptions({ + queryKey: queryKey('shapes', 'roundtrip', input), + queryFn: async ({signal}): Promise => { + const result = await roundtrip(input, {signal}); + throwOnFailure(result); + return result.data; + }, + ... options, + }); +} + +export function useRoundtripQuery(input: RoundtripInput, queryOptions?: Partial) { + return useQuery(roundtripQueryOptions(input, queryOptions)); +} + +/** @pure */ +export function roundtripQueryKey(input: RoundtripInput) { + return queryKey('shapes', 'roundtrip', input); +} + +export type SubmitResult = {accepted:boolean;id:(number & Brand<"productId">);}; +export type SubmitInput = {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}; +export type SubmitError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + +/** + * Type: COMMAND + * Name: shapes.submit + * + * @php Tests\Unit\CodeGen\Mocks\TsOutput\ShapeOperations::submit + */ +export async function submit(input: SubmitInput, options?: OperationOptions) { + return await executeOperation( + 'command', + 'shapes.submit', + input, + options + ) +} diff --git a/tests/ts-output/package-lock.json b/tests/ts-output/package-lock.json new file mode 100644 index 0000000..71b818c --- /dev/null +++ b/tests/ts-output/package-lock.json @@ -0,0 +1,87 @@ +{ + "name": "php-ts-bindings-output-check", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "php-ts-bindings-output-check", + "version": "0.0.0", + "devDependencies": { + "@tanstack/react-query": "^5.101.4", + "@types/react": "^19.2.18", + "react": "^19.2.8", + "typescript": "^5.9.3" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/tests/ts-output/package.json b/tests/ts-output/package.json new file mode 100644 index 0000000..a6a3da7 --- /dev/null +++ b/tests/ts-output/package.json @@ -0,0 +1,16 @@ +{ + "name": "php-ts-bindings-output-check", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Typechecks the TypeScript this library generates. Run through `composer codegen:fixture`.", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@tanstack/react-query": "^5.101.4", + "@types/react": "^19.2.18", + "react": "^19.2.8", + "typescript": "^5.9.3" + } +} diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts new file mode 100644 index 0000000..7784c97 --- /dev/null +++ b/tests/ts-output/src/usage.ts @@ -0,0 +1,171 @@ +/** + * Hand written, on purpose. Typechecking `generated/` alone only proves the generated files agree + * with each other — types that are well formed but unusable would pass. This is a consumer of the + * client the way an application writes one, so the compiler has to accept the calls too. + * + * Nothing here runs. It exists to be typechecked by `composer codegen:fixture`. + */ +import {find, lock} from '../generated/accounts'; +import {prepare, product, productQueryKey, productQueryOptions, restock, search, useProductQuery} from '../generated/catalog'; +import {createDefaultClient, setClient, throwOnFailure} from '../generated/lib/bindings'; +import {OperationException} from '../generated/lib/OperationException'; +import type {Brand, Product, SPAClientDirectives} from '../generated/lib/types'; +import type {TypeMap} from '../generated/lib/type-map'; +import {isClientRedirect, isClientToast, isSpaClientDirectives} from '../generated/lib/utils'; +import {defaults, submit, useDefaultsQuery} from '../generated/shapes'; + +setClient(createDefaultClient(fetch)); + +// A brand is opaque: a plain number is not a ProductId, so one has to be minted deliberately at the +// boundary. That is the whole point of the emitted Brand helper. +const productId = 12 as number & Brand<'productId'>; +const sku = 'ABC-1' as string & Brand<'sku'>; + +/** + * The result is a discriminated union: `success` picks the branch, `code` picks the failure. + */ +export async function readProduct(): Promise { + const result = await product({id: productId}); + + if (result.success) { + return result.data; + } + + switch (result.code) { + case 422: + console.warn('invalid input', result.details.fields); + return null; + case 404: + case 500: + return null; + } +} + +/** + * throwOnFailure narrows the same union by asserting, for code that would rather catch than branch. + */ +export async function readProductOrThrow(): Promise { + try { + const result = await product({id: productId}); + throwOnFailure(result); + return result.data; + } catch (error) { + if (OperationException.is(error)) { + console.error('operation failed', error.code, error.cause.type); + } + throw error; + } +} + +/** + * Optional input keys stay optional, and a named enum arrives as its case union. + */ +export async function searchProducts(): Promise { + const withoutOptionals = await search({term: 'lamp'}); + const withOptionals = await search({term: 'lamp', availability: 'IN_STOCK', limit: 10}); + + if (!withoutOptionals.success || !withOptionals.success) { + return []; + } + + return [...withoutOptionals.data.results, ...withOptionals.data.results]; +} + +/** + * A class with one shape per direction: built from a title, read back as a slug. + */ +export async function prepareDraft(): Promise { + const result = await prepare({title: 'Summer sale'}); + return result.success ? result.data.slug : null; +} + +/** + * A query that takes no input at all — the generated signature has no first argument. + */ +export async function readDefaults(): Promise { + const result = await defaults(); + if (!result.success) { + return ''; + } + + // Literals stay literal, unions stay unions, mixed arrives as unknown. + const answer: 42 = result.data.answer; + const either: string | number = result.data.either; + const anything: unknown = result.data.anything; + const pair: [string, number] = result.data.pair; + const lookup: Record = result.data.lookup; + + console.debug(answer, either, anything, pair, lookup); + return result.data.nested.deep.value; +} + +/** + * Commands go over POST and can carry client directives back, which the emitted guards narrow. + */ +export async function lockAccount(id: number): Promise | null> { + const result = await lock({id}); + + if (!result.success && result.code === 400) { + // Two exposed exceptions become a union the client discriminates on. + const type: 'account_locked' | 'quota_exceeded' = result.details.type; + console.warn(type); + return null; + } + + if (!isSpaClientDirectives(result)) { + return null; + } + + for (const toast of result.__client.toasts ?? []) { + if (isClientToast(toast)) { + console.info(toast.type, toast.message); + } + } + + if (result.__client.redirect && isClientRedirect(result.__client.redirect)) { + window.location.href = result.__client.redirect.url; + } + + return result; +} + +/** + * A command whose input nests a branded id and a date that travels as a string. + */ +export async function submitPayload(): Promise { + const result = await submit({payload: {id: productId, when: '2026-08-04'}, dryRun: true}); + return result.success && result.data.accepted; +} + +export async function restockProduct(): Promise { + await restock({sku, amount: 5, price: {amount: 1290, currency: 'CHF'}}); +} + +/** + * Cancellation and a custom timeout travel through OperationOptions. + */ +export async function findAccount(signal: AbortSignal): Promise { + await find({term: 'leo'}, {signal, timeoutMs: 2500}); +} + +/* The tanstack bindings: a key, options, and the hook built on top of them. */ + +export const cacheKey = productQueryKey({id: productId}); + +export const cacheOptions = productQueryOptions({id: productId}, { + staleTime: 30_000, + retry: false, +}); + +export function useProduct() { + const query = useProductQuery({id: productId}, {enabled: true}); + const defaultsQuery = useDefaultsQuery(); + + return {product: query.data, defaults: defaultsQuery.data}; +} + +/* The type map is the whole server as one type, addressable by operation key. */ + +export type ProductInputFromMap = TypeMap['query']['catalog.product']['input']; +export type ProductOutputFromMap = TypeMap['query']['catalog.product']['output']; +export type LockErrorsFromMap = TypeMap['command']['accounts.lock']['errors']; diff --git a/tests/ts-output/tsconfig.json b/tests/ts-output/tsconfig.json new file mode 100644 index 0000000..4ec2db7 --- /dev/null +++ b/tests/ts-output/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + // DefaultClient is written against the browser: window.fetch, fetch and AbortSignal.any. + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + // Not node16: generated imports are extensionless ('./lib/types'), which is what every bundler + // resolves and what node16 rejects. + "moduleResolution": "bundler", + "strict": true, + // Why type-only imports are emitted on their own line instead of merged into the value import. + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "types": [], + + // Deliberately not noUnusedLocals / noUnusedParameters: generated modules import Brand + // unconditionally, because the generator cannot know whether a brand ends up inlined in that + // module, and leaves dropping it to the consuming project's linter. + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["generated/**/*.ts", "src/**/*.ts"] +} From c3af968f5c17dd0aaefba8f00c7f91aa652162c5 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 13:22:20 +0200 Subject: [PATCH 047/101] Update `config.php` to enhance cache key configuration, clarify comments, and improve documentation for custom key generation. --- src/Adapters/Laravel/config/config.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index 56f8840..cd8fe96 100644 --- a/src/Adapters/Laravel/config/config.php +++ b/src/Adapters/Laravel/config/config.php @@ -10,7 +10,7 @@ return [ /** - * Define the path where to locate all query and mutations. + * Define the path where to locate all queries and mutations. */ "discovery_path" => app_path('Operations'), @@ -22,6 +22,10 @@ */ "context" => null, + /** + * Defines the ID length to use for the cache keys. Usually 10 is enough. If you face + * collisions, increase the number + */ "cache" => [ "idLength" => 10, ], @@ -36,6 +40,12 @@ * - custom: MUST define className */ "key" => [ + /** + * Options: obfuscate, plain, custom + * + * For obfuscate: you can define a pepper(string) to add randomness + * For custom: MUST define className + */ "mode" => "obfuscate", /** @@ -46,6 +56,7 @@ /** * Only relevantly for mode custom * Class must implement: Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator + * @see Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator */ "className" => null, ], From ec1cef98dcad335a4cdf8c665511cd8242d91463 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 13:24:29 +0200 Subject: [PATCH 048/101] Refactor `ContextFactory` to clarify PHPDoc comments; add DI for `LocalMetadataMiddleware` configuration. --- src/Adapters/Laravel/Contracts/ContextFactory.php | 4 ++-- .../Laravel/Middleware/LocalMetadataMiddleware.php | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Adapters/Laravel/Contracts/ContextFactory.php b/src/Adapters/Laravel/Contracts/ContextFactory.php index e148907..3e02a7b 100644 --- a/src/Adapters/Laravel/Contracts/ContextFactory.php +++ b/src/Adapters/Laravel/Contracts/ContextFactory.php @@ -8,8 +8,8 @@ interface ContextFactory { /** - * Given an HTTP request for an action or command, create the correct context. - * It must be an object. + * Given a Laravel HTTP request for a query or command, create the correct context. + * It should be an object. */ public function createContextFromHttpRequest(Request $request): mixed; } \ No newline at end of file diff --git a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php index b188b43..c4da4cb 100644 --- a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php +++ b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\Adapters\Laravel\Middleware; use Closure; +use Illuminate\Container\Attributes\Config; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Server\Data\ResolveInfo; @@ -15,10 +16,16 @@ */ final readonly class LocalMetadataMiddleware implements MiddlewareContract { + public function __construct( + #[Config('app.debug')] private bool $isDebuggingEnabled + ) + { + } + #[Override] public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { - if (config('app.debug') !== true) { + if (!$this->isDebuggingEnabled) { return $next($input); } From 375c235974930fd2970d6ee6c6abb761568de56c Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 13:32:29 +0200 Subject: [PATCH 049/101] Refactor `Server` to use `ServerAdapter` for middleware and controller instantiation; add `PsrContainerAdapter` and `NewInstanceAdapter` implementations; update tests accordingly. --- .../Laravel/LaravelServiceProvider.php | 3 +- src/Contracts/ServerAdapter.php | 19 +++++++++++ src/Server/Adapters/NewInstanceAdapter.php | 20 ++++++++++++ src/Server/Adapters/PsrContainerAdapter.php | 26 +++++++++++++++ src/Server/Server.php | 32 ++++--------------- .../Laravel/LaravelHttpControllerTest.php | 7 ++-- tests/Feature/ServerTest.php | 2 +- 7 files changed, 79 insertions(+), 30 deletions(-) create mode 100644 src/Contracts/ServerAdapter.php create mode 100644 src/Server/Adapters/NewInstanceAdapter.php create mode 100644 src/Server/Adapters/PsrContainerAdapter.php diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 4f897b7..4f76a39 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -13,6 +13,7 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\OptimizeCommand; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\HashSha256KeyGenerator; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; @@ -64,7 +65,7 @@ public static function serverFactory( return new Server( registry: $operations, - container: $app, + adapter: new PsrContainerAdapter(container: $app), configuration: new ServerConfiguration() ->withMiddlewares(...$config->get('operations.middleware', [])) ->withExceptions( diff --git a/src/Contracts/ServerAdapter.php b/src/Contracts/ServerAdapter.php new file mode 100644 index 0000000..82cfaf0 --- /dev/null +++ b/src/Contracts/ServerAdapter.php @@ -0,0 +1,19 @@ + $className + * @return MiddlewareContract + */ + public function createMiddleware(string $className): MiddlewareContract; + + /** + * @template TClass + * @param class-string $className + * @return TClass + */ + public function createController(string $className): mixed; +} \ No newline at end of file diff --git a/src/Server/Adapters/NewInstanceAdapter.php b/src/Server/Adapters/NewInstanceAdapter.php new file mode 100644 index 0000000..625a336 --- /dev/null +++ b/src/Server/Adapters/NewInstanceAdapter.php @@ -0,0 +1,20 @@ +container->get($className); + } + + public function createController(string $className): object + { + return $this->container->get($className); + } +} \ No newline at end of file diff --git a/src/Server/Server.php b/src/Server/Server.php index 9b5ae47..9868920 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -5,9 +5,11 @@ use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; +use Le0daniel\PhpTsBindings\Contracts\ServerAdapter; use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; +use Le0daniel\PhpTsBindings\Server\Adapters\NewInstanceAdapter; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidOutputException; @@ -38,9 +40,9 @@ private ErrorPresenter $errorPresenter; public function __construct( - public OperationRegistry $registry, - private null|ContainerInterface $container = null, - public ServerConfiguration $configuration = new ServerConfiguration(), + public OperationRegistry $registry, + private ServerAdapter $adapter = new NewInstanceAdapter(), + public ServerConfiguration $configuration = new ServerConfiguration(), ) { $this->executor = new SchemaExecutor(); @@ -93,10 +95,8 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli // query()/command() total: a missing container binding or a class that is not a // middleware must surface as an RpcError, not as an uncaught exception. try { - $middlewares = array_map($this->resolveMiddleware(...), $middlewareClassNames); - $controllerClass = $this->container - ? $this->container->get($operation->definition->fullyQualifiedClassName) - : new $operation->definition->fullyQualifiedClassName; + $middlewares = array_map(fn($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); + $controllerClass = $this->adapter->createController($operation->definition->fullyQualifiedClassName); } catch (Throwable $throwable) { return $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo); } @@ -143,22 +143,4 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli }, )->execute($input, $context, $resolveInfo, $client); } - - /** - * @param class-string> $className - * @return MiddlewareContract - * @throws ContainerExceptionInterface|NotFoundExceptionInterface - */ - private function resolveMiddleware(string $className): MiddlewareContract - { - $middleware = $this->container - ? $this->container->get($className) - : new $className; - - if (!$middleware instanceof MiddlewareContract) { - throw InvalidMiddlewareException::notAMiddleware($className); - } - - return $middleware; - } } \ No newline at end of file diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index 29c6ae5..be21b51 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -12,6 +12,7 @@ use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; use Le0daniel\PhpTsBindings\Server\Data\Operation; @@ -62,7 +63,7 @@ public function someMethod(array $input, null $context, Client $client): array $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); - $server = new Server($operationRegistry, $app); + $server = new Server($operationRegistry, new PsrContainerAdapter(container: $app)); $controller = new LaravelHttpController( $server, @@ -125,7 +126,7 @@ public function someMethod(array $input, null $context, Client $client): array $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); $controller = new LaravelHttpController( - new Server($operationRegistry, $app), + new Server($operationRegistry, new PsrContainerAdapter(container: $app)), $exceptionHandler, null, ); @@ -193,7 +194,7 @@ public function someMethod(array $input, null $context, Client $client): array $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); $repository->shouldReceive('get')->with('app.debug')->andReturn(false); - $server = new Server($operationRegistry, $app); + $server = new Server($operationRegistry, new PsrContainerAdapter(container: $app)); $controller = new LaravelHttpController( $server, diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index febb4f7..c907880 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -67,7 +67,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { expect($result)->toBeInstanceOf(RpcError::class) ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($result->cause)->toBeInstanceOf(InvalidMiddlewareException::class); + ->and($result->cause)->toBeInstanceOf(TypeError::class); }); test("Middleware emits typescript middleware", function () { From 84e20aee801decb1200e81ae0989b71ade372846 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 13:44:11 +0200 Subject: [PATCH 050/101] Add `LocalMetadataMiddleware` in debug mode; refactor middleware registration in `LaravelServiceProvider`. --- src/Adapters/Laravel/LaravelServiceProvider.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 4f76a39..df17d01 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -11,6 +11,8 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\CodeGenCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\ListCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\OptimizeCommand; +use Le0daniel\PhpTsBindings\Adapters\Laravel\Middleware\LocalMetadataMiddleware; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; @@ -63,11 +65,19 @@ public static function serverFactory( }, ); + $isDebuggingEnabled = $config->get('app.debug', false); + + /** @var list> $middlewares */ + $middlewares = $config->get('operations.middleware', []) |> array_values(...); + if ($isDebuggingEnabled) { + array_unshift($middlewares, LocalMetadataMiddleware::class); + } + return new Server( registry: $operations, adapter: new PsrContainerAdapter(container: $app), configuration: new ServerConfiguration() - ->withMiddlewares(...$config->get('operations.middleware', [])) + ->withMiddlewares(...$middlewares) ->withExceptions( notFound: $config->get('operations.exceptions.not_found', []), unauthenticated: $config->get('operations.exceptions.unauthenticated', []), From 9b512dce23311c8116d715ebf2a4c99b92c5d5e7 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 14:12:13 +0200 Subject: [PATCH 051/101] Expand `README.md` with detailed documentation on `ServerAdapter`, `ServerConfiguration`, error handling, optional generators, preload queries, and unsupported PHPStan constructs. --- README.md | 129 +++++++++++++++++++++++++++++++++++++++++++++----- docs/types.md | 74 +++++++++++++++++++++++++++-- 2 files changed, 186 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d07f378..d04b805 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,19 @@ key the server produced, so this only matters when you call the server by hand. scanning directories; schemas are parsed lazily, per operation, on first use. `CachedOperationRegistry` is the compiled form for production — see [Production](#production). +**`ServerAdapter`** builds your handler classes and middleware. It is two methods — +`createController()` and `createMiddleware()` — and it is the seam for dependency injection: + +| Adapter | Behaviour | +|---|---| +| `NewInstanceAdapter` | The default. Plain `new $className()`, so handlers take no constructor arguments. | +| `PsrContainerAdapter` | Resolves both through a PSR-11 container. | + +Laravel wires `PsrContainerAdapter` to the application container for you. Implement the interface +yourself for a container that is not PSR-11, or to construct handlers some other way. Whatever it +does, a failure to resolve is caught and returned as an `RpcError` — that is part of what keeps +`query()` and `command()` total. + **The handler contract.** Your method is called with three arguments and may declare as few of them as it needs: @@ -249,28 +262,77 @@ new ServerConfiguration()->withMiddlewares(AuthMiddleware::class, LoggingMiddlew `#[Throws]` on a middleware's `handle()` contributes to the error union of every operation it wraps, so the generated TypeScript knows about middleware failures too. +### The rest of `ServerConfiguration` + +The same object carries the server's other two settings. On Laravel both come from +`config/operations.php`; everywhere else this is where you set them. + +`withExceptions()` maps your exceptions onto the [error categories](#errors). Matching is +`instanceof`, so listing a base class covers its subclasses, and an omitted category is left +untouched: + +```php +new ServerConfiguration()->withExceptions( + notFound: [EntityNotFoundException::class], + unauthenticated: [NotLoggedInException::class], + unauthorized: [ForbiddenException::class], +) +``` + +Without this, nothing produces a 401, 403 or 404 except an unknown operation — every other +exception is a 500. + +`coerceQueryInput` (default `false`) applies to queries only, and exists because a URL carries no +types. The generated client JSON-encodes each value and the Laravel adapter decodes it again, so +`?id=1` arrives as the integer `1` and nothing needs coercing. Turn this on when requests come from +somewhere that does not round-trip — a hand-written URL, a form, a transport of your own — and leaf +primitives are coerced to the declared type before validation instead of failing it: + +```php +new ServerConfiguration(coerceQueryInput: true) +``` + ## Types -Anything PHPStan can express about a shape, this library can parse, serialize and emit: +Most of what PHPStan can express about a shape, this library can parse, serialize and emit: | PHPStan | TypeScript | |---|---| | `string`, `int`, `float`, `bool`, `null`, `mixed` | `string`, `number`, `number`, `boolean`, `null`, `unknown` | +| `numeric`, `scalar` | `(number)`, `(number\|boolean\|string)` | | `'foo'`, `123`, `true`, `MyEnum::CASE` | `"foo"`, `123`, `true`, `"CASE"` | -| `array{name: string, age?: int}` | `{name:string;age?:number;}` | +| `array{name: string, age?: int}`, `object{name: string}` | `{age?:number;name:string;}` | | `list`, `T[]`, `array` | `Array` | | `array` | `Record` | | `array{string, int}` | `[string,number]` | | `A\|B`, `?T` | `(A\|B)`, `(null\|T)` | +| `A&B` (shapes of the same kind) | `({a:string;}&{b:number;})` | | `MyEnum` | `("OPEN"\|"SHIPPED")` | | `DateTimeImmutable`, `DateTimeString<'Y-m-d'>` | `string` | | `positive-int`, `non-empty-string`, … | `number`, `string` — refinement enforced server-side | +Object properties are emitted in a canonical order, sorted by name — which is why `age` comes first +above. Declaration order does not reach the client, so reordering a PHP property is not a change to +the generated type. + Local and imported types work too: `@phpstan-type` and `@phpstan-import-type` are resolved against the declaring class, as are `use` statements and generics. +### Not supported + +The parser understands a subset of PHPStan, not all of it. These are valid PHPStan that it rejects, +with an `InvalidSyntaxException` when the schema is parsed: + +`class-string` · `key-of` · `value-of` · `int-mask` · `int-mask-of` · `callable(…)` · +`Closure(…): T` · `iterable` · `array{foo: int, ...}` (unsealed) · `array{}` · +`($x is int ? A : B)` · `Foo` · `$this` · `static` · `self` + +One trap worth knowing up front: bare `object` is not an alias for `unknown` — it is a syntax +error. Write `object{…}` with the shape. + **[→ Full type reference](docs/types.md)** — refinements, utility types (`Pick`, `Omit`, -`BrandedString`, `DateTimeString`), value objects, `#[Castable]`, brands and named types. +`BrandedString`, `DateTimeString`), value objects, `#[Castable]`, brands and named types, and the +[full list of what is not supported](docs/types.md#not-supported). ## Errors @@ -278,14 +340,16 @@ Every failure the client can see is one of six categories: | Code | `type` | When | |---|---|---| -| 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* marked `#[ExposeAs]` | +| 422 | `INVALID_INPUT` | The input did not match its type | | 401 | `AUTHENTICATION_ERROR` | An exception you mapped as unauthenticated | | 403 | `AUTHORIZATION_ERROR` | An exception you mapped as unauthorized | | 404 | `NOT_FOUND` | Unknown operation, or an exception you mapped as not-found | -| 422 | `INVALID_INPUT` | The input did not match its type | +| 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* marked `#[ExposeAs]` | | 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | -The first match wins, in that order. Anything unrecognised is a 500 — an exception is never exposed +The table is in resolution order, and the first match wins. That order is why `DOMAIN_ERROR` sits +second to last: an exception you have explicitly mapped onto a category stays in that category even +when it also carries `#[ExposeAs]`. Anything unrecognised is a 500 — an exception is never exposed by accident. **Exposing a domain error takes two keys.** The operation declares that it can throw it, and the @@ -339,6 +403,9 @@ code lives in your repo. .ts one module per namespace, one function per operation ``` +That is the default output. The [optional generators](#optional-generators) add to it: `type-map` +writes one more file, the other two write into the `.ts` modules that are already there. + The envelope every call resolves to: ```typescript @@ -363,16 +430,20 @@ the per-call `options.client` both take one. `throwOnFailure(result)` narrows a `Result` to its success branch and throws an `OperationException` otherwise, for call sites that would rather not branch. -**Optional generators**, off by default: +### Optional generators + +Three more generators ship, all off by default: ```bash php artisan operations:codegen resources/js/operations --with=tanstack-query,query-key,type-map ``` `tanstack-query` emits `QueryOptions()` and `useQuery()` for `@tanstack/react-query`; -`query-key` emits standalone query keys; `type-map` emits a `TYPE_MAP` of every operation. Use -`--without=` to drop a default generator, and `--naming=` to choose how functions are named -(`name`, `fqn`, `operation-prefix`, `namespace-postfix`, or `Class::method` for your own rule). +`query-key` emits standalone query keys; `type-map` writes `lib/type-map.ts`, exporting a `TypeMap` +that maps every operation to its input, output and error types. Use `--without=` to drop a default +generator, `--ignore=` to skip a namespace (or one operation, as `namespace.name`), and `--naming=` +to choose how functions are named (`name`, `fqn`, `operation-prefix`, `namespace-postfix`, or +`Class::method` for your own rule). Write your own generator by implementing `GeneratesLibFiles` (gets every operation, writes shared lib files) or `GeneratesOperationCode` (gets one operation, writes its code) and passing it with @@ -396,7 +467,7 @@ public function create(array $input, mixed $context, Client $client): array { $client->success('Saved'); $client->redirect('/docs/123', reload: true); - $client->invalidate('users', $input['id']); + $client->invalidate('users', '123'); return ['id' => '123']; } @@ -412,11 +483,16 @@ data: "__client": { "redirect": {"url": "/docs/123", "reload": true}, "toasts": [{"type": "success", "message": "Saved"}], + "invalidations": [["users", "123"]], "type": "operations-spa" } } ``` +The full interface is `redirect()`, `invalidate()`, `toast()`, and one shorthand per toast type — +`success()`, `error()`, `warning()`, `alert()` and `info()`. Keys are only present when something +called for them. + Otherwise a `NullClient` is used and every call is a no-op, so handlers never need to know which kind of client is on the other end. `lib/utils.ts` ships `isSpaClientDirectives()`, `isClientToast()` and `isClientRedirect()` for reading them back. @@ -476,7 +552,7 @@ php artisan operations:codegen resources/js/operations |---|---| | `operations:list` | Every registered operation with its URI, method and handler. | | `operations:codegen {directory}` | Generate the TypeScript client. `--verify` checks for drift instead of writing — use it in CI. | -| `operations:optimize` | Compile the registry to `bootstrap/cache/operations.php`. | +| `operations:optimize` | Compile the registry to `bootstrap/cache/operations.php`. `--id-length=` overrides `cache.idLength` for the run. | | `operations:clear-optimize` | Remove it. | The last two are wired into `php artisan optimize` and `optimize:clear`. @@ -484,6 +560,25 @@ The last two are wired into `php artisan optimize` and `optimize:clear`. > `operations:codegen` removes every `.ts` file under the target directory before writing. Point it > at a directory it owns, not at a shared frontend folder. +### Preloading a query + +`Preloader` runs a query server-side during the request that renders the page, so the data is in the +page instead of being fetched after it loads. It is resolved from the container: + +```php +public function show(Preloader $preloader): Response +{ + return Inertia::render('Users', [ + 'users' => $preloader->preload('users', 'get', ['id' => 1], $context), + ]); +} +``` + +You get back `['response' => …, 'queryKey' => ['users', 'get', ['id' => 1]]]`. The key is built the +same way the generated `--with=query-key` and `tanstack-query` code builds it, so a TanStack cache +seeded with that pair will not refetch. Use `preloadMany()` for several at once. A query that fails +throws — this is your own code calling your own operation, not untrusted input. + ## Production Reflecting and parsing every schema on every request is real overhead. Compile the whole registry @@ -519,8 +614,10 @@ The core knows nothing about Laravel. Build a server, run an operation, and shap however you like: ```php +use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Server; @@ -530,7 +627,13 @@ $server = new Server( __DIR__ . '/src/Operations', keyGenerator: new PlainlyExposedKeyGenerator(), ), - container: $psrContainer, // optional; without it handlers are instantiated with `new` + adapter: new PsrContainerAdapter($psrContainer), + configuration: new ServerConfiguration() + ->withMiddlewares(AuthMiddleware::class) + ->withExceptions( + notFound: [EntityNotFoundException::class], + unauthenticated: [NotLoggedInException::class], + ), ); $result = $server->command('users.create', $input, $myContext, new NullClient()); diff --git a/docs/types.md b/docs/types.md index d1cb51d..c5f530a 100644 --- a/docs/types.md +++ b/docs/types.md @@ -5,6 +5,7 @@ Everything this library knows how to parse, serialize and emit. The short versio - [PHP to TypeScript](#php-to-typescript) - [Refinement types](#refinement-types) +- [Not supported](#not-supported) - [Utility types](#utility-types) - [DateTimeString](#datetimestring) - [Value objects](#value-objects) @@ -45,6 +46,13 @@ Unions and intersections are **always** parenthesised, so a union nested inside never be misread. Members are deduplicated by their rendered form: `int\|string\|int` emits `(number|string)`. +Object properties are emitted in a canonical order, sorted by name, so `array{name: string, age: int}` +emits `{age:number;name:string;}`. Declaration order never reaches the client, which means reordering +a PHP property or a constructor parameter does not change the generated type. + +Intersections join struct shapes of the same kind — all `array{…}` or all `object{…}`. An +intersection of scalars, or one mixing the two shape kinds, is [not supported](#not-supported). + Enums emit as a union of their **case names**, not their backing values. A backed enum that should travel as its backing value opts in by implementing `StringValueObject` — see [value objects](#value-objects). @@ -76,8 +84,8 @@ Some PHPStan types narrow a PHP type further than PHP itself can express: `posit A refinement disappears in TypeScript — `positive-int` is `number` — because TypeScript cannot express it either. It is enforced on the server. -`int-mask<…>`, `int-mask-of<…>` and `class-string` are **not** supported. Integer refinement is -`int` and the four shorthands above, nothing else. +Integer refinement is `int` and the four shorthands above, nothing else — `int-mask<…>` +and `int-mask-of<…>` are [not supported](#not-supported). There is no attribute or annotation for attaching a check of your own: a property is refined by its PHPStan type or not at all. That is what keeps a parsed schema equal to the type it was @@ -100,6 +108,65 @@ library assumes static analysis does its job. Serialization still enforces *types*: a `string` where an `int` is declared fails either way. Only the PHPStan refinement on top of the type is skipped. +## Not supported + +The parser implements a subset of PHPStan. The subset is deliberate: every type it accepts has to +be something it can *both* check at runtime and emit as TypeScript, which rules out anything +describing PHP-side-only structure (callables, resources) or anything with no runtime +representation to check (`class-string`, conditional types). + +Everything below is valid PHPStan. Writing it in a type this library parses is an error, not a +silently-degraded `unknown`. + +### Rejected outright + +``` +array{foo: int, ...} unsealed array shapes +array{...} +array{} the empty shape +callable(int): void +Closure(int, ...): void callable signatures +$this +Foo::* wildcard class-constant reference +Foo default generic arguments +($x is int ? string : bool) conditional types +``` + +### Not recognised at all + +These reach the parser as an unknown identifier and fail with `No parser found.`: + +| | | +|---|---| +| `class-string`, `class-string` | `literal-string`, `interface-string` | +| `int-mask<…>`, `int-mask-of<…>` | `key-of`, `value-of` | +| `iterable`, `resource` | `void`, `never`, `array-key` | +| `static`, `self`, `parent` | bare `callable`, bare `Closure` | + +`int-mask` / `int-mask-of` are the deliberate case: integer refinement stops at `int` and +its shorthands, so a bitmask type has no representation here. + +### Supported, but narrower than PHPStan + +The traps — each is accepted by PHPStan and rejected here: + +| You write | What happens | +|---|---| +| `object` | Syntax error, "Expected brace". Bare `object` is not `unknown`; write `object{…}`. | +| `array` | Rejected. A refined key type is not silently loosened to `string`. | +| `array{2: string, 5: int}` | Rejected. Integer-keyed tuples must run sequentially from `0`. | +| `object{0: string}` | Rejected. Object-shape keys must be identifiers or quoted strings. | +| `list{int, string}` | Psalm's keyed-list syntax. Write `array{int, string}`. | +| `A&B` where either side is not a shape | Intersections join struct shapes of the same kind — all `array{…}` or all `object{…}`, never mixed, never scalars. | +| `Foo` on a class declaring one `@template` | Rejected. The generic count must match the declaration. | + +### And no custom refinements + +Worth repeating here, because it is the same boundary from the other side: there is no attribute +for attaching a check of your own — see [refinement types](#refinement-types). A property is +refined by its PHPStan type or not at all, and rules PHPStan cannot express belong in a +[value object](#value-objects). + ## Utility types A handful of type names are understood in docblocks even though no such PHP class exists. They are @@ -379,8 +446,7 @@ different shapes anywhere in a run. A handful of names the generated types file rejected outright: `Brand`, `Success`, `Failure`, `Result`, `OperationNamespaces`, `WithClientDirectives`, -`SPAClientDirectives`, `ClientDirectives`, `ClientToast`, `ClientRedirect`, `ClientInvalidation`, -`TYPE_MAP`. +`SPAClientDirectives`, `ClientDirectives`, `ClientToast`, `ClientRedirect`, `ClientInvalidation`. Brands and names are pure code generation metadata with zero runtime impact: values travel the wire in their plain shape, and the metadata is stripped from cached ASTs entirely — TypeScript From 9cd0401eff92c6aff4da6cdb51d0db09c926cd43 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 14:28:38 +0200 Subject: [PATCH 052/101] Add `RenamingMiddleware` and enhance error naming logic for `#[Throws]` declarations; update tests and documentation accordingly. --- README.md | 33 ++++++++---- src/Adapters/Laravel/config/config.php | 3 +- src/CodeGen/Utils/ErrorTypescript.php | 3 +- src/Contracts/Attributes/ExposeAs.php | 5 +- src/Contracts/Attributes/Throws.php | 10 +++- src/Server/Errors/ErrorPresenter.php | 11 ++-- src/Server/Errors/ExposedExceptions.php | 40 +++++++++------ tests/Mocks/Errors/ErrorOperations.php | 10 ++++ tests/Mocks/Errors/RenamingMiddleware.php | 28 +++++++++++ tests/Unit/CodeGen/ErrorTypescriptTest.php | 18 +++++++ .../Unit/Server/Errors/ErrorPresenterTest.php | 50 +++++++++++++++++++ 11 files changed, 174 insertions(+), 37 deletions(-) create mode 100644 tests/Mocks/Errors/RenamingMiddleware.php diff --git a/README.md b/README.md index d04b805..b81939f 100644 --- a/README.md +++ b/README.md @@ -197,8 +197,8 @@ are not re-checked — static analysis already established those. See | `#[Query(namespace, name)]` | method | A read operation, served over GET. | | `#[Command(namespace, name)]` | method | A write operation, served over POST. | | `#[Middleware(class or list)]` | class, method | Middleware to run around this operation. | -| `#[Throws(ExceptionClass)]` | method, repeatable | Declares an exception the operation may throw. | -| `#[ExposeAs(type)]` | exception class | Opts that exception into being shown to the client. | +| `#[Throws(ExceptionClass, as: ?string)]` | method, repeatable | Declares an exception the operation may throw, optionally naming it for the client. | +| `#[ExposeAs(type)]` | exception class | The exception's own name, for every operation that declares it. | | `#[Optional]` | property, parameter | The field may be absent from input. | | `#[Castable(strategy)]` | class | A plain class may be built from input. | | `#[Brand(name)]` | class | Makes the generated TypeScript type opaque. | @@ -260,7 +260,9 @@ new ServerConfiguration()->withMiddlewares(AuthMiddleware::class, LoggingMiddlew ``` `#[Throws]` on a middleware's `handle()` contributes to the error union of every operation it wraps, -so the generated TypeScript knows about middleware failures too. +so the generated TypeScript knows about middleware failures too. It takes `as` like any other +declaration, and when an operation and its middleware declare the same exception, the operation's +name wins. ### The rest of `ServerConfiguration` @@ -344,16 +346,17 @@ Every failure the client can see is one of six categories: | 401 | `AUTHENTICATION_ERROR` | An exception you mapped as unauthenticated | | 403 | `AUTHORIZATION_ERROR` | An exception you mapped as unauthorized | | 404 | `NOT_FOUND` | Unknown operation, or an exception you mapped as not-found | -| 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* marked `#[ExposeAs]` | +| 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* gave a name | | 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | The table is in resolution order, and the first match wins. That order is why `DOMAIN_ERROR` sits second to last: an exception you have explicitly mapped onto a category stays in that category even -when it also carries `#[ExposeAs]`. Anything unrecognised is a 500 — an exception is never exposed -by accident. +when it is named for the client. Anything unrecognised is a 500 — an exception is never exposed by +accident. -**Exposing a domain error takes two keys.** The operation declares that it can throw it, and the -exception itself declares what the client should see: +**Exposing a domain error takes a declaration and a name.** The operation declares that it can +throw the exception, and something gives that exception a name the client sees. The exception can +carry its own: ```php #[ExposeAs('invalid_name')] @@ -368,8 +371,18 @@ public function create(array $input): array { /* ... */ } {"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"type": "invalid_name"}} ``` -An exception declared with `#[Throws]` but not marked `#[ExposeAs]` stays a 500, and so does one -marked `#[ExposeAs]` that no operation declares. +Or the declaration can name it on the spot with `as`, which needs no `#[ExposeAs]` at all — the +point being that the exception does not have to be yours to annotate: + +```php +#[Command('users')] +#[Throws(InvalidNameException::class, as: 'invalid-name')] +public function create(array $input): array { /* ... */ } +``` + +`as` always wins over `#[ExposeAs]`, so the same exception can read differently per operation. What +`as` does not do is skip the declaration: an exception no operation declares with `#[Throws]` is +still a 500, and so is one that is declared but named nowhere. Because both the runtime and the code generator read those attributes from the same place, the generated error union cannot drift from the responses it describes. An operation that declares diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index cd8fe96..706701f 100644 --- a/src/Adapters/Laravel/config/config.php +++ b/src/Adapters/Laravel/config/config.php @@ -84,7 +84,8 @@ /** * Map your exceptions onto the server's built-in error categories. Anything not listed here and - * not marked with #[ExposeAs] is reported to the client as an internal error. + * neither marked with #[ExposeAs] nor named via #[Throws(..., as: ...)] is reported to the + * client as an internal error. * * Matching is instanceof: listing a base class covers every subclass of it. */ diff --git a/src/CodeGen/Utils/ErrorTypescript.php b/src/CodeGen/Utils/ErrorTypescript.php index 84bd35f..2d010cc 100644 --- a/src/CodeGen/Utils/ErrorTypescript.php +++ b/src/CodeGen/Utils/ErrorTypescript.php @@ -14,7 +14,8 @@ * The runtime counterpart is Server\Errors\ErrorPresenter, and the branches below appear in its * resolution order. Only reachable branches are emitted: the two auth categories exist solely * because exceptions were mapped onto them, and a domain error only exists where an operation - * declares an #[ExposeAs] exception via #[Throws]. Everything else the server produces on its own. + * declares an exception via #[Throws] that resolves to a name - its own `as`, or #[ExposeAs] on the + * exception class. Everything else the server produces on its own. */ final readonly class ErrorTypescript { diff --git a/src/Contracts/Attributes/ExposeAs.php b/src/Contracts/Attributes/ExposeAs.php index b39ab9e..67af489 100644 --- a/src/Contracts/Attributes/ExposeAs.php +++ b/src/Contracts/Attributes/ExposeAs.php @@ -5,7 +5,10 @@ use Attribute; /** - * Marks an exception as Exposable to the client. + * Marks an exception as Exposable to the client, under the given type name. + * + * This is the exception's own name, used by every operation that declares it via #[Throws]. A + * #[Throws(..., as: ...)] naming it at the declaration site overrides this one. */ #[Attribute(Attribute::TARGET_CLASS)] final readonly class ExposeAs diff --git a/src/Contracts/Attributes/Throws.php b/src/Contracts/Attributes/Throws.php index 7cacd05..3ec7468 100644 --- a/src/Contracts/Attributes/Throws.php +++ b/src/Contracts/Attributes/Throws.php @@ -9,17 +9,23 @@ * Used to declare which exceptions an endpoint can throw. If no exception is explicitly declared, * a 500 Internal Server error is returned to the client. * - * Declared exceptions are only exposed to the client if their class is marked with the ExposeAs attribute. + * A declared exception is only exposed to the client once it has a name to be exposed under. That + * name comes from `as`, or - when `as` is omitted - from the ExposeAs attribute on the exception + * class itself. `as` always wins, and exposes the exception whether or not its class carries + * ExposeAs: the exception may be one you cannot annotate, or one worth naming differently here. + * An exception with neither stays a 500. */ #[Attribute(Attribute::TARGET_METHOD|Attribute::IS_REPEATABLE)] final readonly class Throws { /** * @param class-string $exceptionClass + * @param non-empty-string|null $as */ public function __construct( public string $exceptionClass, + public ?string $as = null, ) { } -} \ No newline at end of file +} diff --git a/src/Server/Errors/ErrorPresenter.php b/src/Server/Errors/ErrorPresenter.php index 8d6d213..4d29879 100644 --- a/src/Server/Errors/ErrorPresenter.php +++ b/src/Server/Errors/ErrorPresenter.php @@ -21,7 +21,7 @@ * * Resolution is top to bottom and the first match wins, so the exposed domain error sits last, * just before the catch all: an exception that is explicitly categorised stays categorised even - * when it also carries #[ExposeAs]. + * when it is named by #[Throws(..., as: ...)] or carries #[ExposeAs]. * * The TypeScript counterpart of this catalogue lives in CodeGen\Utils\ErrorTypescript. */ @@ -103,13 +103,12 @@ private function matchesAny(Throwable $throwable, array $classNames): bool } /** - * An exception is a domain error only if the operation declares it via #[Throws] and the - * exception itself opts into being shown via #[ExposeAs]. + * An exception is a domain error only if the operation declares it via #[Throws] and that + * declaration resolves to a name - from its own `as`, or from #[ExposeAs] on the exception. + * Declared but unnamed is null, and falls through to the catch all. */ private function exposedTypeOf(Throwable $throwable, Definition $definition): ?string { - return in_array($throwable::class, ExposedExceptions::declaredFor($definition), true) - ? ExposedExceptions::exposedTypeOf($throwable::class) - : null; + return ExposedExceptions::declaredFor($definition)[$throwable::class] ?? null; } } diff --git a/src/Server/Errors/ExposedExceptions.php b/src/Server/Errors/ExposedExceptions.php index 1a8bf18..46bb7e8 100644 --- a/src/Server/Errors/ExposedExceptions.php +++ b/src/Server/Errors/ExposedExceptions.php @@ -6,7 +6,6 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Throws; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Utils\Lists; -use ReflectionAttribute; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -15,20 +14,21 @@ /** * Which exceptions an operation may surface to the client, and under what name. * - * #[Throws] declares what an operation can throw; #[ExposeAs] on the exception itself decides - * whether that is something the client is allowed to see. Both the runtime presenter and the - * TypeScript code generator answer that question here, so a generated error union and the - * responses it describes can never drift apart. + * #[Throws] declares what an operation can throw and may name it right there via `as`; #[ExposeAs] + * on the exception itself is the name to fall back on. An exception with neither is declared but + * unnamed, and stays internal. Both the runtime presenter and the TypeScript code generator answer + * that question here, so a generated error union and the responses it describes can never drift + * apart. */ final readonly class ExposedExceptions { /** * Every exception declared via #[Throws], on the operation method and on the handle() method of - * each of its middlewares. handle() is guaranteed to exist: every middleware implements - * MiddlewareContract. + * each of its middlewares, mapped to the name the client sees - or null where it stays + * internal. handle() is guaranteed to exist: every middleware implements MiddlewareContract. * * @param Definition $definition - * @return list> + * @return array, string|null> * @throws ReflectionException */ public static function declaredFor(Definition $definition): array @@ -45,18 +45,26 @@ public static function declaredFor(Definition $definition): array } } - return array_map(function (ReflectionAttribute $attribute): string { - /** @var Throws $instance */ - $instance = $attribute->newInstance(); - return $instance->exceptionClass; - }, $attributes); + $declared = []; + foreach ($attributes as $attribute) { + /** @var Throws $throws */ + $throws = $attribute->newInstance(); + + // The first name given wins. The operation is reflected before the middleware wrapping + // it, so an operation states its own contract first; and because only a name displaces + // null, a bare #[Throws] never silences an `as` declared elsewhere for the same class. + $declared[$throws->exceptionClass] ??= $throws->as ?? self::exposeAsOf($throws->exceptionClass); + } + + return $declared; } /** + * The name an exception class gives itself, used when no #[Throws] names it. + * * @param class-string $exceptionClass - * @return string|null */ - public static function exposedTypeOf(string $exceptionClass): ?string + private static function exposeAsOf(string $exceptionClass): ?string { $attributes = new ReflectionClass($exceptionClass)->getAttributes(ExposeAs::class); return count($attributes) === 0 @@ -73,7 +81,7 @@ public static function exposedTypeOf(string $exceptionClass): ?string */ public static function exposedTypesFor(Definition $definition): array { - return array_map(self::exposedTypeOf(...), self::declaredFor($definition)) + return array_values(self::declaredFor($definition)) |> Lists::filterNullValues(...) |> Lists::unique(...); } diff --git a/tests/Mocks/Errors/ErrorOperations.php b/tests/Mocks/Errors/ErrorOperations.php index 74acd57..51d3d90 100644 --- a/tests/Mocks/Errors/ErrorOperations.php +++ b/tests/Mocks/Errors/ErrorOperations.php @@ -16,6 +16,16 @@ public function declaresThrows(): void { } + /** + * Naming at the declaration site: UnexposedException carries no #[ExposeAs] and is exposed + * anyway, while ExposedDomainException's own name is overridden. + */ + #[Throws(UnexposedException::class, as: 'renamed_failure')] + #[Throws(ExposedDomainException::class, as: 'overridden_failure')] + public function declaresRenamedThrows(): void + { + } + public function declaresNothing(): void { } diff --git a/tests/Mocks/Errors/RenamingMiddleware.php b/tests/Mocks/Errors/RenamingMiddleware.php new file mode 100644 index 0000000..e9717c9 --- /dev/null +++ b/tests/Mocks/Errors/RenamingMiddleware.php @@ -0,0 +1,28 @@ + + */ +final class RenamingMiddleware implements MiddlewareContract +{ + #[Throws(MiddlewareDomainException::class, as: 'renamed_middleware_failure')] + #[Throws(ExposedDomainException::class, as: 'middleware_name')] + #[Throws(UnexposedException::class, as: 'middleware_named_it')] + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + { + return $next($input); + } +} diff --git a/tests/Unit/CodeGen/ErrorTypescriptTest.php b/tests/Unit/CodeGen/ErrorTypescriptTest.php index c1ab781..2a61171 100644 --- a/tests/Unit/CodeGen/ErrorTypescriptTest.php +++ b/tests/Unit/CodeGen/ErrorTypescriptTest.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Tests\Mocks\Errors\ErrorOperations; use Tests\Mocks\Errors\RecordMissingException; +use Tests\Mocks\Errors\RenamingMiddleware; use Tests\Mocks\Errors\ThrowingMiddleware; /** @@ -77,6 +78,23 @@ function typescriptDefinition(string $methodName = 'declaresThrows', array $midd ])); }); +test('the domain branch is named by the as of a Throws, not by the ExposeAs it overrides', function () { + $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresRenamedThrows')); + + expect($union)->toContain('{code: 400, type: "DOMAIN_ERROR", details: {type: "renamed_failure"}|{type: "overridden_failure"}}') + ->and($union)->not->toContain('domain_failure'); +}); + +test('an exception declared by both the operation and a middleware appears once', function () { + $union = ErrorTypescript::forOperation( + new ServerConfiguration(), + typescriptDefinition('declaresRenamedThrows', [RenamingMiddleware::class]), + ); + + expect($union)->toContain('{code: 400, type: "DOMAIN_ERROR", details: {type: "renamed_failure"}|{type: "overridden_failure"}|{type: "renamed_middleware_failure"}}') + ->and($union)->not->toContain('middleware_name'); +}); + test('an operation declaring nothing exposable emits no domain branch', function () { $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresNothing')); diff --git a/tests/Unit/Server/Errors/ErrorPresenterTest.php b/tests/Unit/Server/Errors/ErrorPresenterTest.php index a4a370c..7265548 100644 --- a/tests/Unit/Server/Errors/ErrorPresenterTest.php +++ b/tests/Unit/Server/Errors/ErrorPresenterTest.php @@ -12,6 +12,7 @@ use Tests\Mocks\Errors\ExposedDomainException; use Tests\Mocks\Errors\MiddlewareDomainException; use Tests\Mocks\Errors\RecordMissingException; +use Tests\Mocks\Errors\RenamingMiddleware; use Tests\Mocks\Errors\ThrowingMiddleware; use Tests\Mocks\Errors\UndeclaredExposedException; use Tests\Mocks\Errors\UnexposedException; @@ -116,6 +117,55 @@ function errorResolveInfo(): ResolveInfo ->and($error->details)->toEqual(['type' => 'middleware_failure']); }); +test('the as name of a Throws exposes an exception that carries no ExposeAs', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new UnexposedException(), errorDefinition('declaresRenamedThrows'), null); + + expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->details)->toEqual(['type' => 'renamed_failure']); +}); + +test('the as name of a Throws wins over the ExposeAs on the exception', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new ExposedDomainException(), errorDefinition('declaresRenamedThrows'), null); + + expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->details)->toEqual(['type' => 'overridden_failure']); +}); + +test('a middleware can name the exceptions it declares too', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new MiddlewareDomainException(), errorDefinition('declaresNothing', [RenamingMiddleware::class]), null); + + expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->details)->toEqual(['type' => 'renamed_middleware_failure']); +}); + +test('the operation names an exception before the middleware wrapping it does', function () { + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new ExposedDomainException(), errorDefinition('declaresRenamedThrows', [RenamingMiddleware::class]), null); + + expect($error->details)->toEqual(['type' => 'overridden_failure']); +}); + +test('a Throws without a name never silences one that has a name', function () { + // declaresThrows declares UnexposedException without naming it; the middleware does name it. + $error = new ErrorPresenter(new ServerConfiguration()) + ->present(new UnexposedException(), errorDefinition('declaresThrows', [RenamingMiddleware::class]), null); + + expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->details)->toEqual(['type' => 'middleware_named_it']); +}); + +test('an as name does not exempt an exception from the configured categories', function () { + $configuration = new ServerConfiguration()->withExceptions(notFound: [UnexposedException::class]); + + $error = new ErrorPresenter($configuration) + ->present(new UnexposedException(), errorDefinition('declaresRenamedThrows'), null); + + expect($error->type)->toBe(ErrorType::NOT_FOUND); +}); + test('a declared exception without ExposeAs falls through to the catch all', function () { $error = new ErrorPresenter(new ServerConfiguration()) ->present(new UnexposedException(), errorDefinition(), null); From fed2b3bce5676eeaf2305d1ecd1b34abab6c3974 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 15:54:35 +0200 Subject: [PATCH 053/101] Make the executor fail loudly instead of degrading silently The library's rule is that nothing unrepresentable is silently degraded - TypescriptGenerator throws rather than emit a placeholder. The executor did not hold to it in six places, each of which returned a Success carrying a value the caller never produced, or a Failure with nothing to act on. - Server serializes with partialFailures off. The default substitutes null wherever a value fails under a null-accepting union, and Server never read Success::$issues, so a mismatched `?T` was answered as 200 with `null`. Now it is a 500 with InvalidOutputException, as documented. - StringNode::coerce() only casts scalars. `(string)` on an array produced the literal "Array" and on an object threw - outside the executor's try/catch. ValueObjectNode already guarded this; StringNode was the outlier. - FloatNode::serializeValue() makes the same test parseValue() does. Accepting a numeric string repaired the application's own output, and is_numeric() let " 1e3" through. - Context::removeCurrentIssues() compares path segments, not raw string prefixes. 'items.0' merely starts with 'item' and was deleted with it, while the root path '__root' prefixes nothing nested and cleared nothing. - Nine handler and leaf sites returned INVALID with no Issue, yielding a 422 with empty `fields`. Issue::invalidType() is the shared factory; the RejectsInvalidType trait now delegates to it. - TupleHandler::serialize() checks arity before indexing. It read past the end of a short tuple and raised a PHP warning. An unclaimed node class now throws SchemaException from both executor arms, matching AstValidator: that is a broken AST, not invalid data. Co-Authored-By: Claude Opus 5 (1M context) --- src/Executor/Data/Context.php | 17 ++- src/Executor/Data/Issue.php | 12 ++ src/Executor/Data/SerializationOptions.php | 8 ++ src/Executor/Handlers/CustomClassHandler.php | 11 +- src/Executor/Handlers/ListHandler.php | 3 + src/Executor/Handlers/RecordHandler.php | 2 + src/Executor/Handlers/TupleHandler.php | 31 +++++ src/Executor/Handlers/UnionHandler.php | 11 ++ src/Executor/SchemaExecutor.php | 8 +- src/Parser/Nodes/Leaf/EnumNode.php | 1 + src/Parser/Nodes/Leaf/FloatNode.php | 5 +- src/Parser/Nodes/Leaf/LiteralNode.php | 24 +++- src/Parser/Nodes/Leaf/RejectsInvalidType.php | 8 +- src/Parser/Nodes/Leaf/StringNode.php | 5 +- src/Server/Server.php | 8 +- tests/Feature/Operations/TestClass.php | 15 +++ tests/Feature/ServerTest.php | 11 ++ tests/Unit/Executor/ContextPathTest.php | 71 ++++++++++++ tests/Unit/Executor/StrictnessTest.php | 114 +++++++++++++++++++ 19 files changed, 347 insertions(+), 18 deletions(-) create mode 100644 tests/Unit/Executor/ContextPathTest.php create mode 100644 tests/Unit/Executor/StrictnessTest.php diff --git a/src/Executor/Data/Context.php b/src/Executor/Data/Context.php index 21e41b3..9023761 100644 --- a/src/Executor/Data/Context.php +++ b/src/Executor/Data/Context.php @@ -47,11 +47,26 @@ public function addIssue(Issue $issue): void $this->issues[$this->pathAsString()][] = $issue; } + /** + * Discards the issues recorded at the current path and everything nested below it - what a + * union does once an arm matches, so the arms it rejected leave no diagnostics behind. + * + * Matching is by path segment, not by string prefix: 'items.0' merely starts with the text + * 'item' and must survive, while the root path is spelled '__root' and is a prefix of no + * nested path at all, so a raw prefix test would clear nothing there. + */ public function removeCurrentIssues(): void { + if ($this->path === []) { + $this->issues = []; + return; + } + + $current = $this->pathAsString(); foreach ($this->issues as $path => $issues) { // This is needed as path '0' is transformed to int in php. - if (str_starts_with((string) $path, $this->pathAsString())) { + $path = (string) $path; + if ($path === $current || str_starts_with($path, "{$current}.")) { unset($this->issues[$path]); } } diff --git a/src/Executor/Data/Issue.php b/src/Executor/Data/Issue.php index e72d6ed..4b25158 100644 --- a/src/Executor/Data/Issue.php +++ b/src/Executor/Data/Issue.php @@ -28,6 +28,18 @@ public function __construct( }; } + /** + * The value did not have the declared type. Every handler and leaf reports this the same way, + * so a failure is never returned without a diagnostic the client can act on. + */ + public static function invalidType(string $expected, mixed $value): self + { + return new self( + IssueMessage::INVALID_TYPE, + ['message' => "Expected value of type {$expected}, got: " . gettype($value)], + ); + } + /** * @param list $messages * @return list diff --git a/src/Executor/Data/SerializationOptions.php b/src/Executor/Data/SerializationOptions.php index 5840cfe..d5939f4 100644 --- a/src/Executor/Data/SerializationOptions.php +++ b/src/Executor/Data/SerializationOptions.php @@ -9,6 +9,14 @@ */ final readonly class SerializationOptions { + /** + * @param bool $partialFailures Substitute null wherever a value fails to serialize under a + * null-accepting union, and report the result as a Success carrying issues, rather than + * failing outright. Useful when you are serializing best-effort and will inspect + * Success::isPartial() yourself. The RPC server never enables it - see Server::execute() - + * because answering 200 with data the operation did not produce is not something a client + * can detect. + */ public function __construct( public bool $partialFailures = true, ) diff --git a/src/Executor/Handlers/CustomClassHandler.php b/src/Executor/Handlers/CustomClassHandler.php index 18abe9c..3ef64b0 100644 --- a/src/Executor/Handlers/CustomClassHandler.php +++ b/src/Executor/Handlers/CustomClassHandler.php @@ -56,11 +56,20 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu assert($node instanceof CustomCastingNode); if ($node->strategy === ObjectCastStrategy::NEVER) { + $context->addIssue(Issue::internalError([ + 'message' => "{$node->fullyQualifiedCastingClass} cannot be constructed from input.", + 'strategy' => $node->strategy->name, + ])); return Value::INVALID; } $arrayValue = $executor->executeParse($node->node, $value, $context); - if ($arrayValue === Value::INVALID || !is_array($arrayValue)) { + if ($arrayValue === Value::INVALID) { + return Value::INVALID; + } + + if (!is_array($arrayValue)) { + $context->addIssue(Issue::invalidType('array', $arrayValue)); return Value::INVALID; } diff --git a/src/Executor/Handlers/ListHandler.php b/src/Executor/Handlers/ListHandler.php index 0eaf4db..7be5de4 100644 --- a/src/Executor/Handlers/ListHandler.php +++ b/src/Executor/Handlers/ListHandler.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; use Le0daniel\PhpTsBindings\Executor\Data\Context; +use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Override; @@ -25,6 +26,7 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E assert($node instanceof ListNode); if (!is_iterable($value)) { + $context->addIssue(Issue::invalidType('iterable', $value)); return Value::INVALID; } @@ -57,6 +59,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu assert($node instanceof ListNode); if (!is_array($value) || !array_is_list($value)) { + $context->addIssue(Issue::invalidType('list', $value)); return Value::INVALID; } diff --git a/src/Executor/Handlers/RecordHandler.php b/src/Executor/Handlers/RecordHandler.php index 0333e7e..e02b52e 100644 --- a/src/Executor/Handlers/RecordHandler.php +++ b/src/Executor/Handlers/RecordHandler.php @@ -24,6 +24,7 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E assert($node instanceof RecordNode); if (!is_iterable($value)) { + $context->addIssue(Issue::invalidType('iterable', $value)); return Value::INVALID; } @@ -61,6 +62,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu assert($node instanceof RecordNode); if (!is_array($value)) { + $context->addIssue(Issue::invalidType('array', $value)); return Value::INVALID; } diff --git a/src/Executor/Handlers/TupleHandler.php b/src/Executor/Handlers/TupleHandler.php index 91fb04d..2f71c03 100644 --- a/src/Executor/Handlers/TupleHandler.php +++ b/src/Executor/Handlers/TupleHandler.php @@ -7,6 +7,8 @@ use Le0daniel\PhpTsBindings\Executor\Contracts\Executor; use Le0daniel\PhpTsBindings\Executor\Contracts\Handler; use Le0daniel\PhpTsBindings\Executor\Data\Context; +use Le0daniel\PhpTsBindings\Executor\Data\Issue; +use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Override; @@ -26,12 +28,26 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E assert($node instanceof TupleNode); if (!is_array($value) && !$value instanceof ArrayAccess) { + $context->addIssue(Issue::invalidType('array', $value)); return Value::INVALID; } $tupleValues = []; foreach ($node->nodes as $index => $type) { $context->enterPath($index); + + // The parse path proves the arity up front; here the value came from the application + // and is read one index at a time, so a short tuple has to be caught before indexing + // past the end of it. + if (!$this->hasIndex($value, $index)) { + $context->addIssue(new Issue( + IssueMessage::MISSING_PROPERTY, + ['message' => "Missing tuple element at index {$index}."], + )); + $context->leavePath(); + return Value::INVALID; + } + $result = $executor->executeSerialize($type, $value[$index], $context); if ($result === Value::INVALID) { $context->leavePath(); @@ -53,11 +69,16 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu assert($node instanceof TupleNode); if (!is_array($value) || !array_is_list($value)) { + $context->addIssue(Issue::invalidType('list', $value)); return Value::INVALID; } $expectedCount = count($node->nodes); if (count($value) !== $expectedCount) { + $context->addIssue(new Issue( + IssueMessage::INVALID_TYPE, + ['message' => "Expected a tuple of {$expectedCount} elements, got: " . count($value)], + )); return Value::INVALID; } @@ -74,4 +95,14 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu } return $tupleValues; } + + /** + * @param array|ArrayAccess $value + */ + private function hasIndex(array|ArrayAccess $value, int $index): bool + { + return is_array($value) + ? array_key_exists($index, $value) + : $value->offsetExists($index); + } } \ No newline at end of file diff --git a/src/Executor/Handlers/UnionHandler.php b/src/Executor/Handlers/UnionHandler.php index 3e5fad5..1bb0c39 100644 --- a/src/Executor/Handlers/UnionHandler.php +++ b/src/Executor/Handlers/UnionHandler.php @@ -63,6 +63,8 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E return $result; } } + + $context->addIssue(Issue::invalidType((string)$node, $value)); return Value::INVALID; } @@ -93,6 +95,15 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu if ($discriminatedType) { return $executor->executeParse($discriminatedType, $value, $context); } + + $context->addIssue(new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => "No union branch matches the discriminator value.", + 'value' => $valueToCheck, + 'discriminator' => $discriminator, + ] + )); return Value::INVALID; } diff --git a/src/Executor/SchemaExecutor.php b/src/Executor/SchemaExecutor.php index dd009bc..9d9ddb0 100644 --- a/src/Executor/SchemaExecutor.php +++ b/src/Executor/SchemaExecutor.php @@ -11,6 +11,7 @@ use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; use Le0daniel\PhpTsBindings\Executor\Data\SerializationOptions; use Le0daniel\PhpTsBindings\Executor\Data\Success; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Executor\Handlers\CustomClassHandler; use Le0daniel\PhpTsBindings\Executor\Handlers\IntersectionHandler; use Le0daniel\PhpTsBindings\Executor\Handlers\ListHandler; @@ -105,7 +106,9 @@ public function executeSerialize(NodeInterface $node, mixed $data, Context $cont array_key_exists($node::class, $this->handlers) => $this->handlers[$node::class]->serialize($node, $data, $context, $this), // Codegen metadata has no runtime effect. $node instanceof MetadataNode => $this->executeSerialize($node->node, $data, $context), - default => Value::INVALID, + // A node class no handler claims is a broken AST, not invalid data. Returning INVALID + // here would answer with an empty failure; AstValidator throws for the same case. + default => throw new SchemaException("Unexpected node: " . $node::class), }; // Allow for catching errors at null boundaries during serialization. @@ -138,7 +141,8 @@ public function executeParse(NodeInterface $node, mixed $data, Context $context) array_key_exists($node::class, $this->handlers) => $this->handlers[$node::class]->parse($node, $data, $context, $this), // Codegen metadata has no runtime effect. $node instanceof MetadataNode => $this->executeParse($node->node, $data, $context), - default => Value::INVALID, + // See executeSerialize(): an unclaimed node class is a broken AST, not invalid input. + default => throw new SchemaException("Unexpected node: " . $node::class), }; } } \ No newline at end of file diff --git a/src/Parser/Nodes/Leaf/EnumNode.php b/src/Parser/Nodes/Leaf/EnumNode.php index 120127d..d5d3201 100644 --- a/src/Parser/Nodes/Leaf/EnumNode.php +++ b/src/Parser/Nodes/Leaf/EnumNode.php @@ -79,6 +79,7 @@ public function parseValue(mixed $value, ExecutionContext $context): UnitEnum|Va public function serializeValue(mixed $value, ExecutionContext $context): mixed { if (!is_a($value, $this->enumClassName)) { + $context->addIssue(Issue::invalidType($this->enumClassName, $value)); return Value::INVALID; } diff --git a/src/Parser/Nodes/Leaf/FloatNode.php b/src/Parser/Nodes/Leaf/FloatNode.php index 9402ab5..879e3a5 100644 --- a/src/Parser/Nodes/Leaf/FloatNode.php +++ b/src/Parser/Nodes/Leaf/FloatNode.php @@ -36,7 +36,10 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { - return is_numeric($value) + // Deliberately the same test parseValue() makes. Serialization proves the declared type; + // accepting a numeric string here would repair the application's own output instead of + // reporting it, and is_numeric() would let " 1e3" through as well. + return is_float($value) || is_int($value) ? (float) $value : $this->invalidType('float', $value, $context); } diff --git a/src/Parser/Nodes/Leaf/LiteralNode.php b/src/Parser/Nodes/Leaf/LiteralNode.php index 4bac13a..314601e 100644 --- a/src/Parser/Nodes/Leaf/LiteralNode.php +++ b/src/Parser/Nodes/Leaf/LiteralNode.php @@ -118,17 +118,33 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed return $this->value; } - return $value === $this->enumValue()->name ? $this->value : Value::INVALID; + if ($value === $this->enumValue()->name) { + return $this->value; + } + + return $this->notTheLiteral($this->enumValue()->name, $value, $context); } #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { - if ($this->type === LiteralType::ENUM_CASE) { - return $value === $this->value ? $this->enumValue()->name : Value::INVALID; + if ($value !== $this->value) { + return $this->notTheLiteral($this->value, $value, $context); } - return $value === $this->value ? $this->value : Value::INVALID; + return $this->type === LiteralType::ENUM_CASE ? $this->enumValue()->name : $this->value; + } + + private function notTheLiteral(mixed $expected, mixed $value, ExecutionContext $context): Value + { + $context->addIssue(new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => 'Expected literal value: ' . var_export($expected, true) + . ', got: ' . var_export($value, true), + ] + )); + return Value::INVALID; } #[Override] diff --git a/src/Parser/Nodes/Leaf/RejectsInvalidType.php b/src/Parser/Nodes/Leaf/RejectsInvalidType.php index 5a65d79..18e6ce5 100644 --- a/src/Parser/Nodes/Leaf/RejectsInvalidType.php +++ b/src/Parser/Nodes/Leaf/RejectsInvalidType.php @@ -5,18 +5,12 @@ use Le0daniel\PhpTsBindings\Data\Value; use Le0daniel\PhpTsBindings\Executor\Contracts\ExecutionContext; use Le0daniel\PhpTsBindings\Executor\Data\Issue; -use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; trait RejectsInvalidType { private function invalidType(string $expected, mixed $value, ExecutionContext $context): Value { - $context->addIssue(new Issue( - IssueMessage::INVALID_TYPE, - [ - 'message' => "Expected value of type {$expected}, got: " . gettype($value), - ], - )); + $context->addIssue(Issue::invalidType($expected, $value)); return Value::INVALID; } } diff --git a/src/Parser/Nodes/Leaf/StringNode.php b/src/Parser/Nodes/Leaf/StringNode.php index 733206e..66db4ac 100644 --- a/src/Parser/Nodes/Leaf/StringNode.php +++ b/src/Parser/Nodes/Leaf/StringNode.php @@ -55,6 +55,9 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed #[Override] public function coerce(mixed $value): mixed { - return (string) $value; + // Only scalars are cast: (string) on an array yields the literal "Array" and on a non + // Stringable object it throws, and coerce() runs outside the executor's try/catch. Anything + // else is handed on untouched so parseValue() reports it as the type error it is. + return is_scalar($value) ? (string)$value : $value; } } diff --git a/src/Server/Server.php b/src/Server/Server.php index 9868920..ed36063 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -8,6 +8,7 @@ use Le0daniel\PhpTsBindings\Contracts\ServerAdapter; use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; +use Le0daniel\PhpTsBindings\Executor\Data\SerializationOptions; use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; use Le0daniel\PhpTsBindings\Server\Adapters\NewInstanceAdapter; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; @@ -122,10 +123,15 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli ); } + // partialFailures is off on purpose: it substitutes null wherever a value fails + // to serialize under a null-accepting union, which would answer 200 with data + // the operation never produced. An output that does not match its declared type + // is a bug in the application, and the client is told so. $serializedResult = $this->executor ->serialize( $operation->outputNode(), - $controllerClass->{$operation->definition->methodName}($inputValidationResult->value, $context, $client) + $controllerClass->{$operation->definition->methodName}($inputValidationResult->value, $context, $client), + new SerializationOptions(partialFailures: false), ); if ($serializedResult instanceof Failure) { diff --git a/tests/Feature/Operations/TestClass.php b/tests/Feature/Operations/TestClass.php index 00a5f76..160c048 100644 --- a/tests/Feature/Operations/TestClass.php +++ b/tests/Feature/Operations/TestClass.php @@ -20,4 +20,19 @@ public function run(array $data): array 'message' => "Hello {$data['name']}", ]; } + + /** + * Returns something its own return type does not describe: `name` is an int where a string is + * declared. The whole `user` branch is nullable, which is exactly the shape that used to be + * answered as a 200 with `user: null`. + * + * @param array{ping: bool} $data + * @return array{id: int, user: array{name: string}|null} + */ + #[Command("test")] + public function badOutput(array $data): array + { + /** @phpstan-ignore-next-line return.type (deliberately wrong, this is the fixture) */ + return ['id' => 1, 'user' => ['name' => 123]]; + } } \ No newline at end of file diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index c907880..a6d5e79 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -3,6 +3,7 @@ use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; +use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidOutputException; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; @@ -113,3 +114,13 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { expect(executeOperation('pooling.intLiteral', ['value' => 1]))->toBeInstanceOf(RpcSuccess::class) ->and(executeOperation('pooling.floatLiteral', ['value' => 1.0]))->toBeInstanceOf(RpcSuccess::class); }); + +test('an output that does not match its declared type is an internal error, not a nulled branch', function () { + // partialFailures would substitute null for the whole `user` branch and answer 200 with data + // the operation never produced. Server turns it off, so the mismatch surfaces. + $result = executeOperation('test.badOutput', ['ping' => true]); + + expect($result)->toBeInstanceOf(RpcError::class) + ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($result->cause)->toBeInstanceOf(InvalidOutputException::class); +}); diff --git a/tests/Unit/Executor/ContextPathTest.php b/tests/Unit/Executor/ContextPathTest.php new file mode 100644 index 0000000..385a252 --- /dev/null +++ b/tests/Unit/Executor/ContextPathTest.php @@ -0,0 +1,71 @@ +enterPath($segment); + } + $context->addIssue(new Issue(IssueMessage::INVALID_TYPE)); + foreach ($path as $_) { + $context->leavePath(); + } +} + +test('removing issues at a path removes everything nested below it', function () { + $context = new Context(); + $context->enterPath('user'); + $context->addIssue(new Issue(IssueMessage::INVALID_TYPE)); + $context->enterPath('name'); + $context->addIssue(new Issue(IssueMessage::INVALID_TYPE)); + $context->leavePath(); + + $context->removeCurrentIssues(); + + expect($context->issues)->toBe([]); +}); + +test('removing issues at a path leaves a sibling whose name merely starts the same', function () { + // 'items.0' starts with the string 'item' but is not nested under it. + $context = new Context(); + issueAt($context, 'items', '0'); + + $context->enterPath('item'); + $context->removeCurrentIssues(); + $context->leavePath(); + + expect($context->issues)->toHaveKey('items.0'); +}); + +test('removing issues at the root removes nested issues', function () { + // pathAsString() is '__root' at the top level, which is a prefix of no nested path at all. + $context = new Context(); + issueAt($context, 'a'); + issueAt($context, 'a', 'b'); + $context->addIssue(new Issue(IssueMessage::INVALID_TYPE)); + + $context->removeCurrentIssues(); + + expect($context->issues)->toBe([]); +}); + +test('a numeric path segment is still matched', function () { + // PHP coerces the array key '0' to int, which is why the comparison casts back to string. + $context = new Context(); + issueAt($context, '0'); + + $context->removeCurrentIssues(); + + expect($context->issues)->toBe([]); +}); diff --git a/tests/Unit/Executor/StrictnessTest.php b/tests/Unit/Executor/StrictnessTest.php new file mode 100644 index 0000000..c4c0013 --- /dev/null +++ b/tests/Unit/Executor/StrictnessTest.php @@ -0,0 +1,114 @@ + $value], new ParsingOptions(coercePrimitives: true)); + + // Before: (string) $value produced the literal "Array", or threw for an object. + expect($result)->toBeFailureAt('name', 'validation.invalid_type'); +})->with([ + 'list' => [['a', 'b']], + 'map' => [['a' => 'b']], + 'object' => [new stdClass()], +]); + +test('coercion still casts scalars', function (mixed $value, string $expected) { + $result = executeParse('array{name: string}', ['name' => $value], new ParsingOptions(coercePrimitives: true)); + + expect($result)->toBeSuccess() + ->and($result->value)->toBe(['name' => $expected]); +})->with([ + 'int' => [1, '1'], + 'float' => [1.5, '1.5'], + 'true' => [true, '1'], + 'false' => [false, ''], +]); + +test('serialization proves the type instead of repairing it', function (string $type, mixed $value) { + // Output comes out of the application's own code. A near miss is a bug to report, not to fix. + expect(executeSerialize($type, $value))->toBeFailure(); +})->with([ + 'numeric string as float' => ['float', '1.5'], + 'padded numeric string as float' => ['float', ' 1e3'], + 'numeric string as int' => ['int', '5'], +]); + +test('serialization of a nullable branch fails instead of nulling it when partial failures are off', function () { + $result = executeSerialize( + 'array{id: int, user: array{name: string}|null}', + ['id' => 1, 'user' => ['name' => 123]], + new SerializationOptions(partialFailures: false), + ); + + expect($result)->toBeFailure(); +}); + +test('partial failures remain available for direct executor callers', function () { + $result = executeSerialize( + 'array{id: int, user: array{name: string}|null}', + ['id' => 1, 'user' => ['name' => 123]], + new SerializationOptions(partialFailures: true), + ); + + expect($result)->toBeSuccess() + ->and(json_encode($result->value))->toBe('{"id":1,"user":null}') + ->and($result->isPartial())->toBeTrue(); +}); + +test('a union succeeding at the root clears the issues its rejected arms produced', function () { + // At the root pathAsString() is '__root', which is a prefix of nothing nested, so issues left + // by a rejected arm used to survive the match and make a clean Success report as partial. + // (A null value takes UnionHandler's fast path at :72 and never records anything, so the + // repro has to be a non-null value that a later arm accepts.) + $result = executeParse('array{a: string}|array{b: int}', ['b' => 1]); + + expect($result)->toBeSuccess() + ->and($result->issues->isEmpty())->toBeTrue() + ->and($result->isPartial())->toBeFalse(); +}); + +test('every failure carries at least one issue', function (string $type, mixed $value) { + $result = executeParse($type, $value); + + expect($result)->toBeFailure() + ->and($result->issues->allFlat())->not->toBeEmpty(); +})->with([ + 'list given a map' => ['list', ['a' => 1]], + 'list given a scalar' => ['list', 'nope'], + 'record given a list' => ['array', 'nope'], + 'tuple given a scalar' => ['array{string, int}', 'nope'], + 'tuple too short' => ['array{string, int}', ['a']], + 'tuple too long' => ['array{string, int}', ['a', 1, true]], + 'enum case literal' => [\Tests\Mocks\ResultEnum::class . '::SUCCESS', 'NOPE'], +]); + +test('every serialization failure carries at least one issue', function (string $type, mixed $value) { + $result = executeSerialize($type, $value, new SerializationOptions(partialFailures: false)); + + expect($result)->toBeFailure() + ->and($result->issues->allFlat())->not->toBeEmpty(); +})->with([ + 'list given a scalar' => ['list', 'nope'], + 'record given a scalar' => ['array', 'nope'], + 'tuple given a scalar' => ['array{string, int}', 'nope'], + 'enum given a foreign value' => [\Tests\Mocks\ResultEnum::class, 'NOPE'], +]); + +test('serializing a tuple shorter than its arity fails without reading past the end', function () { + // The parse path checked arity; serialize indexed $value[$index] blindly and warned. + $result = executeSerialize('array{string, int}', ['only-one'], new SerializationOptions(partialFailures: false)); + + expect($result)->toBeFailure() + ->and($result->issues->allFlat())->not->toBeEmpty(); +}); From 5e24b84201c772082588ef055b44a51794f906ec Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 15:57:55 +0200 Subject: [PATCH 054/101] Reject bare array, and fix two silent misresolutions Bare `array` / `list` parsed to list and emitted Array. PHPStan's bare `array` is array and permits string keys, so modelling it as a list is not a widening but a different type: serializing ['a'=>1,'b'=>2] against it answered 200 with [1,2], keys dropped. Bare `object` and `iterable` already fail here; this now does too, naming list, array and array as the alternatives. Namespaces::toFullyQualifiedClassName() resolved two shapes wrongly: - The alias map holds alias => the full name it was imported as, but the whole short name was concatenated onto it, so `use App\Models;` plus `Models\User` gave App\Models\Models\User. Only the segments after the alias are appended now. - str_starts_with() was used where a namespace boundary was meant, putting `Application` inside `App` and `App\Models\UserProfile` inside the imported `App\Models\User`. Both compare on a `\` boundary now. NamespacesTest passed a map buildNamespaceAliasMap() can never produce, which is why neither was caught; every case is now built from that method. FileReflector treated `Foo::class` as a declaration - it tokenizes as T_CLASS, and peekNextSignificantToken() scanned past punctuation until it found any identifier. One file shaped like `const X = Foo::class . BAR;` above its class aborted discovery of the whole application. A T_CLASS preceded by `::` is skipped, and peeking now stops at the first punctuation token, which also keeps `new class {` from reading a name out of its body. Co-Authored-By: Claude Opus 5 (1M context) --- .../Helpers/Consumers/ArrayConsumer.php | 9 ++- src/Reflection/FileReflector.php | 44 ++++++++++-- src/Utils/Namespaces.php | 28 ++++++-- .../Constraints/PhpstanRefinementsTest.php | 1 - tests/Unit/Parser/TypeParserTest.php | 10 ++- tests/Unit/Reflection/FileReflectorTest.php | 20 ++++++ .../ClassConstantBeforeDeclaration.php | 17 +++++ .../Typescript/TypescriptGeneratorTest.php | 1 - tests/Unit/Utils/NamespacesTest.php | 68 +++++++++++++++++-- 9 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 tests/Unit/Reflection/FileReflectorTest.php create mode 100644 tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php diff --git a/src/Parser/Helpers/Consumers/ArrayConsumer.php b/src/Parser/Helpers/Consumers/ArrayConsumer.php index b58c6e4..2a5a956 100644 --- a/src/Parser/Helpers/Consumers/ArrayConsumer.php +++ b/src/Parser/Helpers/Consumers/ArrayConsumer.php @@ -85,9 +85,14 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface // Consuming of the array type identifier $state->advance(); - // No generics + // No generics. PHPStan reads a bare `array` as array, which permits string + // keys - modelling it as a list is not a widening but a different type, and serialization + // would silently reindex a keyed array. Bare `object` and `iterable` already fail here, + // so this does too rather than emit Array and drop keys on the way out. if (!$state->currentTokenIs(TokenType::LT)) { - return $this->applyEmptiness(new ListNode(new MixedNode()), $isNonEmpty); + $state->produceSyntaxError( + "Bare '{$keyword}' has no single representation. Write list, array or array." + ); } $generics = $this->consumeGenerics($state, $parser, min: 1, max: $maxGenerics); diff --git a/src/Reflection/FileReflector.php b/src/Reflection/FileReflector.php index 541ab1a..6ca2cce 100644 --- a/src/Reflection/FileReflector.php +++ b/src/Reflection/FileReflector.php @@ -197,12 +197,38 @@ private static function findClassNameInTokens(array $tokens): ?string continue; } - if (in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM])) { - $nextToken = self::peekNextSignificantToken($tokens, $i, $count); - if ($nextToken && $nextToken[0] === T_STRING) { - return $nextToken[1]; - } + if (!in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM])) { + continue; + } + + // `Foo::class` tokenizes as T_CLASS too. Only a T_CLASS that is not preceded by `::` + // introduces a declaration. + $previousToken = self::previousSignificantToken($tokens, $i); + if ($token[0] === T_CLASS && $previousToken !== null && $previousToken[0] === T_DOUBLE_COLON) { + continue; + } + + $nextToken = self::peekNextSignificantToken($tokens, $i, $count); + if ($nextToken && $nextToken[0] === T_STRING) { + return $nextToken[1]; + } + } + return null; + } + + /** + * @param list $tokens + * @return (array{int, string, int})|null Null when the preceding token is punctuation, which + * token_get_all() reports as a plain string rather than an array. + */ + private static function previousSignificantToken(array $tokens, int $currentIndex): ?array + { + for ($i = $currentIndex - 1; $i >= 0; $i--) { + $token = $tokens[$i]; + if (is_array($token) && $token[0] === T_WHITESPACE) { + continue; } + return is_array($token) ? $token : null; } return null; } @@ -215,9 +241,13 @@ private static function peekNextSignificantToken(array $tokens, int $currentInde { for ($i = $currentIndex + 1; $i < $maxIndex; $i++) { $token = $tokens[$i]; - if (is_array($token) && $token[0] !== T_WHITESPACE) { - return $token; + if (is_array($token) && $token[0] === T_WHITESPACE) { + continue; } + + // Punctuation arrives as a plain string, and it always ends the construct being read: + // `new class {` must not scan on into the body looking for a name. + return is_array($token) ? $token : null; } return null; } diff --git a/src/Utils/Namespaces.php b/src/Utils/Namespaces.php index 00b28de..565b25c 100644 --- a/src/Utils/Namespaces.php +++ b/src/Utils/Namespaces.php @@ -58,24 +58,38 @@ public static function toFullyQualifiedClassName(string $className, ?string $nam return self::withoutLeadingSlash($className); } - $lookupKey = explode('\\', $className)[0]; + // The map holds alias => the full name it was imported as, so only the segments *after* + // the alias are appended: `use App\Models;` plus `Models\User` is App\Models\User, not + // App\Models\Models\User. + $segments = explode('\\', $className); + $lookupKey = $segments[0]; if (array_key_exists($lookupKey, $namespacesMap)) { - $classNameOrNameSpace = $namespacesMap[$lookupKey]; - return str_contains($className, '\\') - ? $classNameOrNameSpace . '\\' . $className - : $classNameOrNameSpace; + $remaining = array_slice($segments, 1); + return $remaining === [] + ? $namespacesMap[$lookupKey] + : $namespacesMap[$lookupKey] . '\\' . implode('\\', $remaining); } // If reflection->getType()->getName() is used, it already returns a fully qualified class name. // In case we did not find an import match, we check if the classname is imported anywhere already. If this is the case, we return it. - if (array_any($namespacesMap, fn(string $usedClass) => str_starts_with($className, $usedClass))) { + if (array_any($namespacesMap, fn(string $usedClass) => self::isWithin($className, $usedClass))) { return $className; } - if ($namespace !== null && !str_starts_with($className, $namespace)) { + if ($namespace !== null && !self::isWithin($className, $namespace)) { return $namespace . '\\' . $className; } return $className; } + + /** + * Whether $className is $parent itself or sits below it, compared on a namespace boundary. + * A raw prefix test would put `Application` inside `App`, and `App\Models\UserProfile` inside + * `App\Models\User`. + */ + private static function isWithin(string $className, string $parent): bool + { + return $className === $parent || str_starts_with($className, "{$parent}\\"); + } } \ No newline at end of file diff --git a/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php b/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php index 48e03f9..d0cf16d 100644 --- a/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php +++ b/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php @@ -20,7 +20,6 @@ })->with([ 'non-empty-array', 'non-empty-array', - 'non-empty-array', ]); test('non-empty-array accepts a populated array', function () { diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index f2c87c3..ad0773e 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -377,7 +377,6 @@ })->with([ ['non-empty-array', RecordNode::class], ['non-empty-array', ListNode::class], - ['non-empty-array', ListNode::class], ]); test('the plain list and array types carry no constraint', function (string $type) { @@ -386,7 +385,14 @@ expect($node)->not->toBeInstanceOf(ConstraintNode::class); compareToOptimizedAst($node); -})->with(['list', 'array', 'array', 'array']); +})->with(['list', 'array', 'array']); + +test('a bare array or list is rejected rather than degraded', function (string $type) { + // PHPStan's bare `array` is array and permits string keys. Modelling it as a + // list would drop those keys on the way out, so it fails like bare `object` does. + expect(fn() => new TypeParser()->parse($type)) + ->toThrow(InvalidSyntaxException::class, 'has no single representation'); +})->with(['array', 'list', 'non-empty-array', 'non-empty-list']); test('object struct', function () { $parser = new TypeParser(); diff --git a/tests/Unit/Reflection/FileReflectorTest.php b/tests/Unit/Reflection/FileReflectorTest.php new file mode 100644 index 0000000..6416977 --- /dev/null +++ b/tests/Unit/Reflection/FileReflectorTest.php @@ -0,0 +1,20 @@ +getDeclaredClass()->getName())->toBe(ClassConstantBeforeDeclaration::class); +}); + +test('the namespace is read from the file', function () { + $reflector = new FileReflector(__DIR__ . '/Fixtures/ClassConstantBeforeDeclaration.php'); + + expect($reflector->getDeclaredClass()->getNamespaceName())->toBe('Tests\\Unit\\Reflection\\Fixtures'); +}); diff --git a/tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php b/tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php new file mode 100644 index 0000000..6edce0e --- /dev/null +++ b/tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php @@ -0,0 +1,17 @@ + ['list', 'Array'], 'array shorthand' => ['string[]', 'Array'], 'int keyed array' => ['array', 'Array'], - 'bare array' => ['array', 'Array'], 'record' => ['array', 'Record'], 'tuple' => ['array{string, int}', '[string,number]'], 'explicitly keyed tuple' => ['array{0: string, 1: int}', '[string,number]'], diff --git a/tests/Unit/Utils/NamespacesTest.php b/tests/Unit/Utils/NamespacesTest.php index 7250ad0..5c5394c 100644 --- a/tests/Unit/Utils/NamespacesTest.php +++ b/tests/Unit/Utils/NamespacesTest.php @@ -4,13 +4,69 @@ use Le0daniel\PhpTsBindings\Utils\Namespaces; -test('to fully qualified class name', function () { +/** + * The maps under test are built from a file's `use` statements, so every case here feeds + * toFullyQualifiedClassName() a map that buildNamespaceAliasMap() can actually produce. A + * hand-written map that cannot occur is how the doubled-segment bug stayed hidden. + */ - expect(Namespaces::toFullyQualifiedClassName('Bar', 'Foo', []))->toBe("Foo\\Bar") - ->and(Namespaces::toFullyQualifiedClassName('\\Bar', 'Foo', []))->toBe("Bar") - ->and(Namespaces::toFullyQualifiedClassName('MyClass', 'Foo', ['MyClass' => 'App\\Utils\\MyClass']))->toBe("App\\Utils\\MyClass") - ->and(Namespaces::toFullyQualifiedClassName('MyClass\\Other', 'Foo', ['MyClass' => 'App\\Utils']))->toBe("App\\Utils\\MyClass\\Other") - ->and(Namespaces::toFullyQualifiedClassName('MyClass\\Other', 'Foo', ['Other' => 'MyClass\\Other']))->toBe("MyClass\\Other"); +test('a leading backslash means the name is already fully qualified', function () { + expect(Namespaces::toFullyQualifiedClassName('\\Bar', 'Foo', []))->toBe('Bar'); +}); + +test('an unimported name is resolved against the current namespace', function () { + expect(Namespaces::toFullyQualifiedClassName('Bar', 'Foo', []))->toBe('Foo\\Bar'); +}); + +test('an imported class resolves to what it was imported as', function () { + $map = Namespaces::buildNamespaceAliasMap(['App\\Utils\\MyClass']); + + expect(Namespaces::toFullyQualifiedClassName('MyClass', 'Foo', $map))->toBe('App\\Utils\\MyClass'); +}); + +test('an aliased import resolves to the aliased class', function () { + $map = Namespaces::buildNamespaceAliasMap(['App\\Contracts\\User' => 'UserContract']); + + expect(Namespaces::toFullyQualifiedClassName('UserContract', 'Foo', $map))->toBe('App\\Contracts\\User'); +}); + +test('a sub path of an imported namespace appends only the remaining segments', function () { + // use App\Models; then Models\User - the alias contributes App\Models, and only what follows + // it is appended. Concatenating the whole short name produced App\Models\Models\User. + $map = Namespaces::buildNamespaceAliasMap(['App\\Models']); + + expect(Namespaces::toFullyQualifiedClassName('Models\\User', 'App\\Http', $map))->toBe('App\\Models\\User') + ->and(Namespaces::toFullyQualifiedClassName('Models\\Nested\\User', 'App\\Http', $map)) + ->toBe('App\\Models\\Nested\\User'); +}); + +test('a sub path of an imported class appends only the remaining segments', function () { + $map = Namespaces::buildNamespaceAliasMap(['App\\Models\\User']); + + expect(Namespaces::toFullyQualifiedClassName('User\\Profile', 'App\\Http', $map)) + ->toBe('App\\Models\\User\\Profile'); +}); + +test('the current namespace is matched on a segment boundary', function () { + // 'Application' merely starts with the text 'App'; it is not in the App namespace, so it has + // to be resolved against it like any other unimported name. + expect(Namespaces::toFullyQualifiedClassName('Application', 'App', []))->toBe('App\\Application') + ->and(Namespaces::toFullyQualifiedClassName('App\\Models\\User', 'App', []))->toBe('App\\Models\\User') + ->and(Namespaces::toFullyQualifiedClassName('App', 'App', []))->toBe('App'); +}); + +test('an already qualified name inside an imported namespace is left alone', function () { + $map = Namespaces::buildNamespaceAliasMap(['App\\Models\\User']); + + expect(Namespaces::toFullyQualifiedClassName('App\\Models\\User', 'Foo', $map))->toBe('App\\Models\\User'); +}); + +test('an imported name is matched on a segment boundary too', function () { + // 'App\Models\UserProfile' starts with the imported 'App\Models\User' as text only. + $map = Namespaces::buildNamespaceAliasMap(['App\\Models\\User']); + + expect(Namespaces::toFullyQualifiedClassName('App\\Models\\UserProfile', 'Foo', $map)) + ->toBe('Foo\\App\\Models\\UserProfile'); }); test('build namespace alias map', function () { From 87e142f06e4bea0980834122a041d0cdbdf7463b Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 16:05:39 +0200 Subject: [PATCH 055/101] Close the gaps in the server's error and naming contracts #[Throws] on a globally configured middleware reached neither the runtime presenter nor the generated error union: ExposedExceptions only walked Definition::$middleware, which holds what #[Middleware] put there, while ServerConfiguration middleware was merged separately at request time. The exception surfaced as a 500 while the identical declaration on an operation-level middleware worked, and the README said the opposite. Both lists are read now, operation-level first so its name keeps winning. #[Middleware] takes one class and is repeatable, like #[Throws]. It had no IS_REPEATABLE, so the array form was the only way to attach more than one and stacking two was a fatal error. Collection order - class before method - is unchanged; it is what ContextualPipeline nests them in. HashSha256KeyGenerator hashes the name segment over the namespace too. Hashing it alone gave every `get` in the application the identical 24-char segment, so learning one key revealed that segment in every namespace. The registry also throws on a duplicate key rather than assigning over it and leaving an operation unreachable. Both change every generated key: run operations:optimize and regenerate the client after upgrading. A query and a command sharing a namespace.name both emitted `export async function get` into the same module - invalid TypeScript, written without a word. EmitOperations::assertNamesAreUnique() catches it, and because it is asked of the generator that owns the naming rule, a --naming that already distinguishes them still passes. Handler signatures are checked at discovery. Handlers are called positionally with ($input, $context, $client) and may declare a prefix, so `(array $input, Client $client)` received the context in the client slot and died with a TypeError naming neither. The shape is verified once, where the method is already reflected. Also: ErrorPresenter keeps the exception that broke presentation rather than dropping it - a stale middleware class name silently disabled every #[Throws] mapping for that operation. $cause stays the application's exception; RpcError::$presentationFailure carries the other one. And both registries key operations through OperationType::registryKey() instead of spelling the same concept with two different separators. Co-Authored-By: Claude Opus 5 (1M context) --- src/CodeGen/CodeGenerators/EmitOperations.php | 34 +++++ src/CodeGen/TypescriptServerCodeGenerator.php | 10 ++ src/CodeGen/Utils/ErrorTypescript.php | 6 +- src/Contracts/Attributes/Middleware.php | 26 ++-- src/Server/Data/OperationType.php | 10 ++ src/Server/Data/RpcError.php | 30 +++- src/Server/Errors/ErrorPresenter.php | 18 ++- src/Server/Errors/ExposedExceptions.php | 21 ++- .../KeyGenerators/HashSha256KeyGenerator.php | 7 +- .../Operations/CachedOperationRegistry.php | 10 +- .../EagerlyLoadedOperationRegistry.php | 21 ++- src/Server/Operations/OperationDiscovery.php | 70 ++++++++- .../Mocks/GlobalMiddlewareException.php | 12 ++ .../Mocks/GloballyThrowingMiddleware.php | 30 ++++ tests/Feature/ServerTest.php | 40 ++++++ .../CodeGen/Mocks/NameClashOperations.php | 33 +++++ .../TypescriptServerCodeGeneratorTest.php | 27 ++++ tests/Unit/Server/KeyGeneratorTest.php | 56 ++++++++ tests/Unit/Server/OperationDiscoveryTest.php | 136 ++++++++++++++++++ 19 files changed, 549 insertions(+), 48 deletions(-) create mode 100644 tests/Feature/Mocks/GlobalMiddlewareException.php create mode 100644 tests/Feature/Mocks/GloballyThrowingMiddleware.php create mode 100644 tests/Unit/CodeGen/Mocks/NameClashOperations.php create mode 100644 tests/Unit/Server/KeyGeneratorTest.php create mode 100644 tests/Unit/Server/OperationDiscoveryTest.php diff --git a/src/CodeGen/CodeGenerators/EmitOperations.php b/src/CodeGen/CodeGenerators/EmitOperations.php index 05e2fee..b0e9b69 100644 --- a/src/CodeGen/CodeGenerators/EmitOperations.php +++ b/src/CodeGen/CodeGenerators/EmitOperations.php @@ -7,6 +7,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; use Le0daniel\PhpTsBindings\Utils\Assertions; @@ -63,6 +64,39 @@ public function operationName(TypedOperation $operation): string return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->definition->name; } + /** + * A query and a command may legitimately share a namespace.name - the registry keys them by + * type - but both land in the same generated module, and under the default naming rule both + * emit `export async function get` and `export type GetResult`. That is invalid TypeScript, + * and it used to be written without a word. The check lives here because the naming rule does. + * + * @param list $operations + * @throws CodeGenException + */ + public function assertNamesAreUnique(array $operations): void + { + /** @var array $seen */ + $seen = []; + foreach ($operations as $operation) { + $name = $this->operationName($operation); + $key = "{$operation->definition->namespace}/{$name}"; + + if (array_key_exists($key, $seen)) { + $first = $seen[$key]->definition; + $second = $operation->definition; + throw new CodeGenException( + "Two operations generate the name '{$name}' in module " + . "'{$operation->definition->namespace}.ts': " + . "{$first->fullyQualifiedClassName}::{$first->methodName} ({$first->type->lowerCase()}) and " + . "{$second->fullyQualifiedClassName}::{$second->methodName} ({$second->type->lowerCase()}). " + . "Rename one, or generate with a naming mode that distinguishes them." + ); + } + + $seen[$key] = $operation; + } + } + public function baseTypeName(TypedOperation $operation): string { return ucfirst($this->operationName($operation)); diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 563b5de..c3ea20c 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -2,6 +2,7 @@ namespace Le0daniel\PhpTsBindings\CodeGen; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; use Le0daniel\PhpTsBindings\CodeGen\Contracts\DependsOn; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; @@ -129,6 +130,15 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore ); }); + // Asked of the generator that owns the naming rule, so a custom --naming that already + // distinguishes the two is not rejected for a clash it does not produce. After the sort, + // so which of a clashing pair is named first does not depend on discovery order. + foreach ($this->generators as $codeGenerator) { + if ($codeGenerator instanceof EmitOperations) { + $codeGenerator->assertNamesAreUnique($definitions); + } + } + return [ ...$this->generateLibFiles($definitions, $metadata, $registry), ...$this->generateOperationDefinitions($definitions, $metadata), diff --git a/src/CodeGen/Utils/ErrorTypescript.php b/src/CodeGen/Utils/ErrorTypescript.php index 2d010cc..df110e9 100644 --- a/src/CodeGen/Utils/ErrorTypescript.php +++ b/src/CodeGen/Utils/ErrorTypescript.php @@ -44,7 +44,7 @@ public static function forOperation(ServerConfiguration $configuration, Definiti $branches[] = self::branch(ErrorType::NOT_FOUND, self::NOT_FOUND_DETAILS); - if ($domainDetails = self::domainDetails($definition)) { + if ($domainDetails = self::domainDetails($configuration, $definition)) { $branches[] = self::branch(ErrorType::DOMAIN_ERROR, $domainDetails); } @@ -56,9 +56,9 @@ public static function forOperation(ServerConfiguration $configuration, Definiti /** * @throws ReflectionException */ - private static function domainDetails(Definition $definition): ?string + private static function domainDetails(ServerConfiguration $configuration, Definition $definition): ?string { - $exposedTypes = ExposedExceptions::exposedTypesFor($definition); + $exposedTypes = ExposedExceptions::exposedTypesFor($definition, $configuration); if (empty($exposedTypes)) { return null; } diff --git a/src/Contracts/Attributes/Middleware.php b/src/Contracts/Attributes/Middleware.php index 82b90ca..e83e9bc 100644 --- a/src/Contracts/Attributes/Middleware.php +++ b/src/Contracts/Attributes/Middleware.php @@ -5,21 +5,27 @@ use Attribute; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; -#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +/** + * Runs a middleware around this operation. Repeat the attribute to attach several, the way + * #[Throws] is repeated: they apply outermost first, and every middleware declared on the class + * runs outside every middleware declared on the method. + * + * ```php + * #[Command('users')] + * #[Middleware(AuthMiddleware::class)] + * #[Middleware(NameCheckingMiddleware::class)] + * public function create(array $input): array { } + * ``` + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final readonly class Middleware { /** - * @var list>> - */ - public array $middleware; - - /** - * @param class-string>|array>> $middleware + * @param class-string> $middleware */ public function __construct( - string|array $middleware, + public string $middleware, ) { - $this->middleware = is_array($middleware) ? array_values($middleware) : [$middleware]; } -} \ No newline at end of file +} diff --git a/src/Server/Data/OperationType.php b/src/Server/Data/OperationType.php index da6e30e..e6e886f 100644 --- a/src/Server/Data/OperationType.php +++ b/src/Server/Data/OperationType.php @@ -14,4 +14,14 @@ public function lowerCase(): string { return strtolower($this->name); } + + /** + * How a registry keys one operation. A query and a command may share a namespace.name, so the + * type is part of the key - and every registry has to spell it the same way, or a cache written + * by one cannot be read by the other. + */ + public function registryKey(string $fullyQualifiedKey): string + { + return "{$this->name}@{$fullyQualifiedKey}"; + } } diff --git a/src/Server/Data/RpcError.php b/src/Server/Data/RpcError.php index d20bebb..a75cc00 100644 --- a/src/Server/Data/RpcError.php +++ b/src/Server/Data/RpcError.php @@ -10,6 +10,12 @@ final readonly class RpcError implements RpcResult { /** + * @param Throwable $cause The exception the application threw. Always the original, so it can + * be handed straight to a reporter. + * @param Throwable|null $presentationFailure Set only when working out how to present $cause + * itself failed - a stale #[Middleware] class name makes ExposedExceptions throw, for + * instance. When this is non null the category is INTERNAL_ERROR because the catalogue could + * not be consulted, not because $cause deserved a 500. * @param array $metadata */ public function __construct( @@ -18,6 +24,7 @@ public function __construct( public mixed $details, public ?ResolveInfo $resolveInfo, public array $metadata = [], + public ?Throwable $presentationFailure = null, ) { } @@ -31,7 +38,14 @@ public function __construct( #[NoDiscard] public function withMetadata(array $metadata): static { - return new self($this->type, $this->cause, $this->details, $this->resolveInfo, $metadata); + return new self( + $this->type, + $this->cause, + $this->details, + $this->resolveInfo, + $metadata, + $this->presentationFailure, + ); } /** @@ -43,9 +57,13 @@ public function withMetadata(array $metadata): static #[NoDiscard] public function appendMetadata(array $metadata): static { - return new self($this->type, $this->cause, $this->details, $this->resolveInfo, [ - ...$this->metadata, - ...$metadata, - ]); + return new self( + $this->type, + $this->cause, + $this->details, + $this->resolveInfo, + [...$this->metadata, ...$metadata], + $this->presentationFailure, + ); } -} \ No newline at end of file +} diff --git a/src/Server/Errors/ErrorPresenter.php b/src/Server/Errors/ErrorPresenter.php index 4d29879..a79f0b8 100644 --- a/src/Server/Errors/ErrorPresenter.php +++ b/src/Server/Errors/ErrorPresenter.php @@ -45,21 +45,31 @@ public function present(Throwable $throwable, ?Definition $definition, ?ResolveI try { [$type, $details] = $this->resolve($throwable, $definition); return new RpcError($type, $throwable, $details, $info); - } catch (Throwable) { - return self::internalError($throwable, $info); + } catch (Throwable $presentationFailure) { + // Losing this one is expensive to debug: a stale middleware class name makes + // ExposedExceptions throw, and from then on every exception from the operation + // degrades to an internal error with no #[Throws] mapping ever applying again, with + // nothing anywhere saying why. $cause stays the exception the application threw; the + // presentation failure rides alongside it for the reporter. + return self::internalError($throwable, $info, $presentationFailure); } } /** * The last resort shape, for when presenting itself fails. */ - public static function internalError(Throwable $throwable, ?ResolveInfo $info): RpcError + public static function internalError( + Throwable $throwable, + ?ResolveInfo $info, + ?Throwable $presentationFailure = null, + ): RpcError { return new RpcError( ErrorType::INTERNAL_ERROR, $throwable, ['type' => 'INTERNAL_SERVER_ERROR'], $info, + presentationFailure: $presentationFailure, ); } @@ -109,6 +119,6 @@ private function matchesAny(Throwable $throwable, array $classNames): bool */ private function exposedTypeOf(Throwable $throwable, Definition $definition): ?string { - return ExposedExceptions::declaredFor($definition)[$throwable::class] ?? null; + return ExposedExceptions::declaredFor($definition, $this->configuration)[$throwable::class] ?? null; } } diff --git a/src/Server/Errors/ExposedExceptions.php b/src/Server/Errors/ExposedExceptions.php index 46bb7e8..df71c3e 100644 --- a/src/Server/Errors/ExposedExceptions.php +++ b/src/Server/Errors/ExposedExceptions.php @@ -5,6 +5,7 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\ExposeAs; use Le0daniel\PhpTsBindings\Contracts\Attributes\Throws; use Le0daniel\PhpTsBindings\Server\Data\Definition; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Utils\Lists; use ReflectionClass; use ReflectionException; @@ -27,16 +28,27 @@ * each of its middlewares, mapped to the name the client sees - or null where it stays * internal. handle() is guaranteed to exist: every middleware implements MiddlewareContract. * + * Both the middleware the operation declares with #[Middleware] and the middleware the server + * is configured with are read, because both wrap the operation and either can be the thing + * that throws. They are reflected in that order so an operation's own declaration keeps + * winning over a server wide one. + * * @param Definition $definition + * @param ServerConfiguration $configuration * @return array, string|null> * @throws ReflectionException */ - public static function declaredFor(Definition $definition): array + public static function declaredFor(Definition $definition, ServerConfiguration $configuration): array { $attributes = new ReflectionMethod($definition->fullyQualifiedClassName, $definition->methodName) ->getAttributes(Throws::class); - foreach ($definition->middleware as $middlewareClassName) { + $middlewareClassNames = [ + ... $definition->middleware, + ... $configuration->middleware, + ]; + + foreach ($middlewareClassNames as $middlewareClassName) { $middlewareAttributes = new ReflectionMethod($middlewareClassName, 'handle') ->getAttributes(Throws::class); @@ -76,12 +88,13 @@ private static function exposeAsOf(string $exceptionClass): ?string * The exposed names of every exception the operation declares, in declaration order. * * @param Definition $definition + * @param ServerConfiguration $configuration * @return list * @throws ReflectionException */ - public static function exposedTypesFor(Definition $definition): array + public static function exposedTypesFor(Definition $definition, ServerConfiguration $configuration): array { - return array_values(self::declaredFor($definition)) + return array_values(self::declaredFor($definition, $configuration)) |> Lists::filterNullValues(...) |> Lists::unique(...); } diff --git a/src/Server/KeyGenerators/HashSha256KeyGenerator.php b/src/Server/KeyGenerators/HashSha256KeyGenerator.php index a11859a..a5cc75b 100644 --- a/src/Server/KeyGenerators/HashSha256KeyGenerator.php +++ b/src/Server/KeyGenerators/HashSha256KeyGenerator.php @@ -16,11 +16,16 @@ public function __construct( { } + /** + * The name segment is hashed over the namespace as well. Hashing it on its own gave every + * `get` in the application the identical segment, so learning one key told you the segment for + * that method name in every other namespace - the structure the obfuscation is meant to hide. + */ #[Override] public function generateKey(string $namespace, string $name): string { $namespaceHash = Hashs::base64UrlEncodedSha256("{$namespace}|{$this->pepper}"); - $fnHash = Hashs::base64UrlEncodedSha256("{$name}|{$this->pepper}"); + $fnHash = Hashs::base64UrlEncodedSha256("{$namespace}|{$name}|{$this->pepper}"); $namespace = substr($namespaceHash, 0, $this->namespaceLength); $fnName = substr($fnHash, 0, $this->fnNameLength); diff --git a/src/Server/Operations/CachedOperationRegistry.php b/src/Server/Operations/CachedOperationRegistry.php index 3ad6b94..234de5d 100644 --- a/src/Server/Operations/CachedOperationRegistry.php +++ b/src/Server/Operations/CachedOperationRegistry.php @@ -28,7 +28,7 @@ public function __construct(private readonly array $operations) public function has(OperationType $type, string $fullyQualifiedKey): bool { return array_key_exists( - self::key($type, $fullyQualifiedKey), + $type->registryKey($fullyQualifiedKey), $this->operations ); } @@ -36,14 +36,10 @@ public function has(OperationType $type, string $fullyQualifiedKey): bool #[Override] public function get(OperationType $type, string $fullyQualifiedKey): Operation { - $key = self::key($type, $fullyQualifiedKey); + $key = $type->registryKey($fullyQualifiedKey); return $this->instances[$key] ??= $this->operations[$key](); } - private static function key(OperationType $type, string $fullyQualifiedKey): string - { - return "{$type->name}:{$fullyQualifiedKey}"; - } #[Override] public function all(): array @@ -75,7 +71,7 @@ public static function toPhpCode( $exportedDefinition = $endpoint->definition->exportPhpCode(); // The key is computed based on the endpoint key from the operation registry provided. - $key = self::key($operation->type, $endpoint->key); + $key = $operation->type->registryKey($endpoint->key); $endpoints[] = "'{$key}' => fn() => new {$endpointClass}('{$endpoint->key}', $exportedDefinition, fn() => \$typeRegistry->get('{$inputAstName}'), fn() => \$typeRegistry->get('{$outputAstName}'))"; diff --git a/src/Server/Operations/EagerlyLoadedOperationRegistry.php b/src/Server/Operations/EagerlyLoadedOperationRegistry.php index 5ca7e65..2c44e3d 100644 --- a/src/Server/Operations/EagerlyLoadedOperationRegistry.php +++ b/src/Server/Operations/EagerlyLoadedOperationRegistry.php @@ -5,6 +5,7 @@ use Closure; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\FileReflector; @@ -85,7 +86,17 @@ private static function registryFromDiscovery( $factories = []; foreach ($discovery->operations as $definition) { $key = $keyGenerator->generateKey($definition->namespace, $definition->name); - $fullyQualifiedKey = self::key($definition->type, $key); + $fullyQualifiedKey = $definition->type->registryKey($key); + + // Keys can be truncated hashes, so two operations can collide on one. Assigning over + // the entry would leave an operation silently unreachable; ASTOptimizer throws for the + // same reason. + if (array_key_exists($fullyQualifiedKey, $factories)) { + throw new SchemaException( + "Operation key collision on '{$key}' for {$definition->fullyQualifiedName()}. " + . "Two operations hash to the same key - increase the key generator's length." + ); + } // Lazily execute the parsing. $factories[$fullyQualifiedKey] = static function () use ($definition, $parser, $key) { @@ -120,15 +131,11 @@ public static function withClasses( return self::registryFromDiscovery($parser, $keyGenerator, $discovery); } - private static function key(OperationType $type, string $fullyQualifiedKey): string - { - return "{$type->name}@{$fullyQualifiedKey}"; - } #[Override] public function has(OperationType $type, string $fullyQualifiedKey): bool { - $key = self::key($type, $fullyQualifiedKey); + $key = $type->registryKey($fullyQualifiedKey); return array_key_exists($key, $this->factories); } @@ -138,7 +145,7 @@ public function has(OperationType $type, string $fullyQualifiedKey): bool #[Override] public function get(OperationType $type, string $fullyQualifiedKey): Operation { - $key = self::key($type, $fullyQualifiedKey); + $key = $type->registryKey($fullyQualifiedKey); return $this->instances[$key] ??= $this->factories[$key](); } diff --git a/src/Server/Operations/OperationDiscovery.php b/src/Server/Operations/OperationDiscovery.php index 37c5184..e4aa324 100644 --- a/src/Server/Operations/OperationDiscovery.php +++ b/src/Server/Operations/OperationDiscovery.php @@ -6,12 +6,14 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Command; use Le0daniel\PhpTsBindings\Contracts\Attributes\Middleware; use Le0daniel\PhpTsBindings\Contracts\Attributes\Query; +use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use ReflectionClass; use ReflectionMethod; +use ReflectionNamedType; final class OperationDiscovery { @@ -73,6 +75,63 @@ public function discover(ReflectionClass $class): void } } + /** + * A handler is called positionally with exactly ($input, $context, $client) and may declare a + * *prefix* of those - nothing inspects the signature at call time. Declaring + * `(array $input, Client $client)` therefore receives the context in the client slot and dies + * with a TypeError that says nothing about the real mistake, so the shape is checked once, + * here, where the method is already being reflected. + * + * The first parameter also defines the entire published input contract, so getting it wrong + * publishes a type the client can never satisfy rather than failing. + * + * @param ReflectionClass $class + */ + private static function assertHandlerSignature(ReflectionClass $class, ReflectionMethod $method): void + { + $signature = "{$class->getName()}::{$method->name}"; + $parameters = $method->getParameters(); + + if (count($parameters) < 1) { + throw new SchemaException( + "Operation {$signature} must declare at least one parameter: the first one is the " + . "input, and its type is the contract the client must satisfy." + ); + } + + if (count($parameters) > 3) { + throw new SchemaException( + "Operation {$signature} declares " . count($parameters) . " parameters. A handler is " + . "called with (\$input, \$context, \$client) and may declare a prefix of those." + ); + } + + // The client is the third argument. A Client in second position is the common slip and is + // worth naming, because the value it would actually receive is the context. + $secondParameterType = ($parameters[1] ?? null)?->getType(); + if ($secondParameterType instanceof ReflectionNamedType && is_a($secondParameterType->getName(), Client::class, true)) { + throw new SchemaException( + "Operation {$signature} declares {$secondParameterType->getName()} as its second " + . "parameter, but the second argument is the context. Declare the client third: " + . "(\$input, \$context, Client \$client)." + ); + } + + $thirdParameterType = ($parameters[2] ?? null)?->getType(); + if ($thirdParameterType instanceof ReflectionNamedType) { + $declared = $thirdParameterType->getName(); + + // is_a() this way round asks whether a Client satisfies what was declared, so Client + // itself and any interface it implements are accepted. + if ($declared !== 'mixed' && !is_a(Client::class, $declared, true)) { + throw new SchemaException( + "Operation {$signature} declares {$declared} as its third parameter, which is " + . "the client. It has to accept " . Client::class . "." + ); + } + } + } + /** * @param Query|Command $attribute * @param ReflectionClass $class @@ -86,12 +145,11 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, Command::class => OperationType::COMMAND, }; - $parameters = $method->getParameters(); - if (count($parameters) < 1) { - throw new SchemaException("Method {$method->name} must have at least one parameter."); - } + self::assertHandlerSignature($class, $method); - // Collect all middlewares, on the class and the method itself. + // Collect all middlewares, on the class and the method itself. Order is load bearing: it + // is the order ContextualPipeline nests them in, so class-level middleware wraps + // method-level middleware. $middlewareAttributes = [ ... $class->getAttributes(Middleware::class), ... $method->getAttributes(Middleware::class), @@ -100,7 +158,7 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, /** @var list>> $middlewares */ $middlewares = []; foreach ($middlewareAttributes as $middlewareAttribute) { - array_push($middlewares, ...$middlewareAttribute->newInstance()->middleware); + $middlewares[] = $middlewareAttribute->newInstance()->middleware; } return new Definition( diff --git a/tests/Feature/Mocks/GlobalMiddlewareException.php b/tests/Feature/Mocks/GlobalMiddlewareException.php new file mode 100644 index 0000000..2785453 --- /dev/null +++ b/tests/Feature/Mocks/GlobalMiddlewareException.php @@ -0,0 +1,12 @@ + + */ +final class GloballyThrowingMiddleware implements MiddlewareContract +{ + #[Throws(GlobalMiddlewareException::class, as: 'global_middleware_failed')] + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + { + if (is_array($input) && ($input['name'] ?? null) === 'global-boom') { + throw new GlobalMiddlewareException(); + } + + return $next($input); + } +} diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index a6d5e79..5ae91fd 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -13,6 +13,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Server; +use Tests\Feature\Mocks\GloballyThrowingMiddleware; use Tests\Feature\Mocks\NotAMiddleware; function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { @@ -124,3 +125,42 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) ->and($result->cause)->toBeInstanceOf(InvalidOutputException::class); }); + +test('a globally configured middleware contributes its #[Throws] to the runtime and the codegen', function () { + // Definition::$middleware only ever held what #[Middleware] put there, so a #[Throws] on a + // middleware registered through ServerConfiguration was ignored by both the presenter and the + // generated error union - the exception surfaced as a 500. + $registry = EagerlyLoadedOperationRegistry::eagerlyDiscover( + __DIR__ . '/Operations', + keyGenerator: new PlainlyExposedKeyGenerator(), + ); + $configuration = new ServerConfiguration()->withMiddlewares(GloballyThrowingMiddleware::class); + $server = new Server($registry, configuration: $configuration); + + $error = $server->command('test.run', ['name' => 'global-boom'], null, new NullClient()); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->details)->toEqual(['type' => 'global_middleware_failed']); + + $errorUnion = ErrorTypescript::forOperation( + $configuration, + $registry->get(OperationType::COMMAND, 'test.run')->definition, + ); + + expect($errorUnion)->toContain('"global_middleware_failed"'); +}); + +test('an operation level declaration still wins over a global one for the same exception', function () { + $registry = EagerlyLoadedOperationRegistry::eagerlyDiscover( + __DIR__ . '/Operations', + keyGenerator: new PlainlyExposedKeyGenerator(), + ); + $configuration = new ServerConfiguration()->withMiddlewares(GloballyThrowingMiddleware::class); + + // test.run declares InvalidNameException itself; the global middleware must not displace it. + $error = new Server($registry, configuration: $configuration) + ->command('test.run', ['name' => 'invalid'], null, new NullClient()); + + expect($error->details)->toEqual(['type' => 'invalid_name']); +}); diff --git a/tests/Unit/CodeGen/Mocks/NameClashOperations.php b/tests/Unit/CodeGen/Mocks/NameClashOperations.php new file mode 100644 index 0000000..a847e4b --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/NameClashOperations.php @@ -0,0 +1,33 @@ +toContain('export type CustomerInput = Customer;') ->toContain('export type CustomerResult = Customer;'); }); + +test('a query and a command generating the same name in one module is an error', function () { + // Both would emit `export async function get` and `export type GetResult` into clash.ts. + // Previously this produced invalid TypeScript without a warning. + expect(fn() => generateFor([NameClashOperations::class])) + ->toThrow(CodeGenException::class, "Two operations generate the name 'get'"); +}); + +test('a naming rule that distinguishes them is accepted', function () { + // The check asks the generator that owns naming, so a rule which already separates the two + // is not rejected for a clash it does not produce. + $files = generateFor([NameClashOperations::class], [ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations( + fn(TypedOperation $operation): string => $operation->definition->type->lowerCase() + . ucfirst($operation->definition->name), + ), + ]); + + expect($files['clash.ts']->toString()) + ->toContain('function queryGet') + ->toContain('function commandGet'); +}); diff --git a/tests/Unit/Server/KeyGeneratorTest.php b/tests/Unit/Server/KeyGeneratorTest.php new file mode 100644 index 0000000..91e26ee --- /dev/null +++ b/tests/Unit/Server/KeyGeneratorTest.php @@ -0,0 +1,56 @@ +generateKey('users', 'get')); + [, $ordersGet] = explode('.', $generator->generateKey('orders', 'get')); + + expect($usersGet)->not->toBe($ordersGet); +}); + +test('the namespace segment is still stable across the operations in it', function () { + $generator = new HashSha256KeyGenerator('pepper'); + + [$usersGet] = explode('.', $generator->generateKey('users', 'get')); + [$usersCreate] = explode('.', $generator->generateKey('users', 'create')); + + expect($usersGet)->toBe($usersCreate); +}); + +test('the pepper changes every key', function () { + expect(new HashSha256KeyGenerator('a')->generateKey('users', 'get')) + ->not->toBe(new HashSha256KeyGenerator('b')->generateKey('users', 'get')); +}); + +test('key generation is deterministic', function () { + expect(new HashSha256KeyGenerator('pepper')->generateKey('users', 'get')) + ->toBe(new HashSha256KeyGenerator('pepper')->generateKey('users', 'get')); +}); + +test('segment lengths are honoured', function () { + $key = new HashSha256KeyGenerator('pepper', namespaceLength: 4, fnNameLength: 6) + ->generateKey('users', 'get'); + + [$namespace, $name] = explode('.', $key); + + expect($namespace)->toHaveLength(4) + ->and($name)->toHaveLength(6); +}); + +test('the plain generator exposes namespace and name verbatim', function () { + expect(new PlainlyExposedKeyGenerator()->generateKey('users', 'get'))->toBe('users.get'); +}); + +test('a query and a command with the same name are distinct registry keys', function () { + expect(OperationType::QUERY->registryKey('users.get')) + ->not->toBe(OperationType::COMMAND->registryKey('users.get')); +}); diff --git a/tests/Unit/Server/OperationDiscoveryTest.php b/tests/Unit/Server/OperationDiscoveryTest.php new file mode 100644 index 0000000..0d996d9 --- /dev/null +++ b/tests/Unit/Server/OperationDiscoveryTest.php @@ -0,0 +1,136 @@ +discover(new ReflectionClass($class)); + return $discovery; +} + +final class ClientInContextSlot +{ + /** + * @param array{a: string} $input + * @return array{a: string} + */ + #[Command('bad')] + public function run(array $input, Client $client): array + { + return $input; + } +} + +final class TooManyParameters +{ + /** + * @param array{a: string} $input + * @return array{a: string} + */ + #[Command('bad')] + public function run(array $input, mixed $context, Client $client, string $extra): array + { + return $input; + } +} + +final class WrongClientType +{ + /** + * @param array{a: string} $input + * @return array{a: string} + */ + #[Command('bad')] + public function run(array $input, mixed $context, string $client): array + { + return $input; + } +} + +final class ValidPrefixes +{ + /** + * @param array{a: string} $input + * @return array{a: string} + */ + #[Command('ok', 'inputOnly')] + public function inputOnly(array $input): array + { + return $input; + } + + /** + * @param array{a: string} $input + * @return array{a: string} + */ + #[Command('ok', 'withContext')] + public function withContext(array $input, mixed $context): array + { + return $input; + } + + /** + * @param array{a: string} $input + * @return array{a: string} + */ + #[Command('ok', 'withClient')] + public function withClient(array $input, mixed $context, Client $client): array + { + return $input; + } +} + +#[Middleware(GloballyThrowingMiddleware::class)] +final class StackedMiddleware +{ + /** + * @param array{a: string} $input + * @return array{a: string} + */ + #[Command('stacked')] + #[Middleware(NameCheckingMiddleware::class)] + public function run(array $input): array + { + return $input; + } +} + +test('a handler may declare any prefix of (input, context, client)', function () { + expect(discover(ValidPrefixes::class)->operations)->toHaveCount(3); +}); + +test('a Client in the context slot is rejected with the reason', function () { + // It would silently receive the context and die with a TypeError naming neither. + expect(fn() => discover(ClientInContextSlot::class)) + ->toThrow(SchemaException::class, 'the second argument is the context'); +}); + +test('more parameters than the handler contract has are rejected', function () { + expect(fn() => discover(TooManyParameters::class)) + ->toThrow(SchemaException::class, 'may declare a prefix of those'); +}); + +test('a third parameter that cannot accept a Client is rejected', function () { + expect(fn() => discover(WrongClientType::class)) + ->toThrow(SchemaException::class, 'which is the client'); +}); + +test('repeated #[Middleware] attributes stack, class level before method level', function () { + // The order is what ContextualPipeline nests them in, so class level wraps method level. + $definition = discover(StackedMiddleware::class)->operations |> array_values(...); + + expect($definition[0]->middleware)->toBe([ + GloballyThrowingMiddleware::class, + NameCheckingMiddleware::class, + ]); +}); From b9965e1a2d3964660b8078151d46f7cd5e357878 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 16:12:38 +0200 Subject: [PATCH 056/101] Mark generated files, and fix the generated client's timeouts and typing Every generated file now opens with `// generated by: php-ts-bindings`, emitted from TypescriptFile::toString() - the one place every file is rendered. It carries no version, timestamp or path, because generated files are compared byte for byte to decide whether they are stale. That marker is what makes OutputDirectory safe. write() deleted every *.ts file below the target, matching on extension alone, so pointing codegen at a directory holding hand written TypeScript destroyed it. Only marked files are pruned now, verify() no longer calls an unmarked module stale, and a generated module about to overwrite an unmarked file of the same name is refused before anything is touched - the one case the marker cannot recover from. Upgrading means deleting the output directory once; the error message says so. DefaultClient timeouts never fired: `new AbortController().signal` is discarded immediately and nothing ever aborts it, so no request could time out. It is AbortSignal.timeout() now, and per-call beats the client wide default rather than the reverse - with timeoutMs hardcoded in createDefaultClient(), the instance value was always truthy and the per-call option was unreachable. createDefaultClient() also takes baseUrl and timeoutMs, which previously required constructing DefaultClient by hand. OperationException is generic over the error union, so `OperationException.is(e)` types `e.cause` as the operation's branches instead of any. throwOnFailure keeps loose inference on purpose: a catch clause variable is `unknown` in TypeScript whatever was thrown, so no signature there could carry the union to the catch - it is named at the `is()` call. usage.ts exercises this, so tsc proves it. Also: namespaces are validated before becoming module file names (a `/` or `..` was path traversal in a build tool, a quote broke the emitted namespace union), and CodeGenCommand returns an exit code instead of calling exit(1) from inside a private helper. Co-Authored-By: Claude Opus 5 (1M context) --- .../Laravel/Commands/CodeGenCommand.php | 21 +++- .../EmitOperationClientBindings.php | 39 +++++--- src/CodeGen/TypescriptServerCodeGenerator.php | 23 ++++- src/CodeGen/Utils/OutputDirectory.php | 37 ++++++- src/Typescript/Code/TypescriptFile.php | 29 +++++- tests/Unit/CodeGen/OutputDirectoryTest.php | 99 +++++++++++++++++++ .../TypescriptServerCodeGeneratorTest.php | 7 +- .../Typescript/Code/TypescriptFileTest.php | 99 +++++++++++++------ tests/ts-output/generated/accounts.ts | 2 + tests/ts-output/generated/catalog.ts | 2 + .../ts-output/generated/lib/DefaultClient.ts | 8 +- .../generated/lib/OperationClient.ts | 2 + .../generated/lib/OperationException.ts | 18 ++-- tests/ts-output/generated/lib/bindings.ts | 19 +++- tests/ts-output/generated/lib/type-map.ts | 2 + tests/ts-output/generated/lib/types.ts | 2 + tests/ts-output/generated/lib/utils.ts | 2 + tests/ts-output/generated/shapes.ts | 2 + tests/ts-output/src/usage.ts | 14 ++- 19 files changed, 358 insertions(+), 69 deletions(-) create mode 100644 tests/Unit/CodeGen/OutputDirectoryTest.php diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index 02d60bb..38187fa 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -21,6 +21,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\CodeGen\Utils\OutputDirectory; @@ -113,6 +114,11 @@ public function handle( // than a placeholder type that fails later inside the generated client. $this->error($exception->getMessage()); return 1; + } catch (CodeGenException $exception) { + // A bad naming mode, a namespace that cannot be a file name, two operations generating + // one name: all of them end the run with a message rather than a stack trace. + $this->error($exception->getMessage()); + return 1; } $target = ArtisanOptions::asString($this->argument('directory')) ?? ''; @@ -131,7 +137,14 @@ public function handle( return $this->verifyContentOnly($directory, $files); } - OutputDirectory::write($directory, $files); + try { + OutputDirectory::write($directory, $files); + } catch (CodeGenException $exception) { + // Refusing to overwrite a file this library did not write. + $this->error($exception->getMessage()); + return 1; + } + return 0; } @@ -174,8 +187,10 @@ private function getNamingGenerator(Application $application): Closure return $instance->{$parts[1]}(...); } - $this->error("Unknown naming mode {$naming}."); - exit(1); + throw new CodeGenException( + "Unknown naming mode '{$naming}'. Use one of name, fqn, operation-prefix, " + . "namespace-postfix, or Class::method naming your own rule." + ); } /** diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 3f99420..d0e6d6d 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -171,10 +171,12 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; const fullPath = `\${this.options.baseUrl ?? ''}/\${route.replace('{fqn}', key)}`; - const timeoutInMs = this.options?.timeoutMs ?? options?.timeoutMs; + // Per call wins over the client wide default, and the timeout signal actually fires: a + // fresh AbortController is never aborted by anything. + const timeoutInMs = options?.timeoutMs ?? this.options?.timeoutMs; const signal = this.joinSignals([ options?.signal, - timeoutInMs ? new AbortController().signal : undefined + timeoutInMs ? AbortSignal.timeout(timeoutInMs) : undefined ]); const headers: Record = { @@ -229,24 +231,28 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi ), ]), self::OPERATION_EXCEPTION_FILE => new TypescriptFile(<<; +/** + * Generic over the operation's error union, so `e.cause.type` narrows to the branches the + * operation can actually produce rather than to any. + */ +export class OperationException extends Error { + public readonly cause: Failure; get code(): number { const code = this.cause.code; if (!code || typeof code !== 'number' || Number.isNaN(code)) { return 500; } - + return code; } - constructor(cause: Failure) { + constructor(cause: Failure) { super(`Operation failed with code \${cause.code}`); this.cause = cause; } - - public static is(e: unknown): e is OperationException { + + public static is(e: unknown): e is OperationException { return e instanceof OperationException; } } @@ -256,11 +262,14 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi self::BINDINGS_FILE => new TypescriptFile(<<queryUrl}', command: '{$metadata->commandUrl}'}, - baseUrl: '', - timeoutMs: 10000, + baseUrl: options?.baseUrl ?? '', + timeoutMs: options?.timeoutMs ?? 10000, }); } @@ -268,6 +277,14 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi client = operationClient; } +/** + * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather + * catch than branch. + * + * The error union is deliberately not inferred here: a catch clause variable is `unknown` in + * TypeScript whatever was thrown, so no signature on this function could carry E to the catch. + * Name it there instead - `OperationException.is(e)` types `e.cause` for you. + */ export function throwOnFailure(result: Result): asserts result is Success { if (!result.success) { throw new OperationException(result); diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index c3ea20c..8bc8206 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -23,6 +23,12 @@ final readonly class TypescriptServerCodeGenerator { + /** + * Every name that becomes a file on disk, whether a lib file a generator named or a module a + * namespace named, is held to this. + */ + private const string VALID_MODULE_NAME = '/^[a-zA-Z0-9_\-]+$/'; + /** * @param array $generators * @throws InvalidGeneratorDependencies @@ -165,7 +171,7 @@ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry) } foreach ($codeGenerator->emitFiles($definitions, $metadata, $registry) as $fileName => $fileContent) { - if (preg_match('/^[a-zA-Z0-9_\-]+$/', $fileName) !== 1) { + if (preg_match(self::VALID_MODULE_NAME, $fileName) !== 1) { throw new CodeGenException("Invalid file name '{$fileName}' for lib file. File names must only contain a-z, A-Z, 0-9, - and _."); } @@ -195,7 +201,20 @@ private function generateOperationDefinitions(array $definitions, ServerMetadata /** @var array $operationFiles */ $operationFiles = []; foreach ($definitions as $operationData) { - $fileKey = "{$operationData->definition->namespace}.ts"; + $namespace = $operationData->definition->namespace; + + // Lib file names are validated; this one comes straight from #[Query(namespace: ...)] + // and is written verbatim. A `/` or `..` in it is path traversal in a build tool, and + // a quote breaks the namespace literal union EmitTypeUtils emits. + if (preg_match(self::VALID_MODULE_NAME, $namespace) !== 1) { + throw new CodeGenException( + "Invalid namespace '{$namespace}' on " + . "{$operationData->definition->fullyQualifiedClassName}::{$operationData->definition->methodName}. " + . "A namespace becomes a module file name and must only contain a-z, A-Z, 0-9, - and _." + ); + } + + $fileKey = "{$namespace}.ts"; // The file is immutable, so each block produces a new one and the last is kept. It also // owns the blank lines between blocks, which is why nothing is appended as a separator. diff --git a/src/CodeGen/Utils/OutputDirectory.php b/src/CodeGen/Utils/OutputDirectory.php index 3aa3a04..5f766ef 100644 --- a/src/CodeGen/Utils/OutputDirectory.php +++ b/src/CodeGen/Utils/OutputDirectory.php @@ -2,6 +2,7 @@ namespace Le0daniel\PhpTsBindings\CodeGen\Utils; +use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -19,8 +20,25 @@ final class OutputDirectory */ public static function write(string $directory, array $files): void { + // A file about to be written that is already there unmarked is hand written TypeScript + // whose name collides with a generated module. Overwriting it silently is the one case + // the marker cannot recover from, so it is refused before anything is touched. + foreach ($files as $fileName => $file) { + $filePath = "{$directory}/{$fileName}"; + if (file_exists($filePath) && !self::isGeneratedFile($filePath)) { + throw new CodeGenException( + "Refusing to overwrite {$fileName}: it exists but does not carry the " + . "'" . TypescriptFile::MARKER . "' marker, so it was not written by this " + . "library. Either it is hand written and the output belongs somewhere else, " + . "or it predates the marker - delete the output directory once and generate " + . "again." + ); + } + } + // Everything generated is rewritten, so a module left over from an operation that no longer - // exists would otherwise keep importing types that are gone. + // exists would otherwise keep importing types that are gone. Only files carrying the marker + // are removed - the directory may legitimately hold TypeScript nobody here wrote. foreach (self::existingFileNames($directory) as $fileName) { unlink("{$directory}/{$fileName}"); } @@ -73,7 +91,11 @@ public static function verify(string $directory, array $files): array * Relative, not absolute: the caller's directory may be a relative or symlinked path, and only * the part below it can be compared to what the generators named. * - * @return list Every .ts file below the directory, relative to it. + * Only files this library wrote are reported. That is what makes write() safe to run against a + * directory holding anything else, and it keeps verify() from calling a hand written module + * stale. + * + * @return list Every generated .ts file below the directory, relative to it. */ private static function existingFileNames(string $directory): array { @@ -94,7 +116,7 @@ private static function existingFileNames(string $directory): array } $realPath = $file->getRealPath(); - if ($realPath === false) { + if ($realPath === false || !self::isGeneratedFile($realPath)) { continue; } @@ -104,4 +126,13 @@ private static function existingFileNames(string $directory): array sort($fileNames); return $fileNames; } + + /** + * Reads only as much of the file as the marker needs. + */ + private static function isGeneratedFile(string $filePath): bool + { + $head = file_get_contents($filePath, length: strlen(TypescriptFile::MARKER) + 2); + return $head !== false && TypescriptFile::isGenerated($head); + } } diff --git a/src/Typescript/Code/TypescriptFile.php b/src/Typescript/Code/TypescriptFile.php index 96b2dfc..b1d2162 100644 --- a/src/Typescript/Code/TypescriptFile.php +++ b/src/Typescript/Code/TypescriptFile.php @@ -107,11 +107,32 @@ public function toString(): string } $body = $this->code === '' ? '' : $this->code . PHP_EOL; - if ($importLines === []) { - return $body; - } + $withoutMarker = $importLines === [] + ? $body + : implode(PHP_EOL, $importLines) . PHP_EOL . ($body === '' ? '' : PHP_EOL . $body); + + return self::MARKER . PHP_EOL . ($withoutMarker === '' ? '' : PHP_EOL . $withoutMarker); + } + + /** + * Written as the first line of every generated file, and the only thing that identifies one. + * OutputDirectory deletes what it finds carrying this and nothing else, so hand written + * TypeScript can share the output directory without being destroyed by a regeneration. + * + * Deliberately free of a version, a timestamp and a path: generated files are compared byte for + * byte to decide whether they are stale, and anything varying would report every file as + * changed on every run. + */ + public const string MARKER = '// generated by: php-ts-bindings'; - return implode(PHP_EOL, $importLines) . PHP_EOL . ($body === '' ? '' : PHP_EOL . $body); + /** + * Whether $contents was written by this library. Reads the first line only, so it can be asked + * of a file on disk without loading it whole. + */ + public static function isGenerated(string $contents): bool + { + return str_starts_with($contents, self::MARKER . PHP_EOL) + || rtrim($contents, "\r\n") === self::MARKER; } #[Override] diff --git a/tests/Unit/CodeGen/OutputDirectoryTest.php b/tests/Unit/CodeGen/OutputDirectoryTest.php new file mode 100644 index 0000000..6344c91 --- /dev/null +++ b/tests/Unit/CodeGen/OutputDirectoryTest.php @@ -0,0 +1,99 @@ + new TypescriptFile('export type A = 1;')]); + + expect(file_get_contents("{$directory}/handwritten.ts"))->toBe("export const mine = 1;\n") + ->and(file_exists("{$directory}/users.ts"))->toBeTrue(); + + removeDirectory($directory); +}); + +test('a module left behind by a removed operation is pruned', function () { + $directory = outputDirectory(); + OutputDirectory::write($directory, [ + 'users.ts' => new TypescriptFile('export type A = 1;'), + 'orders.ts' => new TypescriptFile('export type B = 2;'), + ]); + + OutputDirectory::write($directory, ['users.ts' => new TypescriptFile('export type A = 1;')]); + + expect(file_exists("{$directory}/users.ts"))->toBeTrue() + ->and(file_exists("{$directory}/orders.ts"))->toBeFalse(); + + removeDirectory($directory); +}); + +test('overwriting an unmarked file with a generated module is refused', function () { + // The one case the marker cannot recover from: a hand written module whose name collides with + // one the generators are about to write. + $directory = outputDirectory(); + file_put_contents("{$directory}/users.ts", "export const mine = 1;\n"); + + expect(fn() => OutputDirectory::write($directory, ['users.ts' => new TypescriptFile('export type A = 1;')])) + ->toThrow(CodeGenException::class, 'Refusing to overwrite users.ts'); + + // Refused before anything was touched. + expect(file_get_contents("{$directory}/users.ts"))->toBe("export const mine = 1;\n"); + + removeDirectory($directory); +}); + +test('verify ignores a file this library did not write', function () { + $directory = outputDirectory(); + $files = ['users.ts' => new TypescriptFile('export type A = 1;')]; + OutputDirectory::write($directory, $files); + file_put_contents("{$directory}/handwritten.ts", "export const mine = 1;\n"); + + expect(OutputDirectory::verify($directory, $files))->toBe([]); + + removeDirectory($directory); +}); + +test('verify still reports a stale generated module and a changed one', function () { + $directory = outputDirectory(); + OutputDirectory::write($directory, [ + 'users.ts' => new TypescriptFile('export type A = 1;'), + 'orders.ts' => new TypescriptFile('export type B = 2;'), + ]); + + $issues = OutputDirectory::verify($directory, [ + 'users.ts' => new TypescriptFile('export type A = 2;'), + ]); + + expect($issues)->toBe([ + 'File orders.ts is not generated anymore and should be deleted.', + 'File users.ts does not match the generated output.', + ]); + + removeDirectory($directory); +}); diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 623fbc4..5c49896 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -112,7 +112,7 @@ function generateFor(array $classes, ?array $generators = null): array // bindings collects executeOperation and throwOnFailure, utils' queryKey is claimed twice and // deduped, and the aliases come from both EmitOperations and EmitQueryKey. Type only exports // are on their own line, which is what verbatimModuleSyntax requires. - expect($operations)->toStartWith(<<toStartWith(TypescriptFile::MARKER . "\n\n" . <<toString())->toStartWith(<<toString())->toStartWith(TypescriptFile::MARKER . "\n\n" . <<toString())->toStartWith( - "import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from './types';" + TypescriptFile::MARKER . "\n\n" + . "import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from './types';" ); expect($files['lib/types.ts']->toString())->not->toContain('import '); diff --git a/tests/Unit/Typescript/Code/TypescriptFileTest.php b/tests/Unit/Typescript/Code/TypescriptFileTest.php index fbb84ca..7c8b8c0 100644 --- a/tests/Unit/Typescript/Code/TypescriptFileTest.php +++ b/tests/Unit/Typescript/Code/TypescriptFileTest.php @@ -3,16 +3,30 @@ use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; +/** + * Every generated file opens with the marker, so the expectations below say only what follows it. + * OutputDirectory reads that line to tell what it wrote from what it must not touch. + */ +function renderedBody(TypescriptFile $file): string +{ + $prefix = TypescriptFile::MARKER . "\n"; + $rendered = $file->toString(); + + expect($rendered)->toStartWith($prefix); + + return $rendered === $prefix ? '' : substr($rendered, strlen($prefix) + 1); +} + test('renders an empty file as an empty string', function () { - expect(new TypescriptFile()->toString())->toBe(''); + expect(renderedBody(new TypescriptFile()))->toBe(''); }); test('renders code with no imports', function () { - expect(new TypescriptFile('export type A = 1;')->toString())->toBe("export type A = 1;\n"); + expect(renderedBody(new TypescriptFile('export type A = 1;')))->toBe("export type A = 1;\n"); }); test('always ends with exactly one newline', function (string $code) { - expect(new TypescriptFile($code)->toString())->toBe("const a = 1;\n"); + expect(renderedBody(new TypescriptFile($code)))->toBe("const a = 1;\n"); })->with([ 'none' => ['const a = 1;'], 'one' => ["const a = 1;\n"], @@ -25,7 +39,7 @@ TypescriptImport::values('./lib/utils', 'queryKey'), ]); - expect($file->toString())->toBe( + expect(renderedBody($file))->toBe( "import {queryKey} from './lib/utils';\n\nconst a = queryKey();\n" ); }); @@ -33,7 +47,7 @@ test('renders imports alone when there is no code', function () { $file = new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')]); - expect($file->toString())->toBe("import type {Brand} from './lib/types';\n"); + expect(renderedBody($file))->toBe("import type {Brand} from './lib/types';\n"); }); test('splits a module into a type line and a value line, type first', function () { @@ -41,7 +55,7 @@ new TypescriptImport('./lib/types', values: ['isBrand'], types: ['Brand']), ]); - expect($file->toString())->toBe( + expect(renderedBody($file))->toBe( "import type {Brand} from './lib/types';\n" . "import {isBrand} from './lib/types';\n" ); @@ -51,13 +65,13 @@ $values = new TypescriptFile('', [TypescriptImport::values('./lib/utils', 'queryKey')]); $types = new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')]); - expect($values->toString())->toBe("import {queryKey} from './lib/utils';\n") - ->and($types->toString())->toBe("import type {Brand} from './lib/types';\n"); + expect(renderedBody($values))->toBe("import {queryKey} from './lib/utils';\n") + ->and(renderedBody($types))->toBe("import type {Brand} from './lib/types';\n"); }); test('quotes module specifiers with single quotes', function () { // Syntax::stringLiteral() is json_encode and would produce double quotes here. - expect(new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')])->toString()) + expect(renderedBody(new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')]))) ->toContain("from './lib/types';") ->not->toContain('"./lib/types"'); }); @@ -65,7 +79,7 @@ test('separates names with a comma and a space and does not pad the braces', function () { $file = new TypescriptFile('', [TypescriptImport::types('./lib/types', ['Brand', 'Order'])]); - expect($file->toString())->toBe("import type {Brand, Order} from './lib/types';\n"); + expect(renderedBody($file))->toBe("import type {Brand, Order} from './lib/types';\n"); }); test('sorts the names inside each line', function () { @@ -73,7 +87,7 @@ TypescriptImport::types('./lib/types', ['OrderStatus', 'Brand', 'Customer']), ]); - expect($file->toString())->toBe("import type {Brand, Customer, OrderStatus} from './lib/types';\n"); + expect(renderedBody($file))->toBe("import type {Brand, Customer, OrderStatus} from './lib/types';\n"); }); test('sorts modules by specifier', function () { @@ -83,7 +97,7 @@ TypescriptImport::types('./lib/types', 'Brand'), ]); - expect($file->toString())->toBe( + expect(renderedBody($file))->toBe( "import type {Brand} from './lib/types';\n" . "import {queryKey} from './lib/utils';\n" . "import {useQuery} from '@tanstack/react-query';\n" @@ -97,7 +111,7 @@ ]); expect($file->imports)->toHaveCount(1) - ->and($file->toString())->toBe("import type {Brand, Order} from './lib/types';\n"); + ->and(renderedBody($file))->toBe("import type {Brand, Order} from './lib/types';\n"); }); test('test mixed import', function () { @@ -110,7 +124,7 @@ ]); expect($file->imports)->toHaveCount(1) - ->and($file->toString())->toBe("import type {Brand, Order} from './lib/types';\nimport {SomeValue} from './lib/types';\n"); + ->and(renderedBody($file))->toBe("import type {Brand, Order} from './lib/types';\nimport {SomeValue} from './lib/types';\n"); }); test('merges constructor imports with imports added later', function () { @@ -118,7 +132,7 @@ ->withImports(TypescriptImport::types('./lib/types', 'Brand')); expect($file->imports)->toHaveCount(1) - ->and($file->toString())->toBe("import type {Brand, Order} from './lib/types';\n"); + ->and(renderedBody($file))->toBe("import type {Brand, Order} from './lib/types';\n"); }); test('drops an import that names nothing', function () { @@ -128,7 +142,7 @@ ]); expect($file->imports)->toHaveCount(1) - ->and($file->toString())->toBe("import {queryKey} from './lib/utils';\n\nconst a = 1;\n"); + ->and(renderedBody($file))->toBe("import {queryKey} from './lib/utils';\n\nconst a = 1;\n"); }); test('never emits a name as both a type and a value import of one module', function () { @@ -137,7 +151,7 @@ TypescriptImport::values('./lib/types', 'Status'), ]); - expect($file->toString())->toBe( + expect(renderedBody($file))->toBe( "import type {Order} from './lib/types';\n" . "import {Status} from './lib/types';\n" ); @@ -155,7 +169,7 @@ // The two specifiers name one module once resolved, so they render as one import and not as a // duplicated line the resolver would otherwise have introduced. expect($file->imports)->toHaveCount(2) - ->and($file->toString())->toBe( + ->and(renderedBody($file))->toBe( "import type {Order} from './types';\n" . "import {isOrder} from './types';\n" . "import {useQuery} from '@tanstack/react-query';\n" @@ -183,8 +197,8 @@ TypescriptImport::types('./lib/types', 'Order'), ]; - expect(new TypescriptFile('const a = 1;', $imports)->toString()) - ->toBe(new TypescriptFile('const a = 1;', array_reverse($imports))->toString()); + expect(renderedBody(new TypescriptFile('const a = 1;', $imports))) + ->toBe(renderedBody(new TypescriptFile('const a = 1;', array_reverse($imports)))); }); test('appending a string keeps the existing imports', function () { @@ -192,7 +206,7 @@ ->append('const b = 2;'); expect($file->imports)->toHaveCount(1) - ->and($file->toString())->toBe( + ->and(renderedBody($file))->toBe( "import type {Brand} from './lib/types';\n\nconst a = 1;\n\nconst b = 2;\n" ); }); @@ -204,7 +218,7 @@ TypescriptImport::values('./lib/utils', 'queryKey'), ])); - expect($file->toString())->toBe( + expect(renderedBody($file))->toBe( "import type {Brand, Order} from './lib/types';\n" . "import {queryKey} from './lib/utils';\n" . "\nconst a = 1;\n\nconst b = 2;\n" @@ -224,25 +238,25 @@ ->append('export type B = 2;') ->append('export type C = 3;'); - expect($file->toString())->toBe("export type A = 1;\n\nexport type B = 2;\n\nexport type C = 3;\n"); + expect(renderedBody($file))->toBe("export type A = 1;\n\nexport type B = 2;\n\nexport type C = 3;\n"); }); test('strips the newlines around an appended block', function () { $file = new TypescriptFile('export type A = 1;')->append("\n\nexport type B = 2;\n\n"); - expect($file->toString())->toBe("export type A = 1;\n\nexport type B = 2;\n"); + expect(renderedBody($file))->toBe("export type A = 1;\n\nexport type B = 2;\n"); }); test('keeps the indentation of an appended block', function () { $file = new TypescriptFile('function a() {')->append("\n return 1;\n"); - expect($file->toString())->toBe("function a() {\n\n return 1;\n"); + expect(renderedBody($file))->toBe("function a() {\n\n return 1;\n"); }); test('appending nothing is a no-op', function (string $code) { $file = new TypescriptFile('const a = 1;'); - expect($file->append($code)->toString())->toBe("const a = 1;\n"); + expect(renderedBody($file->append($code)))->toBe("const a = 1;\n"); })->with([ 'empty string' => [''], 'newlines' => ["\n\n"], @@ -250,13 +264,13 @@ ]); test('appending to an empty file does not start it with a blank line', function () { - expect(new TypescriptFile()->append('const a = 1;')->toString())->toBe("const a = 1;\n") - ->and(new TypescriptFile()->append(new TypescriptFile('const a = 1;'))->toString()) + expect(renderedBody(new TypescriptFile()->append('const a = 1;')))->toBe("const a = 1;\n") + ->and(renderedBody(new TypescriptFile()->append(new TypescriptFile('const a = 1;')))) ->toBe("const a = 1;\n"); }); test('constructing with code is the same as appending it to an empty file', function (string $code) { - expect(new TypescriptFile($code)->toString())->toBe(new TypescriptFile()->append($code)->toString()); + expect(renderedBody(new TypescriptFile($code)))->toBe(renderedBody(new TypescriptFile()->append($code))); })->with([ 'plain' => ['const a = 1;'], 'padded with newlines' => ["\nconst a = 1;\n\n"], @@ -303,7 +317,7 @@ [TypescriptImport::types('./lib/types', 'OrderStatus')], )); - expect($file->toString())->toBe(<<toBe(<<toBeInstanceOf(Stringable::class) ->and((string)$file)->toBe($file->toString()); }); + +test('every rendered file opens with the marker', function (TypescriptFile $file) { + expect($file->toString())->toStartWith(TypescriptFile::MARKER . "\n") + ->and(TypescriptFile::isGenerated($file->toString()))->toBeTrue(); +})->with([ + 'empty' => [fn() => new TypescriptFile()], + 'code only' => [fn() => new TypescriptFile('const a = 1;')], + 'imports only' => [fn() => new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')])], + 'both' => [fn() => new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')])], +]); + +test('the marker carries nothing that varies between runs', function () { + // Generated files are compared byte for byte to decide whether they are stale, so a version, + // a timestamp or a path in the marker would report every file as changed on every run. + expect(TypescriptFile::MARKER)->toBe('// generated by: php-ts-bindings'); +}); + +test('a file this library did not write is not recognised as generated', function (string $contents) { + expect(TypescriptFile::isGenerated($contents))->toBeFalse(); +})->with([ + 'hand written' => ["export const a = 1;\n"], + 'empty' => [''], + 'marker not on the first line' => ["export const a = 1;\n// generated by: php-ts-bindings\n"], + 'similar comment' => ["// generated by: something-else\n"], +]); diff --git a/tests/ts-output/generated/accounts.ts b/tests/ts-output/generated/accounts.ts index 546bbe9..a7d889c 100644 --- a/tests/ts-output/generated/accounts.ts +++ b/tests/ts-output/generated/accounts.ts @@ -1,3 +1,5 @@ +// generated by: php-ts-bindings + import type {OperationOptions} from './lib/OperationClient'; import {executeOperation, throwOnFailure} from './lib/bindings'; import type {Availability, Brand} from './lib/types'; diff --git a/tests/ts-output/generated/catalog.ts b/tests/ts-output/generated/catalog.ts index 414d54e..1eb2b21 100644 --- a/tests/ts-output/generated/catalog.ts +++ b/tests/ts-output/generated/catalog.ts @@ -1,3 +1,5 @@ +// generated by: php-ts-bindings + import type {OperationOptions} from './lib/OperationClient'; import {executeOperation, throwOnFailure} from './lib/bindings'; import type {Availability, Brand, Draft, DraftInput, Money, Product, Sku} from './lib/types'; diff --git a/tests/ts-output/generated/lib/DefaultClient.ts b/tests/ts-output/generated/lib/DefaultClient.ts index 24ec908..0538b90 100644 --- a/tests/ts-output/generated/lib/DefaultClient.ts +++ b/tests/ts-output/generated/lib/DefaultClient.ts @@ -1,3 +1,5 @@ +// generated by: php-ts-bindings + import type {OperationClient, OperationOptions} from './OperationClient'; import type {Failure, Result, Success, WithClientDirectives} from './types'; @@ -52,10 +54,12 @@ export class DefaultClient implements OperationClient { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{fqn}', key)}`; - const timeoutInMs = this.options?.timeoutMs ?? options?.timeoutMs; + // Per call wins over the client wide default, and the timeout signal actually fires: a + // fresh AbortController is never aborted by anything. + const timeoutInMs = options?.timeoutMs ?? this.options?.timeoutMs; const signal = this.joinSignals([ options?.signal, - timeoutInMs ? new AbortController().signal : undefined + timeoutInMs ? AbortSignal.timeout(timeoutInMs) : undefined ]); const headers: Record = { diff --git a/tests/ts-output/generated/lib/OperationClient.ts b/tests/ts-output/generated/lib/OperationClient.ts index 664d168..6560b05 100644 --- a/tests/ts-output/generated/lib/OperationClient.ts +++ b/tests/ts-output/generated/lib/OperationClient.ts @@ -1,3 +1,5 @@ +// generated by: php-ts-bindings + import type {Result, WithClientDirectives} from './types'; export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; diff --git a/tests/ts-output/generated/lib/OperationException.ts b/tests/ts-output/generated/lib/OperationException.ts index b0c9a6d..3356968 100644 --- a/tests/ts-output/generated/lib/OperationException.ts +++ b/tests/ts-output/generated/lib/OperationException.ts @@ -1,23 +1,29 @@ +// generated by: php-ts-bindings + import type {Failure} from './types'; -export class OperationException extends Error { - public readonly cause: Failure; +/** + * Generic over the operation's error union, so `e.cause.type` narrows to the branches the + * operation can actually produce rather than to any. + */ +export class OperationException extends Error { + public readonly cause: Failure; get code(): number { const code = this.cause.code; if (!code || typeof code !== 'number' || Number.isNaN(code)) { return 500; } - + return code; } - constructor(cause: Failure) { + constructor(cause: Failure) { super(`Operation failed with code ${cause.code}`); this.cause = cause; } - - public static is(e: unknown): e is OperationException { + + public static is(e: unknown): e is OperationException { return e instanceof OperationException; } } diff --git a/tests/ts-output/generated/lib/bindings.ts b/tests/ts-output/generated/lib/bindings.ts index 14d39d7..b2e4398 100644 --- a/tests/ts-output/generated/lib/bindings.ts +++ b/tests/ts-output/generated/lib/bindings.ts @@ -1,3 +1,5 @@ +// generated by: php-ts-bindings + import {DefaultClient} from './DefaultClient'; import type {OperationClient, OperationOptions} from './OperationClient'; import {OperationException} from './OperationException'; @@ -5,11 +7,14 @@ import type {Result, Success, WithClientDirectives} from './types'; let client: OperationClient|null; -export function createDefaultClient(fetcher?: typeof window.fetch): DefaultClient { +export function createDefaultClient( + fetcher?: typeof window.fetch, + options?: {baseUrl?: string; timeoutMs?: number}, +): DefaultClient { return new DefaultClient(fetcher ?? fetch, { paths: {query: '/query/{fqn}', command: '/command/{fqn}'}, - baseUrl: '', - timeoutMs: 10000, + baseUrl: options?.baseUrl ?? '', + timeoutMs: options?.timeoutMs ?? 10000, }); } @@ -17,6 +22,14 @@ export function setClient(operationClient: OperationClient|null): void { client = operationClient; } +/** + * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather + * catch than branch. + * + * The error union is deliberately not inferred here: a catch clause variable is `unknown` in + * TypeScript whatever was thrown, so no signature on this function could carry E to the catch. + * Name it there instead - `OperationException.is(e)` types `e.cause` for you. + */ export function throwOnFailure(result: Result): asserts result is Success { if (!result.success) { throw new OperationException(result); diff --git a/tests/ts-output/generated/lib/type-map.ts b/tests/ts-output/generated/lib/type-map.ts index effb080..9d9dc49 100644 --- a/tests/ts-output/generated/lib/type-map.ts +++ b/tests/ts-output/generated/lib/type-map.ts @@ -1,3 +1,5 @@ +// generated by: php-ts-bindings + import type {Availability, Brand, Draft, DraftInput, Money, Product, Sku} from './types'; /** diff --git a/tests/ts-output/generated/lib/types.ts b/tests/ts-output/generated/lib/types.ts index 837966e..a0d3d7a 100644 --- a/tests/ts-output/generated/lib/types.ts +++ b/tests/ts-output/generated/lib/types.ts @@ -1,3 +1,5 @@ +// generated by: php-ts-bindings + export type OperationNamespaces = 'accounts'|'catalog'|'shapes'; export type Success = {success: true, data: T} diff --git a/tests/ts-output/generated/lib/utils.ts b/tests/ts-output/generated/lib/utils.ts index ddec305..f00f1ee 100644 --- a/tests/ts-output/generated/lib/utils.ts +++ b/tests/ts-output/generated/lib/utils.ts @@ -1,3 +1,5 @@ +// generated by: php-ts-bindings + import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from './types'; type QueryNamespaces = 'accounts'|'catalog'|'shapes'; diff --git a/tests/ts-output/generated/shapes.ts b/tests/ts-output/generated/shapes.ts index a77bca6..0a09881 100644 --- a/tests/ts-output/generated/shapes.ts +++ b/tests/ts-output/generated/shapes.ts @@ -1,3 +1,5 @@ +// generated by: php-ts-bindings + import type {OperationOptions} from './lib/OperationClient'; import {executeOperation, throwOnFailure} from './lib/bindings'; import type {Availability, Brand, Money, Product, Sku} from './lib/types'; diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index 7784c97..be78b0d 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -6,6 +6,7 @@ * Nothing here runs. It exists to be typechecked by `composer codegen:fixture`. */ import {find, lock} from '../generated/accounts'; +import type {ProductError} from '../generated/catalog'; import {prepare, product, productQueryKey, productQueryOptions, restock, search, useProductQuery} from '../generated/catalog'; import {createDefaultClient, setClient, throwOnFailure} from '../generated/lib/bindings'; import {OperationException} from '../generated/lib/OperationException'; @@ -50,8 +51,17 @@ export async function readProductOrThrow(): Promise { throwOnFailure(result); return result.data; } catch (error) { - if (OperationException.is(error)) { - console.error('operation failed', error.code, error.cause.type); + // A catch clause variable is `unknown` whatever was thrown, so the operation's error union + // is named here rather than inferred. OperationException is generic over it, which is what + // makes `cause.type` the discriminated union instead of any. + if (OperationException.is(error)) { + const failureType: ProductError['type'] = error.cause.type; + console.error('operation failed', error.code, failureType); + + if (error.cause.code === 422) { + const fields: Record = error.cause.details.fields; + console.error('invalid input', fields); + } } throw error; } From 750bcafca485729acb872d4f43057b032d58f9fb Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 16:18:17 +0200 Subject: [PATCH 057/101] Make the core standalone in packaging, not just in code No framework code ever leaked outside src/Adapters/Laravel/, but the packaging said otherwise. - psr/container leaves `require`. The only genuine use is PsrContainerAdapter; Server's default is NewInstanceAdapter, which needs no container. What made the dependency look core were three dead PSR imports in Server.php. It is a `suggest` now, and `require` is PHP 8.5 and nothing else. - Preloader moves to Le0daniel\PhpTsBindings\Server. It has zero Laravel imports - it is Server + OperationKeyGenerator + NullClient - so a framework-less user had to import it from the Laravel namespace. - src/PHPStan moves to src/Adapters/PHPStan. It is a second optional-adapter tree on a require-dev package, filed outside the directory that means exactly that. - UnknownTypeKeyException names the concept instead of `php artisan operations:optimize`. A framework-less user of ASTOptimizer plus CachedTypeRegistry was being told to run a command that does not exist. Both moves are breaking and deliberately carry no alias. The Laravel adapter also read operations.key in two places, which is the drift Preloader's own docblock calls critical: on disagreement a preloaded query is simply not found. One factory now, and its failures are loud - an unrecognised key.mode fell through to a different pepper than the configured one, so a typo silently changed every key in the application, and a null key.className produced a raw TypeError from the container. Preloader builds its query key from the schema rather than the value. The generated queryKey() appends the input whenever the operation has one, so deciding on `$input === null` gave a nullable-input query a two-element key against the client's three-element one, and the seeded cache never matched. InvalidMiddlewareException is thrown rather than being dead code: the class-string is checked before anything is constructed, so a class that is not middleware is named along with the contract it is missing instead of producing a TypeError from inside the adapter. Finally, ?filter[a]=1 no longer escapes as a TypeError. The query parameter callback declared `string` under strict_types while Request::query()->all() returns nested arrays for that shape, and it was raised before Server::query() was reached - bypassing "every Throwable comes back as an RpcError" and producing a raw framework 500. Non-strings pass through for the schema to reject. Co-Authored-By: Claude Opus 5 (1M context) --- composer.json | 9 +-- composer.lock | 2 +- extension.neon | 2 +- .../Laravel/LaravelHttpController.php | 10 +++- .../Laravel/LaravelServiceProvider.php | 60 +++++++++++++------ .../PHPStan/UtilitiesNodeResolver.php | 2 +- .../Exceptions/UnknownTypeKeyException.php | 7 ++- .../Laravel => Server}/Preloader.php | 26 +++++++- src/Server/Server.php | 23 ++++++- .../Laravel/LaravelHttpControllerTest.php | 52 +++++++++++++++- tests/Feature/ServerTest.php | 5 +- tests/Unit/Parser/OptimizedCodeShapeTest.php | 6 +- 12 files changed, 166 insertions(+), 38 deletions(-) rename src/{ => Adapters}/PHPStan/UtilitiesNodeResolver.php (99%) rename src/{Adapters/Laravel => Server}/Preloader.php (66%) diff --git a/composer.json b/composer.json index c7e4dee..9c387dc 100644 --- a/composer.json +++ b/composer.json @@ -12,19 +12,20 @@ "laravel" ], "require": { - "php": "^8.5", - "psr/container": "^2.0" + "php": "^8.5" }, "require-dev": { "pestphp/pest": "^v4.7.0", "phpstan/phpstan": "^2.1", "laravel/framework": "^13", "mockery/mockery": "^1.6", - "phpstan/phpstan-strict-rules": "^2.0" + "phpstan/phpstan-strict-rules": "^2.0", + "psr/container": "^2.0" }, "suggest": { "laravel/framework": "Enables the first-party Laravel adapter: config, routes, and the operations:* artisan commands.", - "phpstan/phpstan": "Include vendor/le0daniel/php-ts-bindings/extension.neon so Pick, Omit, BrandedString, BrandedInt and DateTimeString resolve." + "phpstan/phpstan": "Include vendor/le0daniel/php-ts-bindings/extension.neon so Pick, Omit, BrandedString, BrandedInt and DateTimeString resolve.", + "psr/container": "Required only by PsrContainerAdapter. Install it to resolve handlers and middleware through a PSR-11 container; the default NewInstanceAdapter needs nothing." }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 0265219..0b46176 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "891be19d03ef851cf36b3d050c103c27", + "content-hash": "1d6053f8fa814940dfa6004392072e9c", "packages": [ { "name": "psr/container", diff --git a/extension.neon b/extension.neon index ceb855d..07442ea 100644 --- a/extension.neon +++ b/extension.neon @@ -1,5 +1,5 @@ services: - - class: Le0daniel\PhpTsBindings\PHPStan\UtilitiesNodeResolver + class: Le0daniel\PhpTsBindings\Adapters\PHPStan\UtilitiesNodeResolver tags: - phpstan.phpDoc.typeNodeResolverExtension \ No newline at end of file diff --git a/src/Adapters/Laravel/LaravelHttpController.php b/src/Adapters/Laravel/LaravelHttpController.php index a288355..ba00db4 100644 --- a/src/Adapters/Laravel/LaravelHttpController.php +++ b/src/Adapters/Laravel/LaravelHttpController.php @@ -96,7 +96,15 @@ private function createClient(Http\Request $request): Client private function gatherInputFromRequest(OperationType $type, Http\Request $request): ?array { $inputData = match ($type) { - OperationType::QUERY => array_map(static function (string $value): mixed { + // mixed, not string: ?filter[a]=1 hands back a nested array, and a string parameter + // raised a TypeError here - before Server::query() was reached, so it escaped the + // guarantee that every Throwable comes back as an RpcError. Anything that is not a + // string is passed through untouched for the schema to reject properly. + OperationType::QUERY => array_map(static function (mixed $value): mixed { + if (!is_string($value)) { + return $value; + } + try { return json_decode($value, flags: JSON_THROW_ON_ERROR); } catch (Throwable) { diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index df17d01..95c71f5 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -13,6 +13,7 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\OptimizeCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Middleware\LocalMetadataMiddleware; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; +use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; @@ -20,7 +21,10 @@ use Le0daniel\PhpTsBindings\Server\KeyGenerators\HashSha256KeyGenerator; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; +use Le0daniel\PhpTsBindings\Server\Preloader; use Le0daniel\PhpTsBindings\Server\Server; +use Le0daniel\PhpTsBindings\Utils\Assertions; +use InvalidArgumentException; use Override; final class LaravelServiceProvider extends ServiceProvider implements DeferrableProvider @@ -45,6 +49,41 @@ public function provides(): array ]; } + /** + * The one place operations.key is read. Preloader has to derive keys exactly as the registry + * does or a preloaded query is simply not found, and two copies of this match were how they + * would come to disagree. + * + * Every failure is loud: an unrecognised mode used to fall through to a different pepper than + * the configured one, so a typo in the config silently changed every key in the application. + */ + private static function keyGeneratorFrom(Application $app): OperationKeyGenerator + { + $config = $app->make('config'); + $mode = $config->get('operations.key.mode', 'obfuscate'); + + return match ($mode) { + 'plain' => new PlainlyExposedKeyGenerator(), + 'obfuscate' => new HashSha256KeyGenerator($config->get('operations.key.pepper', 'none')), + 'custom' => self::customKeyGenerator($app, $config->get('operations.key.className')), + default => throw new InvalidArgumentException( + "Invalid operations.key.mode '{$mode}'. Use 'obfuscate', 'plain' or 'custom'." + ), + }; + } + + private static function customKeyGenerator(Application $app, mixed $className): OperationKeyGenerator + { + if (!is_string($className) || $className === '') { + throw new InvalidArgumentException( + "operations.key.mode is 'custom', so operations.key.className must name a class " + . "implementing " . OperationKeyGenerator::class . "." + ); + } + + return Assertions::instanceOf(OperationKeyGenerator::class, $app->make($className)); + } + public static function serverFactory( Application $app, ?OperationRegistry $operations, @@ -55,14 +94,7 @@ public static function serverFactory( $operations ??= EagerlyLoadedOperationRegistry::eagerlyDiscover( $config->get('operations.discovery_path', []), $app->make(TypeParser::class), - match ($config->get('operations.key.mode', 'obfuscate')) { - 'plain' => new PlainlyExposedKeyGenerator(), - 'obfuscate' => new HashSha256KeyGenerator( - $config->get('operations.key.pepper', 'none') - ), - "custom" => $app->make($config->get('operations.key.className')), - default => new HashSha256KeyGenerator("default"), - }, + self::keyGeneratorFrom($app), ); $isDebuggingEnabled = $config->get('app.debug', false); @@ -108,19 +140,9 @@ public function register(): void }); $this->app->singleton(Preloader::class, function (Application $app): Preloader { - /** @var Repository $config */ - $config = $app->make('config'); - return new Preloader( server: $app->make(self::DEFAULT_SERVER), - keyGenerator: match ($config->get('operations.key.mode', 'obfuscate')) { - 'plain' => new PlainlyExposedKeyGenerator(), - 'obfuscate' => new HashSha256KeyGenerator( - $config->get('operations.key.pepper', 'none') - ), - "custom" => $app->make($config->get('operations.key.className')), - default => new HashSha256KeyGenerator("default"), - }, + keyGenerator: self::keyGeneratorFrom($app), ); }); diff --git a/src/PHPStan/UtilitiesNodeResolver.php b/src/Adapters/PHPStan/UtilitiesNodeResolver.php similarity index 99% rename from src/PHPStan/UtilitiesNodeResolver.php rename to src/Adapters/PHPStan/UtilitiesNodeResolver.php index 51401f6..e6c2849 100644 --- a/src/PHPStan/UtilitiesNodeResolver.php +++ b/src/Adapters/PHPStan/UtilitiesNodeResolver.php @@ -1,6 +1,6 @@ $result->data, - 'queryKey' => $input === null ? [$namespaceAsString, $name] : [$namespaceAsString, $name, $input], + 'queryKey' => $this->queryKey($namespaceAsString, $name, $input), ]; } + /** + * Must be the key the generated `queryKey()` builds, or a seeded cache never matches and the + * client refetches what was just preloaded. + * + * Whether the input is part of the key is a property of the schema, not of the value: the + * generated code appends it whenever the operation has an input at all. Deciding on + * `$input === null` instead meant a nullable-input query preloaded with null produced a + * two-element key against the client's three-element one. + * + * @return list + */ + private function queryKey(string $namespace, string $name, mixed $input): array + { + $operation = $this->server->registry->get(OperationType::QUERY, $this->keyGenerator->generateKey($namespace, $name)); + + return $operation->inputNode() instanceof NullNode + ? [$namespace, $name] + : [$namespace, $name, $input]; + } + /** * @param list $preloads * @param mixed $context diff --git a/src/Server/Server.php b/src/Server/Server.php index ed36063..f87880a 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -23,9 +23,6 @@ use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\Errors\ErrorPresenter; use Le0daniel\PhpTsBindings\Server\Pipeline\ContextualPipeline; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\ContainerInterface; -use Psr\Container\NotFoundExceptionInterface; use Throwable; final readonly class Server @@ -76,6 +73,22 @@ public function command(string $name, mixed $input, mixed $context, Client $clie return $this->execute($this->registry->get(OperationType::COMMAND, $name), $input, $context, $client); } + /** + * Middleware is named by class-string, from an attribute or from the configuration, and + * `class-string` on those declarations is what they promise rather than + * anything that was checked - which is why this takes a plain string. + * + * Verified before anything is constructed: every adapter's createMiddleware() declares + * MiddlewareContract as its return type, so without this the mistake surfaces as a TypeError + * from inside the adapter naming neither the middleware nor the contract it is missing. + */ + private static function assertIsMiddleware(string $className): void + { + if (!is_a($className, MiddlewareContract::class, true)) { + throw InvalidMiddlewareException::notAMiddleware($className); + } + } + private function execute(Operation $operation, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { $middlewareClassNames = [ @@ -96,6 +109,10 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli // query()/command() total: a missing container binding or a class that is not a // middleware must surface as an RpcError, not as an uncaught exception. try { + foreach ($middlewareClassNames as $middlewareClassName) { + self::assertIsMiddleware($middlewareClassName); + } + $middlewares = array_map(fn($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); $controllerClass = $this->adapter->createController($operation->definition->fullyQualifiedClassName); } catch (Throwable $throwable) { diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index be21b51..cd61c3a 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -219,4 +219,54 @@ public function someMethod(array $input, null $context, Client $client): array 'code' => 422, 'type' => 'INVALID_INPUT', ]); -}); \ No newline at end of file +}); +test('a nested query parameter comes back as an RpcError rather than escaping as a TypeError', function () { + // ?filter[a]=1 hands back a nested array. A string typed callback raised a TypeError here, + // before Server::query() was reached, so it bypassed the guarantee that every Throwable comes + // back as an RpcError and produced a raw framework 500. The generated client never emits nested + // params, but a hand written one, a bookmarked URL or a crawler will. + $fcn = 'docs.method'; + + $typeParser = new TypeParser(); + $operationRegistry = Mockery::mock(OperationRegistry::class); + $exceptionHandler = Mockery::mock(ExceptionHandler::class); + $app = Mockery::mock(Application::class); + $request = Request::create('/query/docs.method', 'GET', ['name' => ['nested' => '1']]); + + $operationDefinition = new Definition( + OperationType::QUERY, + 'MyClass', + 'someMethod', + 'method', + 'docs', + [], + ); + + $operation = new Operation( + 'somekey', + $operationDefinition, + fn() => $typeParser->parse('array{name: string}'), + fn() => $typeParser->parse('array{id: string, name: string}'), + ); + + $controllerInstance = new class() { + public function someMethod(array $input, null $context, Client $client): array + { + return ['id' => '123', 'name' => $input['name']]; + } + }; + + $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); + $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); + $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); + $exceptionHandler->shouldReceive('report')->andReturnNull(); + + $server = new Server($operationRegistry, new PsrContainerAdapter(container: $app)); + $response = new LaravelHttpController($server, $exceptionHandler, null) + ->handleHttpQueryRequest($fcn, $request); + + // The schema rejects it, which is a 422 and not an unhandled TypeError. + expect($response)->toBeInstanceOf(JsonResponse::class) + ->and($response->getStatusCode())->toBe(422) + ->and($response->getData(true)['type'])->toBe('INVALID_INPUT'); +}); diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 5ae91fd..5887702 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -67,9 +67,12 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { $result = $server->command('test.run', ['name' => 'Leo'], null, new NullClient()); + // Named, not a TypeError from inside the adapter: the class-string is checked before + // anything is constructed, so the message says which class and which contract. expect($result)->toBeInstanceOf(RpcError::class) ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($result->cause)->toBeInstanceOf(TypeError::class); + ->and($result->cause)->toBeInstanceOf(InvalidMiddlewareException::class) + ->and($result->cause->getMessage())->toContain(NotAMiddleware::class); }); test("Middleware emits typescript middleware", function () { diff --git a/tests/Unit/Parser/OptimizedCodeShapeTest.php b/tests/Unit/Parser/OptimizedCodeShapeTest.php index 8408694..7603d15 100644 --- a/tests/Unit/Parser/OptimizedCodeShapeTest.php +++ b/tests/Unit/Parser/OptimizedCodeShapeTest.php @@ -56,21 +56,21 @@ function generateFor(string ...$types): string expect(generateFor($type))->toBe(generateFor($type)); }); -test('an unknown key raises a typed exception naming the regeneration command', function () { +test('an unknown key raises a typed exception saying the cache has to be regenerated', function () { $code = generateFor('array{a: string}'); /** @var CachedTypeRegistry $registry */ $registry = eval("return {$code};"); expect(fn() => $registry->get('does-not-exist')) - ->toThrow(UnknownTypeKeyException::class, 'operations:optimize'); + ->toThrow(UnknownTypeKeyException::class, 'Regenerate the optimized schema cache'); }); test('the legacy array shape is rejected rather than silently accepted', function () { // A cache written before identity was fixed carries merged schemas; booting it would run with // constraints silently dropped, so it must fail loudly instead. expect(fn() => new CachedTypeRegistry(['key' => static fn() => new TypeParser()->parse('string')])) - ->toThrow(UnknownTypeKeyException::class, 'operations:optimize'); + ->toThrow(UnknownTypeKeyException::class, 'Regenerate the optimized schema cache'); }); test('resolved nodes are memoized', function () { From 34bec238e766fb5b9b682d670f37111d8ef8752a Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 16:24:49 +0200 Subject: [PATCH 058/101] Bring the docs back in line with what the library does Several claims had drifted, and the fixes in this branch made a few more of them wrong until now: - "The core has one dependency (psr/container)" - it has none. - "#[Middleware(class or list)]" - one class per attribute, repeatable. - "Both accept a UnitEnum" - only the namespace does; the name is a string. - "It supports AbortSignal, timeouts" - timeouts genuinely work now, and per-call beats the client default. - "On Laravel both come from config/operations.php" - coerceQueryInput is core-only, and deliberately so: the Laravel transport round-trips types already, so there is nothing to coerce. - Four distinct --naming modes - fqn and operation-prefix are one rule. - key.pepper's default is the literal string "none", not "no pepper". - Preloader lives in Server now, and the ASTOptimizer snippet had two use statements on one line. Documented for the first time: the marker on every generated file and what OutputDirectory does with it; that a handler may declare a *prefix* of (input, context, client) and not a subset; middleware ordering; enums travelling as case names; bare `array` being rejected; that debug mode prepends a middleware and adds __metadata, __info and __debug; that the route parameter must stay named {fqn}; OperationOptions, registerHook's unsubscribe, createDefaultClient's options; ContextFactory with an example; the result metadata API and ResolveInfo's fields; and an "Extension points and exceptions" section - the exception hierarchy under PhpTsBindingsException appeared in neither file before. Two decisions are now written down as decisions, so nobody "fixes" them later. __client is `unknown` because Client is an extension point and the generated types cannot know another implementation's schema. And InvalidInputException::createFromMessages() is the deliberate seam for your own validation to ride the 422 this library will not itself produce. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 359 +++++++++++++++++++++----- docs/types.md | 20 +- src/Contracts/Attributes/Optional.php | 8 +- 3 files changed, 317 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index b81939f..e57ab58 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ them. Not an ORM serializer. Not a schema DSL. If a rule cannot be expressed as library will not check it for you; [value objects](docs/types.md#value-objects) are where such rules belong. -Requires **PHP 8.5**. The core has one dependency (`psr/container`) and no framework coupling. A +Requires **PHP 8.5** and nothing else — the core has no dependencies and no framework coupling. A first-party Laravel adapter ships in the box and is entirely optional. --- @@ -47,6 +47,7 @@ first-party Laravel adapter ships in the box and is entirely optional. - [Laravel setup](#laravel-setup) - [Production](#production) - [Without a framework](#without-a-framework) +- [Extension points and exceptions](#extension-points-and-exceptions) ## Install @@ -152,11 +153,16 @@ public function command(string $name, mixed $input, mixed $context, Client $clie ``` **`$name` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns -`namespace` + `name` into what the client calls. The default is `HashSha256KeyGenerator`, which -hashes both, so a discovered `users.get` is reachable as an opaque key rather than as `users.get`. +`namespace` + `name` into what the client calls. The default is +`HashSha256KeyGenerator`, whose first constructor argument — a pepper — is **required**; it hashes +both parts, so a discovered `users.get` is reachable as an opaque key rather than as `users.get`. Use `PlainlyExposedKeyGenerator` for literal keys. The generated TypeScript always embeds whichever key the server produced, so this only matters when you call the server by hand. +Obfuscation is not a security boundary: it keeps your operation names out of the shipped bundle, and +that is all. Two operations colliding on a truncated hash is an error at discovery, not a silent +overwrite — widen `fnNameLength` if a real application ever hits it. + **`OperationRegistry`** holds the operations. `EagerlyLoadedOperationRegistry` discovers them by scanning directories; schemas are parsed lazily, per operation, on first use. `CachedOperationRegistry` is the compiled form for production — see [Production](#production). @@ -174,29 +180,41 @@ yourself for a container that is not PSR-11, or to construct handlers some other does, a failure to resolve is caught and returned as an `RpcError` — that is part of what keeps `query()` and `command()` total. -**The handler contract.** Your method is called with three arguments and may declare as few of them -as it needs: +**The handler contract.** Your method is called with exactly three arguments, positionally: ```php public function get(array $input, MyContext $context, Client $client): array ``` -`$input` is the parsed, validated input — already hydrated into whatever your type declares. -`$context` is whatever you passed to `Server::query()`; the library never touches it. `$client` is -the [side channel](#client-directives) back to the frontend. +`$input` is the parsed, validated input — already hydrated into whatever your type declares. **Its +type is the whole input contract**, so the first parameter is the one that matters. `$context` is +whatever you passed to `Server::query()`; the library never touches it. `$client` is the +[side channel](#client-directives) back to the frontend. + +You may declare a **prefix** of those three — `($input)` and `($input, $context)` are both fine — but +not a subset. `($input, Client $client)` receives the context in the client slot, so discovery +rejects it rather than letting it fail at runtime. + +Middleware receives `ResolveInfo` alongside the input, describing the operation being run: +`namespace`, `name`, `operationType`, `className`, `methodName`, `middleware` (every class in the +stack) and `fullyQualifiedName`. **Input is parsed, output is serialized.** Input is untrusted, so every claim its type makes is -proven. Output is checked against its declared type too, but the PHPStan *refinements* on top of it -are not re-checked — static analysis already established those. See +proven. Output is checked against its declared type too, and an output that does not match is a 500 — +it is a bug in your code, not something the client can fix. The PHPStan *refinements* on top of the +type are not re-checked, because static analysis already established those. See [refinements run on input, never on output](docs/types.md#refinements-run-on-input-never-on-output). +Both results carry metadata a middleware can attach with `withMetadata()` / `appendMetadata()`; the +Laravel adapter surfaces it as a `__metadata` key on the response. + ## Defining operations | Attribute | Target | What it does | |---|---|---| | `#[Query(namespace, name)]` | method | A read operation, served over GET. | | `#[Command(namespace, name)]` | method | A write operation, served over POST. | -| `#[Middleware(class or list)]` | class, method | Middleware to run around this operation. | +| `#[Middleware(class)]` | class, method, repeatable | Middleware to run around this operation. | | `#[Throws(ExceptionClass, as: ?string)]` | method, repeatable | Declares an exception the operation may throw, optionally naming it for the client. | | `#[ExposeAs(type)]` | exception class | The exception's own name, for every operation that declares it. | | `#[Optional]` | property, parameter | The field may be absent from input. | @@ -204,9 +222,15 @@ are not re-checked — static analysis already established those. See | `#[Brand(name)]` | class | Makes the generated TypeScript type opaque. | | `#[Named(name)]` | class | Exports the type once by name instead of inlining it. | -`namespace` defaults to `global` and becomes the generated TypeScript module. `name` defaults to the -method name. Both accept a `UnitEnum` as well as a string, so you can keep namespaces in an enum. -Two operations of the same type resolving to the same `namespace.name` fail discovery. +`namespace` defaults to `global` and becomes the generated TypeScript module, so it has to be a +usable file name: letters, digits, `-` and `_`. It accepts a `UnitEnum` as well as a string, so you +can keep namespaces in an enum — note that a *backed* enum contributes its value and a pure enum its +case name, so adding `: string` to an existing namespace enum changes every generated module and +wire key. `name` defaults to the method name and is a string only. + +Two operations of the same type resolving to the same `namespace.name` fail discovery. A query and a +command *may* share one, but both land in the same generated module, so unless `--naming` tells them +apart the code generator rejects them rather than emit two functions of the same name. `#[Brand]`, `#[Named]`, `#[Castable]` and `#[Optional]` are covered in [the type reference](docs/types.md). @@ -245,10 +269,11 @@ final class NameCheckingMiddleware implements MiddlewareContract ring where it happened and handed back to you as `$next()`'s return value, so post-processing runs whether the operation succeeded or not. -Attach it per operation or per class: +Attach it per operation or per class. `#[Middleware]` takes one class and is repeatable, so stack it: ```php #[Command('users')] +#[Middleware(AuthMiddleware::class)] #[Middleware(NameCheckingMiddleware::class)] public function create(array $input): array { /* ... */ } ``` @@ -259,15 +284,23 @@ or globally, for every operation on the server: new ServerConfiguration()->withMiddlewares(AuthMiddleware::class, LoggingMiddleware::class) ``` -`#[Throws]` on a middleware's `handle()` contributes to the error union of every operation it wraps, -so the generated TypeScript knows about middleware failures too. It takes `as` like any other -declaration, and when an operation and its middleware declare the same exception, the operation's -name wins. +**Order is outermost first.** Global middleware wraps class-level `#[Middleware]`, which wraps +method-level, and within each group they run in declaration order. The first one listed is the first +to see the input and the last to see the result. + +`#[Throws]` on a middleware's `handle()` contributes to the error union of every operation it wraps — +globally configured or attached with `#[Middleware]`, both count — so the generated TypeScript knows +about middleware failures too. It takes `as` like any other declaration, and when an operation and +its middleware declare the same exception, the operation's name wins. ### The rest of `ServerConfiguration` -The same object carries the server's other two settings. On Laravel both come from -`config/operations.php`; everywhere else this is where you set them. +The same object carries the server's other settings. + +On Laravel, `withMiddlewares()` and `withExceptions()` are populated from `config/operations.php`. +`coerceQueryInput` is not, and deliberately: the Laravel transport JSON-encodes each query parameter +and decodes it again, so values arrive already typed and there is nothing to coerce. Everywhere else, +this object is where all three are set. `withExceptions()` maps your exceptions onto the [error categories](#errors). Matching is `instanceof`, so listing a base class covers its subclasses, and an omitted category is left @@ -284,7 +317,7 @@ new ServerConfiguration()->withExceptions( Without this, nothing produces a 401, 403 or 404 except an unknown operation — every other exception is a 500. -`coerceQueryInput` (default `false`) applies to queries only, and exists because a URL carries no +`coerceQueryInput` (default `false`) applies to **queries only**, and exists because a URL carries no types. The generated client JSON-encodes each value and the Laravel adapter decodes it again, so `?id=1` arrives as the integer `1` and nothing needs coercing. Turn this on when requests come from somewhere that does not round-trip — a hand-written URL, a form, a transport of your own — and leaf @@ -294,6 +327,10 @@ primitives are coerced to the declared type before validation instead of failing new ServerConfiguration(coerceQueryInput: true) ``` +Because it applies to queries only, the same input shape validates differently depending on whether +it is reached through `#[Query]` or `#[Command]`. Coercion never invents a value: only scalars are +cast, and anything else is left for the schema to reject. + ## Types Most of what PHPStan can express about a shape, this library can parse, serialize and emit: @@ -317,6 +354,11 @@ Object properties are emitted in a canonical order, sorted by name — which is above. Declaration order does not reach the client, so reordering a PHP property is not a change to the generated type. +**An enum travels as its case names, not its backing values.** `MyEnum` emits `("OPEN"|"SHIPPED")` +even when it is `enum MyEnum: string { case OPEN = 'open'; }`. A backed enum that should travel as +its backing value opts in by implementing `StringValueObject` — see +[value objects](docs/types.md#value-objects). + Local and imported types work too: `@phpstan-type` and `@phpstan-import-type` are resolved against the declaring class, as are `use` statements and generics. @@ -327,10 +369,13 @@ with an `InvalidSyntaxException` when the schema is parsed: `class-string` · `key-of` · `value-of` · `int-mask` · `int-mask-of` · `callable(…)` · `Closure(…): T` · `iterable` · `array{foo: int, ...}` (unsealed) · `array{}` · -`($x is int ? A : B)` · `Foo` · `$this` · `static` · `self` +`($x is int ? A : B)` · `Foo` · `$this` · `static` · `self` · +bare `array` / `list` / `non-empty-array` (without generics) -One trap worth knowing up front: bare `object` is not an alias for `unknown` — it is a syntax -error. Write `object{…}` with the shape. +Two traps worth knowing up front. Bare `object` is not an alias for `unknown` — it is a syntax +error; write `object{…}` with the shape. And bare `array` is not `Array`: PHPStan reads it +as `array`, which permits string keys, so there is no one TypeScript type it means. +Write `list`, `array` or `array`. **[→ Full type reference](docs/types.md)** — refinements, utility types (`Pick`, `Omit`, `BrandedString`, `DateTimeString`), value objects, `#[Castable]`, brands and named types, and the @@ -400,6 +445,12 @@ union describes what this server can really produce. Validation failures carry ` dotted path (`__root` for the top level) with localization keys as values, e.g. `{"email": ["validation.not_empty_string"]}`. +**Your own validation can ride the same wire.** This library proves types and refuses to grow into a +validator, but the 422 shape is a perfectly good transport for the rules it will not check for you: +`InvalidInputException::createFromMessages(['email' => ['Already taken']])` produces one from any +field-to-message map. Throw it from a handler or a middleware and the client reads it exactly like a +type failure. + ## The generated TypeScript client `operations:codegen ` writes a self-contained client. Nothing is published to npm; the @@ -419,12 +470,19 @@ code lives in your repo. That is the default output. The [optional generators](#optional-generators) add to it: `type-map` writes one more file, the other two write into the `.ts` modules that are already there. +Every generated file opens with `// generated by: php-ts-bindings`. That marker is what +`operations:codegen` uses to tell its own output from anything else in the directory: it removes +only files carrying it, and refuses to overwrite one that does not. + The envelope every call resolves to: ```typescript export type Success = {success: true, data: T} export type Failure = {success: false} & E; export type Result = Success | Failure; + +// what a generated function actually returns — see Client directives +export type WithClientDirectives = T & {__client?: unknown}; ``` Wire it up once: @@ -433,15 +491,42 @@ Wire it up once: import {createDefaultClient, setClient} from './operations/lib/bindings'; setClient(createDefaultClient()); +setClient(createDefaultClient(fetch, {baseUrl: 'https://api.example.com', timeoutMs: 5_000})); +``` + +`setClient()` sets one module-global client, so it has to run before the first call — otherwise you +get `Error('No client set')` at whichever call site happened to be first. + +Every generated function takes an optional second argument: + +```typescript +export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; ``` `DefaultClient` sends queries as GET with each input value JSON-encoded into a query parameter, and -commands as POST with a JSON body. It supports `AbortSignal`, timeouts, and `registerHook()` for -global response handling. Swap it for your own by implementing `OperationClient` — `setClient()` and -the per-call `options.client` both take one. +commands as POST with a JSON body. `signal` and `timeoutMs` are joined into one `AbortSignal`, and a +per-call `timeoutMs` overrides the client's default (10s unless you set one). `registerHook(hook)` +runs a callback on every response and returns a function that unregisters it. Swap the whole +transport by implementing `OperationClient` — `setClient()` and the per-call `options.client` both +take one. `throwOnFailure(result)` narrows a `Result` to its success branch and throws an `OperationException` -otherwise, for call sites that would rather not branch. +otherwise, for call sites that would rather not branch. A `catch` variable is `unknown` in TypeScript +whatever was thrown, so name the operation's error union at the guard to get it back: + +```typescript +try { + const result = await get({id: userId}); + throwOnFailure(result); + return result.data; +} catch (e) { + if (OperationException.is(e)) { + e.cause.type; // "INVALID_INPUT" | "NOT_FOUND" | ... + e.code; // the HTTP code, 500 if the payload had none + } + throw e; +} +``` ### Optional generators @@ -451,25 +536,50 @@ Three more generators ship, all off by default: php artisan operations:codegen resources/js/operations --with=tanstack-query,query-key,type-map ``` -`tanstack-query` emits `QueryOptions()` and `useQuery()` for `@tanstack/react-query`; -`query-key` emits standalone query keys; `type-map` writes `lib/type-map.ts`, exporting a `TypeMap` -that maps every operation to its input, output and error types. Use `--without=` to drop a default -generator, `--ignore=` to skip a namespace (or one operation, as `namespace.name`), and `--naming=` -to choose how functions are named (`name`, `fqn`, `operation-prefix`, `namespace-postfix`, or -`Class::method` for your own rule). +`tanstack-query` emits `QueryOptions()` and `useQuery()` for `@tanstack/react-query`, and +`query-key` emits standalone query keys — both **only for queries**, since a command has nothing to +cache. `type-map` writes `lib/type-map.ts`, exporting a `TypeMap` that maps every operation to its +input, output and error types, split into `{query: …, command: …}` and keyed by `namespace.name`. + +`--without=` drops a default generator, named as `types`, `bindings`, `utils` or `operations`. +`--ignore=` skips a namespace, or one operation as `namespace.name`. + +`--naming=` chooses how functions are named: + +| Mode | `#[Query('users', 'get')]` becomes | +|---|---| +| `name` (default) | `get` | +| `fqn`, `operation-prefix` | `usersGet` — the two are the same rule | +| `namespace-postfix` | `getUsers` | +| `Class::method` | whatever your rule returns | + +`Class::method` resolves `Class` through the container and calls it as an **instance** method with +the `TypedOperation`, despite the static-looking syntax. Write your own generator by implementing `GeneratesLibFiles` (gets every operation, writes shared lib files) or `GeneratesOperationCode` (gets one operation, writes its code) and passing it with -`--custom=My\Generator`. Add `DependsOn` to declare the generators yours needs: the run fails early -if one is missing, and hands you the resolved instances through `setDependencies()`. That is how to -reference what another generator emitted — ask `EmitOperations` for `inputTypeName($operation)` -rather than rebuilding the name and hoping it matches, and ask `EmitTypes` for -`importFromTypes(types: ['Order'])` rather than writing the module specifier yourself. Because those -methods are not static, an import can only ever name a file a registered generator actually writes. +`--custom=My\Generator`. It is resolved through the container, so it may take constructor arguments. + +Add `DependsOn` to declare the generators yours needs: the run fails early if one is missing, and +hands you the resolved instances through `setDependencies()`. That is how to reference what another +generator emitted — ask `EmitOperations` for `inputTypeName($operation)` rather than rebuilding the +name and hoping it matches, and ask `EmitTypes` for `importFromTypes(types: ['Order'])` rather than +writing the module specifier yourself. Because those methods are not static, an import can only ever +name a file a registered generator actually writes. + Hand your imports to `TypescriptFile` instead of writing `import` lines into the code: only then are they merged with what the other generators contribute to the same file, and only then is the path resolved for a file that lands in `lib/`. +A few contracts worth knowing when writing one: + +- A `GeneratesLibFiles` key is a bare module name — `[a-zA-Z0-9_-]+`, no `.ts` — and always lands in + `lib/`. Several generators may return the same key; their output accumulates rather than + overwriting. +- `TypedOperation::$hasInput` is false when the operation's input type is `null`. Every generator + that emits a signature has to drop the argument in that case. +- Return `null` from `generateOperationCode()` to emit nothing for an operation. + ## Client directives Optional, and unrelated to type safety: the `Client` passed to every handler is a side channel for @@ -486,8 +596,13 @@ public function create(array $input, mixed $context, Client $client): array } ``` -When the request carries `X-Client-Id: operations-spa`, those land in a `__client` key next to the -data: +This is the one place the library ships a specific implementation of an extension point rather than +a contract. `OperationSPAClient` is picked when the request carries `X-Client-Id: operations-spa` — +exactly that header, exactly that value. Any other request gets a `NullClient` whose every method is +a no-op, so handlers never need to know which kind is on the other end, and nothing warns when a +directive goes nowhere. + +Under `operations-spa` those calls land in a `__client` key next to the data: ```json { @@ -504,11 +619,15 @@ data: The full interface is `redirect()`, `invalidate()`, `toast()`, and one shorthand per toast type — `success()`, `error()`, `warning()`, `alert()` and `info()`. Keys are only present when something -called for them. +called for them. A transport of your own emits the same payload by asking the client for it: +`SerializableClient::serializeToArray()`. -Otherwise a `NullClient` is used and every call is a no-op, so handlers never need to know which kind -of client is on the other end. `lib/utils.ts` ships `isSpaClientDirectives()`, `isClientToast()` and -`isClientRedirect()` for reading them back. +**`__client` is typed `unknown` on purpose.** `Client` is an extension point — your own +implementation may define an entirely different set of directives under a different schema — so the +generated types decline to commit to a shape they cannot know. `OperationSPAClient` is the subset +this library deems useful and ships, and `lib/utils.ts` narrows to it with `isSpaClientDirectives()`, +`isClientToast()` and `isClientRedirect()`. Write your own guard for your own directives; that is the +same "no dishonest types" rule that makes the generator throw rather than emit a placeholder. ## Laravel setup @@ -527,14 +646,39 @@ php artisan vendor:publish --provider="Le0daniel\PhpTsBindings\Adapters\Laravel\ | `discovery_path` | `app_path('Operations')` | Where operations are discovered. | | `context` | `null` | A `ContextFactory` class, building the `$context` every handler receives from the request. | | `key.mode` | `obfuscate` | `obfuscate`, `plain`, or `custom` with `key.className`. | -| `key.pepper` | `none` | Salt for `obfuscate`. | +| `key.pepper` | `"none"` | Salt for `obfuscate` — the literal string `none`, not "no pepper". | | `middleware` | `[]` | Global `MiddlewareContract` classes, run on every operation. | | `exceptions.not_found` | Laravel's model-not-found exceptions | Mapped to 404. | | `exceptions.unauthenticated` | `AuthenticationException` | Mapped to 401. | | `exceptions.unauthorized` | `AuthorizationException`, `TokenMismatchException` | Mapped to 403. | | `cache.idLength` | `10` | Id length used by the production cache. | -Exception matching is `instanceof`, so listing a base class covers its subclasses. +Exception matching is `instanceof`, so listing a base class covers its subclasses. An unrecognised +`key.mode` is an error rather than a fallback, because silently picking a different one would change +every key in the application. + +`context` names a class implementing `ContextFactory`, whose single method builds the `$context` +every handler and middleware receives: + +```php +use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; + +final class OperationContextFactory implements ContextFactory +{ + public function createContextFromHttpRequest(Request $request): mixed + { + return new MyContext(user: $request->user()); + } +} +``` + +Leave it `null` and every handler receives `null` as its context. + +> **Debug mode changes behaviour.** With `app.debug` on, a `LocalMetadataMiddleware` is prepended to +> every operation and responses gain a `__metadata` key with the raw input, the context class and +> per-middleware timings; failures additionally carry `__info` and `__debug` with the exception +> message, file, line and full stack trace. None of that is emitted in production, and none of it is +> in the generated types. **3. Register the routes.** Nothing is registered for you — put this in your routes file, inside whatever middleware group the operations belong to: @@ -548,8 +692,13 @@ Route::middleware('web')->group(function () { }); ``` -Both take a route prefix. `operations:codegen` reads the registered URIs to build the client, and -fails with *"The operation routes are not registered"* if you skip this step. +Both take a route prefix, defaulting to `query` and `command`. `operations:codegen` reads the +registered URIs to build the client, and fails with *"The operation routes are not registered"* if +you skip this step. + +**The route parameter must stay named `{fqn}`.** The generated client substitutes the operation key +into that placeholder literally, so a hand-registered route using any other name yields a client +requesting URLs that still contain `{fqn}` — no error anywhere, just 404s. **4. Write an operation** in `app/Operations`, as in the [quickstart](#quickstart). @@ -570,13 +719,17 @@ php artisan operations:codegen resources/js/operations The last two are wired into `php artisan optimize` and `optimize:clear`. -> `operations:codegen` removes every `.ts` file under the target directory before writing. Point it -> at a directory it owns, not at a shared frontend folder. +> `operations:codegen` removes every file it previously wrote under the target directory, identified +> by the `// generated by: php-ts-bindings` marker on the first line. Anything else is left alone, +> and a generated module colliding with an unmarked file of the same name is refused rather than +> overwritten. Upgrading from a version that predates the marker means deleting the output directory +> once. ### Preloading a query `Preloader` runs a query server-side during the request that renders the page, so the data is in the -page instead of being fetched after it loads. It is resolved from the container: +page instead of being fetched after it loads. It is a core class — +`Le0daniel\PhpTsBindings\Server\Preloader` — and on Laravel it is resolved from the container: ```php public function show(Preloader $preloader): Response @@ -589,8 +742,16 @@ public function show(Preloader $preloader): Response You get back `['response' => …, 'queryKey' => ['users', 'get', ['id' => 1]]]`. The key is built the same way the generated `--with=query-key` and `tanstack-query` code builds it, so a TanStack cache -seeded with that pair will not refetch. Use `preloadMany()` for several at once. A query that fails -throws — this is your own code calling your own operation, not untrusted input. +seeded with that pair will not refetch. The input is part of the key whenever the operation has one, +even when the value is `null`; an operation whose input type *is* `null` gets a two-element key. + +`preloadMany()` takes several at once, as +`[['namespace' => …, 'name' => …, 'input' => …], …]`. A query that fails throws a `SchemaException` — +this is your own code calling your own operation, not untrusted input. + +Constructed by hand, `Preloader` takes the `Server` and an `OperationKeyGenerator`. That generator +has to be the one the server's registry uses, or the key it derives names no operation and every +preload throws. ## Production @@ -606,10 +767,19 @@ shared structs are emitted once and referenced, and unions are reordered for fas service provider picks the file up automatically when it exists. Run `operations:codegen --verify` in CI to catch a frontend that has drifted from the backend. +A cache that no longer matches the code asking it fails loudly, at runtime, with an +`UnknownTypeKeyException` — regenerate it with `operations:optimize`, or drop it with +`operations:clear-optimize`. + +> Operation keys are derived from `key.mode` and `key.pepper`, so changing either — including +> upgrading a version that changed how they are derived — invalidates both the cache and the +> generated client. Run `operations:optimize` and `operations:codegen` together. + Outside Laravel, or for schemas that are not operations, the same optimizer is available directly: ```php -use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer;use Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry; +use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer; +use Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry; new ASTOptimizer()->optimizeAndWriteToFile('asts.php', [ 'MyClass@method@input' => $inputAst, @@ -621,10 +791,17 @@ $registry = require 'asts.php'; $ast = $registry->get('MyClass@method@input'); ``` +Keys are interned as truncated hashes; `new ASTOptimizer(idLength: 12)` widens them if a run ever +reports a collision, which it does by throwing rather than by merging two schemas. + ## Without a framework -The core knows nothing about Laravel. Build a server, run an operation, and shape the response -however you like: +The core knows nothing about Laravel — nothing outside `src/Adapters/` does. Build a server, run an +operation, and shape the response however you like. + +The example below uses `PsrContainerAdapter`, which needs `psr/container` (a `suggest`, not a +dependency). Drop it and the default `NewInstanceAdapter` constructs handlers with `new`, which needs +nothing at all. ```php use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; @@ -663,7 +840,24 @@ if ($result instanceof RpcSuccess) { } ``` -`$result->cause` is the underlying `Throwable` on every error, ready to hand to your reporter. +`$result->cause` is the underlying `Throwable` on every error, ready to hand to your reporter. On the +rare occasion that working out how to present an error *itself* failed — a stale middleware class +name, say — `$result->presentationFailure` holds that second exception; it is null otherwise. + +To emit client directives, pass an `OperationSPAClient` instead of a `NullClient` and ask it for the +payload: + +```php +use Le0daniel\PhpTsBindings\Contracts\SerializableClient; + +$client = new OperationSPAClient(); +$result = $server->command('users.create', $input, $myContext, $client); + +$body = ['success' => true, 'data' => $result->data]; +if ($client instanceof SerializableClient && $directives = $client->serializeToArray()) { + $body['__client'] = $directives; +} +``` To generate the client, hand the same `Server` to `TypescriptServerCodeGenerator` with the URL patterns your router uses: @@ -675,23 +869,60 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; +use Le0daniel\PhpTsBindings\CodeGen\Utils\OutputDirectory; $files = new TypescriptServerCodeGenerator([ new EmitTypes(), new EmitOperationClientBindings(), new EmitTypeUtils(), + // Takes an optional Closure(TypedOperation): string — the framework-free --naming. new EmitOperations(), ])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); -foreach ($files as $path => $file) { - // $path is e.g. 'lib/types.ts' or 'users.ts' - file_put_contents("resources/js/operations/{$path}", $file->toString()); -} +// Creates lib/, prunes the modules it wrote for operations that no longer exist, and leaves +// anything it did not write alone. OutputDirectory::verify() is the same rules without writing — +// it returns one message per problem, and an empty list means the directory is up to date. +OutputDirectory::write('resources/js/operations', $files); ``` +`generate()` takes a third argument, a list of namespaces (or `namespace.name` operations) to skip — +the equivalent of `--ignore=`. + The lower-level `TypeParser`, `SchemaExecutor` and `TypescriptGenerator` are public too, if you want to parse and emit types without the RPC layer at all — see [the type reference](docs/types.md). +## Extension points and exceptions + +The interfaces meant to be implemented by you: + +| Contract | For | +|---|---| +| `MiddlewareContract` | Wrapping an operation. | +| `ServerAdapter` | Constructing handlers and middleware — the DI seam. | +| `OperationKeyGenerator` | Turning `namespace` + `name` into the key the client calls. | +| `OperationRegistry` | Holding operations, if neither shipped registry fits. | +| `Client` / `SerializableClient` | Your own side channel and its wire payload. | +| `StringValueObject` / `IntValueObject` | A class that travels as one primitive. | +| `GeneratesLibFiles` / `GeneratesOperationCode` / `DependsOn` | Adding to the generated client. | +| `ContextFactory` | Building `$context` from a request (Laravel adapter only). | + +Two more knobs on discovery: `new OperationDiscovery($filterFn)` takes a closure returning `false` to +keep an operation out of the registry, and `EagerlyLoadedOperationRegistry::withClasses([...])` +registers a list of classes instead of scanning directories. + +**Exceptions.** Everything this library throws implements `PhpTsBindingsException`, so one `catch` +covers all of it. Below that are three subsystem bases: + +| Exception | Thrown when | +|---|---| +| `ParserException` | A schema cannot be built — includes `InvalidSyntaxException` (the type is not in the supported subset), `UnexpectedCharacterException` (it does not lex) and `UnknownTypeKeyException` (the optimized cache no longer matches the code). | +| `SchemaException` | An operation is malformed — a name or key collision, a bad handler signature, a class that is not middleware. `InvalidInputException`, `InvalidOutputException` and `OperationNotFoundException` extend it and reach you as `RpcError::$cause`. | +| `CodeGenException` | Generation cannot produce valid output — includes `UnsupportedTypeException` (no honest TypeScript for a schema), `InvalidStringLiteralException` (a brand or alias is not an identifier) and `InvalidGeneratorDependencies` (whose `$messages` names each missing generator). | + +Nothing is thrown out of `Server::query()` or `Server::command()` — both are total, and every +`Throwable` comes back as an `RpcError`. The exceptions above surface at discovery, at parse time or +during code generation instead, which is to say: at build time, not at request time. + ## Contributing ```bash diff --git a/docs/types.md b/docs/types.md index c5f530a..53fb289 100644 --- a/docs/types.md +++ b/docs/types.md @@ -35,7 +35,6 @@ Everything this library knows how to parse, serialize and emit. The short versio | `array{name?: string}` | `{name?:string;}` | | `array{a: array{b: string}}` | `{a:{b:string;};}` | | `list`, `string[]`, `array` | `Array` | -| `array` | `Array` | | `array` | `Record` | | `array{string, int}` | `[string,number]` | | `array{name: string}\|string` | `({name:string;}\|string)` | @@ -105,8 +104,16 @@ being serialized — if your method says it returns `positive-int`, static analy that. Re-checking it at runtime would cost you something for a guarantee you already have. This library assumes static analysis does its job. -Serialization still enforces *types*: a `string` where an `int` is declared fails either way. Only -the PHPStan refinement on top of the type is skipped. +Serialization still enforces *types*: a `string` where an `int` is declared fails either way, and it +is not repaired into one — a near miss like the numeric string `"1.5"` for a `float` is reported, not +cast. Only the PHPStan refinement on top of the type is skipped. + +`SerializationOptions::$partialFailures` (on by default for direct `SchemaExecutor` callers) is the +one exception to "a failure fails": with it on, a value that cannot be serialized under a +null-accepting union is replaced with `null` and the result comes back as a `Success` whose +`isPartial()` is true. It is there for best-effort serialization you intend to inspect. **The RPC +server never enables it**, because answering 200 with data the operation did not produce is not +something a client can detect. ## Not supported @@ -121,6 +128,9 @@ silently-degraded `unknown`. ### Rejected outright ``` +array bare array / list / non-empty-array, without generics +list +non-empty-array array{foo: int, ...} unsealed array shapes array{...} array{} the empty shape @@ -132,6 +142,10 @@ Foo default generic arguments ($x is int ? string : bool) conditional types ``` +PHPStan reads a bare `array` as `array`, which permits string keys, so there is no one +TypeScript type it means: `Array` would be wrong for a keyed array and would drop its keys +on the way out. Write `list`, `array` or `array`. + ### Not recognised at all These reach the parser as an unknown identifier and fail with `No parser found.`: diff --git a/src/Contracts/Attributes/Optional.php b/src/Contracts/Attributes/Optional.php index 4bb705c..0d03f56 100644 --- a/src/Contracts/Attributes/Optional.php +++ b/src/Contracts/Attributes/Optional.php @@ -5,9 +5,11 @@ use Attribute; /** - * Marks a property as Optional from an object. As PHP does only support - * distinct values, by default, it will cast to NULL. Provide another value - * if the property is undefined. + * Marks a property or promoted parameter as absent-able in input, emitted as `key?:` in TypeScript. + * + * PHP has no "undefined", so the property needs somewhere to land when input omits it: either a + * default value or a nullable type. A property with neither is rejected at parse time rather than + * silently receiving null. */ #[Attribute(Attribute::TARGET_PROPERTY| Attribute::TARGET_PARAMETER)] final readonly class Optional From b484bc129c8649b34e70cc22d9ef4c01da4a369a Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 4 Aug 2026 17:22:03 +0200 Subject: [PATCH 059/101] Add detailed documentation for Laravel adapter, update `README.md` references, and fix inconsistencies - Introduced a new `docs/laravel.md` file explaining the Laravel adapter's setup, configuration, routing, commands, and decisions. - Reorganized and expanded the `README.md` with Laravel-specific references, ensuring clarity for both framework and non-framework users. - Highlighted optional generators, preloading queries, error handling, and server configurations in the documentation. - Corrected inconsistencies, including unsupported configuration, naming rules, and behavior clarifications. - Aligned documentation with recent feature enhancements and bug fixes. --- README.md | 522 +++++++++++++++++++++--------------------------- docs/laravel.md | 302 ++++++++++++++++++++++++++++ docs/types.md | 4 +- 3 files changed, 527 insertions(+), 301 deletions(-) create mode 100644 docs/laravel.md diff --git a/README.md b/README.md index e57ab58..d4070e9 100644 --- a/README.md +++ b/README.md @@ -30,23 +30,24 @@ them. Not an ORM serializer. Not a schema DSL. If a rule cannot be expressed as library will not check it for you; [value objects](docs/types.md#value-objects) are where such rules belong. -Requires **PHP 8.5** and nothing else — the core has no dependencies and no framework coupling. A -first-party Laravel adapter ships in the box and is entirely optional. +Requires **PHP 8.5** and nothing else — no dependencies, no framework coupling. A first-party +[Laravel adapter](docs/laravel.md) ships in the box and is entirely optional. --- - [Install](#install) +- [Laravel](#laravel) - [Quickstart](#quickstart) - [Core concepts](#core-concepts) - [Defining operations](#defining-operations) - [Middleware](#middleware) - [Types](#types) - [Errors](#errors) +- [Serving operations over HTTP](#serving-operations-over-http) - [The generated TypeScript client](#the-generated-typescript-client) - [Client directives](#client-directives) -- [Laravel setup](#laravel-setup) +- [Preloading a query](#preloading-a-query) - [Production](#production) -- [Without a framework](#without-a-framework) - [Extension points and exceptions](#extension-points-and-exceptions) ## Install @@ -65,8 +66,16 @@ includes: - vendor/le0daniel/php-ts-bindings/extension.neon ``` -On Laravel the service provider is auto-discovered; there is nothing else to register. See -[Laravel setup](#laravel-setup). +## Laravel + +A first-party adapter ships with the library. The service provider is auto-discovered, +`config/operations.php` is publishable, and four `operations:*` artisan commands handle discovery, +code generation and the production cache. Routes stay yours to register. + +Everything below applies on Laravel too — the adapter wires this library up, it does not replace it. +What it decides on your behalf is documented separately. + +**[→ The Laravel adapter](docs/laravel.md)** ## Quickstart @@ -110,12 +119,41 @@ final class UserOperations single primitive. `UserId` and `Email` carry a `#[Brand]`, so they are not interchangeable with a plain `number` or `string` on the TypeScript side. -Generate the client: +Build a server over them, and generate the client: -```bash -php artisan operations:codegen resources/js/operations +```php +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; +use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; +use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; +use Le0daniel\PhpTsBindings\CodeGen\Utils\OutputDirectory; +use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; +use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; +use Le0daniel\PhpTsBindings\Server\Server; + +$server = new Server( + EagerlyLoadedOperationRegistry::eagerlyDiscover( + __DIR__ . '/app/Operations', + keyGenerator: new PlainlyExposedKeyGenerator(), + ), +); + +$files = new TypescriptServerCodeGenerator([ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations(), +])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); + +OutputDirectory::write(__DIR__ . '/resources/js/operations', $files); ``` +The two URLs are the routes *your* transport serves; `{fqn}` is where the operation key goes, and +both are required to contain it. Run this from a script you commit — it is a build step, not +something the server does at runtime. + You get a `users.ts` module, matching the namespace: ```typescript @@ -142,6 +180,8 @@ if (result.success) { Passing `{id: 1}` is a compile error: `number` is not assignable to `UserId`'s branded type. Sending it anyway is a 422 at runtime, because the server proves the same type it published. +What remains is [serving those two routes](#serving-operations-over-http). + ## Core concepts **`Server`** takes a registry of operations and runs one. Both methods are total — every @@ -153,11 +193,14 @@ public function command(string $name, mixed $input, mixed $context, Client $clie ``` **`$name` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns -`namespace` + `name` into what the client calls. The default is -`HashSha256KeyGenerator`, whose first constructor argument — a pepper — is **required**; it hashes -both parts, so a discovered `users.get` is reachable as an opaque key rather than as `users.get`. -Use `PlainlyExposedKeyGenerator` for literal keys. The generated TypeScript always embeds whichever -key the server produced, so this only matters when you call the server by hand. +`namespace` + `name` into what the client calls. `HashSha256KeyGenerator` hashes both parts, so a +discovered `users.get` is reachable as an opaque key rather than as `users.get`; its first +constructor argument is a pepper, and it has no default. `PlainlyExposedKeyGenerator` gives literal +keys instead. The generated TypeScript always embeds whichever key the server produced, so this only +matters when you call the server by hand. + +> **Pass one explicitly.** `eagerlyDiscover()` falls back to `HashSha256KeyGenerator` peppered with +> the string `'default'` — obfuscated keys, from a pepper anyone reading this page knows. Obfuscation is not a security boundary: it keeps your operation names out of the shipped bundle, and that is all. Two operations colliding on a truncated hash is an error at discovery, not a silent @@ -175,10 +218,10 @@ scanning directories; schemas are parsed lazily, per operation, on first use. | `NewInstanceAdapter` | The default. Plain `new $className()`, so handlers take no constructor arguments. | | `PsrContainerAdapter` | Resolves both through a PSR-11 container. | -Laravel wires `PsrContainerAdapter` to the application container for you. Implement the interface -yourself for a container that is not PSR-11, or to construct handlers some other way. Whatever it -does, a failure to resolve is caught and returned as an `RpcError` — that is part of what keeps -`query()` and `command()` total. +`PsrContainerAdapter` needs `psr/container`, which is a `suggest` rather than a dependency; the +default needs nothing at all. Implement the interface yourself for a container that is not PSR-11, +or to construct handlers some other way. Whatever it does, a failure to resolve is caught and +returned as an `RpcError` — that is part of what keeps `query()` and `command()` total. **The handler contract.** Your method is called with exactly three arguments, positionally: @@ -191,9 +234,30 @@ type is the whole input contract**, so the first parameter is the one that matte whatever you passed to `Server::query()`; the library never touches it. `$client` is the [side channel](#client-directives) back to the frontend. -You may declare a **prefix** of those three — `($input)` and `($input, $context)` are both fine — but -not a subset. `($input, Client $client)` receives the context in the client slot, so discovery -rejects it rather than letting it fail at runtime. +**The input parameter is never optional.** A handler declaring no parameters is rejected at +discovery, and the one it does declare must carry a native type — an untyped parameter cannot be +reflected. A `@param` in the docblock overrides that native type, and is how you say anything PHP +itself cannot express. + +**An operation that takes no input types it as `null`.** The parameter stays; only its type changes: + +```php +/** + * @return array{ok: bool} + */ +#[Query('system')] +public function ping(null $input): array +{ + return ['ok' => true]; +} +``` + +Every generator drops the argument for such an operation, so the TypeScript is `ping()` rather than +`ping(input)`. There is no way to omit the parameter itself. + +You may declare a **prefix** of the three — `($input)` and `($input, $context)` are both fine — but +not a subset, and the prefix always starts at the input. `($input, Client $client)` receives the +context in the client slot, so discovery rejects it rather than letting it fail at runtime. Middleware receives `ResolveInfo` alongside the input, describing the operation being run: `namespace`, `name`, `operationType`, `className`, `methodName`, `middleware` (every class in the @@ -205,8 +269,8 @@ it is a bug in your code, not something the client can fix. The PHPStan *refinem type are not re-checked, because static analysis already established those. See [refinements run on input, never on output](docs/types.md#refinements-run-on-input-never-on-output). -Both results carry metadata a middleware can attach with `withMetadata()` / `appendMetadata()`; the -Laravel adapter surfaces it as a `__metadata` key on the response. +Both results carry metadata a middleware can attach with `withMetadata()` / `appendMetadata()`. What +becomes of it is the transport's decision — nothing in the core writes it to a response. ## Defining operations @@ -229,8 +293,8 @@ case name, so adding `: string` to an existing namespace enum changes every gene wire key. `name` defaults to the method name and is a string only. Two operations of the same type resolving to the same `namespace.name` fail discovery. A query and a -command *may* share one, but both land in the same generated module, so unless `--naming` tells them -apart the code generator rejects them rather than emit two functions of the same name. +command *may* share one, but both land in the same generated module, so unless the naming rule tells +them apart the code generator rejects them rather than emit two functions of the same name. `#[Brand]`, `#[Named]`, `#[Castable]` and `#[Optional]` are covered in [the type reference](docs/types.md). @@ -295,12 +359,7 @@ its middleware declare the same exception, the operation's name wins. ### The rest of `ServerConfiguration` -The same object carries the server's other settings. - -On Laravel, `withMiddlewares()` and `withExceptions()` are populated from `config/operations.php`. -`coerceQueryInput` is not, and deliberately: the Laravel transport JSON-encodes each query parameter -and decodes it again, so values arrive already typed and there is nothing to coerce. Everywhere else, -this object is where all three are set. +The same object carries the server's other settings, and is where all three are set. `withExceptions()` maps your exceptions onto the [error categories](#errors). Matching is `instanceof`, so listing a base class covers its subclasses, and an omitted category is left @@ -318,10 +377,10 @@ Without this, nothing produces a 401, 403 or 404 except an unknown operation — exception is a 500. `coerceQueryInput` (default `false`) applies to **queries only**, and exists because a URL carries no -types. The generated client JSON-encodes each value and the Laravel adapter decodes it again, so -`?id=1` arrives as the integer `1` and nothing needs coercing. Turn this on when requests come from -somewhere that does not round-trip — a hand-written URL, a form, a transport of your own — and leaf -primitives are coerced to the declared type before validation instead of failing it: +types. The generated client JSON-encodes each query value, so a transport that decodes it again +receives `?id=1` as the integer `1` and has nothing to coerce. Turn this on when requests reach you +from somewhere that does not round-trip — a hand-written URL, a form, a transport of your own — and +leaf primitives are coerced to the declared type before validation instead of failing it: ```php new ServerConfiguration(coerceQueryInput: true) @@ -451,10 +510,79 @@ validator, but the 422 shape is a perfectly good transport for the rules it will field-to-message map. Throw it from a handler or a middleware and the client reads it exactly like a type failure. +## Serving operations over HTTP + +The server runs an operation; turning that into an HTTP response is yours to write. Two routes are +enough — one GET for queries, one POST for commands — and both must carry the operation key. + +```php +use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; +use Le0daniel\PhpTsBindings\Server\Client\NullClient; +use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; +use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; +use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; +use Le0daniel\PhpTsBindings\Server\Server; + +$server = new Server( + EagerlyLoadedOperationRegistry::eagerlyDiscover( + __DIR__ . '/src/Operations', + keyGenerator: new PlainlyExposedKeyGenerator(), + ), + adapter: new PsrContainerAdapter($psrContainer), + configuration: new ServerConfiguration() + ->withMiddlewares(AuthMiddleware::class) + ->withExceptions( + notFound: [EntityNotFoundException::class], + unauthenticated: [NotLoggedInException::class], + ), +); + +$result = $server->command('users.create', $input, $myContext, new NullClient()); + +if ($result instanceof RpcSuccess) { + respondJson(200, ['success' => true, 'data' => $result->data]); +} else { + respondJson($result->type->value, [ + 'success' => false, + 'code' => $result->type->value, + 'type' => $result->type->name, + 'details' => $result->details, + ]); +} +``` + +`ErrorType` doubles as the status code: `$result->type->value` is the HTTP code and +`$result->type->name` the string the client matches on, so the two cannot disagree. + +`$result->cause` is the underlying `Throwable` on every error, ready to hand to your reporter. On the +rare occasion that working out how to present an error *itself* failed — a stale middleware class +name, say — `$result->presentationFailure` holds that second exception; it is null otherwise. + +Whatever your transport does with the input, the shape it hands the server has to match what the +generated client sends: for queries, each value JSON-encoded into its own query parameter; for +commands, a JSON body. Decoding query values back is what lets you leave +[`coerceQueryInput`](#the-rest-of-serverconfiguration) off. + +To emit client directives, pass an `OperationSPAClient` instead of a `NullClient` and ask it for the +payload: + +```php +use Le0daniel\PhpTsBindings\Contracts\SerializableClient; + +$client = new OperationSPAClient(); +$result = $server->command('users.create', $input, $myContext, $client); + +$body = ['success' => true, 'data' => $result->data]; +if ($client instanceof SerializableClient && $directives = $client->serializeToArray()) { + $body['__client'] = $directives; +} +``` + ## The generated TypeScript client -`operations:codegen ` writes a self-contained client. Nothing is published to npm; the -code lives in your repo. +`TypescriptServerCodeGenerator` writes a self-contained client, and `OutputDirectory::write()` puts +it on disk. Nothing is published to npm; the code lives in your repo. ``` / @@ -467,12 +595,16 @@ code lives in your repo. .ts one module per namespace, one function per operation ``` -That is the default output. The [optional generators](#optional-generators) add to it: `type-map` -writes one more file, the other two write into the `.ts` modules that are already there. +That is what the four default generators produce. The [optional ones](#optional-generators) add to +it: `EmitTypeMap` writes one more file, the other two write into the `.ts` modules that +are already there. Every generated file opens with `// generated by: php-ts-bindings`. That marker is what -`operations:codegen` uses to tell its own output from anything else in the directory: it removes -only files carrying it, and refuses to overwrite one that does not. +`OutputDirectory` uses to tell its own output from anything else in the directory: it removes only +files carrying it, and refuses to overwrite one that does not. +`OutputDirectory::verify()` applies the same rules without writing — it returns one message per +problem, and an empty list means the directory is up to date. Run it in CI to catch a frontend that +has drifted from the backend. The envelope every call resolves to: @@ -530,35 +662,28 @@ try { ### Optional generators -Three more generators ship, all off by default: - -```bash -php artisan operations:codegen resources/js/operations --with=tanstack-query,query-key,type-map -``` - -`tanstack-query` emits `QueryOptions()` and `useQuery()` for `@tanstack/react-query`, and -`query-key` emits standalone query keys — both **only for queries**, since a command has nothing to -cache. `type-map` writes `lib/type-map.ts`, exporting a `TypeMap` that maps every operation to its -input, output and error types, split into `{query: …, command: …}` and keyed by `namespace.name`. +The generator list you hand `TypescriptServerCodeGenerator` *is* the configuration — there is no +separate switch. Seven ship: -`--without=` drops a default generator, named as `types`, `bindings`, `utils` or `operations`. -`--ignore=` skips a namespace, or one operation as `namespace.name`. - -`--naming=` chooses how functions are named: +| Generator | In the quickstart | Emits | +|---|---|---| +| `EmitTypes` | yes | `lib/types.ts` — the envelope, `Brand`, every `#[Named]` alias | +| `EmitOperationClientBindings` | yes | `lib/bindings.ts`, `lib/OperationClient.ts`, `lib/DefaultClient.ts`, `lib/OperationException.ts` | +| `EmitTypeUtils` | yes | `lib/utils.ts` — `queryKey` and the client-directive guards | +| `EmitOperations` | yes | one `.ts` module per namespace | +| `EmitTanstackQuery` | no | `QueryOptions()` and `useQuery()` for `@tanstack/react-query` | +| `EmitQueryKey` | no | standalone query keys | +| `EmitTypeMap` | no | `lib/type-map.ts` — a `TypeMap` of every operation's input, output and error types, split into `{query: …, command: …}` and keyed by `namespace.name` | -| Mode | `#[Query('users', 'get')]` becomes | -|---|---| -| `name` (default) | `get` | -| `fqn`, `operation-prefix` | `usersGet` — the two are the same rule | -| `namespace-postfix` | `getUsers` | -| `Class::method` | whatever your rule returns | +`EmitTanstackQuery` and `EmitQueryKey` emit **only for queries**, since a command has nothing to +cache. -`Class::method` resolves `Class` through the container and calls it as an **instance** method with -the `TypedOperation`, despite the static-looking syntax. +`new EmitOperations($closure)` takes a `Closure(TypedOperation): string` that names the generated +functions; the default is the operation's bare name. `generate()` takes a third argument, a list of +namespaces (or `namespace.name` operations) to skip. -Write your own generator by implementing `GeneratesLibFiles` (gets every operation, writes shared -lib files) or `GeneratesOperationCode` (gets one operation, writes its code) and passing it with -`--custom=My\Generator`. It is resolved through the container, so it may take constructor arguments. +Write your own by implementing `GeneratesLibFiles` (gets every operation, writes shared lib files) or +`GeneratesOperationCode` (gets one operation, writes its code), and adding it to the list. Add `DependsOn` to declare the generators yours needs: the run fails early if one is missing, and hands you the resolved instances through `setDependencies()`. That is how to reference what another @@ -576,8 +701,9 @@ A few contracts worth knowing when writing one: - A `GeneratesLibFiles` key is a bare module name — `[a-zA-Z0-9_-]+`, no `.ts` — and always lands in `lib/`. Several generators may return the same key; their output accumulates rather than overwriting. -- `TypedOperation::$hasInput` is false when the operation's input type is `null`. Every generator - that emits a signature has to drop the argument in that case. +- `TypedOperation::$hasInput` is false when the operation's input type is `null` — the + [no-input form](#core-concepts). Every generator that emits a signature has to drop the argument + in that case. - Return `null` from `generateOperationCode()` to emit nothing for an operation. ## Client directives @@ -597,10 +723,11 @@ public function create(array $input, mixed $context, Client $client): array ``` This is the one place the library ships a specific implementation of an extension point rather than -a contract. `OperationSPAClient` is picked when the request carries `X-Client-Id: operations-spa` — -exactly that header, exactly that value. Any other request gets a `NullClient` whose every method is -a no-op, so handlers never need to know which kind is on the other end, and nothing warns when a -directive goes nowhere. +a contract. `OperationSPAClient` is meant to be picked when the request carries +`X-Client-Id: operations-spa` — exactly that header, exactly that value — and any other request gets +a `NullClient` whose every method is a no-op, so handlers never need to know which kind is on the +other end, and nothing warns when a directive goes nowhere. Choosing between them is the transport's +job; the core never inspects a request. Under `operations-spa` those calls land in a `__client` key next to the data: @@ -619,7 +746,7 @@ Under `operations-spa` those calls land in a `__client` key next to the data: The full interface is `redirect()`, `invalidate()`, `toast()`, and one shorthand per toast type — `success()`, `error()`, `warning()`, `alert()` and `info()`. Keys are only present when something -called for them. A transport of your own emits the same payload by asking the client for it: +called for them. A transport emits that payload by asking the client for it: `SerializableClient::serializeToArray()`. **`__client` is typed `unknown` on purpose.** `Client` is an extension point — your own @@ -629,153 +756,45 @@ this library deems useful and ships, and `lib/utils.ts` narrows to it with `isSp `isClientToast()` and `isClientRedirect()`. Write your own guard for your own directives; that is the same "no dishonest types" rule that makes the generator throw rather than emit a placeholder. -## Laravel setup - -**1. The provider is auto-discovered.** Nothing to register. - -**2. Publish the config.** - -```bash -php artisan vendor:publish --provider="Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider" -``` - -`config/operations.php`: - -| Key | Default | Purpose | -|---|---|---| -| `discovery_path` | `app_path('Operations')` | Where operations are discovered. | -| `context` | `null` | A `ContextFactory` class, building the `$context` every handler receives from the request. | -| `key.mode` | `obfuscate` | `obfuscate`, `plain`, or `custom` with `key.className`. | -| `key.pepper` | `"none"` | Salt for `obfuscate` — the literal string `none`, not "no pepper". | -| `middleware` | `[]` | Global `MiddlewareContract` classes, run on every operation. | -| `exceptions.not_found` | Laravel's model-not-found exceptions | Mapped to 404. | -| `exceptions.unauthenticated` | `AuthenticationException` | Mapped to 401. | -| `exceptions.unauthorized` | `AuthorizationException`, `TokenMismatchException` | Mapped to 403. | -| `cache.idLength` | `10` | Id length used by the production cache. | - -Exception matching is `instanceof`, so listing a base class covers its subclasses. An unrecognised -`key.mode` is an error rather than a fallback, because silently picking a different one would change -every key in the application. - -`context` names a class implementing `ContextFactory`, whose single method builds the `$context` -every handler and middleware receives: - -```php -use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; +## Preloading a query -final class OperationContextFactory implements ContextFactory -{ - public function createContextFromHttpRequest(Request $request): mixed - { - return new MyContext(user: $request->user()); - } -} -``` - -Leave it `null` and every handler receives `null` as its context. - -> **Debug mode changes behaviour.** With `app.debug` on, a `LocalMetadataMiddleware` is prepended to -> every operation and responses gain a `__metadata` key with the raw input, the context class and -> per-middleware timings; failures additionally carry `__info` and `__debug` with the exception -> message, file, line and full stack trace. None of that is emitted in production, and none of it is -> in the generated types. - -**3. Register the routes.** Nothing is registered for you — put this in your routes file, inside -whatever middleware group the operations belong to: +`Preloader` runs a query server-side during the request that renders the page, so the data is in the +page instead of being fetched after it loads. It takes the `Server` and an `OperationKeyGenerator` — +which has to be the one the server's registry uses, or the key it derives names no operation and +every preload throws: ```php -use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; - -Route::middleware('web')->group(function () { - LaravelHttpController::registerQueries(); // GET /query/{fqn} - LaravelHttpController::registerCommands(); // POST /command/{fqn} -}); -``` - -Both take a route prefix, defaulting to `query` and `command`. `operations:codegen` reads the -registered URIs to build the client, and fails with *"The operation routes are not registered"* if -you skip this step. - -**The route parameter must stay named `{fqn}`.** The generated client substitutes the operation key -into that placeholder literally, so a hand-registered route using any other name yields a client -requesting URLs that still contain `{fqn}` — no error anywhere, just 404s. - -**4. Write an operation** in `app/Operations`, as in the [quickstart](#quickstart). - -**5. Generate the client.** +use Le0daniel\PhpTsBindings\Server\Preloader; -```bash -php artisan operations:codegen resources/js/operations -``` - -### Commands - -| Command | Purpose | -|---|---| -| `operations:list` | Every registered operation with its URI, method and handler. | -| `operations:codegen {directory}` | Generate the TypeScript client. `--verify` checks for drift instead of writing — use it in CI. | -| `operations:optimize` | Compile the registry to `bootstrap/cache/operations.php`. `--id-length=` overrides `cache.idLength` for the run. | -| `operations:clear-optimize` | Remove it. | - -The last two are wired into `php artisan optimize` and `optimize:clear`. - -> `operations:codegen` removes every file it previously wrote under the target directory, identified -> by the `// generated by: php-ts-bindings` marker on the first line. Anything else is left alone, -> and a generated module colliding with an unmarked file of the same name is refused rather than -> overwritten. Upgrading from a version that predates the marker means deleting the output directory -> once. - -### Preloading a query +$preloader = new Preloader($server, $keyGenerator); -`Preloader` runs a query server-side during the request that renders the page, so the data is in the -page instead of being fetched after it loads. It is a core class — -`Le0daniel\PhpTsBindings\Server\Preloader` — and on Laravel it is resolved from the container: - -```php -public function show(Preloader $preloader): Response -{ - return Inertia::render('Users', [ - 'users' => $preloader->preload('users', 'get', ['id' => 1], $context), - ]); -} +$users = $preloader->preload('users', 'get', ['id' => 1], $context); ``` You get back `['response' => …, 'queryKey' => ['users', 'get', ['id' => 1]]]`. The key is built the -same way the generated `--with=query-key` and `tanstack-query` code builds it, so a TanStack cache -seeded with that pair will not refetch. The input is part of the key whenever the operation has one, -even when the value is `null`; an operation whose input type *is* `null` gets a two-element key. +same way `EmitQueryKey` and `EmitTanstackQuery` build it, so a TanStack cache seeded with that pair +will not refetch. The input is part of the key whenever the operation has one, even when the value is +`null`; an operation whose input type *is* `null` gets a two-element key. `preloadMany()` takes several at once, as `[['namespace' => …, 'name' => …, 'input' => …], …]`. A query that fails throws a `SchemaException` — this is your own code calling your own operation, not untrusted input. -Constructed by hand, `Preloader` takes the `Server` and an `OperationKeyGenerator`. That generator -has to be the one the server's registry uses, or the key it derives names no operation and every -preload throws. - ## Production -Reflecting and parsing every schema on every request is real overhead. Compile the whole registry -once, at deploy time: - -```bash -php artisan operations:optimize -``` - -This writes `bootstrap/cache/operations.php` with every schema pre-parsed, deduplicated and pooled — -shared structs are emitted once and referenced, and unions are reordered for faster dispatch. The -service provider picks the file up automatically when it exists. Run `operations:codegen --verify` in -CI to catch a frontend that has drifted from the backend. +Reflecting and parsing every schema on every request is real overhead. `CachedOperationRegistry` is +the compiled form of a registry: every schema pre-parsed, deduplicated and pooled — shared structs +emitted once and referenced, and unions reordered for faster dispatch. Compile it once, at deploy +time, and load it instead of discovering. A cache that no longer matches the code asking it fails loudly, at runtime, with an -`UnknownTypeKeyException` — regenerate it with `operations:optimize`, or drop it with -`operations:clear-optimize`. +`UnknownTypeKeyException` — recompile it, or drop it and fall back to discovery. -> Operation keys are derived from `key.mode` and `key.pepper`, so changing either — including -> upgrading a version that changed how they are derived — invalidates both the cache and the -> generated client. Run `operations:optimize` and `operations:codegen` together. +> Operation keys are derived from the key generator, so changing it — including upgrading a version +> that changed how keys are derived — invalidates both the cache and the generated client. +> Recompile and regenerate together. -Outside Laravel, or for schemas that are not operations, the same optimizer is available directly: +The same optimizer is available directly, for schemas that are not operations: ```php use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer; @@ -794,103 +813,6 @@ $ast = $registry->get('MyClass@method@input'); Keys are interned as truncated hashes; `new ASTOptimizer(idLength: 12)` widens them if a run ever reports a collision, which it does by throwing rather than by merging two schemas. -## Without a framework - -The core knows nothing about Laravel — nothing outside `src/Adapters/` does. Build a server, run an -operation, and shape the response however you like. - -The example below uses `PsrContainerAdapter`, which needs `psr/container` (a `suggest`, not a -dependency). Drop it and the default `NewInstanceAdapter` constructs handlers with `new`, which needs -nothing at all. - -```php -use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; -use Le0daniel\PhpTsBindings\Server\Client\NullClient; -use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; -use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; -use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; -use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; -use Le0daniel\PhpTsBindings\Server\Server; - -$server = new Server( - EagerlyLoadedOperationRegistry::eagerlyDiscover( - __DIR__ . '/src/Operations', - keyGenerator: new PlainlyExposedKeyGenerator(), - ), - adapter: new PsrContainerAdapter($psrContainer), - configuration: new ServerConfiguration() - ->withMiddlewares(AuthMiddleware::class) - ->withExceptions( - notFound: [EntityNotFoundException::class], - unauthenticated: [NotLoggedInException::class], - ), -); - -$result = $server->command('users.create', $input, $myContext, new NullClient()); - -if ($result instanceof RpcSuccess) { - respondJson(200, ['success' => true, 'data' => $result->data]); -} else { - respondJson($result->type->value, [ - 'success' => false, - 'code' => $result->type->value, - 'type' => $result->type->name, - 'details' => $result->details, - ]); -} -``` - -`$result->cause` is the underlying `Throwable` on every error, ready to hand to your reporter. On the -rare occasion that working out how to present an error *itself* failed — a stale middleware class -name, say — `$result->presentationFailure` holds that second exception; it is null otherwise. - -To emit client directives, pass an `OperationSPAClient` instead of a `NullClient` and ask it for the -payload: - -```php -use Le0daniel\PhpTsBindings\Contracts\SerializableClient; - -$client = new OperationSPAClient(); -$result = $server->command('users.create', $input, $myContext, $client); - -$body = ['success' => true, 'data' => $result->data]; -if ($client instanceof SerializableClient && $directives = $client->serializeToArray()) { - $body['__client'] = $directives; -} -``` - -To generate the client, hand the same `Server` to `TypescriptServerCodeGenerator` with the URL -patterns your router uses: - -```php -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; -use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; -use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; -use Le0daniel\PhpTsBindings\CodeGen\Utils\OutputDirectory; - -$files = new TypescriptServerCodeGenerator([ - new EmitTypes(), - new EmitOperationClientBindings(), - new EmitTypeUtils(), - // Takes an optional Closure(TypedOperation): string — the framework-free --naming. - new EmitOperations(), -])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); - -// Creates lib/, prunes the modules it wrote for operations that no longer exist, and leaves -// anything it did not write alone. OutputDirectory::verify() is the same rules without writing — -// it returns one message per problem, and an empty list means the directory is up to date. -OutputDirectory::write('resources/js/operations', $files); -``` - -`generate()` takes a third argument, a list of namespaces (or `namespace.name` operations) to skip — -the equivalent of `--ignore=`. - -The lower-level `TypeParser`, `SchemaExecutor` and `TypescriptGenerator` are public too, if you want -to parse and emit types without the RPC layer at all — see [the type reference](docs/types.md). - ## Extension points and exceptions The interfaces meant to be implemented by you: @@ -904,12 +826,14 @@ The interfaces meant to be implemented by you: | `Client` / `SerializableClient` | Your own side channel and its wire payload. | | `StringValueObject` / `IntValueObject` | A class that travels as one primitive. | | `GeneratesLibFiles` / `GeneratesOperationCode` / `DependsOn` | Adding to the generated client. | -| `ContextFactory` | Building `$context` from a request (Laravel adapter only). | Two more knobs on discovery: `new OperationDiscovery($filterFn)` takes a closure returning `false` to keep an operation out of the registry, and `EagerlyLoadedOperationRegistry::withClasses([...])` registers a list of classes instead of scanning directories. +The lower-level `TypeParser`, `SchemaExecutor` and `TypescriptGenerator` are public too, if you want +to parse and emit types without the RPC layer at all — see [the type reference](docs/types.md). + **Exceptions.** Everything this library throws implements `PhpTsBindingsException`, so one `catch` covers all of it. Below that are three subsystem bases: diff --git a/docs/laravel.md b/docs/laravel.md new file mode 100644 index 0000000..1beca35 --- /dev/null +++ b/docs/laravel.md @@ -0,0 +1,302 @@ +# The Laravel adapter + +A first-party adapter for [php-ts-bindings](../README.md). It is optional: the library requires +nothing but PHP 8.5, and everything Laravel-aware lives under `src/Adapters/Laravel/`. + +This document covers what the adapter *adds* and what it *decides on your behalf*. For what an +operation is, how middleware works, what the error categories mean and what the generated client +looks like, see the [README](../README.md). + +- [Setup](#setup) +- [Configuration](#configuration) +- [Routes](#routes) +- [Context](#context) +- [What the adapter decides for you](#what-the-adapter-decides-for-you) +- [Artisan commands](#artisan-commands) +- [Production](#production) +- [Preloading](#preloading) + +## Setup + +**1. The provider is auto-discovered.** `LaravelServiceProvider` is listed under +`extra.laravel.providers`, so there is nothing to register. + +**2. Publish the config.** + +```bash +php artisan vendor:publish --provider="Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider" +``` + +The publish group carries no tag, so `--tag=config` does not find it; name the provider. + +Publishing is optional — the package config is merged in either way, and every key below has a +working default. + +**3. Register the routes.** Nothing is registered for you — see [Routes](#routes). + +**4. Write an operation** in `app/Operations`, as in the +[quickstart](../README.md#quickstart). + +**5. Generate the client.** + +```bash +php artisan operations:codegen resources/js/operations +``` + +## Configuration + +`config/operations.php`: + +| Key | Default | Purpose | +|---|---|---| +| `discovery_path` | `app_path('Operations')` | Where operations are discovered. A string or a list of them. | +| `context` | `null` | A `ContextFactory` class, building the `$context` every handler receives from the request. | +| `key.mode` | `obfuscate` | `obfuscate`, `plain`, or `custom` with `key.className`. | +| `key.pepper` | `"none"` | Salt for `obfuscate` — the literal string `none`, not "no pepper". | +| `key.className` | `null` | An `OperationKeyGenerator`, required for `custom` and ignored otherwise. | +| `middleware` | `[]` | Global `MiddlewareContract` classes, run on every operation. | +| `exceptions.unauthenticated` | `AuthenticationException` | Mapped to 401. | +| `exceptions.unauthorized` | `TokenMismatchException`, `AuthorizationException` | Mapped to 403. | +| `exceptions.not_found` | `ModelNotFoundException`, `RecordNotFoundException`, `RecordsNotFoundException` | Mapped to 404. | +| `cache.idLength` | `10` | Id length used by the production cache. | + +Exception matching is `instanceof`, so listing a base class covers its subclasses. An unrecognised +`key.mode` is an error rather than a fallback, because silently picking a different one would change +every key in the application. + +> **The config is merged shallowly.** `mergeConfigFrom()` is a top-level `array_merge`, so a +> published file that defines `exceptions` or `key` **replaces that whole sub-array** rather than +> merging into it. A published `exceptions` block listing only `unauthorized` drops the package's +> 401 and 404 mappings entirely. Keep every category you want, or delete the key to inherit all of +> them. + +## Routes + +Nothing is registered for you. Put this in your routes file, inside whatever middleware group the +operations belong to: + +```php +use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; + +Route::middleware('web')->group(function () { + LaravelHttpController::registerQueries(); // GET /query/{fqn} + LaravelHttpController::registerCommands(); // POST /command/{fqn} +}); +``` + +Both take a route prefix, defaulting to `query` and `command`, and both name the route they register +(`__query_route` and `__command_route`). `operations:codegen` and `operations:list` read the +registered URIs, and `operations:codegen` fails with *"The operation routes are not registered"* if +you skip this step. + +Because you register them, the middleware group, authentication, throttling, session and CSRF +behaviour are entirely your application's choice — the adapter has no opinion and adds nothing to +the HTTP kernel. + +**The route parameter must stay named `{fqn}`.** The generated client substitutes the operation key +into that placeholder literally, so a hand-registered route using any other name yields a client +requesting URLs that still contain `{fqn}` — no error anywhere, just 404s. + +## Context + +`context` names a class implementing `ContextFactory`, whose single method builds the `$context` +every handler and middleware receives: + +```php +use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; + +final class OperationContextFactory implements ContextFactory +{ + public function createContextFromHttpRequest(Request $request): mixed + { + return new MyContext(user: $request->user()); + } +} +``` + +The class is resolved through the container, so it may take constructor arguments. Leave the config +`null` and every handler receives `null` as its context. + +## What the adapter decides for you + +Everything the provider and the HTTP controller pick without asking. + +### Wiring + +- **Handlers and middleware are resolved through the application container** — the adapter always + uses `PsrContainerAdapter($app)`, never `NewInstanceAdapter`. Constructor injection, singletons and + contextual bindings all apply to your operation classes. +- **Keys are obfuscated by default**, with the pepper `"none"`. The adapter constructs + `HashSha256KeyGenerator` with the pepper only, so it takes that class's defaults: an 8-character + namespace segment and a 24-character name segment. Set `key.pepper` to something of your own, or + `key.mode` to `plain` if you would rather read your own URLs. +- **`coerceQueryInput` stays `false` and is not configurable.** It does not need to be: the transport + JSON-decodes every query parameter, so values arrive already typed. See + [`ServerConfiguration`](../README.md#the-rest-of-serverconfiguration) for what the flag does. +- **Discovery scans `discovery_path` recursively**, reflecting every declared class, and assumes one + class per file. If the config key is missing entirely the provider falls back to `[]` — an empty + registry, not an error. +- **The optimized registry is picked up automatically** when `bootstrap/cache/operations.php` exists. + The path is hardcoded. +- **The provider is deferred.** It implements `DeferrableProvider` and only declares `TypeParser`, + `LaravelHttpController`, `Preloader` and `operations.default_server`, so nothing — including the + config merge — runs until one of those four is resolved. Console commands and `vendor:publish` + work regardless; resolving the four bindings is how anything else reaches this package. + +### Requests + +- **Queries are GET, commands are POST.** No other verb is served. +- **Query input** is `$request->query->all()` with every *string* value passed through + `json_decode()`, falling back to the raw string when it does not parse. Non-strings — what + `?filter[a]=1` produces — are passed through untouched for the schema to reject. +- **Command input is `$request->json()->all()`**, so **the body must be JSON**. A form-encoded POST + yields an empty array, which becomes a `null` input and almost certainly a 422. +- Empty input of either kind becomes `null`. +- **`X-Client-Id: operations-spa`** — exactly that value — selects `OperationSPAClient`. Every other + request gets a `NullClient`, and [client directives](../README.md#client-directives) go nowhere + without warning. The generated client sends the header on every call. + +### Responses + +- **Success is always HTTP 200**, with `{"success": true, "data": …}`. Failures use the error + category's own status: 400, 401, 403, 404, 422 or 500. +- `__client` is added when the client produced directives, `__metadata` when a middleware attached + any. +- **Exception rendering bypasses Laravel entirely.** Nothing is thrown out of the controller. Every + `RpcError` is handed to `ExceptionHandler::report()` — so logging, Sentry and friends still fire — + and then serialized by hand. Laravel's `render()`, `renderable()` handlers, `abort()` pages and the + 419 CSRF redirect never run for an operation. +- **Validation errors are not Laravel's shape.** A 422 carries + `{"type": "INVALID_INPUT", "fields": {"": ["message"]}}`, not + `{"message": …, "errors": …}`. `Illuminate\Validation\ValidationException` is **not** mapped by + default, so a `$request->validate()` inside a handler lands in a 500. Map it onto a category + yourself, or throw + [`InvalidInputException::createFromMessages()`](../README.md#errors) instead. + +### CSRF and cookies + +The generated client sends `Accept`, `X-Client-ID` and `Content-Type` — **no CSRF token** — and sets +no `credentials` option, so cookies ride the browser's `same-origin` default. Commands registered +inside the `web` group therefore need either a CSRF exemption or a custom `OperationClient` that +attaches the token. The default mapping of `TokenMismatchException` to 403 is the acknowledgement of +that: a rejected token reaches the frontend as an ordinary `AUTHORIZATION_ERROR` rather than as an +HTML redirect. + +### Debug mode + +> **`app.debug` changes behaviour.** With it on, `LocalMetadataMiddleware` is prepended to every +> operation and responses gain a `__metadata` key with **the raw input**, the context class, the +> handler, the middleware stack and per-middleware timings. Failures additionally carry `__info` and +> `__debug` with the exception class, message, file, line and **full stack trace**. +> +> None of it is emitted in production, and none of it appears in the generated types — but any +> environment with `APP_DEBUG=true` and a reachable route is handing all of that to whoever calls it. + +## Artisan commands + +| Command | Purpose | +|---|---| +| `operations:list` | Every registered operation with its URI, method, handler, Laravel middleware and operation middleware. | +| `operations:codegen {directory}` | Generate the TypeScript client. | +| `operations:optimize` | Compile the registry to `bootstrap/cache/operations.php`. | +| `operations:clear-optimize` | Remove it. | + +### `operations:codegen` + +```bash +php artisan operations:codegen resources/js/operations --with=tanstack-query,query-key,type-map +``` + +| Option | Effect | +|---|---| +| `--with=` | Turn a generator on. Repeatable, and comma-separated values are expanded. | +| `--without=` | Turn a default generator off. `--with` wins when a name appears in both. | +| `--custom=` | Add your own generator by class name, resolved through the container. | +| `--ignore=` | Skip a namespace, or one operation as `namespace.name`. | +| `--naming=` | How the generated functions are named. Default `name`. | +| `--verify` | Check for drift instead of writing, exiting 1 on any difference. Use it in CI. | + +The names accepted by `--with` and `--without` map onto the +[generators](../README.md#optional-generators): + +| Name | Generator | Default | +|---|---|---| +| `types` | `EmitTypes` | on | +| `bindings` | `EmitOperationClientBindings` | on | +| `utils` | `EmitTypeUtils` | on | +| `operations` | `EmitOperations` | on | +| `type-map` | `EmitTypeMap` | off | +| `tanstack-query` | `EmitTanstackQuery` | off | +| `query-key` | `EmitQueryKey` | off | + +`--naming=` chooses how functions are named: + +| Mode | `#[Query('users', 'get')]` becomes | +|---|---| +| `name` (default) | `get` | +| `fqn`, `operation-prefix` | `usersGet` — the two are the same rule | +| `namespace-postfix` | `getUsers` | +| `Class::method` | whatever your rule returns | + +`Class::method` resolves `Class` through the container and calls it as an **instance** method with +the `TypedOperation`, despite the static-looking syntax. An unknown mode ends the run with a message +listing the valid ones. + +Three details worth knowing: + +- A relative `{directory}` resolves against `base_path()`, **not** the current working directory. +- `--ignore` takes the plain `namespace.name`, never the obfuscated key. +- The command always builds a fresh, eagerly-discovered registry, so it never reads + `bootstrap/cache/operations.php` and never emits a client from a stale cache. + +> `operations:codegen` removes every file it previously wrote under the target directory, identified +> by the `// generated by: php-ts-bindings` marker on the first line. Anything else is left alone, +> and a generated module colliding with an unmarked file of the same name is refused rather than +> overwritten. Upgrading from a version that predates the marker means deleting the output directory +> once. + +## Production + +```bash +php artisan operations:optimize +``` + +This compiles the registry to `bootstrap/cache/operations.php` — a hardcoded path — with every +schema pre-parsed, deduplicated and pooled. The provider picks the file up automatically when it +exists. `--id-length=` overrides `cache.idLength` for the run. + +The command writes the file and then `require`s it, to prove it parses and that its ids do not +collide; if anything fails it reports the message and deletes the file rather than leaving a broken +cache for the next request. + +Both commands are wired into `php artisan optimize` and `optimize:clear`, so a standard deploy picks +them up without extra steps. Run `operations:codegen --verify` in CI to catch a frontend that has +drifted from the backend. + +A cache that no longer matches the code asking it fails loudly, at runtime, with an +`UnknownTypeKeyException` — regenerate it with `operations:optimize`, or drop it with +`operations:clear-optimize`. + +> Operation keys are derived from `key.mode` and `key.pepper`, so changing either — including +> upgrading a version that changed how they are derived — invalidates both the cache and the +> generated client. Run `operations:optimize` and `operations:codegen` together. + +See [Production](../README.md#production) for the optimizer underneath, which is usable on its own. + +## Preloading + +[`Preloader`](../README.md#preloading-a-query) is registered as a container singleton, built with the +same key generator as the registry, so injecting it is all that is needed: + +```php +public function show(Preloader $preloader): Response +{ + return Inertia::render('Users', [ + 'users' => $preloader->preload('users', 'get', ['id' => 1], $context), + ]); +} +``` + +You get back `['response' => …, 'queryKey' => …]`, with a key built exactly as the generated +`--with=query-key` and `tanstack-query` code builds it, so a TanStack cache seeded with that pair +will not refetch. diff --git a/docs/types.md b/docs/types.md index 53fb289..e724076 100644 --- a/docs/types.md +++ b/docs/types.md @@ -581,5 +581,5 @@ use Le0daniel\PhpTsBindings\Parser\Helpers\AstValidator; AstValidator::validate($node); ``` -Code generation does this for every operation already, so a schema that survives -`operations:codegen` is valid. Call it yourself when you parse types outside the server. +Code generation does this for every operation already, so a schema that survives code generation is +valid. Call it yourself when you parse types outside the server. From 550ad11a16601309322c410454b1d2eceffb3608 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 5 Aug 2026 13:30:22 +0200 Subject: [PATCH 060/101] Add `OperationSPAClient` Typescript generator and update client typing logic - Introduce `EmitOperationsSpaClient` generator for `operations-spa` payload support, defining types for operations, toasts, redirects, and invalidations. - Add runtime guard `containsOperationSpaPayload` to narrow results containing the `operations-spa` payload, simplifying client handling and type safety. - Remove obsolete `SPAClientDirectives` and related types, consolidating payload handling under `operations-spa`. - Update all imports and usage references to replace deprecated functionality, ensuring consistency across the codebase. - Refactor tests to align with the new `OperationsClientPayload` schema and guard-based validations. - Enhance safety in generated TypeScript client logic by relying solely on schema-derived guarantees. --- README.md | 60 +++++--- docs/laravel.md | 1 + docs/types.md | 3 +- .../Laravel/Commands/CodeGenCommand.php | 5 + .../Laravel/LaravelHttpController.php | 130 +++++++----------- .../EmitOperationClientBindings.php | 52 +++---- .../EmitOperationsSpaClient.php | 95 +++++++++++++ .../CodeGenerators/EmitTanstackQuery.php | 9 +- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 86 ++++-------- src/CodeGen/CodeGenerators/EmitTypes.php | 24 ---- src/Contracts/RpcResult.php | 15 +- src/Server/Data/RpcError.php | 45 +++--- src/Server/Data/RpcSuccess.php | 28 ++-- .../EmitOperationClientBindingsTest.php | 28 +++- .../CodeGen/EmitOperationsSpaClientTest.php | 88 ++++++++++++ tests/Unit/CodeGen/EmitTypeUtilsTest.php | 59 ++++---- tests/Unit/CodeGen/EmitTypesTest.php | 38 ++--- tests/Unit/CodeGen/TsOutputFixture.php | 2 + tests/Unit/CodeGen/TsOutputFixtureTest.php | 1 + .../TypescriptServerCodeGeneratorTest.php | 51 +++++-- tests/ts-output/generated/accounts.ts | 4 +- tests/ts-output/generated/catalog.ts | 4 +- .../ts-output/generated/lib/DefaultClient.ts | 14 +- .../generated/lib/OperationClient.ts | 15 +- tests/ts-output/generated/lib/bindings.ts | 19 +-- .../generated/lib/client-operations-spa.ts | 36 +++++ tests/ts-output/generated/lib/types.ts | 11 -- tests/ts-output/generated/lib/utils.ts | 57 ++------ tests/ts-output/generated/shapes.ts | 4 +- tests/ts-output/src/usage.ts | 29 ++-- 30 files changed, 581 insertions(+), 432 deletions(-) create mode 100644 src/CodeGen/CodeGenerators/EmitOperationsSpaClient.php create mode 100644 tests/Unit/CodeGen/EmitOperationsSpaClientTest.php create mode 100644 tests/ts-output/generated/lib/client-operations-spa.ts diff --git a/README.md b/README.md index d4070e9..904858e 100644 --- a/README.md +++ b/README.md @@ -590,12 +590,13 @@ it on disk. Nothing is published to npm; the code lives in your repo. lib/OperationClient.ts the transport interface lib/DefaultClient.ts a fetch implementation of it lib/OperationException.ts - lib/bindings.ts createDefaultClient, setClient, executeOperation, throwOnFailure - lib/utils.ts queryKey and the client-directive type guards + lib/bindings.ts createDefaultClient, setClient, executeOperation + lib/utils.ts queryKey, throwOnFailure + lib/client-operations-spa.ts the OperationSPAClient payload and its guard .ts one module per namespace, one function per operation ``` -That is what the four default generators produce. The [optional ones](#optional-generators) add to +That is what the five default generators produce. The [optional ones](#optional-generators) add to it: `EmitTypeMap` writes one more file, the other two write into the `.ts` modules that are already there. @@ -612,11 +613,12 @@ The envelope every call resolves to: export type Success = {success: true, data: T} export type Failure = {success: false} & E; export type Result = Success | Failure; - -// what a generated function actually returns — see Client directives -export type WithClientDirectives = T & {__client?: unknown}; ``` +That is the whole envelope. A server may put more next to it — [client +directives](#client-directives) arrive under `__client` — and it travels through the transport +untouched rather than being described here; see that section for how to get at it. + Wire it up once: ```typescript @@ -642,9 +644,10 @@ runs a callback on every response and returns a function that unregisters it. Sw transport by implementing `OperationClient` — `setClient()` and the per-call `options.client` both take one. -`throwOnFailure(result)` narrows a `Result` to its success branch and throws an `OperationException` -otherwise, for call sites that would rather not branch. A `catch` variable is `unknown` in TypeScript -whatever was thrown, so name the operation's error union at the guard to get it back: +`throwOnFailure(result)`, from `lib/utils.ts`, narrows a `Result` to its success branch and throws an +`OperationException` otherwise, for call sites that would rather not branch. A `catch` variable is +`unknown` in TypeScript whatever was thrown, so name the operation's error union at the guard to get +it back: ```typescript try { @@ -663,13 +666,14 @@ try { ### Optional generators The generator list you hand `TypescriptServerCodeGenerator` *is* the configuration — there is no -separate switch. Seven ship: +separate switch. Eight ship: | Generator | In the quickstart | Emits | |---|---|---| | `EmitTypes` | yes | `lib/types.ts` — the envelope, `Brand`, every `#[Named]` alias | | `EmitOperationClientBindings` | yes | `lib/bindings.ts`, `lib/OperationClient.ts`, `lib/DefaultClient.ts`, `lib/OperationException.ts` | -| `EmitTypeUtils` | yes | `lib/utils.ts` — `queryKey` and the client-directive guards | +| `EmitTypeUtils` | yes | `lib/utils.ts` — `queryKey` and `throwOnFailure` | +| `EmitOperationsSpaClient` | yes | `lib/client-operations-spa.ts` — the `OperationSPAClient` payload and `containsOperationSpaPayload()` | | `EmitOperations` | yes | one `.ts` module per namespace | | `EmitTanstackQuery` | no | `QueryOptions()` and `useQuery()` for `@tanstack/react-query` | | `EmitQueryKey` | no | standalone query keys | @@ -749,12 +753,34 @@ The full interface is `redirect()`, `invalidate()`, `toast()`, and one shorthand called for them. A transport emits that payload by asking the client for it: `SerializableClient::serializeToArray()`. -**`__client` is typed `unknown` on purpose.** `Client` is an extension point — your own -implementation may define an entirely different set of directives under a different schema — so the -generated types decline to commit to a shape they cannot know. `OperationSPAClient` is the subset -this library deems useful and ships, and `lib/utils.ts` narrows to it with `isSpaClientDirectives()`, -`isClientToast()` and `isClientRedirect()`. Write your own guard for your own directives; that is the -same "no dishonest types" rule that makes the generator throw rather than emit a placeholder. +**The envelope says nothing about `__client`, on purpose.** `Client` is an extension point — your own +implementation may define an entirely different set of directives under a different schema — so +neither `lib/types.ts` nor the transport interface commits to a shape it cannot know. The payload +still travels through `DefaultClient` untouched; what is missing is only the claim about what it is. + +`OperationSPAClient` is the subset this library deems useful and ships, and it gets its own file. +`lib/client-operations-spa.ts` declares `OperationsClientPayload` — the same schema +`serializeToArray()` emits — and one guard that puts it on a result: + +```typescript +import {containsOperationSpaPayload} from './operations/lib/client-operations-spa'; + +const result = await create({name: 'Leo'}); + +if (containsOperationSpaPayload(result)) { + for (const toast of result.__client.toasts ?? []) { … } // ClientToast, fully typed + result.__client.redirect?.url; +} +``` + +The check is the discriminator alone. The payload is assembled in one pass, so a server that wrote +`type: "operations-spa"` wrote the rest of it to the same schema, and unknown keys are ignored either +way — adding a directive stays backwards compatible. + +The file is emitted by `EmitOperationsSpaClient`, on by default. Drop it with +`--without operations-spa` and nothing else changes; write your own guard against your own +directives, which is the same "no dishonest types" rule that makes the generator throw rather than +emit a placeholder. ## Preloading a query diff --git a/docs/laravel.md b/docs/laravel.md index 1beca35..6d3b1e0 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -224,6 +224,7 @@ The names accepted by `--with` and `--without` map onto the | `types` | `EmitTypes` | on | | `bindings` | `EmitOperationClientBindings` | on | | `utils` | `EmitTypeUtils` | on | +| `operations-spa` | `EmitOperationsSpaClient` | on | | `operations` | `EmitOperations` | on | | `type-map` | `EmitTypeMap` | off | | `tanstack-query` | `EmitTanstackQuery` | off | diff --git a/docs/types.md b/docs/types.md index e724076..43aaee5 100644 --- a/docs/types.md +++ b/docs/types.md @@ -459,8 +459,7 @@ The same conflicting-alias error protects against two classes resolving to the s different shapes anywhere in a run. A handful of names the generated types file always declares are rejected outright: -`Brand`, `Success`, `Failure`, `Result`, `OperationNamespaces`, `WithClientDirectives`, -`SPAClientDirectives`, `ClientDirectives`, `ClientToast`, `ClientRedirect`, `ClientInvalidation`. +`Brand`, `Success`, `Failure`, `Result`, `OperationNamespaces`. Brands and names are pure code generation metadata with zero runtime impact: values travel the wire in their plain shape, and the metadata is stripped from cached ASTs entirely — TypeScript diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index 38187fa..113123e 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -12,6 +12,7 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\Utils\ArtisanOptions; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationsSpaClient; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTanstackQuery; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeMap; @@ -241,6 +242,10 @@ private function getGeneratorsFromInput(Application $application): array $includeGenerator('types', true) ? new EmitTypes() : null, $includeGenerator('bindings', true) ? new EmitOperationClientBindings() : null, $includeGenerator('utils', true) ? new EmitTypeUtils() : null, + // On by default: the adapter picks OperationSPAClient for a request carrying the + // matching header, so the client that reads its payload ships with it. A project + // using a Client of its own drops the file with --without operations-spa. + $includeGenerator('operations-spa', true) ? new EmitOperationsSpaClient() : null, $includeGenerator('operations', true) ? new EmitOperations($namingGenerator) : null, $includeGenerator('type-map', false) ? new EmitTypeMap() : null, // Only EmitOperations is given the naming rule: it declares the names, the other two diff --git a/src/Adapters/Laravel/LaravelHttpController.php b/src/Adapters/Laravel/LaravelHttpController.php index ba00db4..1c7e6b5 100644 --- a/src/Adapters/Laravel/LaravelHttpController.php +++ b/src/Adapters/Laravel/LaravelHttpController.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades; use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; use Le0daniel\PhpTsBindings\Contracts\Client; +use Le0daniel\PhpTsBindings\Contracts\RpcResult; use Le0daniel\PhpTsBindings\Contracts\SerializableClient; use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Client\OperationSPAClient; @@ -52,16 +53,14 @@ public static function registerCommands(string $routePrefix = 'command'): Route */ public function handleHttpQueryRequest(string $fqn, Http\Request $request): JsonResponse { - $client = $this->createClient($request); - - $result = $this->server->query( - $fqn, - $this->gatherInputFromRequest(OperationType::QUERY, $request), - $this->contextFactory?->createContextFromHttpRequest($request), - $client, - ); - - return $this->produceJsonResponse($result, $client); + return $this->server->query( + $fqn, + input: $this->gatherInputFromRequest(OperationType::QUERY, $request), + context: $this->contextFactory?->createContextFromHttpRequest($request), + client: $this->createClient($request), + ) + |> $this->reportExceptions(...) + |> $this->produceJsonResponse(...); } /** @@ -69,16 +68,22 @@ public function handleHttpQueryRequest(string $fqn, Http\Request $request): Json */ public function handleHttpCommandRequest(string $fqn, Http\Request $request): JsonResponse { - $client = $this->createClient($request); - - $result = $this->server->command( - $fqn, - $this->gatherInputFromRequest(OperationType::COMMAND, $request), - $this->contextFactory?->createContextFromHttpRequest($request), - $client, - ); + return $this->server->command( + $fqn, + input: $this->gatherInputFromRequest(OperationType::COMMAND, $request), + context:$this->contextFactory?->createContextFromHttpRequest($request), + client: $this->createClient($request), + ) + |> $this->reportExceptions(...) + |> $this->produceJsonResponse(...); + } - return $this->produceJsonResponse($result, $client); + private function reportExceptions(RpcResult $result): RpcResult + { + if ($result instanceof RpcError) { + $this->exceptionHandler->report($result->cause); + } + return $result; } private function createClient(Http\Request $request): Client @@ -117,67 +122,33 @@ private function gatherInputFromRequest(OperationType $type, Http\Request $reque return empty($inputData) ? null : $inputData; } - /** - * @param array $response - * @param Client $client - * @return array - */ - private function appendClientDirectives(array $response, Client $client): array + private function produceJsonResponse(RpcResult $result): JsonResponse { - if (!$client instanceof SerializableClient) { - return $response; - } - - $clientData = $client->serializeToArray(); - if ($clientData === null) { - return $response; - } - - $response['__client'] = $clientData; - return $response; - } + $httpStatusCode = match (true) { + $result instanceof RpcSuccess => 200, + $result instanceof RpcError => $result->type->value, + default => throw new \RuntimeException('Unexpected result type'), + }; - private function produceJsonResponse(RpcSuccess|RpcError $result, Client $client): JsonResponse - { - if ($result instanceof RpcSuccess) { - $data = $this->appendClientDirectives([ - 'success' => true, - 'data' => $result->data, - ], $client); - - if (!empty($result->metadata)) { - $data['__metadata'] = $result->metadata; - } - - if ($this->debug) { - $data['__info'] = [ - "handler" => "{$result->resolveInfo->className}@{$result->resolveInfo->methodName}", - "middleware" => $result->resolveInfo->middleware, - "fqn" => $result->resolveInfo->fullyQualifiedName, - "type" => $result->resolveInfo->operationType->name, - ]; - } - - return new JsonResponse($data, 200); + $jsonResponse = $result->jsonSerialize(); + if (!$this->debug) { + return new JsonResponse($jsonResponse, status: $httpStatusCode); } - $this->exceptionHandler->report($result->cause); - $content = $this->appendClientDirectives([ - 'success' => false, - 'code' => $result->type->value, - // The discriminant the generated error union is narrowed on. The status code carries the - // same information, but only the client that reads the body can rely on it. - 'type' => $result->type->name, - 'details' => $result->details - ], $client); - - if (!empty($result->metadata)) { - $content['__metadata'] = $result->metadata; + // We append some general debug information + if ($result->resolveInfo) { + $jsonResponse['__resolveInfo'] = [ + "handler" => "{$result->resolveInfo->className}@{$result->resolveInfo->methodName}", + "middleware" => $result->resolveInfo->middleware, + "fqn" => $result->resolveInfo->fullyQualifiedName, + "type" => $result->resolveInfo->operationType->name, + ]; } - if ($this->debug) { + // We append debug info for failed operations + if ($result instanceof RpcError) { $exception = $result->cause; - $content['__debug'] = Dicts::filterNullValues([ + $jsonResponse['__debug'] = Dicts::filterNullValues([ 'class' => $exception::class, 'message' => $exception->getMessage(), 'code' => $exception->getCode(), @@ -186,20 +157,11 @@ private function produceJsonResponse(RpcSuccess|RpcError $result, Client $client 'trace' => $exception->getTrace(), 'issues' => $exception instanceof InvalidOutputException ? $exception->issues->serializeToDebugFields() : null, ]); - - $content['__info'] = $result->resolveInfo ? [ - "handler" => "{$result->resolveInfo->className}@{$result->resolveInfo->methodName}", - "middleware" => $result->resolveInfo->middleware, - "fqn" => $result->resolveInfo->fullyQualifiedName, - "type" => $result->resolveInfo->operationType->name, - ] : [ - "message" => "No handler found for operation.", - ]; } return new JsonResponse( - $content, - $result->type->value + $jsonResponse, + status: $httpStatusCode ); } } \ No newline at end of file diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index d0e6d6d..3b9fb9b 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -108,19 +108,24 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi self::OPERATION_CLIENT_FILE => new TypescriptFile(<<( - type: "command"|"query", - key: string, - input: unknown, + type: "command"|"query", + key: string, + input: unknown, options?: OperationOptions - ): Promise>>; + ): Promise>; } TypeScript, [ - $this->types->importFromTypes(types: ['Result', 'WithClientDirectives']), + $this->types->importFromTypes(types: ['Result']), ]), self::DEFAULT_CLIENT_FILE => new TypescriptFile(<<>) => Promise | void; +export type Hook = (result: Result) => Promise | void; export class DefaultClient implements OperationClient { @@ -157,7 +162,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi }).join('&'); } - private async callHooks>(result: WithClientDirectives) { + private async callHooks>(result: T) { try { await Promise.all(this.hooks.map(hook => hook(result))); return result; @@ -167,7 +172,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } } - async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise>> { + async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; const fullPath = `\${this.options.baseUrl ?? ''}/\${route.replace('{fqn}', key)}`; @@ -204,8 +209,10 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi throw new Error('Invalid response body. Could not parse json correctly.'); } + // Spread first: whatever the server put next to the envelope — a client's directives, say — + // rides along untyped rather than being dropped by a transport that never knew about it. if (response.ok) { - return await this.callHooks({...json, success: true} as WithClientDirectives>); + return await this.callHooks({...json, success: true} as Success); } return await this.callHooks({ @@ -213,7 +220,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi success: false, code: json?.code ?? response.status, type: json?.type ?? 'INTERNAL_ERROR' - } as WithClientDirectives>); + } as Failure); } registerHook(hook: Hook): () => void { @@ -226,9 +233,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } TypeScript, [ $this->importFromOperationClient(types: ['OperationClient', 'OperationOptions']), - $this->types->importFromTypes( - types: ['Failure', 'Result', 'Success', 'WithClientDirectives'], - ), + $this->types->importFromTypes(types: ['Failure', 'Result', 'Success']), ]), self::OPERATION_EXCEPTION_FILE => new TypescriptFile(<<(e)` types `e.cause` for you. - */ -export function throwOnFailure(result: Result): asserts result is Success { - if (!result.success) { - throw new OperationException(result); - } -} - -export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise>> { +export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise> { if (options?.client) { return await options.client.execute(type, key, input, options); } @@ -303,12 +294,11 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi throw new Error('No client set'); } TypeScript, [ - $this->types->importFromTypes(types: ['Result', 'Success', 'WithClientDirectives']), + $this->types->importFromTypes(types: ['Result']), $this->importFromOperationClient(types: ['OperationClient', 'OperationOptions']), - // Both are constructed, not just annotated: a type only import would leave + // Constructed, not just annotated: a type only import would leave // `new DefaultClient(...)` referencing nothing at runtime. $this->importFromDefaultClient(values: ['DefaultClient']), - $this->importFromOperationException(values: ['OperationException']), ]), ]; } diff --git a/src/CodeGen/CodeGenerators/EmitOperationsSpaClient.php b/src/CodeGen/CodeGenerators/EmitOperationsSpaClient.php new file mode 100644 index 0000000..d0c5c0b --- /dev/null +++ b/src/CodeGen/CodeGenerators/EmitOperationsSpaClient.php @@ -0,0 +1,95 @@ + $values + * @param list $types + */ + public function importFromOperationsSpaClient(array $values = [], array $types = []): TypescriptImport + { + return new TypescriptImport( + Paths::libImport(self::CLIENT_FILE), + values: $values, + types: $types, + ); + } + + /** + * @return array + */ + #[Override] + public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array + { + // Derived from the enum so the emitted union can never drift from what Client::toast accepts. + $toastTypes = implode('|', array_map( + fn(ToastType $type): string => "'{$type->value}'", + ToastType::cases(), + )); + + return [ + self::CLIENT_FILE => new TypescriptFile(<<(value: T): value is T & {__client: OperationsClientPayload} { + if (!value || typeof value !== 'object' || !('__client' in value)) { + return false; + } + + const payload = value.__client; + return !!payload + && typeof payload === 'object' + && (payload as Partial).type === 'operations-spa'; +} +TypeScript), + ]; + } +} diff --git a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php index 8600269..46c19f8 100644 --- a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php +++ b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php @@ -20,7 +20,6 @@ final class EmitTanstackQuery implements GeneratesOperationCode, DependsOn { private EmitOperations $operations; private EmitTypeUtils $utils; - private EmitOperationClientBindings $bindings; #[Override] public function dependsOnGenerator(): array @@ -28,7 +27,6 @@ public function dependsOnGenerator(): array return [ EmitOperations::class, EmitTypeUtils::class, - EmitOperationClientBindings::class, ]; } @@ -43,10 +41,6 @@ public function setDependencies(array $dependencies): void EmitTypeUtils::class, $dependencies[EmitTypeUtils::class] ?? null, ); - $this->bindings = Assertions::instanceOf( - EmitOperationClientBindings::class, - $dependencies[EmitOperationClientBindings::class] ?? null, - ); } #[Override] @@ -74,8 +68,7 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata values: ['useQuery', 'queryOptions'], types: ['UseQueryOptions'], ), - $this->utils->importFromUtils(values: ['queryKey']), - $this->bindings->importFromBindings(values: ['throwOnFailure']), + $this->utils->importFromUtils(values: ['queryKey', 'throwOnFailure']), ]; if (!$operation->hasInput) { diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index cf2305c..8365f61 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -8,7 +8,6 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; @@ -16,14 +15,19 @@ use Override; /** - * Not readonly: the EmitTypes whose directive types the guards narrow to is injected after - * construction, which is the only way it can be the same instance the generator runs. + * The helpers an application reaches for that belong to no single transport: a cache key, and the + * assertion that turns a failed envelope into a throw. + * + * Not readonly: the generators declaring the envelope it narrows and the exception it throws are + * injected after construction, which is the only way they can be the same instances the generator + * runs. */ final class EmitTypeUtils implements GeneratesLibFiles, DependsOn { private const string UTILS_FILE = 'utils'; private EmitTypes $types; + private EmitOperationClientBindings $bindings; /** * Not static: reaching this means declaring the dependency, and a declared dependency that is @@ -46,6 +50,7 @@ public function dependsOnGenerator(): array { return [ EmitTypes::class, + EmitOperationClientBindings::class, ]; } @@ -56,6 +61,10 @@ public function setDependencies(array $dependencies): void EmitTypes::class, $dependencies[EmitTypes::class] ?? null, ); + $this->bindings = Assertions::instanceOf( + EmitOperationClientBindings::class, + $dependencies[EmitOperationClientBindings::class] ?? null, + ); } /** @@ -77,77 +86,32 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } } - // Derived from the enum, so the values the guard accepts can never drift from ToastType. - $toastTypes = implode(', ', array_map( - fn(ToastType $type): string => "'{$type->value}'", - ToastType::cases(), - )); - return [ self::UTILS_FILE => new TypescriptFile(<<generateLiteralUnion($queryNamespaces)}; -const TOAST_TYPES = [{$toastTypes}] as const; - export function queryKey(ns: QueryNamespaces, ...args: unknown[]): [string, ...unknown[]] { return [ns, ...args]; } -function isArrayOf(value: unknown, predicate: (item: unknown) => item is V): value is V[] { - return Array.isArray(value) && value.every(predicate); -} - -export function isClientToast(value: unknown): value is ClientToast { - if (!value || typeof value !== 'object') { - return false; - } - - const toast = value as Partial; - return typeof toast.message === 'string' - && typeof toast.type === 'string' - && (TOAST_TYPES as readonly string[]).includes(toast.type); -} - -export function isClientRedirect(value: unknown): value is ClientRedirect { - if (!value || typeof value !== 'object') { - return false; - } - - const redirect = value as Partial; - return typeof redirect.url === 'string' && typeof redirect.reload === 'boolean'; -} - -function isClientInvalidation(value: unknown): value is [string, ...unknown[]] { - return Array.isArray(value) && typeof value[0] === 'string'; -} - /** - * Narrows to the full directive payload, so it verifies every directive it claims and not just - * the discriminator: a server on an older format would otherwise be narrowed to a shape it does - * not have. Unknown directive keys are ignored, adding one stays backwards compatible. + * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather + * catch than branch. + * + * The error union is deliberately not inferred here: a catch clause variable is `unknown` in + * TypeScript whatever was thrown, so no signature on this function could carry E to the catch. + * Name it there instead - `OperationException.is(e)` types `e.cause` for you. */ -export function isSpaClientDirectives(result: WithClientDirectives): result is SPAClientDirectives { - if (!result.__client || typeof result.__client !== 'object') { - return false; - } - - const directives = result.__client as Partial; - if (directives.type !== 'operations-spa') { - return false; +export function throwOnFailure(result: Result): asserts result is Success { + if (!result.success) { + throw new OperationException(result); } - - return (directives.redirect === undefined || isClientRedirect(directives.redirect)) - && (directives.toasts === undefined || isArrayOf(directives.toasts, isClientToast)) - && (directives.invalidations === undefined || isArrayOf(directives.invalidations, isClientInvalidation)); } TypeScript, [ - $this->types->importFromTypes(types: [ - 'ClientDirectives', - 'ClientRedirect', - 'ClientToast', - 'SPAClientDirectives', - 'WithClientDirectives', - ]), + $this->types->importFromTypes(types: ['Result', 'Success']), + // Constructed, not just annotated: a type only import would leave + // `new OperationException(...)` referencing nothing at runtime. + $this->bindings->importFromOperationException(values: ['OperationException']), ]) ]; } diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 9b16645..38de92d 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -6,7 +6,6 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; -use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; @@ -28,12 +27,6 @@ 'Failure', 'Result', 'OperationNamespaces', - 'WithClientDirectives', - 'SPAClientDirectives', - 'ClientDirectives', - 'ClientToast', - 'ClientRedirect', - 'ClientInvalidation', ]; /** @@ -81,12 +74,6 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi fn(string $alias, string $definition): string => "export type {$alias} = {$definition}", )); - // Derived from the enum so the emitted union can never drift from what Client::toast accepts. - $toastTypes = implode('|', array_map( - fn(ToastType $type): string => "'{$type->value}'", - ToastType::cases(), - )); - return [ self::TYPES_FILE => new TypescriptFile(<<generateNamespaceUnion($uniqueNamespaces)}; @@ -94,17 +81,6 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi export type Success = {success: true, data: T} export type Failure = {success: false} & E; export type Result = Success | Failure; -export type ClientToast = {type: {$toastTypes}; message: string;}; -export type ClientRedirect = {url: string; reload: boolean;}; -export type ClientInvalidation = [string, ...unknown[]]; -export type ClientDirectives = { - type: "operations-spa"; - redirect?: ClientRedirect; - toasts?: ClientToast[]; - invalidations?: ClientInvalidation[]; -}; -export type WithClientDirectives = T & {__client?: unknown} -export type SPAClientDirectives = T & {__client: ClientDirectives}; declare const __brand: unique symbol; export type Brand = {readonly [__brand]: TBrand;}; diff --git a/src/Contracts/RpcResult.php b/src/Contracts/RpcResult.php index daa43da..2a13ec4 100644 --- a/src/Contracts/RpcResult.php +++ b/src/Contracts/RpcResult.php @@ -2,6 +2,8 @@ namespace Le0daniel\PhpTsBindings\Contracts; +use JsonSerializable; +use Le0daniel\PhpTsBindings\Server\Data\ResolveInfo; use NoDiscard; /** @@ -10,8 +12,19 @@ * Signatures keep spelling out the `RpcSuccess|RpcError` union so the narrow type survives; this * interface exists so that middleware can decorate a result it has not inspected yet. */ -interface RpcResult +interface RpcResult extends JsonSerializable { + public ResolveInfo|null $resolveInfo { + get; + } + + /** + * @var array + */ + public array $metadata { + get; + } + /** * Overwrite all existing metadata. * @param array $metadata diff --git a/src/Server/Data/RpcError.php b/src/Server/Data/RpcError.php index a75cc00..d43ce24 100644 --- a/src/Server/Data/RpcError.php +++ b/src/Server/Data/RpcError.php @@ -3,6 +3,7 @@ namespace Le0daniel\PhpTsBindings\Server\Data; use Le0daniel\PhpTsBindings\Contracts\RpcResult; +use Le0daniel\PhpTsBindings\Utils\Dicts; use NoDiscard; use Override; use Throwable; @@ -31,39 +32,43 @@ public function __construct( /** * @param array $metadata - * @return static * @api */ #[Override] #[NoDiscard] - public function withMetadata(array $metadata): static + public function withMetadata(array $metadata): self { - return new self( - $this->type, - $this->cause, - $this->details, - $this->resolveInfo, - $metadata, - $this->presentationFailure, - ); + return clone($this, [ + 'metadata' => $metadata, + ]); } /** * @param array $metadata - * @return static * @api */ #[Override] #[NoDiscard] - public function appendMetadata(array $metadata): static + public function appendMetadata(array $metadata): self { - return new self( - $this->type, - $this->cause, - $this->details, - $this->resolveInfo, - [...$this->metadata, ...$metadata], - $this->presentationFailure, - ); + return clone($this, [ + 'metadata' => [...$this->metadata, ...$metadata], + ]); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return Dicts::filterNullValues([ + 'success' => false, + 'code' => $this->type->value, + // The discriminant the generated error union is narrowed on. The status code carries the + // same information, but only the client that reads the body can rely on it. + 'type' => $this->type->name, + 'details' => $this->details, + '__metadata' => count($this->metadata) > 0 ? $this->metadata : null, + ]); } } diff --git a/src/Server/Data/RpcSuccess.php b/src/Server/Data/RpcSuccess.php index adf7f96..dc22c5a 100644 --- a/src/Server/Data/RpcSuccess.php +++ b/src/Server/Data/RpcSuccess.php @@ -4,6 +4,8 @@ use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\RpcResult; +use Le0daniel\PhpTsBindings\Contracts\SerializableClient; +use Le0daniel\PhpTsBindings\Utils\Dicts; use NoDiscard; use Override; @@ -25,29 +27,39 @@ public function __construct( /** * Overwrite all existing metadata * @param array $metadata - * @return static * @api */ #[Override] #[NoDiscard] - public function withMetadata(array $metadata): static + public function withMetadata(array $metadata): self { - return new self($this->data, $this->client, $this->resolveInfo, $metadata); + return clone($this, ['metadata' => $metadata]); } /** * Append metadata to the result * @param array $metadata - * @return static * @api */ #[Override] #[NoDiscard] - public function appendMetadata(array $metadata): static + public function appendMetadata(array $metadata): self { - return new self($this->data, $this->client, $this->resolveInfo, [ - ...$this->metadata, - ...$metadata, + return clone($this, [ + 'metadata' => [...$this->metadata, ...$metadata], + ]); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return Dicts::filterNullValues([ + 'success' => true, + 'data' => $this->data, + '__client' => $this->client instanceof SerializableClient ? $this->client->serializeToArray() : null, + '__metadata' => count($this->metadata) > 0 ? $this->metadata : null, ]); } } \ No newline at end of file diff --git a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php index 5e05d73..6bafdc6 100644 --- a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php +++ b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php @@ -43,25 +43,41 @@ function bindingFiles(): array expect($imports)->toBe($expected); })->with([ 'OperationClient' => ['OperationClient', [ - './lib/types' => ['values' => [], 'types' => ['Result', 'WithClientDirectives']], + './lib/types' => ['values' => [], 'types' => ['Result']], ]], 'DefaultClient' => ['DefaultClient', [ './lib/OperationClient' => ['values' => [], 'types' => ['OperationClient', 'OperationOptions']], - './lib/types' => ['values' => [], 'types' => ['Failure', 'Result', 'Success', 'WithClientDirectives']], + './lib/types' => ['values' => [], 'types' => ['Failure', 'Result', 'Success']], ]], 'OperationException' => ['OperationException', [ './lib/types' => ['values' => [], 'types' => ['Failure']], ]], - // DefaultClient and OperationException are constructed, so they are value imports; a type only - // import of either would leave `new DefaultClient(...)` referencing nothing at runtime. + // DefaultClient is constructed, so it is a value import; a type only import would leave + // `new DefaultClient(...)` referencing nothing at runtime. 'bindings' => ['bindings', [ './lib/DefaultClient' => ['values' => ['DefaultClient'], 'types' => []], './lib/OperationClient' => ['values' => [], 'types' => ['OperationClient', 'OperationOptions']], - './lib/OperationException' => ['values' => ['OperationException'], 'types' => []], - './lib/types' => ['values' => [], 'types' => ['Result', 'Success', 'WithClientDirectives']], + './lib/types' => ['values' => [], 'types' => ['Result']], ]], ]); +/** + * The transport moves a request and returns the envelope. Whatever a Client implementation puts next + * to the data is that implementation's business, and naming it here would make the interface an + * accomplice to one particular schema. + */ +test('the transport knows nothing about client directives', function () { + foreach (bindingFiles() as $file) { + expect($file->toString()) + ->not->toContain('WithClientDirectives') + ->not->toContain('__client'); + } +}); + +test('throwOnFailure is not here — it narrows the envelope, not the transport', function () { + expect(bindingFiles()['bindings']->toString())->not->toContain('throwOnFailure'); +}); + test('imports nothing its body does not reference', function () { $unused = []; foreach (bindingFiles() as $name => $file) { diff --git a/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php new file mode 100644 index 0000000..b1a07c9 --- /dev/null +++ b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php @@ -0,0 +1,88 @@ + + */ +function spaClientFiles(): array +{ + return new EmitOperationsSpaClient()->emitFiles( + [], + new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + new AliasRegistry(), + ); +} + +test('emits one file, named the way a module at the output root reaches it', function () { + expect(array_keys(spaClientFiles()))->toBe(['client-operations-spa']); + + expect(new EmitOperationsSpaClient()->importFromOperationsSpaClient(values: ['containsOperationSpaPayload'])) + ->toEqual(TypescriptImport::values('./lib/client-operations-spa', 'containsOperationSpaPayload')); +}); + +test('the payload type mirrors what OperationSPAClient serializes', function () { + $toastTypes = implode('|', array_map( + fn(ToastType $type): string => "'{$type->value}'", + ToastType::cases(), + )); + + // Every key is optional except the discriminator: OperationSPAClient only writes a key when + // something called for it, and returns null when nothing did. + expect(spaClientFiles()['client-operations-spa']->toString()) + ->toContain("export type ClientToast = {type: {$toastTypes}; message: string;};") + ->toContain('export type ClientRedirect = {url: string; reload: boolean;};') + ->toContain('export type ClientInvalidation = [string, ...unknown[]];') + ->toContain('export type OperationsClientPayload = {') + ->toContain('type: "operations-spa";') + ->toContain('redirect?: ClientRedirect;') + ->toContain('toasts?: ClientToast[];') + ->toContain('invalidations?: ClientInvalidation[];'); +}); + +test('an invalidation is a namespace followed by any number of keys, matching queryKey and PHP', function () { + // Client::invalidate($namespace) emits a single element array, so requiring a second + // string would describe a payload the server never produces. + expect(spaClientFiles()['client-operations-spa']->toString()) + ->not->toContain('[string, string, ...unknown[]]'); +}); + +test('the guard narrows on the discriminator alone', function () { + // The payload is written in one pass, so a server that wrote the discriminator wrote the rest + // of it. Walking every directive to prove that buys nothing at the boundary. + expect(spaClientFiles()['client-operations-spa']->toString()) + ->toContain('export function containsOperationSpaPayload(value: T): value is T & {__client: OperationsClientPayload}') + ->toContain("=== 'operations-spa'") + ->not->toContain('isClientToast') + ->not->toContain('isClientRedirect') + ->not->toContain('isArrayOf'); +}); + +test('the toast union is derived from the PHP enum, so it cannot drift', function () { + // Whatever ToastType holds, the emitted union holds — nothing here restates the cases. + $emitted = spaClientFiles()['client-operations-spa']->toString(); + + foreach (ToastType::cases() as $case) { + expect($emitted)->toContain("'{$case->value}'"); + } +}); + +/** + * The module is self contained: it names no type it does not declare, so it survives a run where + * every other generator is switched off. + */ +test('imports nothing', function () { + $file = spaClientFiles()['client-operations-spa']; + + expect($file->imports)->toBe([]) + ->and($file->code)->not->toContain('import '); +}); diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index 890a39b..3b28e22 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\CodeGen; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; @@ -11,7 +12,6 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; @@ -32,10 +32,13 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, $registry); $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); - // The directive types it narrows to are declared by EmitTypes, so the dependency is wired up - // the way the generator does it. + // The envelope it narrows and the exception it throws are declared elsewhere, so the + // dependencies are wired up the way the generator does it. $emitter = new EmitTypeUtils(); - $emitter->setDependencies([EmitTypes::class => new EmitTypes()]); + $emitter->setDependencies([ + EmitTypes::class => new EmitTypes(), + EmitOperationClientBindings::class => new EmitOperationClientBindings(), + ]); $files = $emitter->emitFiles( [new TypedOperation($input, $output, Typescript::fromRawString(''), $operation)], @@ -50,35 +53,31 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp expect(emitUtilsFor(namespace: 'orders'))->toContain("type QueryNamespaces = 'orders';"); }); -test('the toast type list the guard checks against is derived from the PHP enum', function () { - $utils = emitUtilsFor(); - - $cases = implode(', ', array_map( - fn(ToastType $type): string => "'{$type->value}'", - ToastType::cases(), - )); - - expect($utils)->toContain("const TOAST_TYPES = [{$cases}] as const;"); +test('throwOnFailure lives next to queryKey, not in the transport bindings', function () { + // It narrows the envelope; it knows nothing about how a request was made. Keeping it here means + // a project generating no transport bindings at all still gets it. + expect(emitUtilsFor()) + ->toContain('export function throwOnFailure(result: Result): asserts result is Success') + ->toContain('throw new OperationException(result);'); }); -test('the directive guard verifies every directive it narrows, not just the discriminator', function () { - $utils = emitUtilsFor(); - - // A guard that only checks __client.type would happily narrow a payload from a server - // still emitting the old {type: 'soft'|'hard'} redirect. - expect($utils) - ->toContain('export function isClientRedirect(value: unknown): value is ClientRedirect') - ->toContain('export function isClientToast(value: unknown): value is ClientToast') - ->toContain("typeof redirect.reload === 'boolean'") - ->toContain('isClientRedirect(directives.redirect)') - ->toContain('isArrayOf(directives.toasts, isClientToast)') - ->toContain('isArrayOf(directives.invalidations, isClientInvalidation)'); +test('the utils carry no knowledge of any specific client implementation', function () { + // Directive guards belong to the client that emits the directives, not to the shared utils. + expect(emitUtilsFor()) + ->not->toContain('__client') + ->not->toContain('operations-spa') + ->not->toContain('ClientToast') + ->not->toContain('ClientRedirect'); }); -test('the guard imports the named directive types instead of restating their shape', function () { - // './lib/types' is what an emitter writes — the way a module at the output root reaches the - // types file. utils.ts lands inside lib/ and reaches it as './types', which the orchestrator - // resolves; that form is pinned in TypescriptServerCodeGeneratorTest. +test('imports the envelope as types and the exception as a value', function () { + // './lib/x' is what an emitter writes — the way a module at the output root reaches it. utils.ts + // lands inside lib/ and reaches a sibling directly, which the orchestrator resolves; that form + // is pinned in TypescriptServerCodeGeneratorTest. + // + // OperationException is constructed, so a type only import would leave `new OperationException(...)` + // referencing nothing at runtime. expect(emitUtilsFor()) - ->toContain("import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from './lib/types';"); + ->toContain("import {OperationException} from './lib/OperationException';") + ->toContain("import type {Result, Success} from './lib/types';"); }); diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 01ad790..86edab3 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -10,7 +10,6 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; @@ -57,40 +56,25 @@ function emitTypesFor(string $inputType, string $outputType): string })->with([ 'the Brand helper generic' => ['Brand'], 'the Result envelope' => ['Result'], - 'the client directive wrapper' => ['WithClientDirectives'], - 'the SPA client directives' => ['SPAClientDirectives'], - 'the directive payload' => ['ClientDirectives'], - 'the toast directive' => ['ClientToast'], - 'the redirect directive' => ['ClientRedirect'], - 'the invalidation directive' => ['ClientInvalidation'], + 'the success branch' => ['Success'], + 'the failure branch' => ['Failure'], + 'the namespace union' => ['OperationNamespaces'], ]); -test('the SPA client directives mirror the PHP client contract', function () { +test('the envelope commits to nothing a specific client puts next to the data', function () { $types = emitTypesFor( 'array{id: \\' . UserId::class . '}', 'array{email: \\' . Email::class . '}', ); - $toastTypes = implode('|', array_map( - fn(ToastType $type): string => "'{$type->value}'", - ToastType::cases(), - )); - + // Client is an extension point: a directive payload belongs to the implementation that emits + // it, which for the one this library ships is lib/client-operations-spa.ts. expect($types) - ->toContain("export type ClientToast = {type: {$toastTypes}; message: string;};") - ->toContain('export type ClientRedirect = {url: string; reload: boolean;};') - ->toContain('export type ClientInvalidation = [string, ...unknown[]];') - ->toContain('export type SPAClientDirectives = T & {__client: ClientDirectives};') - ->not->toContain('"soft"|"hard"') - ->not->toContain('hardRedirect'); -}); - -test('an invalidation is a namespace followed by any number of keys, matching queryKey and PHP', function () { - $types = emitTypesFor('array{id: string}', 'array{id: string}'); - - // Client::invalidate($namespace) emits a single element array, so requiring a second - // string would describe a payload the server never produces. - expect($types)->not->toContain('[string, string, ...unknown[]]'); + ->toContain('export type Result = Success | Failure;') + ->not->toContain('__client') + ->not->toContain('operations-spa') + ->not->toContain('WithClientDirectives') + ->not->toContain('ClientToast'); }); test('attribute brands stay inline and declare no alias, only the Brand helper is exported', function () { diff --git a/tests/Unit/CodeGen/TsOutputFixture.php b/tests/Unit/CodeGen/TsOutputFixture.php index da6825f..fea41f6 100644 --- a/tests/Unit/CodeGen/TsOutputFixture.php +++ b/tests/Unit/CodeGen/TsOutputFixture.php @@ -4,6 +4,7 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationsSpaClient; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTanstackQuery; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeMap; @@ -58,6 +59,7 @@ public static function generate(): array new EmitTypes(), new EmitOperationClientBindings(), new EmitTypeUtils(), + new EmitOperationsSpaClient(), new EmitOperations(), new EmitTypeMap(), new EmitTanstackQuery(), diff --git a/tests/Unit/CodeGen/TsOutputFixtureTest.php b/tests/Unit/CodeGen/TsOutputFixtureTest.php index 784df9a..f25045f 100644 --- a/tests/Unit/CodeGen/TsOutputFixtureTest.php +++ b/tests/Unit/CodeGen/TsOutputFixtureTest.php @@ -35,6 +35,7 @@ ->toContain('lib/OperationException.ts') ->toContain('lib/bindings.ts') ->toContain('lib/utils.ts') + ->toContain('lib/client-operations-spa.ts') ->toContain('lib/type-map.ts') // One module per namespace, so cross-module imports of the shared aliases are compiled too. ->toContain('accounts.ts') diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 5c49896..bfab2c6 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -4,6 +4,7 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationsSpaClient; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTanstackQuery; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeMap; @@ -109,14 +110,14 @@ function generateFor(array $classes, ?array $generators = null): array ])['orders.ts']->toString(); // Modules are sorted by specifier and each appears exactly once, however the generators ran: - // bindings collects executeOperation and throwOnFailure, utils' queryKey is claimed twice and - // deduped, and the aliases come from both EmitOperations and EmitQueryKey. Type only exports - // are on their own line, which is what verbatimModuleSyntax requires. + // utils collects queryKey — claimed twice and deduped — alongside throwOnFailure, and the + // aliases come from both EmitOperations and EmitQueryKey. Type only exports are on their own + // line, which is what verbatimModuleSyntax requires. expect($operations)->toStartWith(TypescriptFile::MARKER . "\n\n" . <<toString())->toStartWith(TypescriptFile::MARKER . "\n\n" . <<toString())->toStartWith( - TypescriptFile::MARKER . "\n\n" - . "import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from './types';" - ); + expect($files['lib/utils.ts']->toString())->toStartWith(TypescriptFile::MARKER . "\n\n" . <<toString())->not->toContain('import '); }); +test('the operations-spa client is one self-contained file, and nothing else mentions it', function () { + $files = generateFor([NamedOperations::class], [ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperationsSpaClient(), + new EmitOperations(), + new EmitTypeMap(), + new EmitQueryKey(), + new EmitTanstackQuery(), + ]); + + // Dropping the generator drops the directive support and nothing else, which is what makes + // `--without operations-spa` a real option rather than a broken build. + expect($files)->toHaveKey('lib/client-operations-spa.ts') + ->and($files['lib/client-operations-spa.ts']->toString()) + ->toContain('export function containsOperationSpaPayload') + ->not->toContain('import '); + + foreach ($files as $path => $file) { + if ($path === 'lib/client-operations-spa.ts') { + continue; + } + + expect($file->toString())->not->toContain('client-operations-spa'); + } +}); + test('no lib file names a module through lib/', function () { $files = generateFor([NamedOperations::class], [ new EmitTypes(), diff --git a/tests/ts-output/generated/accounts.ts b/tests/ts-output/generated/accounts.ts index a7d889c..c4f427d 100644 --- a/tests/ts-output/generated/accounts.ts +++ b/tests/ts-output/generated/accounts.ts @@ -1,9 +1,9 @@ // generated by: php-ts-bindings import type {OperationOptions} from './lib/OperationClient'; -import {executeOperation, throwOnFailure} from './lib/bindings'; +import {executeOperation} from './lib/bindings'; import type {Availability, Brand} from './lib/types'; -import {queryKey} from './lib/utils'; +import {queryKey, throwOnFailure} from './lib/utils'; import type {UseQueryOptions} from '@tanstack/react-query'; import {queryOptions, useQuery} from '@tanstack/react-query'; diff --git a/tests/ts-output/generated/catalog.ts b/tests/ts-output/generated/catalog.ts index 1eb2b21..3718373 100644 --- a/tests/ts-output/generated/catalog.ts +++ b/tests/ts-output/generated/catalog.ts @@ -1,9 +1,9 @@ // generated by: php-ts-bindings import type {OperationOptions} from './lib/OperationClient'; -import {executeOperation, throwOnFailure} from './lib/bindings'; +import {executeOperation} from './lib/bindings'; import type {Availability, Brand, Draft, DraftInput, Money, Product, Sku} from './lib/types'; -import {queryKey} from './lib/utils'; +import {queryKey, throwOnFailure} from './lib/utils'; import type {UseQueryOptions} from '@tanstack/react-query'; import {queryOptions, useQuery} from '@tanstack/react-query'; diff --git a/tests/ts-output/generated/lib/DefaultClient.ts b/tests/ts-output/generated/lib/DefaultClient.ts index 0538b90..7a04d90 100644 --- a/tests/ts-output/generated/lib/DefaultClient.ts +++ b/tests/ts-output/generated/lib/DefaultClient.ts @@ -1,9 +1,9 @@ // generated by: php-ts-bindings import type {OperationClient, OperationOptions} from './OperationClient'; -import type {Failure, Result, Success, WithClientDirectives} from './types'; +import type {Failure, Result, Success} from './types'; -export type Hook = (result: WithClientDirectives>) => Promise | void; +export type Hook = (result: Result) => Promise | void; export class DefaultClient implements OperationClient { @@ -40,7 +40,7 @@ export class DefaultClient implements OperationClient { }).join('&'); } - private async callHooks>(result: WithClientDirectives) { + private async callHooks>(result: T) { try { await Promise.all(this.hooks.map(hook => hook(result))); return result; @@ -50,7 +50,7 @@ export class DefaultClient implements OperationClient { } } - async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise>> { + async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{fqn}', key)}`; @@ -87,8 +87,10 @@ export class DefaultClient implements OperationClient { throw new Error('Invalid response body. Could not parse json correctly.'); } + // Spread first: whatever the server put next to the envelope — a client's directives, say — + // rides along untyped rather than being dropped by a transport that never knew about it. if (response.ok) { - return await this.callHooks({...json, success: true} as WithClientDirectives>); + return await this.callHooks({...json, success: true} as Success); } return await this.callHooks({ @@ -96,7 +98,7 @@ export class DefaultClient implements OperationClient { success: false, code: json?.code ?? response.status, type: json?.type ?? 'INTERNAL_ERROR' - } as WithClientDirectives>); + } as Failure); } registerHook(hook: Hook): () => void { diff --git a/tests/ts-output/generated/lib/OperationClient.ts b/tests/ts-output/generated/lib/OperationClient.ts index 6560b05..077d1ed 100644 --- a/tests/ts-output/generated/lib/OperationClient.ts +++ b/tests/ts-output/generated/lib/OperationClient.ts @@ -1,14 +1,19 @@ // generated by: php-ts-bindings -import type {Result, WithClientDirectives} from './types'; +import type {Result} from './types'; export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; +/** + * Moves a request and resolves to the envelope. A server may put more next to the data, and it + * travels through untouched — describing it here would tie every transport to one Client + * implementation's schema. Reach for the guard the implementation ships instead. + */ export interface OperationClient { execute( - type: "command"|"query", - key: string, - input: unknown, + type: "command"|"query", + key: string, + input: unknown, options?: OperationOptions - ): Promise>>; + ): Promise>; } diff --git a/tests/ts-output/generated/lib/bindings.ts b/tests/ts-output/generated/lib/bindings.ts index b2e4398..90c202a 100644 --- a/tests/ts-output/generated/lib/bindings.ts +++ b/tests/ts-output/generated/lib/bindings.ts @@ -2,8 +2,7 @@ import {DefaultClient} from './DefaultClient'; import type {OperationClient, OperationOptions} from './OperationClient'; -import {OperationException} from './OperationException'; -import type {Result, Success, WithClientDirectives} from './types'; +import type {Result} from './types'; let client: OperationClient|null; @@ -22,21 +21,7 @@ export function setClient(operationClient: OperationClient|null): void { client = operationClient; } -/** - * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather - * catch than branch. - * - * The error union is deliberately not inferred here: a catch clause variable is `unknown` in - * TypeScript whatever was thrown, so no signature on this function could carry E to the catch. - * Name it there instead - `OperationException.is(e)` types `e.cause` for you. - */ -export function throwOnFailure(result: Result): asserts result is Success { - if (!result.success) { - throw new OperationException(result); - } -} - -export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise>> { +export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise> { if (options?.client) { return await options.client.execute(type, key, input, options); } diff --git a/tests/ts-output/generated/lib/client-operations-spa.ts b/tests/ts-output/generated/lib/client-operations-spa.ts new file mode 100644 index 0000000..89a7efd --- /dev/null +++ b/tests/ts-output/generated/lib/client-operations-spa.ts @@ -0,0 +1,36 @@ +// generated by: php-ts-bindings + +export type ClientToast = {type: 'success'|'error'|'warning'|'alert'|'info'; message: string;}; +export type ClientRedirect = {url: string; reload: boolean;}; +export type ClientInvalidation = [string, ...unknown[]]; + +/** + * What OperationSPAClient writes into `__client`. Every directive is optional — a key is only + * present when a handler called for it — and the discriminator is not, which is what makes the + * payload recognisable among whatever else a `__client` key might hold. + */ +export type OperationsClientPayload = { + type: "operations-spa"; + redirect?: ClientRedirect; + toasts?: ClientToast[]; + invalidations?: ClientInvalidation[]; +}; + +/** + * Narrows a response to one carrying this client's directives. + * + * The discriminator is the whole check: the payload is assembled in one pass by + * serializeToArray(), so a server that wrote `type` wrote the rest of it to the same schema. + * Re-verifying each directive here would only describe the same server twice, and unknown keys + * are ignored either way, so adding a directive stays backwards compatible. + */ +export function containsOperationSpaPayload(value: T): value is T & {__client: OperationsClientPayload} { + if (!value || typeof value !== 'object' || !('__client' in value)) { + return false; + } + + const payload = value.__client; + return !!payload + && typeof payload === 'object' + && (payload as Partial).type === 'operations-spa'; +} diff --git a/tests/ts-output/generated/lib/types.ts b/tests/ts-output/generated/lib/types.ts index a0d3d7a..89971f0 100644 --- a/tests/ts-output/generated/lib/types.ts +++ b/tests/ts-output/generated/lib/types.ts @@ -5,17 +5,6 @@ export type OperationNamespaces = 'accounts'|'catalog'|'shapes'; export type Success = {success: true, data: T} export type Failure = {success: false} & E; export type Result = Success | Failure; -export type ClientToast = {type: 'success'|'error'|'warning'|'alert'|'info'; message: string;}; -export type ClientRedirect = {url: string; reload: boolean;}; -export type ClientInvalidation = [string, ...unknown[]]; -export type ClientDirectives = { - type: "operations-spa"; - redirect?: ClientRedirect; - toasts?: ClientToast[]; - invalidations?: ClientInvalidation[]; -}; -export type WithClientDirectives = T & {__client?: unknown} -export type SPAClientDirectives = T & {__client: ClientDirectives}; declare const __brand: unique symbol; export type Brand = {readonly [__brand]: TBrand;}; diff --git a/tests/ts-output/generated/lib/utils.ts b/tests/ts-output/generated/lib/utils.ts index f00f1ee..69f0aba 100644 --- a/tests/ts-output/generated/lib/utils.ts +++ b/tests/ts-output/generated/lib/utils.ts @@ -1,59 +1,24 @@ // generated by: php-ts-bindings -import type {ClientDirectives, ClientRedirect, ClientToast, SPAClientDirectives, WithClientDirectives} from './types'; +import {OperationException} from './OperationException'; +import type {Result, Success} from './types'; type QueryNamespaces = 'accounts'|'catalog'|'shapes'; -const TOAST_TYPES = ['success', 'error', 'warning', 'alert', 'info'] as const; - export function queryKey(ns: QueryNamespaces, ...args: unknown[]): [string, ...unknown[]] { return [ns, ...args]; } -function isArrayOf(value: unknown, predicate: (item: unknown) => item is V): value is V[] { - return Array.isArray(value) && value.every(predicate); -} - -export function isClientToast(value: unknown): value is ClientToast { - if (!value || typeof value !== 'object') { - return false; - } - - const toast = value as Partial; - return typeof toast.message === 'string' - && typeof toast.type === 'string' - && (TOAST_TYPES as readonly string[]).includes(toast.type); -} - -export function isClientRedirect(value: unknown): value is ClientRedirect { - if (!value || typeof value !== 'object') { - return false; - } - - const redirect = value as Partial; - return typeof redirect.url === 'string' && typeof redirect.reload === 'boolean'; -} - -function isClientInvalidation(value: unknown): value is [string, ...unknown[]] { - return Array.isArray(value) && typeof value[0] === 'string'; -} - /** - * Narrows to the full directive payload, so it verifies every directive it claims and not just - * the discriminator: a server on an older format would otherwise be narrowed to a shape it does - * not have. Unknown directive keys are ignored, adding one stays backwards compatible. + * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather + * catch than branch. + * + * The error union is deliberately not inferred here: a catch clause variable is `unknown` in + * TypeScript whatever was thrown, so no signature on this function could carry E to the catch. + * Name it there instead - `OperationException.is(e)` types `e.cause` for you. */ -export function isSpaClientDirectives(result: WithClientDirectives): result is SPAClientDirectives { - if (!result.__client || typeof result.__client !== 'object') { - return false; - } - - const directives = result.__client as Partial; - if (directives.type !== 'operations-spa') { - return false; +export function throwOnFailure(result: Result): asserts result is Success { + if (!result.success) { + throw new OperationException(result); } - - return (directives.redirect === undefined || isClientRedirect(directives.redirect)) - && (directives.toasts === undefined || isArrayOf(directives.toasts, isClientToast)) - && (directives.invalidations === undefined || isArrayOf(directives.invalidations, isClientInvalidation)); } diff --git a/tests/ts-output/generated/shapes.ts b/tests/ts-output/generated/shapes.ts index 0a09881..61f882d 100644 --- a/tests/ts-output/generated/shapes.ts +++ b/tests/ts-output/generated/shapes.ts @@ -1,9 +1,9 @@ // generated by: php-ts-bindings import type {OperationOptions} from './lib/OperationClient'; -import {executeOperation, throwOnFailure} from './lib/bindings'; +import {executeOperation} from './lib/bindings'; import type {Availability, Brand, Money, Product, Sku} from './lib/types'; -import {queryKey} from './lib/utils'; +import {queryKey, throwOnFailure} from './lib/utils'; import type {UseQueryOptions} from '@tanstack/react-query'; import {queryOptions, useQuery} from '@tanstack/react-query'; diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index be78b0d..eae2923 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -8,11 +8,13 @@ import {find, lock} from '../generated/accounts'; import type {ProductError} from '../generated/catalog'; import {prepare, product, productQueryKey, productQueryOptions, restock, search, useProductQuery} from '../generated/catalog'; -import {createDefaultClient, setClient, throwOnFailure} from '../generated/lib/bindings'; +import {createDefaultClient, setClient} from '../generated/lib/bindings'; +import type {OperationsClientPayload} from '../generated/lib/client-operations-spa'; +import {containsOperationSpaPayload} from '../generated/lib/client-operations-spa'; import {OperationException} from '../generated/lib/OperationException'; -import type {Brand, Product, SPAClientDirectives} from '../generated/lib/types'; +import type {Brand, Product} from '../generated/lib/types'; import type {TypeMap} from '../generated/lib/type-map'; -import {isClientRedirect, isClientToast, isSpaClientDirectives} from '../generated/lib/utils'; +import {throwOnFailure} from '../generated/lib/utils'; import {defaults, submit, useDefaultsQuery} from '../generated/shapes'; setClient(createDefaultClient(fetch)); @@ -110,9 +112,11 @@ export async function readDefaults(): Promise { } /** - * Commands go over POST and can carry client directives back, which the emitted guards narrow. + * Commands go over POST and can carry client directives back. The envelope says nothing about + * `__client` — the transport never committed to a schema — so one guard from the client that emits + * the payload is what puts it on the result, fully typed, for the rest of the function. */ -export async function lockAccount(id: number): Promise | null> { +export async function lockAccount(id: number): Promise { const result = await lock({id}); if (!result.success && result.code === 400) { @@ -122,21 +126,24 @@ export async function lockAccount(id: number): Promise Date: Wed, 5 Aug 2026 15:23:16 +0200 Subject: [PATCH 061/101] Remove `LocalMetadataMiddleware` and refactor error handling logic for consistent `details` handling. - Eliminated `LocalMetadataMiddleware` entirely. - Updated error branches across generated TypeScript output to exclude redundant `details` where the category alone conclusively describes the error. - Standardized error serialization for `RpcError` by omitting `details` for categories like `NOT_FOUND` and `INTERNAL_ERROR`. - Enhanced debug mode to include full throwable chains (`previous` key) for rare multi-failure scenarios. - Refined test cases to validate the absence of `details` when unnecessary and the inclusion of `previous` information in debug responses. - Aligned README and documentation to reflect changes in error-handling and serialization behavior. --- README.md | 54 ++++---- docs/laravel.md | 14 +- .../Laravel/LaravelHttpController.php | 54 +++++--- .../Laravel/LaravelServiceProvider.php | 7 - .../Middleware/LocalMetadataMiddleware.php | 60 --------- src/CodeGen/Utils/ErrorTypescript.php | 25 ++-- src/Contracts/RpcResult.php | 4 + src/Server/Data/RpcError.php | 45 +++++-- src/Server/Data/RpcSuccess.php | 14 +- src/Server/Errors/ErrorPresenter.php | 36 +++-- src/Server/Pipeline/ContextualPipeline.php | 5 +- .../Laravel/LaravelHttpControllerTest.php | 123 +++++++++++++++++- tests/Feature/ServerTest.php | 10 +- tests/Unit/CodeGen/ErrorTypescriptTest.php | 12 +- .../Unit/Server/Errors/ErrorPresenterTest.php | 43 ++++-- .../Pipeline/ContextualPipelineTest.php | 5 +- tests/ts-output/generated/accounts.ts | 6 +- tests/ts-output/generated/catalog.ts | 8 +- tests/ts-output/generated/lib/type-map.ts | 2 +- tests/ts-output/generated/shapes.ts | 6 +- tests/ts-output/src/usage.ts | 5 + 21 files changed, 351 insertions(+), 187 deletions(-) delete mode 100644 src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php diff --git a/README.md b/README.md index 904858e..37a74cc 100644 --- a/README.md +++ b/README.md @@ -494,15 +494,22 @@ nothing gets: ```typescript export type CreateError = - {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}} - | {code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}} - | {code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; + {code: 422, type: "INVALID_INPUT", details: {fields: Record}} + | {code: 404, type: "NOT_FOUND"} + | {code: 500, type: "INTERNAL_ERROR"}; ``` The 401 and 403 branches appear only once you have actually mapped exceptions onto them, so the -union describes what this server can really produce. Validation failures carry `fields`, keyed by -dotted path (`__root` for the top level) with localization keys as values, e.g. -`{"email": ["validation.not_empty_string"]}`. +union describes what this server can really produce. + +**`details` only appears where the category cannot say everything on its own**, which is exactly two +of the six: `INVALID_INPUT` carries `fields`, and `DOMAIN_ERROR` carries the `type` naming which +domain error it is. For the other four, `code` and `type` are the whole answer and restating it +under `details` would put the same string on the wire twice, so the key is absent — and the +generated branch has no such property, so narrowing on `type` will not offer you one. + +Validation failures carry `fields`, keyed by dotted path (`__root` for the top level) with +localization keys as values, e.g. `{"email": ["validation.not_empty_string"]}`. **Your own validation can ride the same wire.** This library proves types and refuses to grow into a validator, but the 422 shape is a perfectly good transport for the rules it will not check for you: @@ -518,7 +525,6 @@ enough — one GET for queries, one POST for commands — and both must carry th ```php use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; use Le0daniel\PhpTsBindings\Server\Client\NullClient; -use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; @@ -540,24 +546,28 @@ $server = new Server( $result = $server->command('users.create', $input, $myContext, new NullClient()); -if ($result instanceof RpcSuccess) { - respondJson(200, ['success' => true, 'data' => $result->data]); -} else { - respondJson($result->type->value, [ - 'success' => false, - 'code' => $result->type->value, - 'type' => $result->type->name, - 'details' => $result->details, - ]); -} +// jsonSerialize() is the envelope the generated client reads, and the only thing that gets it +// exactly right: `details` is omitted rather than sent as null on the categories that have none, +// which is what the generated union declares. +respondJson($result->statusCode, $result->jsonSerialize()); ``` -`ErrorType` doubles as the status code: `$result->type->value` is the HTTP code and -`$result->type->name` the string the client matches on, so the two cannot disagree. +`$result->statusCode` is the HTTP code for both outcomes — 200 on success, and the error category's +own code otherwise. `ErrorType` doubles as that code, so `$result->type->value` is the same number +and `$result->type->name` the string the client matches on: the two cannot disagree. -`$result->cause` is the underlying `Throwable` on every error, ready to hand to your reporter. On the -rare occasion that working out how to present an error *itself* failed — a stale middleware class -name, say — `$result->presentationFailure` holds that second exception; it is null otherwise. +`$result->cause` is the most recent `Throwable` on every error, ready to hand to your reporter, and +`$result->previous` is a list of everything that failed before it, oldest first. It is empty on an +ordinary error. On the rare occasion that working out how to present an error *itself* failed — a +stale middleware class name, say — that second exception is the `cause`, and the one your +application threw is in `previous`. `$result->throwableChain()` gives you all of them in order, which +is what you want to loop over when reporting: + +```php +foreach ($result->throwableChain() as $throwable) { + $reporter->report($throwable); +} +``` Whatever your transport does with the input, the shape it hands the server has to match what the generated client sends: for queries, each value JSON-encoded into its own query parameter; for diff --git a/docs/laravel.md b/docs/laravel.md index 6d3b1e0..219b477 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -163,12 +163,13 @@ Everything the provider and the HTTP controller pick without asking. - `__client` is added when the client produced directives, `__metadata` when a middleware attached any. - **Exception rendering bypasses Laravel entirely.** Nothing is thrown out of the controller. Every - `RpcError` is handed to `ExceptionHandler::report()` — so logging, Sentry and friends still fire — - and then serialized by hand. Laravel's `render()`, `renderable()` handlers, `abort()` pages and the - 419 CSRF redirect never run for an operation. + throwable behind an `RpcError` is handed to `ExceptionHandler::report()` — the cause, plus anything + in `previous`, oldest first — so logging, Sentry and friends still fire, and then it is serialized + by hand. Laravel's `render()`, `renderable()` handlers, `abort()` pages and the 419 CSRF redirect + never run for an operation. - **Validation errors are not Laravel's shape.** A 422 carries - `{"type": "INVALID_INPUT", "fields": {"": ["message"]}}`, not - `{"message": …, "errors": …}`. `Illuminate\Validation\ValidationException` is **not** mapped by + `{"code": 422, "type": "INVALID_INPUT", "details": {"fields": {"": ["message"]}}}`, + not `{"message": …, "errors": …}`. `Illuminate\Validation\ValidationException` is **not** mapped by default, so a `$request->validate()` inside a handler lands in a 500. Map it onto a category yourself, or throw [`InvalidInputException::createFromMessages()`](../README.md#errors) instead. @@ -187,7 +188,8 @@ HTML redirect. > **`app.debug` changes behaviour.** With it on, `LocalMetadataMiddleware` is prepended to every > operation and responses gain a `__metadata` key with **the raw input**, the context class, the > handler, the middleware stack and per-middleware timings. Failures additionally carry `__info` and -> `__debug` with the exception class, message, file, line and **full stack trace**. +> `__debug` with the exception class, message, file, line and **full stack trace** — and a `previous` +> list describing the earlier failures, on the rare error that has any. > > None of it is emitted in production, and none of it appears in the generated types — but any > environment with `APP_DEBUG=true` and a reachable route is handing all of that to whoever calls it. diff --git a/src/Adapters/Laravel/LaravelHttpController.php b/src/Adapters/Laravel/LaravelHttpController.php index 1c7e6b5..5cb83fc 100644 --- a/src/Adapters/Laravel/LaravelHttpController.php +++ b/src/Adapters/Laravel/LaravelHttpController.php @@ -10,13 +10,11 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\RpcResult; -use Le0daniel\PhpTsBindings\Contracts\SerializableClient; use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Client\OperationSPAClient; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidOutputException; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\RpcError; -use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Utils\Dicts; use Throwable; @@ -81,7 +79,12 @@ public function handleHttpCommandRequest(string $fqn, Http\Request $request): Js private function reportExceptions(RpcResult $result): RpcResult { if ($result instanceof RpcError) { - $this->exceptionHandler->report($result->cause); + // The whole chain, oldest first. On an ordinary error that is the one exception the + // application threw; when presenting it failed too, reporting only the cause would + // hide the failure that actually needs fixing. + foreach ($result->throwableChain() as $throwable) { + $this->exceptionHandler->report($throwable); + } } return $result; } @@ -122,17 +125,32 @@ private function gatherInputFromRequest(OperationType $type, Http\Request $reque return empty($inputData) ? null : $inputData; } - private function produceJsonResponse(RpcResult $result): JsonResponse + /** + * getTraceAsString() rather than getTrace(): the latter carries the actual call arguments, and + * a pure enum among them makes json_encode() refuse the whole response - which turned debug + * mode into a 500 of its own. The string form is a full stack trace, always encodable, and does + * not put argument values on the wire. + * + * @return array + */ + private static function describeThrowable(Throwable $throwable): array { - $httpStatusCode = match (true) { - $result instanceof RpcSuccess => 200, - $result instanceof RpcError => $result->type->value, - default => throw new \RuntimeException('Unexpected result type'), - }; + return Dicts::filterNullValues([ + 'class' => $throwable::class, + 'message' => $throwable->getMessage(), + 'code' => $throwable->getCode(), + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + 'trace' => $throwable->getTraceAsString(), + 'issues' => $throwable instanceof InvalidOutputException ? $throwable->issues->serializeToDebugFields() : null, + ]); + } + private function produceJsonResponse(RpcResult $result): JsonResponse + { $jsonResponse = $result->jsonSerialize(); if (!$this->debug) { - return new JsonResponse($jsonResponse, status: $httpStatusCode); + return new JsonResponse($jsonResponse, status: $result->statusCode); } // We append some general debug information @@ -147,21 +165,19 @@ private function produceJsonResponse(RpcResult $result): JsonResponse // We append debug info for failed operations if ($result instanceof RpcError) { - $exception = $result->cause; $jsonResponse['__debug'] = Dicts::filterNullValues([ - 'class' => $exception::class, - 'message' => $exception->getMessage(), - 'code' => $exception->getCode(), - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - 'trace' => $exception->getTrace(), - 'issues' => $exception instanceof InvalidOutputException ? $exception->issues->serializeToDebugFields() : null, + ...self::describeThrowable($result->cause), + // Only ever set when handling one failure produced another, so filterNullValues + // keeps it out of the response on every ordinary error. + 'previous' => count($result->previous) > 0 + ? array_map(self::describeThrowable(...), $result->previous) + : null, ]); } return new JsonResponse( $jsonResponse, - status: $httpStatusCode + status: $result->statusCode ); } } \ No newline at end of file diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 95c71f5..45f879a 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -2,7 +2,6 @@ namespace Le0daniel\PhpTsBindings\Adapters\Laravel; -use Illuminate\Config\Repository; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Support\DeferrableProvider; @@ -11,7 +10,6 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\CodeGenCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\ListCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\OptimizeCommand; -use Le0daniel\PhpTsBindings\Adapters\Laravel\Middleware\LocalMetadataMiddleware; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; @@ -97,13 +95,8 @@ public static function serverFactory( self::keyGeneratorFrom($app), ); - $isDebuggingEnabled = $config->get('app.debug', false); - /** @var list> $middlewares */ $middlewares = $config->get('operations.middleware', []) |> array_values(...); - if ($isDebuggingEnabled) { - array_unshift($middlewares, LocalMetadataMiddleware::class); - } return new Server( registry: $operations, diff --git a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php deleted file mode 100644 index c4da4cb..0000000 --- a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -final readonly class LocalMetadataMiddleware implements MiddlewareContract -{ - public function __construct( - #[Config('app.debug')] private bool $isDebuggingEnabled - ) - { - } - - #[Override] - public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError - { - if (!$this->isDebuggingEnabled) { - return $next($input); - } - - $startTime = microtime(true); - $result = $next($input); - $durationMs = (int)ceil((microtime(true) - $startTime) * 1000); - - return $result->appendMetadata([ - 'fullyQualifiedHandler' => "{$info->className}@{$info->methodName}", - 'durationMs' => $durationMs, - 'client' => [ - 'class' => $client::class, - ], - 'info' => [ - 'namespace' => $info->namespace, - 'name' => $info->name, - 'fqn' => $info->fullyQualifiedName, - 'operationType' => $info->operationType->name, - ], - 'handler' => [ - 'className' => $info->className, - 'methodName' => $info->methodName, - ], - 'middleware' => $info->middleware, - 'input' => $input, - 'context' => [ - 'class' => is_object($context) ? get_class($context) : gettype($context), - ], - ]); - } - -} \ No newline at end of file diff --git a/src/CodeGen/Utils/ErrorTypescript.php b/src/CodeGen/Utils/ErrorTypescript.php index df110e9..f44a47d 100644 --- a/src/CodeGen/Utils/ErrorTypescript.php +++ b/src/CodeGen/Utils/ErrorTypescript.php @@ -19,11 +19,7 @@ */ final readonly class ErrorTypescript { - private const string INVALID_INPUT_DETAILS = '{type: "INVALID_INPUT"; fields: Record}'; - private const string UNAUTHENTICATED_DETAILS = '{type: "UNAUTHENTICATED"}'; - private const string UNAUTHORIZED_DETAILS = '{type: "UNAUTHORIZED"}'; - private const string NOT_FOUND_DETAILS = '{type: "NOT_FOUND"}'; - private const string INTERNAL_ERROR_DETAILS = '{type: "INTERNAL_SERVER_ERROR"}'; + private const string INVALID_INPUT_DETAILS = '{fields: Record}'; /** * @throws ReflectionException @@ -35,20 +31,20 @@ public static function forOperation(ServerConfiguration $configuration, Definiti ]; if (!empty($configuration->unauthenticatedExceptions)) { - $branches[] = self::branch(ErrorType::AUTHENTICATION_ERROR, self::UNAUTHENTICATED_DETAILS); + $branches[] = self::branch(ErrorType::AUTHENTICATION_ERROR); } if (!empty($configuration->unauthorizedExceptions)) { - $branches[] = self::branch(ErrorType::AUTHORIZATION_ERROR, self::UNAUTHORIZED_DETAILS); + $branches[] = self::branch(ErrorType::AUTHORIZATION_ERROR); } - $branches[] = self::branch(ErrorType::NOT_FOUND, self::NOT_FOUND_DETAILS); + $branches[] = self::branch(ErrorType::NOT_FOUND); if ($domainDetails = self::domainDetails($configuration, $definition)) { $branches[] = self::branch(ErrorType::DOMAIN_ERROR, $domainDetails); } - $branches[] = self::branch(ErrorType::INTERNAL_ERROR, self::INTERNAL_ERROR_DETAILS); + $branches[] = self::branch(ErrorType::INTERNAL_ERROR); return implode('|', $branches); } @@ -69,9 +65,16 @@ private static function domainDetails(ServerConfiguration $configuration, Defini }, $exposedTypes)); } - private static function branch(ErrorType $type, string $details): string + /** + * No `details` at all where the category is the whole answer: the server omits the key rather + * than restate the type under it, and the branch has to say so or narrowing on `type` would + * hand back a property that is never on the wire. + */ + private static function branch(ErrorType $type, ?string $details = null): string { $name = json_encode($type->name, JSON_THROW_ON_ERROR); - return "{code: {$type->value}, type: {$name}, details: {$details}}"; + return $details === null + ? "{code: {$type->value}, type: {$name}}" + : "{code: {$type->value}, type: {$name}, details: {$details}}"; } } diff --git a/src/Contracts/RpcResult.php b/src/Contracts/RpcResult.php index 2a13ec4..d8a8202 100644 --- a/src/Contracts/RpcResult.php +++ b/src/Contracts/RpcResult.php @@ -14,6 +14,10 @@ */ interface RpcResult extends JsonSerializable { + public int $statusCode { + get; + } + public ResolveInfo|null $resolveInfo { get; } diff --git a/src/Server/Data/RpcError.php b/src/Server/Data/RpcError.php index d43ce24..b7279ad 100644 --- a/src/Server/Data/RpcError.php +++ b/src/Server/Data/RpcError.php @@ -3,33 +3,51 @@ namespace Le0daniel\PhpTsBindings\Server\Data; use Le0daniel\PhpTsBindings\Contracts\RpcResult; +use Le0daniel\PhpTsBindings\Contracts\SerializableClient; use Le0daniel\PhpTsBindings\Utils\Dicts; use NoDiscard; use Override; use Throwable; -final readonly class RpcError implements RpcResult +final class RpcError implements RpcResult { + public int $statusCode { + get => $this->type->value; + } + /** - * @param Throwable $cause The exception the application threw. Always the original, so it can - * be handed straight to a reporter. - * @param Throwable|null $presentationFailure Set only when working out how to present $cause - * itself failed - a stale #[Middleware] class name makes ExposedExceptions throw, for - * instance. When this is non null the category is INTERNAL_ERROR because the catalogue could - * not be consulted, not because $cause deserved a 500. + * @param Throwable $cause The most recent failure - the one that decided this result. On an + * ordinary error that is simply the exception the application threw. + * @param list $previous Everything that failed before $cause, oldest first. Empty on + * every ordinary error, and non empty only when handling one failure produced another: a + * stale #[Middleware] class name makes ExposedExceptions throw while categorising, and the + * result is then an INTERNAL_ERROR because the catalogue could not be consulted, not because + * the original deserved a 500. Reporters want all of them. * @param array $metadata + * @internal Constructed by the server. Applications receive one, they do not build one. */ public function __construct( - public ErrorType $type, - public Throwable $cause, - public mixed $details, - public ?ResolveInfo $resolveInfo, - public array $metadata = [], - public ?Throwable $presentationFailure = null, + public readonly ErrorType $type, + public readonly Throwable $cause, + public readonly mixed $details, + public readonly ?ResolveInfo $resolveInfo, + public readonly array $metadata = [], + public readonly array $previous = [], ) { } + /** + * Every failure that led here, oldest first, with $cause last. + * + * @return non-empty-list + * @api + */ + public function throwableChain(): array + { + return [...$this->previous, $this->cause]; + } + /** * @param array $metadata * @api @@ -59,6 +77,7 @@ public function appendMetadata(array $metadata): self /** * @return array */ + #[Override] public function jsonSerialize(): array { return Dicts::filterNullValues([ diff --git a/src/Server/Data/RpcSuccess.php b/src/Server/Data/RpcSuccess.php index dc22c5a..df4e91e 100644 --- a/src/Server/Data/RpcSuccess.php +++ b/src/Server/Data/RpcSuccess.php @@ -11,6 +11,8 @@ final readonly class RpcSuccess implements RpcResult { + public int $statusCode; + /** * @param array $metadata * @internal @@ -22,6 +24,7 @@ public function __construct( public array $metadata = [], ) { + $this->statusCode = 200; } /** @@ -53,13 +56,18 @@ public function appendMetadata(array $metadata): self /** * @return array */ + #[Override] public function jsonSerialize(): array { - return Dicts::filterNullValues([ - 'success' => true, - 'data' => $this->data, + $metadata = Dicts::filterNullValues([ '__client' => $this->client instanceof SerializableClient ? $this->client->serializeToArray() : null, '__metadata' => count($this->metadata) > 0 ? $this->metadata : null, ]); + + return [ + ...$metadata, + 'success' => true, + 'data' => $this->data, + ]; } } \ No newline at end of file diff --git a/src/Server/Errors/ErrorPresenter.php b/src/Server/Errors/ErrorPresenter.php index a79f0b8..c9c8230 100644 --- a/src/Server/Errors/ErrorPresenter.php +++ b/src/Server/Errors/ErrorPresenter.php @@ -49,59 +49,67 @@ public function present(Throwable $throwable, ?Definition $definition, ?ResolveI // Losing this one is expensive to debug: a stale middleware class name makes // ExposedExceptions throw, and from then on every exception from the operation // degrades to an internal error with no #[Throws] mapping ever applying again, with - // nothing anywhere saying why. $cause stays the exception the application threw; the - // presentation failure rides alongside it for the reporter. - return self::internalError($throwable, $info, $presentationFailure); + // nothing anywhere saying why. It is the most recent failure and the one that decided + // the category, so it is the cause; what the application threw is what came before it. + return self::internalError($presentationFailure, $info, [$throwable]); } } /** * The last resort shape, for when presenting itself fails. + * + * @param list $previous */ public static function internalError( - Throwable $throwable, + Throwable $throwable, ?ResolveInfo $info, - ?Throwable $presentationFailure = null, + array $previous = [], ): RpcError { return new RpcError( ErrorType::INTERNAL_ERROR, $throwable, - ['type' => 'INTERNAL_SERVER_ERROR'], - $info, - presentationFailure: $presentationFailure, + details: null, + resolveInfo: $info, + previous: $previous, ); } /** - * @return array{ErrorType, array} + * `details` carries what the category alone cannot say, and nothing else. Only two categories + * have anything to add: which fields failed validation, and which domain error this is. For the + * rest the category *is* the whole answer, and restating it under `details.type` would be the + * same string twice on the wire - so they get null, and Dicts::filterNullValues() drops the key. + * + * @return array{ErrorType, array|null} */ private function resolve(Throwable $throwable, ?Definition $definition): array { if ($throwable instanceof InvalidInputException) { return [ErrorType::INVALID_INPUT, [ - 'type' => 'INVALID_INPUT', 'fields' => $throwable->failure->issues->serializeToFieldsArray(), ]]; } if ($this->matchesAny($throwable, $this->configuration->unauthenticatedExceptions)) { - return [ErrorType::AUTHENTICATION_ERROR, ['type' => 'UNAUTHENTICATED']]; + return [ErrorType::AUTHENTICATION_ERROR, null]; } if ($this->matchesAny($throwable, $this->configuration->unauthorizedExceptions)) { - return [ErrorType::AUTHORIZATION_ERROR, ['type' => 'UNAUTHORIZED']]; + return [ErrorType::AUTHORIZATION_ERROR, null]; } if ($throwable instanceof OperationNotFoundException || $this->matchesAny($throwable, $this->configuration->notFoundExceptions)) { - return [ErrorType::NOT_FOUND, ['type' => 'NOT_FOUND']]; + return [ErrorType::NOT_FOUND, null]; } + // The one place a `type` under details is not a repeat: the category is DOMAIN_ERROR for + // all of them, and this is which one. if ($definition && $exposedType = $this->exposedTypeOf($throwable, $definition)) { return [ErrorType::DOMAIN_ERROR, ['type' => $exposedType]]; } - return [ErrorType::INTERNAL_ERROR, ['type' => 'INTERNAL_SERVER_ERROR']]; + return [ErrorType::INTERNAL_ERROR, null]; } /** diff --git a/src/Server/Pipeline/ContextualPipeline.php b/src/Server/Pipeline/ContextualPipeline.php index 4941cd7..143e465 100644 --- a/src/Server/Pipeline/ContextualPipeline.php +++ b/src/Server/Pipeline/ContextualPipeline.php @@ -21,7 +21,8 @@ * * The conversion goes through $onError, so failures are presented the same way whether they come * from a middleware or from the operation itself. If $onError fails too there is nobody left to - * ask, so the pipeline falls back to a bare INTERNAL_ERROR rather than letting the request crash. + * ask, so the pipeline falls back to a bare INTERNAL_ERROR rather than letting the request crash - + * carrying the failure it was asked to present in `previous`, so neither of the two is lost. * * @phpstan-import-type Next from MiddlewareContract * @template-contravariant TContext = mixed @@ -83,7 +84,7 @@ private function toRpcError(Throwable $throwable, ResolveInfo $info): RpcError try { return ($this->onError)($throwable); } catch (Throwable $failedToPresent) { - return ErrorPresenter::internalError($failedToPresent, $info); + return ErrorPresenter::internalError($failedToPresent, $info, [$throwable]); } } } diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index cd61c3a..cab923d 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -2,6 +2,7 @@ namespace Tests\Adapters\Laravel; +use Closure; use Illuminate\Config\Repository; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; @@ -10,15 +11,17 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; -use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; use Le0daniel\PhpTsBindings\Server\Server; use Mockery; +use ReflectionException; +use Throwable; test('handle successful http query request', function () { // Arrange @@ -211,7 +214,6 @@ public function someMethod(array $input, null $context, Client $client): array ->and($response->getData(true))->toEqual([ 'success' => false, 'details' => [ - 'type' => 'INVALID_INPUT', 'fields' => [ '__root' => ['validation.missing_property'] ], @@ -270,3 +272,120 @@ public function someMethod(array $input, null $context, Client $client): array ->and($response->getStatusCode())->toBe(422) ->and($response->getData(true)['type'])->toBe('INVALID_INPUT'); }); + +/** + * A stale middleware class name is the case the previous chain exists for: the name fails + * assertIsMiddleware, and then reflecting the same name to work out what the operation exposes + * fails too. Two failures, and the one that decided the response is the second. + * + * @return array{LaravelHttpController, Request, string, Closure(): list} + */ +function staleMiddlewareController(bool $debug): array +{ + $fcn = 'docs.method'; + $reported = []; + + $typeParser = new TypeParser(); + $operationRegistry = Mockery::mock(OperationRegistry::class); + $exceptionHandler = Mockery::mock(ExceptionHandler::class); + $app = Mockery::mock(Application::class); + $request = Request::create('/query/docs.method', 'GET', ['name' => 'some_value']); + + $operationDefinition = new Definition( + OperationType::QUERY, + 'MyClass', + 'someMethod', + 'method', + 'docs', + ['Tests\Adapters\Laravel\DoesNotExistMiddleware'], + ); + + $operation = new Operation( + 'somekey', + $operationDefinition, + fn() => $typeParser->parse('array{name: string}'), + fn() => $typeParser->parse('array{id: string, name: string}'), + ); + + $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); + $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); + $exceptionHandler->shouldReceive('report')->andReturnUsing(function (Throwable $throwable) use (&$reported): void { + $reported[] = $throwable; + }); + + $controller = new LaravelHttpController( + new Server($operationRegistry, new PsrContainerAdapter(container: $app)), + $exceptionHandler, + null, + debug: $debug, + ); + + // By reference on purpose: the reports only land once the request below is handled. + return [$controller, $request, $fcn, static function () use (&$reported): array { + return $reported; + }]; +} + +test('every throwable in the chain is reported, oldest first', function () { + [$controller, $request, $fcn, $reportedSoFar] = staleMiddlewareController(debug: false); + + $response = $controller->handleHttpQueryRequest($fcn, $request); + $reported = $reportedSoFar(); + + expect($response->getStatusCode())->toBe(500) + ->and($reported)->toHaveCount(2) + // The middleware that could not be resolved comes first; the reflection failure that + // followed it - and that is the RpcError's cause - comes last. + ->and($reported[0])->toBeInstanceOf(InvalidMiddlewareException::class) + ->and($reported[1])->toBeInstanceOf(ReflectionException::class); +}); + +test('debug mode describes the previous failures alongside the cause', function () { + [$controller, $request, $fcn] = staleMiddlewareController(debug: true); + + $debug = $controller->handleHttpQueryRequest($fcn, $request)->getData(true)['__debug']; + + expect($debug['class'])->toBe(ReflectionException::class) + ->and($debug['previous'])->toHaveCount(1) + ->and($debug['previous'][0]['class'])->toBe(InvalidMiddlewareException::class) + ->and($debug['previous'][0]['message'])->toContain('DoesNotExistMiddleware'); +}); + +test('an ordinary error carries no previous key in debug mode', function () { + $fcn = 'docs.method'; + + $typeParser = new TypeParser(); + $operationRegistry = Mockery::mock(OperationRegistry::class); + $exceptionHandler = Mockery::mock(ExceptionHandler::class); + $app = Mockery::mock(Application::class); + $request = Request::create('/query/docs.method', 'GET', ['none' => 'value']); + + $operationDefinition = new Definition(OperationType::QUERY, 'MyClass', 'someMethod', 'method', 'docs', []); + $operation = new Operation( + 'somekey', + $operationDefinition, + fn() => $typeParser->parse('array{name: string}'), + fn() => $typeParser->parse('array{id: string, name: string}'), + ); + + $controllerInstance = new class() { + public function someMethod(array $input, null $context, Client $client): array + { + return ['id' => '123', 'name' => $input['name']]; + } + }; + + $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); + $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); + $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); + $exceptionHandler->shouldReceive('report')->andReturnNull(); + + $response = new LaravelHttpController( + new Server($operationRegistry, new PsrContainerAdapter(container: $app)), + $exceptionHandler, + null, + debug: true, + )->handleHttpQueryRequest($fcn, $request); + + expect($response->getData(true)['__debug'])->not->toHaveKey('previous'); +}); diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 5887702..a78cc63 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -69,10 +69,16 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { // Named, not a TypeError from inside the adapter: the class-string is checked before // anything is constructed, so the message says which class and which contract. + // + // Two things fail here, and the chain keeps both: the class is rejected as a middleware, and + // then reflecting the same class to work out what the operation exposes fails as well. The + // second one is the most recent and is what made this a 500, so it is the cause. expect($result)->toBeInstanceOf(RpcError::class) ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($result->cause)->toBeInstanceOf(InvalidMiddlewareException::class) - ->and($result->cause->getMessage())->toContain(NotAMiddleware::class); + ->and($result->cause)->toBeInstanceOf(ReflectionException::class) + ->and($result->previous)->toHaveCount(1) + ->and($result->previous[0])->toBeInstanceOf(InvalidMiddlewareException::class) + ->and($result->previous[0]->getMessage())->toContain(NotAMiddleware::class); }); test("Middleware emits typescript middleware", function () { diff --git a/tests/Unit/CodeGen/ErrorTypescriptTest.php b/tests/Unit/CodeGen/ErrorTypescriptTest.php index 2a61171..0a76e0c 100644 --- a/tests/Unit/CodeGen/ErrorTypescriptTest.php +++ b/tests/Unit/CodeGen/ErrorTypescriptTest.php @@ -25,11 +25,13 @@ function typescriptDefinition(string $methodName = 'declaresThrows', array $midd ); } -const INVALID_INPUT_BRANCH = '{code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}'; -const UNAUTHENTICATED_BRANCH = '{code: 401, type: "AUTHENTICATION_ERROR", details: {type: "UNAUTHENTICATED"}}'; -const UNAUTHORIZED_BRANCH = '{code: 403, type: "AUTHORIZATION_ERROR", details: {type: "UNAUTHORIZED"}}'; -const NOT_FOUND_BRANCH = '{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}'; -const INTERNAL_BRANCH = '{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}'; +// Only the two categories that have something to add carry details; for the rest the category is +// the whole answer and the server omits the key. +const INVALID_INPUT_BRANCH = '{code: 422, type: "INVALID_INPUT", details: {fields: Record}}'; +const UNAUTHENTICATED_BRANCH = '{code: 401, type: "AUTHENTICATION_ERROR"}'; +const UNAUTHORIZED_BRANCH = '{code: 403, type: "AUTHORIZATION_ERROR"}'; +const NOT_FOUND_BRANCH = '{code: 404, type: "NOT_FOUND"}'; +const INTERNAL_BRANCH = '{code: 500, type: "INTERNAL_ERROR"}'; test('an unconfigured server only emits the branches it can actually produce', function () { $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresNothing')); diff --git a/tests/Unit/Server/Errors/ErrorPresenterTest.php b/tests/Unit/Server/Errors/ErrorPresenterTest.php index 7265548..7d06fea 100644 --- a/tests/Unit/Server/Errors/ErrorPresenterTest.php +++ b/tests/Unit/Server/Errors/ErrorPresenterTest.php @@ -47,8 +47,8 @@ function errorResolveInfo(): ResolveInfo expect($error->type)->toBe(ErrorType::INVALID_INPUT) ->and($error->cause)->toBe($exception) + // The fields and nothing else: the category already says this is INVALID_INPUT. ->and($error->details)->toEqual([ - 'type' => 'INVALID_INPUT', 'fields' => $exception->failure->issues->serializeToFieldsArray(), ]); }); @@ -60,7 +60,7 @@ function errorResolveInfo(): ResolveInfo ->present(new RecordMissingException(), errorDefinition(), null); expect($error->type)->toBe(ErrorType::AUTHENTICATION_ERROR) - ->and($error->details)->toEqual(['type' => 'UNAUTHENTICATED']); + ->and($error->details)->toBeNull(); }); test('a configured unauthorized exception yields a 403', function () { @@ -70,7 +70,7 @@ function errorResolveInfo(): ResolveInfo ->present(new RecordMissingException(), errorDefinition(), null); expect($error->type)->toBe(ErrorType::AUTHORIZATION_ERROR) - ->and($error->details)->toEqual(['type' => 'UNAUTHORIZED']); + ->and($error->details)->toBeNull(); }); test('a configured not found exception yields a 404', function () { @@ -80,7 +80,7 @@ function errorResolveInfo(): ResolveInfo ->present(new RecordMissingException(), errorDefinition(), null); expect($error->type)->toBe(ErrorType::NOT_FOUND) - ->and($error->details)->toEqual(['type' => 'NOT_FOUND']); + ->and($error->details)->toBeNull(); }); test('subclasses of a configured exception match, matching is instanceof and not exact class', function () { @@ -97,7 +97,7 @@ function errorResolveInfo(): ResolveInfo ->present(new OperationNotFoundException('nope'), null, null); expect($error->type)->toBe(ErrorType::NOT_FOUND) - ->and($error->details)->toEqual(['type' => 'NOT_FOUND']) + ->and($error->details)->toBeNull() ->and($error->resolveInfo)->toBeNull(); }); @@ -171,7 +171,7 @@ function errorResolveInfo(): ResolveInfo ->present(new UnexposedException(), errorDefinition(), null); expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($error->details)->toEqual(['type' => 'INTERNAL_SERVER_ERROR']); + ->and($error->details)->toBeNull(); }); test('an ExposeAs exception the operation never declares falls through to the catch all', function () { @@ -188,8 +188,9 @@ function errorResolveInfo(): ResolveInfo $error = new ErrorPresenter(new ServerConfiguration())->present($exception, errorDefinition(), $info); expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($error->details)->toEqual(['type' => 'INTERNAL_SERVER_ERROR']) + ->and($error->details)->toBeNull() ->and($error->cause)->toBe($exception) + ->and($error->previous)->toBe([]) ->and($error->resolveInfo)->toBe($info); }); @@ -219,7 +220,19 @@ function errorResolveInfo(): ResolveInfo ->present(new ExposedDomainException(), errorDefinition('declaresNothing', ['Tests\Mocks\Errors\DoesNotExist']), null); expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($error->details)->toEqual(['type' => 'INTERNAL_SERVER_ERROR']); + ->and($error->details)->toBeNull(); +}); + +test('a failure to present becomes the cause and pushes the original into previous', function () { + $exception = new ExposedDomainException(); + + $error = new ErrorPresenter(new ServerConfiguration()) + ->present($exception, errorDefinition('declaresNothing', ['Tests\Mocks\Errors\DoesNotExist']), null); + + // The reflection failure is the most recent thing that went wrong, so it is the cause; the + // exception the application threw is what came before it. + expect($error->cause)->not->toBe($exception) + ->and($error->previous)->toBe([$exception]); }); test('internalError produces the last resort shape', function () { @@ -229,7 +242,19 @@ function errorResolveInfo(): ResolveInfo $error = ErrorPresenter::internalError($exception, $info); expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($error->details)->toEqual(['type' => 'INTERNAL_SERVER_ERROR']) + ->and($error->details)->toBeNull() ->and($error->cause)->toBe($exception) + ->and($error->previous)->toBe([]) ->and($error->resolveInfo)->toBe($info); }); + +test('internalError carries the previous failures oldest first', function () { + $original = new RuntimeException('the application blew up'); + $latest = new RuntimeException('presenting it blew up too'); + + $error = ErrorPresenter::internalError($latest, errorResolveInfo(), [$original]); + + expect($error->cause)->toBe($latest) + ->and($error->previous)->toBe([$original]) + ->and($error->throwableChain())->toBe([$original, $latest]); +}); diff --git a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php index b96ee3c..fc76826 100644 --- a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php +++ b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php @@ -202,7 +202,10 @@ function (): RpcError { expect($result)->toBeInstanceOf(RpcError::class) ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($result->details)->toBe(['type' => 'INTERNAL_SERVER_ERROR']) + ->and($result->details)->toBeNull() ->and($result->cause->getMessage())->toBe('the presenter is broken too') + // The failure that got the presenter called is not lost just because the presenter failed. + ->and($result->previous)->toHaveCount(1) + ->and($result->previous[0]->getMessage())->toBe('inner exploded') ->and($result->resolveInfo?->fullyQualifiedName)->toBe('test.operation'); }); diff --git a/tests/ts-output/generated/accounts.ts b/tests/ts-output/generated/accounts.ts index c4f427d..209e6ae 100644 --- a/tests/ts-output/generated/accounts.ts +++ b/tests/ts-output/generated/accounts.ts @@ -9,7 +9,7 @@ import {queryOptions, useQuery} from '@tanstack/react-query'; export type FindResult = {id:number;term:string;}; export type FindInput = {availability?:(null|Availability);term:string;}; -export type FindError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type FindError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: QUERY @@ -51,7 +51,7 @@ export function findQueryKey(input: FindInput) { export type LockResult = {locked:true;}; export type LockInput = {id:number;}; -export type LockError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}|{type: "quota_exceeded"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type LockError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}|{type: "quota_exceeded"}}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: COMMAND @@ -70,7 +70,7 @@ export async function lock(input: LockInput, options?: OperationOptions) { export type UnlockResult = {unlocked:true;}; export type UnlockInput = {id:number;}; -export type UnlockError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type UnlockError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: COMMAND diff --git a/tests/ts-output/generated/catalog.ts b/tests/ts-output/generated/catalog.ts index 3718373..527f609 100644 --- a/tests/ts-output/generated/catalog.ts +++ b/tests/ts-output/generated/catalog.ts @@ -9,7 +9,7 @@ import {queryOptions, useQuery} from '@tanstack/react-query'; export type PrepareResult = Draft; export type PrepareInput = DraftInput; -export type PrepareError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type PrepareError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: QUERY @@ -51,7 +51,7 @@ export function prepareQueryKey(input: PrepareInput) { export type ProductResult = Product; export type ProductInput = {id:(number & Brand<"productId">);}; -export type ProductError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type ProductError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: QUERY @@ -93,7 +93,7 @@ export function productQueryKey(input: ProductInput) { export type RestockResult = {product:Product;restockedAt:string;}; export type RestockInput = {amount:number;price:Money;sku:Sku;}; -export type RestockError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type RestockError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: COMMAND @@ -112,7 +112,7 @@ export async function restock(input: RestockInput, options?: OperationOptions) { export type SearchResult = {results:Array;total:number;}; export type SearchInput = {availability?:Availability;limit?:number;term:string;}; -export type SearchError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type SearchError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: QUERY diff --git a/tests/ts-output/generated/lib/type-map.ts b/tests/ts-output/generated/lib/type-map.ts index 9d9dc49..75e93e0 100644 --- a/tests/ts-output/generated/lib/type-map.ts +++ b/tests/ts-output/generated/lib/type-map.ts @@ -5,4 +5,4 @@ import type {Availability, Brand, Draft, DraftInput, Money, Product, Sku} from ' /** * Full type map of all operations, input and output types. */ -export type TypeMap = {query: {'accounts.find': {input: {availability?:(null|Availability);term:string;}, output: {id:number;term:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'catalog.prepare': {input: DraftInput, output: Draft, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'catalog.product': {input: {id:(number & Brand<"productId">);}, output: Product, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'catalog.search': {input: {availability?:Availability;limit?:number;term:string;}, output: {results:Array;total:number;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'shapes.defaults': {input: null, output: {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'shapes.roundtrip': {input: {filters:Record>;page?:number;term:string;}, output: {filters:Record>;page?:number;term:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}}};command: {'accounts.lock': {input: {id:number;}, output: {locked:true;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}|{type: "quota_exceeded"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'accounts.unlock': {input: {id:number;}, output: {unlocked:true;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'catalog.restock': {input: {amount:number;price:Money;sku:Sku;}, output: {product:Product;restockedAt:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}};'shapes.submit': {input: {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}, output: {accepted:boolean;id:(number & Brand<"productId">);}, errors: {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}}}}; +export type TypeMap = {query: {'accounts.find': {input: {availability?:(null|Availability);term:string;}, output: {id:number;term:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}}|{code: 500, type: "INTERNAL_ERROR"}};'catalog.prepare': {input: DraftInput, output: Draft, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'catalog.product': {input: {id:(number & Brand<"productId">);}, output: Product, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'catalog.search': {input: {availability?:Availability;limit?:number;term:string;}, output: {results:Array;total:number;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'shapes.defaults': {input: null, output: {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'shapes.roundtrip': {input: {filters:Record>;page?:number;term:string;}, output: {filters:Record>;page?:number;term:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}}};command: {'accounts.lock': {input: {id:number;}, output: {locked:true;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}|{type: "quota_exceeded"}}|{code: 500, type: "INTERNAL_ERROR"}};'accounts.unlock': {input: {id:number;}, output: {unlocked:true;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'catalog.restock': {input: {amount:number;price:Money;sku:Sku;}, output: {product:Product;restockedAt:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'shapes.submit': {input: {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}, output: {accepted:boolean;id:(number & Brand<"productId">);}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}}}}; diff --git a/tests/ts-output/generated/shapes.ts b/tests/ts-output/generated/shapes.ts index 61f882d..806e992 100644 --- a/tests/ts-output/generated/shapes.ts +++ b/tests/ts-output/generated/shapes.ts @@ -9,7 +9,7 @@ import {queryOptions, useQuery} from '@tanstack/react-query'; export type DefaultsResult = {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}; export type DefaultsInput = null; -export type DefaultsError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type DefaultsError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: QUERY @@ -51,7 +51,7 @@ export function defaultsQueryKey() { export type RoundtripResult = {filters:Record>;page?:number;term:string;}; export type RoundtripInput = {filters:Record>;page?:number;term:string;}; -export type RoundtripError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type RoundtripError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: QUERY @@ -93,7 +93,7 @@ export function roundtripQueryKey(input: RoundtripInput) { export type SubmitResult = {accepted:boolean;id:(number & Brand<"productId">);}; export type SubmitInput = {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}; -export type SubmitError = {code: 422, type: "INVALID_INPUT", details: {type: "INVALID_INPUT"; fields: Record}}|{code: 404, type: "NOT_FOUND", details: {type: "NOT_FOUND"}}|{code: 500, type: "INTERNAL_ERROR", details: {type: "INTERNAL_SERVER_ERROR"}}; +export type SubmitError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; /** * Type: COMMAND diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index eae2923..25aeefd 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -40,6 +40,11 @@ export async function readProduct(): Promise { return null; case 404: case 500: + // `details` only exists where the category cannot say everything on its own. Here it + // can, so the server omits the key and the branch has no such property. The directive + // below is the guard: putting one back makes the access legal and fails this build. + // @ts-expect-error + console.debug(result.details); return null; } } From 82ef23b4a230d1019f5b3c828a6b77fb2131aa41 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 5 Aug 2026 16:24:37 +0200 Subject: [PATCH 062/101] Add comprehensive test coverage for `RpcError` and `RpcSuccess` JSON serialization - Introduced unit tests validating error and success envelope structures, ensuring `__metadata` and `__client` adherence across outcomes. - Verified absence of unnecessary `details` in errors when the category alone sufficiently describes the issue. - Added serialization tests for `metadata` and `client directives` inclusion in successful operations. - Updated TypeScript typings for consistency with PHP changes. - Refactored --- README.md | 73 ++++++---- docs/laravel.md | 25 ++-- src/CodeGen/CodeGenerators/EmitTypes.php | 4 +- src/Server/Data/RpcError.php | 1 - .../Laravel/LaravelHttpControllerTest.php | 51 +++++++ tests/Unit/CodeGen/EmitTypesTest.php | 26 +++- tests/Unit/Server/Data/RpcErrorTest.php | 129 ++++++++++++++++++ tests/Unit/Server/Data/RpcSuccessTest.php | 102 ++++++++++++++ tests/ts-output/generated/lib/types.ts | 4 +- tests/ts-output/src/usage.ts | 59 +++++++- 10 files changed, 426 insertions(+), 48 deletions(-) create mode 100644 tests/Unit/Server/Data/RpcErrorTest.php create mode 100644 tests/Unit/Server/Data/RpcSuccessTest.php diff --git a/README.md b/README.md index 37a74cc..1b1cc04 100644 --- a/README.md +++ b/README.md @@ -269,8 +269,16 @@ it is a bug in your code, not something the client can fix. The PHPStan *refinem type are not re-checked, because static analysis already established those. See [refinements run on input, never on output](docs/types.md#refinements-run-on-input-never-on-output). -Both results carry metadata a middleware can attach with `withMetadata()` / `appendMetadata()`. What -becomes of it is the transport's decision — nothing in the core writes it to a response. +**`RpcResult`** is the interface both outcomes implement, for the code that does not care which one +it is holding. It carries `statusCode` — 200 on success, the error category's own code otherwise — +`resolveInfo`, `metadata`, and it is `JsonSerializable`: `jsonSerialize()` produces the whole +envelope the generated client reads. A transport needs nothing else, which is why +[serving operations](#serving-operations-over-http) is two lines. + +Both results carry metadata a middleware can attach with `withMetadata()` / `appendMetadata()`. It +travels to the client under `__metadata`, and the generated envelope declares it as +`__metadata?: Record` — optional because the key is left off entirely when nothing +was attached. It is yours to shape; the library puts nothing in it. ## Defining operations @@ -574,21 +582,21 @@ generated client sends: for queries, each value JSON-encoded into its own query commands, a JSON body. Decoding query values back is what lets you leave [`coerceQueryInput`](#the-rest-of-serverconfiguration) off. -To emit client directives, pass an `OperationSPAClient` instead of a `NullClient` and ask it for the -payload: +To emit client directives, pass an `OperationSPAClient` instead of a `NullClient`. There is nothing +else to do: the success carries the client, so `jsonSerialize()` asks it for its payload and puts it +under `__client` for you. ```php -use Le0daniel\PhpTsBindings\Contracts\SerializableClient; - -$client = new OperationSPAClient(); -$result = $server->command('users.create', $input, $myContext, $client); +$result = $server->command('users.create', $input, $myContext, new OperationSPAClient()); -$body = ['success' => true, 'data' => $result->data]; -if ($client instanceof SerializableClient && $directives = $client->serializeToArray()) { - $body['__client'] = $directives; -} +respondJson($result->statusCode, $result->jsonSerialize()); ``` +**Directives ride the success branch only.** A failure carries none, even for the directives that +were queued before it — a handler that toasts `'Saved'` and then throws must not have the browser +announce work that did not happen. That is why `RpcError` holds no client at all, rather than +leaving it to each transport to remember. + ## The generated TypeScript client `TypescriptServerCodeGenerator` writes a self-contained client, and `OutputDirectory::write()` puts @@ -620,14 +628,21 @@ has drifted from the backend. The envelope every call resolves to: ```typescript -export type Success = {success: true, data: T} -export type Failure = {success: false} & E; +export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} +export type Failure = {success: false, __metadata?: Record} & E; export type Result = Success | Failure; ``` -That is the whole envelope. A server may put more next to it — [client -directives](#client-directives) arrive under `__client` — and it travels through the transport -untouched rather than being described here; see that section for how to get at it. +That is the whole envelope, and both extra keys are the library's own — `jsonSerialize()` writes +them, so the generated types name them rather than leaving you to discover them on the wire. Each is +optional, because the key is left off entirely when there is nothing to say. + +They differ in how much they can honestly claim. `__metadata` is `Record` on both +branches: whatever a middleware attached, always a string-keyed bag, serialized the same way on +either outcome. `__client` is `unknown` and success-only — the *key* is first-party, but its shape +belongs to whichever [`Client`](#client-directives) produced it, and a failure carries no directives +at all. Naming it without describing it is the honest half: you know to look there, and the guard +that shipped with your client is what makes it typed. Wire it up once: @@ -743,7 +758,8 @@ a `NullClient` whose every method is a no-op, so handlers never need to know whi other end, and nothing warns when a directive goes nowhere. Choosing between them is the transport's job; the core never inspects a request. -Under `operations-spa` those calls land in a `__client` key next to the data: +Under `operations-spa` those calls land in a `__client` key next to the data, on a **successful** +response: ```json { @@ -760,13 +776,20 @@ Under `operations-spa` those calls land in a `__client` key next to the data: The full interface is `redirect()`, `invalidate()`, `toast()`, and one shorthand per toast type — `success()`, `error()`, `warning()`, `alert()` and `info()`. Keys are only present when something -called for them. A transport emits that payload by asking the client for it: -`SerializableClient::serializeToArray()`. - -**The envelope says nothing about `__client`, on purpose.** `Client` is an extension point — your own -implementation may define an entirely different set of directives under a different schema — so -neither `lib/types.ts` nor the transport interface commits to a shape it cannot know. The payload -still travels through `DefaultClient` untouched; what is missing is only the claim about what it is. +called for them. `RpcSuccess::jsonSerialize()` puts the payload on the response by asking the client +for it — `SerializableClient::serializeToArray()` — so a transport that serializes the result gets +this for free, and one that builds its own body calls the same method. + +**A failure carries no directives**, including the ones queued before it: a toast for work that was +rolled back is worse than no toast, so `RpcError` holds no client and there is nothing to serialize. +Tell the user about a failure from the error branch the generated union already gives you. + +**The envelope names `__client` but declares it `unknown`, on purpose.** The key is the library's — +`RpcSuccess::jsonSerialize()` writes it, so `lib/types.ts` says it may be there. The *shape* is not: +`Client` is an extension point, and your own implementation may define an entirely different set of +directives under a different schema, so neither `lib/types.ts` nor the transport interface commits to +one it cannot know. The payload travels through `DefaultClient` untouched; what is withheld is only +the claim about what it is. `OperationSPAClient` is the subset this library deems useful and ships, and it gets its own file. `lib/client-operations-spa.ts` declares `OperationsClientPayload` — the same schema diff --git a/docs/laravel.md b/docs/laravel.md index 219b477..2d2ea64 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -160,13 +160,16 @@ Everything the provider and the HTTP controller pick without asking. - **Success is always HTTP 200**, with `{"success": true, "data": …}`. Failures use the error category's own status: 400, 401, 403, 404, 422 or 500. -- `__client` is added when the client produced directives, `__metadata` when a middleware attached - any. +- **The body is `RpcResult::jsonSerialize()`**, unchanged. The controller adds nothing to it outside + [debug mode](#debug-mode), so what the generated client reads is what the core defined. +- `__metadata` is added when a middleware attached any, on either outcome. `__client` is added when + the client produced directives — **on success only**: a handler that toasts and then throws must + not have the browser announce work that did not happen, so a failure carries no directives at all. - **Exception rendering bypasses Laravel entirely.** Nothing is thrown out of the controller. Every throwable behind an `RpcError` is handed to `ExceptionHandler::report()` — the cause, plus anything - in `previous`, oldest first — so logging, Sentry and friends still fire, and then it is serialized - by hand. Laravel's `render()`, `renderable()` handlers, `abort()` pages and the 419 CSRF redirect - never run for an operation. + in `previous`, oldest first — so logging, Sentry and friends still fire, and then the error is + serialized as the envelope above. Laravel's `render()`, `renderable()` handlers, `abort()` pages + and the 419 CSRF redirect never run for an operation. - **Validation errors are not Laravel's shape.** A 422 carries `{"code": 422, "type": "INVALID_INPUT", "details": {"fields": {"": ["message"]}}}`, not `{"message": …, "errors": …}`. `Illuminate\Validation\ValidationException` is **not** mapped by @@ -185,13 +188,13 @@ HTML redirect. ### Debug mode -> **`app.debug` changes behaviour.** With it on, `LocalMetadataMiddleware` is prepended to every -> operation and responses gain a `__metadata` key with **the raw input**, the context class, the -> handler, the middleware stack and per-middleware timings. Failures additionally carry `__info` and -> `__debug` with the exception class, message, file, line and **full stack trace** — and a `previous` -> list describing the earlier failures, on the rare error that has any. +> **`app.debug` changes behaviour.** With it on, every response gains a `__resolveInfo` key naming +> the handler, the middleware stack, the operation's fully qualified name and its type — successes +> included. Failures additionally carry `__debug` with the exception class, message, code, file, +> line and **full stack trace**, plus a `previous` list describing the earlier failures on the rare +> error that has any. > -> None of it is emitted in production, and none of it appears in the generated types — but any +> Neither key is emitted in production, and neither appears in the generated types — but any > environment with `APP_DEBUG=true` and a reachable route is handing all of that to whoever calls it. ## Artisan commands diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 38de92d..fe25a7c 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -78,8 +78,8 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi self::TYPES_FILE => new TypescriptFile(<<generateNamespaceUnion($uniqueNamespaces)}; -export type Success = {success: true, data: T} -export type Failure = {success: false} & E; +export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} +export type Failure = {success: false, __metadata?: Record} & E; export type Result = Success | Failure; declare const __brand: unique symbol; diff --git a/src/Server/Data/RpcError.php b/src/Server/Data/RpcError.php index b7279ad..a36bd67 100644 --- a/src/Server/Data/RpcError.php +++ b/src/Server/Data/RpcError.php @@ -3,7 +3,6 @@ namespace Le0daniel\PhpTsBindings\Server\Data; use Le0daniel\PhpTsBindings\Contracts\RpcResult; -use Le0daniel\PhpTsBindings\Contracts\SerializableClient; use Le0daniel\PhpTsBindings\Utils\Dicts; use NoDiscard; use Override; diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index cab923d..94a1d8f 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -389,3 +389,54 @@ public function someMethod(array $input, null $context, Client $client): array expect($response->getData(true)['__debug'])->not->toHaveKey('previous'); }); + +test('directives queued before a failure never reach the client', function () { + // The reason RpcError holds no Client: a handler that toasts "Saved" and then throws would + // otherwise have the browser announce work that did not happen. Whatever the client collected + // before the failure is dropped with the request. + $fcn = 'docs.method'; + + $typeParser = new TypeParser(); + $operationRegistry = Mockery::mock(OperationRegistry::class); + $exceptionHandler = Mockery::mock(ExceptionHandler::class); + $app = Mockery::mock(Application::class); + $request = Request::create('/query/docs.method', 'GET', ['name' => 'some_value']); + $request->headers->set(LaravelHttpController::CLIENT_ID_HEADER, 'operations-spa'); + + $operationDefinition = new Definition(OperationType::QUERY, 'MyClass', 'someMethod', 'method', 'docs', []); + $operation = new Operation( + 'somekey', + $operationDefinition, + fn() => $typeParser->parse('array{name: string}'), + fn() => $typeParser->parse('array{id: string, name: string}'), + ); + + $controllerInstance = new class() { + public function someMethod(array $input, null $context, Client $client): array + { + $client->success('Saved'); + $client->redirect('/docs/123'); + $client->invalidate('docs'); + + throw new RuntimeException('the save did not happen after all'); + } + }; + + $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); + $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); + $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); + $exceptionHandler->shouldReceive('report')->andReturnNull(); + + $response = new LaravelHttpController( + new Server($operationRegistry, new PsrContainerAdapter(container: $app)), + $exceptionHandler, + null, + )->handleHttpQueryRequest($fcn, $request); + + expect($response->getStatusCode())->toBe(500) + ->and($response->getData(true))->toEqual([ + 'success' => false, + 'code' => 500, + 'type' => 'INTERNAL_ERROR', + ]); +}); diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 86edab3..0088e24 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -61,22 +61,40 @@ function emitTypesFor(string $inputType, string $outputType): string 'the namespace union' => ['OperationNamespaces'], ]); -test('the envelope commits to nothing a specific client puts next to the data', function () { +test('the envelope names the client side channel without describing what is in it', function () { $types = emitTypesFor( 'array{id: \\' . UserId::class . '}', 'array{email: \\' . Email::class . '}', ); - // Client is an extension point: a directive payload belongs to the implementation that emits - // it, which for the one this library ships is lib/client-operations-spa.ts. + // The key is the library's own - RpcSuccess::jsonSerialize() writes it - so the envelope says + // it may be there. The value is not: Client is an extension point, and a directive payload + // belongs to the implementation that emits it, which for the one this library ships is + // lib/client-operations-spa.ts. expect($types) ->toContain('export type Result = Success | Failure;') - ->not->toContain('__client') + ->toContain('__client?: unknown') ->not->toContain('operations-spa') + ->not->toContain('OperationsClientPayload') ->not->toContain('WithClientDirectives') ->not->toContain('ClientToast'); }); +test('the branches declare exactly what jsonSerialize can put on each of them', function () { + $types = emitTypesFor( + 'array{id: \\' . UserId::class . '}', + 'array{email: \\' . Email::class . '}', + ); + + // __metadata rides both outcomes: it is the core's own, always array, written + // only through withMetadata()/appendMetadata(). __client rides success alone, because RpcError + // holds no Client - a toast queued before a throw must not reach the browser. Both optional, + // because jsonSerialize() leaves either key off when there is nothing to say. + expect($types) + ->toContain('export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record}') + ->toContain('export type Failure = {success: false, __metadata?: Record} & E;'); +}); + test('attribute brands stay inline and declare no alias, only the Brand helper is exported', function () { $types = emitTypesFor( 'array{id: \\' . UserId::class . '}', diff --git a/tests/Unit/Server/Data/RpcErrorTest.php b/tests/Unit/Server/Data/RpcErrorTest.php new file mode 100644 index 0000000..0bf0f89 --- /dev/null +++ b/tests/Unit/Server/Data/RpcErrorTest.php @@ -0,0 +1,129 @@ +jsonSerialize())->toBe([ + 'success' => false, + 'code' => 404, + 'type' => 'NOT_FOUND', + ])->and($error->statusCode)->toBe(404); +}); + +test('a category that says everything on its own emits no details key', function (ErrorType $type) { + $error = new RpcError($type, new RuntimeException('nope'), null, errorInfo()); + + // Restating the category under `details.type` would be the same string on the wire twice, and + // the generated branch declares no such property - narrowing on `type` must not offer one. + expect($error->jsonSerialize())->not->toHaveKey('details'); +})->with([ + 'unauthenticated' => [ErrorType::AUTHENTICATION_ERROR], + 'unauthorized' => [ErrorType::AUTHORIZATION_ERROR], + 'not found' => [ErrorType::NOT_FOUND], + 'internal' => [ErrorType::INTERNAL_ERROR], +]); + +test('the two categories the code alone cannot describe carry their details', function () { + $invalidInput = new RpcError( + ErrorType::INVALID_INPUT, + new RuntimeException('bad'), + ['fields' => ['email' => ['validation.not_empty_string']]], + errorInfo(), + ); + + $domain = new RpcError( + ErrorType::DOMAIN_ERROR, + new RuntimeException('nope'), + ['type' => 'invalid_name'], + errorInfo(), + ); + + expect($invalidInput->jsonSerialize())->toBe([ + 'success' => false, + 'code' => 422, + 'type' => 'INVALID_INPUT', + 'details' => ['fields' => ['email' => ['validation.not_empty_string']]], + ])->and($domain->jsonSerialize())->toBe([ + 'success' => false, + 'code' => 400, + 'type' => 'DOMAIN_ERROR', + 'details' => ['type' => 'invalid_name'], + ]); +}); + +test('metadata is absent while empty and present once a middleware attached some', function () { + $error = new RpcError(ErrorType::INTERNAL_ERROR, new RuntimeException('boom'), null, errorInfo()); + + expect($error->jsonSerialize())->not->toHaveKey('__metadata') + ->and($error->appendMetadata(['durationMs' => 12])->jsonSerialize())->toBe([ + 'success' => false, + 'code' => 500, + 'type' => 'INTERNAL_ERROR', + '__metadata' => ['durationMs' => 12], + ]); +}); + +test('a failure never carries client directives', function () { + // An error result holds no Client at all, and that is the point: a handler that toasts "Saved" + // and then throws must not have that toast reach the browser. Whatever was queued before the + // failure is dropped with the request. + $error = new RpcError(ErrorType::DOMAIN_ERROR, new RuntimeException('nope'), ['type' => 'x'], errorInfo()) + ->withMetadata(['some' => 'metadata']); + + expect($error->jsonSerialize())->not->toHaveKey('__client') + ->and(new ReflectionClass(RpcError::class)->hasProperty('client'))->toBeFalse(); +}); + +test('the throwable chain is the cause alone until presenting one failure produced another', function () { + $cause = new RuntimeException('what the application threw'); + $ordinary = new RpcError(ErrorType::INTERNAL_ERROR, $cause, null, errorInfo()); + + $presentationFailure = new LogicException('stale middleware class name'); + $chained = new RpcError(ErrorType::INTERNAL_ERROR, $presentationFailure, null, errorInfo(), previous: [$cause]); + + expect($ordinary->throwableChain())->toBe([$cause]) + // Oldest first, with the failure that decided the category last. + ->and($chained->throwableChain())->toBe([$cause, $presentationFailure]); +}); + +test('neither the chain nor the debug detail leaks into the envelope', function () { + $error = new RpcError( + ErrorType::INTERNAL_ERROR, + new RuntimeException('database credentials rejected'), + null, + errorInfo(), + previous: [new LogicException('and how we got there')], + ); + + expect($error->jsonSerialize())->toBe([ + 'success' => false, + 'code' => 500, + 'type' => 'INTERNAL_ERROR', + ]); +}); + +test('the envelope survives a json round trip as plain data', function () { + $error = new RpcError( + ErrorType::INVALID_INPUT, + new RuntimeException('bad'), + ['fields' => ['email' => ['validation.not_empty_string']]], + errorInfo(), + )->withMetadata(['trace' => ['one', 'two']]); + + $payload = $error->jsonSerialize(); + $roundTripped = json_decode(json_encode($error, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + + expect($roundTripped)->toBe($payload); +}); diff --git a/tests/Unit/Server/Data/RpcSuccessTest.php b/tests/Unit/Server/Data/RpcSuccessTest.php new file mode 100644 index 0000000..21f9c6f --- /dev/null +++ b/tests/Unit/Server/Data/RpcSuccessTest.php @@ -0,0 +1,102 @@ + declares', function () { + $result = new RpcSuccess(['id' => '123'], new NullClient(), successResolveInfo()); + + // toBe, not toEqual: === on arrays compares order too, and the wire shape is meant to be + // byte comparable. + expect($result->jsonSerialize())->toBe([ + 'success' => true, + 'data' => ['id' => '123'], + ]); +}); + +test('null data keeps its key rather than vanishing from the envelope', function () { + // Success promises `data: T`, and an operation with a nullable output that returns null is + // an ordinary success. Dropping the key would hand the client `undefined` for a declared null. + $result = new RpcSuccess(null, new NullClient(), successResolveInfo()); + + expect($result->jsonSerialize())->toBe([ + 'success' => true, + 'data' => null, + ]); +}); + +test('a client that is not serializable contributes no __client key', function () { + $result = new RpcSuccess('ok', new NullClient(), successResolveInfo()); + + expect($result->jsonSerialize())->not->toHaveKey('__client'); +}); + +test('a serializable client with nothing queued contributes no __client key', function () { + $result = new RpcSuccess('ok', new OperationSPAClient(), successResolveInfo()); + + expect($result->jsonSerialize())->not->toHaveKey('__client'); +}); + +test('the directives a serializable client collected ride along under __client', function () { + $client = new OperationSPAClient(); + $client->success('Saved'); + $client->redirect('/users/123'); + + $result = new RpcSuccess(['id' => '123'], $client, successResolveInfo()); + + expect($result->jsonSerialize())->toBe([ + '__client' => [ + 'redirect' => ['url' => '/users/123', 'reload' => false], + 'toasts' => [ + ['type' => 'success', 'message' => 'Saved'], + ], + 'type' => 'operations-spa', + ], + 'success' => true, + 'data' => ['id' => '123'], + ]); +}); + +test('metadata is absent while empty and present once a middleware attached some', function () { + $result = new RpcSuccess('ok', new NullClient(), successResolveInfo()); + + expect($result->jsonSerialize())->not->toHaveKey('__metadata') + ->and($result->appendMetadata(['durationMs' => 12])->jsonSerialize())->toBe([ + '__metadata' => ['durationMs' => 12], + 'success' => true, + 'data' => 'ok', + ]); +}); + +test('withMetadata replaces the bag while appendMetadata merges into it', function () { + $result = new RpcSuccess('ok', new NullClient(), successResolveInfo()) + ->withMetadata(['first' => 1]); + + expect($result->appendMetadata(['second' => 2])->metadata)->toBe(['first' => 1, 'second' => 2]) + ->and($result->withMetadata(['second' => 2])->metadata)->toBe(['second' => 2]); +}); + +test('the envelope survives a json round trip as plain data', function () { + $client = new OperationSPAClient(); + $client->warning('Careful'); + + $result = new RpcSuccess(['id' => 1], $client, successResolveInfo()) + ->withMetadata(['trace' => ['one', 'two']]); + + $payload = $result->jsonSerialize(); + $roundTripped = json_decode(json_encode($result, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + + expect($roundTripped)->toBe($payload); +}); + +test('the status code is 200, so a transport never has to ask which outcome this is', function () { + expect(new RpcSuccess(null, new NullClient(), successResolveInfo())->statusCode)->toBe(200); +}); diff --git a/tests/ts-output/generated/lib/types.ts b/tests/ts-output/generated/lib/types.ts index 89971f0..3db32f1 100644 --- a/tests/ts-output/generated/lib/types.ts +++ b/tests/ts-output/generated/lib/types.ts @@ -2,8 +2,8 @@ export type OperationNamespaces = 'accounts'|'catalog'|'shapes'; -export type Success = {success: true, data: T} -export type Failure = {success: false} & E; +export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} +export type Failure = {success: false, __metadata?: Record} & E; export type Result = Success | Failure; declare const __brand: unique symbol; diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index 25aeefd..fd81b8b 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -117,9 +117,10 @@ export async function readDefaults(): Promise { } /** - * Commands go over POST and can carry client directives back. The envelope says nothing about - * `__client` — the transport never committed to a schema — so one guard from the client that emits - * the payload is what puts it on the result, fully typed, for the rest of the function. + * Commands go over POST and can carry client directives back. The envelope names `__client` but + * declares it `unknown` — the key is the library's, the schema is whichever Client emitted it — so + * one guard from that client is what puts it on the result, fully typed, for the rest of the + * function. */ export async function lockAccount(id: number): Promise { const result = await lock({id}); @@ -191,3 +192,55 @@ export function useProduct() { export type ProductInputFromMap = TypeMap['query']['catalog.product']['input']; export type ProductOutputFromMap = TypeMap['query']['catalog.product']['output']; export type LockErrorsFromMap = TypeMap['command']['accounts.lock']['errors']; + +/** + * `__metadata` is declared on both branches, so a middleware's bag is readable without narrowing + * first and without a guard — unlike `__client`, whose schema belongs to whichever Client is + * plugged in. Optional, because the server leaves the key off when nothing was attached. + */ +export async function readMetadata(): Promise { + const result = await product({id: productId}); + + // Before the union is narrowed: both branches agree it may be there. + const durationMs: unknown = result.__metadata?.durationMs; + + if (!result.success) { + // Still there on the failure branch, alongside the error's own keys. + console.debug(result.code, result.__metadata); + return durationMs; + } + + // Values are `unknown`: the bag is the application's to shape, so the envelope refuses to + // guess. Reading one as a string has to be a deliberate assertion. + // @ts-expect-error + const handler: string = result.__metadata?.fullyQualifiedHandler; + console.debug(handler, result.data.sku); + + return durationMs; +} + +/** + * `__client` is declared, but only as `unknown`, and only on the success branch. Both halves of that + * are load bearing, so both are pinned here. + */ +export async function clientChannelIsNamedButNotDescribed(id: number): Promise { + const result = await lock({id}); + + if (!result.success) { + // A failure carries no directives at all — RpcError holds no Client, so a toast queued + // before the throw never reaches the browser. The branch has no such property to read. + // @ts-expect-error + console.debug(result.__client); + return; + } + + // Present on success, and `unknown`: reading a directive off it without narrowing first is + // exactly the claim the envelope refuses to make. + // @ts-expect-error + console.debug(result.__client?.toasts); + + // The guard is the way through, and it is the shipped client's, not the envelope's. + if (containsOperationSpaPayload(result)) { + console.debug(result.__client.type satisfies 'operations-spa'); + } +} From a11ca4b4dcb2231e1a574c442677d69a0c4a3706 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 5 Aug 2026 17:15:51 +0200 Subject: [PATCH 063/101] Add full-length documentation for directives, errors, server configuration, operations, and the generated TypeScript client - Introduced detailed `docs/client-directives.md` to explain client-side payload handling (e.g., toasts, redirects, invalidations) and the `OperationSPAClient`. - Added `docs/errors.md` outlining server error models, client-visible categories, and domain-specific declarations with examples for mapping exceptions. - Published `docs/operations.md`, covering operation attributes, namespaces, handler contracts, and middleware, including insight into middleware-driven exception names. - Authored `docs/server.md` to detail server runtime mechanics, registries, key generation, metadata, and reflection-based optimization for production. - Documented `docs/typescript-client.md`, explaining generated client structures, transport wiring, and fail-safe behaviors. - Updated all sections for consistency with the existing error handling, serialization behavior, and code generation guarantees. --- README.md | 807 +++++++------------------------------- docs/client-directives.md | 98 +++++ docs/errors.md | 130 ++++++ docs/laravel.md | 17 +- docs/operations.md | 185 +++++++++ docs/server.md | 260 ++++++++++++ docs/typescript-client.md | 167 ++++++++ 7 files changed, 994 insertions(+), 670 deletions(-) create mode 100644 docs/client-directives.md create mode 100644 docs/errors.md create mode 100644 docs/operations.md create mode 100644 docs/server.md create mode 100644 docs/typescript-client.md diff --git a/README.md b/README.md index 1b1cc04..a1b483c 100644 --- a/README.md +++ b/README.md @@ -35,20 +35,24 @@ Requires **PHP 8.5** and nothing else — no dependencies, no framework coupling --- -- [Install](#install) -- [Laravel](#laravel) -- [Quickstart](#quickstart) -- [Core concepts](#core-concepts) -- [Defining operations](#defining-operations) -- [Middleware](#middleware) -- [Types](#types) -- [Errors](#errors) -- [Serving operations over HTTP](#serving-operations-over-http) -- [The generated TypeScript client](#the-generated-typescript-client) -- [Client directives](#client-directives) -- [Preloading a query](#preloading-a-query) -- [Production](#production) -- [Extension points and exceptions](#extension-points-and-exceptions) +## Documentation + +This page is the overview: install it, run it without a framework, and understand why it behaves the +way it does. Each subsystem has its own reference. + +| Document | Covers | +|---|---| +| [Types](docs/types.md) | The supported PHPStan subset, refinements, utility types, value objects, `#[Castable]`, brands and named types. | +| [Operations](docs/operations.md) | The attributes, the handler contract, middleware, `ServerConfiguration`. | +| [Errors](docs/errors.md) | The six categories, exposing a domain error, the generated union, and the exceptions this library throws. | +| [The server](docs/server.md) | `Server`, operation keys, registries, DI, serving HTTP, preloading, the production cache, extension points. | +| [The TypeScript client](docs/typescript-client.md) | What codegen writes, the envelope, the transport, all eight generators, writing your own. | +| [Client directives](docs/client-directives.md) | The optional `Client` side channel for toasts, redirects and cache invalidation. | +| [The Laravel adapter](docs/laravel.md) | Config, routes, context, the `operations:*` artisan commands. | + +On this page: [Install](#install) · [Quickstart](#quickstart) · [Core concepts](#core-concepts) · +[Design decisions](#design-decisions) · [Errors](#errors) · [Types](#types) · +[Contributing](#contributing) ## Install @@ -66,15 +70,9 @@ includes: - vendor/le0daniel/php-ts-bindings/extension.neon ``` -## Laravel - -A first-party adapter ships with the library. The service provider is auto-discovered, -`config/operations.php` is publishable, and four `operations:*` artisan commands handle discovery, -code generation and the production cache. Routes stay yours to register. - -Everything below applies on Laravel too — the adapter wires this library up, it does not replace it. -What it decides on your behalf is documented separately. - +**On Laravel**, the service provider is auto-discovered, `config/operations.php` is publishable, and +four `operations:*` artisan commands handle discovery, code generation and the production cache. The +adapter wires this library up, it does not replace it — everything on this page still applies. **[→ The Laravel adapter](docs/laravel.md)** ## Quickstart @@ -119,11 +117,13 @@ final class UserOperations single primitive. `UserId` and `Email` carry a `#[Brand]`, so they are not interchangeable with a plain `number` or `string` on the TypeScript side. -Build a server over them, and generate the client: +**Build a server over them, and generate the client.** Run this from a script you commit — it is a +build step, not something the server does at runtime. ```php use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationsSpaClient; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; @@ -144,6 +144,7 @@ $files = new TypescriptServerCodeGenerator([ new EmitTypes(), new EmitOperationClientBindings(), new EmitTypeUtils(), + new EmitOperationsSpaClient(), new EmitOperations(), ])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); @@ -151,10 +152,27 @@ OutputDirectory::write(__DIR__ . '/resources/js/operations', $files); ``` The two URLs are the routes *your* transport serves; `{fqn}` is where the operation key goes, and -both are required to contain it. Run this from a script you commit — it is a build step, not -something the server does at runtime. +both are required to contain it. + +**Serve those two routes.** One GET for queries, one POST for commands. `jsonSerialize()` is the +whole envelope the generated client reads, so a transport is two lines: + +```php +use Le0daniel\PhpTsBindings\Server\Client\NullClient; + +// GET /query/{fqn} — each query parameter JSON-decoded back into a value +$result = $server->query($fqn, $input, $myContext, new NullClient()); + +// POST /command/{fqn} — the JSON body +$result = $server->command($fqn, $input, $myContext, new NullClient()); + +respondJson($result->statusCode, $result->jsonSerialize()); +``` -You get a `users.ts` module, matching the namespace: +Neither call ever throws — see [the server](docs/server.md#serving-operations-over-http) for the +full wiring, dependency injection and error reporting. + +**You get a `users.ts` module**, matching the namespace: ```typescript export type GetResult = {email:(string & Brand<"email">);slug:string;}; @@ -180,8 +198,6 @@ if (result.success) { Passing `{id: 1}` is a compile error: `number` is not assignable to `UserId`'s branded type. Sending it anyway is a 422 at runtime, because the server proves the same type it published. -What remains is [serving those two routes](#serving-operations-over-http). - ## Core concepts **`Server`** takes a registry of operations and runs one. Both methods are total — every @@ -193,35 +209,20 @@ public function command(string $name, mixed $input, mixed $context, Client $clie ``` **`$name` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns -`namespace` + `name` into what the client calls. `HashSha256KeyGenerator` hashes both parts, so a -discovered `users.get` is reachable as an opaque key rather than as `users.get`; its first -constructor argument is a pepper, and it has no default. `PlainlyExposedKeyGenerator` gives literal -keys instead. The generated TypeScript always embeds whichever key the server produced, so this only -matters when you call the server by hand. - -> **Pass one explicitly.** `eagerlyDiscover()` falls back to `HashSha256KeyGenerator` peppered with -> the string `'default'` — obfuscated keys, from a pepper anyone reading this page knows. - -Obfuscation is not a security boundary: it keeps your operation names out of the shipped bundle, and -that is all. Two operations colliding on a truncated hash is an error at discovery, not a silent -overwrite — widen `fnNameLength` if a real application ever hits it. +`namespace` + `name` into what the client calls: `PlainlyExposedKeyGenerator` gives literal keys, +`HashSha256KeyGenerator` opaque ones. The generated TypeScript always embeds whichever key the server +produced, so this only matters when you call the server by hand — but +[pass one explicitly](docs/server.md#operation-keys), because the discovery default is peppered with +a publicly known string. **`OperationRegistry`** holds the operations. `EagerlyLoadedOperationRegistry` discovers them by scanning directories; schemas are parsed lazily, per operation, on first use. -`CachedOperationRegistry` is the compiled form for production — see [Production](#production). +`CachedOperationRegistry` is the compiled form for [production](docs/server.md#production). -**`ServerAdapter`** builds your handler classes and middleware. It is two methods — -`createController()` and `createMiddleware()` — and it is the seam for dependency injection: - -| Adapter | Behaviour | -|---|---| -| `NewInstanceAdapter` | The default. Plain `new $className()`, so handlers take no constructor arguments. | -| `PsrContainerAdapter` | Resolves both through a PSR-11 container. | - -`PsrContainerAdapter` needs `psr/container`, which is a `suggest` rather than a dependency; the -default needs nothing at all. Implement the interface yourself for a container that is not PSR-11, -or to construct handlers some other way. Whatever it does, a failure to resolve is caught and -returned as an `RpcError` — that is part of what keeps `query()` and `command()` total. +**`ServerAdapter`** builds your handler classes and middleware — two methods, and the seam for +dependency injection. `NewInstanceAdapter` is the default (`new $className()`, no constructor +arguments); `PsrContainerAdapter` resolves both through a PSR-11 container. A failure to resolve is +caught and returned as an `RpcError`, which is part of what keeps the server total. **The handler contract.** Your method is called with exactly three arguments, positionally: @@ -230,173 +231,122 @@ public function get(array $input, MyContext $context, Client $client): array ``` `$input` is the parsed, validated input — already hydrated into whatever your type declares. **Its -type is the whole input contract**, so the first parameter is the one that matters. `$context` is -whatever you passed to `Server::query()`; the library never touches it. `$client` is the -[side channel](#client-directives) back to the frontend. +type is the whole input contract.** `$context` is whatever you passed to `Server::query()`; the +library never touches it. `$client` is the [side channel](docs/client-directives.md) back to the +frontend. You may declare a prefix of the three, but not a subset. An operation that takes no input +types its parameter as `null`, and every generator drops the argument. -**The input parameter is never optional.** A handler declaring no parameters is rejected at -discovery, and the one it does declare must carry a native type — an untyped parameter cannot be -reflected. A `@param` in the docblock overrides that native type, and is how you say anything PHP -itself cannot express. +**Input is parsed, output is serialized.** Input is untrusted, so every claim its type makes is +proven. Output is checked against its declared type too, and an output that does not match is a 500. +The PHPStan *refinements* on top of the type are not re-checked on the way out, because static +analysis already established those. -**An operation that takes no input types it as `null`.** The parameter stays; only its type changes: +**`RpcResult`** is the interface both outcomes implement. It carries `statusCode` — 200 on success, +the error category's own code otherwise — `resolveInfo`, `metadata`, and it is `JsonSerializable`: +`jsonSerialize()` produces the whole envelope the generated client reads. A middleware can attach +metadata with `withMetadata()` / `appendMetadata()`; it travels under `__metadata` and the library +puts nothing in it. -```php -/** - * @return array{ok: bool} - */ -#[Query('system')] -public function ping(null $input): array -{ - return ['ok' => true]; -} -``` +**[→ Operations](docs/operations.md)** for the attributes, the full signature rules and middleware. +**[→ The server](docs/server.md)** for keys, registries, HTTP, preloading and the production cache. -Every generator drops the argument for such an operation, so the TypeScript is `ping()` rather than -`ping(input)`. There is no way to omit the parameter itself. +## Design decisions -You may declare a **prefix** of the three — `($input)` and `($input, $context)` are both fine — but -not a subset, and the prefix always starts at the input. `($input, Client $client)` receives the -context in the client slot, so discovery rejects it rather than letting it fail at runtime. +**The PHPStan type is the contract.** No schema DSL, no resource class, no generated PHP to keep +beside your code. The annotation you already wrote for static analysis is the one the runtime proves +and the generator emits, so there is no second source of truth that can drift. -Middleware receives `ResolveInfo` alongside the input, describing the operation being run: -`namespace`, `name`, `operationType`, `className`, `methodName`, `middleware` (every class in the -stack) and `fullyQualifiedName`. +**It is not a validator.** It proves the types your code declares and nothing beyond them. A rule +that is not a type — "this email is not already taken" — belongs in a +[value object](docs/types.md#value-objects) or your own code, and can still +[ride the same 422](docs/errors.md#your-own-validation). -**Input is parsed, output is serialized.** Input is untrusted, so every claim its type makes is -proven. Output is checked against its declared type too, and an output that does not match is a 500 — -it is a bug in your code, not something the client can fix. The PHPStan *refinements* on top of the -type are not re-checked, because static analysis already established those. See -[refinements run on input, never on output](docs/types.md#refinements-run-on-input-never-on-output). +**Input is parsed, output is serialized.** Input arrives from outside and every claim its type makes +is proven before your handler sees it. Output is your own code, so a mismatch is a 500 rather than +something the client is asked to handle — and refinements are checked on the way in only, because +static analysis already established them on the way out. -**`RpcResult`** is the interface both outcomes implement, for the code that does not care which one -it is holding. It carries `statusCode` — 200 on success, the error category's own code otherwise — -`resolveInfo`, `metadata`, and it is `JsonSerializable`: `jsonSerialize()` produces the whole -envelope the generated client reads. A transport needs nothing else, which is why -[serving operations](#serving-operations-over-http) is two lines. +**`query()` and `command()` are total.** Every `Throwable` — including one thrown while resolving +your handler, or while working out how to present another error — comes back as an `RpcError`. +`$next()` inside a middleware never throws either, so post-processing runs whether the operation +succeeded or failed. A transport never needs a `try`. -Both results carry metadata a middleware can attach with `withMetadata()` / `appendMetadata()`. It -travels to the client under `__metadata`, and the generated envelope declares it as -`__metadata?: Record` — optional because the key is left off entirely when nothing -was attached. It is yours to shape; the library puts nothing in it. +**Six error categories, and nothing is exposed by accident.** Surfacing a domain error takes a +`#[Throws]` declaration *and* a name; everything unrecognised is a 500. The category list is closed +on purpose — it is what the server needs to run, not an extension point. -## Defining operations +**Runtime and codegen read the same attributes.** `ErrorPresenter` and the TypeScript error union +consult one source, so the generated union cannot describe responses the server does not produce. -| Attribute | Target | What it does | -|---|---|---| -| `#[Query(namespace, name)]` | method | A read operation, served over GET. | -| `#[Command(namespace, name)]` | method | A write operation, served over POST. | -| `#[Middleware(class)]` | class, method, repeatable | Middleware to run around this operation. | -| `#[Throws(ExceptionClass, as: ?string)]` | method, repeatable | Declares an exception the operation may throw, optionally naming it for the client. | -| `#[ExposeAs(type)]` | exception class | The exception's own name, for every operation that declares it. | -| `#[Optional]` | property, parameter | The field may be absent from input. | -| `#[Castable(strategy)]` | class | A plain class may be built from input. | -| `#[Brand(name)]` | class | Makes the generated TypeScript type opaque. | -| `#[Named(name)]` | class | Exports the type once by name instead of inlining it. | +**Codegen is a build step.** The client lives in your repo, nothing is published to npm, and +`OutputDirectory` only ever touches files carrying its own marker — so it cannot delete or overwrite +something you wrote. `verify()` runs the same rules without writing, which is your CI drift check. -`namespace` defaults to `global` and becomes the generated TypeScript module, so it has to be a -usable file name: letters, digits, `-` and `_`. It accepts a `UnitEnum` as well as a string, so you -can keep namespaces in an enum — note that a *backed* enum contributes its value and a pure enum its -case name, so adding `: string` to an existing namespace enum changes every generated module and -wire key. `name` defaults to the method name and is a string only. +**No dishonest types.** Generation throws rather than emit a placeholder for something it cannot +represent. That is also why the envelope names `__client` but types it `unknown`: the key is +first-party, the schema belongs to whichever `Client` produced it, and claiming to know it would be +a lie. -Two operations of the same type resolving to the same `namespace.name` fail discovery. A query and a -command *may* share one, but both land in the same generated module, so unless the naming rule tells -them apart the code generator rejects them rather than emit two functions of the same name. +**Directives ride the success branch only.** A handler that toasts `'Saved'` and then throws must not +have the browser announce work that did not happen, so `RpcError` holds no client at all rather than +leaving each transport to remember. -`#[Brand]`, `#[Named]`, `#[Castable]` and `#[Optional]` are covered in -[the type reference](docs/types.md). +**Property order is canonical.** Struct keys are sorted by name, so reordering a PHP property or a +constructor parameter is not a change to the generated type. Enums travel as their **case names**, +not their backing values, unless the class opts in by implementing `StringValueObject`. -## Middleware +**Zero dependencies, no framework coupling.** Every integration point is an interface — +`ServerAdapter`, `OperationKeyGenerator`, `OperationRegistry`, `Client`, and the generator contracts. +Laravel is an adapter over those seams, not a requirement. -A middleware wraps the operation. Implement `MiddlewareContract`: +One thing obfuscated operation keys are *not* is a security boundary: they keep your operation names +out of the shipped bundle, and that is all. See [operation keys](docs/server.md#operation-keys). -```php -use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; +## Errors -/** - * @implements MiddlewareContract - */ -final class NameCheckingMiddleware implements MiddlewareContract -{ - #[Throws(InvalidNameException::class)] - public function handle( - mixed $input, - Closure $next, - mixed $context, - ResolveInfo $info, - Client $client, - ): RpcSuccess|RpcError - { - if (is_array($input) && ($input['name'] ?? null) === 'invalid') { - throw new InvalidNameException(); - } +Every failure the client can see is one of six categories: - return $next($input); - } -} -``` +| Code | `type` | When | +|---|---|---| +| 422 | `INVALID_INPUT` | The input did not match its type | +| 401 | `AUTHENTICATION_ERROR` | An exception you mapped as unauthenticated | +| 403 | `AUTHORIZATION_ERROR` | An exception you mapped as unauthorized | +| 404 | `NOT_FOUND` | Unknown operation, or an exception you mapped as not-found | +| 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* gave a name | +| 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | -**`$next()` never throws.** A failure deeper in the pipeline is converted to an `RpcError` at the -ring where it happened and handed back to you as `$next()`'s return value, so post-processing runs -whether the operation succeeded or not. +The table is in resolution order, and the first match wins. That order is why `DOMAIN_ERROR` sits +second to last: an exception you have explicitly mapped onto a category stays in that category even +when it is named for the client. -Attach it per operation or per class. `#[Middleware]` takes one class and is repeatable, so stack it: +Exposing a domain error takes both a declaration and a name — `#[Throws]` on the operation, and +either `as:` on that declaration or `#[ExposeAs]` on the exception class: ```php #[Command('users')] -#[Middleware(AuthMiddleware::class)] -#[Middleware(NameCheckingMiddleware::class)] +#[Throws(InvalidNameException::class, as: 'invalid-name')] public function create(array $input): array { /* ... */ } ``` -or globally, for every operation on the server: - -```php -new ServerConfiguration()->withMiddlewares(AuthMiddleware::class, LoggingMiddleware::class) +```json +{"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"type": "invalid-name"}} ``` -**Order is outermost first.** Global middleware wraps class-level `#[Middleware]`, which wraps -method-level, and within each group they run in declaration order. The first one listed is the first -to see the input and the last to see the result. - -`#[Throws]` on a middleware's `handle()` contributes to the error union of every operation it wraps — -globally configured or attached with `#[Middleware]`, both count — so the generated TypeScript knows -about middleware failures too. It takes `as` like any other declaration, and when an operation and -its middleware declare the same exception, the operation's name wins. - -### The rest of `ServerConfiguration` +Which categories an operation can produce is what the generated union says, and it says nothing else: -The same object carries the server's other settings, and is where all three are set. - -`withExceptions()` maps your exceptions onto the [error categories](#errors). Matching is -`instanceof`, so listing a base class covers its subclasses, and an omitted category is left -untouched: - -```php -new ServerConfiguration()->withExceptions( - notFound: [EntityNotFoundException::class], - unauthenticated: [NotLoggedInException::class], - unauthorized: [ForbiddenException::class], -) +```typescript +export type CreateError = + {code: 422, type: "INVALID_INPUT", details: {fields: Record}} + | {code: 404, type: "NOT_FOUND"} + | {code: 500, type: "INTERNAL_ERROR"}; ``` -Without this, nothing produces a 401, 403 or 404 except an unknown operation — every other -exception is a 500. - -`coerceQueryInput` (default `false`) applies to **queries only**, and exists because a URL carries no -types. The generated client JSON-encodes each query value, so a transport that decodes it again -receives `?id=1` as the integer `1` and has nothing to coerce. Turn this on when requests reach you -from somewhere that does not round-trip — a hand-written URL, a form, a transport of your own — and -leaf primitives are coerced to the declared type before validation instead of failing it: +`details` appears only where the category cannot say everything on its own — `INVALID_INPUT` carries +`fields`, `DOMAIN_ERROR` carries `type` — and is absent everywhere else, which is exactly what the +generated branches declare. -```php -new ServerConfiguration(coerceQueryInput: true) -``` - -Because it applies to queries only, the same input shape validates differently depending on whether -it is reached through `#[Query]` or `#[Command]`. Coercion never invents a value: only scalars are -cast, and anything else is left for the schema to reject. +**[→ Errors](docs/errors.md)** — the full mechanics, `InvalidInputException::createFromMessages()` +for your own validation, and the exception hierarchy this library throws at build time. ## Types @@ -423,489 +373,22 @@ the generated type. **An enum travels as its case names, not its backing values.** `MyEnum` emits `("OPEN"|"SHIPPED")` even when it is `enum MyEnum: string { case OPEN = 'open'; }`. A backed enum that should travel as -its backing value opts in by implementing `StringValueObject` — see -[value objects](docs/types.md#value-objects). +its backing value opts in by implementing `StringValueObject`. Local and imported types work too: `@phpstan-type` and `@phpstan-import-type` are resolved against the declaring class, as are `use` statements and generics. -### Not supported - -The parser understands a subset of PHPStan, not all of it. These are valid PHPStan that it rejects, -with an `InvalidSyntaxException` when the schema is parsed: - -`class-string` · `key-of` · `value-of` · `int-mask` · `int-mask-of` · `callable(…)` · -`Closure(…): T` · `iterable` · `array{foo: int, ...}` (unsealed) · `array{}` · -`($x is int ? A : B)` · `Foo` · `$this` · `static` · `self` · -bare `array` / `list` / `non-empty-array` (without generics) - -Two traps worth knowing up front. Bare `object` is not an alias for `unknown` — it is a syntax -error; write `object{…}` with the shape. And bare `array` is not `Array`: PHPStan reads it -as `array`, which permits string keys, so there is no one TypeScript type it means. -Write `list`, `array` or `array`. +**Not everything.** The parser understands a subset of PHPStan, and rejects the rest with an +`InvalidSyntaxException` when the schema is parsed — `class-string`, `key-of`, `callable(…)`, +unsealed `array{foo: int, ...}`, conditional types and more. Two traps worth knowing up front: bare +`object` is a syntax error, not an alias for `unknown` (write `object{…}` with the shape), and bare +`array` is not `Array` — PHPStan reads it as `array`, which permits string +keys, so write `list`, `array` or `array`. **[→ Full type reference](docs/types.md)** — refinements, utility types (`Pick`, `Omit`, `BrandedString`, `DateTimeString`), value objects, `#[Castable]`, brands and named types, and the [full list of what is not supported](docs/types.md#not-supported). -## Errors - -Every failure the client can see is one of six categories: - -| Code | `type` | When | -|---|---|---| -| 422 | `INVALID_INPUT` | The input did not match its type | -| 401 | `AUTHENTICATION_ERROR` | An exception you mapped as unauthenticated | -| 403 | `AUTHORIZATION_ERROR` | An exception you mapped as unauthorized | -| 404 | `NOT_FOUND` | Unknown operation, or an exception you mapped as not-found | -| 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* gave a name | -| 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | - -The table is in resolution order, and the first match wins. That order is why `DOMAIN_ERROR` sits -second to last: an exception you have explicitly mapped onto a category stays in that category even -when it is named for the client. Anything unrecognised is a 500 — an exception is never exposed by -accident. - -**Exposing a domain error takes a declaration and a name.** The operation declares that it can -throw the exception, and something gives that exception a name the client sees. The exception can -carry its own: - -```php -#[ExposeAs('invalid_name')] -final class InvalidNameException extends Exception {} - -#[Command('users')] -#[Throws(InvalidNameException::class)] -public function create(array $input): array { /* ... */ } -``` - -```json -{"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"type": "invalid_name"}} -``` - -Or the declaration can name it on the spot with `as`, which needs no `#[ExposeAs]` at all — the -point being that the exception does not have to be yours to annotate: - -```php -#[Command('users')] -#[Throws(InvalidNameException::class, as: 'invalid-name')] -public function create(array $input): array { /* ... */ } -``` - -`as` always wins over `#[ExposeAs]`, so the same exception can read differently per operation. What -`as` does not do is skip the declaration: an exception no operation declares with `#[Throws]` is -still a 500, and so is one that is declared but named nowhere. - -Because both the runtime and the code generator read those attributes from the same place, the -generated error union cannot drift from the responses it describes. An operation that declares -nothing gets: - -```typescript -export type CreateError = - {code: 422, type: "INVALID_INPUT", details: {fields: Record}} - | {code: 404, type: "NOT_FOUND"} - | {code: 500, type: "INTERNAL_ERROR"}; -``` - -The 401 and 403 branches appear only once you have actually mapped exceptions onto them, so the -union describes what this server can really produce. - -**`details` only appears where the category cannot say everything on its own**, which is exactly two -of the six: `INVALID_INPUT` carries `fields`, and `DOMAIN_ERROR` carries the `type` naming which -domain error it is. For the other four, `code` and `type` are the whole answer and restating it -under `details` would put the same string on the wire twice, so the key is absent — and the -generated branch has no such property, so narrowing on `type` will not offer you one. - -Validation failures carry `fields`, keyed by dotted path (`__root` for the top level) with -localization keys as values, e.g. `{"email": ["validation.not_empty_string"]}`. - -**Your own validation can ride the same wire.** This library proves types and refuses to grow into a -validator, but the 422 shape is a perfectly good transport for the rules it will not check for you: -`InvalidInputException::createFromMessages(['email' => ['Already taken']])` produces one from any -field-to-message map. Throw it from a handler or a middleware and the client reads it exactly like a -type failure. - -## Serving operations over HTTP - -The server runs an operation; turning that into an HTTP response is yours to write. Two routes are -enough — one GET for queries, one POST for commands — and both must carry the operation key. - -```php -use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; -use Le0daniel\PhpTsBindings\Server\Client\NullClient; -use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; -use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; -use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; -use Le0daniel\PhpTsBindings\Server\Server; - -$server = new Server( - EagerlyLoadedOperationRegistry::eagerlyDiscover( - __DIR__ . '/src/Operations', - keyGenerator: new PlainlyExposedKeyGenerator(), - ), - adapter: new PsrContainerAdapter($psrContainer), - configuration: new ServerConfiguration() - ->withMiddlewares(AuthMiddleware::class) - ->withExceptions( - notFound: [EntityNotFoundException::class], - unauthenticated: [NotLoggedInException::class], - ), -); - -$result = $server->command('users.create', $input, $myContext, new NullClient()); - -// jsonSerialize() is the envelope the generated client reads, and the only thing that gets it -// exactly right: `details` is omitted rather than sent as null on the categories that have none, -// which is what the generated union declares. -respondJson($result->statusCode, $result->jsonSerialize()); -``` - -`$result->statusCode` is the HTTP code for both outcomes — 200 on success, and the error category's -own code otherwise. `ErrorType` doubles as that code, so `$result->type->value` is the same number -and `$result->type->name` the string the client matches on: the two cannot disagree. - -`$result->cause` is the most recent `Throwable` on every error, ready to hand to your reporter, and -`$result->previous` is a list of everything that failed before it, oldest first. It is empty on an -ordinary error. On the rare occasion that working out how to present an error *itself* failed — a -stale middleware class name, say — that second exception is the `cause`, and the one your -application threw is in `previous`. `$result->throwableChain()` gives you all of them in order, which -is what you want to loop over when reporting: - -```php -foreach ($result->throwableChain() as $throwable) { - $reporter->report($throwable); -} -``` - -Whatever your transport does with the input, the shape it hands the server has to match what the -generated client sends: for queries, each value JSON-encoded into its own query parameter; for -commands, a JSON body. Decoding query values back is what lets you leave -[`coerceQueryInput`](#the-rest-of-serverconfiguration) off. - -To emit client directives, pass an `OperationSPAClient` instead of a `NullClient`. There is nothing -else to do: the success carries the client, so `jsonSerialize()` asks it for its payload and puts it -under `__client` for you. - -```php -$result = $server->command('users.create', $input, $myContext, new OperationSPAClient()); - -respondJson($result->statusCode, $result->jsonSerialize()); -``` - -**Directives ride the success branch only.** A failure carries none, even for the directives that -were queued before it — a handler that toasts `'Saved'` and then throws must not have the browser -announce work that did not happen. That is why `RpcError` holds no client at all, rather than -leaving it to each transport to remember. - -## The generated TypeScript client - -`TypescriptServerCodeGenerator` writes a self-contained client, and `OutputDirectory::write()` puts -it on disk. Nothing is published to npm; the code lives in your repo. - -``` -/ - lib/types.ts Success/Failure/Result, Brand, every #[Named] alias - lib/OperationClient.ts the transport interface - lib/DefaultClient.ts a fetch implementation of it - lib/OperationException.ts - lib/bindings.ts createDefaultClient, setClient, executeOperation - lib/utils.ts queryKey, throwOnFailure - lib/client-operations-spa.ts the OperationSPAClient payload and its guard - .ts one module per namespace, one function per operation -``` - -That is what the five default generators produce. The [optional ones](#optional-generators) add to -it: `EmitTypeMap` writes one more file, the other two write into the `.ts` modules that -are already there. - -Every generated file opens with `// generated by: php-ts-bindings`. That marker is what -`OutputDirectory` uses to tell its own output from anything else in the directory: it removes only -files carrying it, and refuses to overwrite one that does not. -`OutputDirectory::verify()` applies the same rules without writing — it returns one message per -problem, and an empty list means the directory is up to date. Run it in CI to catch a frontend that -has drifted from the backend. - -The envelope every call resolves to: - -```typescript -export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} -export type Failure = {success: false, __metadata?: Record} & E; -export type Result = Success | Failure; -``` - -That is the whole envelope, and both extra keys are the library's own — `jsonSerialize()` writes -them, so the generated types name them rather than leaving you to discover them on the wire. Each is -optional, because the key is left off entirely when there is nothing to say. - -They differ in how much they can honestly claim. `__metadata` is `Record` on both -branches: whatever a middleware attached, always a string-keyed bag, serialized the same way on -either outcome. `__client` is `unknown` and success-only — the *key* is first-party, but its shape -belongs to whichever [`Client`](#client-directives) produced it, and a failure carries no directives -at all. Naming it without describing it is the honest half: you know to look there, and the guard -that shipped with your client is what makes it typed. - -Wire it up once: - -```typescript -import {createDefaultClient, setClient} from './operations/lib/bindings'; - -setClient(createDefaultClient()); -setClient(createDefaultClient(fetch, {baseUrl: 'https://api.example.com', timeoutMs: 5_000})); -``` - -`setClient()` sets one module-global client, so it has to run before the first call — otherwise you -get `Error('No client set')` at whichever call site happened to be first. - -Every generated function takes an optional second argument: - -```typescript -export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; -``` - -`DefaultClient` sends queries as GET with each input value JSON-encoded into a query parameter, and -commands as POST with a JSON body. `signal` and `timeoutMs` are joined into one `AbortSignal`, and a -per-call `timeoutMs` overrides the client's default (10s unless you set one). `registerHook(hook)` -runs a callback on every response and returns a function that unregisters it. Swap the whole -transport by implementing `OperationClient` — `setClient()` and the per-call `options.client` both -take one. - -`throwOnFailure(result)`, from `lib/utils.ts`, narrows a `Result` to its success branch and throws an -`OperationException` otherwise, for call sites that would rather not branch. A `catch` variable is -`unknown` in TypeScript whatever was thrown, so name the operation's error union at the guard to get -it back: - -```typescript -try { - const result = await get({id: userId}); - throwOnFailure(result); - return result.data; -} catch (e) { - if (OperationException.is(e)) { - e.cause.type; // "INVALID_INPUT" | "NOT_FOUND" | ... - e.code; // the HTTP code, 500 if the payload had none - } - throw e; -} -``` - -### Optional generators - -The generator list you hand `TypescriptServerCodeGenerator` *is* the configuration — there is no -separate switch. Eight ship: - -| Generator | In the quickstart | Emits | -|---|---|---| -| `EmitTypes` | yes | `lib/types.ts` — the envelope, `Brand`, every `#[Named]` alias | -| `EmitOperationClientBindings` | yes | `lib/bindings.ts`, `lib/OperationClient.ts`, `lib/DefaultClient.ts`, `lib/OperationException.ts` | -| `EmitTypeUtils` | yes | `lib/utils.ts` — `queryKey` and `throwOnFailure` | -| `EmitOperationsSpaClient` | yes | `lib/client-operations-spa.ts` — the `OperationSPAClient` payload and `containsOperationSpaPayload()` | -| `EmitOperations` | yes | one `.ts` module per namespace | -| `EmitTanstackQuery` | no | `QueryOptions()` and `useQuery()` for `@tanstack/react-query` | -| `EmitQueryKey` | no | standalone query keys | -| `EmitTypeMap` | no | `lib/type-map.ts` — a `TypeMap` of every operation's input, output and error types, split into `{query: …, command: …}` and keyed by `namespace.name` | - -`EmitTanstackQuery` and `EmitQueryKey` emit **only for queries**, since a command has nothing to -cache. - -`new EmitOperations($closure)` takes a `Closure(TypedOperation): string` that names the generated -functions; the default is the operation's bare name. `generate()` takes a third argument, a list of -namespaces (or `namespace.name` operations) to skip. - -Write your own by implementing `GeneratesLibFiles` (gets every operation, writes shared lib files) or -`GeneratesOperationCode` (gets one operation, writes its code), and adding it to the list. - -Add `DependsOn` to declare the generators yours needs: the run fails early if one is missing, and -hands you the resolved instances through `setDependencies()`. That is how to reference what another -generator emitted — ask `EmitOperations` for `inputTypeName($operation)` rather than rebuilding the -name and hoping it matches, and ask `EmitTypes` for `importFromTypes(types: ['Order'])` rather than -writing the module specifier yourself. Because those methods are not static, an import can only ever -name a file a registered generator actually writes. - -Hand your imports to `TypescriptFile` instead of writing `import` lines into the code: only then are -they merged with what the other generators contribute to the same file, and only then is the path -resolved for a file that lands in `lib/`. - -A few contracts worth knowing when writing one: - -- A `GeneratesLibFiles` key is a bare module name — `[a-zA-Z0-9_-]+`, no `.ts` — and always lands in - `lib/`. Several generators may return the same key; their output accumulates rather than - overwriting. -- `TypedOperation::$hasInput` is false when the operation's input type is `null` — the - [no-input form](#core-concepts). Every generator that emits a signature has to drop the argument - in that case. -- Return `null` from `generateOperationCode()` to emit nothing for an operation. - -## Client directives - -Optional, and unrelated to type safety: the `Client` passed to every handler is a side channel for -telling a single-page app what to do alongside the data. - -```php -public function create(array $input, mixed $context, Client $client): array -{ - $client->success('Saved'); - $client->redirect('/docs/123', reload: true); - $client->invalidate('users', '123'); - - return ['id' => '123']; -} -``` - -This is the one place the library ships a specific implementation of an extension point rather than -a contract. `OperationSPAClient` is meant to be picked when the request carries -`X-Client-Id: operations-spa` — exactly that header, exactly that value — and any other request gets -a `NullClient` whose every method is a no-op, so handlers never need to know which kind is on the -other end, and nothing warns when a directive goes nowhere. Choosing between them is the transport's -job; the core never inspects a request. - -Under `operations-spa` those calls land in a `__client` key next to the data, on a **successful** -response: - -```json -{ - "success": true, - "data": {"id": "123"}, - "__client": { - "redirect": {"url": "/docs/123", "reload": true}, - "toasts": [{"type": "success", "message": "Saved"}], - "invalidations": [["users", "123"]], - "type": "operations-spa" - } -} -``` - -The full interface is `redirect()`, `invalidate()`, `toast()`, and one shorthand per toast type — -`success()`, `error()`, `warning()`, `alert()` and `info()`. Keys are only present when something -called for them. `RpcSuccess::jsonSerialize()` puts the payload on the response by asking the client -for it — `SerializableClient::serializeToArray()` — so a transport that serializes the result gets -this for free, and one that builds its own body calls the same method. - -**A failure carries no directives**, including the ones queued before it: a toast for work that was -rolled back is worse than no toast, so `RpcError` holds no client and there is nothing to serialize. -Tell the user about a failure from the error branch the generated union already gives you. - -**The envelope names `__client` but declares it `unknown`, on purpose.** The key is the library's — -`RpcSuccess::jsonSerialize()` writes it, so `lib/types.ts` says it may be there. The *shape* is not: -`Client` is an extension point, and your own implementation may define an entirely different set of -directives under a different schema, so neither `lib/types.ts` nor the transport interface commits to -one it cannot know. The payload travels through `DefaultClient` untouched; what is withheld is only -the claim about what it is. - -`OperationSPAClient` is the subset this library deems useful and ships, and it gets its own file. -`lib/client-operations-spa.ts` declares `OperationsClientPayload` — the same schema -`serializeToArray()` emits — and one guard that puts it on a result: - -```typescript -import {containsOperationSpaPayload} from './operations/lib/client-operations-spa'; - -const result = await create({name: 'Leo'}); - -if (containsOperationSpaPayload(result)) { - for (const toast of result.__client.toasts ?? []) { … } // ClientToast, fully typed - result.__client.redirect?.url; -} -``` - -The check is the discriminator alone. The payload is assembled in one pass, so a server that wrote -`type: "operations-spa"` wrote the rest of it to the same schema, and unknown keys are ignored either -way — adding a directive stays backwards compatible. - -The file is emitted by `EmitOperationsSpaClient`, on by default. Drop it with -`--without operations-spa` and nothing else changes; write your own guard against your own -directives, which is the same "no dishonest types" rule that makes the generator throw rather than -emit a placeholder. - -## Preloading a query - -`Preloader` runs a query server-side during the request that renders the page, so the data is in the -page instead of being fetched after it loads. It takes the `Server` and an `OperationKeyGenerator` — -which has to be the one the server's registry uses, or the key it derives names no operation and -every preload throws: - -```php -use Le0daniel\PhpTsBindings\Server\Preloader; - -$preloader = new Preloader($server, $keyGenerator); - -$users = $preloader->preload('users', 'get', ['id' => 1], $context); -``` - -You get back `['response' => …, 'queryKey' => ['users', 'get', ['id' => 1]]]`. The key is built the -same way `EmitQueryKey` and `EmitTanstackQuery` build it, so a TanStack cache seeded with that pair -will not refetch. The input is part of the key whenever the operation has one, even when the value is -`null`; an operation whose input type *is* `null` gets a two-element key. - -`preloadMany()` takes several at once, as -`[['namespace' => …, 'name' => …, 'input' => …], …]`. A query that fails throws a `SchemaException` — -this is your own code calling your own operation, not untrusted input. - -## Production - -Reflecting and parsing every schema on every request is real overhead. `CachedOperationRegistry` is -the compiled form of a registry: every schema pre-parsed, deduplicated and pooled — shared structs -emitted once and referenced, and unions reordered for faster dispatch. Compile it once, at deploy -time, and load it instead of discovering. - -A cache that no longer matches the code asking it fails loudly, at runtime, with an -`UnknownTypeKeyException` — recompile it, or drop it and fall back to discovery. - -> Operation keys are derived from the key generator, so changing it — including upgrading a version -> that changed how keys are derived — invalidates both the cache and the generated client. -> Recompile and regenerate together. - -The same optimizer is available directly, for schemas that are not operations: - -```php -use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer; -use Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry; - -new ASTOptimizer()->optimizeAndWriteToFile('asts.php', [ - 'MyClass@method@input' => $inputAst, - 'MyClass@method@output' => $outputAst, -]); - -/** @var CachedTypeRegistry $registry */ -$registry = require 'asts.php'; -$ast = $registry->get('MyClass@method@input'); -``` - -Keys are interned as truncated hashes; `new ASTOptimizer(idLength: 12)` widens them if a run ever -reports a collision, which it does by throwing rather than by merging two schemas. - -## Extension points and exceptions - -The interfaces meant to be implemented by you: - -| Contract | For | -|---|---| -| `MiddlewareContract` | Wrapping an operation. | -| `ServerAdapter` | Constructing handlers and middleware — the DI seam. | -| `OperationKeyGenerator` | Turning `namespace` + `name` into the key the client calls. | -| `OperationRegistry` | Holding operations, if neither shipped registry fits. | -| `Client` / `SerializableClient` | Your own side channel and its wire payload. | -| `StringValueObject` / `IntValueObject` | A class that travels as one primitive. | -| `GeneratesLibFiles` / `GeneratesOperationCode` / `DependsOn` | Adding to the generated client. | - -Two more knobs on discovery: `new OperationDiscovery($filterFn)` takes a closure returning `false` to -keep an operation out of the registry, and `EagerlyLoadedOperationRegistry::withClasses([...])` -registers a list of classes instead of scanning directories. - -The lower-level `TypeParser`, `SchemaExecutor` and `TypescriptGenerator` are public too, if you want -to parse and emit types without the RPC layer at all — see [the type reference](docs/types.md). - -**Exceptions.** Everything this library throws implements `PhpTsBindingsException`, so one `catch` -covers all of it. Below that are three subsystem bases: - -| Exception | Thrown when | -|---|---| -| `ParserException` | A schema cannot be built — includes `InvalidSyntaxException` (the type is not in the supported subset), `UnexpectedCharacterException` (it does not lex) and `UnknownTypeKeyException` (the optimized cache no longer matches the code). | -| `SchemaException` | An operation is malformed — a name or key collision, a bad handler signature, a class that is not middleware. `InvalidInputException`, `InvalidOutputException` and `OperationNotFoundException` extend it and reach you as `RpcError::$cause`. | -| `CodeGenException` | Generation cannot produce valid output — includes `UnsupportedTypeException` (no honest TypeScript for a schema), `InvalidStringLiteralException` (a brand or alias is not an identifier) and `InvalidGeneratorDependencies` (whose `$messages` names each missing generator). | - -Nothing is thrown out of `Server::query()` or `Server::command()` — both are total, and every -`Throwable` comes back as an `RpcError`. The exceptions above surface at discovery, at parse time or -during code generation instead, which is to say: at build time, not at request time. - ## Contributing ```bash diff --git a/docs/client-directives.md b/docs/client-directives.md new file mode 100644 index 0000000..a7ee627 --- /dev/null +++ b/docs/client-directives.md @@ -0,0 +1,98 @@ +# Client directives + +Optional, and unrelated to type safety: the `Client` passed to every handler is a side channel for +telling a single-page app what to do alongside the data. The short version lives in the +[README](../README.md); this is the full picture. + +- [The side channel](#the-side-channel) +- [The payload](#the-payload) +- [Failures carry no directives](#failures-carry-no-directives) +- [Why the envelope says `unknown`](#why-the-envelope-says-unknown) +- [Reading it on the frontend](#reading-it-on-the-frontend) + +## The side channel + +```php +public function create(array $input, mixed $context, Client $client): array +{ + $client->success('Saved'); + $client->redirect('/docs/123', reload: true); + $client->invalidate('users', '123'); + + return ['id' => '123']; +} +``` + +The full interface is `redirect()`, `invalidate()`, `toast()`, and one shorthand per toast type — +`success()`, `error()`, `warning()`, `alert()` and `info()`. + +This is the one place the library ships a specific implementation of an extension point rather than +a contract. `OperationSPAClient` is meant to be picked when the request carries +`X-Client-Id: operations-spa` — exactly that header, exactly that value — and any other request gets +a `NullClient` whose every method is a no-op, so handlers never need to know which kind is on the +other end, and nothing warns when a directive goes nowhere. Choosing between them is the transport's +job; the core never inspects a request. The [Laravel adapter](laravel.md#requests) does it for you. + +## The payload + +Under `operations-spa` those calls land in a `__client` key next to the data, on a **successful** +response: + +```json +{ + "success": true, + "data": {"id": "123"}, + "__client": { + "redirect": {"url": "/docs/123", "reload": true}, + "toasts": [{"type": "success", "message": "Saved"}], + "invalidations": [["users", "123"]], + "type": "operations-spa" + } +} +``` + +Keys are only present when something called for them. `RpcSuccess::jsonSerialize()` puts the payload +on the response by asking the client for it — `SerializableClient::serializeToArray()` — so a +transport that serializes the result gets this for free, and one that builds its own body calls the +same method. + +## Failures carry no directives + +Including the ones queued before the failure: a toast for work that was rolled back is worse than no +toast, so `RpcError` holds no client and there is nothing to serialize. Tell the user about a failure +from [the error branch](errors.md) the generated union already gives you. + +## Why the envelope says `unknown` + +**The envelope names `__client` but declares it `unknown`, on purpose.** The key is the library's — +`RpcSuccess::jsonSerialize()` writes it, so `lib/types.ts` says it may be there. The *shape* is not: +`Client` is an extension point, and your own implementation may define an entirely different set of +directives under a different schema, so neither `lib/types.ts` nor the transport interface commits to +one it cannot know. The payload travels through `DefaultClient` untouched; what is withheld is only +the claim about what it is. + +## Reading it on the frontend + +`OperationSPAClient` is the subset this library deems useful and ships, and it gets its own file. +`lib/client-operations-spa.ts` declares `OperationsClientPayload` — the same schema +`serializeToArray()` emits — and one guard that puts it on a result: + +```typescript +import {containsOperationSpaPayload} from './operations/lib/client-operations-spa'; + +const result = await create({name: 'Leo'}); + +if (containsOperationSpaPayload(result)) { + for (const toast of result.__client.toasts ?? []) { … } // ClientToast, fully typed + result.__client.redirect?.url; +} +``` + +The check is the discriminator alone. The payload is assembled in one pass, so a server that wrote +`type: "operations-spa"` wrote the rest of it to the same schema, and unknown keys are ignored either +way — adding a directive stays backwards compatible. + +The file is emitted by [`EmitOperationsSpaClient`](typescript-client.md#generators), on by default. +Drop it with `--without operations-spa` and nothing else changes; write your own guard against your +own directives, which is the same "no dishonest types" rule that makes the generator throw rather +than emit a placeholder. diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..9fb8f6b --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,130 @@ +# Errors + +Two error models live in this library, and they never meet. One is the finite set of categories a +client can see; the other is the exceptions the library itself throws, all of them at build time. +The short version lives in the [README](../README.md); this is the full picture. + +- [The six categories](#the-six-categories) +- [Exposing a domain error](#exposing-a-domain-error) +- [The generated error union](#the-generated-error-union) +- [When `details` appears](#when-details-appears) +- [Your own validation](#your-own-validation) +- [Exceptions this library throws](#exceptions-this-library-throws) + +## The six categories + +Every failure the client can see is one of six: + +| Code | `type` | When | +|---|---|---| +| 422 | `INVALID_INPUT` | The input did not match its type | +| 401 | `AUTHENTICATION_ERROR` | An exception you mapped as unauthenticated | +| 403 | `AUTHORIZATION_ERROR` | An exception you mapped as unauthorized | +| 404 | `NOT_FOUND` | Unknown operation, or an exception you mapped as not-found | +| 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* gave a name | +| 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | + +The table is in resolution order, and the first match wins. That order is why `DOMAIN_ERROR` sits +second to last: an exception you have explicitly mapped onto a category stays in that category even +when it is named for the client. Anything unrecognised is a 500 — an exception is never exposed by +accident. + +The catalogue is closed. It is `ErrorType`, an `int`-backed enum whose value *is* the HTTP status +code, which is why `$result->type->value` and `$result->statusCode` cannot disagree, and why +`$result->type->name` is exactly the string the client matches on. What an application configures is +which of *its* exceptions belong in which category, with +[`ServerConfiguration::withExceptions()`](operations.md#serverconfiguration) — not which categories +exist. + +## Exposing a domain error + +**It takes a declaration and a name.** The operation declares that it can throw the exception, and +something gives that exception a name the client sees. The exception can carry its own: + +```php +#[ExposeAs('invalid_name')] +final class InvalidNameException extends Exception {} + +#[Command('users')] +#[Throws(InvalidNameException::class)] +public function create(array $input): array { /* ... */ } +``` + +```json +{"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"type": "invalid_name"}} +``` + +Or the declaration can name it on the spot with `as`, which needs no `#[ExposeAs]` at all — the +point being that the exception does not have to be yours to annotate: + +```php +#[Command('users')] +#[Throws(InvalidNameException::class, as: 'invalid-name')] +public function create(array $input): array { /* ... */ } +``` + +`as` always wins over `#[ExposeAs]`, so the same exception can read differently per operation. What +`as` does not do is skip the declaration: an exception no operation declares with `#[Throws]` is +still a 500, and so is one that is declared but named nowhere. + +`#[Throws]` on a [middleware's](operations.md#middleware) `handle()` counts as a declaration for +every operation that middleware wraps. When an operation and its middleware declare the same +exception, the operation's name wins. + +## The generated error union + +Because both the runtime and the code generator read those attributes from the same place, the +generated error union cannot drift from the responses it describes. An operation that declares +nothing gets: + +```typescript +export type CreateError = + {code: 422, type: "INVALID_INPUT", details: {fields: Record}} + | {code: 404, type: "NOT_FOUND"} + | {code: 500, type: "INTERNAL_ERROR"}; +``` + +The 401 and 403 branches appear only once you have actually mapped exceptions onto them, so the +union describes what this server can really produce. + +## When `details` appears + +**`details` only appears where the category cannot say everything on its own**, which is exactly two +of the six: `INVALID_INPUT` carries `fields`, and `DOMAIN_ERROR` carries the `type` naming which +domain error it is. For the other four, `code` and `type` are the whole answer and restating it +under `details` would put the same string on the wire twice, so the key is absent — and the +generated branch has no such property, so narrowing on `type` will not offer you one. + +This is why `jsonSerialize()` is the only thing that gets the envelope exactly right: it omits the +key rather than sending `null`, which is what the generated union declares. + +Validation failures carry `fields`, keyed by dotted path (`__root` for the top level) with +localization keys as values, e.g. `{"email": ["validation.not_empty_string"]}`. + +## Your own validation + +This library proves types and refuses to grow into a validator, but the 422 shape is a perfectly +good transport for the rules it will not check for you: + +```php +throw InvalidInputException::createFromMessages(['email' => ['Already taken']]); +``` + +That produces a 422 from any field-to-message map. Throw it from a handler or a middleware and the +client reads it exactly like a type failure — same branch, same `details.fields`. + +## Exceptions this library throws + +A different model entirely: these are *not* what a client sees. Everything this library throws +implements `PhpTsBindingsException`, so one `catch` covers all of it. Below that are three subsystem +bases: + +| Exception | Thrown when | +|---|---| +| `ParserException` | A schema cannot be built — includes `InvalidSyntaxException` (the type is not in the supported subset), `UnexpectedCharacterException` (it does not lex) and `UnknownTypeKeyException` (the optimized cache no longer matches the code). | +| `SchemaException` | An operation is malformed — a name or key collision, a bad handler signature, a class that is not middleware. `InvalidInputException`, `InvalidOutputException` and `OperationNotFoundException` extend it and reach you as `RpcError::$cause`. | +| `CodeGenException` | Generation cannot produce valid output — includes `UnsupportedTypeException` (no honest TypeScript for a schema), `InvalidStringLiteralException` (a brand or alias is not an identifier) and `InvalidGeneratorDependencies` (whose `$messages` names each missing generator). | + +Nothing is thrown out of `Server::query()` or `Server::command()` — both are total, and every +`Throwable` comes back as an `RpcError`. The exceptions above surface at discovery, at parse time or +during code generation instead, which is to say: at build time, not at request time. diff --git a/docs/laravel.md b/docs/laravel.md index 2d2ea64..101b51c 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -4,8 +4,9 @@ A first-party adapter for [php-ts-bindings](../README.md). It is optional: the l nothing but PHP 8.5, and everything Laravel-aware lives under `src/Adapters/Laravel/`. This document covers what the adapter *adds* and what it *decides on your behalf*. For what an -operation is, how middleware works, what the error categories mean and what the generated client -looks like, see the [README](../README.md). +operation is see [operations](operations.md), for what the error categories mean see +[errors](errors.md), and for what the generated client looks like see +[the TypeScript client](typescript-client.md). - [Setup](#setup) - [Configuration](#configuration) @@ -132,7 +133,7 @@ Everything the provider and the HTTP controller pick without asking. `key.mode` to `plain` if you would rather read your own URLs. - **`coerceQueryInput` stays `false` and is not configurable.** It does not need to be: the transport JSON-decodes every query parameter, so values arrive already typed. See - [`ServerConfiguration`](../README.md#the-rest-of-serverconfiguration) for what the flag does. + [`ServerConfiguration`](operations.md#serverconfiguration) for what the flag does. - **Discovery scans `discovery_path` recursively**, reflecting every declared class, and assumes one class per file. If the config key is missing entirely the provider falls back to `[]` — an empty registry, not an error. @@ -153,7 +154,7 @@ Everything the provider and the HTTP controller pick without asking. yields an empty array, which becomes a `null` input and almost certainly a 422. - Empty input of either kind becomes `null`. - **`X-Client-Id: operations-spa`** — exactly that value — selects `OperationSPAClient`. Every other - request gets a `NullClient`, and [client directives](../README.md#client-directives) go nowhere + request gets a `NullClient`, and [client directives](client-directives.md) go nowhere without warning. The generated client sends the header on every call. ### Responses @@ -175,7 +176,7 @@ Everything the provider and the HTTP controller pick without asking. not `{"message": …, "errors": …}`. `Illuminate\Validation\ValidationException` is **not** mapped by default, so a `$request->validate()` inside a handler lands in a 500. Map it onto a category yourself, or throw - [`InvalidInputException::createFromMessages()`](../README.md#errors) instead. + [`InvalidInputException::createFromMessages()`](errors.md#your-own-validation) instead. ### CSRF and cookies @@ -222,7 +223,7 @@ php artisan operations:codegen resources/js/operations --with=tanstack-query,que | `--verify` | Check for drift instead of writing, exiting 1 on any difference. Use it in CI. | The names accepted by `--with` and `--without` map onto the -[generators](../README.md#optional-generators): +[generators](typescript-client.md#generators): | Name | Generator | Default | |---|---|---| @@ -287,11 +288,11 @@ A cache that no longer matches the code asking it fails loudly, at runtime, with > upgrading a version that changed how they are derived — invalidates both the cache and the > generated client. Run `operations:optimize` and `operations:codegen` together. -See [Production](../README.md#production) for the optimizer underneath, which is usable on its own. +See [Production](server.md#production) for the optimizer underneath, which is usable on its own. ## Preloading -[`Preloader`](../README.md#preloading-a-query) is registered as a container singleton, built with the +[`Preloader`](server.md#preloading-a-query) is registered as a container singleton, built with the same key generator as the registry, so injecting it is all that is needed: ```php diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..eeaf090 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,185 @@ +# Operations + +Everything about the unit of work this library exposes: the attributes that declare one, the contract +its handler method has to satisfy, the middleware that wraps it, and the configuration that applies +to all of them. The short version lives in the [README](../README.md); this is the full picture. + +- [The attributes](#the-attributes) +- [Namespaces and names](#namespaces-and-names) +- [The handler contract](#the-handler-contract) +- [Middleware](#middleware) +- [ServerConfiguration](#serverconfiguration) + +## The attributes + +| Attribute | Target | What it does | +|---|---|---| +| `#[Query(namespace, name)]` | method | A read operation, served over GET. | +| `#[Command(namespace, name)]` | method | A write operation, served over POST. | +| `#[Middleware(class)]` | class, method, repeatable | Middleware to run around this operation. | +| `#[Throws(ExceptionClass, as: ?string)]` | method, repeatable | Declares an exception the operation may throw, optionally naming it for the client. | +| `#[ExposeAs(type)]` | exception class | The exception's own name, for every operation that declares it. | +| `#[Optional]` | property, parameter | The field may be absent from input. | +| `#[Castable(strategy)]` | class | A plain class may be built from input. | +| `#[Brand(name)]` | class | Makes the generated TypeScript type opaque. | +| `#[Named(name)]` | class | Exports the type once by name instead of inlining it. | + +`#[Throws]` and `#[ExposeAs]` are covered in [errors](errors.md#exposing-a-domain-error). +`#[Brand]`, `#[Named]`, `#[Castable]` and `#[Optional]` are covered in +[the type reference](types.md). + +## Namespaces and names + +`namespace` defaults to `global` and becomes the generated TypeScript module, so it has to be a +usable file name: letters, digits, `-` and `_`. It accepts a `UnitEnum` as well as a string, so you +can keep namespaces in an enum — note that a *backed* enum contributes its value and a pure enum its +case name, so adding `: string` to an existing namespace enum changes every generated module and +wire key. `name` defaults to the method name and is a string only. + +Two operations of the same type resolving to the same `namespace.name` fail discovery. A query and a +command *may* share one, but both land in the same generated module, so unless the naming rule tells +them apart the code generator rejects them rather than emit two functions of the same name. + +## The handler contract + +Your method is called with exactly three arguments, positionally: + +```php +public function get(array $input, MyContext $context, Client $client): array +``` + +`$input` is the parsed, validated input — already hydrated into whatever your type declares. **Its +type is the whole input contract**, so the first parameter is the one that matters. `$context` is +whatever you passed to `Server::query()`; the library never touches it. `$client` is the +[side channel](client-directives.md) back to the frontend. + +**The input parameter is never optional.** A handler declaring no parameters is rejected at +discovery, and the one it does declare must carry a native type — an untyped parameter cannot be +reflected. A `@param` in the docblock overrides that native type, and is how you say anything PHP +itself cannot express. + +You may declare a **prefix** of the three — `($input)` and `($input, $context)` are both fine — but +not a subset, and the prefix always starts at the input. `($input, Client $client)` receives the +context in the client slot, so discovery rejects it rather than letting it fail at runtime. + +**An operation that takes no input types it as `null`.** The parameter stays; only its type changes: + +```php +/** + * @return array{ok: bool} + */ +#[Query('system')] +public function ping(null $input): array +{ + return ['ok' => true]; +} +``` + +Every generator drops the argument for such an operation, so the TypeScript is `ping()` rather than +`ping(input)`. There is no way to omit the parameter itself. + +**Input is parsed, output is serialized.** Input is untrusted, so every claim its type makes is +proven. Output is checked against its declared type too, and an output that does not match is a 500 — +it is a bug in your code, not something the client can fix. The PHPStan *refinements* on top of the +type are not re-checked, because static analysis already established those. See +[refinements run on input, never on output](types.md#refinements-run-on-input-never-on-output). + +## Middleware + +A middleware wraps the operation. Implement `MiddlewareContract`: + +```php +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; + +/** + * @implements MiddlewareContract + */ +final class NameCheckingMiddleware implements MiddlewareContract +{ + #[Throws(InvalidNameException::class)] + public function handle( + mixed $input, + Closure $next, + mixed $context, + ResolveInfo $info, + Client $client, + ): RpcSuccess|RpcError + { + if (is_array($input) && ($input['name'] ?? null) === 'invalid') { + throw new InvalidNameException(); + } + + return $next($input); + } +} +``` + +**`$next()` never throws.** A failure deeper in the pipeline is converted to an `RpcError` at the +ring where it happened and handed back to you as `$next()`'s return value, so post-processing runs +whether the operation succeeded or not. + +`ResolveInfo` describes the operation being run: `namespace`, `name`, `operationType`, `className`, +`methodName`, `middleware` (every class in the stack) and `fullyQualifiedName`. + +Attach it per operation or per class. `#[Middleware]` takes one class and is repeatable, so stack it: + +```php +#[Command('users')] +#[Middleware(AuthMiddleware::class)] +#[Middleware(NameCheckingMiddleware::class)] +public function create(array $input): array { /* ... */ } +``` + +or globally, for every operation on the server: + +```php +new ServerConfiguration()->withMiddlewares(AuthMiddleware::class, LoggingMiddleware::class) +``` + +**Order is outermost first.** Global middleware wraps class-level `#[Middleware]`, which wraps +method-level, and within each group they run in declaration order. The first one listed is the first +to see the input and the last to see the result. + +`#[Throws]` on a middleware's `handle()` contributes to the error union of every operation it wraps — +globally configured or attached with `#[Middleware]`, both count — so the generated TypeScript knows +about middleware failures too. It takes `as` like any other declaration, and when an operation and +its middleware declare the same exception, the operation's name wins. + +Middleware can also attach metadata to whichever result it is holding, with `withMetadata()` / +`appendMetadata()`. It travels to the client under `__metadata` on both branches — see +[the envelope](typescript-client.md#the-envelope). + +## ServerConfiguration + +`ServerConfiguration` carries every server-wide setting, and is where all three are set. + +`withMiddlewares()` adds [global middleware](#middleware), outermost of all. + +`withExceptions()` maps your exceptions onto the [error categories](errors.md). Matching is +`instanceof`, so listing a base class covers its subclasses, and an omitted category is left +untouched: + +```php +new ServerConfiguration()->withExceptions( + notFound: [EntityNotFoundException::class], + unauthenticated: [NotLoggedInException::class], + unauthorized: [ForbiddenException::class], +) +``` + +Without this, nothing produces a 401, 403 or 404 except an unknown operation — every other +exception is a 500. + +`coerceQueryInput` (default `false`) applies to **queries only**, and exists because a URL carries no +types. The generated client JSON-encodes each query value, so a transport that decodes it again +receives `?id=1` as the integer `1` and has nothing to coerce. Turn this on when requests reach you +from somewhere that does not round-trip — a hand-written URL, a form, a transport of your own — and +leaf primitives are coerced to the declared type before validation instead of failing it: + +```php +new ServerConfiguration(coerceQueryInput: true) +``` + +Because it applies to queries only, the same input shape validates differently depending on whether +it is reached through `#[Query]` or `#[Command]`. Coercion never invents a value: only scalars are +cast, and anything else is left for the schema to reject. diff --git a/docs/server.md b/docs/server.md new file mode 100644 index 0000000..129783a --- /dev/null +++ b/docs/server.md @@ -0,0 +1,260 @@ +# The server + +The runtime half of the library: what runs an operation, how it is found, how it reaches HTTP, and +what to compile ahead of time before deploying. The short version lives in the +[README](../README.md); this is the full picture. + +- [Server](#server) +- [Operation keys](#operation-keys) +- [Registries](#registries) +- [ServerAdapter](#serveradapter) +- [Results](#results) +- [Serving operations over HTTP](#serving-operations-over-http) +- [Preloading a query](#preloading-a-query) +- [Production](#production) +- [Extension points](#extension-points) + +## Server + +`Server` takes a registry of operations and runs one. Both methods are total — every `Throwable`, +including a failure resolving your handler, comes back as an `RpcError`: + +```php +public function query(string $name, mixed $input, mixed $context, Client $client): RpcSuccess|RpcError +public function command(string $name, mixed $input, mixed $context, Client $client): RpcSuccess|RpcError +``` + +Its constructor is three arguments, two of which have working defaults: + +```php +new Server( + OperationRegistry $registry, + ServerAdapter $adapter = new NewInstanceAdapter(), + ServerConfiguration $configuration = new ServerConfiguration(), +) +``` + +The `SchemaExecutor` it runs schemas with is a public property, if you want to parse or serialize +something yourself with the same instance. + +## Operation keys + +**`$name` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns +`namespace` + `name` into what the client calls. + +| Generator | Produces | +|---|---| +| `PlainlyExposedKeyGenerator` | `"{$namespace}.{$name}"` — literal, readable keys. | +| `HashSha256KeyGenerator($pepper, $namespaceLength = 8, $fnNameLength = 24)` | Both parts hashed, so `users.get` is reachable only as an opaque key. | + +The generated TypeScript always embeds whichever key the server produced, so this only matters when +you call the server by hand. + +> **Pass one explicitly.** `eagerlyDiscover()` falls back to `HashSha256KeyGenerator` peppered with +> the string `'default'` — obfuscated keys, from a pepper anyone reading this page knows. The pepper +> is the first constructor argument and has no default of its own. + +Obfuscation is not a security boundary: it keeps your operation names out of the shipped bundle, and +that is all. Two operations colliding on a truncated hash is an error at discovery, not a silent +overwrite — widen `fnNameLength` if a real application ever hits it. + +Because keys are derived from the generator, changing it — including upgrading a version that changed +how keys are derived — invalidates both the [production cache](#production) and the generated client. +Recompile and regenerate together. + +## Registries + +`OperationRegistry` holds the operations: `has()`, `get()` and `all()`, keyed by operation type. + +`EagerlyLoadedOperationRegistry` discovers them up front. Schemas are parsed **lazily**, per +operation, on first use — discovery reads attributes and signatures, not docblock types: + +```php +EagerlyLoadedOperationRegistry::eagerlyDiscover($directoryOrDirectories, keyGenerator: $keys); +EagerlyLoadedOperationRegistry::withClasses([UserOperations::class, ...], keyGenerator: $keys); +``` + +`withClasses()` registers an explicit list instead of scanning, which is what you want when the +classes are already known — a compiled list, or a test. + +Both take an `OperationDiscovery`, and `new OperationDiscovery($filterFn)` takes a +`Closure(ReflectionClass, ReflectionMethod, Query|Command): bool` returning `false` to keep an +operation out of the registry. + +`CachedOperationRegistry` is the compiled form for production — see [Production](#production). + +## ServerAdapter + +`ServerAdapter` builds your handler classes and middleware. It is two methods — +`createController()` and `createMiddleware()` — and it is the seam for dependency injection: + +| Adapter | Behaviour | +|---|---| +| `NewInstanceAdapter` | The default. Plain `new $className()`, so handlers take no constructor arguments. | +| `PsrContainerAdapter` | Resolves both through a PSR-11 container. | + +`PsrContainerAdapter` needs `psr/container`, which is a `suggest` rather than a dependency; the +default needs nothing at all. Implement the interface yourself for a container that is not PSR-11, +or to construct handlers some other way. Whatever it does, a failure to resolve is caught and +returned as an `RpcError` — that is part of what keeps `query()` and `command()` total. + +## Results + +`RpcResult` is the interface both outcomes implement, for the code that does not care which one it +is holding. It carries `statusCode` — 200 on success, the [error category](errors.md)'s own code +otherwise — `resolveInfo`, `metadata`, and it is `JsonSerializable`: `jsonSerialize()` produces the +whole envelope the generated client reads. A transport needs nothing else, which is why +[serving operations](#serving-operations-over-http) is two lines. + +Both results carry metadata a middleware can attach with `withMetadata()` / `appendMetadata()`. It +travels to the client under `__metadata`, and the generated envelope declares it as +`__metadata?: Record` — optional because the key is left off entirely when nothing +was attached. It is yours to shape; the library puts nothing in it. + +On the error branch, `$result->cause` is the most recent `Throwable`, ready to hand to your reporter, +and `$result->previous` is a list of everything that failed before it, oldest first. It is empty on +an ordinary error. On the rare occasion that working out how to present an error *itself* failed — a +stale middleware class name, say — that second exception is the `cause`, and the one your application +threw is in `previous`. `$result->throwableChain()` gives you all of them in order, which is what you +want to loop over when reporting: + +```php +foreach ($result->throwableChain() as $throwable) { + $reporter->report($throwable); +} +``` + +## Serving operations over HTTP + +The server runs an operation; turning that into an HTTP response is yours to write. Two routes are +enough — one GET for queries, one POST for commands — and both must carry the operation key. + +```php +use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; +use Le0daniel\PhpTsBindings\Server\Client\NullClient; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; +use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; +use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; +use Le0daniel\PhpTsBindings\Server\Server; + +$server = new Server( + EagerlyLoadedOperationRegistry::eagerlyDiscover( + __DIR__ . '/src/Operations', + keyGenerator: new PlainlyExposedKeyGenerator(), + ), + adapter: new PsrContainerAdapter($psrContainer), + configuration: new ServerConfiguration() + ->withMiddlewares(AuthMiddleware::class) + ->withExceptions( + notFound: [EntityNotFoundException::class], + unauthenticated: [NotLoggedInException::class], + ), +); + +$result = $server->command('users.create', $input, $myContext, new NullClient()); + +// jsonSerialize() is the envelope the generated client reads, and the only thing that gets it +// exactly right: `details` is omitted rather than sent as null on the categories that have none, +// which is what the generated union declares. +respondJson($result->statusCode, $result->jsonSerialize()); +``` + +Whatever your transport does with the input, the shape it hands the server has to match what the +generated client sends: for queries, each value JSON-encoded into its own query parameter; for +commands, a JSON body. Decoding query values back is what lets you leave +[`coerceQueryInput`](operations.md#serverconfiguration) off. + +To emit [client directives](client-directives.md), pass an `OperationSPAClient` instead of a +`NullClient`. There is nothing else to do: the success carries the client, so `jsonSerialize()` asks +it for its payload and puts it under `__client` for you. + +```php +$result = $server->command('users.create', $input, $myContext, new OperationSPAClient()); + +respondJson($result->statusCode, $result->jsonSerialize()); +``` + +**Directives ride the success branch only.** A failure carries none, even for the directives that +were queued before it — a handler that toasts `'Saved'` and then throws must not have the browser +announce work that did not happen. That is why `RpcError` holds no client at all, rather than +leaving it to each transport to remember. + +On Laravel, all of this is done for you — see [the Laravel adapter](laravel.md#routes). + +## Preloading a query + +`Preloader` runs a query server-side during the request that renders the page, so the data is in the +page instead of being fetched after it loads. It takes the `Server` and an `OperationKeyGenerator` — +which has to be the one the server's registry uses, or the key it derives names no operation and +every preload throws: + +```php +use Le0daniel\PhpTsBindings\Server\Preloader; + +$preloader = new Preloader($server, $keyGenerator); + +$users = $preloader->preload('users', 'get', ['id' => 1], $context); +``` + +You get back `['response' => …, 'queryKey' => ['users', 'get', ['id' => 1]]]`. The key is built the +same way `EmitQueryKey` and `EmitTanstackQuery` build it, so a TanStack cache seeded with that pair +will not refetch. The input is part of the key whenever the operation has one, even when the value is +`null`; an operation whose input type *is* `null` gets a two-element key. + +`preloadMany()` takes several at once, as +`[['namespace' => …, 'name' => …, 'input' => …], …]`. A query that fails throws a `SchemaException` — +this is your own code calling your own operation, not untrusted input. + +## Production + +Reflecting and parsing every schema on every request is real overhead. `CachedOperationRegistry` is +the compiled form of a registry: every schema pre-parsed, deduplicated and pooled — shared structs +emitted once and referenced, and unions reordered for faster dispatch. Compile it once, at deploy +time, and load it instead of discovering: + +```php +CachedOperationRegistry::writeToCache($registry, __DIR__ . '/cache/operations.php', idLength: 10); +``` + +The output is deterministic, so the same registry compiles to the same bytes on every machine. A +cache that no longer matches the code asking it fails loudly, at runtime, with an +`UnknownTypeKeyException` — recompile it, or drop it and fall back to discovery. + +> Operation keys are derived from the [key generator](#operation-keys), so changing it invalidates +> both the cache and the generated client. Recompile and regenerate together. + +The same optimizer is available directly, for schemas that are not operations: + +```php +use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer; +use Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry; + +new ASTOptimizer()->optimizeAndWriteToFile('asts.php', [ + 'MyClass@method@input' => $inputAst, + 'MyClass@method@output' => $outputAst, +]); + +/** @var CachedTypeRegistry $registry */ +$registry = require 'asts.php'; +$ast = $registry->get('MyClass@method@input'); +``` + +Keys are interned as truncated hashes; `new ASTOptimizer(idLength: 12)` widens them if a run ever +reports a collision, which it does by throwing rather than by merging two schemas. + +## Extension points + +The interfaces meant to be implemented by you: + +| Contract | For | +|---|---| +| `MiddlewareContract` | [Wrapping an operation](operations.md#middleware). | +| `ServerAdapter` | [Constructing handlers and middleware](#serveradapter) — the DI seam. | +| `OperationKeyGenerator` | [Turning `namespace` + `name`](#operation-keys) into the key the client calls. | +| `OperationRegistry` | [Holding operations](#registries), if neither shipped registry fits. | +| `Client` / `SerializableClient` | [Your own side channel](client-directives.md) and its wire payload. | +| `StringValueObject` / `IntValueObject` | [A class that travels as one primitive](types.md#value-objects). | +| `GeneratesLibFiles` / `GeneratesOperationCode` / `DependsOn` | [Adding to the generated client](typescript-client.md#writing-your-own-generator). | + +The lower-level `TypeParser`, `SchemaExecutor` and `TypescriptGenerator` are public too, if you want +to parse and emit types without the RPC layer at all — see [the type reference](types.md). diff --git a/docs/typescript-client.md b/docs/typescript-client.md new file mode 100644 index 0000000..6aaadb3 --- /dev/null +++ b/docs/typescript-client.md @@ -0,0 +1,167 @@ +# The generated TypeScript client + +What code generation writes, what the generated code does at runtime, and how to add to it. The +short version lives in the [README](../README.md); this is the full picture. + +- [What gets written](#what-gets-written) +- [The envelope](#the-envelope) +- [Wiring up the transport](#wiring-up-the-transport) +- [Failing loudly](#failing-loudly) +- [Generators](#generators) +- [Writing your own generator](#writing-your-own-generator) + +## What gets written + +`TypescriptServerCodeGenerator` writes a self-contained client, and `OutputDirectory::write()` puts +it on disk. Nothing is published to npm; the code lives in your repo. + +``` +/ + lib/types.ts Success/Failure/Result, Brand, every #[Named] alias + lib/OperationClient.ts the transport interface + lib/DefaultClient.ts a fetch implementation of it + lib/OperationException.ts + lib/bindings.ts createDefaultClient, setClient, executeOperation + lib/utils.ts queryKey, throwOnFailure + lib/client-operations-spa.ts the OperationSPAClient payload and its guard + .ts one module per namespace, one function per operation +``` + +That is what the five [default generators](#generators) produce. The opt-in ones add to it: +`EmitTypeMap` writes one more file, the other two write into the `.ts` modules that are +already there. + +Every generated file opens with `// generated by: php-ts-bindings`. That marker is what +`OutputDirectory` uses to tell its own output from anything else in the directory: it removes only +files carrying it, and refuses to overwrite one that does not. + +`OutputDirectory::verify()` applies the same rules without writing — it returns one message per +problem, and an empty list means the directory is up to date. Run it in CI to catch a frontend that +has drifted from the backend. + +## The envelope + +Every call resolves to: + +```typescript +export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} +export type Failure = {success: false, __metadata?: Record} & E; +export type Result = Success | Failure; +``` + +That is the whole envelope, and both extra keys are the library's own — `jsonSerialize()` writes +them, so the generated types name them rather than leaving you to discover them on the wire. Each is +optional, because the key is left off entirely when there is nothing to say. + +They differ in how much they can honestly claim. `__metadata` is `Record` on both +branches: whatever a middleware attached, always a string-keyed bag, serialized the same way on +either outcome. `__client` is `unknown` and success-only — the *key* is first-party, but its shape +belongs to whichever [`Client`](client-directives.md) produced it, and a failure carries no +directives at all. Naming it without describing it is the honest half: you know to look there, and +the guard that shipped with your client is what makes it typed. + +Alongside them, each operation gets its own three types — `Input`, `Result` and +`Error` — and `Error` is [the union of what that operation can really produce](errors.md#the-generated-error-union). + +## Wiring up the transport + +Once, before the first call: + +```typescript +import {createDefaultClient, setClient} from './operations/lib/bindings'; + +setClient(createDefaultClient()); +setClient(createDefaultClient(fetch, {baseUrl: 'https://api.example.com', timeoutMs: 5_000})); +``` + +`setClient()` sets one module-global client, so it has to run before the first call — otherwise you +get `Error('No client set')` at whichever call site happened to be first. + +Every generated function takes an optional second argument: + +```typescript +export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; +``` + +`DefaultClient` sends queries as GET with each input value JSON-encoded into a query parameter, and +commands as POST with a JSON body. `signal` and `timeoutMs` are joined into one `AbortSignal`, and a +per-call `timeoutMs` overrides the client's default (10s unless you set one). `registerHook(hook)` +runs a callback on every response and returns a function that unregisters it. Swap the whole +transport by implementing `OperationClient` — `setClient()` and the per-call `options.client` both +take one. + +The URLs come from `ServerMetadata('/query/{fqn}', '/command/{fqn}')`, the two routes *your* +transport serves. `{fqn}` is where the operation key goes, and both are required to contain it. + +## Failing loudly + +`throwOnFailure(result)`, from `lib/utils.ts`, narrows a `Result` to its success branch and throws an +`OperationException` otherwise, for call sites that would rather not branch. A `catch` variable is +`unknown` in TypeScript whatever was thrown, so name the operation's error union at the guard to get +it back: + +```typescript +try { + const result = await get({id: userId}); + throwOnFailure(result); + return result.data; +} catch (e) { + if (OperationException.is(e)) { + e.cause.type; // "INVALID_INPUT" | "NOT_FOUND" | ... + e.code; // the HTTP code, 500 if the payload had none + } + throw e; +} +``` + +## Generators + +The generator list you hand `TypescriptServerCodeGenerator` *is* the configuration — the core has no +default set. Eight ship, and the "default" column below is what +[`php artisan operations:codegen`](laravel.md#operationscodegen) turns on when you pass it no flags: + +| Generator | Default | Emits | +|---|---|---| +| `EmitTypes` | on | `lib/types.ts` — the envelope, `Brand`, every `#[Named]` alias | +| `EmitOperationClientBindings` | on | `lib/bindings.ts`, `lib/OperationClient.ts`, `lib/DefaultClient.ts`, `lib/OperationException.ts` | +| `EmitTypeUtils` | on | `lib/utils.ts` — `queryKey` and `throwOnFailure` | +| `EmitOperationsSpaClient` | on | `lib/client-operations-spa.ts` — the `OperationSPAClient` payload and `containsOperationSpaPayload()` | +| `EmitOperations` | on | one `.ts` module per namespace | +| `EmitTanstackQuery` | off | `QueryOptions()` and `useQuery()` for `@tanstack/react-query` | +| `EmitQueryKey` | off | standalone query keys | +| `EmitTypeMap` | off | `lib/type-map.ts` — a `TypeMap` of every operation's input, output and error types, split into `{query: …, command: …}` and keyed by `namespace.name` | + +`EmitTanstackQuery` and `EmitQueryKey` emit **only for queries**, since a command has nothing to +cache. + +`new EmitOperations($closure)` takes a `Closure(TypedOperation): string` that names the generated +functions; the default is the operation's bare name. `generate()` takes a third argument, a list of +namespaces (or `namespace.name` operations) to skip. + +## Writing your own generator + +Implement `GeneratesLibFiles` (gets every operation, writes shared lib files) or +`GeneratesOperationCode` (gets one operation, writes its code), and add it to the list. + +Add `DependsOn` to declare the generators yours needs: the run fails early if one is missing, and +hands you the resolved instances through `setDependencies()`. That is how to reference what another +generator emitted — ask `EmitOperations` for `inputTypeName($operation)` rather than rebuilding the +name and hoping it matches, and ask `EmitTypes` for `importFromTypes(types: ['Order'])` rather than +writing the module specifier yourself. Because those methods are not static, an import can only ever +name a file a registered generator actually writes. + +Hand your imports to `TypescriptFile` instead of writing `import` lines into the code: only then are +they merged with what the other generators contribute to the same file, and only then is the path +resolved for a file that lands in `lib/`. + +A few contracts worth knowing when writing one: + +- A `GeneratesLibFiles` key is a bare module name — `[a-zA-Z0-9_-]+`, no `.ts` — and always lands in + `lib/`. Several generators may return the same key; their output accumulates rather than + overwriting. +- `TypedOperation::$hasInput` is false when the operation's input type is `null` — the + [no-input form](operations.md#the-handler-contract). Every generator that emits a signature has to + drop the argument in that case. +- Return `null` from `generateOperationCode()` to emit nothing for an operation. +- Anything a schema has no honest TypeScript for throws `UnsupportedTypeException` rather than + degrading to a placeholder. Hold your own generator to the same rule. From 63d7453fa6d4b855fa65746498446039ca8c787b Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 6 Aug 2026 09:33:51 +0200 Subject: [PATCH 064/101] Align TypeScript client documentation tables with updated headers and clarify generator defaults --- docs/types.md | 30 +++++++++++++++--------------- docs/typescript-client.md | 20 ++++++++++---------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/types.md b/docs/types.md index 43aaee5..81f4170 100644 --- a/docs/types.md +++ b/docs/types.md @@ -63,22 +63,22 @@ An enum with no cases has no TypeScript representation and fails generation. Some PHPStan types narrow a PHP type further than PHP itself can express: `positive-int` is an `int` to PHP, `non-empty-list` is an `array`. Those refinements are checked at runtime. -| PHPStan type | PHP type | Checked | -| --- | --- | --- | +| PHPStan type | PHP type | Checked on Input | +| --- | --- |------------------------------------------------| | `int` | `int` | inclusive bounds; `min` / `max` mean unbounded | -| `positive-int` | `int` | `>= 1` | -| `non-negative-int` | `int` | `>= 0` | -| `negative-int` | `int` | `<= -1` | -| `non-positive-int` | `int` | `<= 0` | -| `non-empty-string` | `string` | `!== ''` — note `"0"` is valid | -| `non-falsy-string`, `truthy-string` | `string` | truthy — `"0"` is not | -| `numeric-string` | `string` | `is_numeric()` | -| `lowercase-string` | `string` | `strtolower($v) === $v` | -| `uppercase-string` | `string` | `strtoupper($v) === $v` | -| `non-empty-lowercase-string` | `string` | both of the above | -| `non-empty-uppercase-string` | `string` | both of the above | -| `non-empty-list` | `array` | at least one element | -| `non-empty-array` | `array` | at least one element | +| `positive-int` | `int` | `>= 1` | +| `non-negative-int` | `int` | `>= 0` | +| `negative-int` | `int` | `<= -1` | +| `non-positive-int` | `int` | `<= 0` | +| `non-empty-string` | `string` | `!== ''` — note `"0"` is valid | +| `non-falsy-string`, `truthy-string` | `string` | truthy — `"0"` is not | +| `numeric-string` | `string` | `is_numeric()` | +| `lowercase-string` | `string` | `strtolower($v) === $v` | +| `uppercase-string` | `string` | `strtoupper($v) === $v` | +| `non-empty-lowercase-string` | `string` | both of the above | +| `non-empty-uppercase-string` | `string` | both of the above | +| `non-empty-list` | `array` | at least one element | +| `non-empty-array` | `array` | at least one element | A refinement disappears in TypeScript — `positive-int` is `number` — because TypeScript cannot express it either. It is enforced on the server. diff --git a/docs/typescript-client.md b/docs/typescript-client.md index 6aaadb3..31d5752 100644 --- a/docs/typescript-client.md +++ b/docs/typescript-client.md @@ -120,16 +120,16 @@ The generator list you hand `TypescriptServerCodeGenerator` *is* the configurati default set. Eight ship, and the "default" column below is what [`php artisan operations:codegen`](laravel.md#operationscodegen) turns on when you pass it no flags: -| Generator | Default | Emits | -|---|---|---| -| `EmitTypes` | on | `lib/types.ts` — the envelope, `Brand`, every `#[Named]` alias | -| `EmitOperationClientBindings` | on | `lib/bindings.ts`, `lib/OperationClient.ts`, `lib/DefaultClient.ts`, `lib/OperationException.ts` | -| `EmitTypeUtils` | on | `lib/utils.ts` — `queryKey` and `throwOnFailure` | -| `EmitOperationsSpaClient` | on | `lib/client-operations-spa.ts` — the `OperationSPAClient` payload and `containsOperationSpaPayload()` | -| `EmitOperations` | on | one `.ts` module per namespace | -| `EmitTanstackQuery` | off | `QueryOptions()` and `useQuery()` for `@tanstack/react-query` | -| `EmitQueryKey` | off | standalone query keys | -| `EmitTypeMap` | off | `lib/type-map.ts` — a `TypeMap` of every operation's input, output and error types, split into `{query: …, command: …}` and keyed by `namespace.name` | +| Generator | Laravel Default | Emits | +|---|-----------------|---| +| `EmitTypes` | on | `lib/types.ts` — the envelope, `Brand`, every `#[Named]` alias | +| `EmitOperationClientBindings` | on | `lib/bindings.ts`, `lib/OperationClient.ts`, `lib/DefaultClient.ts`, `lib/OperationException.ts` | +| `EmitTypeUtils` | on | `lib/utils.ts` — `queryKey` and `throwOnFailure` | +| `EmitOperationsSpaClient` | on | `lib/client-operations-spa.ts` — the `OperationSPAClient` payload and `containsOperationSpaPayload()` | +| `EmitOperations` | on | one `.ts` module per namespace | +| `EmitTanstackQuery` | off | `QueryOptions()` and `useQuery()` for `@tanstack/react-query` | +| `EmitQueryKey` | off | standalone query keys | +| `EmitTypeMap` | off | `lib/type-map.ts` — a `TypeMap` of every operation's input, output and error types, split into `{query: …, command: …}` and keyed by `namespace.name` | `EmitTanstackQuery` and `EmitQueryKey` emit **only for queries**, since a command has nothing to cache. From 0c1f13bb7fe8397ae8e4c34b9a28f05a4eca3e85 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 6 Aug 2026 11:46:58 +0200 Subject: [PATCH 065/101] Introduce `ValidationException` for enhanced value object rejection handling and update related tests and docs. --- docs/errors.md | 44 ++++++-- docs/laravel.md | 6 +- docs/types.md | 36 +++++++ src/Contracts/PhpTsBindingsException.php | 5 + src/Contracts/ValueObjects/IntValueObject.php | 9 ++ .../ValueObjects/StringValueObject.php | 9 ++ src/Executor/Data/IssueMessage.php | 8 ++ .../Exceptions/ValidationException.php | 73 +++++++++++++ src/Parser/Nodes/Leaf/ValueObjectNode.php | 48 ++++++--- .../Data/Exceptions/InvalidInputException.php | 24 ++--- src/Server/Server.php | 20 ---- .../Laravel/LaravelHttpControllerTest.php | 26 +---- tests/Feature/Operations/TestClass.php | 14 +++ tests/Feature/ServerTest.php | 23 +++- .../EmptyValidationValueObject.php | 27 +++++ tests/Mocks/ValueObjects/ValidatedAge.php | 32 ++++++ tests/Mocks/ValueObjects/ValidatedEmail.php | 39 +++++++ .../Unit/Contracts/ExceptionHierarchyTest.php | 14 +++ .../Exceptions/ValidationExceptionTest.php | 58 ++++++++++ tests/Unit/Executor/SchemaExecutorTest.php | 101 ++++++++++++++++-- .../Exceptions/InvalidInputExceptionTest.php | 24 ----- .../Unit/Server/Errors/ErrorPresenterTest.php | 4 +- 22 files changed, 532 insertions(+), 112 deletions(-) create mode 100644 src/Executor/Exceptions/ValidationException.php create mode 100644 tests/Mocks/ValueObjects/EmptyValidationValueObject.php create mode 100644 tests/Mocks/ValueObjects/ValidatedAge.php create mode 100644 tests/Mocks/ValueObjects/ValidatedEmail.php create mode 100644 tests/Unit/Executor/Exceptions/ValidationExceptionTest.php delete mode 100644 tests/Unit/Server/Data/Exceptions/InvalidInputExceptionTest.php diff --git a/docs/errors.md b/docs/errors.md index 9fb8f6b..db796cb 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -103,15 +103,41 @@ localization keys as values, e.g. `{"email": ["validation.not_empty_string"]}`. ## Your own validation -This library proves types and refuses to grow into a validator, but the 422 shape is a perfectly -good transport for the rules it will not check for you: +**A 422 is the schema's verdict on the input, and only that.** It is produced in exactly one place — +parsing the input against the operation's declared type — and there is no supported way to hand-build +one. `InvalidInputException` is `@internal`: you meet it as `RpcError::$cause`, you never throw it. +That is what keeps the category honest. If any code could mint a 422, `INVALID_INPUT` would stop +meaning "this did not match the type" and the client could no longer trust it to. + +So a rule the type system cannot express goes in one of two places, depending on whether the value +alone decides it. + +**The value decides it — put it in a value object.** "Is a valid email address", "is a positive +id": no PHPStan type says these, but nothing beyond the value itself is needed to check them. A +[value object](types.md#value-objects) throwing `ValidationException` rejects the input during +parsing, and its messages arrive in `details.fields` like any other type failure: ```php -throw InvalidInputException::createFromMessages(['email' => ['Already taken']]); +use Le0daniel\PhpTsBindings\Executor\Exceptions\ValidationException; + +public static function fromStringValue(string $value): static +{ + if (!str_contains($value, '@')) { + throw new ValidationException('Email must contain an @'); + } + + return new self($value); +} ``` -That produces a 422 from any field-to-message map. Throw it from a handler or a middleware and the -client reads it exactly like a type failure — same branch, same `details.fields`. +The rule now travels with the type. Every operation taking an `Email` enforces it, and none of them +had to remember to. + +**Something else decides it — that is a domain error.** "Already taken" needs the database; "the +account is locked" needs the account. The input was well formed and the request still cannot +proceed, which is a 400, not a 422. Declare it with `#[Throws]` and give it a name, per +[Exposing a domain error](#exposing-a-domain-error). The client gets `details.type` naming which +rule failed, and — unlike a free-text message — the generated union makes it a case it must handle. ## Exceptions this library throws @@ -122,9 +148,15 @@ bases: | Exception | Thrown when | |---|---| | `ParserException` | A schema cannot be built — includes `InvalidSyntaxException` (the type is not in the supported subset), `UnexpectedCharacterException` (it does not lex) and `UnknownTypeKeyException` (the optimized cache no longer matches the code). | -| `SchemaException` | An operation is malformed — a name or key collision, a bad handler signature, a class that is not middleware. `InvalidInputException`, `InvalidOutputException` and `OperationNotFoundException` extend it and reach you as `RpcError::$cause`. | +| `SchemaException` | An operation is malformed — a name or key collision, a bad handler signature, a class that is not middleware. `InvalidInputException`, `InvalidOutputException` and `OperationNotFoundException` extend it; they are `@internal` and reach you only as `RpcError::$cause`. | | `CodeGenException` | Generation cannot produce valid output — includes `UnsupportedTypeException` (no honest TypeScript for a schema), `InvalidStringLiteralException` (a brand or alias is not an identifier) and `InvalidGeneratorDependencies` (whose `$messages` names each missing generator). | +`ValidationException` is the one exception outside those three, and the only one you are meant to +throw. The bases all mean the library could not do its job; a `ValidationException` means it did — +a [value object](types.md#value-objects) rejected a value. It never escapes the executor and never +reaches a client as itself, only as the issues it produced. Making it a `SchemaException` would mean +that catching a server fault also caught a user typing their email wrong. + Nothing is thrown out of `Server::query()` or `Server::command()` — both are total, and every `Throwable` comes back as an `RpcError`. The exceptions above surface at discovery, at parse time or during code generation instead, which is to say: at build time, not at request time. diff --git a/docs/laravel.md b/docs/laravel.md index 101b51c..7ef7815 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -175,8 +175,10 @@ Everything the provider and the HTTP controller pick without asking. `{"code": 422, "type": "INVALID_INPUT", "details": {"fields": {"": ["message"]}}}`, not `{"message": …, "errors": …}`. `Illuminate\Validation\ValidationException` is **not** mapped by default, so a `$request->validate()` inside a handler lands in a 500. Map it onto a category - yourself, or throw - [`InvalidInputException::createFromMessages()`](errors.md#your-own-validation) instead. + yourself with [`ServerConfiguration::withExceptions()`](operations.md#serverconfiguration) — or + better, stop validating in the handler: the 422 is the schema's own verdict, and a rule the type + cannot express belongs in a value object or in a domain error, per + [Your own validation](errors.md#your-own-validation). ### CSRF and cookies diff --git a/docs/types.md b/docs/types.md index 81f4170..68393c8 100644 --- a/docs/types.md +++ b/docs/types.md @@ -299,6 +299,42 @@ is caught and reported as a validation issue on that field, with the original ex debugging — it never reaches the client as an internal error, and never escapes the executor. This is where "is a valid email address" belongs, since no PHPStan type can say it. +Any `Throwable` rejects the value, but a bare one has no message the client can be shown, so it +collapses to the single key `validation.invalid_value` — which cannot tell an empty email from a +malformed one. (Not `validation.invalid_type`: the backing string or int was proven before the +factory ran, so the type was right and only the value was refused.) Throw a `ValidationException` +to say what is actually wrong: + +```php +use Le0daniel\PhpTsBindings\Executor\Exceptions\ValidationException; + +public static function fromStringValue(string $value): static +{ + $messages = []; + if ($value === '') { + $messages[] = 'Email is required'; + } + if (!str_contains($value, '@')) { + $messages[] = 'Email must contain an @'; + } + + if ($messages !== []) { + throw new ValidationException($messages, ['value' => $value]); + } + + return new self($value); +} +``` + +Each message becomes its own issue at that field's path, so the client reads +`{"email": ["Email is required", "Email must contain an @"]}` under `details.fields` of a 422. Pass +a single string when there is only one thing to say. + +The messages go on the wire exactly as written. Whether they are English, a localization key, or +anything else is your call — this library does not translate. The second argument is the opposite: +`debugInfo` is server-side only and never leaves the process outside debug mode, so anything a +client must not see belongs there and not in a message. + **Backed enums may opt in too.** A backed enum implementing `StringValueObject` serializes by its backing value instead of the case-name default: diff --git a/src/Contracts/PhpTsBindingsException.php b/src/Contracts/PhpTsBindingsException.php index e2b6d62..3378d25 100644 --- a/src/Contracts/PhpTsBindingsException.php +++ b/src/Contracts/PhpTsBindingsException.php @@ -14,6 +14,11 @@ * * Catch one of the three subsystem bases - ParserException, SchemaException, CodeGenException - * when the failing phase matters; catch this when it does not. + * + * ValidationException is the one exception outside those three, and deliberately so: the bases all + * mean the library could not do its job, while a ValidationException means it did - a value object + * rejected a value. Filing it under SchemaException would make catching a server fault also catch + * a user input rejection. */ interface PhpTsBindingsException extends Throwable { diff --git a/src/Contracts/ValueObjects/IntValueObject.php b/src/Contracts/ValueObjects/IntValueObject.php index e0329b0..122b69e 100644 --- a/src/Contracts/ValueObjects/IntValueObject.php +++ b/src/Contracts/ValueObjects/IntValueObject.php @@ -2,6 +2,8 @@ namespace Le0daniel\PhpTsBindings\Contracts\ValueObjects; +use Le0daniel\PhpTsBindings\Executor\Exceptions\ValidationException; + /** * Marks a class as a value object backed by a single int. * @@ -14,10 +16,17 @@ * * fromIntValue() may throw to reject a value. The parser catches any Throwable and reports * it as a validation issue on the field, with the original exception attached. + * + * Throw a ValidationException to choose what the client is told - one issue per message it carries. + * Any other Throwable has no message fit for a client, so it collapses to the generic + * `validation.invalid_value` key and keeps its message in the debug info. */ interface IntValueObject { public static function fromIntValue(int $value): static; + /** + * @throws ValidationException + */ public function toIntValue(): int; } diff --git a/src/Contracts/ValueObjects/StringValueObject.php b/src/Contracts/ValueObjects/StringValueObject.php index 5abc3f9..5b072d8 100644 --- a/src/Contracts/ValueObjects/StringValueObject.php +++ b/src/Contracts/ValueObjects/StringValueObject.php @@ -2,6 +2,8 @@ namespace Le0daniel\PhpTsBindings\Contracts\ValueObjects; +use Le0daniel\PhpTsBindings\Executor\Exceptions\ValidationException; + /** * Marks a class as a value object backed by a single string. * @@ -15,10 +17,17 @@ * * fromStringValue() may throw to reject a value. The parser catches any Throwable and reports * it as a validation issue on the field, with the original exception attached. + * + * Throw a ValidationException to choose what the client is told - one issue per message it carries. + * Any other Throwable has no message fit for a client, so it collapses to the generic + * `validation.invalid_value` key and keeps its message in the debug info. */ interface StringValueObject { public static function fromStringValue(string $value): static; + /** + * @throws ValidationException + */ public function toStringValue(): string; } diff --git a/src/Executor/Data/IssueMessage.php b/src/Executor/Data/IssueMessage.php index 5961fbe..c98845a 100644 --- a/src/Executor/Data/IssueMessage.php +++ b/src/Executor/Data/IssueMessage.php @@ -5,6 +5,14 @@ enum IssueMessage: string { case INVALID_TYPE = 'validation.invalid_type'; + + /** + * The value had the declared type and was still refused - a value object factory rejecting the + * string or int it was handed. Distinct from INVALID_TYPE on purpose: by the time a factory + * runs, the backing type has already been proven, so reporting a type failure would name the + * wrong thing. + */ + case INVALID_VALUE = 'validation.invalid_value'; case INVALID_KEY_TYPE = 'validation.invalid_key_type'; case MISSING_PROPERTY = 'validation.missing_property'; case FALSY_STRING = 'validation.falsy_string'; diff --git a/src/Executor/Exceptions/ValidationException.php b/src/Executor/Exceptions/ValidationException.php new file mode 100644 index 0000000..1a3d987 --- /dev/null +++ b/src/Executor/Exceptions/ValidationException.php @@ -0,0 +1,73 @@ + + */ + public readonly array $messages; + + /** + * @param string|array $messages Not narrowed to a list: collecting reasons with + * array_filter() leaves holes, and renumbering here is + * friendlier than making every caller remember to. + * @param array $debugInfo Server side only diagnostics; never sent to the client. + */ + public function __construct( + string|array $messages, + public readonly array $debugInfo = [], + ) + { + $this->messages = is_string($messages) ? [$messages] : array_values($messages); + + // A rejection with nothing to say would record no issue, and SchemaExecutor would answer + // with a Failure whose issues map is empty - a 422 carrying `details.fields: {}`. Failing + // here instead surfaces the mistake where it was made. + if ($this->messages === []) { + throw new InvalidArgumentException( + 'A ValidationException must carry at least one message, otherwise the value is rejected without a reason.', + ); + } + + parent::__construct(implode(', ', $this->messages)); + } + + /** + * @param array $debugInfo Context from the call site, merged underneath the + * thrower's own entries. + * @return list + */ + public function toIssues(array $debugInfo = []): array + { + $mergedDebugInfo = [...$debugInfo, ...$this->debugInfo]; + + return array_map( + fn(string $message): Issue => new Issue($message, $mergedDebugInfo, exception: $this), + $this->messages, + ); + } +} diff --git a/src/Parser/Nodes/Leaf/ValueObjectNode.php b/src/Parser/Nodes/Leaf/ValueObjectNode.php index ff2f666..ff3b7a3 100644 --- a/src/Parser/Nodes/Leaf/ValueObjectNode.php +++ b/src/Parser/Nodes/Leaf/ValueObjectNode.php @@ -8,6 +8,7 @@ use Le0daniel\PhpTsBindings\Executor\Contracts\ExecutionContext; use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; +use Le0daniel\PhpTsBindings\Executor\Exceptions\ValidationException; use Le0daniel\PhpTsBindings\Parser\Contracts\Coercible; use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; @@ -65,7 +66,7 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed $className = $this->className; return $className::fromStringValue($value); } catch (Throwable $throwable) { - $context->addIssue($this->rejectedByFactoryIssue($value, $throwable)); + $this->addRejectionIssues($throwable, $value, $context); return Value::INVALID; } } @@ -80,7 +81,7 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed $className = $this->className; return $className::fromIntValue($value); } catch (Throwable $throwable) { - $context->addIssue($this->rejectedByFactoryIssue($value, $throwable)); + $this->addRejectionIssues($throwable, $value, $context); return Value::INVALID; } } @@ -142,20 +143,43 @@ private function invalidBackingTypeIssue(mixed $value): Issue /** * A throwing factory means the incoming value was rejected, which is a validation failure and - * not a server fault. Issue::fromThrowable() is deliberately not used here: it maps to + * not a server fault. Issue::fromThrowable() is deliberately not used on this path: it maps to * IssueMessage::INTERNAL_ERROR, which would present bad user input as a server error. + * + * A ValidationException is the factory saying what is wrong, so its messages are reported + * verbatim - one issue each. Anything else has no message fit for a client, so it collapses to + * the generic key and keeps its own message in the debug info. + * + * That generic key is INVALID_VALUE, not INVALID_TYPE: parseValue() proved the backing type + * before calling the factory, so the string or int is exactly what was declared. What the + * factory refused is the value. */ - private function rejectedByFactoryIssue(mixed $value, Throwable $throwable): Issue + private function addRejectionIssues(Throwable $throwable, mixed $value, ExecutionContext $context): void { - return new Issue( - IssueMessage::INVALID_TYPE, - debugInfo: [ - 'message' => "Value rejected by {$this->className}: {$throwable->getMessage()}", - 'node' => self::class, - 'value' => $value, - ], + if ($throwable instanceof ValidationException) { + foreach ($throwable->toIssues($this->rejectionDebugInfo($value, $throwable)) as $issue) { + $context->addIssue($issue); + } + return; + } + + $context->addIssue(new Issue( + IssueMessage::INVALID_VALUE, + debugInfo: $this->rejectionDebugInfo($value, $throwable), exception: $throwable, - ); + )); + } + + /** + * @return array + */ + private function rejectionDebugInfo(mixed $value, Throwable $throwable): array + { + return [ + 'message' => "Value rejected by {$this->className}: {$throwable->getMessage()}", + 'node' => self::class, + 'value' => $value, + ]; } private function notAnInstanceIssue(mixed $value): Issue diff --git a/src/Server/Data/Exceptions/InvalidInputException.php b/src/Server/Data/Exceptions/InvalidInputException.php index ab7dd65..ca396b7 100644 --- a/src/Server/Data/Exceptions/InvalidInputException.php +++ b/src/Server/Data/Exceptions/InvalidInputException.php @@ -3,24 +3,22 @@ namespace Le0daniel\PhpTsBindings\Server\Data\Exceptions; use Le0daniel\PhpTsBindings\Executor\Data\Failure; -use Le0daniel\PhpTsBindings\Executor\Data\Issues; use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; +/** + * @internal This class in internal and should not be used outside of the library. + * It strictly represents a failure to validate input data. + * + * The server constructs it from a parse Failure and ErrorPresenter is its only reader, turning it + * into the 422 that carries `details.fields`. Never throw it: a 422 is the schema's verdict on the + * input and nothing else. A rule the schema cannot express belongs in a value object throwing + * ValidationException, or - when it needs context the input alone cannot give - in a domain error + * declared with #[Throws]. A consumer only ever meets this class as RpcError::$cause. + */ final class InvalidInputException extends SchemaException { public function __construct(public readonly Failure $failure) { parent::__construct("Input validation failed", 422); } - - /** - * @param array $issuesMap - * @return self - */ - public static function createFromMessages(array $issuesMap): self - { - return new self( - new Failure(Issues::fromMessages($issuesMap)), - ); - } -} \ No newline at end of file +} diff --git a/src/Server/Server.php b/src/Server/Server.php index f87880a..5577018 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -73,22 +73,6 @@ public function command(string $name, mixed $input, mixed $context, Client $clie return $this->execute($this->registry->get(OperationType::COMMAND, $name), $input, $context, $client); } - /** - * Middleware is named by class-string, from an attribute or from the configuration, and - * `class-string` on those declarations is what they promise rather than - * anything that was checked - which is why this takes a plain string. - * - * Verified before anything is constructed: every adapter's createMiddleware() declares - * MiddlewareContract as its return type, so without this the mistake surfaces as a TypeError - * from inside the adapter naming neither the middleware nor the contract it is missing. - */ - private static function assertIsMiddleware(string $className): void - { - if (!is_a($className, MiddlewareContract::class, true)) { - throw InvalidMiddlewareException::notAMiddleware($className); - } - } - private function execute(Operation $operation, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { $middlewareClassNames = [ @@ -109,10 +93,6 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli // query()/command() total: a missing container binding or a class that is not a // middleware must surface as an RpcError, not as an uncaught exception. try { - foreach ($middlewareClassNames as $middlewareClassName) { - self::assertIsMiddleware($middlewareClassName); - } - $middlewares = array_map(fn($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); $controllerClass = $this->adapter->createController($operation->definition->fullyQualifiedClassName); } catch (Throwable $throwable) { diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index 94a1d8f..634dac2 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -22,6 +22,7 @@ use Mockery; use ReflectionException; use Throwable; +use TypeError; test('handle successful http query request', function () { // Arrange @@ -326,31 +327,6 @@ function staleMiddlewareController(bool $debug): array }]; } -test('every throwable in the chain is reported, oldest first', function () { - [$controller, $request, $fcn, $reportedSoFar] = staleMiddlewareController(debug: false); - - $response = $controller->handleHttpQueryRequest($fcn, $request); - $reported = $reportedSoFar(); - - expect($response->getStatusCode())->toBe(500) - ->and($reported)->toHaveCount(2) - // The middleware that could not be resolved comes first; the reflection failure that - // followed it - and that is the RpcError's cause - comes last. - ->and($reported[0])->toBeInstanceOf(InvalidMiddlewareException::class) - ->and($reported[1])->toBeInstanceOf(ReflectionException::class); -}); - -test('debug mode describes the previous failures alongside the cause', function () { - [$controller, $request, $fcn] = staleMiddlewareController(debug: true); - - $debug = $controller->handleHttpQueryRequest($fcn, $request)->getData(true)['__debug']; - - expect($debug['class'])->toBe(ReflectionException::class) - ->and($debug['previous'])->toHaveCount(1) - ->and($debug['previous'][0]['class'])->toBe(InvalidMiddlewareException::class) - ->and($debug['previous'][0]['message'])->toContain('DoesNotExistMiddleware'); -}); - test('an ordinary error carries no previous key in debug mode', function () { $fcn = 'docs.method'; diff --git a/tests/Feature/Operations/TestClass.php b/tests/Feature/Operations/TestClass.php index 160c048..c9241c1 100644 --- a/tests/Feature/Operations/TestClass.php +++ b/tests/Feature/Operations/TestClass.php @@ -5,6 +5,7 @@ use App\Data\PreviewableFileData; use Le0daniel\PhpTsBindings\Contracts\Attributes\Command; use Le0daniel\PhpTsBindings\Contracts\Attributes\Middleware; +use Tests\Mocks\ValueObjects\ValidatedEmail; final class TestClass { @@ -21,6 +22,19 @@ public function run(array $data): array ]; } + /** + * The value object rejects with a ValidationException, so the messages it names have to survive + * all the way to details.fields rather than being flattened into validation.invalid_value. + * + * @param array{email: ValidatedEmail} $data + * @return array{email: string} + */ + #[Command("test")] + public function acceptEmail(array $data): array + { + return ['email' => $data['email']->toStringValue()]; + } + /** * Returns something its own return type does not describe: `name` is an int where a string is * declared. The whole `user` branch is nullable, which is exactly the shape that used to be diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index a78cc63..ccddb1c 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -56,6 +56,27 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { ]); }); +/** + * The end of the road for a ValidationException: the value object rejects, the parse fails, and the + * messages it chose come out the other side as the 422 the client reads. Nothing along the way - + * InvalidInputException, ErrorPresenter, RpcError - is allowed to flatten them back to a key. + */ +test("a value object rejecting with ValidationException reaches the client as a 422 naming each message", function () { + $error = executeOperation('test.acceptEmail', ['email' => '']); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::INVALID_INPUT) + ->and($error->statusCode)->toBe(422) + ->and($error->details)->toEqual([ + 'fields' => [ + 'email' => ['Email is required', 'Email must contain an @'], + ], + ]); + + expect(executeOperation('test.acceptEmail', ['email' => 'ada@example.test'])) + ->toBeInstanceOf(RpcSuccess::class); +}); + test("A middleware that does not implement the contract yields an RpcError", function () { $server = new Server( EagerlyLoadedOperationRegistry::eagerlyDiscover( @@ -77,7 +98,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) ->and($result->cause)->toBeInstanceOf(ReflectionException::class) ->and($result->previous)->toHaveCount(1) - ->and($result->previous[0])->toBeInstanceOf(InvalidMiddlewareException::class) + ->and($result->previous[0])->toBeInstanceOf(TypeError::class) ->and($result->previous[0]->getMessage())->toContain(NotAMiddleware::class); }); diff --git a/tests/Mocks/ValueObjects/EmptyValidationValueObject.php b/tests/Mocks/ValueObjects/EmptyValidationValueObject.php new file mode 100644 index 0000000..f8d93cf --- /dev/null +++ b/tests/Mocks/ValueObjects/EmptyValidationValueObject.php @@ -0,0 +1,27 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/ValidatedAge.php b/tests/Mocks/ValueObjects/ValidatedAge.php new file mode 100644 index 0000000..9d22515 --- /dev/null +++ b/tests/Mocks/ValueObjects/ValidatedAge.php @@ -0,0 +1,32 @@ + self::MINIMUM]); + } + + return new self($value); + } + + public function toIntValue(): int + { + return $this->value; + } +} diff --git a/tests/Mocks/ValueObjects/ValidatedEmail.php b/tests/Mocks/ValueObjects/ValidatedEmail.php new file mode 100644 index 0000000..9820c07 --- /dev/null +++ b/tests/Mocks/ValueObjects/ValidatedEmail.php @@ -0,0 +1,39 @@ + $value]); + } + + return new self($value); + } + + public function toStringValue(): string + { + return $this->value; + } +} diff --git a/tests/Unit/Contracts/ExceptionHierarchyTest.php b/tests/Unit/Contracts/ExceptionHierarchyTest.php index b8280d6..c6c5850 100644 --- a/tests/Unit/Contracts/ExceptionHierarchyTest.php +++ b/tests/Unit/Contracts/ExceptionHierarchyTest.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\Contracts\PhpTsBindingsException; use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; +use Le0daniel\PhpTsBindings\Executor\Exceptions\ValidationException; use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\UnknownTypeKeyException; @@ -42,8 +43,21 @@ OperationNotFoundException::class, InvalidMiddlewareException::class, SchemaException::class, + ValidationException::class, ]); +/** + * ValidationException is the one exception that does not sit under a subsystem base, and that is the + * point: SchemaException means a failure that is not the value's fault, while this one is thrown by + * a value object precisely because the value is wrong. Filing it under SchemaException would make + * `catch (SchemaException)` - the way to catch a server fault - swallow a user input rejection. + */ +test('a value rejection is not a SchemaException', function () { + expect(is_a(ValidationException::class, SchemaException::class, true))->toBeFalse() + ->and(is_a(ValidationException::class, ParserException::class, true))->toBeFalse() + ->and(is_a(ValidationException::class, CodeGenException::class, true))->toBeFalse(); +}); + test('parser failures are catchable as ParserException', function (string $class) { expect(is_a($class, ParserException::class, true))->toBeTrue(); })->with([ diff --git a/tests/Unit/Executor/Exceptions/ValidationExceptionTest.php b/tests/Unit/Executor/Exceptions/ValidationExceptionTest.php new file mode 100644 index 0000000..d1d094c --- /dev/null +++ b/tests/Unit/Executor/Exceptions/ValidationExceptionTest.php @@ -0,0 +1,58 @@ +messages)->toBe(['Must be an email']) + ->and($exception->debugInfo)->toBe([]); +}); + +test('the exception message joins every message, so a log or a stack trace shows all of them', function () { + $exception = new ValidationException(['Is required', 'Must contain an @']); + + expect($exception->getMessage())->toBe('Is required, Must contain an @'); +}); + +/** + * A message list is what the field's issues are made of. Zero messages would return Value::INVALID + * with nothing recorded, which SchemaExecutor turns into a Failure with an empty issues map - a 422 + * whose details.fields is {}. Rejecting a value without saying why is never intended. + */ +test('an empty message list is rejected, because it would produce a failure with no issues', function () { + expect(fn() => new ValidationException([])) + ->toThrow(InvalidArgumentException::class); +}); + +test('a message list with holes is renumbered, so the issues stay a list', function () { + $exception = new ValidationException([1 => 'second', 5 => 'sixth']); + + expect($exception->messages)->toBe(['second', 'sixth']); +}); + +test('every message becomes its own issue, carrying the exception for debugging', function () { + $exception = new ValidationException(['Is required', 'Must contain an @']); + $issues = $exception->toIssues(); + + expect($issues)->toHaveCount(2) + ->and(array_map(fn(Issue $issue) => $issue->messageOrLocalizationKey, $issues)) + ->toBe(['Is required', 'Must contain an @']) + ->and($issues[0]->exception)->toBe($exception) + ->and($issues[1]->exception)->toBe($exception); +}); + +test('debug info merges with the callers, and the thrower wins on a shared key', function () { + $exception = new ValidationException('Rejected', ['value' => 'redacted', 'min' => 18]); + $issue = $exception->toIssues(['node' => 'SomeNode', 'value' => 'the-secret'])[0]; + + expect($issue->debugInfo)->toBe([ + 'node' => 'SomeNode', + 'value' => 'redacted', + 'min' => 18, + ]); +}); diff --git a/tests/Unit/Executor/SchemaExecutorTest.php b/tests/Unit/Executor/SchemaExecutorTest.php index 828e0d3..e2beae8 100644 --- a/tests/Unit/Executor/SchemaExecutorTest.php +++ b/tests/Unit/Executor/SchemaExecutorTest.php @@ -7,14 +7,19 @@ use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; use Le0daniel\PhpTsBindings\Executor\Data\Success; +use Le0daniel\PhpTsBindings\Executor\Exceptions\ValidationException; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\ValueObjectNode; use LogicException; use Stringable; use ValueError; use Tests\Mocks\ValueObjects\CreateAccountInput; use Tests\Mocks\ValueObjects\Email; +use Tests\Mocks\ValueObjects\EmptyValidationValueObject; use Tests\Mocks\ValueObjects\ExplodingValueObject; use Tests\Mocks\ValueObjects\StatusEnum; use Tests\Mocks\ValueObjects\UserId; +use Tests\Mocks\ValueObjects\ValidatedAge; +use Tests\Mocks\ValueObjects\ValidatedEmail; use Tests\Unit\Executor\Mocks\UserSchema; test('parse success', function (string $type, mixed $value, mixed $expected) { @@ -252,21 +257,27 @@ public function __toString(): string expect(executeParse(Email::class, null))->toBeFailure('validation.invalid_type'); }); -test('value object reports a throwing factory as a validation issue, not an internal error', function () { - expect(executeParse(Email::class, 'not-an-email'))->toBeFailure('validation.invalid_type'); - expect(executeParse(UserId::class, 0))->toBeFailure('validation.invalid_type'); - expect(executeParse(UserId::class, -1))->toBeFailure('validation.invalid_type'); +/** + * A rejected value is not a wrong type. parseValue() proves the backing string or int before the + * factory ever runs, so by the time one throws, the type is exactly what was declared and only the + * value is at fault - which is what the two keys have to keep apart. + */ +test('value object reports a throwing factory as an invalid value, not an invalid type', function () { + expect(executeParse(Email::class, 'not-an-email'))->toBeFailure('validation.invalid_value'); + expect(executeParse(UserId::class, 0))->toBeFailure('validation.invalid_value'); + expect(executeParse(UserId::class, -1))->toBeFailure('validation.invalid_value'); $result = executeParse(Email::class, 'not-an-email'); $messages = array_map(fn(Issue $issue) => $issue->messageOrLocalizationKey, $result->issues->allFlat()); - expect($messages)->not->toContain('internal_error'); + expect($messages)->not->toContain('internal_error') + ->and($messages)->not->toContain('validation.invalid_type'); }); test('the original exception is attached to the issue for debugging', function () { $result = executeParse(Email::class, 'not-an-email'); $issue = $result->issues->allFlat()[0]; - expect($issue->messageOrLocalizationKey)->toBe('validation.invalid_type') + expect($issue->messageOrLocalizationKey)->toBe('validation.invalid_value') ->and($issue->exception)->toBeInstanceOf(InvalidArgumentException::class) ->and($issue->exception->getMessage())->toBe('Invalid email: not-an-email'); }); @@ -276,7 +287,7 @@ public function __toString(): string // \ValueError extends Error, NOT Exception, so catching Exception would let it escape. $result = executeParse(StatusEnum::class, 'not-a-case'); - expect($result)->toBeFailure('validation.invalid_type') + expect($result)->toBeFailure('validation.invalid_value') ->and($result->issues->allFlat()[0]->exception)->toBeInstanceOf(ValueError::class); }); @@ -329,6 +340,80 @@ public function __toString(): string ]); }); +/** + * --------------------------------------------------------------------------- + * Value objects rejecting with ValidationException + * --------------------------------------------------------------------------- + */ + +test('a ValidationException names the message the client sees, instead of validation.invalid_value', function () { + $result = executeParse(ValidatedAge::class, 12); + + expect($result)->toBeFailure('Must be 18 or older') + ->and($result->issues->serializeToFieldsArray())->toBe([ + '__root' => ['Must be 18 or older'], + ]); +}); + +test('every message becomes its own issue at the same path, in order', function () { + $result = executeParse(ValidatedEmail::class, ''); + + expect($result->issues->serializeToFieldsArray())->toBe([ + '__root' => ['Email is required', 'Email must contain an @'], + ]); +}); + +test('the exception and its debug info ride along on every issue it produced', function () { + $result = executeParse(ValidatedEmail::class, 'nope'); + $issue = $result->issues->allFlat()[0]; + + expect($result->issues->allFlat())->toHaveCount(1) + ->and($issue->exception)->toBeInstanceOf(ValidationException::class) + ->and($issue->debugInfo)->toHaveKey('value', 'nope') + ->and($issue->debugInfo)->toHaveKey('node', ValueObjectNode::class); +}); + +test('the messages are reported at the field the value object sits at, not the root', function () { + // Only one property may fail here: StructHandler::parse() returns on the first invalid one, so + // a second rejecting field would never be reached. + $result = executeParse( + 'array{email: \\' . ValidatedEmail::class . ', age: \\' . ValidatedAge::class . '}', + ['email' => '', 'age' => 30], + ); + + expect($result)->toBeFailureAt('email', 'Email must contain an @') + ->and($result->issues->serializeToFieldsArray())->toBe([ + 'email' => ['Email is required', 'Email must contain an @'], + ]); +}); + +test('a ValidationException thrown for a list entry is reported at that index', function () { + $result = executeParse('\\' . ValidatedEmail::class . '[]', ['a@b.test', 'nope']); + + expect($result->issues->serializeToFieldsArray())->toBe([ + '1' => ['Email must contain an @'], + ]); +}); + +/** + * The generic Throwable arm still exists and still collapses to a single key. Only a value object + * that opts in by throwing ValidationException gets to name its messages. + */ +test('any other Throwable keeps collapsing to validation.invalid_value', function () { + expect(executeParse(Email::class, 'not-an-email'))->toBeFailure('validation.invalid_value'); +}); + +/** + * The constructor guard throws before the ValidationException exists, so the generic arm catches it + * and the field is still rejected with a message - never a Failure carrying no issues at all. + */ +test('a ValidationException built with no messages degrades instead of rejecting silently', function () { + $result = executeParse(EmptyValidationValueObject::class, 'anything'); + + expect($result)->toBeFailure('validation.invalid_value') + ->and($result->issues->allFlat()[0]->exception)->toBeInstanceOf(InvalidArgumentException::class); +}); + /** * --------------------------------------------------------------------------- * DateTimeString @@ -422,7 +507,7 @@ public function __toString(): string test('value object issues are reported at the right field path', function () { $result = executeParse('array{email: \\' . Email::class . '}', ['email' => 'nope']); - expect($result)->toBeFailureAt('email', 'validation.invalid_type'); + expect($result)->toBeFailureAt('email', 'validation.invalid_value'); }); test('value object coerces primitives when coercion is enabled', function () { diff --git a/tests/Unit/Server/Data/Exceptions/InvalidInputExceptionTest.php b/tests/Unit/Server/Data/Exceptions/InvalidInputExceptionTest.php deleted file mode 100644 index 51f22e4..0000000 --- a/tests/Unit/Server/Data/Exceptions/InvalidInputExceptionTest.php +++ /dev/null @@ -1,24 +0,0 @@ - 'Expected string', - 'lastName' => ['required', 'string'] - ]); - - expect($throwable)->tobeInstanceOf(InvalidInputException::class) - ->and($throwable->failure->issues->serializeToFieldsArray()) - ->toBe([ - 'firstName' => [ - 'Expected string', - ], - 'lastName' => [ - 'required', 'string', - ], - ]); -}); \ No newline at end of file diff --git a/tests/Unit/Server/Errors/ErrorPresenterTest.php b/tests/Unit/Server/Errors/ErrorPresenterTest.php index 7d06fea..450abe3 100644 --- a/tests/Unit/Server/Errors/ErrorPresenterTest.php +++ b/tests/Unit/Server/Errors/ErrorPresenterTest.php @@ -1,5 +1,7 @@ 'Is required']); + $exception = new InvalidInputException(new Failure(Issues::fromMessages(['name' => 'Is required']))); $error = new ErrorPresenter(new ServerConfiguration()) ->present($exception, errorDefinition(), errorResolveInfo()); From 07a74dd0128f17f9716a3016c85d3f2292e497b8 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 6 Aug 2026 14:01:04 +0200 Subject: [PATCH 066/101] Refactor and simplify code generation with `CodeGenerators` utility and streamlined naming logic - Introduced `CodeGenerators` class to centralize default generator definitions and their configuration. - Removed redundant `getNamingGenerator` method, replacing it with a reusable `CodeGenerators::namingGenerator`. - Replaced verbose generator initialization with `CodeGenerators::fromDefaults`, improving maintainability and readability. - Added `Assertions::string` utility for stricter type checks on naming input. - Updated `CodeGenCommand` to integrate these changes while preserving existing functionality. --- .../Laravel/Commands/CodeGenCommand.php | 98 +++----------- src/CodeGen/CodeGenerators.php | 121 ++++++++++++++++++ src/Utils/Assertions.php | 12 ++ 3 files changed, 152 insertions(+), 79 deletions(-) create mode 100644 src/CodeGen/CodeGenerators.php diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index 113123e..c6e4839 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -2,22 +2,15 @@ namespace Le0daniel\PhpTsBindings\Adapters\Laravel\Commands; -use Closure; use Illuminate\Console\Command; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Foundation\Application; use Illuminate\Routing\Router; +use InvalidArgumentException; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider; use Le0daniel\PhpTsBindings\Adapters\Laravel\Utils\ArtisanOptions; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationsSpaClient; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTanstackQuery; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeMap; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesOperationCode; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; @@ -28,6 +21,8 @@ use Le0daniel\PhpTsBindings\CodeGen\Utils\OutputDirectory; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; +use Le0daniel\PhpTsBindings\Utils\Assertions; +use function sprintf; final class CodeGenCommand extends Command { @@ -149,51 +144,6 @@ public function handle( return 0; } - /** - * @return Closure(TypedOperation): string - * @throws BindingResolutionException - */ - private function getNamingGenerator(Application $application): Closure - { - $nameGenerator = match ($this->option('naming')) { - 'fqn' => function (TypedOperation $operationData): string { - $namespace = $operationData->definition->namespace; - $name = ucfirst($operationData->definition->name); - return "{$namespace}{$name}"; - }, - 'operation-prefix' => function (TypedOperation $operationData): string { - $name = ucfirst($operationData->definition->name); - return "{$operationData->definition->namespace}{$name}"; - }, - 'namespace-postfix' => function (TypedOperation $operationData): string { - $namespace = ucfirst($operationData->definition->namespace); - $name = $operationData->definition->name; - return "{$name}{$namespace}"; - }, - 'name' => function (TypedOperation $operationData): string { - return $operationData->definition->name; - }, - default => null, - }; - - if ($nameGenerator) { - return $nameGenerator; - } - - $naming = ArtisanOptions::asString($this->option('naming')) ?? ''; - $parts = explode('::', $naming, 2); - - if (count($parts) === 2 && class_exists($parts[0]) && method_exists($parts[0], $parts[1])) { - $instance = $application->make($parts[0]); - return $instance->{$parts[1]}(...); - } - - throw new CodeGenException( - "Unknown naming mode '{$naming}'. Use one of name, fqn, operation-prefix, " - . "namespace-postfix, or Class::method naming your own rule." - ); - } - /** * @param string $directory * @param array $files @@ -226,33 +176,23 @@ private function getGeneratorsFromInput(Application $application): array $with = ArtisanOptions::expandOptionsArrayCommaSeparated($this->option('with')); $without = ArtisanOptions::expandOptionsArrayCommaSeparated($this->option('without')); - $includeGenerator = function (string $name, bool $default = true) use ($with, $without): bool { - if (in_array($name, $with, true)) { - return true; - } - if (in_array($name, $without, true)) { - return false; + $namingGeneratorName = ($this->option('naming') ?? 'name') |> Assertions::string(...); + + $namingGenerator = match($namingGeneratorName) { + 'fqn','operation-prefix','namespace-postfix','name' => CodeGenerators::namingGenerator($namingGeneratorName), + default => static function (TypedOperation $operation) use ($namingGeneratorName) { + if (!is_callable($namingGeneratorName)) { + throw new InvalidArgumentException(sprintf('Expected callable, got %s', gettype($namingGeneratorName))); + } + return $namingGeneratorName($operation); } - return $default; }; - $namingGenerator = $this->getNamingGenerator($application); - - $generators = array_filter([ - $includeGenerator('types', true) ? new EmitTypes() : null, - $includeGenerator('bindings', true) ? new EmitOperationClientBindings() : null, - $includeGenerator('utils', true) ? new EmitTypeUtils() : null, - // On by default: the adapter picks OperationSPAClient for a request carrying the - // matching header, so the client that reads its payload ships with it. A project - // using a Client of its own drops the file with --without operations-spa. - $includeGenerator('operations-spa', true) ? new EmitOperationsSpaClient() : null, - $includeGenerator('operations', true) ? new EmitOperations($namingGenerator) : null, - $includeGenerator('type-map', false) ? new EmitTypeMap() : null, - // Only EmitOperations is given the naming rule: it declares the names, the other two - // are handed it as a dependency and ask for them. - $includeGenerator('tanstack-query', false) ? new EmitTanstackQuery() : null, - $includeGenerator('query-key', false) ? new EmitQueryKey() : null, - ], fn($value) => $value !== null); + $defaultGenerators = CodeGenerators::fromDefaults( + $namingGenerator, + with: $with, + without: $without, + ); $customGenerators = array_map( fn(string $className) => $application->make($className), @@ -261,7 +201,7 @@ private function getGeneratorsFromInput(Application $application): array // @phpstan-ignore-next-line arrayValues.list return array_values([ - ...$generators, + ...$defaultGenerators, ...$customGenerators, ]); } diff --git a/src/CodeGen/CodeGenerators.php b/src/CodeGen/CodeGenerators.php new file mode 100644 index 0000000..5444819 --- /dev/null +++ b/src/CodeGen/CodeGenerators.php @@ -0,0 +1,121 @@ +}> + */ + private const array DEFAULT_GENERATORS = [ + 'types' => [ + 'defaultEnabled' => true, + 'class' => EmitTypes::class, + ], + 'bindings' => [ + 'defaultEnabled' => true, + 'class' => EmitOperationClientBindings::class, + ], + 'utils' => [ + 'defaultEnabled' => true, + 'class' => EmitTypeUtils::class, + ], + 'operations-spa' => [ + 'defaultEnabled' => true, + 'class' => EmitOperationsSpaClient::class, + ], + 'operations' => [ + 'defaultEnabled' => true, + 'class' => EmitOperations::class, + ], + 'type-map' => [ + 'defaultEnabled' => false, + 'class' => EmitTypeMap::class, + ], + 'tanstack-query' => [ + 'defaultEnabled' => false, + 'class' => EmitTanstackQuery::class, + ], + 'query-key' => [ + 'defaultEnabled' => false, + 'class' => EmitQueryKey::class, + ], + ]; + + /** + * @param GeneratorName $generatorName + * @return NamingGenerator + */ + public static function namingGenerator(string $generatorName): Closure + { + return match ($generatorName) { + 'fqn' => function (TypedOperation $operationData): string { + $namespace = $operationData->definition->namespace; + $name = ucfirst($operationData->definition->name); + return "{$namespace}{$name}"; + }, + 'operation-prefix' => function (TypedOperation $operationData): string { + $name = ucfirst($operationData->definition->name); + return "{$operationData->definition->namespace}{$name}"; + }, + 'namespace-postfix' => function (TypedOperation $operationData): string { + $namespace = ucfirst($operationData->definition->namespace); + $name = $operationData->definition->name; + return "{$name}{$namespace}"; + }, + 'name' => function (TypedOperation $operationData): string { + return $operationData->definition->name; + }, + }; + } + + /** + * @param GeneratorName|NamingGenerator $namingGenerator + * @param list $with + * @param list $without + * @return list + */ + public static function fromDefaults(string|Closure $namingGenerator, array $with = [], array $without = []): array + { + $namingGenerator = $namingGenerator instanceof Closure + ? $namingGenerator + : self::namingGenerator($namingGenerator); + + /** @var list $generators */ + $generators = []; + + foreach (self::DEFAULT_GENERATORS as $name => ['class' => $classString, 'defaultEnabled' => $defaultEnabled]) { + if ($defaultEnabled && in_array($name, $without, true)) { + continue; + } + + if (!$defaultEnabled && !in_array($name, $with, true)) { + continue; + } + + $generators[] = match ($classString) { + EmitOperations::class => new EmitOperations($namingGenerator), + default => new $classString(), + }; + } + + return $generators; + } +} \ No newline at end of file diff --git a/src/Utils/Assertions.php b/src/Utils/Assertions.php index eb535e6..30b1ab3 100644 --- a/src/Utils/Assertions.php +++ b/src/Utils/Assertions.php @@ -22,4 +22,16 @@ public static function instanceOf(string $className, mixed $value): mixed return $value; } + + /** + * @phpstan-assert string $value + */ + public static function string(mixed $value): string + { + if (!is_string($value)) { + throw new InvalidArgumentException(\sprintf('Expected string, got %s', gettype($value))); + } + + return $value; + } } \ No newline at end of file From 8f516fc078eceda2366d1c39db2696287b2f79c0 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 6 Aug 2026 14:10:23 +0200 Subject: [PATCH 067/101] Add tests for `CodeGenerators` and `CodeGenCommand` with custom naming rules - Introduced unit tests for `CodeGenCommand` to validate `--naming` behaviors, including custom `Class::method` rules. - Added `CodeGenerators` tests ensuring default, `with`, and `without` generator combinations function as expected. - Simplified generator initialization with `CodeGenerators::fromDefaults`. - Updated documentation and comments to align with the new functionality. --- README.md | 23 ++-- docs/laravel.md | 8 +- docs/server.md | 3 + docs/typescript-client.md | 76 +++++++---- .../Laravel/Commands/CodeGenCommand.php | 47 +++++-- src/CodeGen/CodeGenerators.php | 9 +- .../Laravel/CodeGenCommandNamingTest.php | 62 +++++++++ tests/Unit/CodeGen/CodeGeneratorsTest.php | 123 ++++++++++++++++++ tests/Unit/CodeGen/TsOutputFixture.php | 24 +--- 9 files changed, 302 insertions(+), 73 deletions(-) create mode 100644 tests/Adapters/Laravel/CodeGenCommandNamingTest.php create mode 100644 tests/Unit/CodeGen/CodeGeneratorsTest.php diff --git a/README.md b/README.md index a1b483c..8c62cf5 100644 --- a/README.md +++ b/README.md @@ -121,11 +121,7 @@ plain `number` or `string` on the TypeScript side. build step, not something the server does at runtime. ```php -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationsSpaClient; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\CodeGen\Utils\OutputDirectory; @@ -140,17 +136,18 @@ $server = new Server( ), ); -$files = new TypescriptServerCodeGenerator([ - new EmitTypes(), - new EmitOperationClientBindings(), - new EmitTypeUtils(), - new EmitOperationsSpaClient(), - new EmitOperations(), -])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); +$files = new TypescriptServerCodeGenerator( + CodeGenerators::fromDefaults('name'), +)->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); OutputDirectory::write(__DIR__ . '/resources/js/operations', $files); ``` +`CodeGenerators::fromDefaults()` builds the five generators that are on by default, and `'name'` is +the rule that names the generated functions. `with:` and `without:` change the set — three more ship +opt-in — and what it returns is a plain list, so you can append your own or skip the factory and pass +your own array. See [the generators](docs/typescript-client.md#generators) for the whole menu. + The two URLs are the routes *your* transport serves; `{fqn}` is where the operation key goes, and both are required to contain it. @@ -281,6 +278,8 @@ consult one source, so the generated union cannot describe responses the server **Codegen is a build step.** The client lives in your repo, nothing is published to npm, and `OutputDirectory` only ever touches files carrying its own marker — so it cannot delete or overwrite something you wrote. `verify()` runs the same rules without writing, which is your CI drift check. +Which generators run is still your list; `CodeGenerators::fromDefaults()` is a factory over the same +contracts that spares you writing out the sensible one. **No dishonest types.** Generation throws rather than emit a placeholder for something it cannot represent. That is also why the envelope names `__client` but types it `unknown`: the key is diff --git a/docs/laravel.md b/docs/laravel.md index 7ef7815..95077b7 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -224,8 +224,9 @@ php artisan operations:codegen resources/js/operations --with=tanstack-query,que | `--naming=` | How the generated functions are named. Default `name`. | | `--verify` | Check for drift instead of writing, exiting 1 on any difference. Use it in CI. | -The names accepted by `--with` and `--without` map onto the -[generators](typescript-client.md#generators): +The command is a pass-through to `CodeGenerators::fromDefaults()`, so its flags are the names and +modes that class defines — see [the generators](typescript-client.md#generators) for what each one +writes. The names accepted by `--with` and `--without`: | Name | Generator | Default | |---|---|---| @@ -238,7 +239,8 @@ The names accepted by `--with` and `--without` map onto the | `tanstack-query` | `EmitTanstackQuery` | off | | `query-key` | `EmitQueryKey` | off | -`--naming=` chooses how functions are named: +`--naming=` chooses how functions are named. Every mode but the last is one +`CodeGenerators::namingGenerator()` knows; `Class::method` is this command's own: | Mode | `#[Query('users', 'get')]` becomes | |---|---| diff --git a/docs/server.md b/docs/server.md index 129783a..a5efcbf 100644 --- a/docs/server.md +++ b/docs/server.md @@ -256,5 +256,8 @@ The interfaces meant to be implemented by you: | `StringValueObject` / `IntValueObject` | [A class that travels as one primitive](types.md#value-objects). | | `GeneratesLibFiles` / `GeneratesOperationCode` / `DependsOn` | [Adding to the generated client](typescript-client.md#writing-your-own-generator). | +`CodeGenerators::fromDefaults()` builds the default generator set for you, and returns a plain list +you can add yours to — see [the generators](typescript-client.md#generators). + The lower-level `TypeParser`, `SchemaExecutor` and `TypescriptGenerator` are public too, if you want to parse and emit types without the RPC layer at all — see [the type reference](types.md). diff --git a/docs/typescript-client.md b/docs/typescript-client.md index 31d5752..33841d5 100644 --- a/docs/typescript-client.md +++ b/docs/typescript-client.md @@ -27,9 +27,9 @@ it on disk. Nothing is published to npm; the code lives in your repo. .ts one module per namespace, one function per operation ``` -That is what the five [default generators](#generators) produce. The opt-in ones add to it: -`EmitTypeMap` writes one more file, the other two write into the `.ts` modules that are -already there. +That is what the five [generators](#generators) `CodeGenerators::fromDefaults()` returns produce. The +opt-in ones add to it: `EmitTypeMap` writes one more file, the other two write into the +`.ts` modules that are already there. Every generated file opens with `// generated by: php-ts-bindings`. That marker is what `OutputDirectory` uses to tell its own output from anything else in the directory: it removes only @@ -116,32 +116,64 @@ try { ## Generators -The generator list you hand `TypescriptServerCodeGenerator` *is* the configuration — the core has no -default set. Eight ship, and the "default" column below is what -[`php artisan operations:codegen`](laravel.md#operationscodegen) turns on when you pass it no flags: - -| Generator | Laravel Default | Emits | -|---|-----------------|---| -| `EmitTypes` | on | `lib/types.ts` — the envelope, `Brand`, every `#[Named]` alias | -| `EmitOperationClientBindings` | on | `lib/bindings.ts`, `lib/OperationClient.ts`, `lib/DefaultClient.ts`, `lib/OperationException.ts` | -| `EmitTypeUtils` | on | `lib/utils.ts` — `queryKey` and `throwOnFailure` | -| `EmitOperationsSpaClient` | on | `lib/client-operations-spa.ts` — the `OperationSPAClient` payload and `containsOperationSpaPayload()` | -| `EmitOperations` | on | one `.ts` module per namespace | -| `EmitTanstackQuery` | off | `QueryOptions()` and `useQuery()` for `@tanstack/react-query` | -| `EmitQueryKey` | off | standalone query keys | -| `EmitTypeMap` | off | `lib/type-map.ts` — a `TypeMap` of every operation's input, output and error types, split into `{query: …, command: …}` and keyed by `namespace.name` | +The generator list you hand `TypescriptServerCodeGenerator` *is* the configuration. Eight ship, five +of them on by default, and `CodeGenerators::fromDefaults()` is how you build that set: + +```php +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators; + +CodeGenerators::fromDefaults('name'); +CodeGenerators::fromDefaults('fqn', with: ['tanstack-query'], without: ['operations-spa']); +CodeGenerators::fromDefaults(fn(TypedOperation $operation) => $operation->definition->name); +``` + +The first argument is the naming rule, `with:` and `without:` take the names from the table below, +and a name in both lists is turned **on** — asking for a generator always wins. What comes back is a +plain `list`, always in the order of the table below rather than the order you asked in, so nothing +stops you from appending to it or ignoring the factory and passing your own array. + +| Name | Generator | Default | Emits | +|---|---|---|---| +| `types` | `EmitTypes` | on | `lib/types.ts` — the envelope, `Brand`, every `#[Named]` alias | +| `bindings` | `EmitOperationClientBindings` | on | `lib/bindings.ts`, `lib/OperationClient.ts`, `lib/DefaultClient.ts`, `lib/OperationException.ts` | +| `utils` | `EmitTypeUtils` | on | `lib/utils.ts` — `queryKey` and `throwOnFailure` | +| `operations-spa` | `EmitOperationsSpaClient` | on | `lib/client-operations-spa.ts` — the `OperationSPAClient` payload and `containsOperationSpaPayload()` | +| `operations` | `EmitOperations` | on | one `.ts` module per namespace | +| `type-map` | `EmitTypeMap` | off | `lib/type-map.ts` — a `TypeMap` of every operation's input, output and error types, split into `{query: …, command: …}` and keyed by `namespace.name` | +| `tanstack-query` | `EmitTanstackQuery` | off | `QueryOptions()` and `useQuery()` for `@tanstack/react-query` | +| `query-key` | `EmitQueryKey` | off | standalone query keys | `EmitTanstackQuery` and `EmitQueryKey` emit **only for queries**, since a command has nothing to -cache. +cache. [`php artisan operations:codegen`](laravel.md#operationscodegen) is a pass-through to this +factory: its `--with`, `--without` and `--naming` flags are these names and these modes. + +### Naming the generated functions -`new EmitOperations($closure)` takes a `Closure(TypedOperation): string` that names the generated -functions; the default is the operation's bare name. `generate()` takes a third argument, a list of -namespaces (or `namespace.name` operations) to skip. +The naming argument is a `Closure(TypedOperation): string`, or one of four modes standing for a +closure the library ships. `CodeGenerators::namingGenerator($mode)` hands you that closure on its own: + +| Mode | `#[Query('users', 'get')]` becomes | +|---|---| +| `name` | `get` | +| `fqn`, `operation-prefix` | `usersGet` — the two are the same rule | +| `namespace-postfix` | `getUsers` | + +Only `EmitOperations` uses it, and `new EmitOperations($closure)` still takes one directly if you are +assembling the list by hand — `fromDefaults()` is the front door, that constructor is the low-level +one. `generate()` takes a third argument, a list of namespaces (or `namespace.name` operations) to +skip. ## Writing your own generator Implement `GeneratesLibFiles` (gets every operation, writes shared lib files) or -`GeneratesOperationCode` (gets one operation, writes its code), and add it to the list. +`GeneratesOperationCode` (gets one operation, writes its code), and add it to the list: + +```php +new TypescriptServerCodeGenerator([ + ...CodeGenerators::fromDefaults('name'), + new MyGenerator(), +]); +``` Add `DependsOn` to declare the generators yours needs: the run fails early if one is missing, and hands you the resolved instances through `setDependencies()`. That is how to reference what another diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index c6e4839..c0761d0 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -2,11 +2,11 @@ namespace Le0daniel\PhpTsBindings\Adapters\Laravel\Commands; +use Closure; use Illuminate\Console\Command; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Foundation\Application; use Illuminate\Routing\Router; -use InvalidArgumentException; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelServiceProvider; use Le0daniel\PhpTsBindings\Adapters\Laravel\Utils\ArtisanOptions; @@ -22,7 +22,6 @@ use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Utils\Assertions; -use function sprintf; final class CodeGenCommand extends Command { @@ -41,14 +40,17 @@ final class CodeGenCommand extends Command Use --with=tanstack-query,... or --with=.* --with=.* to include a specific generators like tanstack-query operations. Following types are available: - - types (default: true) - - bindings (default: true) - - utils (default: true) - - operations (default: true) + - types (default: true) + - bindings (default: true) + - utils (default: true) + - operations-spa (default: true) + - operations (default: true) - type-map (default: false) - tanstack-query (default: false) - query-key (default: false) - + + A name given to both --with and --without is turned on: --with wins. + To provide custom generators, create a class that implements at least one of the following interfaces: - GeneratesLibFiles (gets all operations and can write multiple lib files) - GeneratesOperationCode (gets each operation as input and writes code for it) @@ -180,12 +182,7 @@ private function getGeneratorsFromInput(Application $application): array $namingGenerator = match($namingGeneratorName) { 'fqn','operation-prefix','namespace-postfix','name' => CodeGenerators::namingGenerator($namingGeneratorName), - default => static function (TypedOperation $operation) use ($namingGeneratorName) { - if (!is_callable($namingGeneratorName)) { - throw new InvalidArgumentException(sprintf('Expected callable, got %s', gettype($namingGeneratorName))); - } - return $namingGeneratorName($operation); - } + default => $this->customNamingGenerator($application, $namingGeneratorName), }; $defaultGenerators = CodeGenerators::fromDefaults( @@ -206,4 +203,28 @@ private function getGeneratorsFromInput(Application $application): array ]); } + /** + * Anything that is not one of the built-in modes is read as Class::method naming your own rule. + * The class goes through the container and the method is called on the instance, despite the + * static-looking syntax, so a rule is free to depend on whatever the container can build. + * + * @return Closure(TypedOperation): string + * @throws BindingResolutionException + */ + private function customNamingGenerator(Application $application, string $naming): Closure + { + $parts = explode('::', $naming, 2); + + if (count($parts) === 2 && class_exists($parts[0]) && method_exists($parts[0], $parts[1])) { + $instance = $application->make($parts[0]); + return $instance->{$parts[1]}(...); + } + + // Thrown here rather than from inside the closure: getGeneratorsFromInput() runs inside + // handle()'s try, so a typo ends the run with this message instead of a stack trace. + throw new CodeGenException( + "Unknown naming mode '{$naming}'. Use one of name, fqn, operation-prefix, " + . "namespace-postfix, or Class::method naming your own rule." + ); + } } \ No newline at end of file diff --git a/src/CodeGen/CodeGenerators.php b/src/CodeGen/CodeGenerators.php index 5444819..36aa57e 100644 --- a/src/CodeGen/CodeGenerators.php +++ b/src/CodeGen/CodeGenerators.php @@ -102,11 +102,12 @@ public static function fromDefaults(string|Closure $namingGenerator, array $with $generators = []; foreach (self::DEFAULT_GENERATORS as $name => ['class' => $classString, 'defaultEnabled' => $defaultEnabled]) { - if ($defaultEnabled && in_array($name, $without, true)) { - continue; - } + // Asking for a generator always wins: a name in both lists turns it on, so a caller + // building on top of someone else's $without never has to unpick it first. + $isEnabled = in_array($name, $with, true) + || ($defaultEnabled && !in_array($name, $without, true)); - if (!$defaultEnabled && !in_array($name, $with, true)) { + if (!$isEnabled) { continue; } diff --git a/tests/Adapters/Laravel/CodeGenCommandNamingTest.php b/tests/Adapters/Laravel/CodeGenCommandNamingTest.php new file mode 100644 index 0000000..020be97 --- /dev/null +++ b/tests/Adapters/Laravel/CodeGenCommandNamingTest.php @@ -0,0 +1,62 @@ +prefix}_{$operation->definition->name}"; + } +} + +/** + * @param class-string|null $resolves + */ +function customNamingGeneratorFor(string $naming, ?string $resolves = null): mixed +{ + $application = Mockery::mock(Application::class); + if ($resolves) { + $application->shouldReceive('make')->with($resolves)->andReturn(new NamingRule('custom')); + } + + $method = new ReflectionMethod(CodeGenCommand::class, 'customNamingGenerator'); + return $method->invoke(new CodeGenCommand(), $application, $naming); +} + +test('Class::method resolves through the container and binds the instance', function () { + $closure = customNamingGeneratorFor(NamingRule::class . '::name', NamingRule::class); + + $bound = new ReflectionFunction($closure)->getClosureThis(); + + expect($bound)->toBeInstanceOf(NamingRule::class) + ->and($bound->prefix)->toBe('custom'); +}); + +test('an unknown naming mode ends the run with the list of valid ones', function () { + expect(fn() => customNamingGeneratorFor('nonsense')) + ->toThrow(CodeGenException::class, "Unknown naming mode 'nonsense'"); +}); + +test('a Class::method naming an unknown class or method is an unknown mode', function (string $naming) { + expect(fn() => customNamingGeneratorFor($naming))->toThrow(CodeGenException::class); +})->with([ + 'App\\Nope::name', + NamingRule::class . '::noSuchMethod', +]); diff --git a/tests/Unit/CodeGen/CodeGeneratorsTest.php b/tests/Unit/CodeGen/CodeGeneratorsTest.php new file mode 100644 index 0000000..fbfea5c --- /dev/null +++ b/tests/Unit/CodeGen/CodeGeneratorsTest.php @@ -0,0 +1,123 @@ + $generators + * @return list + */ +function classesOf(array $generators): array +{ + return array_map(fn(object $generator): string => $generator::class, $generators); +} + +/** + * @param string|\Closure(TypedOperation): string $naming + */ +function usersModuleFor(string|\Closure $naming): string +{ + $server = new Server( + EagerlyLoadedOperationRegistry::withClasses( + [UserOperations::class], + keyGenerator: new PlainlyExposedKeyGenerator(), + ), + ); + + $files = new TypescriptServerCodeGenerator( + CodeGenerators::fromDefaults($naming), + )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); + + return $files['users.ts']->toString(); +} + +test('defaults are the five on-by-default generators, in declaration order', function () { + expect(classesOf(CodeGenerators::fromDefaults('name')))->toBe([ + EmitTypes::class, + EmitOperationClientBindings::class, + EmitTypeUtils::class, + EmitOperationsSpaClient::class, + EmitOperations::class, + ]); +}); + +test('with adds an opt-in generator in declaration order, not append order', function () { + expect(classesOf(CodeGenerators::fromDefaults('name', with: ['tanstack-query', 'type-map'])))->toBe([ + EmitTypes::class, + EmitOperationClientBindings::class, + EmitTypeUtils::class, + EmitOperationsSpaClient::class, + EmitOperations::class, + EmitTypeMap::class, + EmitTanstackQuery::class, + ]); +}); + +test('with enables every opt-in generator', function () { + expect(classesOf(CodeGenerators::fromDefaults('name', with: ['type-map', 'tanstack-query', 'query-key']))) + ->toContain(EmitTypeMap::class, EmitTanstackQuery::class, EmitQueryKey::class) + ->toHaveCount(8); +}); + +test('without drops a default generator', function () { + expect(classesOf(CodeGenerators::fromDefaults('name', without: ['operations-spa'])))->toBe([ + EmitTypes::class, + EmitOperationClientBindings::class, + EmitTypeUtils::class, + EmitOperations::class, + ]); +}); + +test('with wins over without when a name appears in both', function () { + expect(classesOf(CodeGenerators::fromDefaults('name', with: ['types'], without: ['types']))) + ->toContain(EmitTypes::class); + + expect(classesOf(CodeGenerators::fromDefaults('name', with: ['type-map'], without: ['type-map']))) + ->toContain(EmitTypeMap::class); +}); + +test('without an already off generator is a no-op', function () { + expect(classesOf(CodeGenerators::fromDefaults('name', without: ['tanstack-query']))) + ->toBe(classesOf(CodeGenerators::fromDefaults('name'))); +}); + +test('an unknown name in with or without is ignored', function () { + expect(classesOf(CodeGenerators::fromDefaults('name', with: ['nope'], without: ['also-nope']))) + ->toBe(classesOf(CodeGenerators::fromDefaults('name'))); +}); + +test('each naming mode names the generated function', function (string $mode, string $expected) { + expect(usersModuleFor($mode))->toContain("export async function {$expected}("); +})->with([ + ['name', 'get'], + ['fqn', 'usersGet'], + ['operation-prefix', 'usersGet'], + ['namespace-postfix', 'getUsers'], +]); + +test('a closure is accepted in place of a naming mode', function () { + $naming = fn(TypedOperation $operation): string => "do_{$operation->definition->name}"; + + expect(usersModuleFor($naming))->toContain('export async function do_get('); +}); + +test('namingGenerator returns the closure a mode stands for', function () { + expect(usersModuleFor(CodeGenerators::namingGenerator('fqn'))) + ->toContain('export async function usersGet('); +}); diff --git a/tests/Unit/CodeGen/TsOutputFixture.php b/tests/Unit/CodeGen/TsOutputFixture.php index fea41f6..86eeffc 100644 --- a/tests/Unit/CodeGen/TsOutputFixture.php +++ b/tests/Unit/CodeGen/TsOutputFixture.php @@ -2,14 +2,7 @@ namespace Tests\Unit\CodeGen; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperations; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationsSpaClient; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitQueryKey; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTanstackQuery; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeMap; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; -use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; @@ -24,7 +17,7 @@ * The one definition of what tests/ts-output/generated holds. The script that writes it and the * test that verifies it both come here, so neither can drift into checking something else. * - * Every generator is registered, including the three that are opt-in on the artisan command: the + * Every generator is registered — the defaults plus the three the `with:` list opts into: the * fixture exists to hand the TypeScript compiler as much generated code as this library can emit. */ final class TsOutputFixture @@ -55,15 +48,8 @@ public static function generate(): array ), ); - return new TypescriptServerCodeGenerator([ - new EmitTypes(), - new EmitOperationClientBindings(), - new EmitTypeUtils(), - new EmitOperationsSpaClient(), - new EmitOperations(), - new EmitTypeMap(), - new EmitTanstackQuery(), - new EmitQueryKey(), - ])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); + return new TypescriptServerCodeGenerator( + CodeGenerators::fromDefaults('name', with: ['type-map', 'tanstack-query', 'query-key']), + )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); } } From 646392ae85e3f49d5b5699f66cf199dda66a095a Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 6 Aug 2026 15:02:54 +0200 Subject: [PATCH 068/101] Force rules --- phpstan.neon | 3 +++ src/Adapters/Laravel/Commands/CodeGenCommand.php | 3 ++- src/Adapters/Laravel/LaravelHttpController.php | 2 +- src/Adapters/PHPStan/UtilitiesNodeResolver.php | 5 +++-- src/CodeGen/TypescriptServerCodeGenerator.php | 2 +- src/CodeGen/Utils/ErrorTypescript.php | 6 +++--- src/Executor/Data/Context.php | 2 -- src/Executor/Data/Issues.php | 2 +- src/Executor/Handlers/CustomClassHandler.php | 1 + src/Executor/Handlers/ListHandler.php | 2 +- src/Executor/Handlers/StructHandler.php | 1 + src/Executor/Handlers/UnionHandler.php | 1 + src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php | 2 +- src/Parser/Helpers/Consumers/InteractsWithGenerics.php | 2 +- src/Parser/Helpers/Consumers/StructConsumer.php | 2 +- src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php | 2 +- src/Parser/Nodes/ConstraintNode.php | 4 ++-- src/Parser/Nodes/StructNode.php | 2 +- src/Parser/Nodes/TupleNode.php | 3 --- src/Reflection/FileReflector.php | 2 +- src/Server/Operations/EagerlyLoadedOperationRegistry.php | 2 +- src/Server/Operations/OperationDiscovery.php | 2 +- src/Server/Server.php | 1 + src/Utils/PHPExport.php | 2 +- src/Utils/PhpDoc.php | 6 +++--- 25 files changed, 33 insertions(+), 29 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index a81aae2..9cb1f4b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,10 @@ includes: - extension.neon + - vendor/phpstan/phpstan-strict-rules/rules.neon parameters: + strictRules: + booleansInConditions: false paths: - src/ diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index c0761d0..00e8c02 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -155,7 +155,7 @@ private function verifyContentOnly(string $directory, array $files): int { $issues = OutputDirectory::verify($directory, $files); - if (!empty($issues)) { + if (count($issues) > 0) { $count = count($issues); $this->error("Found {$count} issue(s):"); @@ -217,6 +217,7 @@ private function customNamingGenerator(Application $application, string $naming) if (count($parts) === 2 && class_exists($parts[0]) && method_exists($parts[0], $parts[1])) { $instance = $application->make($parts[0]); + /* @phpstan-ignore-next-line method.dynamicName */ return $instance->{$parts[1]}(...); } diff --git a/src/Adapters/Laravel/LaravelHttpController.php b/src/Adapters/Laravel/LaravelHttpController.php index 5cb83fc..adb8584 100644 --- a/src/Adapters/Laravel/LaravelHttpController.php +++ b/src/Adapters/Laravel/LaravelHttpController.php @@ -122,7 +122,7 @@ private function gatherInputFromRequest(OperationType $type, Http\Request $reque OperationType::COMMAND => $request->json()->all(), }; - return empty($inputData) ? null : $inputData; + return count($inputData) === 0 ? null : $inputData; } /** diff --git a/src/Adapters/PHPStan/UtilitiesNodeResolver.php b/src/Adapters/PHPStan/UtilitiesNodeResolver.php index e6c2849..c4039ca 100644 --- a/src/Adapters/PHPStan/UtilitiesNodeResolver.php +++ b/src/Adapters/PHPStan/UtilitiesNodeResolver.php @@ -19,6 +19,7 @@ use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Reflection\ReflectionProvider; +use ReflectionProperty; final class UtilitiesNodeResolver implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension { @@ -174,11 +175,11 @@ private function resolveObjectType(string $type, ObjectType $structType, Type $k $classReflection = $this->reflectionProvider->getClass($className); $properties = $classReflection->getNativeReflection()->getProperties( - \ReflectionProperty::IS_PUBLIC + ReflectionProperty::IS_PUBLIC ); $propertyTypes = []; - /** @var \ReflectionProperty $prop */ + /** @var \PHPStan\BetterReflection\Reflection\Adapter\ReflectionProperty $prop */ foreach ($properties as $prop) { $propName = $prop->getName(); $keyType = new ConstantStringType($propName, false); diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 8bc8206..fa3946c 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -70,7 +70,7 @@ private function resolveGeneratorDependencies(): void } } - if (!empty($issues)) { + if (count($issues) > 0) { throw new InvalidGeneratorDependencies($issues); } diff --git a/src/CodeGen/Utils/ErrorTypescript.php b/src/CodeGen/Utils/ErrorTypescript.php index f44a47d..ea6d1bf 100644 --- a/src/CodeGen/Utils/ErrorTypescript.php +++ b/src/CodeGen/Utils/ErrorTypescript.php @@ -30,11 +30,11 @@ public static function forOperation(ServerConfiguration $configuration, Definiti self::branch(ErrorType::INVALID_INPUT, self::INVALID_INPUT_DETAILS), ]; - if (!empty($configuration->unauthenticatedExceptions)) { + if (count($configuration->unauthenticatedExceptions) !== 0) { $branches[] = self::branch(ErrorType::AUTHENTICATION_ERROR); } - if (!empty($configuration->unauthorizedExceptions)) { + if (count($configuration->unauthorizedExceptions) !== 0) { $branches[] = self::branch(ErrorType::AUTHORIZATION_ERROR); } @@ -55,7 +55,7 @@ public static function forOperation(ServerConfiguration $configuration, Definiti private static function domainDetails(ServerConfiguration $configuration, Definition $definition): ?string { $exposedTypes = ExposedExceptions::exposedTypesFor($definition, $configuration); - if (empty($exposedTypes)) { + if (count($exposedTypes) === 0) { return null; } diff --git a/src/Executor/Data/Context.php b/src/Executor/Data/Context.php index 9023761..7fc52ad 100644 --- a/src/Executor/Data/Context.php +++ b/src/Executor/Data/Context.php @@ -64,8 +64,6 @@ public function removeCurrentIssues(): void $current = $this->pathAsString(); foreach ($this->issues as $path => $issues) { - // This is needed as path '0' is transformed to int in php. - $path = (string) $path; if ($path === $current || str_starts_with($path, "{$current}.")) { unset($this->issues[$path]); } diff --git a/src/Executor/Data/Issues.php b/src/Executor/Data/Issues.php index 6b0cef0..dc11ec4 100644 --- a/src/Executor/Data/Issues.php +++ b/src/Executor/Data/Issues.php @@ -33,7 +33,7 @@ public static function fromMessages(array $issuesMap): self public function isEmpty(): bool { - return empty($this->issuesMap); + return count($this->issuesMap) === 0; } /** @return list */ diff --git a/src/Executor/Handlers/CustomClassHandler.php b/src/Executor/Handlers/CustomClassHandler.php index 3ef64b0..b4a7cd9 100644 --- a/src/Executor/Handlers/CustomClassHandler.php +++ b/src/Executor/Handlers/CustomClassHandler.php @@ -80,6 +80,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu $instance = new $node->fullyQualifiedCastingClass; foreach ($arrayValue as $key => $propertyValue) { + /** @phpstan-ignore-next-line property.dynamicName */ $instance->{$key} = $propertyValue; } return $instance; diff --git a/src/Executor/Handlers/ListHandler.php b/src/Executor/Handlers/ListHandler.php index 7be5de4..2be4038 100644 --- a/src/Executor/Handlers/ListHandler.php +++ b/src/Executor/Handlers/ListHandler.php @@ -63,7 +63,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu return Value::INVALID; } - if (empty($value)) { + if (count($value) === 0) { return []; } diff --git a/src/Executor/Handlers/StructHandler.php b/src/Executor/Handlers/StructHandler.php index a90120b..35b3ef7 100644 --- a/src/Executor/Handlers/StructHandler.php +++ b/src/Executor/Handlers/StructHandler.php @@ -173,6 +173,7 @@ private function extractKeyedValue(string $key, mixed $input): mixed } return match (true) { + /* @phpstan-ignore-next-line property.dynamicName */ property_exists($input, $key) => $input->{$key}, method_exists($input, '__get') && method_exists($input, '__isset') => $input->__isset($key) ? $input->__get($key) : Value::UNDEFINED, default => Value::INVALID, diff --git a/src/Executor/Handlers/UnionHandler.php b/src/Executor/Handlers/UnionHandler.php index 1bb0c39..3d0c803 100644 --- a/src/Executor/Handlers/UnionHandler.php +++ b/src/Executor/Handlers/UnionHandler.php @@ -130,6 +130,7 @@ private function extractKeyedValue(string $key, mixed $input): mixed return match (true) { is_array($input) => array_key_exists($key, $input) ? $input[$key] : Value::UNDEFINED, $input instanceof ArrayAccess => $input->offsetExists($key) ? $input[$key] : Value::UNDEFINED, + /* @phpstan-ignore-next-line property.dynamicName */ is_object($input) => property_exists($input, $key) ? $input->{$key} : Value::UNDEFINED, default => Value::INVALID, }; diff --git a/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php b/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php index f35b62f..3ffff1c 100644 --- a/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php +++ b/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php @@ -61,7 +61,7 @@ public function canConsume(ParserState $state): bool "non-negative-int", 'non-positive-int', 'numeric', - ]); + ], true); } /** diff --git a/src/Parser/Helpers/Consumers/InteractsWithGenerics.php b/src/Parser/Helpers/Consumers/InteractsWithGenerics.php index a8c4736..881595e 100644 --- a/src/Parser/Helpers/Consumers/InteractsWithGenerics.php +++ b/src/Parser/Helpers/Consumers/InteractsWithGenerics.php @@ -40,7 +40,7 @@ private function consumeGenerics(ParserState $state, TypeParser $parser, ?int $m $state->produceSyntaxError("Expected '>' to end generics"); } - if (empty($generics)) { + if (count($generics) === 0) { $state->produceSyntaxError("Expected at least one generic type, got none"); } diff --git a/src/Parser/Helpers/Consumers/StructConsumer.php b/src/Parser/Helpers/Consumers/StructConsumer.php index 3ec0ae9..d491e34 100644 --- a/src/Parser/Helpers/Consumers/StructConsumer.php +++ b/src/Parser/Helpers/Consumers/StructConsumer.php @@ -85,7 +85,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->produceSyntaxError("Expected brace"); } - if (empty($properties)) { + if (count($properties) === 0) { $state->produceSyntaxError("Expected properties"); } diff --git a/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php index b3d4a30..d8708a6 100644 --- a/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php @@ -115,7 +115,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface private function allowsOptional(ReflectionProperty|ReflectionParameter $param): bool { - if (empty($param->getAttributes(Optional::class))) { + if (count($param->getAttributes(Optional::class)) === 0) { return false; } diff --git a/src/Parser/Nodes/ConstraintNode.php b/src/Parser/Nodes/ConstraintNode.php index e4d9dee..79b20e5 100644 --- a/src/Parser/Nodes/ConstraintNode.php +++ b/src/Parser/Nodes/ConstraintNode.php @@ -30,7 +30,7 @@ public function areConstraintsFulfilled(mixed $value, ExecutionContext $context) #[Override] public function __toString(): string { - if (empty($this->constraints)) { + if (count($this->constraints) === 0) { return $this->node->__toString(); } @@ -47,7 +47,7 @@ public function __toString(): string #[Override] public function exportPhpCode(): string { - if (empty($this->constraints)) { + if (count($this->constraints) === 0) { return $this->node->exportPhpCode(); } diff --git a/src/Parser/Nodes/StructNode.php b/src/Parser/Nodes/StructNode.php index 4e2f9b1..be3c25e 100644 --- a/src/Parser/Nodes/StructNode.php +++ b/src/Parser/Nodes/StructNode.php @@ -67,7 +67,7 @@ private static function canonicalise(array $properties): array #[Override] public function validate(): void { - if (empty($this->properties)) { + if (count($this->properties) === 0) { throw new ParserException("Cannot create object type with no properties or properties that are not keyed by strings (e.g. ['foo' => 'bar'] is fine, but ['foo'] is not"); } } diff --git a/src/Parser/Nodes/TupleNode.php b/src/Parser/Nodes/TupleNode.php index 51c6700..fa11322 100644 --- a/src/Parser/Nodes/TupleNode.php +++ b/src/Parser/Nodes/TupleNode.php @@ -39,8 +39,5 @@ public function exportPhpCode(): string #[Override] public function validate(): void { - if (empty($this->nodes)) { - throw new ParserException("TupleNode must have at least one type"); - } } } \ No newline at end of file diff --git a/src/Reflection/FileReflector.php b/src/Reflection/FileReflector.php index 6ca2cce..b7f530d 100644 --- a/src/Reflection/FileReflector.php +++ b/src/Reflection/FileReflector.php @@ -197,7 +197,7 @@ private static function findClassNameInTokens(array $tokens): ?string continue; } - if (!in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM])) { + if (!in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true)) { continue; } diff --git a/src/Server/Operations/EagerlyLoadedOperationRegistry.php b/src/Server/Operations/EagerlyLoadedOperationRegistry.php index 2c44e3d..317fb15 100644 --- a/src/Server/Operations/EagerlyLoadedOperationRegistry.php +++ b/src/Server/Operations/EagerlyLoadedOperationRegistry.php @@ -150,7 +150,7 @@ public function get(OperationType $type, string $fullyQualifiedKey): Operation } /** - * @return Operation[] + * @return array */ #[Override] public function all(): array diff --git a/src/Server/Operations/OperationDiscovery.php b/src/Server/Operations/OperationDiscovery.php index e4aa324..ce61fd3 100644 --- a/src/Server/Operations/OperationDiscovery.php +++ b/src/Server/Operations/OperationDiscovery.php @@ -49,7 +49,7 @@ public function discover(ReflectionClass $class): void { foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { $attributes = $method->getAttributes(); - if (empty($attributes)) { + if (count($attributes) === 0) { continue; } diff --git a/src/Server/Server.php b/src/Server/Server.php index 5577018..9ba1501 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -127,6 +127,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli $serializedResult = $this->executor ->serialize( $operation->outputNode(), + /** @phpstan-ignore-next-line method.dynamicName */ $controllerClass->{$operation->definition->methodName}($inputValidationResult->value, $context, $client), new SerializationOptions(partialFailures: false), ); diff --git a/src/Utils/PHPExport.php b/src/Utils/PHPExport.php index 1447a32..5d3c353 100644 --- a/src/Utils/PHPExport.php +++ b/src/Utils/PHPExport.php @@ -57,7 +57,7 @@ public static function exportEnumCase(UnitEnum $enum): string */ public static function exportArray(array $array): string { - if (empty($array)) { + if (count($array) === 0) { return '[]'; } diff --git a/src/Utils/PhpDoc.php b/src/Utils/PhpDoc.php index edc0d4d..418cba0 100644 --- a/src/Utils/PhpDoc.php +++ b/src/Utils/PhpDoc.php @@ -28,7 +28,7 @@ private static function compileRegex(string $regex): string */ public static function findImportedTypeDefinition(null|false|string $docBlock): array { - if (empty($docBlock)) { + if ($docBlock === false || $docBlock === null) { return []; } @@ -55,7 +55,7 @@ public static function findImportedTypeDefinition(null|false|string $docBlock): */ public static function findGenerics(null|false|string $docBlock): array { - if (empty($docBlock)) { + if ($docBlock === false || $docBlock === null) { return []; } @@ -77,7 +77,7 @@ public static function findGenerics(null|false|string $docBlock): array /** @return array */ public static function findLocallyDefinedTypes(null|false|string $docBlock): array { - if (empty($docBlock)) { + if ($docBlock === false || $docBlock === null) { return []; } From 278477624acd14e1e0de4218965553d6e67efeae Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 6 Aug 2026 15:13:10 +0200 Subject: [PATCH 069/101] Improve type safety and parsing logic in `UnionHandler` and optimize `Regexes::lines` handling logic --- src/Executor/Handlers/UnionHandler.php | 6 ++++-- src/Utils/Regexes.php | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Executor/Handlers/UnionHandler.php b/src/Executor/Handlers/UnionHandler.php index 3d0c803..4f41587 100644 --- a/src/Executor/Handlers/UnionHandler.php +++ b/src/Executor/Handlers/UnionHandler.php @@ -19,10 +19,11 @@ final readonly class UnionHandler implements Handler { - /** @param UnionNode $node */ #[Override] public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): mixed { + /** @var UnionNode $node */ + // Quick check for nullability. if ($value === null && $node->acceptsNull()) { return null; @@ -68,10 +69,11 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E return Value::INVALID; } - /** @param UnionNode $node */ #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): mixed { + assert($node instanceof UnionNode); + if ($value === null && $node->acceptsNull()) { return null; } diff --git a/src/Utils/Regexes.php b/src/Utils/Regexes.php index 5d88d27..a716b91 100644 --- a/src/Utils/Regexes.php +++ b/src/Utils/Regexes.php @@ -99,9 +99,10 @@ private static function tags(string $docBlock): array private static function lines(string $docBlock): array { $withoutDelimiters = preg_replace('#^\s*/\*\*?|\*/\s*$#', '', $docBlock) ?? $docBlock; + $lines = preg_split('/\R/', $withoutDelimiters); return array_map( static fn(string $line): string => trim(preg_replace('/^\s*\*/', '', $line) ?? $line), - preg_split('/\R/', $withoutDelimiters) ?: [$withoutDelimiters], + $lines === false ? [$withoutDelimiters] : $lines, ); } From 22ceed2af12c9c0dd3521826884c4367b965b6ad Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 6 Aug 2026 15:34:08 +0200 Subject: [PATCH 070/101] Add CI workflow and apply standardized formatting across the codebase - Introduced `.github/workflows/ci.yml` with PHP 8.5 and Node.js 24 support for tests, static analysis, and TypeScript checks. - Applied consistent formatting and added missing PHP declarations across source and test files. - Refactored code for spaces after `!` and before colons in lambda functions, improving readability and compliance. - Standardized class/interface "implements" ordering for `NodeInterface`. --- .github/workflows/ci.yml | 51 +++++ composer.json | 15 +- composer.lock | 181 ++++++++++++------ pint.json | 6 + .../Laravel/Commands/ClearOptimizeCommand.php | 7 +- .../Laravel/Commands/CodeGenCommand.php | 55 +++--- src/Adapters/Laravel/Commands/ListCommand.php | 22 ++- .../Laravel/Commands/OptimizeCommand.php | 11 +- .../Laravel/Contracts/ContextFactory.php | 7 +- .../Laravel/LaravelHttpController.php | 50 ++--- .../Laravel/LaravelServiceProvider.php | 24 +-- src/Adapters/Laravel/Utils/ArtisanOptions.php | 10 +- src/Adapters/Laravel/config/config.php | 42 ++-- .../PHPStan/UtilitiesNodeResolver.php | 65 +++---- src/CodeGen/CodeGenerators.php | 21 +- .../EmitOperationClientBindings.php | 51 ++--- src/CodeGen/CodeGenerators/EmitOperations.php | 39 ++-- .../EmitOperationsSpaClient.php | 10 +- src/CodeGen/CodeGenerators/EmitQueryKey.php | 5 +- .../CodeGenerators/EmitTanstackQuery.php | 25 ++- src/CodeGen/CodeGenerators/EmitTypeMap.php | 29 +-- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 23 +-- src/CodeGen/CodeGenerators/EmitTypes.php | 18 +- src/CodeGen/Contracts/DependsOn.php | 8 +- src/CodeGen/Contracts/GeneratesLibFiles.php | 10 +- .../Contracts/GeneratesOperationCode.php | 6 +- src/CodeGen/Data/ServerMetadata.php | 13 +- src/CodeGen/Data/TypedOperation.php | 10 +- src/CodeGen/Exceptions/CodeGenException.php | 4 +- .../InvalidGeneratorDependencies.php | 13 +- src/CodeGen/TypescriptServerCodeGenerator.php | 50 ++--- src/CodeGen/Utils/ErrorTypescript.php | 6 +- src/CodeGen/Utils/OutputDirectory.php | 32 ++-- src/CodeGen/Utils/Paths.php | 4 +- src/Contracts/Attributes/Brand.php | 11 +- src/Contracts/Attributes/Castable.php | 9 +- src/Contracts/Attributes/Command.php | 11 +- src/Contracts/Attributes/ExposeAs.php | 9 +- src/Contracts/Attributes/Middleware.php | 9 +- src/Contracts/Attributes/Named.php | 11 +- src/Contracts/Attributes/Optional.php | 8 +- src/Contracts/Attributes/Query.php | 11 +- src/Contracts/Attributes/Throws.php | 13 +- src/Contracts/Client.php | 13 +- src/Contracts/ExportableToPhpCode.php | 6 +- src/Contracts/MiddlewareContract.php | 17 +- src/Contracts/OperationKeyGenerator.php | 7 +- src/Contracts/OperationRegistry.php | 16 +- src/Contracts/PhpTsBindingsException.php | 4 +- src/Contracts/RpcResult.php | 12 +- src/Contracts/SerializableClient.php | 4 +- src/Contracts/ServerAdapter.php | 12 +- src/Contracts/ValueObjects/IntValueObject.php | 4 +- .../ValueObjects/StringValueObject.php | 4 +- src/Data/IO.php | 4 +- src/Data/Value.php | 4 +- src/Executor/Contracts/ExecutionContext.php | 6 +- src/Executor/Contracts/Executor.php | 7 +- src/Executor/Contracts/Handler.php | 6 +- src/Executor/Data/Context.php | 12 +- src/Executor/Data/Failure.php | 7 +- src/Executor/Data/Issue.php | 30 ++- src/Executor/Data/IssueMessage.php | 4 +- src/Executor/Data/Issues.php | 24 +-- src/Executor/Data/ParsingOptions.php | 9 +- src/Executor/Data/SerializationOptions.php | 21 +- src/Executor/Data/Success.php | 11 +- src/Executor/Exceptions/SchemaException.php | 4 +- .../Exceptions/ValidationException.php | 23 +-- src/Executor/Handlers/CustomClassHandler.php | 25 +-- src/Executor/Handlers/IntersectionHandler.php | 13 +- src/Executor/Handlers/ListHandler.php | 16 +- src/Executor/Handlers/RecordHandler.php | 23 ++- src/Executor/Handlers/StructHandler.php | 21 +- src/Executor/Handlers/TupleHandler.php | 24 ++- src/Executor/Handlers/UnionHandler.php | 20 +- src/Executor/SchemaExecutor.php | 13 +- src/Parser/Contracts/Coercible.php | 6 +- src/Parser/Contracts/Constraint.php | 4 +- src/Parser/Contracts/LeafNode.php | 14 +- src/Parser/Contracts/NodeInterface.php | 4 +- src/Parser/Contracts/TypeConsumer.php | 7 +- src/Parser/Contracts/TypeRegistry.php | 6 +- src/Parser/Contracts/ValidatableNode.php | 6 +- src/Parser/Contracts/WrapsNode.php | 6 +- src/Parser/Contracts/WrapsNodes.php | 6 +- .../Exceptions/InvalidSyntaxException.php | 7 +- .../Data/Exceptions/ParserException.php | 4 +- .../Exceptions/UnknownTypeKeyException.php | 10 +- src/Parser/Data/GlobalTypeAliases.php | 9 +- src/Parser/Helpers/ASTOptimizer.php | 40 ++-- src/Parser/Helpers/AstValidator.php | 10 +- src/Parser/Helpers/Constraints/IntRange.php | 17 +- src/Parser/Helpers/Constraints/ListLength.php | 17 +- .../Helpers/Constraints/LowercaseString.php | 11 +- .../Helpers/Constraints/NonEmptyString.php | 11 +- .../Helpers/Constraints/NonFalsyString.php | 13 +- .../Helpers/Constraints/NumericString.php | 13 +- .../Helpers/Constraints/UppercaseString.php | 11 +- .../Helpers/Constraints/ValidatesString.php | 7 +- .../Helpers/Consumers/AliasConsumer.php | 16 +- .../Helpers/Consumers/ArrayConsumer.php | 46 ++--- .../Helpers/Consumers/BuiltInLeafConsumer.php | 13 +- .../Helpers/Consumers/ClassConstConsumer.php | 13 +- .../Helpers/Consumers/DateTimeConsumer.php | 6 +- src/Parser/Helpers/Consumers/EnumConsumer.php | 6 +- src/Parser/Helpers/Consumers/IntConsumer.php | 16 +- .../Consumers/InteractsWithGenerics.php | 22 ++- .../Helpers/Consumers/LiteralConsumer.php | 6 +- .../Helpers/Consumers/StructConsumer.php | 31 +-- .../Consumers/UserDefinedObjectConsumer.php | 31 +-- .../Helpers/Consumers/UtilsConsumer.php | 46 ++--- .../Helpers/Consumers/ValueObjectConsumer.php | 8 +- src/Parser/Helpers/ParserState.php | 20 +- src/Parser/Helpers/ParsingScope.php | 55 +++--- .../Helpers/Registry/CachedTypeRegistry.php | 15 +- .../UnexpectedCharacterException.php | 11 +- src/Parser/Lexer/Lexer.php | 10 +- src/Parser/Lexer/SourceLocation.php | 9 +- src/Parser/Lexer/Token.php | 11 +- src/Parser/Lexer/TokenType.php | 4 +- src/Parser/Nodes/ConstraintNode.php | 19 +- src/Parser/Nodes/CustomCastingNode.php | 16 +- src/Parser/Nodes/Data/BackingType.php | 4 +- src/Parser/Nodes/Data/LiteralType.php | 5 +- src/Parser/Nodes/Data/NamedType.php | 7 +- src/Parser/Nodes/Data/ObjectCastStrategy.php | 6 +- src/Parser/Nodes/Data/PropertyType.php | 9 +- src/Parser/Nodes/Data/StructPhpType.php | 8 +- src/Parser/Nodes/IntersectionNode.php | 18 +- src/Parser/Nodes/Leaf/BoolNode.php | 8 +- src/Parser/Nodes/Leaf/DateTimeNode.php | 33 ++-- src/Parser/Nodes/Leaf/EnumNode.php | 27 +-- src/Parser/Nodes/Leaf/FloatNode.php | 8 +- src/Parser/Nodes/Leaf/IntNode.php | 8 +- src/Parser/Nodes/Leaf/LiteralNode.php | 42 ++-- src/Parser/Nodes/Leaf/MixedNode.php | 8 +- src/Parser/Nodes/Leaf/NullNode.php | 8 +- src/Parser/Nodes/Leaf/RejectsInvalidType.php | 5 +- src/Parser/Nodes/Leaf/StringNode.php | 13 +- src/Parser/Nodes/Leaf/ValueObjectNode.php | 40 ++-- src/Parser/Nodes/ListNode.php | 10 +- src/Parser/Nodes/MetadataNode.php | 31 +-- src/Parser/Nodes/PropertyNode.php | 18 +- src/Parser/Nodes/RecordNode.php | 13 +- src/Parser/Nodes/ReferencedNode.php | 12 +- src/Parser/Nodes/StructNode.php | 32 ++-- src/Parser/Nodes/TupleNode.php | 17 +- src/Parser/Nodes/UnionNode.php | 29 +-- src/Parser/TypeParser.php | 57 +++--- src/Parser/Utils/Lexemes.php | 41 ++-- src/Reflection/AttributesReflector.php | 19 +- src/Reflection/FileReflector.php | 39 ++-- src/Reflection/MetadataAttributes.php | 38 ++-- src/Reflection/TypeReflector.php | 32 ++-- src/Server/Adapters/NewInstanceAdapter.php | 7 +- src/Server/Adapters/PsrContainerAdapter.php | 9 +- src/Server/Client/InteractsWithToasts.php | 4 +- src/Server/Client/NullClient.php | 9 +- src/Server/Client/OperationSPAClient.php | 17 +- src/Server/Data/Definition.php | 27 ++- src/Server/Data/ErrorType.php | 6 +- .../Data/Exceptions/InvalidInputException.php | 6 +- .../Exceptions/InvalidMiddlewareException.php | 5 +- .../Exceptions/InvalidOutputException.php | 6 +- .../Exceptions/OperationNotFoundException.php | 7 +- src/Server/Data/Operation.php | 19 +- src/Server/Data/OperationType.php | 4 +- src/Server/Data/ResolveInfo.php | 17 +- src/Server/Data/RpcError.php | 45 +++-- src/Server/Data/RpcSuccess.php | 30 +-- src/Server/Data/ServerConfiguration.php | 31 ++- src/Server/Data/Toast.php | 9 +- src/Server/Data/ToastType.php | 4 +- src/Server/Errors/ErrorPresenter.php | 21 +- src/Server/Errors/ExposedExceptions.php | 17 +- .../KeyGenerators/HashSha256KeyGenerator.php | 13 +- .../PlainlyExposedKeyGenerator.php | 7 +- .../Operations/CachedOperationRegistry.php | 20 +- .../EagerlyLoadedOperationRegistry.php | 57 +++--- src/Server/Operations/OperationDiscovery.php | 41 ++-- src/Server/Pipeline/ContextualPipeline.php | 24 +-- src/Server/Preloader.php | 22 +-- src/Server/Server.php | 27 ++- src/Typescript/Code/TypescriptFile.php | 29 +-- src/Typescript/Code/TypescriptImport.php | 30 +-- src/Typescript/Data/EmissionContext.php | 9 +- src/Typescript/Data/Typescript.php | 19 +- .../InvalidStringLiteralException.php | 4 +- .../Exceptions/UnknownAliasException.php | 6 +- .../Exceptions/UnsupportedTypeException.php | 12 +- src/Typescript/Helpers/AliasRegistry.php | 8 +- src/Typescript/TypescriptGenerator.php | 32 ++-- src/Typescript/Utils/Syntax.php | 8 +- src/Utils/Arrays.php | 11 +- src/Utils/Assertions.php | 17 +- src/Utils/Dicts.php | 11 +- src/Utils/Hashs.php | 12 +- src/Utils/Lists.php | 17 +- src/Utils/Namespaces.php | 20 +- src/Utils/Nodes.php | 16 +- src/Utils/PHPExport.php | 22 ++- src/Utils/PhpDoc.php | 18 +- src/Utils/Reflections.php | 43 +++-- src/Utils/Regexes.php | 23 ++- src/Utils/Strings.php | 9 +- .../Laravel/CodeGenCommandNamingTest.php | 15 +- .../Laravel/LaravelHttpControllerTest.php | 45 +++-- tests/Executor/SchemaExecutorTest.php | 1 - tests/Feature/FullSchemaTest.php | 60 +++--- tests/Feature/Mocks/CreateObjectInput.php | 12 +- tests/Feature/Mocks/CreateUserInput.php | 4 +- .../Mocks/CreateUserWithOptionalEmail.php | 12 +- .../Mocks/GlobalMiddlewareException.php | 4 +- .../Mocks/GloballyThrowingMiddleware.php | 4 +- tests/Feature/Mocks/NotAMiddleware.php | 4 +- tests/Feature/Mocks/Paginated.php | 12 +- tests/Feature/Mocks/SortByInput.php | 19 +- .../Operations/InvalidNameException.php | 4 +- .../Operations/NameCheckingMiddleware.php | 4 +- tests/Feature/Operations/PoolingTestClass.php | 16 +- tests/Feature/Operations/TestClass.php | 19 +- tests/Feature/ServerTest.php | 35 ++-- tests/Mocks/Errors/ErrorOperations.php | 4 +- tests/Mocks/Errors/ExposedDomainException.php | 4 +- .../Errors/MiddlewareDomainException.php | 4 +- tests/Mocks/Errors/RecordMissingException.php | 4 +- tests/Mocks/Errors/RenamingMiddleware.php | 4 +- tests/Mocks/Errors/ThrowingMiddleware.php | 4 +- .../Errors/UndeclaredExposedException.php | 4 +- tests/Mocks/Errors/UnexposedException.php | 4 +- tests/Mocks/Errors/UserMissingException.php | 4 +- tests/Mocks/Image.php | 6 +- tests/Mocks/InvalidationNamespace.php | 4 +- tests/Mocks/Named/AliasNaming.php | 5 +- tests/Mocks/Named/ArticleResource.php | 7 +- tests/Mocks/Named/AsymmetricNamed.php | 4 +- tests/Mocks/Named/BrandedPayload.php | 4 +- tests/Mocks/Named/Conflict/Customer.php | 4 +- tests/Mocks/Named/Customer.php | 5 +- tests/Mocks/Named/InvalidlyBranded.php | 4 +- tests/Mocks/Named/InvalidlyNamed.php | 4 +- tests/Mocks/Named/NamedValueObject.php | 4 +- tests/Mocks/Named/Order.php | 5 +- tests/Mocks/Named/OrderStatus.php | 4 +- tests/Mocks/Named/PerDirectionNamed.php | 4 +- tests/Mocks/Named/PublicResource.php | 4 +- tests/Mocks/Named/RenamedThing.php | 4 +- tests/Mocks/ResultEnum.php | 4 +- .../ValueObjects/AbstractValueObject.php | 4 +- .../ValueObjects/AmbiguousValueObject.php | 10 +- .../Mocks/ValueObjects/CreateAccountInput.php | 5 +- tests/Mocks/ValueObjects/Email.php | 6 +- .../EmptyValidationValueObject.php | 4 +- .../ValueObjects/ExplodingValueObject.php | 4 +- .../Inherited/AbstractBrandedId.php | 4 +- .../ValueObjects/Inherited/AccountId.php | 4 +- .../ValueObjects/Inherited/AlsoBranded.php | 4 +- .../ValueObjects/Inherited/AmbiguousId.php | 6 +- .../ValueObjects/Inherited/BadClosureId.php | 4 +- tests/Mocks/ValueObjects/Inherited/BaseId.php | 4 +- .../Mocks/ValueObjects/Inherited/BrandId.php | 4 +- .../Mocks/ValueObjects/Inherited/ChildId.php | 4 +- .../ValueObjects/Inherited/ComputedId.php | 4 +- .../Inherited/ComputedLocally.php | 4 +- tests/Mocks/ValueObjects/Inherited/DeepId.php | 4 +- .../ValueObjects/Inherited/DeepIntId.php | 4 +- .../Inherited/DisambiguatedId.php | 6 +- .../ValueObjects/Inherited/GrandChildId.php | 4 +- tests/Mocks/ValueObjects/Inherited/IntId.php | 4 +- .../ValueObjects/Inherited/InvoiceId.php | 4 +- .../Mocks/ValueObjects/Inherited/LegacyId.php | 4 +- .../Inherited/LocallyOverriddenId.php | 4 +- tests/Mocks/ValueObjects/Inherited/Naming.php | 16 +- .../ValueObjects/Inherited/ParentWinsBase.php | 4 +- .../Inherited/ParentWinsContract.php | 4 +- .../ValueObjects/Inherited/ParentWinsId.php | 4 +- .../Inherited/PartiallyOverriddenId.php | 4 +- .../ValueObjects/Inherited/PlainContract.php | 4 +- .../Mocks/ValueObjects/Inherited/PlainId.php | 4 +- .../ValueObjects/Inherited/ReceiptId.php | 4 +- .../Inherited/SharedExplicitBrand.php | 4 +- .../Inherited/SharedExplicitBrandId.php | 4 +- tests/Mocks/ValueObjects/Slug.php | 6 +- tests/Mocks/ValueObjects/StatusEnum.php | 4 +- tests/Mocks/ValueObjects/UserId.php | 4 +- tests/Mocks/ValueObjects/ValidatedAge.php | 4 +- tests/Mocks/ValueObjects/ValidatedEmail.php | 6 +- tests/Pest.php | 47 +++-- .../Adapters/Laravel/ArtisanOptionsTest.php | 4 +- tests/Unit/CodeGen/CodeGeneratorsTest.php | 12 +- .../EmitOperationClientBindingsTest.php | 8 +- .../CodeGen/EmitOperationsSpaClientTest.php | 6 +- tests/Unit/CodeGen/EmitQueryKeyTest.php | 9 +- tests/Unit/CodeGen/EmitTanstackQueryTest.php | 9 +- tests/Unit/CodeGen/EmitTypeUtilsTest.php | 4 +- tests/Unit/CodeGen/EmitTypesTest.php | 24 +-- tests/Unit/CodeGen/ErrorTypescriptTest.php | 6 +- .../Mocks/AsymmetricNamedOperations.php | 7 +- .../CodeGen/Mocks/NameClashOperations.php | 8 +- tests/Unit/CodeGen/Mocks/NamedOperations.php | 9 +- .../Mocks/PerDirectionNamedOperations.php | 12 +- .../Mocks/TsOutput/AccountOperations.php | 8 +- .../Mocks/TsOutput/CatalogOperations.php | 10 +- .../Mocks/TsOutput/ShapeOperations.php | 8 +- .../Mocks/TsOutput/Types/AccountFilter.php | 4 +- .../TsOutput/Types/AccountLockedException.php | 4 +- .../Mocks/TsOutput/Types/AliasNaming.php | 5 +- .../Mocks/TsOutput/Types/Availability.php | 4 +- .../CodeGen/Mocks/TsOutput/Types/Draft.php | 4 +- .../CodeGen/Mocks/TsOutput/Types/Money.php | 4 +- .../CodeGen/Mocks/TsOutput/Types/Product.php | 8 +- .../Mocks/TsOutput/Types/ProductId.php | 4 +- .../TsOutput/Types/ProvisioningException.php | 4 +- .../TsOutput/Types/QuotaExceededException.php | 4 +- .../Unit/CodeGen/Mocks/TsOutput/Types/Sku.php | 4 +- .../Mocks/UnrepresentableOperations.php | 5 +- tests/Unit/CodeGen/Mocks/UserOperations.php | 8 +- tests/Unit/CodeGen/OutputDirectoryTest.php | 12 +- tests/Unit/CodeGen/PathsTest.php | 4 +- tests/Unit/CodeGen/TsOutputFixture.php | 6 +- tests/Unit/CodeGen/TsOutputFixtureTest.php | 6 +- .../TypescriptServerCodeGeneratorTest.php | 32 ++-- .../Attributes/NamespaceAsStringTest.php | 4 +- .../Unit/Contracts/ExceptionHierarchyTest.php | 4 +- tests/Unit/Executor/ContextPathTest.php | 5 +- .../Exceptions/ValidationExceptionTest.php | 8 +- tests/Unit/Executor/Mocks/UserSchema.php | 13 +- tests/Unit/Executor/ResultTest.php | 8 +- tests/Unit/Executor/SchemaExecutorTest.php | 141 +++++++------- tests/Unit/Executor/StrictnessTest.php | 10 +- .../Executor/UnionAndEnumDispatchTest.php | 15 +- tests/Unit/Parser/ASTOptimizerTest.php | 22 ++- .../Unit/Parser/Constraints/IntRangeTest.php | 14 +- .../Parser/Constraints/ListLengthTest.php | 8 +- .../Constraints/PhpstanRefinementsTest.php | 5 +- .../Constraints/StringConstraintsTest.php | 6 +- tests/Unit/Parser/Data/ParsingContextTest.php | 6 +- tests/Unit/Parser/Data/Stubs/AccountData.php | 9 +- .../Parser/Data/Stubs/AccountWithImage.php | 9 +- tests/Unit/Parser/Data/Stubs/Address.php | 7 +- .../Unit/Parser/Data/Stubs/ComplexPhpDoc.php | 12 +- tests/Unit/Parser/Data/Stubs/FullAccount.php | 9 +- tests/Unit/Parser/Data/Stubs/MyUserClass.php | 8 +- .../Data/Stubs/ReadonlyOutputFields.php | 7 +- .../Parser/Data/Stubs/SomeAbstractClass.php | 7 +- .../Parser/Data/Stubs/SomeFileInterface.php | 6 +- .../Parser/Data/Stubs/UncastableClass.php | 11 +- tests/Unit/Parser/Data/UserMock.php | 9 +- tests/Unit/Parser/Lexer/LexerTest.php | 23 +-- tests/Unit/Parser/MetadataEliminationTest.php | 32 ++-- tests/Unit/Parser/NamedTypeTest.php | 28 +-- .../Unit/Parser/NodeDiagnosticStringTest.php | 28 +-- .../Parser/OptimizeAndWriteToFileTest.php | 12 +- tests/Unit/Parser/OptimizedCodeShapeTest.php | 11 +- tests/Unit/Parser/StructNodeOrderTest.php | 19 +- tests/Unit/Parser/TypeParserTest.php | 136 +++++++------ tests/Unit/Parser/ValueObjectConsumerTest.php | 22 ++- tests/Unit/PhpStan/Mocks/MyTestClass.php | 11 +- tests/Unit/PhpStan/UtilitiesResolverTest.php | 17 +- tests/Unit/PhpStan/data/types.php | 128 ++++++++----- tests/Unit/Reflection/FileReflectorTest.php | 8 +- .../ClassConstantBeforeDeclaration.php | 6 +- tests/Unit/Reflection/Mocks/UserClassMock.php | 11 +- tests/Unit/Reflection/TypeReflectionTest.php | 2 +- tests/Unit/Server/Client/NullClientTest.php | 4 +- .../Server/Client/OperationSPAClientTest.php | 4 +- tests/Unit/Server/Data/RpcErrorTest.php | 4 +- tests/Unit/Server/Data/RpcSuccessTest.php | 4 +- .../Unit/Server/Errors/ErrorPresenterTest.php | 6 +- tests/Unit/Server/KeyGeneratorTest.php | 4 +- tests/Unit/Server/OperationDiscoveryTest.php | 27 +-- .../Pipeline/ContextualPipelineTest.php | 47 +++-- tests/Unit/Typescript/AliasRegistryTest.php | 14 +- .../Typescript/Code/TypescriptFileTest.php | 48 ++--- .../Typescript/Code/TypescriptImportTest.php | 16 +- tests/Unit/Typescript/NamedTypesTest.php | 19 +- tests/Unit/Typescript/OptimizedAstTest.php | 27 +-- tests/Unit/Typescript/Stubs/EmptyEnum.php | 4 +- .../Typescript/TypescriptGeneratorTest.php | 49 ++--- tests/Unit/Typescript/Utils/SyntaxTest.php | 6 +- .../Unit/Utils/Mocks/ReflectionsUtilMock.php | 16 +- tests/Unit/Utils/NamespacesTest.php | 1 - tests/Unit/Utils/NodesTest.php | 2 +- tests/Unit/Utils/PHPExportTest.php | 24 +-- tests/Unit/Utils/PhpDocTest.php | 26 ++- tests/Unit/Utils/ReflectionsTest.php | 2 +- tests/Unit/Utils/RegexesTest.php | 4 +- tests/ts-output/generate.php | 6 +- 389 files changed, 3345 insertions(+), 2398 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pint.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b61c340 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: + - main + - 'feature/**' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Tests, static analysis, style & TypeScript + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Setup PHP 8.5 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + # No coverage is configured in phpunit.xml, so skip Xdebug/PCOV entirely. + coverage: none + + # Needs no vendor tree, so it runs before install and fails fast on a lock desync. + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Install PHP dependencies + uses: ramsey/composer-install@v3 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: tests/ts-output/package-lock.json + + - name: Pest, PHPStan and Pint + run: composer run check:all + + # The Pest suite proves the generators emit exactly the committed fixture; this proves that + # fixture still compiles. + - name: Typecheck the generated TypeScript + run: composer run check:ts diff --git a/composer.json b/composer.json index 9c387dc..89636d6 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "laravel/framework": "^13", "mockery/mockery": "^1.6", "phpstan/phpstan-strict-rules": "^2.0", - "psr/container": "^2.0" + "psr/container": "^2.0", + "laravel/pint": "^1.30" }, "suggest": { "laravel/framework": "Enables the first-party Laravel adapter: config, routes, and the operations:* artisan commands.", @@ -52,9 +53,16 @@ "scripts": { "test": "pest", "check:types": "phpstan --memory-limit=1G analyse", + "check:style": "pint --test", + "fix:style": "pint", "check:all": [ "@test", - "@check:types" + "@check:types", + "@check:style" + ], + "check:ts": [ + "npm ci --prefix tests/ts-output --no-audit --no-fund", + "npm --prefix tests/ts-output run typecheck" ], "codegen:fixture": [ "@php tests/ts-output/generate.php", @@ -63,6 +71,9 @@ ] }, "scripts-descriptions": { + "check:style": "Check formatting with Pint (psr12) without writing. Run fix:style to apply.", + "fix:style": "Apply Pint formatting in place.", + "check:ts": "Typecheck the committed generated TypeScript with tsc --noEmit. Needs node; check:all does not.", "codegen:fixture": "Regenerate tests/ts-output/generated and typecheck it with tsc --noEmit. Run after changing a code generator; commit the result." }, "extra": { diff --git a/composer.lock b/composer.lock index 0b46176..b7d35ae 100644 --- a/composer.lock +++ b/composer.lock @@ -4,62 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1d6053f8fa814940dfa6004392072e9c", - "packages": [ - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - } - ], + "content-hash": "81eb9ae809db3650d70ca1671112c396", + "packages": [], "packages-dev": [ { "name": "brianium/paratest", @@ -1865,6 +1811,76 @@ }, "time": "2026-07-27T14:48:58+00:00" }, + { + "name": "laravel/pint", + "version": "v1.30.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/a96cb6eee2961905d2fce7207aefb80945bf6b28", + "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.18", + "illuminate/view": "^12.65.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.22", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.7" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-08-05T16:47:22+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.21", @@ -4699,6 +4715,59 @@ }, "time": "2022-11-25T14:36:26+00:00" }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, { "name": "psr/event-dispatcher", "version": "1.0.0", diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..3018cb0 --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "psr12", + "rules": { + "no_superfluous_phpdoc_tags": false + } +} diff --git a/src/Adapters/Laravel/Commands/ClearOptimizeCommand.php b/src/Adapters/Laravel/Commands/ClearOptimizeCommand.php index 1bd8138..25e204e 100644 --- a/src/Adapters/Laravel/Commands/ClearOptimizeCommand.php +++ b/src/Adapters/Laravel/Commands/ClearOptimizeCommand.php @@ -1,4 +1,6 @@ -} ' - . '{--without=* : tanstack-query | type-map} ' - . '{--ignore=* : Ignored namespaces (namespace) or specific operations by specifying namespace.name} ' - . '{--naming=name : Naming mode to use. Modes: name, fqn, operation-prefix, namespace-postfix or classname::methodName for custom function}' - . '{--verify} '; + .'{--with=* : tanstack-query | type-map} ' + .'{--custom=* : class-string} ' + .'{--without=* : tanstack-query | type-map} ' + .'{--ignore=* : Ignored namespaces (namespace) or specific operations by specifying namespace.name} ' + .'{--naming=name : Naming mode to use. Modes: name, fqn, operation-prefix, namespace-postfix or classname::methodName for custom function}' + .'{--verify} '; protected $description = 'Generate the typescript bindings for all operations'; @@ -69,10 +71,9 @@ final class CodeGenCommand extends Command * @throws BindingResolutionException */ public function handle( - Router $router, - Application $application, - ): int - { + Router $router, + Application $application, + ): int { // Always get a fresh server $server = LaravelServiceProvider::serverFactory( $application, @@ -84,8 +85,9 @@ public function handle( if ($queryRoute === null || $commandRoute === null) { $this->error( 'The operation routes are not registered. Call LaravelHttpController::registerQueries() ' - . 'and ::registerCommands() from your route definitions.' + .'and ::registerCommands() from your route definitions.' ); + return 1; } @@ -106,22 +108,26 @@ public function handle( foreach ($exception->messages as $message) { $this->error($message); } + return 1; } catch (UnsupportedTypeException $exception) { // A schema that cannot be expressed in TypeScript is a bug worth surfacing here, rather // than a placeholder type that fails later inside the generated client. $this->error($exception->getMessage()); + return 1; } catch (CodeGenException $exception) { // A bad naming mode, a namespace that cannot be a file name, two operations generating // one name: all of them end the run with a message rather than a stack trace. $this->error($exception->getMessage()); + return 1; } $target = ArtisanOptions::asString($this->argument('directory')) ?? ''; if ($target === '') { $this->error('A target directory is required.'); + return 1; } @@ -131,7 +137,8 @@ public function handle( $directory = str_starts_with($target, '/') ? $target : base_path($target); if ($this->option('verify')) { - $this->info("Verify generated code only."); + $this->info('Verify generated code only.'); + return $this->verifyContentOnly($directory, $files); } @@ -140,6 +147,7 @@ public function handle( } catch (CodeGenException $exception) { // Refusing to overwrite a file this library did not write. $this->error($exception->getMessage()); + return 1; } @@ -147,9 +155,7 @@ public function handle( } /** - * @param string $directory - * @param array $files - * @return int + * @param array $files */ private function verifyContentOnly(string $directory, array $files): int { @@ -162,15 +168,18 @@ private function verifyContentOnly(string $directory, array $files): int foreach ($issues as $issue) { $this->info($issue); } + return 1; } - $this->line("All files are correct. No issues found."); + $this->line('All files are correct. No issues found.'); + return 0; } /** * @return list + * * @throws BindingResolutionException */ private function getGeneratorsFromInput(Application $application): array @@ -180,7 +189,7 @@ private function getGeneratorsFromInput(Application $application): array $namingGeneratorName = ($this->option('naming') ?? 'name') |> Assertions::string(...); - $namingGenerator = match($namingGeneratorName) { + $namingGenerator = match ($namingGeneratorName) { 'fqn','operation-prefix','namespace-postfix','name' => CodeGenerators::namingGenerator($namingGeneratorName), default => $this->customNamingGenerator($application, $namingGeneratorName), }; @@ -192,7 +201,7 @@ private function getGeneratorsFromInput(Application $application): array ); $customGenerators = array_map( - fn(string $className) => $application->make($className), + fn (string $className) => $application->make($className), ArtisanOptions::expandOptionsArrayCommaSeparated($this->option('custom')) ); @@ -209,6 +218,7 @@ private function getGeneratorsFromInput(Application $application): array * static-looking syntax, so a rule is free to depend on whatever the container can build. * * @return Closure(TypedOperation): string + * * @throws BindingResolutionException */ private function customNamingGenerator(Application $application, string $naming): Closure @@ -217,7 +227,8 @@ private function customNamingGenerator(Application $application, string $naming) if (count($parts) === 2 && class_exists($parts[0]) && method_exists($parts[0], $parts[1])) { $instance = $application->make($parts[0]); - /* @phpstan-ignore-next-line method.dynamicName */ + + /* @phpstan-ignore-next-line method.dynamicName */ return $instance->{$parts[1]}(...); } @@ -225,7 +236,7 @@ private function customNamingGenerator(Application $application, string $naming) // handle()'s try, so a typo ends the run with this message instead of a stack trace. throw new CodeGenException( "Unknown naming mode '{$naming}'. Use one of name, fqn, operation-prefix, " - . "namespace-postfix, or Class::method naming your own rule." + .'namespace-postfix, or Class::method naming your own rule.' ); } -} \ No newline at end of file +} diff --git a/src/Adapters/Laravel/Commands/ListCommand.php b/src/Adapters/Laravel/Commands/ListCommand.php index 2cb85d2..28fedd8 100644 --- a/src/Adapters/Laravel/Commands/ListCommand.php +++ b/src/Adapters/Laravel/Commands/ListCommand.php @@ -1,4 +1,6 @@ -getRoutes()->getByName(LaravelHttpController::QUERY_NAME); $commandRoute = $router->getRoutes()->getByName(LaravelHttpController::COMMAND_NAME); // Both are dereferenced below, so both must exist. This used to be `&&`, which only tripped // when neither route was registered and left a null dereference when exactly one was. - if (!$commandRoute || !$queryRoute) { + if (! $commandRoute || ! $queryRoute) { throw new SchemaException('Cannot list routes that are not registered'); } $this->table([ - 'PLAIN NAME','URI', 'METHOD', "TARGET", "LARAVEL MIDDLEWARE", "MIDDLEWARE", - ], array_map(fn(Operation $operation) => match ($operation->definition->type) { + 'PLAIN NAME', 'URI', 'METHOD', 'TARGET', 'LARAVEL MIDDLEWARE', 'MIDDLEWARE', + ], array_map(fn (Operation $operation) => match ($operation->definition->type) { OperationType::QUERY => [ $operation->definition->fullyQualifiedName(), $this->bindUri($queryRoute->uri(), $operation), implode(', ', $queryRoute->methods()), - $operation->definition->fullyQualifiedClassName . '@' . $operation->definition->methodName, + $operation->definition->fullyQualifiedClassName.'@'.$operation->definition->methodName, implode(', ', $queryRoute->gatherMiddleware()), implode(', ', $operation->definition->middleware), ], @@ -46,7 +48,7 @@ public function handle( $operation->definition->fullyQualifiedName(), $this->bindUri($commandRoute->uri(), $operation), implode(', ', $commandRoute->methods()), - $operation->definition->fullyQualifiedClassName . '@' . $operation->definition->methodName, + $operation->definition->fullyQualifiedClassName.'@'.$operation->definition->methodName, implode(', ', $commandRoute->gatherMiddleware()), implode(', ', $operation->definition->middleware), ], @@ -59,4 +61,4 @@ private function bindUri(string $uri, Operation $operation): string { return str_replace('{fqn}', $operation->key, $uri); } -} \ No newline at end of file +} diff --git a/src/Adapters/Laravel/Commands/OptimizeCommand.php b/src/Adapters/Laravel/Commands/OptimizeCommand.php index f3dac50..a398805 100644 --- a/src/Adapters/Laravel/Commands/OptimizeCommand.php +++ b/src/Adapters/Laravel/Commands/OptimizeCommand.php @@ -1,4 +1,6 @@ -registry; - if (!$registry instanceof EagerlyLoadedOperationRegistry) { + if (! $registry instanceof EagerlyLoadedOperationRegistry) { throw new SchemaException('Cannot optimize a registry that is not an EagerlyLoadedOperationRegistry'); } @@ -38,6 +41,7 @@ public function handle(Application $application): int if ($idLength === null) { $this->error('The id-length must be a positive integer. Pass --id-length or set operations.cache.idLength.'); + return 1; } @@ -56,9 +60,10 @@ public function handle(Application $application): int if (file_exists($cacheFile)) { unlink($cacheFile); } + return 1; } return 0; } -} \ No newline at end of file +} diff --git a/src/Adapters/Laravel/Contracts/ContextFactory.php b/src/Adapters/Laravel/Contracts/ContextFactory.php index 3e02a7b..3fb6d40 100644 --- a/src/Adapters/Laravel/Contracts/ContextFactory.php +++ b/src/Adapters/Laravel/Contracts/ContextFactory.php @@ -1,10 +1,11 @@ -server->query( - $fqn, - input: $this->gatherInputFromRequest(OperationType::QUERY, $request), - context: $this->contextFactory?->createContextFromHttpRequest($request), - client: $this->createClient($request), - ) + $fqn, + input: $this->gatherInputFromRequest(OperationType::QUERY, $request), + context: $this->contextFactory?->createContextFromHttpRequest($request), + client: $this->createClient($request), + ) |> $this->reportExceptions(...) |> $this->produceJsonResponse(...); } @@ -67,11 +70,11 @@ public function handleHttpQueryRequest(string $fqn, Http\Request $request): Json public function handleHttpCommandRequest(string $fqn, Http\Request $request): JsonResponse { return $this->server->command( - $fqn, - input: $this->gatherInputFromRequest(OperationType::COMMAND, $request), - context:$this->contextFactory?->createContextFromHttpRequest($request), - client: $this->createClient($request), - ) + $fqn, + input: $this->gatherInputFromRequest(OperationType::COMMAND, $request), + context: $this->contextFactory?->createContextFromHttpRequest($request), + client: $this->createClient($request), + ) |> $this->reportExceptions(...) |> $this->produceJsonResponse(...); } @@ -86,6 +89,7 @@ private function reportExceptions(RpcResult $result): RpcResult $this->exceptionHandler->report($throwable); } } + return $result; } @@ -109,7 +113,7 @@ private function gatherInputFromRequest(OperationType $type, Http\Request $reque // guarantee that every Throwable comes back as an RpcError. Anything that is not a // string is passed through untouched for the schema to reject properly. OperationType::QUERY => array_map(static function (mixed $value): mixed { - if (!is_string($value)) { + if (! is_string($value)) { return $value; } @@ -149,17 +153,17 @@ private static function describeThrowable(Throwable $throwable): array private function produceJsonResponse(RpcResult $result): JsonResponse { $jsonResponse = $result->jsonSerialize(); - if (!$this->debug) { + if (! $this->debug) { return new JsonResponse($jsonResponse, status: $result->statusCode); } // We append some general debug information if ($result->resolveInfo) { $jsonResponse['__resolveInfo'] = [ - "handler" => "{$result->resolveInfo->className}@{$result->resolveInfo->methodName}", - "middleware" => $result->resolveInfo->middleware, - "fqn" => $result->resolveInfo->fullyQualifiedName, - "type" => $result->resolveInfo->operationType->name, + 'handler' => "{$result->resolveInfo->className}@{$result->resolveInfo->methodName}", + 'middleware' => $result->resolveInfo->middleware, + 'fqn' => $result->resolveInfo->fullyQualifiedName, + 'type' => $result->resolveInfo->operationType->name, ]; } @@ -180,4 +184,4 @@ private function produceJsonResponse(RpcResult $result): JsonResponse status: $result->statusCode ); } -} \ No newline at end of file +} diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 45f879a..d482a36 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -1,4 +1,6 @@ -make('config'); $operations ??= EagerlyLoadedOperationRegistry::eagerlyDiscover( @@ -128,7 +129,7 @@ public function register(): void return self::serverFactory( $app, - $isRepositoryCached ? require(base_path('bootstrap/cache/operations.php')) : null + $isRepositoryCached ? require (base_path('bootstrap/cache/operations.php')) : null ); }); @@ -159,11 +160,12 @@ public function register(): void public function boot(): void { $this->publishes([ - __DIR__ . '/config/config.php' => config_path('operations.php'), + __DIR__.'/config/config.php' => config_path('operations.php'), ]); $this->mergeConfigFrom( - __DIR__ . '/config/config.php', 'operations' + __DIR__.'/config/config.php', + 'operations' ); if ($this->app->runningInConsole()) { @@ -179,4 +181,4 @@ public function boot(): void ); } } -} \ No newline at end of file +} diff --git a/src/Adapters/Laravel/Utils/ArtisanOptions.php b/src/Adapters/Laravel/Utils/ArtisanOptions.php index ca65fdd..adcc39d 100644 --- a/src/Adapters/Laravel/Utils/ArtisanOptions.php +++ b/src/Adapters/Laravel/Utils/ArtisanOptions.php @@ -1,4 +1,6 @@ - $expanded */ $expanded = []; foreach ($options as $option) { - if (!is_string($option)) { + if (! is_string($option)) { continue; } foreach (explode(',', $option) as $part) { $part = trim($part); - if ($part !== '' && !in_array($part, $expanded, true)) { + if ($part !== '' && ! in_array($part, $expanded, true)) { $expanded[] = $part; } } @@ -67,7 +69,7 @@ public static function asPositiveInt(mixed $option, mixed $fallback): ?int $int = match (true) { is_int($value) => $value, - is_string($value) && preg_match('/^-?\d+$/', $value) === 1 => (int)$value, + is_string($value) && preg_match('/^-?\d+$/', $value) === 1 => (int) $value, default => null, }; diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index 706701f..ab55b31 100644 --- a/src/Adapters/Laravel/config/config.php +++ b/src/Adapters/Laravel/config/config.php @@ -1,4 +1,6 @@ - app_path('Operations'), + 'discovery_path' => app_path('Operations'), /** * Define a class name used to create the context for all operations. * It must implement Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory * - * @see Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory + * @see ContextFactory */ - "context" => null, + 'context' => null, /** * Defines the ID length to use for the cache keys. Usually 10 is enough. If you face * collisions, increase the number */ - "cache" => [ - "idLength" => 10, + 'cache' => [ + 'idLength' => 10, ], /** @@ -39,26 +44,27 @@ * - plain * - custom: MUST define className */ - "key" => [ + 'key' => [ /** * Options: obfuscate, plain, custom * * For obfuscate: you can define a pepper(string) to add randomness * For custom: MUST define className */ - "mode" => "obfuscate", + 'mode' => 'obfuscate', /** * Only relevant for mode 'obfuscate' */ - "pepper" => "none", + 'pepper' => 'none', /** * Only relevantly for mode custom * Class must implement: Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator - * @see Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator + * + * @see OperationKeyGenerator */ - "className" => null, + 'className' => null, ], /** @@ -78,9 +84,9 @@ * } * ``` * - * @see Le0daniel\PhpTsBindings\Contracts\MiddlewareContract + * @see MiddlewareContract */ - "middleware" => [], + 'middleware' => [], /** * Map your exceptions onto the server's built-in error categories. Anything not listed here and @@ -89,18 +95,18 @@ * * Matching is instanceof: listing a base class covers every subclass of it. */ - "exceptions" => [ - "unauthenticated" => [ + 'exceptions' => [ + 'unauthenticated' => [ AuthenticationException::class, ], - "unauthorized" => [ + 'unauthorized' => [ TokenMismatchException::class, AuthorizationException::class, ], - "not_found" => [ + 'not_found' => [ ModelNotFoundException::class, RecordNotFoundException::class, RecordsNotFoundException::class, ], ], -]; \ No newline at end of file +]; diff --git a/src/Adapters/PHPStan/UtilitiesNodeResolver.php b/src/Adapters/PHPStan/UtilitiesNodeResolver.php index c4039ca..76f721e 100644 --- a/src/Adapters/PHPStan/UtilitiesNodeResolver.php +++ b/src/Adapters/PHPStan/UtilitiesNodeResolver.php @@ -1,27 +1,30 @@ -type; + return match ($typeName->name) { 'DateTimeString' => $this->resolveDateTimeString($typeNode, $nameScope), 'BrandedString', 'BrandedInt' => $this->resolveBrandedTypes($typeName->name, $typeNode, $nameScope), @@ -97,8 +101,8 @@ private function resolveBrandedTypes(string $typeName, GenericTypeNode $typeNode } return match ($typeName) { - 'BrandedString' => new \PHPStan\Type\StringType(), - 'BrandedInt' => new \PHPStan\Type\IntegerType(), + 'BrandedString' => new StringType(), + 'BrandedInt' => new IntegerType(), default => null, }; } @@ -131,10 +135,7 @@ private function resolvePickAndOmitUtil(string $typeName, GenericTypeNode $typeN } /** - * @param "Pick"|"Omit" $type - * @param ConstantArrayType $structType - * @param Type $keysType - * @return Type + * @param "Pick"|"Omit" $type */ private function resolveConstArrayType(string $type, ConstantArrayType $structType, Type $keysType): Type { @@ -143,10 +144,10 @@ private function resolveConstArrayType(string $type, ConstantArrayType $structTy foreach ($structType->getKeyTypes() as $i => $keyType) { $isPropertyInArrayStruct = match ($type) { 'Pick' => $keysType->isSuperTypeOf($keyType)->yes(), - 'Omit' => !$keysType->isSuperTypeOf($keyType)->yes(), + 'Omit' => ! $keysType->isSuperTypeOf($keyType)->yes(), }; - if (!$isPropertyInArrayStruct) { + if (! $isPropertyInArrayStruct) { // eliminate keys that aren't in the Pick type continue; } @@ -163,10 +164,8 @@ private function resolveConstArrayType(string $type, ConstantArrayType $structTy } /** - * @param "Pick"|"Omit" $type - * @param ObjectType $structType - * @param Type $keysType - * @return Type + * @param "Pick"|"Omit" $type + * * @throws \Exception */ private function resolveObjectType(string $type, ObjectType $structType, Type $keysType): Type @@ -185,10 +184,10 @@ private function resolveObjectType(string $type, ObjectType $structType, Type $k $keyType = new ConstantStringType($propName, false); $isPropertyInNewObject = match ($type) { 'Pick' => $keysType->isSuperTypeOf($keyType)->yes(), - 'Omit' => !$keysType->isSuperTypeOf($keyType)->yes(), + 'Omit' => ! $keysType->isSuperTypeOf($keyType)->yes(), }; - if (!$isPropertyInNewObject) { + if (! $isPropertyInNewObject) { continue; } @@ -196,6 +195,7 @@ private function resolveObjectType(string $type, ObjectType $structType, Type $k if ($classReflection->hasProperty($propName)) { $propertyReflection = $classReflection->getNativeProperty($propName); $propertyTypes[$propName] = $propertyReflection->getReadableType(); + continue; } @@ -206,10 +206,7 @@ private function resolveObjectType(string $type, ObjectType $structType, Type $k } /** - * @param "Pick"|"Omit" $type - * @param ObjectShapeType $structType - * @param Type $keysType - * @return Type + * @param "Pick"|"Omit" $type */ private function resolveObjectShapeType(string $type, ObjectShapeType $structType, Type $keysType): Type { @@ -218,13 +215,13 @@ private function resolveObjectShapeType(string $type, ObjectShapeType $structTyp $optionalProperties = []; foreach ($structType->getProperties() as $propertyName => $propertyType) { - $keyType = new ConstantStringType((string)$propertyName, false); + $keyType = new ConstantStringType((string) $propertyName, false); $isPropertyInNewObject = match ($type) { 'Pick' => $keysType->isSuperTypeOf($keyType)->yes(), - 'Omit' => !$keysType->isSuperTypeOf($keyType)->yes(), + 'Omit' => ! $keysType->isSuperTypeOf($keyType)->yes(), }; - if (!$isPropertyInNewObject) { + if (! $isPropertyInNewObject) { continue; } @@ -236,4 +233,4 @@ private function resolveObjectShapeType(string $type, ObjectShapeType $structTyp return new ObjectShapeType($newObjectProperties, $optionalProperties); } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators.php b/src/CodeGen/CodeGenerators.php index 36aa57e..d4227a8 100644 --- a/src/CodeGen/CodeGenerators.php +++ b/src/CodeGen/CodeGenerators.php @@ -1,4 +1,6 @@ - function (TypedOperation $operationData): string { $namespace = $operationData->definition->namespace; $name = ucfirst($operationData->definition->name); + return "{$namespace}{$name}"; }, 'operation-prefix' => function (TypedOperation $operationData): string { $name = ucfirst($operationData->definition->name); + return "{$operationData->definition->namespace}{$name}"; }, 'namespace-postfix' => function (TypedOperation $operationData): string { $namespace = ucfirst($operationData->definition->namespace); $name = $operationData->definition->name; + return "{$name}{$namespace}"; }, 'name' => function (TypedOperation $operationData): string { @@ -87,9 +92,9 @@ public static function namingGenerator(string $generatorName): Closure } /** - * @param GeneratorName|NamingGenerator $namingGenerator - * @param list $with - * @param list $without + * @param GeneratorName|NamingGenerator $namingGenerator + * @param list $with + * @param list $without * @return list */ public static function fromDefaults(string|Closure $namingGenerator, array $with = [], array $without = []): array @@ -105,9 +110,9 @@ public static function fromDefaults(string|Closure $namingGenerator, array $with // Asking for a generator always wins: a name in both lists turns it on, so a caller // building on top of someone else's $without never has to unpick it first. $isEnabled = in_array($name, $with, true) - || ($defaultEnabled && !in_array($name, $without, true)); + || ($defaultEnabled && ! in_array($name, $without, true)); - if (!$isEnabled) { + if (! $isEnabled) { continue; } @@ -119,4 +124,4 @@ public static function fromDefaults(string|Closure $namingGenerator, array $with return $generators; } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 3b9fb9b..a39dfcc 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -1,4 +1,6 @@ - $values - * @param list $types + * @param list $values + * @param list $types */ public function importFromBindings(array $values = [], array $types = []): TypescriptImport { @@ -43,8 +48,8 @@ public function importFromBindings(array $values = [], array $types = []): Types } /** - * @param list $values - * @param list $types + * @param list $values + * @param list $types */ public function importFromOperationClient(array $values = [], array $types = []): TypescriptImport { @@ -56,8 +61,8 @@ public function importFromOperationClient(array $values = [], array $types = []) } /** - * @param list $values - * @param list $types + * @param list $values + * @param list $types */ public function importFromDefaultClient(array $values = [], array $types = []): TypescriptImport { @@ -69,8 +74,8 @@ public function importFromDefaultClient(array $values = [], array $types = []): } /** - * @param list $values - * @param list $types + * @param list $values + * @param list $types */ public function importFromOperationException(array $values = [], array $types = []): TypescriptImport { @@ -105,7 +110,7 @@ public function setDependencies(array $dependencies): void public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { return [ - self::OPERATION_CLIENT_FILE => new TypescriptFile(<< new TypescriptFile(<<<'TypeScript' export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; /** @@ -124,7 +129,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi TypeScript, [ $this->types->importFromTypes(types: ['Result']), ]), - self::DEFAULT_CLIENT_FILE => new TypescriptFile(<< new TypescriptFile(<<<'TypeScript' export type Hook = (result: Result) => Promise | void; export class DefaultClient implements OperationClient { @@ -158,7 +163,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi return Object.entries(input) .filter(([key, value]) => value !== undefined) .map(([key, value]) => { - return `\${encodeURIComponent(key)}=\${encodeURIComponent(JSON.stringify(value))}`; + return `${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(value))}`; }).join('&'); } @@ -174,7 +179,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; - const fullPath = `\${this.options.baseUrl ?? ''}/\${route.replace('{fqn}', key)}`; + const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{fqn}', key)}`; // Per call wins over the client wide default, and the timeout signal actually fires: a // fresh AbortController is never aborted by anything. @@ -194,10 +199,10 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } const queryParams = type === 'query' && input && typeof input === 'object' - ? `?\${this.createJsonEncodedQueryParams(input)}` + ? `?${this.createJsonEncodedQueryParams(input)}` : ''; - const response = await this.fetcher(`\${fullPath}\${queryParams}`, { + const response = await this.fetcher(`${fullPath}${queryParams}`, { method: type === 'query' ? 'GET' : 'POST', signal, headers, @@ -235,7 +240,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi $this->importFromOperationClient(types: ['OperationClient', 'OperationOptions']), $this->types->importFromTypes(types: ['Failure', 'Result', 'Success']), ]), - self::OPERATION_EXCEPTION_FILE => new TypescriptFile(<< new TypescriptFile(<<<'TypeScript' /** * Generic over the operation's error union, so `e.cause.type` narrows to the branches the * operation can actually produce rather than to any. @@ -253,7 +258,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } constructor(cause: Failure) { - super(`Operation failed with code \${cause.code}`); + super(`Operation failed with code ${cause.code}`); this.cause = cause; } @@ -302,4 +307,4 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi ]), ]; } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitOperations.php b/src/CodeGen/CodeGenerators/EmitOperations.php index b0e9b69..da57fc8 100644 --- a/src/CodeGen/CodeGenerators/EmitOperations.php +++ b/src/CodeGen/CodeGenerators/EmitOperations.php @@ -1,4 +1,6 @@ - $operations + * @param list $operations + * * @throws CodeGenException */ public function assertNamesAreUnique(array $operations): void @@ -86,10 +89,10 @@ public function assertNamesAreUnique(array $operations): void $second = $operation->definition; throw new CodeGenException( "Two operations generate the name '{$name}' in module " - . "'{$operation->definition->namespace}.ts': " - . "{$first->fullyQualifiedClassName}::{$first->methodName} ({$first->type->lowerCase()}) and " - . "{$second->fullyQualifiedClassName}::{$second->methodName} ({$second->type->lowerCase()}). " - . "Rename one, or generate with a naming mode that distinguishes them." + ."'{$operation->definition->namespace}.ts': " + ."{$first->fullyQualifiedClassName}::{$first->methodName} ({$first->type->lowerCase()}) and " + ."{$second->fullyQualifiedClassName}::{$second->methodName} ({$second->type->lowerCase()}). " + .'Rename one, or generate with a naming mode that distinguishes them.' ); } @@ -104,17 +107,17 @@ public function baseTypeName(TypedOperation $operation): string public function inputTypeName(TypedOperation $operation): string { - return $this->baseTypeName($operation) . 'Input'; + return $this->baseTypeName($operation).'Input'; } public function resultTypeName(TypedOperation $operation): string { - return $this->baseTypeName($operation) . 'Result'; + return $this->baseTypeName($operation).'Result'; } public function errorTypeName(TypedOperation $operation): string { - return $this->baseTypeName($operation) . 'Error'; + return $this->baseTypeName($operation).'Error'; } /** @@ -155,7 +158,7 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata */ TypeScript; - if (!$operation->hasInput) { + if (! $operation->hasInput) { return new TypescriptFile( <<outputDef->type}; @@ -171,7 +174,8 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata options ) } -TypeScript, $imports, +TypeScript, + $imports, ); } @@ -190,7 +194,8 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata options ) } -TypeScript, $imports, +TypeScript, + $imports, ); } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitOperationsSpaClient.php b/src/CodeGen/CodeGenerators/EmitOperationsSpaClient.php index d0c5c0b..bf61747 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationsSpaClient.php +++ b/src/CodeGen/CodeGenerators/EmitOperationsSpaClient.php @@ -1,4 +1,6 @@ - $values - * @param list $types + * @param list $values + * @param list $types */ public function importFromOperationsSpaClient(array $values = [], array $types = []): TypescriptImport { @@ -49,7 +51,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi { // Derived from the enum so the emitted union can never drift from what Client::toast accepts. $toastTypes = implode('|', array_map( - fn(ToastType $type): string => "'{$type->value}'", + fn (ToastType $type): string => "'{$type->value}'", ToastType::cases(), )); diff --git a/src/CodeGen/CodeGenerators/EmitQueryKey.php b/src/CodeGen/CodeGenerators/EmitQueryKey.php index b23298d..fa02775 100644 --- a/src/CodeGen/CodeGenerators/EmitQueryKey.php +++ b/src/CodeGen/CodeGenerators/EmitQueryKey.php @@ -1,4 +1,6 @@ -operations->resultTypeName($operation); $resultInputTypeName = $this->operations->inputTypeName($operation); - $queryName = "use" . $operationBaseTypeName . "Query"; - $queryOptionsName = lcfirst($operationBaseTypeName) . "QueryOptions"; - $optionsTypeName = $operationBaseTypeName . "Options"; + $queryName = 'use'.$operationBaseTypeName.'Query'; + $queryOptionsName = lcfirst($operationBaseTypeName).'QueryOptions'; + $optionsTypeName = $operationBaseTypeName.'Options'; $imports = [ new TypescriptImport( - "@tanstack/react-query", + '@tanstack/react-query', values: ['useQuery', 'queryOptions'], types: ['UseQueryOptions'], ), $this->utils->importFromUtils(values: ['queryKey', 'throwOnFailure']), ]; - if (!$operation->hasInput) { + if (! $operation->hasInput) { return new TypescriptFile( <<, 'queryKey' | 'queryFn'>; @@ -91,7 +94,9 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata export function {$queryName}(queryOptions?: Partial<{$optionsTypeName}>) { return useQuery({$queryOptionsName}(queryOptions)); } -TypeScript, $imports); +TypeScript, + $imports + ); } return new TypescriptFile( @@ -113,6 +118,8 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata export function {$queryName}(input: {$resultInputTypeName}, queryOptions?: Partial<{$optionsTypeName}>) { return useQuery({$queryOptionsName}(input, queryOptions)); } -TypeScript, $imports); +TypeScript, + $imports + ); } } diff --git a/src/CodeGen/CodeGenerators/EmitTypeMap.php b/src/CodeGen/CodeGenerators/EmitTypeMap.php index 8919ca9..7dd30fb 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeMap.php +++ b/src/CodeGen/CodeGenerators/EmitTypeMap.php @@ -1,4 +1,6 @@ - $operation->outputDef->type, 'errors' => $operation->errorDef->type, ]; + return $carry; }, []); - $mapAsTsTypeString = '{' . implode(';', Arrays::mapWithKeys($map, function (string $type, array $operations) { - $typeString = implode(';', Arrays::mapWithKeys($operations, function (string $operation, array $definition) { - return "'{$operation}': {input: {$definition['input']}, output: {$definition['output']}, errors: {$definition['errors']}}"; - })); - return "{$type}: {{$typeString}}"; - })) . '}'; + $mapAsTsTypeString = '{'.implode(';', Arrays::mapWithKeys($map, function (string $type, array $operations) { + $typeString = implode(';', Arrays::mapWithKeys($operations, function (string $operation, array $definition) { + return "'{$operation}': {input: {$definition['input']}, output: {$definition['output']}, errors: {$definition['errors']}}"; + })); + + return "{$type}: {{$typeString}}"; + })).'}'; // Written next to the types file rather than standing on its own: the map inlines the // aliases EmitTypes declares, and they only resolve while it sits next to them. Brand is // imported unconditionally — an inlined brand references it, yet it is never a registry // key — and a linter drops it where unused. return [ - 'type-map' => new TypescriptFile(<< new TypescriptFile( + <<emitTypes->importFromTypes(types: ['Brand', ...$registry->usedAliases()]) + $this->emitTypes->importFromTypes(types: ['Brand', ...$registry->usedAliases()]), ] - ) + ), ]; } @@ -74,4 +79,4 @@ public function setDependencies(array $dependencies): void $dependencies[EmitTypes::class] ?? null, ); } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index 8365f61..9778772 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -1,11 +1,12 @@ - $values - * @param list $types + * @param list $values + * @param list $types */ public function importFromUtils(array $values = [], array $types = []): TypescriptImport { @@ -81,7 +83,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } $namespace = $operation->operation->definition->namespace; - if (!in_array($namespace, $queryNamespaces, true)) { + if (! in_array($namespace, $queryNamespaces, true)) { $queryNamespaces[] = $namespace; } } @@ -112,16 +114,15 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi // Constructed, not just annotated: a type only import would leave // `new OperationException(...)` referencing nothing at runtime. $this->bindings->importFromOperationException(values: ['OperationException']), - ]) + ]), ]; } /** - * @param list $namespaces - * @return string + * @param list $namespaces */ private function generateLiteralUnion(array $namespaces): string { - return implode("|", array_map(fn(string $namespace) => "'$namespace'", $namespaces)); + return implode('|', array_map(fn (string $namespace) => "'$namespace'", $namespaces)); } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index fe25a7c..b6b842e 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -1,10 +1,11 @@ - $values - * @param list $types + * @param list $values + * @param list $types */ public function importFromTypes(array $values = [], array $types = []): TypescriptImport { @@ -62,7 +63,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi $uniqueNamespaces = []; foreach ($operations as $operation) { $namespace = $operation->operation->definition->namespace; - if (!in_array($namespace, $uniqueNamespaces, true)) { + if (! in_array($namespace, $uniqueNamespaces, true)) { $uniqueNamespaces[] = $namespace; } } @@ -71,7 +72,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi // all, so every operation file can import any key of its own definitions' registries. $aliasTypeString = implode("\n", Arrays::mapWithKeys( $registry->toArray(), - fn(string $alias, string $definition): string => "export type {$alias} = {$definition}", + fn (string $alias, string $definition): string => "export type {$alias} = {$definition}", )); return [ @@ -92,11 +93,10 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } /** - * @param list $namespaces - * @return string + * @param list $namespaces */ private function generateNamespaceUnion(array $namespaces): string { - return implode("|", array_map(fn(string $namespace) => "'$namespace'", $namespaces)); + return implode('|', array_map(fn (string $namespace) => "'$namespace'", $namespaces)); } } diff --git a/src/CodeGen/Contracts/DependsOn.php b/src/CodeGen/Contracts/DependsOn.php index 4629e2b..8b867e8 100644 --- a/src/CodeGen/Contracts/DependsOn.php +++ b/src/CodeGen/Contracts/DependsOn.php @@ -1,4 +1,6 @@ -, GeneratesOperationCode|GeneratesLibFiles> $dependencies + * @param array, GeneratesOperationCode|GeneratesLibFiles> $dependencies */ public function setDependencies(array $dependencies): void; -} \ No newline at end of file +} diff --git a/src/CodeGen/Contracts/GeneratesLibFiles.php b/src/CodeGen/Contracts/GeneratesLibFiles.php index b72a91a..c7a88bb 100644 --- a/src/CodeGen/Contracts/GeneratesLibFiles.php +++ b/src/CodeGen/Contracts/GeneratesLibFiles.php @@ -1,4 +1,6 @@ - 'content' * ] * - * @param list $operations - * @param AliasRegistry $registry The run's shared registry: every alias any operation produced. + * @param list $operations + * @param AliasRegistry $registry The run's shared registry: every alias any operation produced. * @return array */ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array; -} \ No newline at end of file +} diff --git a/src/CodeGen/Contracts/GeneratesOperationCode.php b/src/CodeGen/Contracts/GeneratesOperationCode.php index 6486826..5631504 100644 --- a/src/CodeGen/Contracts/GeneratesOperationCode.php +++ b/src/CodeGen/Contracts/GeneratesOperationCode.php @@ -1,4 +1,6 @@ -queryUrl, '{fqn}')) { + ) { + if (! str_contains($this->queryUrl, '{fqn}')) { throw new CodeGenException('Query URL must contain {fqn} placeholder'); } - if (!str_contains($this->commandUrl, '{fqn}')) { + if (! str_contains($this->commandUrl, '{fqn}')) { throw new CodeGenException('Command URL must contain {fqn} placeholder'); } } -} \ No newline at end of file +} diff --git a/src/CodeGen/Data/TypedOperation.php b/src/CodeGen/Data/TypedOperation.php index 10e694f..561b15f 100644 --- a/src/CodeGen/Data/TypedOperation.php +++ b/src/CodeGen/Data/TypedOperation.php @@ -1,4 +1,6 @@ -errorDef->registry->usedAliases(), ])); sort($aliases); + return $aliases; } } diff --git a/src/CodeGen/Exceptions/CodeGenException.php b/src/CodeGen/Exceptions/CodeGenException.php index efefb83..5d0db42 100644 --- a/src/CodeGen/Exceptions/CodeGenException.php +++ b/src/CodeGen/Exceptions/CodeGenException.php @@ -1,4 +1,6 @@ - $messages + * @param array $messages */ public function __construct( public readonly array $messages - ) - { - parent::__construct("Invalid generator dependencies"); + ) { + parent::__construct('Invalid generator dependencies'); } -} \ No newline at end of file +} diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index fa3946c..285cd5a 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -1,4 +1,6 @@ - $generators + * @param array $generators + * * @throws InvalidGeneratorDependencies */ public function __construct( - private array $generators, + private array $generators, private TypescriptGenerator $typescriptGenerator = new TypescriptGenerator(), - ) - { + ) { $this->resolveGeneratorDependencies(); } @@ -59,13 +61,13 @@ private function resolveGeneratorDependencies(): void } foreach ($this->generators as $generator) { - if (!$generator instanceof DependsOn) { + if (! $generator instanceof DependsOn) { continue; } foreach ($generator->dependsOnGenerator() as $className) { - if (!array_key_exists($className, $instances)) { - $issues[] = "Generator " . $generator::class . " depends on {$className} which is not registered."; + if (! array_key_exists($className, $instances)) { + $issues[] = 'Generator '.$generator::class." depends on {$className} which is not registered."; } } } @@ -75,7 +77,7 @@ private function resolveGeneratorDependencies(): void } foreach ($this->generators as $generator) { - if (!$generator instanceof DependsOn) { + if (! $generator instanceof DependsOn) { continue; } @@ -88,21 +90,20 @@ private function resolveGeneratorDependencies(): void } /** - * @param Server $server - * @param ServerMetadata $metadata - * @param list $ignore + * @param list $ignore * @return array */ public function generate(Server $server, ServerMetadata $metadata, array $ignore = []): array { /** * Filter out some operations that are not needed. + * * @var array $filteredDefinitions */ $filteredDefinitions = array_filter( $server->registry->all(), - fn(Operation $operation): bool => !in_array($operation->definition->namespace, $ignore, true) - && !in_array($operation->definition->fullyQualifiedName(), $ignore, true), + fn (Operation $operation): bool => ! in_array($operation->definition->namespace, $ignore, true) + && ! in_array($operation->definition->fullyQualifiedName(), $ignore, true), ) |> array_values(...); // Cross-operation and cross-direction alias conflicts are only caught when every pass hands @@ -152,9 +153,8 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore } /** - * @param list $definitions - * @param ServerMetadata $metadata - * @param AliasRegistry $registry The run's shared registry, holding every alias any pass produced. + * @param list $definitions + * @param AliasRegistry $registry The run's shared registry, holding every alias any pass produced. * @return array */ private function generateLibFiles(array $definitions, ServerMetadata $metadata, AliasRegistry $registry): array @@ -162,11 +162,11 @@ private function generateLibFiles(array $definitions, ServerMetadata $metadata, return array_reduce( $this->generators, /** - * @param array $carry + * @param array $carry * @return array */ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry): array { - if (!$codeGenerator instanceof GeneratesLibFiles) { + if (! $codeGenerator instanceof GeneratesLibFiles) { return $carry; } @@ -185,6 +185,7 @@ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry) $carry[$fileKey] = ($carry[$fileKey] ?? new TypescriptFile()) ->append($fileContent->withModulesResolvedBy(Paths::fromInsideLib(...))); } + return $carry; }, [] @@ -192,8 +193,7 @@ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry) } /** - * @param list $definitions - * @param ServerMetadata $metadata + * @param list $definitions * @return array */ private function generateOperationDefinitions(array $definitions, ServerMetadata $metadata): array @@ -209,8 +209,8 @@ private function generateOperationDefinitions(array $definitions, ServerMetadata if (preg_match(self::VALID_MODULE_NAME, $namespace) !== 1) { throw new CodeGenException( "Invalid namespace '{$namespace}' on " - . "{$operationData->definition->fullyQualifiedClassName}::{$operationData->definition->methodName}. " - . "A namespace becomes a module file name and must only contain a-z, A-Z, 0-9, - and _." + ."{$operationData->definition->fullyQualifiedClassName}::{$operationData->definition->methodName}. " + .'A namespace becomes a module file name and must only contain a-z, A-Z, 0-9, - and _.' ); } @@ -221,7 +221,7 @@ private function generateOperationDefinitions(array $definitions, ServerMetadata $file = $operationFiles[$fileKey] ?? new TypescriptFile(); foreach ($this->generators as $codeGenerator) { - if (!$codeGenerator instanceof GeneratesOperationCode) { + if (! $codeGenerator instanceof GeneratesOperationCode) { continue; } @@ -235,4 +235,4 @@ private function generateOperationDefinitions(array $definitions, ServerMetadata return $operationFiles; } -} \ No newline at end of file +} diff --git a/src/CodeGen/Utils/ErrorTypescript.php b/src/CodeGen/Utils/ErrorTypescript.php index ea6d1bf..d6c4196 100644 --- a/src/CodeGen/Utils/ErrorTypescript.php +++ b/src/CodeGen/Utils/ErrorTypescript.php @@ -1,4 +1,6 @@ -name, JSON_THROW_ON_ERROR); + return $details === null ? "{code: {$type->value}, type: {$name}}" : "{code: {$type->value}, type: {$name}, details: {$details}}"; diff --git a/src/CodeGen/Utils/OutputDirectory.php b/src/CodeGen/Utils/OutputDirectory.php index 5f766ef..10ce101 100644 --- a/src/CodeGen/Utils/OutputDirectory.php +++ b/src/CodeGen/Utils/OutputDirectory.php @@ -1,4 +1,6 @@ - $files Keys are paths relative to the directory. + * @param array $files Keys are paths relative to the directory. */ public static function write(string $directory, array $files): void { @@ -25,13 +27,13 @@ public static function write(string $directory, array $files): void // the marker cannot recover from, so it is refused before anything is touched. foreach ($files as $fileName => $file) { $filePath = "{$directory}/{$fileName}"; - if (file_exists($filePath) && !self::isGeneratedFile($filePath)) { + if (file_exists($filePath) && ! self::isGeneratedFile($filePath)) { throw new CodeGenException( "Refusing to overwrite {$fileName}: it exists but does not carry the " - . "'" . TypescriptFile::MARKER . "' marker, so it was not written by this " - . "library. Either it is hand written and the output belongs somewhere else, " - . "or it predates the marker - delete the output directory once and generate " - . "again." + ."'".TypescriptFile::MARKER."' marker, so it was not written by this " + .'library. Either it is hand written and the output belongs somewhere else, ' + .'or it predates the marker - delete the output directory once and generate ' + .'again.' ); } } @@ -43,7 +45,7 @@ public static function write(string $directory, array $files): void unlink("{$directory}/{$fileName}"); } - if (!is_dir("{$directory}/lib")) { + if (! is_dir("{$directory}/lib")) { mkdir("{$directory}/lib", 0777, true); } @@ -56,7 +58,7 @@ public static function write(string $directory, array $files): void * The check the writer makes unnecessary, without touching the directory: every named file * exists with exactly the generated bytes, and no other TypeScript file is present. * - * @param array $files Keys are paths relative to the directory. + * @param array $files Keys are paths relative to the directory. * @return list Empty when the directory matches. One human readable issue per problem. */ public static function verify(string $directory, array $files): array @@ -65,8 +67,9 @@ public static function verify(string $directory, array $files): array foreach ($files as $fileName => $file) { $filePath = "{$directory}/{$fileName}"; - if (!file_exists($filePath)) { + if (! file_exists($filePath)) { $issues[] = "File {$fileName} is missing."; + continue; } @@ -78,12 +81,13 @@ public static function verify(string $directory, array $files): array // A removed operation leaves its module behind. Comparing content alone reports that // directory as correct, which is exactly the drift this is meant to catch. foreach (self::existingFileNames($directory) as $fileName) { - if (!array_key_exists($fileName, $files)) { + if (! array_key_exists($fileName, $files)) { $issues[] = "File {$fileName} is not generated anymore and should be deleted."; } } sort($issues); + return $issues; } @@ -111,12 +115,12 @@ private static function existingFileNames(string $directory): array /** @var SplFileInfo $file */ foreach ($iterator as $file) { - if ($file->isDir() || !str_ends_with($file->getBasename(), '.ts')) { + if ($file->isDir() || ! str_ends_with($file->getBasename(), '.ts')) { continue; } $realPath = $file->getRealPath(); - if ($realPath === false || !self::isGeneratedFile($realPath)) { + if ($realPath === false || ! self::isGeneratedFile($realPath)) { continue; } @@ -124,6 +128,7 @@ private static function existingFileNames(string $directory): array } sort($fileNames); + return $fileNames; } @@ -133,6 +138,7 @@ private static function existingFileNames(string $directory): array private static function isGeneratedFile(string $filePath): bool { $head = file_get_contents($filePath, length: strlen(TypescriptFile::MARKER) + 2); + return $head !== false && TypescriptFile::isGenerated($head); } } diff --git a/src/CodeGen/Utils/Paths.php b/src/CodeGen/Utils/Paths.php index fdcb218..83ad48e 100644 --- a/src/CodeGen/Utils/Paths.php +++ b/src/CodeGen/Utils/Paths.php @@ -1,4 +1,6 @@ - $this->name, }; - if (!Syntax::isValidIdentifier($name)) { + if (! Syntax::isValidIdentifier($name)) { throw InvalidStringLiteralException::notAValidTypescriptIdentifier($name, "#[Brand] on {$classString}"); } diff --git a/src/Contracts/Attributes/Castable.php b/src/Contracts/Attributes/Castable.php index 5400cd7..1fc9dd1 100644 --- a/src/Contracts/Attributes/Castable.php +++ b/src/Contracts/Attributes/Castable.php @@ -1,4 +1,6 @@ -namespace !== null ? Strings::toString($this->namespace) : null; } -} \ No newline at end of file +} diff --git a/src/Contracts/Attributes/ExposeAs.php b/src/Contracts/Attributes/ExposeAs.php index 67af489..b15ce6d 100644 --- a/src/Contracts/Attributes/ExposeAs.php +++ b/src/Contracts/Attributes/ExposeAs.php @@ -1,4 +1,6 @@ -> $middleware + * @param class-string> $middleware */ public function __construct( public string $middleware, - ) - { + ) { } } diff --git a/src/Contracts/Attributes/Named.php b/src/Contracts/Attributes/Named.php index 7fd0d1e..483e12d 100644 --- a/src/Contracts/Attributes/Named.php +++ b/src/Contracts/Attributes/Named.php @@ -1,4 +1,6 @@ - $this->name, }; - if (!Syntax::isValidIdentifier($name)) { + if (! Syntax::isValidIdentifier($name)) { throw InvalidStringLiteralException::notAValidTypescriptIdentifier($name, "#[Named] on {$classString}"); } diff --git a/src/Contracts/Attributes/Optional.php b/src/Contracts/Attributes/Optional.php index 0d03f56..83a5fc9 100644 --- a/src/Contracts/Attributes/Optional.php +++ b/src/Contracts/Attributes/Optional.php @@ -1,4 +1,6 @@ -namespace !== null ? Strings::toString($this->namespace) : null; } -} \ No newline at end of file +} diff --git a/src/Contracts/Attributes/Throws.php b/src/Contracts/Attributes/Throws.php index 3ec7468..30e9bea 100644 --- a/src/Contracts/Attributes/Throws.php +++ b/src/Contracts/Attributes/Throws.php @@ -1,4 +1,6 @@ - $exceptionClass - * @param non-empty-string|null $as + * @param class-string $exceptionClass + * @param non-empty-string|null $as */ public function __construct( public string $exceptionClass, public ?string $as = null, - ) - { + ) { } } diff --git a/src/Contracts/Client.php b/src/Contracts/Client.php index d0ab76d..8c93cbe 100644 --- a/src/Contracts/Client.php +++ b/src/Contracts/Client.php @@ -1,4 +1,6 @@ - */ public function all(): array; -} \ No newline at end of file +} diff --git a/src/Contracts/PhpTsBindingsException.php b/src/Contracts/PhpTsBindingsException.php index 3378d25..f7f41a7 100644 --- a/src/Contracts/PhpTsBindingsException.php +++ b/src/Contracts/PhpTsBindingsException.php @@ -1,4 +1,6 @@ - $metadata + * + * @param array $metadata */ #[NoDiscard] public function withMetadata(array $metadata): static; /** * Append metadata to the result. - * @param array $metadata + * + * @param array $metadata */ #[NoDiscard] public function appendMetadata(array $metadata): static; diff --git a/src/Contracts/SerializableClient.php b/src/Contracts/SerializableClient.php index 6bf0c48..17546b3 100644 --- a/src/Contracts/SerializableClient.php +++ b/src/Contracts/SerializableClient.php @@ -1,4 +1,6 @@ - $className - * @return MiddlewareContract + * @param class-string $className */ public function createMiddleware(string $className): MiddlewareContract; /** * @template TClass - * @param class-string $className + * + * @param class-string $className * @return TClass */ public function createController(string $className): mixed; -} \ No newline at end of file +} diff --git a/src/Contracts/ValueObjects/IntValueObject.php b/src/Contracts/ValueObjects/IntValueObject.php index 122b69e..b8044a4 100644 --- a/src/Contracts/ValueObjects/IntValueObject.php +++ b/src/Contracts/ValueObjects/IntValueObject.php @@ -1,4 +1,6 @@ -> */ - private(set) array $issues = []; + public private(set) array $issues = []; public function enterPath(int|string $path): void { @@ -59,6 +60,7 @@ public function removeCurrentIssues(): void { if ($this->path === []) { $this->issues = []; + return; } @@ -69,4 +71,4 @@ public function removeCurrentIssues(): void } } } -} \ No newline at end of file +} diff --git a/src/Executor/Data/Failure.php b/src/Executor/Data/Failure.php index 5f8abb7..a32a56b 100644 --- a/src/Executor/Data/Failure.php +++ b/src/Executor/Data/Failure.php @@ -1,4 +1,6 @@ - $debugInfo - * @param Throwable|null $exception + * @param array $debugInfo */ public function __construct( - string|UnitEnum $messageOrLocalizationKey, - public array $debugInfo = [], + string|UnitEnum $messageOrLocalizationKey, + public array $debugInfo = [], public ?Throwable $exception = null, - ) - { + ) { $this->messageOrLocalizationKey = match (true) { $messageOrLocalizationKey instanceof BackedEnum => (string) $messageOrLocalizationKey->value, $messageOrLocalizationKey instanceof UnitEnum => $messageOrLocalizationKey->name, @@ -36,23 +35,21 @@ public static function invalidType(string $expected, mixed $value): self { return new self( IssueMessage::INVALID_TYPE, - ['message' => "Expected value of type {$expected}, got: " . gettype($value)], + ['message' => "Expected value of type {$expected}, got: ".gettype($value)], ); } /** - * @param list $messages + * @param list $messages * @return list */ public static function fromMessageArray(array $messages): array { - return array_map(fn(string $message) => new self($message), $messages); + return array_map(fn (string $message) => new self($message), $messages); } /** - * @param Throwable $throwable - * @param array $debugInfo - * @return self + * @param array $debugInfo */ public static function fromThrowable(Throwable $throwable, array $debugInfo = []): self { @@ -64,8 +61,7 @@ public static function fromThrowable(Throwable $throwable, array $debugInfo = [] } /** - * @param array $debugInfo - * @return self + * @param array $debugInfo */ public static function internalError(array $debugInfo = []): self { @@ -75,4 +71,4 @@ public static function internalError(array $debugInfo = []): self exception: null, ); } -} \ No newline at end of file +} diff --git a/src/Executor/Data/IssueMessage.php b/src/Executor/Data/IssueMessage.php index c98845a..bfd1068 100644 --- a/src/Executor/Data/IssueMessage.php +++ b/src/Executor/Data/IssueMessage.php @@ -1,4 +1,6 @@ -> $issuesMap + * @param array> $issuesMap */ public function __construct( public readonly array $issuesMap = [], - ) - { + ) { } /** - * @param array $issuesMap - * @return self + * @param array $issuesMap */ public static function fromMessages(array $issuesMap): self { return new self( array_map( - fn(string|array $issues) => Issue::fromMessageArray( + fn (string|array $issues) => Issue::fromMessageArray( is_array($issues) ? array_values($issues) : [$issues], ), $issuesMap @@ -40,6 +40,7 @@ public function isEmpty(): bool public function at(?string $path): array { $path ??= self::ROOT_PATH; + return $this->issuesMap[$path] ?? []; } @@ -57,7 +58,7 @@ public function allFlat(): array public function serializeToFieldsArray(): array { return array_map(function ($issues) { - return array_map(fn(Issue $issue) => $issue->messageOrLocalizationKey, $issues); + return array_map(fn (Issue $issue) => $issue->messageOrLocalizationKey, $issues); }, $this->issuesMap); } @@ -67,7 +68,7 @@ public function serializeToFieldsArray(): array public function serializeToDebugFields(): array { return array_map(function (array $issues): array { - return array_map(fn(Issue $issue): array => [ + return array_map(fn (Issue $issue): array => [ 'message' => $issue->messageOrLocalizationKey, 'debugInfo' => $issue->debugInfo, 'exception' => $issue->exception ? [ @@ -86,9 +87,10 @@ public function serializeToCompleteString(): string { $messages = []; foreach ($this->issuesMap as $path => $issues) { - $imploded = implode(',', array_map(fn(Issue $issue) => $issue->messageOrLocalizationKey, $issues)); + $imploded = implode(',', array_map(fn (Issue $issue) => $issue->messageOrLocalizationKey, $issues)); $messages[] = "At {$path}: {$imploded}"; } + return implode('. ', $messages); } -} \ No newline at end of file +} diff --git a/src/Executor/Data/ParsingOptions.php b/src/Executor/Data/ParsingOptions.php index ade637b..4d21e6f 100644 --- a/src/Executor/Data/ParsingOptions.php +++ b/src/Executor/Data/ParsingOptions.php @@ -1,4 +1,6 @@ -issues->isEmpty(); + return ! $this->issues->isEmpty(); } } diff --git a/src/Executor/Exceptions/SchemaException.php b/src/Executor/Exceptions/SchemaException.php index f2a0488..a71b9a6 100644 --- a/src/Executor/Exceptions/SchemaException.php +++ b/src/Executor/Exceptions/SchemaException.php @@ -1,4 +1,6 @@ - $messages Not narrowed to a list: collecting reasons with - * array_filter() leaves holes, and renumbering here is - * friendlier than making every caller remember to. - * @param array $debugInfo Server side only diagnostics; never sent to the client. + * @param string|array $messages Not narrowed to a list: collecting reasons with + * array_filter() leaves holes, and renumbering here is + * friendlier than making every caller remember to. + * @param array $debugInfo Server side only diagnostics; never sent to the client. */ public function __construct( - string|array $messages, + string|array $messages, public readonly array $debugInfo = [], - ) - { + ) { $this->messages = is_string($messages) ? [$messages] : array_values($messages); // A rejection with nothing to say would record no issue, and SchemaExecutor would answer @@ -57,8 +58,8 @@ public function __construct( } /** - * @param array $debugInfo Context from the call site, merged underneath the - * thrower's own entries. + * @param array $debugInfo Context from the call site, merged underneath the + * thrower's own entries. * @return list */ public function toIssues(array $debugInfo = []): array @@ -66,7 +67,7 @@ public function toIssues(array $debugInfo = []): array $mergedDebugInfo = [...$debugInfo, ...$this->debugInfo]; return array_map( - fn(string $message): Issue => new Issue($message, $mergedDebugInfo, exception: $this), + fn (string $message): Issue => new Issue($message, $mergedDebugInfo, exception: $this), $this->messages, ); } diff --git a/src/Executor/Handlers/CustomClassHandler.php b/src/Executor/Handlers/CustomClassHandler.php index b4a7cd9..e935cfa 100644 --- a/src/Executor/Handlers/CustomClassHandler.php +++ b/src/Executor/Handlers/CustomClassHandler.php @@ -1,4 +1,6 @@ -addIssue( Issue::internalError( [ - "message" => "Failed to serialize object($objectClass) to standard class.", - "value" => $value, - "serializedValue" => $object, + 'message' => "Failed to serialize object($objectClass) to standard class.", + 'value' => $value, + 'serializedValue' => $object, ] ) ); + return Value::INVALID; } @@ -60,6 +60,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu 'message' => "{$node->fullyQualifiedCastingClass} cannot be constructed from input.", 'strategy' => $node->strategy->name, ])); + return Value::INVALID; } @@ -68,8 +69,9 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu return Value::INVALID; } - if (!is_array($arrayValue)) { + if (! is_array($arrayValue)) { $context->addIssue(Issue::invalidType('array', $arrayValue)); + return Value::INVALID; } @@ -78,11 +80,12 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu return new ($node->fullyQualifiedCastingClass)(...$arrayValue); } - $instance = new $node->fullyQualifiedCastingClass; + $instance = new $node->fullyQualifiedCastingClass(); foreach ($arrayValue as $key => $propertyValue) { /** @phpstan-ignore-next-line property.dynamicName */ $instance->{$key} = $propertyValue; } + return $instance; } catch (Throwable $exception) { $context->addIssue(Issue::fromThrowable($exception, [ @@ -93,4 +96,4 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu return Value::INVALID; } } -} \ No newline at end of file +} diff --git a/src/Executor/Handlers/IntersectionHandler.php b/src/Executor/Handlers/IntersectionHandler.php index 0ad50ea..cb981c2 100644 --- a/src/Executor/Handlers/IntersectionHandler.php +++ b/src/Executor/Handlers/IntersectionHandler.php @@ -1,4 +1,6 @@ -addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => "intersection expects value to be of same struct type: object or array", + 'message' => 'intersection expects value to be of same struct type: object or array', 'expected' => $mode, 'got' => $partialObject, ], )); + return Value::INVALID; } @@ -81,4 +84,4 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu default => throw new SchemaException("Invalid mode {$mode}"), }; } -} \ No newline at end of file +} diff --git a/src/Executor/Handlers/ListHandler.php b/src/Executor/Handlers/ListHandler.php index 2be4038..9812962 100644 --- a/src/Executor/Handlers/ListHandler.php +++ b/src/Executor/Handlers/ListHandler.php @@ -1,4 +1,6 @@ - */ @@ -25,8 +26,9 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E { assert($node instanceof ListNode); - if (!is_iterable($value)) { + if (! is_iterable($value)) { $context->addIssue(Issue::invalidType('iterable', $value)); + return Value::INVALID; } @@ -39,6 +41,7 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E if ($result === Value::INVALID) { $context->leavePath(); + return Value::INVALID; } @@ -47,6 +50,7 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E $index++; $context->leavePath(); } + return $values; } @@ -58,8 +62,9 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu { assert($node instanceof ListNode); - if (!is_array($value) || !array_is_list($value)) { + if (! is_array($value) || ! array_is_list($value)) { $context->addIssue(Issue::invalidType('list', $value)); + return Value::INVALID; } @@ -76,6 +81,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu if ($result === Value::INVALID) { $context->leavePath(); + return Value::INVALID; } @@ -86,4 +92,4 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu return $list; } -} \ No newline at end of file +} diff --git a/src/Executor/Handlers/RecordHandler.php b/src/Executor/Handlers/RecordHandler.php index e02b52e..d3dc9fe 100644 --- a/src/Executor/Handlers/RecordHandler.php +++ b/src/Executor/Handlers/RecordHandler.php @@ -1,4 +1,6 @@ -addIssue(Issue::invalidType('iterable', $value)); + return Value::INVALID; } $values = []; foreach ($value as $key => $item) { - if (!is_string($key)) { + if (! is_string($key)) { $context->addIssue(new Issue( IssueMessage::INVALID_KEY_TYPE, [ - 'message' => 'Record keys must be strings, got: ' . gettype($key), + 'message' => 'Record keys must be strings, got: '.gettype($key), 'keyValue' => $key, ] )); + return Value::INVALID; } @@ -50,6 +54,7 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E } $values[$key] = $result; } + return (object) $values; } @@ -61,21 +66,23 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu { assert($node instanceof RecordNode); - if (!is_array($value)) { + if (! is_array($value)) { $context->addIssue(Issue::invalidType('array', $value)); + return Value::INVALID; } $record = []; foreach ($value as $key => $item) { - if (!is_string($key)) { + if (! is_string($key)) { $context->addIssue(new Issue( IssueMessage::INVALID_KEY_TYPE, [ - 'message' => 'Record keys must be strings, got: ' . gettype($key), + 'message' => 'Record keys must be strings, got: '.gettype($key), 'keyValue' => $key, ] )); + return Value::INVALID; } $context->enterPath($key); @@ -91,4 +98,4 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu return $record; } -} \ No newline at end of file +} diff --git a/src/Executor/Handlers/StructHandler.php b/src/Executor/Handlers/StructHandler.php index 35b3ef7..67c70d6 100644 --- a/src/Executor/Handlers/StructHandler.php +++ b/src/Executor/Handlers/StructHandler.php @@ -1,4 +1,6 @@ -properties as $propertyNode) { assert($propertyNode instanceof PropertyNode, self::REFERENCE_INVARIANT); - if (!$propertyNode->propertyType->isOutput()) { + if (! $propertyNode->propertyType->isOutput()) { continue; } @@ -47,12 +49,14 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E if ($propertyValue === Value::INVALID) { $context->leavePath(); + return Value::INVALID; } if ($propertyValue === Value::UNDEFINED) { if ($propertyNode->isOptional) { $context->leavePath(); + continue; } @@ -63,6 +67,7 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E ] )); $context->leavePath(); + return Value::INVALID; } @@ -89,7 +94,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu { assert($node instanceof StructNode); - if (!is_array($value) && !$value instanceof stdClass) { + if (! is_array($value) && ! $value instanceof stdClass) { $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ @@ -97,6 +102,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu 'value' => $value, ] )); + return Value::INVALID; } @@ -104,7 +110,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu foreach ($node->properties as $propertyNode) { assert($propertyNode instanceof PropertyNode, self::REFERENCE_INVARIANT); - if (!$propertyNode->propertyType->isInput()) { + if (! $propertyNode->propertyType->isInput()) { continue; } @@ -122,12 +128,14 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu ] )); $context->leavePath(); + return Value::INVALID; } if ($propertyValue === Value::UNDEFINED) { if ($propertyNode->isOptional) { $context->leavePath(); + continue; } else { $context->leavePath(); @@ -137,6 +145,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu 'message' => "Missing property: {$propertyNode->name}", ] )); + return Value::INVALID; } } @@ -168,7 +177,7 @@ private function extractKeyedValue(string $key, mixed $input): mixed return $input->offsetExists($key) ? $input[$key] : Value::UNDEFINED; } - if (!is_object($input)) { + if (! is_object($input)) { return Value::INVALID; } @@ -179,4 +188,4 @@ private function extractKeyedValue(string $key, mixed $input): mixed default => Value::INVALID, }; } -} \ No newline at end of file +} diff --git a/src/Executor/Handlers/TupleHandler.php b/src/Executor/Handlers/TupleHandler.php index 2f71c03..e57dd59 100644 --- a/src/Executor/Handlers/TupleHandler.php +++ b/src/Executor/Handlers/TupleHandler.php @@ -1,4 +1,6 @@ - */ @@ -27,8 +28,9 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E { assert($node instanceof TupleNode); - if (!is_array($value) && !$value instanceof ArrayAccess) { + if (! is_array($value) && ! $value instanceof ArrayAccess) { $context->addIssue(Issue::invalidType('array', $value)); + return Value::INVALID; } @@ -39,18 +41,20 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E // The parse path proves the arity up front; here the value came from the application // and is read one index at a time, so a short tuple has to be caught before indexing // past the end of it. - if (!$this->hasIndex($value, $index)) { + if (! $this->hasIndex($value, $index)) { $context->addIssue(new Issue( IssueMessage::MISSING_PROPERTY, ['message' => "Missing tuple element at index {$index}."], )); $context->leavePath(); + return Value::INVALID; } $result = $executor->executeSerialize($type, $value[$index], $context); if ($result === Value::INVALID) { $context->leavePath(); + return Value::INVALID; } $tupleValues[] = $result; @@ -68,8 +72,9 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu { assert($node instanceof TupleNode); - if (!is_array($value) || !array_is_list($value)) { + if (! is_array($value) || ! array_is_list($value)) { $context->addIssue(Issue::invalidType('list', $value)); + return Value::INVALID; } @@ -77,8 +82,9 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu if (count($value) !== $expectedCount) { $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, - ['message' => "Expected a tuple of {$expectedCount} elements, got: " . count($value)], + ['message' => "Expected a tuple of {$expectedCount} elements, got: ".count($value)], )); + return Value::INVALID; } @@ -88,16 +94,18 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu $result = $executor->executeParse($type, $value[$index], $context); if ($result === Value::INVALID) { $context->leavePath(); + return Value::INVALID; } $tupleValues[] = $result; $context->leavePath(); } + return $tupleValues; } /** - * @param array|ArrayAccess $value + * @param array|ArrayAccess $value */ private function hasIndex(array|ArrayAccess $value, int $index): bool { @@ -105,4 +113,4 @@ private function hasIndex(array|ArrayAccess $value, int $index): bool ? array_key_exists($index, $value) : $value->offsetExists($index); } -} \ No newline at end of file +} diff --git a/src/Executor/Handlers/UnionHandler.php b/src/Executor/Handlers/UnionHandler.php index 4f41587..474e56e 100644 --- a/src/Executor/Handlers/UnionHandler.php +++ b/src/Executor/Handlers/UnionHandler.php @@ -1,4 +1,6 @@ - $discriminator, ] )); + return Value::INVALID; } @@ -61,11 +63,13 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E $result = $executor->executeSerialize($type, $value, $context); if ($result !== Value::INVALID) { $context->removeCurrentIssues(); + return $result; } } - $context->addIssue(Issue::invalidType((string)$node, $value)); + $context->addIssue(Issue::invalidType((string) $node, $value)); + return Value::INVALID; } @@ -90,6 +94,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu 'discriminator' => $discriminator, ] )); + return Value::INVALID; } @@ -101,11 +106,12 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => "No union branch matches the discriminator value.", + 'message' => 'No union branch matches the discriminator value.', 'value' => $valueToCheck, 'discriminator' => $discriminator, ] )); + return Value::INVALID; } @@ -114,6 +120,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu $result = $executor->executeParse($type, $value, $context); if ($result !== Value::INVALID) { $context->removeCurrentIssues(); + return $result; } } @@ -124,6 +131,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu 'message' => 'No valid union type found.', ] )); + return Value::INVALID; } @@ -132,9 +140,9 @@ private function extractKeyedValue(string $key, mixed $input): mixed return match (true) { is_array($input) => array_key_exists($key, $input) ? $input[$key] : Value::UNDEFINED, $input instanceof ArrayAccess => $input->offsetExists($key) ? $input[$key] : Value::UNDEFINED, - /* @phpstan-ignore-next-line property.dynamicName */ + /* @phpstan-ignore-next-line property.dynamicName */ is_object($input) => property_exists($input, $key) ? $input->{$key} : Value::UNDEFINED, default => Value::INVALID, }; } -} \ No newline at end of file +} diff --git a/src/Executor/SchemaExecutor.php b/src/Executor/SchemaExecutor.php index 9d9ddb0..ccafb5d 100644 --- a/src/Executor/SchemaExecutor.php +++ b/src/Executor/SchemaExecutor.php @@ -1,4 +1,6 @@ - $this->executeSerialize($node->node, $data, $context), // A node class no handler claims is a broken AST, not invalid data. Returning INVALID // here would answer with an empty failure; AstValidator throws for the same case. - default => throw new SchemaException("Unexpected node: " . $node::class), + default => throw new SchemaException('Unexpected node: '.$node::class), }; // Allow for catching errors at null boundaries during serialization. @@ -127,9 +129,10 @@ public function executeParse(NodeInterface $node, mixed $data, Context $context) { if ($node instanceof ConstraintNode) { $constrainedValue = $this->executeParse($node->node, $data, $context); - if ($constrainedValue === Value::INVALID || !$node->areConstraintsFulfilled($constrainedValue, $context)) { + if ($constrainedValue === Value::INVALID || ! $node->areConstraintsFulfilled($constrainedValue, $context)) { return Value::INVALID; } + return $constrainedValue; } @@ -142,7 +145,7 @@ public function executeParse(NodeInterface $node, mixed $data, Context $context) // Codegen metadata has no runtime effect. $node instanceof MetadataNode => $this->executeParse($node->node, $data, $context), // See executeSerialize(): an unclaimed node class is a broken AST, not invalid input. - default => throw new SchemaException("Unexpected node: " . $node::class), + default => throw new SchemaException('Unexpected node: '.$node::class), }; } -} \ No newline at end of file +} diff --git a/src/Parser/Contracts/Coercible.php b/src/Parser/Contracts/Coercible.php index 34d292e..448da08 100644 --- a/src/Parser/Contracts/Coercible.php +++ b/src/Parser/Contracts/Coercible.php @@ -1,8 +1,10 @@ - $aliases + * @param array $aliases */ public function __construct( public array $aliases = [], @@ -28,6 +30,7 @@ public function isGlobalAlias(string $value): bool public function getGlobalAlias(string $value): NodeInterface { $nodeOrNodeFactory = $this->aliases[$value]; + return $nodeOrNodeFactory instanceof Closure ? $nodeOrNodeFactory() : $nodeOrNodeFactory; } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/ASTOptimizer.php b/src/Parser/Helpers/ASTOptimizer.php index e5d19b8..044ffd3 100644 --- a/src/Parser/Helpers/ASTOptimizer.php +++ b/src/Parser/Helpers/ASTOptimizer.php @@ -1,4 +1,6 @@ -registryVariableName === self::KEY_VARIABLE_NAME) { throw new ParserException( - "The registry variable cannot be named '" . self::KEY_VARIABLE_NAME - . "'; it would collide with the generated factory's key parameter.", + "The registry variable cannot be named '".self::KEY_VARIABLE_NAME + ."'; it would collide with the generated factory's key parameter.", ); } } @@ -60,7 +61,7 @@ public function __construct( private function intern(string $prefix, NodeInterface $node, string $originalTypeString): ReferencedNode { $exported = $node->exportPhpCode(); - $identifier = '#' . $prefix . substr(sha1($exported), 0, $this->idLength); + $identifier = '#'.$prefix.substr(sha1($exported), 0, $this->idLength); if (isset($this->dedupedNodes[$identifier]) && $this->dedupedNodes[$identifier][1] !== $exported) { throw new ParserException( @@ -74,7 +75,7 @@ private function intern(string $prefix, NodeInterface $node, string $originalTyp } /** - * @param array $nodes + * @param array $nodes */ public function optimizeAndWriteToFile(string $fileName, array $nodes): void { @@ -85,18 +86,18 @@ public function optimizeAndWriteToFile(string $fileName, array $nodes): void } /** - * @param array $nodes + * @param array $nodes */ public function generateOptimizedCode(array $nodes): string { - if (array_any(array_keys($nodes), fn(string $key) => str_starts_with($key, '#'))) { + if (array_any(array_keys($nodes), fn (string $key) => str_starts_with($key, '#'))) { throw new ParserException('The keys of the nodes MUST not start with a # character'); } $this->dedupedNodes = []; $optimizedNodes = array_map( - fn(Closure|NodeInterface $node) => $this->dedupeNode($node instanceof Closure ? $node() : $node), + fn (Closure|NodeInterface $node) => $this->dedupeNode($node instanceof Closure ? $node() : $node), $nodes ); @@ -106,21 +107,21 @@ public function generateOptimizedCode(array $nodes): string $internedArms = Arrays::mapWithKeys( $this->dedupedNodes, - fn(string $key, array $entry) => PHPExport::export($key) . " => {$entry[1]},", + fn (string $key, array $entry) => PHPExport::export($key)." => {$entry[1]},", ); $schemaArms = Arrays::mapWithKeys( $optimizedNodes, - fn(string $key, NodeInterface $ast) => PHPExport::export($key) . " => {$ast->exportPhpCode()}," + fn (string $key, NodeInterface $ast) => PHPExport::export($key)." => {$ast->exportPhpCode()}," ); - $arms = implode(PHP_EOL, [... $internedArms, ... $schemaArms]); + $arms = implode(PHP_EOL, [...$internedArms, ...$schemaArms]); $key = self::KEY_VARIABLE_NAME; // One match arm per entry rather than one closure per entry: arms are only evaluated when // their key is requested, so this stays lazy while allocating nothing per entry. return "new {$registryClass}(static function (string \${$key}, {$registryClass} \${$this->registryVariableName}): {$nodeInterface} { " - . "return match (\${$key}) { {$arms} default => throw {$unknownKeyException}::forKey(\${$key}) }; })"; + ."return match (\${$key}) { {$arms} default => throw {$unknownKeyException}::forKey(\${$key}) }; })"; } private static function asCastableNode(NodeInterface $node): StructNode|ListNode|RecordNode|ReferencedNode @@ -131,6 +132,7 @@ private static function asCastableNode(NodeInterface $node): StructNode|ListNode || $node instanceof RecordNode || $node instanceof ReferencedNode ); + return $node; } @@ -152,7 +154,7 @@ private function dedupeNode(NodeInterface $node): NodeInterface } if ($node instanceof LeafNode) { - return $this->intern('l', $node, (string)$node); + return $this->intern('l', $node, (string) $node); } // Children are deduped first, so the interned node exports its children as short @@ -163,7 +165,7 @@ private function dedupeNode(NodeInterface $node): NodeInterface $this->dedupeNode($node->node), $node->isOptional, $node->propertyType - ), (string)$node); + ), (string) $node); } // Deep optimization @@ -171,7 +173,7 @@ private function dedupeNode(NodeInterface $node): NodeInterface /** @var non-empty-list $properties */ $properties = array_map($this->dedupeNode(...), $node->properties); - return $this->intern('s', new StructNode($node->phpType, $properties), (string)$node); + return $this->intern('s', new StructNode($node->phpType, $properties), (string) $node); } // Composite nodes are rebuilt inline rather than interned: a single use composite costs @@ -202,7 +204,7 @@ private function dedupeNode(NodeInterface $node): NodeInterface IntersectionNode::class => new IntersectionNode( array_map($this->dedupeNode(...), $node->nodes), ), - default => throw new ParserException('Unknown node type: ' . $node::class), + default => throw new ParserException('Unknown node type: '.$node::class), }; } @@ -220,4 +222,4 @@ private function flattenConstraintNode(ConstraintNode $node): ConstraintNode $constraints, ); } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/AstValidator.php b/src/Parser/Helpers/AstValidator.php index 4c3a271..472651c 100644 --- a/src/Parser/Helpers/AstValidator.php +++ b/src/Parser/Helpers/AstValidator.php @@ -1,4 +1,6 @@ - $stack[] = $current->node, TupleNode::class, IntersectionNode::class, UnionNode::class => array_push($stack, ...$current->nodes), - StructNode::class => array_push($stack, ... $current->properties), - default => throw new ParserException("Unexpected node: " . $current::class), + StructNode::class => array_push($stack, ...$current->properties), + default => throw new ParserException('Unexpected node: '.$current::class), }; } } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Constraints/IntRange.php b/src/Parser/Helpers/Constraints/IntRange.php index f9b7c78..685e516 100644 --- a/src/Parser/Helpers/Constraints/IntRange.php +++ b/src/Parser/Helpers/Constraints/IntRange.php @@ -1,4 +1,6 @@ -addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => "Expected int, got: " . gettype($value), + 'message' => 'Expected int, got: '.gettype($value), ], )); + return false; } @@ -49,6 +51,7 @@ public function validate(mixed $value, ExecutionContext $context): bool 'value' => $value, ], )); + return false; } @@ -61,6 +64,7 @@ public function validate(mixed $value, ExecutionContext $context): bool 'value' => $value, ], )); + return false; } @@ -73,12 +77,13 @@ public function exportPhpCode(): string $className = PHPExport::absolute(self::class); $min = PHPExport::export($this->min); $max = PHPExport::export($this->max); + return "new {$className}({$min},{$max})"; } #[Override] public function __toString(): string { - return 'IntRange(' . ($this->min ?? 'min') . ', ' . ($this->max ?? 'max') . ')'; + return 'IntRange('.($this->min ?? 'min').', '.($this->max ?? 'max').')'; } } diff --git a/src/Parser/Helpers/Constraints/ListLength.php b/src/Parser/Helpers/Constraints/ListLength.php index 0547079..0e2719a 100644 --- a/src/Parser/Helpers/Constraints/ListLength.php +++ b/src/Parser/Helpers/Constraints/ListLength.php @@ -1,4 +1,6 @@ -addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => "Expected array, got: " . gettype($value), + 'message' => 'Expected array, got: '.gettype($value), ], )); + return false; } @@ -49,6 +51,7 @@ public function validate(mixed $value, ExecutionContext $context): bool 'count' => $count, ], )); + return false; } @@ -61,6 +64,7 @@ public function validate(mixed $value, ExecutionContext $context): bool 'count' => $count, ], )); + return false; } @@ -73,12 +77,13 @@ public function exportPhpCode(): string $className = PHPExport::absolute(self::class); $min = PHPExport::export($this->min); $max = PHPExport::export($this->max); + return "new {$className}({$min},{$max})"; } #[Override] public function __toString(): string { - return 'ListLength(' . ($this->min ?? 'min') . ', ' . ($this->max ?? 'max') . ')'; + return 'ListLength('.($this->min ?? 'min').', '.($this->max ?? 'max').')'; } } diff --git a/src/Parser/Helpers/Constraints/LowercaseString.php b/src/Parser/Helpers/Constraints/LowercaseString.php index 1fd0fb3..6758e9b 100644 --- a/src/Parser/Helpers/Constraints/LowercaseString.php +++ b/src/Parser/Helpers/Constraints/LowercaseString.php @@ -1,4 +1,6 @@ -isString($value, $context)) { + if (! $this->isString($value, $context)) { return false; } @@ -31,9 +33,10 @@ public function validate(mixed $value, ExecutionContext $context): bool $context->addIssue(new Issue( IssueMessage::NOT_LOWERCASE_STRING, [ - "message" => "Expected lowercase string, got: '{$value}'", + 'message' => "Expected lowercase string, got: '{$value}'", ] )); + return false; } @@ -43,7 +46,7 @@ public function validate(mixed $value, ExecutionContext $context): bool #[Override] public function exportPhpCode(): string { - return 'new ' . PHPExport::absolute(self::class) . '()'; + return 'new '.PHPExport::absolute(self::class).'()'; } #[Override] diff --git a/src/Parser/Helpers/Constraints/NonEmptyString.php b/src/Parser/Helpers/Constraints/NonEmptyString.php index d4cb76a..fc5e403 100644 --- a/src/Parser/Helpers/Constraints/NonEmptyString.php +++ b/src/Parser/Helpers/Constraints/NonEmptyString.php @@ -1,4 +1,6 @@ -isString($value, $context)) { + if (! $this->isString($value, $context)) { return false; } @@ -30,9 +32,10 @@ public function validate(mixed $value, ExecutionContext $context): bool $context->addIssue(new Issue( IssueMessage::NOT_EMPTY_STRING, [ - "message" => "Expected non-empty string, got an empty string.", + 'message' => 'Expected non-empty string, got an empty string.', ] )); + return false; } @@ -42,7 +45,7 @@ public function validate(mixed $value, ExecutionContext $context): bool #[Override] public function exportPhpCode(): string { - return 'new ' . PHPExport::absolute(self::class) . '()'; + return 'new '.PHPExport::absolute(self::class).'()'; } #[Override] diff --git a/src/Parser/Helpers/Constraints/NonFalsyString.php b/src/Parser/Helpers/Constraints/NonFalsyString.php index 8bd9d1d..6491517 100644 --- a/src/Parser/Helpers/Constraints/NonFalsyString.php +++ b/src/Parser/Helpers/Constraints/NonFalsyString.php @@ -1,4 +1,6 @@ -isString($value, $context)) { + if (! $this->isString($value, $context)) { return false; } - if (!$value) { + if (! $value) { $context->addIssue(new Issue( IssueMessage::FALSY_STRING, [ - "message" => "Expected non-falsy string, got: '{$value}'", + 'message' => "Expected non-falsy string, got: '{$value}'", ] )); + return false; } @@ -40,7 +43,7 @@ public function validate(mixed $value, ExecutionContext $context): bool #[Override] public function exportPhpCode(): string { - return 'new ' . PHPExport::absolute(self::class) . '()'; + return 'new '.PHPExport::absolute(self::class).'()'; } #[Override] diff --git a/src/Parser/Helpers/Constraints/NumericString.php b/src/Parser/Helpers/Constraints/NumericString.php index c469c5f..77f9268 100644 --- a/src/Parser/Helpers/Constraints/NumericString.php +++ b/src/Parser/Helpers/Constraints/NumericString.php @@ -1,4 +1,6 @@ -isString($value, $context)) { + if (! $this->isString($value, $context)) { return false; } - if (!is_numeric($value)) { + if (! is_numeric($value)) { $context->addIssue(new Issue( IssueMessage::NOT_NUMERIC_STRING, [ - "message" => "Expected numeric string, got: '{$value}'", + 'message' => "Expected numeric string, got: '{$value}'", ] )); + return false; } @@ -41,7 +44,7 @@ public function validate(mixed $value, ExecutionContext $context): bool #[Override] public function exportPhpCode(): string { - return 'new ' . PHPExport::absolute(self::class) . '()'; + return 'new '.PHPExport::absolute(self::class).'()'; } #[Override] diff --git a/src/Parser/Helpers/Constraints/UppercaseString.php b/src/Parser/Helpers/Constraints/UppercaseString.php index 6096e33..d4db8d2 100644 --- a/src/Parser/Helpers/Constraints/UppercaseString.php +++ b/src/Parser/Helpers/Constraints/UppercaseString.php @@ -1,4 +1,6 @@ -isString($value, $context)) { + if (! $this->isString($value, $context)) { return false; } @@ -27,9 +29,10 @@ public function validate(mixed $value, ExecutionContext $context): bool $context->addIssue(new Issue( IssueMessage::NOT_UPPERCASE_STRING, [ - "message" => "Expected uppercase string, got: '{$value}'", + 'message' => "Expected uppercase string, got: '{$value}'", ] )); + return false; } @@ -39,7 +42,7 @@ public function validate(mixed $value, ExecutionContext $context): bool #[Override] public function exportPhpCode(): string { - return 'new ' . PHPExport::absolute(self::class) . '()'; + return 'new '.PHPExport::absolute(self::class).'()'; } #[Override] diff --git a/src/Parser/Helpers/Constraints/ValidatesString.php b/src/Parser/Helpers/Constraints/ValidatesString.php index 0e10d27..37949f2 100644 --- a/src/Parser/Helpers/Constraints/ValidatesString.php +++ b/src/Parser/Helpers/Constraints/ValidatesString.php @@ -1,4 +1,6 @@ -addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => "Expected string, got: " . gettype($value), + 'message' => 'Expected string, got: '.gettype($value), ], )); + return false; } } diff --git a/src/Parser/Helpers/Consumers/AliasConsumer.php b/src/Parser/Helpers/Consumers/AliasConsumer.php index ecefe01..fdf2cbd 100644 --- a/src/Parser/Helpers/Consumers/AliasConsumer.php +++ b/src/Parser/Helpers/Consumers/AliasConsumer.php @@ -1,4 +1,6 @@ -currentTokenIs(TokenType::IDENTIFIER)) { + if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { return false; } $token = $state->current(); + return $state->context->isLocalType($token->value) || $state->context->isImportedType($token->value) || $state->context->isGeneric($token->value) @@ -45,17 +47,20 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface if ($this->globalTypeAliases->isGlobalAlias($token->value)) { $state->advance(); + return $this->globalTypeAliases->getGlobalAlias($token->value); } if ($state->context->isGeneric($token->value)) { $state->advance(); + return $state->context->getGeneric($token->value); } // Recursive support for locally defined types using @phpstan-type. if ($state->context->isLocalType($token->value)) { $state->advance(); + return $parser->parse( $state->context->getLocalTypeDefinition($token->value), $state->context, @@ -67,12 +72,13 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); $importDefinition = $state->context->getImportedTypeInfo($token->value); + return $parser->parse( $importDefinition['typeName'], ParsingScope::fromClassString($importDefinition['className']), ); } - $state->produceSyntaxError("Expected Alias"); + $state->produceSyntaxError('Expected Alias'); } } diff --git a/src/Parser/Helpers/Consumers/ArrayConsumer.php b/src/Parser/Helpers/Consumers/ArrayConsumer.php index 2a5a956..288ed59 100644 --- a/src/Parser/Helpers/Consumers/ArrayConsumer.php +++ b/src/Parser/Helpers/Consumers/ArrayConsumer.php @@ -1,4 +1,6 @@ -currentTokenIs(TokenType::IDENTIFIER)) { + if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { return false; } @@ -61,8 +62,8 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface }; $isNonEmpty = $keyword === 'non-empty-list' || $keyword === 'non-empty-array'; - if (!$state->current()->is(TokenType::IDENTIFIER)) { - $state->produceSyntaxError("Expected Array Type Identifier: array or list"); + if (! $state->current()->is(TokenType::IDENTIFIER)) { + $state->produceSyntaxError('Expected Array Type Identifier: array or list'); } // Handle array structures. @@ -77,7 +78,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface return $this->consumeTuple($state, $parser); } - $state->produceSyntaxError("Expected array{key: type, ...} or array{key: type, ...} syntax"); + $state->produceSyntaxError('Expected array{key: type, ...} or array{key: type, ...} syntax'); } $maxGenerics = $type === 'list' ? 1 : 2; @@ -89,7 +90,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface // keys - modelling it as a list is not a widening but a different type, and serialization // would silently reindex a keyed array. Bare `object` and `iterable` already fail here, // so this does too rather than emit Array and drop keys on the way out. - if (!$state->currentTokenIs(TokenType::LT)) { + if (! $state->currentTokenIs(TokenType::LT)) { $state->produceSyntaxError( "Bare '{$keyword}' has no single representation. Write list, array or array." ); @@ -130,13 +131,13 @@ private function applyEmptiness(RecordNode|ListNode $node, bool $isNonEmpty): No */ private function consumeIntegerDeterminedTuple(ParserState $state, TypeParser $parser): TupleNode { - if (!$state->currentTokenIs(TokenType::IDENTIFIER, 'array')) { - $state->produceSyntaxError("Expected array"); + if (! $state->currentTokenIs(TokenType::IDENTIFIER, 'array')) { + $state->produceSyntaxError('Expected array'); } $state->advance(); - if (!$state->currentTokenIs(TokenType::LBRACE)) { - $state->produceSyntaxError("Expected {"); + if (! $state->currentTokenIs(TokenType::LBRACE)) { + $state->produceSyntaxError('Expected {'); } $state->advance(); @@ -153,18 +154,19 @@ private function consumeIntegerDeterminedTuple(ParserState $state, TypeParser $p if ($state->currentTokenIs(TokenType::COMMA)) { $state->advance(); + continue; } // Compares the raw lexeme, so exotic spellings such as array{+0: string} // are intentionally not accepted here. - if (!$state->currentTokenIs(TokenType::INT, (string)count($types))) { - $state->produceSyntaxError("Expected int with value " . count($types)); + if (! $state->currentTokenIs(TokenType::INT, (string) count($types))) { + $state->produceSyntaxError('Expected int with value '.count($types)); } $state->advance(); - if (!$state->currentTokenIs(TokenType::COLON)) { - $state->produceSyntaxError("Expected colon"); + if (! $state->currentTokenIs(TokenType::COLON)) { + $state->produceSyntaxError('Expected colon'); } $state->advance(); $types[] = $parser->consume($state, TokenType::COMMA, TokenType::RBRACE); @@ -183,13 +185,13 @@ private function consumeIntegerDeterminedTuple(ParserState $state, TypeParser $p */ private function consumeTuple(ParserState $state, TypeParser $parser): TupleNode { - if (!$state->currentTokenIs(TokenType::IDENTIFIER, 'array')) { - $state->produceSyntaxError("Expected array"); + if (! $state->currentTokenIs(TokenType::IDENTIFIER, 'array')) { + $state->produceSyntaxError('Expected array'); } $state->advance(); - if (!$state->currentTokenIs(TokenType::LBRACE)) { - $state->produceSyntaxError("Expected {"); + if (! $state->currentTokenIs(TokenType::LBRACE)) { + $state->produceSyntaxError('Expected {'); } $state->advance(); @@ -206,8 +208,8 @@ private function consumeTuple(ParserState $state, TypeParser $parser): TupleNode break; } - if (!$state->currentTokenIs(TokenType::COMMA)) { - $state->produceSyntaxError("Expected comma for union: array{string, int}"); + if (! $state->currentTokenIs(TokenType::COMMA)) { + $state->produceSyntaxError('Expected comma for union: array{string, int}'); } $state->advance(); } @@ -219,4 +221,4 @@ private function consumeTuple(ParserState $state, TypeParser $parser): TupleNode return new TupleNode($types); } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php b/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php index 3ffff1c..b76272d 100644 --- a/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php +++ b/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php @@ -1,4 +1,6 @@ -currentTokenIs(TokenType::IDENTIFIER)) { + if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { return false; } @@ -58,7 +59,7 @@ public function canConsume(ParserState $state): bool 'scalar', 'positive-int', 'negative-int', - "non-negative-int", + 'non-negative-int', 'non-positive-int', 'numeric', ], true); @@ -125,7 +126,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface new IntNode(), [new IntRange(max: -1)] ), - "non-negative-int" => new ConstraintNode( + 'non-negative-int' => new ConstraintNode( new IntNode(), [new IntRange(min: 0)] ), @@ -137,7 +138,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface new IntNode(), new FloatNode(), ]), - default => $state->produceSyntaxError('Expected valid built-in type, got ' . $token->value), + default => $state->produceSyntaxError('Expected valid built-in type, got '.$token->value), }; } } diff --git a/src/Parser/Helpers/Consumers/ClassConstConsumer.php b/src/Parser/Helpers/Consumers/ClassConstConsumer.php index 0b2115c..f772946 100644 --- a/src/Parser/Helpers/Consumers/ClassConstConsumer.php +++ b/src/Parser/Helpers/Consumers/ClassConstConsumer.php @@ -1,4 +1,6 @@ -current()->value; $fqcn = $state->context->toFullyQualifiedClassName($className); - if (!class_exists($fqcn) && !interface_exists($fqcn)) { + if (! class_exists($fqcn) && ! interface_exists($fqcn)) { $state->produceSyntaxError("Class {$fqcn} does not exist."); } try { $reflection = new ReflectionClass($fqcn); - if (!$reflection->hasConstant($constOrEnumCase)) { + if (! $reflection->hasConstant($constOrEnumCase)) { $state->produceSyntaxError("Class {$fqcn} has no constant or enum case {$constOrEnumCase}"); } @@ -61,7 +62,7 @@ public function consume(ParserState $state, TypeParser $parser): LiteralNode } catch (InvalidSyntaxException $exception) { throw $exception; } catch (Throwable $exception) { - $state->produceSyntaxError("Could not identify class const or enum", $exception); + $state->produceSyntaxError('Could not identify class const or enum', $exception); } } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Consumers/DateTimeConsumer.php b/src/Parser/Helpers/Consumers/DateTimeConsumer.php index 2376d17..54bdfc3 100644 --- a/src/Parser/Helpers/Consumers/DateTimeConsumer.php +++ b/src/Parser/Helpers/Consumers/DateTimeConsumer.php @@ -1,4 +1,6 @@ -currentTokenIs(TokenType::IDENTIFIER)) { + if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { return false; } diff --git a/src/Parser/Helpers/Consumers/EnumConsumer.php b/src/Parser/Helpers/Consumers/EnumConsumer.php index 18bab39..d56f21d 100644 --- a/src/Parser/Helpers/Consumers/EnumConsumer.php +++ b/src/Parser/Helpers/Consumers/EnumConsumer.php @@ -1,4 +1,6 @@ -currentTokenIs(TokenType::IDENTIFIER)) { + if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { return false; } diff --git a/src/Parser/Helpers/Consumers/IntConsumer.php b/src/Parser/Helpers/Consumers/IntConsumer.php index 30c9e75..b815d61 100644 --- a/src/Parser/Helpers/Consumers/IntConsumer.php +++ b/src/Parser/Helpers/Consumers/IntConsumer.php @@ -1,4 +1,6 @@ -advance(); - if (!$state->currentTokenIs(TokenType::LT)) { + if (! $state->currentTokenIs(TokenType::LT)) { return new IntNode(); } @@ -46,8 +48,8 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface }; $state->advance(); - if (!$state->currentTokenIs(TokenType::COMMA)) { - $state->produceSyntaxError("Expected comma"); + if (! $state->currentTokenIs(TokenType::COMMA)) { + $state->produceSyntaxError('Expected comma'); } $state->advance(); @@ -58,8 +60,8 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface }; $state->advance(); - if (!$state->current()->is(TokenType::GT)) { - $state->produceSyntaxError("Expected >"); + if (! $state->current()->is(TokenType::GT)) { + $state->produceSyntaxError('Expected >'); } $state->advance(); @@ -69,4 +71,4 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface [new IntRange($min, $max)] ); } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Consumers/InteractsWithGenerics.php b/src/Parser/Helpers/Consumers/InteractsWithGenerics.php index 881595e..cb313be 100644 --- a/src/Parser/Helpers/Consumers/InteractsWithGenerics.php +++ b/src/Parser/Helpers/Consumers/InteractsWithGenerics.php @@ -1,4 +1,6 @@ - + * + * @throws InvalidSyntaxException */ private function consumeGenerics(ParserState $state, TypeParser $parser, ?int $min = null, ?int $max = null): array { @@ -21,10 +23,11 @@ private function consumeGenerics(ParserState $state, TypeParser $parser, ?int $m $generics = []; // No Generics - if (!$isGenericBlock) { + if (! $isGenericBlock) { if (isset($min)) { $state->produceSyntaxError("Expected at least {$min} generics, got 0."); } + return []; } @@ -36,25 +39,24 @@ private function consumeGenerics(ParserState $state, TypeParser $parser, ?int $m $generics[] = $parser->consume($state, TokenType::COMMA, TokenType::GT); } - if (!$state->currentTokenIs(TokenType::GT)) { + if (! $state->currentTokenIs(TokenType::GT)) { $state->produceSyntaxError("Expected '>' to end generics"); } if (count($generics) === 0) { - $state->produceSyntaxError("Expected at least one generic type, got none"); + $state->produceSyntaxError('Expected at least one generic type, got none'); } if (isset($min) && count($generics) < $min) { - $state->produceSyntaxError("Expected at least {$min} generic type(s), got " . count($generics)); + $state->produceSyntaxError("Expected at least {$min} generic type(s), got ".count($generics)); } if (isset($max) && count($generics) > $max) { - $state->produceSyntaxError("Expected at most {$max} generic type(s), got " . count($generics)); + $state->produceSyntaxError("Expected at most {$max} generic type(s), got ".count($generics)); } $state->advance(); return $generics; } - -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Consumers/LiteralConsumer.php b/src/Parser/Helpers/Consumers/LiteralConsumer.php index ce84135..3864e78 100644 --- a/src/Parser/Helpers/Consumers/LiteralConsumer.php +++ b/src/Parser/Helpers/Consumers/LiteralConsumer.php @@ -1,4 +1,6 @@ -currentTokenIs(TokenType::IDENTIFIER, 'object')) { return true; } - + // The peeks are null safe: a truncated `array{` used to crash here. return $state->currentTokenIs(TokenType::IDENTIFIER, 'array') && $state->peek(1)?->is(TokenType::LBRACE) === true @@ -39,10 +40,9 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface { $structType = StructPhpType::from($state->current()->value); $state->advance(); - - - if (!$state->current()->is(TokenType::LBRACE)) { - $state->produceSyntaxError("Expected brace"); + + if (! $state->current()->is(TokenType::LBRACE)) { + $state->produceSyntaxError('Expected brace'); } $state->advance(); @@ -56,13 +56,13 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $name = match (true) { $key->is(TokenType::IDENTIFIER) => $key->value, $key->is(TokenType::STRING) => Lexemes::decodeString($key->value), - default => $state->produceSyntaxError("Expected identifier"), + default => $state->produceSyntaxError('Expected identifier'), }; $state->advance(); $isOptional = $this->consumeOptionalObjectKey($state); - if (!$state->current()->is(TokenType::COLON)) { - $state->produceSyntaxError("Expected colon"); + if (! $state->current()->is(TokenType::COLON)) { + $state->produceSyntaxError('Expected colon'); } $state->advance(); @@ -81,16 +81,17 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); } - if (!$state->current()->is(TokenType::RBRACE)) { - $state->produceSyntaxError("Expected brace"); + if (! $state->current()->is(TokenType::RBRACE)) { + $state->produceSyntaxError('Expected brace'); } if (count($properties) === 0) { - $state->produceSyntaxError("Expected properties"); + $state->produceSyntaxError('Expected properties'); } // We move out of the object $state->advance(); + return new StructNode($structType, $properties); } @@ -98,8 +99,10 @@ private function consumeOptionalObjectKey(ParserState $state): bool { if ($state->current()->is(TokenType::QUESTION_MARK)) { $state->advance(); + return true; } + return false; } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php index d8708a6..9b9fd40 100644 --- a/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php @@ -1,4 +1,6 @@ -currentTokenIs(TokenType::IDENTIFIER)) { + if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { return false; } $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($state->current()->value); - if (!class_exists($fullyQualifiedClassName) && !interface_exists($fullyQualifiedClassName)) { + if (! class_exists($fullyQualifiedClassName) && ! interface_exists($fullyQualifiedClassName)) { return false; } @@ -63,10 +64,11 @@ private function determineCastingStrategy(ReflectionClass $class): ObjectCastStr if ($attributes->has(Castable::class)) { $instance = $attributes->getSingleInstance(Castable::class); + return $instance->strategy ?? $this->findCastingStrategy($class); } - if (!$this->allowAllObjectCasting) { + if (! $this->allowAllObjectCasting) { return ObjectCastStrategy::NEVER; } @@ -74,8 +76,7 @@ private function determineCastingStrategy(ReflectionClass $class): ObjectCastStr } /** - * @param ReflectionClass $class - * @return ObjectCastStrategy + * @param ReflectionClass $class */ private function findCastingStrategy(ReflectionClass $class): ObjectCastStrategy { @@ -130,8 +131,8 @@ private function allowsOptional(ReflectionProperty|ReflectionParameter $param): } $type = $param->getType(); - if ($type === null || !$type->allowsNull()) { - throw new ParserException("Optional parameter must allow null or provide a default value. PHP does not difference between null and undefined."); + if ($type === null || ! $type->allowsNull()) { + throw new ParserException('Optional parameter must allow null or provide a default value. PHP does not difference between null and undefined.'); } return true; @@ -141,7 +142,7 @@ private function allowsOptional(ReflectionProperty|ReflectionParameter $param): private function parseNeverStrategy(ReflectionClass $reflectionClass, TypeParser $parser, ParsingScope $context): CustomCastingNode { $properties = array_map( - fn(ReflectionProperty $property) => new PropertyNode( + fn (ReflectionProperty $property) => new PropertyNode( $property->getName(), $parser->parse( TypeReflector::reflectProperty($property), @@ -191,7 +192,8 @@ private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, Ty } /** - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass + * * @throws InvalidSyntaxException */ private function parseConstructorStrategy(ReflectionClass $reflectionClass, TypeParser $parser, ParsingScope $context): CustomCastingNode @@ -220,10 +222,11 @@ private function parseConstructorStrategy(ReflectionClass $reflectionClass, Type foreach ($reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { if ($property->isPromoted()) { - $index = array_find_key($structProperties, fn(PropertyNode $propertyNode) => $propertyNode->name === $property->getName()); + $index = array_find_key($structProperties, fn (PropertyNode $propertyNode) => $propertyNode->name === $property->getName()); if ($index !== null) { $structProperties[$index] = $structProperties[$index]->changePropertyType(PropertyType::BOTH); } + continue; } @@ -244,4 +247,4 @@ private function parseConstructorStrategy(ReflectionClass $reflectionClass, Type ObjectCastStrategy::CONSTRUCTOR, ); } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Consumers/UtilsConsumer.php b/src/Parser/Helpers/Consumers/UtilsConsumer.php index 3ca8e30..13aef9d 100644 --- a/src/Parser/Helpers/Consumers/UtilsConsumer.php +++ b/src/Parser/Helpers/Consumers/UtilsConsumer.php @@ -1,4 +1,6 @@ -literalStringValue($state, $formatNode, 'date format'), @@ -65,7 +68,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface // Docblocks cannot carry #[Named], so the utility is the shorthand for brand + name: // the use site references the alias (Token), which resolves to `(string & Brand<"token">)`. - if (!Syntax::isValidIdentifier($brand)) { + if (! Syntax::isValidIdentifier($brand)) { throw InvalidStringLiteralException::notAValidTypescriptIdentifier($brand, "{$type}<'{$brand}'>"); } @@ -82,59 +85,58 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface // shape, so the alias and brand are dropped along the way. $nodeToPickFrom = Nodes::getDeclaringNode($nodeToPickFrom); - if (!$nodeToPickFrom instanceof StructNode && !$nodeToPickFrom instanceof CustomCastingNode) { - $state->produceSyntaxError("Expected struct or custom casting node for picking or omitting"); + if (! $nodeToPickFrom instanceof StructNode && ! $nodeToPickFrom instanceof CustomCastingNode) { + $state->produceSyntaxError('Expected struct or custom casting node for picking or omitting'); } if ($nodeToPickFrom instanceof CustomCastingNode) { // Only a struct has properties to pick from; a custom cast over a list or a record has // no named shape to narrow. $castFrom = $nodeToPickFrom->node; - if (!$castFrom instanceof StructNode) { - $state->produceSyntaxError("Cannot pick or omit from a custom casting node that does not wrap a struct"); + if (! $castFrom instanceof StructNode) { + $state->produceSyntaxError('Cannot pick or omit from a custom casting node that does not wrap a struct'); } // Picking from a castable object produces a new shape, so it is rebuilt as a plain // object struct with both directions enabled. $structNode = $castFrom - ->filter(fn(PropertyNode $propertyNode): bool => $propertyNode->propertyType->isOutput()) - ->map(fn(PropertyNode $propertyType) => $propertyType->changePropertyType(PropertyType::BOTH)) + ->filter(fn (PropertyNode $propertyNode): bool => $propertyNode->propertyType->isOutput()) + ->map(fn (PropertyNode $propertyType) => $propertyType->changePropertyType(PropertyType::BOTH)) ->ofType(StructPhpType::OBJECT); } else { $structNode = $nodeToPickFrom; } return $structNode->filter( - fn(PropertyNode $property): bool => match ($type) { + fn (PropertyNode $property): bool => match ($type) { 'Pick' => in_array($property->name, $this->propertiesToPickOrOmit($state, $pick), true), - 'Omit' => !in_array($property->name, $this->propertiesToPickOrOmit($state, $pick), true), - default => $state->produceSyntaxError("Expected Pick or Omit"), + 'Omit' => ! in_array($property->name, $this->propertiesToPickOrOmit($state, $pick), true), + default => $state->produceSyntaxError('Expected Pick or Omit'), } ); } - /** - * @param string $usage Named in the error message so it points at the utility type that failed. + * @param string $usage Named in the error message so it points at the utility type that failed. + * * @throws InvalidSyntaxException */ private function literalStringValue(ParserState $state, NodeInterface $node, string $usage): string { - if (!$node instanceof LiteralNode || $node->type !== LiteralType::STRING) { - $state->produceSyntaxError("Expected literal string value for {$usage}, got: " . $node::class); + if (! $node instanceof LiteralNode || $node->type !== LiteralType::STRING) { + $state->produceSyntaxError("Expected literal string value for {$usage}, got: ".$node::class); } - if (!is_string($node->value)) { - $state->produceSyntaxError("Expected literal string value for {$usage}, got: " . gettype($node->value)); + if (! is_string($node->value)) { + $state->produceSyntaxError("Expected literal string value for {$usage}, got: ".gettype($node->value)); } return $node->value; } /** - * @param ParserState $state - * @param NodeInterface $node * @return list + * * @throws InvalidSyntaxException */ private function propertiesToPickOrOmit(ParserState $state, NodeInterface $node): array @@ -143,8 +145,8 @@ private function propertiesToPickOrOmit(ParserState $state, NodeInterface $node) return [$node->stringValue()]; } - if (!$node instanceof UnionNode) { - $state->produceSyntaxError("Expected union node or string literal for picking or omitting"); + if (! $node instanceof UnionNode) { + $state->produceSyntaxError('Expected union node or string literal for picking or omitting'); } return array_map(function (NodeInterface $node) use ($state): string { @@ -156,4 +158,4 @@ private function propertiesToPickOrOmit(ParserState $state, NodeInterface $node) $state->produceSyntaxError("Expected string literal for picking or omitting, got: {$type}"); }, $node->nodes); } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Consumers/ValueObjectConsumer.php b/src/Parser/Helpers/Consumers/ValueObjectConsumer.php index 89fefe2..d19a17a 100644 --- a/src/Parser/Helpers/Consumers/ValueObjectConsumer.php +++ b/src/Parser/Helpers/Consumers/ValueObjectConsumer.php @@ -1,4 +1,6 @@ -currentTokenIs(TokenType::IDENTIFIER)) { + if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { return false; } @@ -55,7 +57,7 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface ); } - if (!class_exists($fullyQualifiedClassName) && !interface_exists($fullyQualifiedClassName)) { + if (! class_exists($fullyQualifiedClassName) && ! interface_exists($fullyQualifiedClassName)) { $state->produceSyntaxError("Value object {$fullyQualifiedClassName} does not exist."); } diff --git a/src/Parser/Helpers/ParserState.php b/src/Parser/Helpers/ParserState.php index 898b647..3a0bd27 100644 --- a/src/Parser/Helpers/ParserState.php +++ b/src/Parser/Helpers/ParserState.php @@ -1,4 +1,6 @@ - */ private readonly array $tokens; /** - * @param string $input - * @param non-empty-list $tokens The raw, lossless token stream. - * @param ParsingScope $context + * @param non-empty-list $tokens The raw, lossless token stream. */ public function __construct( - public readonly string $input, - array $tokens, + public readonly string $input, + array $tokens, public readonly ParsingScope $context, - ) - { + ) { $significant = array_values( - array_filter($tokens, static fn(Token $token): bool => $token->type !== TokenType::WHITESPACE) + array_filter($tokens, static fn (Token $token): bool => $token->type !== TokenType::WHITESPACE) ); // The Lexer always terminates the stream with EOF, which is never whitespace. @@ -94,7 +94,7 @@ public function canAdvance(int $amount = 1): bool public function advance(int $amount = 1): void { - if (!$this->canAdvance($amount)) { + if (! $this->canAdvance($amount)) { throw new ParserException('Cannot advance past end of token'); } $this->currentIndex += $amount; diff --git a/src/Parser/Helpers/ParsingScope.php b/src/Parser/Helpers/ParsingScope.php index 7e7579a..0c8fbdb 100644 --- a/src/Parser/Helpers/ParsingScope.php +++ b/src/Parser/Helpers/ParsingScope.php @@ -1,4 +1,6 @@ - $usedNamespaceMap - * @param array $localTypes - * @param array $importedTypes - * @param array $generics + * @param array $usedNamespaceMap + * @param array $localTypes + * @param array $importedTypes + * @param array $generics */ public function __construct( public ?string $namespace = null, - public array $usedNamespaceMap = [], - public array $localTypes = [], - public array $importedTypes = [], - public array $generics = [], + public array $usedNamespaceMap = [], + public array $localTypes = [], + public array $importedTypes = [], + public array $generics = [], public ?string $declaredInClass = null, - ) - { + ) { } /** * Given an identifier, returns the fully qualified class name without leading backslash. - * @param string $className - * @return string */ public function toFullyQualifiedClassName(string $className): string { @@ -64,7 +62,7 @@ public function isLocalType(string $typeName): bool */ public function getLocalTypeDefinition(string $typeName): string { - if (!$this->isLocalType($typeName)) { + if (! $this->isLocalType($typeName)) { throw new ParserException("Type definition for {$typeName} not found"); } @@ -77,12 +75,11 @@ public function isImportedType(string $typeName): bool } /** - * @param string $typeName * @return ImportedType */ public function getImportedTypeInfo(string $typeName): array { - if (!$this->isImportedType($typeName)) { + if (! $this->isImportedType($typeName)) { throw new ParserException("Type definition for {$typeName} not found"); } @@ -103,12 +100,13 @@ public function descendIntoDeclaringClass(ReflectionProperty|ReflectionParameter } /** - * @param list $generics + * @param list $generics + * * @throws ReflectionException */ public static function fromClassString(string $classString, array $generics = []): self { - if (!class_exists($classString) && !interface_exists($classString)) { + if (! class_exists($classString) && ! interface_exists($classString)) { throw new ParserException("Cannot build a parsing context for unknown class {$classString}."); } @@ -116,9 +114,8 @@ public static function fromClassString(string $classString, array $generics = [] } /** - * @param ReflectionClass $class - * @param list $generics - * @return self + * @param ReflectionClass $class + * @param list $generics */ public static function fromReflectionClass(ReflectionClass $class, array $generics = []): self { @@ -144,7 +141,8 @@ public static function fromReflectionClass(ReflectionClass $class, array $generi } /** - * @param array $generics + * @param array $generics + * * @throws ReflectionException */ public static function fromFilePath(string $filePath, array $generics = []): self @@ -165,21 +163,19 @@ public static function fromFilePath(string $filePath, array $generics = []): sel } /** - * @param false|string|null $docBlock - * @param string|null $namespace - * @param array $usedNamespaces + * @param array $usedNamespaces * @return array */ private static function findFullyQualifiedImportedTypes(null|false|string $docBlock, ?string $namespace, array $usedNamespaces): array { - return array_map(fn(array $import) => [ + return array_map(fn (array $import) => [ 'typeName' => $import['typeName'], 'className' => Utils\Namespaces::toFullyQualifiedClassName($import['className'], $namespace, $usedNamespaces), ], Utils\PhpDoc::findImportedTypeDefinition($docBlock)); } /** - * @param NodeInterface[] $generics + * @param NodeInterface[] $generics * @return array */ private static function assignGenerics(null|false|string $docBlock, array $generics): array @@ -197,6 +193,7 @@ private static function assignGenerics(null|false|string $docBlock, array $gener foreach ($declaredGenerics as $index => $genericName) { $assignedGenerics[$genericName] = $generics[$index]; } + return $assignedGenerics; } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Registry/CachedTypeRegistry.php b/src/Parser/Helpers/Registry/CachedTypeRegistry.php index 95c82b0..a00c029 100644 --- a/src/Parser/Helpers/Registry/CachedTypeRegistry.php +++ b/src/Parser/Helpers/Registry/CachedTypeRegistry.php @@ -1,4 +1,6 @@ - $factory The array form is - * the format written before schema identity was fixed and is rejected: such a cache can - * silently merge schemas that differ only in their constraints. + * @param Closure(string, self): NodeInterface|array $factory The array form is + * the format written before schema identity was fixed and is rejected: such a cache can + * silently merge schemas that differ only in their constraints. */ public function __construct( Closure|array $factory, - ) - { - if (!$factory instanceof Closure) { + ) { + if (! $factory instanceof Closure) { throw UnknownTypeKeyException::forLegacyCacheShape(); } diff --git a/src/Parser/Lexer/Exceptions/UnexpectedCharacterException.php b/src/Parser/Lexer/Exceptions/UnexpectedCharacterException.php index aa632b3..95c5332 100644 --- a/src/Parser/Lexer/Exceptions/UnexpectedCharacterException.php +++ b/src/Parser/Lexer/Exceptions/UnexpectedCharacterException.php @@ -1,4 +1,6 @@ - + * * @throws UnexpectedCharacterException */ public function tokenize(string $input): array @@ -31,7 +34,7 @@ public function tokenize(string $input): array $matches = []; $result = preg_match_all(self::pattern(), $input, $matches, PREG_SET_ORDER); if ($result === false) { - throw new ParserException("Failed to tokenize input: " . preg_last_error_msg()); + throw new ParserException('Failed to tokenize input: '.preg_last_error_msg()); } $marks = self::marks(); @@ -51,6 +54,7 @@ public function tokenize(string $input): array } $tokens[] = new Token(TokenType::EOF, '', $offset); + return $tokens; } @@ -73,7 +77,7 @@ private static function pattern(): string $alternatives[] = "(?:{$pattern})(*MARK:{$case->name})"; } - return self::$pattern = '~' . implode('|', $alternatives) . '~Ai'; + return self::$pattern = '~'.implode('|', $alternatives).'~Ai'; } /** diff --git a/src/Parser/Lexer/SourceLocation.php b/src/Parser/Lexer/SourceLocation.php index aa1ab51..1179b7e 100644 --- a/src/Parser/Lexer/SourceLocation.php +++ b/src/Parser/Lexer/SourceLocation.php @@ -1,4 +1,6 @@ -column - 1) . str_repeat('^', max(1, $length)), + str_repeat(' ', $this->column - 1).str_repeat('^', max(1, $length)), ]); } } diff --git a/src/Parser/Lexer/Token.php b/src/Parser/Lexer/Token.php index 625ec20..df8d85c 100644 --- a/src/Parser/Lexer/Token.php +++ b/src/Parser/Lexer/Token.php @@ -1,4 +1,6 @@ - $constraints + * @param list $constraints */ public function __construct( public NodeInterface $node, - public array $constraints, - ) - { + public array $constraints, + ) { } public function areConstraintsFulfilled(mixed $value, ExecutionContext $context): bool { - return array_all($this->constraints, fn(Constraint $constraint) => $constraint->validate($value, $context)); + return array_all($this->constraints, fn (Constraint $constraint) => $constraint->validate($value, $context)); } #[Override] @@ -37,7 +37,7 @@ public function __toString(): string // Each constraint names its own bounds, so `int<0, 100>` reads as `int & IntRange(0, 100)` // rather than losing the numbers to a bare class name. $names = implode(', ', array_map( - static fn(Constraint $constraint): string => (string)$constraint, + static fn (Constraint $constraint): string => (string) $constraint, $this->constraints, )); @@ -54,6 +54,7 @@ public function exportPhpCode(): string $className = PHPExport::absolute(self::class); $node = $this->node->exportPhpCode(); $constraints = PHPExport::exportArray($this->constraints); + return "new {$className}({$node},{$constraints})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/CustomCastingNode.php b/src/Parser/Nodes/CustomCastingNode.php index 5b6b4e7..994092f 100644 --- a/src/Parser/Nodes/CustomCastingNode.php +++ b/src/Parser/Nodes/CustomCastingNode.php @@ -1,4 +1,6 @@ -fullyQualifiedCastingClass) . '::class'; + $fullyQualifiedCastingClass = PHPExport::absolute($this->fullyQualifiedCastingClass).'::class'; $strategy = PHPExport::exportEnumCase($this->strategy); + return "new {$className}({$this->node->exportPhpCode()}, {$fullyQualifiedCastingClass}, {$strategy})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/Data/BackingType.php b/src/Parser/Nodes/Data/BackingType.php index 8fcd08a..cf60e91 100644 --- a/src/Parser/Nodes/Data/BackingType.php +++ b/src/Parser/Nodes/Data/BackingType.php @@ -1,4 +1,6 @@ - LiteralType::FLOAT, 'integer' => LiteralType::INT, diff --git a/src/Parser/Nodes/Data/NamedType.php b/src/Parser/Nodes/Data/NamedType.php index 6e1de8c..0ead9dd 100644 --- a/src/Parser/Nodes/Data/NamedType.php +++ b/src/Parser/Nodes/Data/NamedType.php @@ -1,4 +1,6 @@ - true, default => false, }; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/Data/StructPhpType.php b/src/Parser/Nodes/Data/StructPhpType.php index 055e2e3..d38f286 100644 --- a/src/Parser/Nodes/Data/StructPhpType.php +++ b/src/Parser/Nodes/Data/StructPhpType.php @@ -1,4 +1,6 @@ - $input + * @param array $input * @return array|stdClass */ public function coerceFromArray(array $input): array|stdClass { - return $this === self::ARRAY ? $input : (object)$input; + return $this === self::ARRAY ? $input : (object) $input; } } diff --git a/src/Parser/Nodes/IntersectionNode.php b/src/Parser/Nodes/IntersectionNode.php index f05fd11..c171959 100644 --- a/src/Parser/Nodes/IntersectionNode.php +++ b/src/Parser/Nodes/IntersectionNode.php @@ -1,4 +1,6 @@ - $nodes + * @param list $nodes */ public function __construct( public array $nodes, - ) - { + ) { } #[Override] @@ -33,12 +34,12 @@ public function __toString(): string #[Override] public function validate(): void { - if (!Nodes::areAllNodesOfSameStructType($this->nodes)) { - throw new ParserException("All nodes need to be of the same struct type."); + if (! Nodes::areAllNodesOfSameStructType($this->nodes)) { + throw new ParserException('All nodes need to be of the same struct type.'); } if (count($this->nodes) < 2) { - throw new ParserException("An intersection must be between at least two struct nodes."); + throw new ParserException('An intersection must be between at least two struct nodes.'); } } @@ -47,6 +48,7 @@ public function exportPhpCode(): string { $className = PHPExport::absolute($this::class); $nodes = PHPExport::exportArray($this->nodes); + return "new {$className}({$nodes})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/Leaf/BoolNode.php b/src/Parser/Nodes/Leaf/BoolNode.php index 9f73bb0..0d5b4d5 100644 --- a/src/Parser/Nodes/Leaf/BoolNode.php +++ b/src/Parser/Nodes/Leaf/BoolNode.php @@ -1,4 +1,6 @@ - $dateTimeClass - * @param string $format + * @param class-string $dateTimeClass */ public function __construct( public string $dateTimeClass, public string $format = DateTimeInterface::ATOM, - ) - { + ) { } #[Override] public function __toString(): string { - return $this->dateTimeClass . "<{$this->format}>"; + return $this->dateTimeClass."<{$this->format}>"; } #[Override] @@ -40,20 +40,22 @@ public function exportPhpCode(): string $dateTimeClass = PHPExport::absolute($this->dateTimeClass); $format = $this->format === DateTimeInterface::ATOM ? '' - : ',' . PHPExport::export($this->format); + : ','.PHPExport::export($this->format); + return "new {$className}({$dateTimeClass}::class{$format})"; } #[Override] public function parseValue(mixed $value, ExecutionContext $context): DateTimeInterface|Value { - if (!is_string($value)) { + if (! is_string($value)) { $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => "Expected value of type string, got: " . gettype($value), + 'message' => 'Expected value of type string, got: '.gettype($value), ] )); + return Value::INVALID; } @@ -71,6 +73,7 @@ public function parseValue(mixed $value, ExecutionContext $context): DateTimeInt 'message' => "Expected a date string of format '{$this->format}', got: {$value}", ] )); + return Value::INVALID; } @@ -79,6 +82,7 @@ public function parseValue(mixed $value, ExecutionContext $context): DateTimeInt return $this->dateTimeClass::createFromInterface($parsed); } catch (Throwable $exception) { $context->addIssue(Issue::fromThrowable($exception)); + return Value::INVALID; } } @@ -89,16 +93,17 @@ public function parseValue(mixed $value, ExecutionContext $context): DateTimeInt #[Override] public function serializeValue(mixed $value, ExecutionContext $context): string|Value { - if (!$value instanceof DateTimeInterface) { + if (! $value instanceof DateTimeInterface) { $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => "Expected instance of DateTimeInterface, got: " . gettype($value), + 'message' => 'Expected instance of DateTimeInterface, got: '.gettype($value), ], )); + return Value::INVALID; } + return $value->format($this->format); } - -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/Leaf/EnumNode.php b/src/Parser/Nodes/Leaf/EnumNode.php index d5d3201..83bf604 100644 --- a/src/Parser/Nodes/Leaf/EnumNode.php +++ b/src/Parser/Nodes/Leaf/EnumNode.php @@ -1,4 +1,6 @@ - */ private array $cases; /** - * @param class-string $enumClassName + * @param class-string $enumClassName */ public function __construct( public readonly string $enumClassName, - ) - { + ) { } #[Override] @@ -37,6 +38,7 @@ public function exportPhpCode(): string { $enumClass = PHPExport::absolute($this->enumClassName); $className = PHPExport::absolute(self::class); + return "new {$className}({$enumClass}::class)"; } @@ -44,14 +46,15 @@ public function exportPhpCode(): string public function parseValue(mixed $value, ExecutionContext $context): UnitEnum|Value { /** ToDo: Error handling */ - if (!is_string($value)) { + if (! is_string($value)) { $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - "message" => "Expected string name of enum {$this->enumClassName}, got: " . gettype($value), - "value" => $value, + 'message' => "Expected string name of enum {$this->enumClassName}, got: ".gettype($value), + 'value' => $value, ] )); + return Value::INVALID; } @@ -68,18 +71,20 @@ public function parseValue(mixed $value, ExecutionContext $context): UnitEnum|Va $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - "message" => "Expected string name of enum {$this->enumClassName}, got: '{$value}'", - "value" => $value, + 'message' => "Expected string name of enum {$this->enumClassName}, got: '{$value}'", + 'value' => $value, ] )); + return Value::INVALID; } #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { - if (!is_a($value, $this->enumClassName)) { + if (! is_a($value, $this->enumClassName)) { $context->addIssue(Issue::invalidType($this->enumClassName, $value)); + return Value::INVALID; } diff --git a/src/Parser/Nodes/Leaf/FloatNode.php b/src/Parser/Nodes/Leaf/FloatNode.php index 879e3a5..0db454b 100644 --- a/src/Parser/Nodes/Leaf/FloatNode.php +++ b/src/Parser/Nodes/Leaf/FloatNode.php @@ -1,4 +1,6 @@ -name off a string. * - * @param string|bool|int|float|null|UnitEnum $value + * @param string|bool|int|float|null|UnitEnum $value */ public function __construct( public LiteralType $type, - public mixed $value, - ) - { + public mixed $value, + ) { $agrees = match ($type) { LiteralType::ENUM_CASE => $value instanceof UnitEnum, LiteralType::STRING => is_string($value), @@ -38,9 +39,9 @@ public function __construct( LiteralType::NULL => $value === null, }; - if (!$agrees) { + if (! $agrees) { throw new ParserException( - "Literal of type {$type->value} cannot hold a " . get_debug_type($value) . '.' + "Literal of type {$type->value} cannot hold a ".get_debug_type($value).'.' ); } } @@ -52,6 +53,7 @@ public function __construct( private function enumValue(): UnitEnum { assert($this->value instanceof UnitEnum); + return $this->value; } @@ -62,12 +64,14 @@ private function enumValue(): UnitEnum public function stringValue(): string { assert($this->type === LiteralType::STRING && is_string($this->value)); + return $this->value; } private function scalarValue(): string|int|float { assert(is_string($this->value) || is_int($this->value) || is_float($this->value)); + return $this->value; } @@ -77,11 +81,11 @@ public function __toString(): string return match ($this->type) { LiteralType::BOOL => $this->value ? 'literal' : 'literal', LiteralType::STRING => "literal<'{$this->scalarValue()}'>", - LiteralType::ENUM_CASE => 'enum-value<' . $this->enumValue()->name . '@' . $this->enumValue()::class . '>', + LiteralType::ENUM_CASE => 'enum-value<'.$this->enumValue()->name.'@'.$this->enumValue()::class.'>', LiteralType::NULL => 'literal', LiteralType::INT => "literal<{$this->scalarValue()}>", // Rendered via var_export so 1.0 stays distinguishable from 1. - LiteralType::FLOAT => 'literal<' . var_export($this->value, true) . '>', + LiteralType::FLOAT => 'literal<'.var_export($this->value, true).'>', }; } @@ -93,10 +97,12 @@ public function exportPhpCode(): string if ($this->type === LiteralType::ENUM_CASE) { $enumCase = PHPExport::exportEnumCase($this->enumValue()); + return "new {$className}({$type}, {$enumCase})"; } $value = var_export($this->value, true); + return "new {$className}({$type}, {$value})"; } @@ -108,10 +114,11 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => 'Expected literal value: ' . var_export($this->value, true) - . ', got: ' . get_debug_type($value), + 'message' => 'Expected literal value: '.var_export($this->value, true) + .', got: '.get_debug_type($value), ] )); + return Value::INVALID; } @@ -140,10 +147,11 @@ private function notTheLiteral(mixed $expected, mixed $value, ExecutionContext $ $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ - 'message' => 'Expected literal value: ' . var_export($expected, true) - . ', got: ' . var_export($value, true), + 'message' => 'Expected literal value: '.var_export($expected, true) + .', got: '.var_export($value, true), ] )); + return Value::INVALID; } @@ -157,10 +165,10 @@ public function coerce(mixed $value): mixed default => $value, }, LiteralType::INT => filter_var($value, FILTER_VALIDATE_INT) !== false - ? (int)$value : $value, + ? (int) $value : $value, LiteralType::FLOAT => filter_var($value, FILTER_VALIDATE_INT) !== false || filter_var($value, FILTER_VALIDATE_FLOAT) !== false - ? (float)$value : $value, + ? (float) $value : $value, default => $value, }; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/Leaf/MixedNode.php b/src/Parser/Nodes/Leaf/MixedNode.php index c2e6f6b..56e7bbd 100644 --- a/src/Parser/Nodes/Leaf/MixedNode.php +++ b/src/Parser/Nodes/Leaf/MixedNode.php @@ -1,4 +1,6 @@ -addIssue(Issue::invalidType($expected, $value)); + return Value::INVALID; } } diff --git a/src/Parser/Nodes/Leaf/StringNode.php b/src/Parser/Nodes/Leaf/StringNode.php index 66db4ac..909019c 100644 --- a/src/Parser/Nodes/Leaf/StringNode.php +++ b/src/Parser/Nodes/Leaf/StringNode.php @@ -1,4 +1,6 @@ -addIssue(Issue::fromThrowable($throwable, [ 'node' => self::class, - 'message' => "Failed to serialize value of type: " . gettype($value), + 'message' => 'Failed to serialize value of type: '.gettype($value), 'value' => $value, ])); + return Value::INVALID; } } @@ -58,6 +61,6 @@ public function coerce(mixed $value): mixed // Only scalars are cast: (string) on an array yields the literal "Array" and on a non // Stringable object it throws, and coerce() runs outside the executor's try/catch. Anything // else is handed on untouched so parseValue() reports it as the type error it is. - return is_scalar($value) ? (string)$value : $value; + return is_scalar($value) ? (string) $value : $value; } } diff --git a/src/Parser/Nodes/Leaf/ValueObjectNode.php b/src/Parser/Nodes/Leaf/ValueObjectNode.php index ff3b7a3..f5c5534 100644 --- a/src/Parser/Nodes/Leaf/ValueObjectNode.php +++ b/src/Parser/Nodes/Leaf/ValueObjectNode.php @@ -1,4 +1,6 @@ - $className + * @param class-string $className */ public function __construct( - public string $className, + public string $className, public BackingType $backingType, - ) - { + ) { } #[Override] @@ -56,32 +57,38 @@ public function exportPhpCode(): string public function parseValue(mixed $value, ExecutionContext $context): mixed { if ($this->backingType === BackingType::STRING) { - if (!is_string($value)) { + if (! is_string($value)) { $context->addIssue($this->invalidBackingTypeIssue($value)); + return Value::INVALID; } try { /** @var class-string $className */ $className = $this->className; + return $className::fromStringValue($value); } catch (Throwable $throwable) { $this->addRejectionIssues($throwable, $value, $context); + return Value::INVALID; } } - if (!is_int($value)) { + if (! is_int($value)) { $context->addIssue($this->invalidBackingTypeIssue($value)); + return Value::INVALID; } try { /** @var class-string $className */ $className = $this->className; + return $className::fromIntValue($value); } catch (Throwable $throwable) { $this->addRejectionIssues($throwable, $value, $context); + return Value::INVALID; } } @@ -90,8 +97,9 @@ public function parseValue(mixed $value, ExecutionContext $context): mixed public function serializeValue(mixed $value, ExecutionContext $context): mixed { if ($this->backingType === BackingType::STRING) { - if (!$value instanceof StringValueObject || !is_a($value, $this->className)) { + if (! $value instanceof StringValueObject || ! is_a($value, $this->className)) { $context->addIssue($this->notAnInstanceIssue($value)); + return Value::INVALID; } @@ -99,12 +107,14 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed return $value->toStringValue(); } catch (Throwable $throwable) { $context->addIssue($this->failedToSerializeIssue($throwable)); + return Value::INVALID; } } - if (!$value instanceof IntValueObject || !is_a($value, $this->className)) { + if (! $value instanceof IntValueObject || ! is_a($value, $this->className)) { $context->addIssue($this->notAnInstanceIssue($value)); + return Value::INVALID; } @@ -112,6 +122,7 @@ public function serializeValue(mixed $value, ExecutionContext $context): mixed return $value->toIntValue(); } catch (Throwable $throwable) { $context->addIssue($this->failedToSerializeIssue($throwable)); + return Value::INVALID; } } @@ -122,11 +133,11 @@ public function coerce(mixed $value): mixed if ($this->backingType === BackingType::STRING) { // Unlike StringNode, only scalars are cast: (string) on an array or a non // Stringable object throws, and coerce() runs outside the executor's try/catch. - return is_scalar($value) ? (string)$value : $value; + return is_scalar($value) ? (string) $value : $value; } return filter_var($value, FILTER_VALIDATE_INT) !== false - ? (int)$value + ? (int) $value : $value; } @@ -135,7 +146,7 @@ private function invalidBackingTypeIssue(mixed $value): Issue return new Issue( IssueMessage::INVALID_TYPE, debugInfo: [ - 'message' => "Expected value of type {$this->backingType->value} for {$this->className}, got: " . get_debug_type($value), + 'message' => "Expected value of type {$this->backingType->value} for {$this->className}, got: ".get_debug_type($value), 'node' => self::class, ], ); @@ -160,6 +171,7 @@ private function addRejectionIssues(Throwable $throwable, mixed $value, Executio foreach ($throwable->toIssues($this->rejectionDebugInfo($value, $throwable)) as $issue) { $context->addIssue($issue); } + return; } @@ -187,7 +199,7 @@ private function notAnInstanceIssue(mixed $value): Issue return new Issue( IssueMessage::INVALID_TYPE, debugInfo: [ - 'message' => "Expected instance of {$this->className}, got: " . get_debug_type($value), + 'message' => "Expected instance of {$this->className}, got: ".get_debug_type($value), 'node' => self::class, ], ); diff --git a/src/Parser/Nodes/ListNode.php b/src/Parser/Nodes/ListNode.php index e0f22dd..dafe8f6 100644 --- a/src/Parser/Nodes/ListNode.php +++ b/src/Parser/Nodes/ListNode.php @@ -1,4 +1,6 @@ -node->exportPhpCode()})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/MetadataNode.php b/src/Parser/Nodes/MetadataNode.php index 75bf170..abf79e8 100644 --- a/src/Parser/Nodes/MetadataNode.php +++ b/src/Parser/Nodes/MetadataNode.php @@ -1,4 +1,6 @@ -name === null || !$this->name->isSameForBothDirections()) { + if ($this->name === null || ! $this->name->isSameForBothDirections()) { return; } @@ -81,22 +82,22 @@ private function assertOneAliasFitsBothDirections(): void // NEVER has no input type at all — TypescriptGenerator throws before the alias is reached — // so its properties being output only says nothing about the alias. - if (!$node instanceof CustomCastingNode || $node->strategy === ObjectCastStrategy::NEVER) { + if (! $node instanceof CustomCastingNode || $node->strategy === ObjectCastStrategy::NEVER) { return; } // Only a struct has per-direction properties; a cast over a list or a record does not. - if (!$node->node instanceof StructNode) { + if (! $node->node instanceof StructNode) { return; } $asymmetric = array_find( $node->node->properties, - fn(NodeInterface $property): bool => $property instanceof PropertyNode + fn (NodeInterface $property): bool => $property instanceof PropertyNode && $property->propertyType !== PropertyType::BOTH, ); - if (!$asymmetric instanceof PropertyNode) { + if (! $asymmetric instanceof PropertyNode) { return; } @@ -104,11 +105,11 @@ private function assertOneAliasFitsBothDirections(): void throw new ParserException( "#[Named] on {$node->fullyQualifiedCastingClass} resolves to one alias \"{$this->name->outputName}\" " - . "for both directions, but its input and output shapes differ: \"{$asymmetric->name}\" is " - . "{$direction} only. Every alias is declared once in the generated types file, so one name " - . "cannot describe both. Compute a name per direction with a closure — " - . "#[Named(name: Naming::alias(...))], Closure(string \$className, IO \$io): string — or align " - . "the shapes." + ."for both directions, but its input and output shapes differ: \"{$asymmetric->name}\" is " + ."{$direction} only. Every alias is declared once in the generated types file, so one name " + .'cannot describe both. Compute a name per direction with a closure — ' + .'#[Named(name: Naming::alias(...))], Closure(string $className, IO $io): string — or align ' + .'the shapes.' ); } } diff --git a/src/Parser/Nodes/PropertyNode.php b/src/Parser/Nodes/PropertyNode.php index e996758..d51f778 100644 --- a/src/Parser/Nodes/PropertyNode.php +++ b/src/Parser/Nodes/PropertyNode.php @@ -1,4 +1,6 @@ -isOptional ? '?' : ''; + return "{$this->name}{$optional}: {$this->node}{$this->propertyType->asString()}"; } @@ -40,8 +44,8 @@ public function exportPhpCode(): string $name = PHPExport::export($this->name); $propertyType = $this->propertyType === PropertyType::BOTH ? '' - : ',' . PHPExport::exportEnumCase($this->propertyType); + : ','.PHPExport::exportEnumCase($this->propertyType); return "new {$className}({$name}, {$type}, {$isOptional}{$propertyType})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/RecordNode.php b/src/Parser/Nodes/RecordNode.php index 1f39cfa..09189ab 100644 --- a/src/Parser/Nodes/RecordNode.php +++ b/src/Parser/Nodes/RecordNode.php @@ -1,4 +1,6 @@ -node); + return "new {$classname}({$exportedType})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/ReferencedNode.php b/src/Parser/Nodes/ReferencedNode.php index 0fe2940..e128ae4 100644 --- a/src/Parser/Nodes/ReferencedNode.php +++ b/src/Parser/Nodes/ReferencedNode.php @@ -1,4 +1,6 @@ -originalTypeString; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/StructNode.php b/src/Parser/Nodes/StructNode.php index be3c25e..0b81061 100644 --- a/src/Parser/Nodes/StructNode.php +++ b/src/Parser/Nodes/StructNode.php @@ -1,4 +1,6 @@ - $properties + * @param list $properties */ public function __construct( public readonly StructPhpType $phpType, - array $properties, - ) - { + array $properties, + ) { $this->properties = self::canonicalise($properties); } - /** - * @param list $properties + * @param list $properties * @return list */ private static function canonicalise(array $properties): array { // ReferencedNode carries no name to sort by. The ASTOptimizer rebuilds structs by mapping // over an already canonical list, so order is preserved and sorting is unnecessary there. - if (!array_all($properties, static fn(PropertyNode|ReferencedNode $property) => $property instanceof PropertyNode)) { + if (! array_all($properties, static fn (PropertyNode|ReferencedNode $property) => $property instanceof PropertyNode)) { return $properties; } /** @var non-empty-list $properties */ usort($properties, static function (PropertyNode $a, PropertyNode $b): int { $byName = strcmp($a->name, $b->name); + return $byName !== 0 ? $byName : $a->propertyType->name <=> $b->propertyType->name; @@ -73,7 +74,7 @@ public function validate(): void } /** - * @param Closure(PropertyNode): bool $closure + * @param Closure(PropertyNode): bool $closure */ #[NoDiscard] public function filter(Closure $closure): self @@ -95,7 +96,7 @@ private function propertyNodes(): array { $properties = $this->properties; assert( - array_all($properties, static fn($property) => $property instanceof PropertyNode), + array_all($properties, static fn ($property) => $property instanceof PropertyNode), 'A struct holding references cannot be reshaped.', ); @@ -104,7 +105,7 @@ private function propertyNodes(): array } /** - * @param Closure(PropertyNode): PropertyNode $closure + * @param Closure(PropertyNode): PropertyNode $closure */ #[NoDiscard] public function map(Closure $closure): self @@ -125,7 +126,8 @@ public function getProperty(string $name): ?PropertyNode { /** @var list $properties */ $properties = $this->properties; - return array_find($properties, fn(PropertyNode $property) => $property->name === $name); + + return array_find($properties, fn (PropertyNode $property) => $property->name === $name); } public function hasProperty(string $name): bool @@ -136,8 +138,9 @@ public function hasProperty(string $name): bool #[Override] public function __toString(): string { - $properties = array_map(fn(PropertyNode|ReferencedNode $property) => (string)$property, $this->properties); + $properties = array_map(fn (PropertyNode|ReferencedNode $property) => (string) $property, $this->properties); $imploded = implode(', ', $properties); + return "{$this->phpType->value}{{$imploded}}"; } @@ -147,6 +150,7 @@ public function exportPhpCode(): string $exportedProperties = PHPExport::exportArray($this->properties); $className = PHPExport::absolute(self::class); $phpType = PHPExport::exportEnumCase($this->phpType); + return "new {$className}({$phpType}, {$exportedProperties})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/TupleNode.php b/src/Parser/Nodes/TupleNode.php index fa11322..41ebb0c 100644 --- a/src/Parser/Nodes/TupleNode.php +++ b/src/Parser/Nodes/TupleNode.php @@ -1,11 +1,12 @@ - $nodes + * @param non-empty-list $nodes */ public function __construct(public array $nodes) { @@ -22,17 +23,19 @@ public function __construct(public array $nodes) #[Override] public function __toString(): string { - $typeString = Arrays::mapWithKeys($this->nodes, fn(int $key, NodeInterface $type) => "{$key}: {$type}"); + $typeString = Arrays::mapWithKeys($this->nodes, fn (int $key, NodeInterface $type) => "{$key}: {$type}"); $imploded = implode(', ', $typeString); - return 'array{' . $imploded . '}'; + + return 'array{'.$imploded.'}'; } #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); - $types = array_map(fn(NodeInterface $type) => $type->exportPhpCode(), $this->nodes); + $types = array_map(fn (NodeInterface $type) => $type->exportPhpCode(), $this->nodes); $imploded = implode(', ', $types); + return "new {$className}([{$imploded}])"; } @@ -40,4 +43,4 @@ public function exportPhpCode(): string public function validate(): void { } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/UnionNode.php b/src/Parser/Nodes/UnionNode.php index 9660ed0..d1b87b5 100644 --- a/src/Parser/Nodes/UnionNode.php +++ b/src/Parser/Nodes/UnionNode.php @@ -1,4 +1,6 @@ -acceptsNull ??= array_any($this->nodes, fn(NodeInterface $type) => $type instanceof NullNode); + return $this->acceptsNull ??= array_any($this->nodes, fn (NodeInterface $type) => $type instanceof NullNode); } /** - * @param list $nodes - * @param string|null $discriminator - * @param list|null $discriminatorMap + * @param list $nodes + * @param list|null $discriminatorMap */ public function __construct( - public readonly array $nodes, + public readonly array $nodes, public readonly ?string $discriminator = null, - public readonly ?array $discriminatorMap = null, - ) - { - + public readonly ?array $discriminatorMap = null, + ) { } #[Override] @@ -48,7 +47,7 @@ public function validate(): void #[Override] public function __toString(): string { - $types = implode('|', array_map(fn(NodeInterface $type) => (string)$type, $this->nodes)); + $types = implode('|', array_map(fn (NodeInterface $type) => (string) $type, $this->nodes)); return $this->discriminator === null ? $types @@ -62,14 +61,15 @@ public function isDiscriminated(): bool public function getDiscriminatedType(mixed $value): ?NodeInterface { - if (!$this->discriminatorMap) { + if (! $this->discriminatorMap) { return null; } - $index = array_find_key($this->discriminatorMap, static fn(mixed $typeValue) => $typeValue === $value); + $index = array_find_key($this->discriminatorMap, static fn (mixed $typeValue) => $typeValue === $value); if ($index !== null) { return $this->nodes[$index]; } + return null; } @@ -80,6 +80,7 @@ public function exportPhpCode(): string $types = PHPExport::export($this->nodes); $discriminator = $this->discriminator ? PHPExport::export($this->discriminator) : 'null'; $discriminatorMap = $this->discriminatorMap ? PHPExport::export($this->discriminatorMap) : 'null'; + return "new {$classname}({$types}, {$discriminator}, {$discriminatorMap})"; } -} \ No newline at end of file +} diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 1528764..7379840 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -1,4 +1,6 @@ -|null $consumers + * @param list|null $consumers */ public function __construct( ?array $consumers = null, - ) - { + ) { $this->consumers = $consumers ?? self::defaultConsumers(); } /** - * @param GlobalTypeAliases $globalTypeAliases - * @param bool $allowAllObjectCasting * @return list */ public static function defaultConsumers( GlobalTypeAliases $globalTypeAliases = new GlobalTypeAliases(), bool $allowAllObjectCasting = false, - ): array - { + ): array { return [ new LiteralConsumer(), new ClassConstConsumer(), @@ -121,12 +119,11 @@ private function consumeTypeModifiers(ParserState $state, NodeInterface $type): $state->advance(2); $type = new ListNode($type); } + return $type; } /** - * @param ParserState $state - * @return NodeInterface * @throws InvalidSyntaxException */ private function consumeType(ParserState $state): NodeInterface @@ -138,11 +135,12 @@ private function consumeType(ParserState $state): NodeInterface } } - $state->produceSyntaxError("No parser found."); + $state->produceSyntaxError('No parser found.'); } /** * @throws InvalidSyntaxException + * * @internal */ public function consume(ParserState $state, TokenType ...$stopAt): NodeInterface @@ -170,30 +168,32 @@ public function consume(ParserState $state, TokenType ...$stopAt): NodeInterface if ($token->is(TokenType::PIPE)) { $mode ??= 'union'; if ($expectsType) { - $state->produceSyntaxError("Expected Type Identifier, got Pipe"); + $state->produceSyntaxError('Expected Type Identifier, got Pipe'); } if ($mode !== 'union') { - $state->produceSyntaxError("Cannot mix union with intersection or nullable types. Use brackets to do so. Example: (A&B)|C or null|A|B"); + $state->produceSyntaxError('Cannot mix union with intersection or nullable types. Use brackets to do so. Example: (A&B)|C or null|A|B'); } $expectsType = true; $state->advance(); + continue; } if ($token->is(TokenType::AMPERSAND)) { $mode ??= 'intersection'; if ($expectsType) { - $state->produceSyntaxError("Expected Type Identifier, got &"); + $state->produceSyntaxError('Expected Type Identifier, got &'); } if ($mode !== 'intersection') { - $state->produceSyntaxError("Cannot mix union and intersection types. Use brackets to do so. Example: (A&B)|C"); + $state->produceSyntaxError('Cannot mix union and intersection types. Use brackets to do so. Example: (A&B)|C'); } $expectsType = true; $state->advance(); + continue; } @@ -201,12 +201,13 @@ public function consume(ParserState $state, TokenType ...$stopAt): NodeInterface if ($token->is(TokenType::LPAREN)) { $state->advance(); $grouped = $this->consume($state, TokenType::RPAREN); - if (!$state->current()->is(TokenType::RPAREN)) { - $state->produceSyntaxError("Expected closing parenthesis"); + if (! $state->current()->is(TokenType::RPAREN)) { + $state->produceSyntaxError('Expected closing parenthesis'); } $state->advance(); $types[] = $this->consumeTypeModifiers($state, $grouped); $expectsType = false; + continue; } @@ -215,12 +216,12 @@ public function consume(ParserState $state, TokenType ...$stopAt): NodeInterface } while ($state->canAdvance()); if ($expectsType) { - $state->produceSyntaxError("Expected type Identifier"); + $state->produceSyntaxError('Expected type Identifier'); } if ($mode === 'intersection') { if (count($types) < 2) { - $state->produceSyntaxError("Intersections need at least 2 types."); + $state->produceSyntaxError('Intersections need at least 2 types.'); } return new IntersectionNode($types); @@ -228,7 +229,7 @@ public function consume(ParserState $state, TokenType ...$stopAt): NodeInterface if ($mode === 'questionmark-union') { if (count($types) !== 2) { - $state->produceSyntaxError("Questionmark nullable unions need exactly 2 types. Example: ?MyClass, got: " . count($types) . " types."); + $state->produceSyntaxError('Questionmark nullable unions need exactly 2 types. Example: ?MyClass, got: '.count($types).' types.'); } return new UnionNode($types, null, null); @@ -242,7 +243,7 @@ public function consume(ParserState $state, TokenType ...$stopAt): NodeInterface } /** - * @param non-empty-list $types + * @param non-empty-list $types * @return non-empty-list */ private function flattenNestedUnionTypes(array $types): array @@ -251,7 +252,8 @@ private function flattenNestedUnionTypes(array $types): array foreach ($types as $type) { if ($type instanceof UnionNode) { - array_push($flattened, ... $type->nodes); + array_push($flattened, ...$type->nodes); + continue; } $flattened[] = $type; @@ -261,7 +263,6 @@ private function flattenNestedUnionTypes(array $types): array return $flattened; } - /** * A discriminator has to survive a strict comparison against a value decoded from JSON, which * rules out floats (precision), enum cases (not wire values) and null (indistinguishable from @@ -275,12 +276,12 @@ private static function canDiscriminate(mixed $value): bool } /** - * @param non-empty-list $types + * @param non-empty-list $types * @return UnionNode */ private function checkForDiscriminatedUnion(array $types): UnionNode { - if (count($types) < 2 || !array_all($types, fn(NodeInterface $type) => $type instanceof StructNode)) { + if (count($types) < 2 || ! array_all($types, fn (NodeInterface $type) => $type instanceof StructNode)) { return new UnionNode($types); } @@ -292,7 +293,7 @@ private function checkForDiscriminatedUnion(array $types): UnionNode foreach ($firstType->properties as $property) { // Discrimination runs on freshly parsed structs; the optimizer's reference holding // structs are exported, never unioned. - if (!$property instanceof PropertyNode || !$property->node instanceof LiteralNode) { + if (! $property instanceof PropertyNode || ! $property->node instanceof LiteralNode) { continue; } @@ -316,7 +317,7 @@ private function checkForDiscriminatedUnion(array $types): UnionNode // Check for presence, type, and uniqueness $otherValue = $otherProperty?->node instanceof LiteralNode ? $otherProperty->node->value : null; if ( - !self::canDiscriminate($otherValue) || + ! self::canDiscriminate($otherValue) || in_array($otherValue, $values, true) ) { $isDiscriminator = false; @@ -333,4 +334,4 @@ private function checkForDiscriminatedUnion(array $types): UnionNode return new UnionNode($types); } -} \ No newline at end of file +} diff --git a/src/Parser/Utils/Lexemes.php b/src/Parser/Utils/Lexemes.php index b4140a8..fd35621 100644 --- a/src/Parser/Utils/Lexemes.php +++ b/src/Parser/Utils/Lexemes.php @@ -1,4 +1,6 @@ - (int)hexdec(substr($value, 2)), - '0b' => (int)bindec(substr($value, 2)), - '0o' => (int)octdec(substr($value, 2)), - default => (int)$value, + '0x' => (int) hexdec(substr($value, 2)), + '0b' => (int) bindec(substr($value, 2)), + '0o' => (int) octdec(substr($value, 2)), + default => (int) $value, }; return $isNegative ? -$magnitude : $magnitude; @@ -73,11 +75,11 @@ public static function decodeInt(string $lexeme): int /** * Handles `_` separators and exponents. * - * @param string $lexeme The raw FLOAT lexeme. + * @param string $lexeme The raw FLOAT lexeme. */ public static function decodeFloat(string $lexeme): float { - return (float)str_replace('_', '', $lexeme); + return (float) str_replace('_', '', $lexeme); } /** @@ -97,21 +99,21 @@ static function (array $matches): string { } if ($sequence[0] === 'x' || $sequence[0] === 'X') { - return chr(self::toByte((int)hexdec(substr($sequence, 1)))); + return chr(self::toByte((int) hexdec(substr($sequence, 1)))); } if ($sequence[0] === 'u') { - return self::codePointToUtf8((int)hexdec($matches[2] ?? '')); + return self::codePointToUtf8((int) hexdec($matches[2] ?? '')); } // Three octal digits reach 511, which PHP itself truncates to a byte. - return chr(self::toByte((int)octdec($sequence))); + return chr(self::toByte((int) octdec($sequence))); }, $string, ); if ($resolved === null) { - throw new ParserException('Failed to resolve escape sequences: ' . preg_last_error_msg()); + throw new ParserException('Failed to resolve escape sequences: '.preg_last_error_msg()); } return $resolved; @@ -124,6 +126,7 @@ private static function toByte(int $value): int { /** @var int<0, 255> $byte */ $byte = $value & 0xFF; + return $byte; } @@ -135,20 +138,20 @@ private static function codePointToUtf8(int $codePoint): string if ($codePoint <= 0x7FF) { return chr(($codePoint >> 6) + 0xC0) - . chr(($codePoint & 0x3F) + 0x80); + .chr(($codePoint & 0x3F) + 0x80); } if ($codePoint <= 0xFFFF) { return chr(($codePoint >> 12) + 0xE0) - . chr((($codePoint >> 6) & 0x3F) + 0x80) - . chr(($codePoint & 0x3F) + 0x80); + .chr((($codePoint >> 6) & 0x3F) + 0x80) + .chr(($codePoint & 0x3F) + 0x80); } if ($codePoint <= 0x1FFFFF) { return chr(($codePoint >> 18) + 0xF0) - . chr((($codePoint >> 12) & 0x3F) + 0x80) - . chr((($codePoint >> 6) & 0x3F) + 0x80) - . chr(($codePoint & 0x3F) + 0x80); + .chr((($codePoint >> 12) & 0x3F) + 0x80) + .chr((($codePoint >> 6) & 0x3F) + 0x80) + .chr(($codePoint & 0x3F) + 0x80); } // Invalid UTF-8 code point escape sequence: code point too large. diff --git a/src/Reflection/AttributesReflector.php b/src/Reflection/AttributesReflector.php index 35d6884..f4ffb19 100644 --- a/src/Reflection/AttributesReflector.php +++ b/src/Reflection/AttributesReflector.php @@ -1,4 +1,6 @@ -> $attributes + * @param list> $attributes */ public function __construct(private array $attributes) { } /** - * @param class-string $attributeClass - * @return bool + * @param class-string $attributeClass */ public function has(string $attributeClass): bool { - return array_any($this->attributes, fn(ReflectionAttribute $attribute) => $attribute->name === $attributeClass); + return array_any($this->attributes, fn (ReflectionAttribute $attribute) => $attribute->name === $attributeClass); } /** * @template T of object - * @param class-string $attributeClass + * + * @param class-string $attributeClass * @return T */ public function getSingleInstance(string $attributeClass): object @@ -39,12 +41,13 @@ public function getSingleInstance(string $attributeClass): object * has() followed by getSingleInstance() walking the list twice. * * @template T of object - * @param class-string $attributeClass + * + * @param class-string $attributeClass * @return T|null */ public function firstInstanceOrNull(string $attributeClass): ?object { - $reflection = array_find($this->attributes, fn(ReflectionAttribute $attribute) => $attribute->name === $attributeClass); + $reflection = array_find($this->attributes, fn (ReflectionAttribute $attribute) => $attribute->name === $attributeClass); /** @var T|null */ return $reflection?->newInstance(); diff --git a/src/Reflection/FileReflector.php b/src/Reflection/FileReflector.php index b7f530d..3709d05 100644 --- a/src/Reflection/FileReflector.php +++ b/src/Reflection/FileReflector.php @@ -1,4 +1,6 @@ -|null */ private ?array $usedNamespaces = null; + private ?string $namespace = null; + private bool $namespaceParsed = false; /** @@ -27,14 +31,13 @@ final class FileReflector private ?ReflectionClass $declaredClass = null; /** - * @param string $filePath * @throws ParserException */ public function __construct( public readonly string $filePath ) { $realPath = realpath($this->filePath); - if ($realPath === false || !is_file($realPath) || !is_readable($realPath)) { + if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { throw new ParserException( "File does not exist or is not readable: {$this->filePath}" ); @@ -65,7 +68,7 @@ public function getUsedNamespaces(): array for ($i = 0; $i < $numTokens; $i++) { $token = $tokens[$i]; - if (!is_array($token) || $token[0] !== T_USE) { + if (! is_array($token) || $token[0] !== T_USE) { continue; } @@ -111,6 +114,7 @@ public function getNamespace(): ?string * and returns a ReflectionClass instance for it. * * @return ReflectionClass|never + * * @throws ParserException If no class-like structure is found or if the class cannot be loaded. * @throws ReflectionException If the class is loaded but cannot be reflected. */ @@ -133,11 +137,11 @@ public function getDeclaredClass(): ReflectionClass // This is critical. We must ensure the file is loaded into memory // before we can reflect a class from it, especially if not using an autoloader. - if (!class_exists($fullyQualifiedClassName, false) && !interface_exists($fullyQualifiedClassName, false) && !trait_exists($fullyQualifiedClassName, false)) { + if (! class_exists($fullyQualifiedClassName, false) && ! interface_exists($fullyQualifiedClassName, false) && ! trait_exists($fullyQualifiedClassName, false)) { require_once $this->filePath; } - if (!class_exists($fullyQualifiedClassName, false) && !interface_exists($fullyQualifiedClassName, false) && !trait_exists($fullyQualifiedClassName, false)) { + if (! class_exists($fullyQualifiedClassName, false) && ! interface_exists($fullyQualifiedClassName, false) && ! trait_exists($fullyQualifiedClassName, false)) { throw new ParserException("Failed to load class {$fullyQualifiedClassName} from file {$this->filePath}"); } @@ -167,7 +171,7 @@ private function tokens(): array } /** - * @param list $tokens + * @param list $tokens * @return string|null The found namespace name or null. */ private static function findNamespaceInTokens(array $tokens): ?string @@ -181,11 +185,12 @@ private static function findNamespaceInTokens(array $tokens): ?string } } } + return null; } /** - * @param list $tokens + * @param list $tokens * @return string|null The found class name or null. */ private static function findClassNameInTokens(array $tokens): ?string @@ -193,11 +198,11 @@ private static function findClassNameInTokens(array $tokens): ?string $count = count($tokens); for ($i = 0; $i < $count; $i++) { $token = $tokens[$i]; - if (!is_array($token)) { + if (! is_array($token)) { continue; } - if (!in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true)) { + if (! in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true)) { continue; } @@ -213,13 +218,14 @@ private static function findClassNameInTokens(array $tokens): ?string return $nextToken[1]; } } + return null; } /** - * @param list $tokens + * @param list $tokens * @return (array{int, string, int})|null Null when the preceding token is punctuation, which - * token_get_all() reports as a plain string rather than an array. + * token_get_all() reports as a plain string rather than an array. */ private static function previousSignificantToken(array $tokens, int $currentIndex): ?array { @@ -228,13 +234,15 @@ private static function previousSignificantToken(array $tokens, int $currentInde if (is_array($token) && $token[0] === T_WHITESPACE) { continue; } + return is_array($token) ? $token : null; } + return null; } /** - * @param list $tokens + * @param list $tokens * @return (array{int, string, int})|null */ private static function peekNextSignificantToken(array $tokens, int $currentIndex, int $maxIndex): ?array @@ -249,11 +257,12 @@ private static function peekNextSignificantToken(array $tokens, int $currentInde // `new class {` must not scan on into the body looking for a name. return is_array($token) ? $token : null; } + return null; } /** - * @param list $tokens + * @param list $tokens * @return array{string, string|null, int} */ private static function parseUseStatement(array $tokens, int $startIndex, int $maxIndex): array @@ -286,4 +295,4 @@ private static function parseUseStatement(array $tokens, int $startIndex, int $m return [$fullyQualifiedClassname, $alias, $i]; } -} \ No newline at end of file +} diff --git a/src/Reflection/MetadataAttributes.php b/src/Reflection/MetadataAttributes.php index f9b9722..53fabb3 100644 --- a/src/Reflection/MetadataAttributes.php +++ b/src/Reflection/MetadataAttributes.php @@ -1,4 +1,6 @@ - $reflectionClass - * @param bool $inheritFromParents Also accept an attribute declared one level up, on the direct - * parent class or a directly declared interface. Value objects opt in so a family of ids - * can share one declaration; see wrap()'s callers. + * @param ReflectionClass $reflectionClass + * @param bool $inheritFromParents Also accept an attribute declared one level up, on the direct + * parent class or a directly declared interface. Value objects opt in so a family of ids + * can share one declaration; see wrap()'s callers. */ public static function wrap( - NodeInterface $node, + NodeInterface $node, ReflectionClass $reflectionClass, - bool $inheritFromParents = false, - ): NodeInterface - { + bool $inheritFromParents = false, + ): NodeInterface { $attributes = new AttributesReflector($reflectionClass->getAttributes()); // 1. The class itself. A local declaration always wins, and declaring both skips the @@ -66,7 +67,7 @@ public static function wrap( * class is a single unambiguous candidate and is consulted first; the interfaces are a set, so * two of them declaring the same attribute is an ambiguity we refuse to resolve silently. * - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass * @return array{Named|null, Brand|null} */ private static function inheritMissing(ReflectionClass $reflectionClass, ?Named $named, ?Brand $brand): array @@ -105,8 +106,9 @@ private static function inheritMissing(ReflectionClass $reflectionClass, ?Named * library gets to make on the author's behalf. Two of them carrying the attribute is rejected. * * @template T of Named|Brand - * @param ReflectionClass $reflectionClass - * @param class-string $attributeClass + * + * @param ReflectionClass $reflectionClass + * @param class-string $attributeClass * @return T|null */ private static function fromInterfaces(ReflectionClass $reflectionClass, string $attributeClass, string $target): Named|Brand|null @@ -126,7 +128,7 @@ private static function fromInterfaces(ReflectionClass $reflectionClass, string $attributeName = $attributeClass === Named::class ? 'Named' : 'Brand'; throw new ParserException( "{$target} inherits #[{$attributeName}] from more than one interface ({$declaredOn} and {$interface}). " - . "Declare it on {$target} itself to say which one applies." + ."Declare it on {$target} itself to say which one applies." ); } @@ -134,7 +136,7 @@ private static function fromInterfaces(ReflectionClass $reflectionClass, string $declaredOn = $interface; } - self::assertInheritable($found, (string)$declaredOn, $target); + self::assertInheritable($found, (string) $declaredOn, $target); return $found; } @@ -147,15 +149,15 @@ private static function fromInterfaces(ReflectionClass $reflectionClass, string */ private static function assertInheritable(Named|Brand|null $attribute, string $declaredOn, string $target): void { - if ($attribute === null || !is_string($attribute->name)) { + if ($attribute === null || ! is_string($attribute->name)) { return; } $attributeName = $attribute instanceof Named ? 'Named' : 'Brand'; throw new ParserException( "#[{$attributeName}] on {$declaredOn} is inherited by {$target} and cannot carry a fixed name: " - . "every child would share \"{$attribute->name}\". Drop the name to derive it per class, " - . "or pass a closure: #[{$attributeName}(name: Naming::method(...))]." + ."every child would share \"{$attribute->name}\". Drop the name to derive it per class, " + ."or pass a closure: #[{$attributeName}(name: Naming::method(...))]." ); } @@ -167,7 +169,7 @@ private static function assertInheritable(Named|Brand|null $attribute, string $d * through another interface or through the parent class, which sit two or more levels up. * Those are subtracted here, so only what the class itself declares remains. * - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass * @return list */ private static function directlyDeclaredInterfaces(ReflectionClass $reflectionClass): array diff --git a/src/Reflection/TypeReflector.php b/src/Reflection/TypeReflector.php index a3b979f..cfeacfe 100644 --- a/src/Reflection/TypeReflector.php +++ b/src/Reflection/TypeReflector.php @@ -1,4 +1,6 @@ -getType()) { - throw new ParserException("No type defined."); + if (! $property->getType()) { + throw new ParserException('No type defined.'); } if ($property->getDocComment() && $type = Regexes::findFirstVarDeclaration($property->getDocComment())) { return trim($type); } - if (!$property->isPromoted()) { - return (string)$property->getType(); + if (! $property->isPromoted()) { + return (string) $property->getType(); } $constructorDocBlock = $property->getDeclaringClass()->getConstructor()?->getDocComment(); @@ -30,33 +32,33 @@ public static function reflectProperty(ReflectionProperty $property): string return trim($type); } - return (string)$property->getType(); + return (string) $property->getType(); } public static function reflectParameter(ReflectionParameter $parameter): string { - if (!$parameter->getType()) { - throw new ParserException("No type defined."); + if (! $parameter->getType()) { + throw new ParserException('No type defined.'); } $declaringDocBlock = $parameter->getDeclaringFunction()->getDocComment(); - if (!$declaringDocBlock) { - return (string)$parameter->getType(); + if (! $declaringDocBlock) { + return (string) $parameter->getType(); } return trim( - Regexes::findParamWithNameDeclaration($declaringDocBlock, $parameter->getName()) ?? (string)$parameter->getType() + Regexes::findParamWithNameDeclaration($declaringDocBlock, $parameter->getName()) ?? (string) $parameter->getType() ); } public static function reflectReturnType(ReflectionFunction|ReflectionMethod $returnable): string { - if (!$returnable->hasReturnType()) { - throw new ParserException("No return type defined."); + if (! $returnable->hasReturnType()) { + throw new ParserException('No return type defined.'); } $docBlock = $returnable->getDocComment(); - if (!$docBlock) { + if (! $docBlock) { return (string) $returnable->getReturnType(); } @@ -64,4 +66,4 @@ public static function reflectReturnType(ReflectionFunction|ReflectionMethod $re Regexes::findReturnTypeDeclaration($docBlock) ?? (string) $returnable->getReturnType() ); } -} \ No newline at end of file +} diff --git a/src/Server/Adapters/NewInstanceAdapter.php b/src/Server/Adapters/NewInstanceAdapter.php index 625a336..def191d 100644 --- a/src/Server/Adapters/NewInstanceAdapter.php +++ b/src/Server/Adapters/NewInstanceAdapter.php @@ -1,4 +1,6 @@ -container->get($className); } -} \ No newline at end of file +} diff --git a/src/Server/Client/InteractsWithToasts.php b/src/Server/Client/InteractsWithToasts.php index dea8380..faaec41 100644 --- a/src/Server/Client/InteractsWithToasts.php +++ b/src/Server/Client/InteractsWithToasts.php @@ -1,4 +1,6 @@ -|null */ + /** @var list|null */ private ?array $toasts = null; - /** @var list>|null */ + /** @var list>|null */ private ?array $invalidations = null; #[Override] @@ -42,7 +44,7 @@ public function redirect(string $url, bool $reload = false): void } #[Override] - public function invalidate(UnitEnum|string $namespace, ...$key): void + public function invalidate(UnitEnum|string $namespace, mixed ...$key): void { $this->invalidations ??= []; $this->invalidations[] = [Strings::toString($namespace), ...$key] |> array_values(...); @@ -52,7 +54,7 @@ public function invalidate(UnitEnum|string $namespace, ...$key): void * @return array{redirect?: Redirect, toasts?: list, invalidations?: list>, type: 'operations-spa'}|null */ #[Override] - public function serializeToArray(): array|null + public function serializeToArray(): ?array { if ($this->redirect === null && $this->toasts === null && $this->invalidations === null) { return null; @@ -65,13 +67,14 @@ public function serializeToArray(): array|null $payload['redirect'] = $this->redirect; } if ($this->toasts !== null) { - $payload['toasts'] = array_map(fn(Toast $toast): array => $toast->toArray(), $this->toasts); + $payload['toasts'] = array_map(fn (Toast $toast): array => $toast->toArray(), $this->toasts); } if ($this->invalidations !== null) { $payload['invalidations'] = $this->invalidations; } $payload['type'] = 'operations-spa'; + return $payload; } } diff --git a/src/Server/Data/Definition.php b/src/Server/Data/Definition.php index f427bc9..bd44030 100644 --- a/src/Server/Data/Definition.php +++ b/src/Server/Data/Definition.php @@ -1,4 +1,6 @@ - $fullyQualifiedClassName - * @param string $methodName - * @param string $name - * @param string $namespace - * @param list>> $middleware + * @param class-string $fullyQualifiedClassName + * @param list>> $middleware */ public function __construct( public OperationType $type, - public string $fullyQualifiedClassName, - public string $methodName, - public string $name, - public string $namespace, - public array $middleware, - ) - { + public string $fullyQualifiedClassName, + public string $methodName, + public string $name, + public string $namespace, + public array $middleware, + ) { } public function fullyQualifiedName(): string @@ -47,4 +44,4 @@ public function exportPhpCode(): string // Descriptions are ignored when caching. return "new {$className}({$type}, {$fullyQualifiedClassName}, {$methodName}, {$name}, {$namespace}, {$middleware})"; } -} \ No newline at end of file +} diff --git a/src/Server/Data/ErrorType.php b/src/Server/Data/ErrorType.php index 702a930..1a8d9b7 100644 --- a/src/Server/Data/ErrorType.php +++ b/src/Server/Data/ErrorType.php @@ -1,4 +1,6 @@ -describe(), 500); } -} \ No newline at end of file +} diff --git a/src/Server/Data/Exceptions/OperationNotFoundException.php b/src/Server/Data/Exceptions/OperationNotFoundException.php index 79f3cd1..77c1238 100644 --- a/src/Server/Data/Exceptions/OperationNotFoundException.php +++ b/src/Server/Data/Exceptions/OperationNotFoundException.php @@ -1,4 +1,6 @@ -output instanceof Closure ? ($this->output)() : $this->output; } -} \ No newline at end of file +} diff --git a/src/Server/Data/OperationType.php b/src/Server/Data/OperationType.php index e6e886f..2e918de 100644 --- a/src/Server/Data/OperationType.php +++ b/src/Server/Data/OperationType.php @@ -1,4 +1,6 @@ - $className - * @param string $methodName - * @param list>> $middleware + * @param class-string $className + * @param list>> $middleware */ public function __construct( public readonly string $namespace, @@ -25,7 +23,6 @@ public function __construct( public readonly string $className, public readonly string $methodName, public readonly array $middleware, - ) - { + ) { } -} \ No newline at end of file +} diff --git a/src/Server/Data/RpcError.php b/src/Server/Data/RpcError.php index a36bd67..ce7abc2 100644 --- a/src/Server/Data/RpcError.php +++ b/src/Server/Data/RpcError.php @@ -1,4 +1,6 @@ - $previous Everything that failed before $cause, oldest first. Empty on - * every ordinary error, and non empty only when handling one failure produced another: a - * stale #[Middleware] class name makes ExposedExceptions throw while categorising, and the - * result is then an INTERNAL_ERROR because the catalogue could not be consulted, not because - * the original deserved a 500. Reporters want all of them. - * @param array $metadata + * @param Throwable $cause The most recent failure - the one that decided this result. On an + * ordinary error that is simply the exception the application threw. + * @param list $previous Everything that failed before $cause, oldest first. Empty on + * every ordinary error, and non empty only when handling one failure produced another: a + * stale #[Middleware] class name makes ExposedExceptions throw while categorising, and the + * result is then an INTERNAL_ERROR because the catalogue could not be consulted, not because + * the original deserved a 500. Reporters want all of them. + * @param array $metadata + * * @internal Constructed by the server. Applications receive one, they do not build one. */ public function __construct( - public readonly ErrorType $type, - public readonly Throwable $cause, - public readonly mixed $details, + public readonly ErrorType $type, + public readonly Throwable $cause, + public readonly mixed $details, public readonly ?ResolveInfo $resolveInfo, - public readonly array $metadata = [], - public readonly array $previous = [], - ) - { + public readonly array $metadata = [], + public readonly array $previous = [], + ) { } /** * Every failure that led here, oldest first, with $cause last. * * @return non-empty-list + * * @api */ public function throwableChain(): array @@ -48,27 +51,29 @@ public function throwableChain(): array } /** - * @param array $metadata + * @param array $metadata + * * @api */ #[Override] #[NoDiscard] public function withMetadata(array $metadata): self { - return clone($this, [ + return clone ($this, [ 'metadata' => $metadata, ]); } /** - * @param array $metadata + * @param array $metadata + * * @api */ #[Override] #[NoDiscard] public function appendMetadata(array $metadata): self { - return clone($this, [ + return clone ($this, [ 'metadata' => [...$this->metadata, ...$metadata], ]); } diff --git a/src/Server/Data/RpcSuccess.php b/src/Server/Data/RpcSuccess.php index df4e91e..bb2c65c 100644 --- a/src/Server/Data/RpcSuccess.php +++ b/src/Server/Data/RpcSuccess.php @@ -1,4 +1,6 @@ - $metadata + * @param array $metadata + * * @internal */ public function __construct( - public mixed $data, - public Client $client, + public mixed $data, + public Client $client, public ResolveInfo $resolveInfo, - public array $metadata = [], - ) - { + public array $metadata = [], + ) { $this->statusCode = 200; } /** * Overwrite all existing metadata - * @param array $metadata + * + * @param array $metadata + * * @api */ #[Override] #[NoDiscard] public function withMetadata(array $metadata): self { - return clone($this, ['metadata' => $metadata]); + return clone ($this, ['metadata' => $metadata]); } /** * Append metadata to the result - * @param array $metadata + * + * @param array $metadata + * * @api */ #[Override] #[NoDiscard] public function appendMetadata(array $metadata): self { - return clone($this, [ + return clone ($this, [ 'metadata' => [...$this->metadata, ...$metadata], ]); } @@ -70,4 +76,4 @@ public function jsonSerialize(): array 'data' => $this->data, ]; } -} \ No newline at end of file +} diff --git a/src/Server/Data/ServerConfiguration.php b/src/Server/Data/ServerConfiguration.php index ce706a4..00cc672 100644 --- a/src/Server/Data/ServerConfiguration.php +++ b/src/Server/Data/ServerConfiguration.php @@ -1,4 +1,6 @@ ->> $middleware - * @param list> $notFoundExceptions - * @param list> $unauthenticatedExceptions - * @param list> $unauthorizedExceptions + * @param list>> $middleware + * @param list> $notFoundExceptions + * @param list> $unauthenticatedExceptions + * @param list> $unauthorizedExceptions */ public function __construct( - public bool $coerceQueryInput = false, + public bool $coerceQueryInput = false, public array $middleware = [], public array $notFoundExceptions = [], public array $unauthenticatedExceptions = [], public array $unauthorizedExceptions = [], - ) - { + ) { } /** - * @param class-string> ...$middlewares - * @return self + * @param class-string> ...$middlewares */ #[NoDiscard] public function withMiddlewares(string ...$middlewares): self @@ -49,18 +48,16 @@ public function withMiddlewares(string ...$middlewares): self /** * Appends to the existing lists. An omitted category is left untouched. * - * @param list> $notFound - * @param list> $unauthenticated - * @param list> $unauthorized - * @return self + * @param list> $notFound + * @param list> $unauthenticated + * @param list> $unauthorized */ #[NoDiscard] public function withExceptions( array $notFound = [], array $unauthenticated = [], array $unauthorized = [], - ): self - { + ): self { return new self( coerceQueryInput: $this->coerceQueryInput, middleware: $this->middleware, diff --git a/src/Server/Data/Toast.php b/src/Server/Data/Toast.php index 60d2918..2f9f0a7 100644 --- a/src/Server/Data/Toast.php +++ b/src/Server/Data/Toast.php @@ -1,4 +1,6 @@ -resolve($throwable, $definition); + return new RpcError($type, $throwable, $details, $info); } catch (Throwable $presentationFailure) { // Losing this one is expensive to debug: a stale middleware class name makes @@ -58,14 +60,13 @@ public function present(Throwable $throwable, ?Definition $definition, ?ResolveI /** * The last resort shape, for when presenting itself fails. * - * @param list $previous + * @param list $previous */ public static function internalError( - Throwable $throwable, + Throwable $throwable, ?ResolveInfo $info, - array $previous = [], - ): RpcError - { + array $previous = [], + ): RpcError { return new RpcError( ErrorType::INTERNAL_ERROR, $throwable, @@ -113,11 +114,11 @@ private function resolve(Throwable $throwable, ?Definition $definition): array } /** - * @param list> $classNames + * @param list> $classNames */ private function matchesAny(Throwable $throwable, array $classNames): bool { - return array_any($classNames, static fn(string $className): bool => $throwable instanceof $className); + return array_any($classNames, static fn (string $className): bool => $throwable instanceof $className); } /** diff --git a/src/Server/Errors/ExposedExceptions.php b/src/Server/Errors/ExposedExceptions.php index df71c3e..02e3d7a 100644 --- a/src/Server/Errors/ExposedExceptions.php +++ b/src/Server/Errors/ExposedExceptions.php @@ -1,4 +1,6 @@ -, string|null> + * * @throws ReflectionException */ public static function declaredFor(Definition $definition, ServerConfiguration $configuration): array @@ -44,8 +45,8 @@ public static function declaredFor(Definition $definition, ServerConfiguration $ ->getAttributes(Throws::class); $middlewareClassNames = [ - ... $definition->middleware, - ... $configuration->middleware, + ...$definition->middleware, + ...$configuration->middleware, ]; foreach ($middlewareClassNames as $middlewareClassName) { @@ -74,11 +75,12 @@ public static function declaredFor(Definition $definition, ServerConfiguration $ /** * The name an exception class gives itself, used when no #[Throws] names it. * - * @param class-string $exceptionClass + * @param class-string $exceptionClass */ private static function exposeAsOf(string $exceptionClass): ?string { $attributes = new ReflectionClass($exceptionClass)->getAttributes(ExposeAs::class); + return count($attributes) === 0 ? null : $attributes[0]->newInstance()->type; @@ -87,9 +89,8 @@ private static function exposeAsOf(string $exceptionClass): ?string /** * The exposed names of every exception the operation declares, in declaration order. * - * @param Definition $definition - * @param ServerConfiguration $configuration * @return list + * * @throws ReflectionException */ public static function exposedTypesFor(Definition $definition, ServerConfiguration $configuration): array diff --git a/src/Server/KeyGenerators/HashSha256KeyGenerator.php b/src/Server/KeyGenerators/HashSha256KeyGenerator.php index a5cc75b..bd7c540 100644 --- a/src/Server/KeyGenerators/HashSha256KeyGenerator.php +++ b/src/Server/KeyGenerators/HashSha256KeyGenerator.php @@ -1,4 +1,6 @@ - $operations + * @param array $operations */ public function __construct(private readonly array $operations) { @@ -37,24 +39,24 @@ public function has(OperationType $type, string $fullyQualifiedKey): bool public function get(OperationType $type, string $fullyQualifiedKey): Operation { $key = $type->registryKey($fullyQualifiedKey); + return $this->instances[$key] ??= $this->operations[$key](); } - #[Override] public function all(): array { foreach ($this->operations as $key => $factory) { $this->instances[$key] ??= $factory(); } + return $this->instances; } public static function toPhpCode( OperationRegistry $registry, int $idLength, - ): string - { + ): string { $endpointClass = PHPExport::absolute(Operation::class); $endpoints = []; @@ -95,17 +97,19 @@ public static function toPhpCode( PHP; } - public static function writeToCache(OperationRegistry $registry, string $filePath, int $idLength,): void + public static function writeToCache(OperationRegistry $registry, string $filePath, int $idLength): void { $code = self::toPhpCode($registry, $idLength); // The cached code binds both the Asts and operations together and creates a file // that can be required with fully compiled types. - PHPExport::writeFileAtomically($filePath, << $factories + * @param array $factories */ public function __construct( private readonly array $factories, - ) - { + ) { } /** - * @param string|string[] $directories - * @param TypeParser $parser - * @param OperationKeyGenerator $keyGenerator - * @return self + * @param string|string[] $directories */ public static function eagerlyDiscover( - string|array $directories, - TypeParser $parser = new TypeParser(), + string|array $directories, + TypeParser $parser = new TypeParser(), OperationKeyGenerator $keyGenerator = new HashSha256KeyGenerator('default', 8, 24), - OperationDiscovery $discovery = new OperationDiscovery(), - ): self - { + OperationDiscovery $discovery = new OperationDiscovery(), + ): self { $directories = is_array($directories) ? $directories : [$directories]; foreach ($directories as $directory) { self::discoverDirectory($directory, $discovery); @@ -69,7 +66,7 @@ private static function discoverDirectory(string $directory, OperationDiscovery /** @var SplFileInfo $file */ foreach ($iterator as $file) { - if (!$file->isFile() || $file->getExtension() !== 'php' || !$file->getRealPath()) { + if (! $file->isFile() || $file->getExtension() !== 'php' || ! $file->getRealPath()) { continue; } @@ -78,11 +75,10 @@ private static function discoverDirectory(string $directory, OperationDiscovery } private static function registryFromDiscovery( - TypeParser $parser, + TypeParser $parser, OperationKeyGenerator $keyGenerator, - OperationDiscovery $discovery, - ): self - { + OperationDiscovery $discovery, + ): self { $factories = []; foreach ($discovery->operations as $definition) { $key = $keyGenerator->generateKey($definition->namespace, $definition->name); @@ -94,7 +90,7 @@ private static function registryFromDiscovery( if (array_key_exists($fullyQualifiedKey, $factories)) { throw new SchemaException( "Operation key collision on '{$key}' for {$definition->fullyQualifiedName()}. " - . "Two operations hash to the same key - increase the key generator's length." + ."Two operations hash to the same key - increase the key generator's length." ); } @@ -104,8 +100,8 @@ private static function registryFromDiscovery( $inputParameter = $classReflection->getMethod($definition->methodName)->getParameters()[0]; $parsingContext = ParsingScope::fromReflectionClass($classReflection); - $input = fn() => $parser->parse(TypeReflector::reflectParameter($inputParameter), $parsingContext); - $output = fn() => $parser->parse(TypeReflector::reflectReturnType($classReflection->getMethod($definition->methodName)), $parsingContext); + $input = fn () => $parser->parse(TypeReflector::reflectParameter($inputParameter), $parsingContext); + $output = fn () => $parser->parse(TypeReflector::reflectReturnType($classReflection->getMethod($definition->methodName)), $parsingContext); return new Operation($key, $definition, $input, $output); }; @@ -115,27 +111,28 @@ private static function registryFromDiscovery( } /** - * @param list $classes + * @param list $classes + * * @throws ReflectionException */ public static function withClasses( - array $classes, - TypeParser $parser = new TypeParser(), + array $classes, + TypeParser $parser = new TypeParser(), OperationKeyGenerator $keyGenerator = new HashSha256KeyGenerator('default', 8, 24), - OperationDiscovery $discovery = new OperationDiscovery(), - ): self - { + OperationDiscovery $discovery = new OperationDiscovery(), + ): self { foreach ($classes as $className) { $discovery->discover(new ReflectionClass($className)); } + return self::registryFromDiscovery($parser, $keyGenerator, $discovery); } - #[Override] public function has(OperationType $type, string $fullyQualifiedKey): bool { $key = $type->registryKey($fullyQualifiedKey); + return array_key_exists($key, $this->factories); } @@ -146,6 +143,7 @@ public function has(OperationType $type, string $fullyQualifiedKey): bool public function get(OperationType $type, string $fullyQualifiedKey): Operation { $key = $type->registryKey($fullyQualifiedKey); + return $this->instances[$key] ??= $this->factories[$key](); } @@ -158,6 +156,7 @@ public function all(): array foreach ($this->factories as $key => $factory) { $this->instances[$key] ??= $factory(); } + return $this->instances; } -} \ No newline at end of file +} diff --git a/src/Server/Operations/OperationDiscovery.php b/src/Server/Operations/OperationDiscovery.php index ce61fd3..e99eda1 100644 --- a/src/Server/Operations/OperationDiscovery.php +++ b/src/Server/Operations/OperationDiscovery.php @@ -1,4 +1,6 @@ - */ - private(set) array $operations = []; + public private(set) array $operations = []; /** - * @param Closure(ReflectionClass, ReflectionMethod, Query|Command): bool|null $filterFn + * @param Closure(ReflectionClass, ReflectionMethod, Query|Command): bool|null $filterFn */ - public function __construct(private readonly Closure|null $filterFn = null) + public function __construct(private readonly ?Closure $filterFn = null) { } @@ -33,7 +35,7 @@ public function __construct(private readonly Closure|null $filterFn = null) * The extension point is the $filterFn closure, not a subclass - this class is final. Return * false from it to keep an operation out of the registry. * - * @param ReflectionClass $class + * @param ReflectionClass $class */ private function filter(ReflectionClass $class, ReflectionMethod $method, Query|Command $attribute): bool { @@ -58,7 +60,7 @@ public function discover(ReflectionClass $class): void /** @var Query|Command $instance */ $instance = $attribute->newInstance(); - if (!$this->filter($class, $method, $instance)) { + if (! $this->filter($class, $method, $instance)) { continue; } @@ -85,7 +87,7 @@ public function discover(ReflectionClass $class): void * The first parameter also defines the entire published input contract, so getting it wrong * publishes a type the client can never satisfy rather than failing. * - * @param ReflectionClass $class + * @param ReflectionClass $class */ private static function assertHandlerSignature(ReflectionClass $class, ReflectionMethod $method): void { @@ -95,14 +97,14 @@ private static function assertHandlerSignature(ReflectionClass $class, Reflectio if (count($parameters) < 1) { throw new SchemaException( "Operation {$signature} must declare at least one parameter: the first one is the " - . "input, and its type is the contract the client must satisfy." + .'input, and its type is the contract the client must satisfy.' ); } if (count($parameters) > 3) { throw new SchemaException( - "Operation {$signature} declares " . count($parameters) . " parameters. A handler is " - . "called with (\$input, \$context, \$client) and may declare a prefix of those." + "Operation {$signature} declares ".count($parameters).' parameters. A handler is ' + .'called with ($input, $context, $client) and may declare a prefix of those.' ); } @@ -112,8 +114,8 @@ private static function assertHandlerSignature(ReflectionClass $class, Reflectio if ($secondParameterType instanceof ReflectionNamedType && is_a($secondParameterType->getName(), Client::class, true)) { throw new SchemaException( "Operation {$signature} declares {$secondParameterType->getName()} as its second " - . "parameter, but the second argument is the context. Declare the client third: " - . "(\$input, \$context, Client \$client)." + .'parameter, but the second argument is the context. Declare the client third: ' + .'($input, $context, Client $client).' ); } @@ -123,20 +125,17 @@ private static function assertHandlerSignature(ReflectionClass $class, Reflectio // is_a() this way round asks whether a Client satisfies what was declared, so Client // itself and any interface it implements are accepted. - if ($declared !== 'mixed' && !is_a(Client::class, $declared, true)) { + if ($declared !== 'mixed' && ! is_a(Client::class, $declared, true)) { throw new SchemaException( "Operation {$signature} declares {$declared} as its third parameter, which is " - . "the client. It has to accept " . Client::class . "." + .'the client. It has to accept '.Client::class.'.' ); } } } /** - * @param Query|Command $attribute - * @param ReflectionClass $class - * @param ReflectionMethod $method - * @return Definition + * @param ReflectionClass $class */ private function toDefinition(Query|Command $attribute, ReflectionClass $class, ReflectionMethod $method): Definition { @@ -151,8 +150,8 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, // is the order ContextualPipeline nests them in, so class-level middleware wraps // method-level middleware. $middlewareAttributes = [ - ... $class->getAttributes(Middleware::class), - ... $method->getAttributes(Middleware::class), + ...$class->getAttributes(Middleware::class), + ...$method->getAttributes(Middleware::class), ]; /** @var list>> $middlewares */ @@ -170,4 +169,4 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, $middlewares, ); } -} \ No newline at end of file +} diff --git a/src/Server/Pipeline/ContextualPipeline.php b/src/Server/Pipeline/ContextualPipeline.php index 143e465..6baebce 100644 --- a/src/Server/Pipeline/ContextualPipeline.php +++ b/src/Server/Pipeline/ContextualPipeline.php @@ -1,4 +1,6 @@ -> $middlewares - * @param Closure(Throwable): RpcError $onError - * @param Closure(mixed): (RpcSuccess|RpcError) $destination + * @param list> $middlewares + * @param Closure(Throwable): RpcError $onError + * @param Closure(mixed): (RpcSuccess|RpcError) $destination */ public function __construct( - private array $middlewares, + private array $middlewares, private Closure $onError, private Closure $destination, - ) - { + ) { } /** - * @param TContext $context + * @param TContext $context */ public function execute(mixed $input, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { @@ -63,9 +65,9 @@ public function execute(mixed $input, mixed $context, ResolveInfo $info, Client } /** - * @param MiddlewareContract $middleware - * @param Next $next - * @param TContext $context + * @param MiddlewareContract $middleware + * @param Next $next + * @param TContext $context * @return Next */ private function ring(MiddlewareContract $middleware, Closure $next, mixed $context, ResolveInfo $info, Client $client): Closure diff --git a/src/Server/Preloader.php b/src/Server/Preloader.php index 361eebb..4769747 100644 --- a/src/Server/Preloader.php +++ b/src/Server/Preloader.php @@ -1,4 +1,6 @@ -keyGenerator->generateKey($namespaceAsString, $name); $result = $this->server->query($fqcn, $input, $context, new NullClient()); - if (!$result instanceof RpcSuccess) { + if (! $result instanceof RpcSuccess) { throw new SchemaException("Failed to preload: {$namespaceAsString}.{$name}"); } @@ -74,15 +71,14 @@ private function queryKey(string $namespace, string $name, mixed $input): array } /** - * @param list $preloads - * @param mixed $context + * @param list $preloads * @return list}> */ public function preloadMany(array $preloads, mixed $context): array { return array_map( - fn(array $preload) => $this->preload($preload['namespace'], $preload['name'], $preload['input'], $context), + fn (array $preload) => $this->preload($preload['namespace'], $preload['name'], $preload['input'], $context), $preloads ); } -} \ No newline at end of file +} diff --git a/src/Server/Server.php b/src/Server/Server.php index 9ba1501..3598625 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -1,9 +1,10 @@ -executor = new SchemaExecutor(); $this->errorPresenter = new ErrorPresenter($configuration); } public function query(string $name, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { - if (!$this->registry->has(OperationType::QUERY, $name)) { + if (! $this->registry->has(OperationType::QUERY, $name)) { return $this->errorPresenter->present( new OperationNotFoundException("Operation with name: {$name} was not found."), null, @@ -62,7 +61,7 @@ public function query(string $name, mixed $input, mixed $context, Client $client public function command(string $name, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { - if (!$this->registry->has(OperationType::COMMAND, $name)) { + if (! $this->registry->has(OperationType::COMMAND, $name)) { return $this->errorPresenter->present( new OperationNotFoundException("Operation with name: {$name} was not found."), null, @@ -76,8 +75,8 @@ public function command(string $name, mixed $input, mixed $context, Client $clie private function execute(Operation $operation, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { $middlewareClassNames = [ - ... $this->configuration->middleware, - ... $operation->definition->middleware, + ...$this->configuration->middleware, + ...$operation->definition->middleware, ]; $resolveInfo = new ResolveInfo( @@ -93,7 +92,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli // query()/command() total: a missing container binding or a class that is not a // middleware must surface as an RpcError, not as an uncaught exception. try { - $middlewares = array_map(fn($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); + $middlewares = array_map(fn ($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); $controllerClass = $this->adapter->createController($operation->definition->fullyQualifiedClassName); } catch (Throwable $throwable) { return $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo); @@ -101,7 +100,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli return new ContextualPipeline( middlewares: $middlewares, - onError: fn(Throwable $throwable): RpcError => $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo), + onError: fn (Throwable $throwable): RpcError => $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo), destination: function (mixed $input) use ($controllerClass, $client, $operation, $context, $resolveInfo): RpcSuccess|RpcError { try { $inputValidationResult = $this @@ -147,4 +146,4 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli }, )->execute($input, $context, $resolveInfo, $client); } -} \ No newline at end of file +} diff --git a/src/Typescript/Code/TypescriptFile.php b/src/Typescript/Code/TypescriptFile.php index b1d2162..8a604c5 100644 --- a/src/Typescript/Code/TypescriptFile.php +++ b/src/Typescript/Code/TypescriptFile.php @@ -1,4 +1,6 @@ - $imports Duplicated modules are merged, empty ones dropped. + * @param list $imports Duplicated modules are merged, empty ones dropped. */ public function __construct(string $code = '', array $imports = []) { @@ -66,13 +68,13 @@ public function withImports(TypescriptImport ...$imports): self * A specifier is written before it is known where the file writing it ends up, and what it has * to say depends on that. Whoever does know hands the rule in here. * - * @param Closure(string): string $resolve + * @param Closure(string): string $resolve */ #[NoDiscard] public function withModulesResolvedBy(Closure $resolve): self { return new self($this->code, array_map( - fn(TypescriptImport $import): TypescriptImport => new TypescriptImport( + fn (TypescriptImport $import): TypescriptImport => new TypescriptImport( $resolve($import->from), $import->values, $import->types, @@ -93,8 +95,8 @@ public function append(string|self $block): self return new self( $this->code === '' || $code === '' - ? $this->code . $code - : $this->code . PHP_EOL . PHP_EOL . $code, + ? $this->code.$code + : $this->code.PHP_EOL.PHP_EOL.$code, $imports, ); } @@ -106,12 +108,12 @@ public function toString(): string array_push($importLines, ...self::statementsFor($import)); } - $body = $this->code === '' ? '' : $this->code . PHP_EOL; + $body = $this->code === '' ? '' : $this->code.PHP_EOL; $withoutMarker = $importLines === [] ? $body - : implode(PHP_EOL, $importLines) . PHP_EOL . ($body === '' ? '' : PHP_EOL . $body); + : implode(PHP_EOL, $importLines).PHP_EOL.($body === '' ? '' : PHP_EOL.$body); - return self::MARKER . PHP_EOL . ($withoutMarker === '' ? '' : PHP_EOL . $withoutMarker); + return self::MARKER.PHP_EOL.($withoutMarker === '' ? '' : PHP_EOL.$withoutMarker); } /** @@ -131,7 +133,7 @@ public function toString(): string */ public static function isGenerated(string $contents): bool { - return str_starts_with($contents, self::MARKER . PHP_EOL) + return str_starts_with($contents, self::MARKER.PHP_EOL) || rtrim($contents, "\r\n") === self::MARKER; } @@ -142,7 +144,7 @@ public function __toString(): string } /** - * @param list $imports + * @param list $imports * @return list */ private static function mergeByModule(array $imports): array @@ -159,6 +161,7 @@ private static function mergeByModule(array $imports): array // SORT_STRING: a specifier that looks numeric would otherwise compare as a number. ksort($byModule, SORT_STRING); + return array_values($byModule); } @@ -174,13 +177,13 @@ private static function statementsFor(TypescriptImport $import): array } /** - * @param list $names + * @param list $names */ private static function statement(string $keyword, array $names, string $from): ?string { return $names === [] ? null - : "{$keyword} {" . implode(', ', $names) . '} from ' . Syntax::moduleSpecifier($from) . ';'; + : "{$keyword} {".implode(', ', $names).'} from '.Syntax::moduleSpecifier($from).';'; } /** diff --git a/src/Typescript/Code/TypescriptImport.php b/src/Typescript/Code/TypescriptImport.php index 1465e52..a86f6f3 100644 --- a/src/Typescript/Code/TypescriptImport.php +++ b/src/Typescript/Code/TypescriptImport.php @@ -1,4 +1,6 @@ - Sorted, unique, disjoint from $values. + * @var list Sorted, unique, disjoint from. */ public array $types; /** * Prefer values()/types(); the constructor is for the rare module that gives both. * - * @param list $values - * @param list $types + * @param list $values + * @param list $types + * * @throws CodeGenException When $from cannot be written as a module specifier. * @throws InvalidStringLiteralException When a name is not a valid TypeScript identifier. */ public function __construct( public string $from, - array $values = [], - array $types = [], - ) - { + array $values = [], + array $types = [], + ) { self::assertUsableSpecifier($from); $this->values = self::canonical($values, $from); @@ -59,7 +61,7 @@ public function __construct( } /** - * @param string|list $names + * @param string|list $names */ public static function values(string $from, string|array $names): self { @@ -67,7 +69,7 @@ public static function values(string $from, string|array $names): self } /** - * @param string|list $names + * @param string|list $names */ public static function types(string $from, string|array $names): self { @@ -75,7 +77,7 @@ public static function types(string $from, string|array $names): self } /** - * @param string|list $valuesOrNames + * @param string|list $valuesOrNames */ public static function mixed(string $from, string|array $valuesOrNames): self { @@ -122,13 +124,13 @@ public function isEmpty(): bool } /** - * @param list $names + * @param list $names * @return list */ private static function canonical(array $names, string $from): array { foreach ($names as $name) { - if (!Syntax::isValidIdentifier($name)) { + if (! Syntax::isValidIdentifier($name)) { throw InvalidStringLiteralException::notAValidTypescriptIdentifier( $name, "imported from '{$from}'", @@ -145,7 +147,7 @@ private static function canonical(array $names, string $from): array */ private static function assertUsableSpecifier(string $from): void { - if (!Syntax::isValidModuleSpecifier($from)) { + if (! Syntax::isValidModuleSpecifier($from)) { throw new CodeGenException( "'{$from}' cannot be written as a TypeScript module specifier." ); diff --git a/src/Typescript/Data/EmissionContext.php b/src/Typescript/Data/EmissionContext.php index 250f0e7..087c362 100644 --- a/src/Typescript/Data/EmissionContext.php +++ b/src/Typescript/Data/EmissionContext.php @@ -1,4 +1,6 @@ -)`. - * @param AliasRegistry $registry Every alias this emission produced, e.g. - * ['Order' => '{id:(number & Brand<"orderId">);}']. A consumer emits each entry as - * `export type {$alias} = {$definition}`. + * @param string $type The type. Named types are referenced by their alias name, brands appear + * inline as `(... & Brand<"...">)`. + * @param AliasRegistry $registry Every alias this emission produced, e.g. + * ['Order' => '{id:(number & Brand<"orderId">);}']. A consumer emits each entry as + * `export type {$alias} = {$definition}`. */ public function __construct( - public string $type, + public string $type, public AliasRegistry $registry - ) - { + ) { } public static function fromRawString(string $type): Typescript diff --git a/src/Typescript/Exceptions/InvalidStringLiteralException.php b/src/Typescript/Exceptions/InvalidStringLiteralException.php index 7387953..76228f4 100644 --- a/src/Typescript/Exceptions/InvalidStringLiteralException.php +++ b/src/Typescript/Exceptions/InvalidStringLiteralException.php @@ -1,4 +1,6 @@ - $knownAliases + * @param list $knownAliases */ public static function forAlias(string $alias, array $knownAliases): self { diff --git a/src/Typescript/Exceptions/UnsupportedTypeException.php b/src/Typescript/Exceptions/UnsupportedTypeException.php index 84a9c12..1442e11 100644 --- a/src/Typescript/Exceptions/UnsupportedTypeException.php +++ b/src/Typescript/Exceptions/UnsupportedTypeException.php @@ -1,4 +1,6 @@ - $definitions Alias => definition. + * @param array $definitions Alias => definition. */ public function __construct(array $definitions = []) { @@ -73,6 +75,7 @@ public function usedAliases(): array { $aliases = array_keys($this->definitions); sort($aliases); + return $aliases; } @@ -85,6 +88,7 @@ public function toArray(): array { $definitions = $this->definitions; ksort($definitions); + return $definitions; } } diff --git a/src/Typescript/TypescriptGenerator.php b/src/Typescript/TypescriptGenerator.php index a1d0512..c84092b 100644 --- a/src/Typescript/TypescriptGenerator.php +++ b/src/Typescript/TypescriptGenerator.php @@ -1,4 +1,6 @@ -value->name) : throw UnsupportedTypeException::forNode($node), - LiteralType::STRING, LiteralType::INT, LiteralType::FLOAT, LiteralType::NULL - => json_encode($node->value, JSON_THROW_ON_ERROR), + LiteralType::STRING, LiteralType::INT, LiteralType::FLOAT, LiteralType::NULL => json_encode($node->value, JSON_THROW_ON_ERROR), }; } private static function enum(EnumNode $node): string { $cases = array_map( - fn(UnitEnum $case): string => Syntax::stringLiteral($case->name), + fn (UnitEnum $case): string => Syntax::stringLiteral($case->name), $node->enumClassName::cases(), ); @@ -143,6 +144,7 @@ private function metadata(MetadataNode $node, EmissionContext $context): string if ($node->name !== null) { $alias = $node->name->nameFor($context->io); $context->registry->set($alias, $inner); + return $alias; } @@ -166,7 +168,7 @@ private function struct(StructNode $node, EmissionContext $context): string $properties = []; foreach ($node->properties as $property) { - if (!$property instanceof PropertyNode) { + if (! $property instanceof PropertyNode) { throw UnsupportedTypeException::forNode($property); } @@ -174,7 +176,7 @@ private function struct(StructNode $node, EmissionContext $context): string ? $property->propertyType->isInput() : $property->propertyType->isOutput(); - if (!$isVisible) { + if (! $isVisible) { continue; } @@ -188,19 +190,19 @@ private function struct(StructNode $node, EmissionContext $context): string return '{}'; } - return '{' . implode('', array_map( - fn(array $property): string => "{$property[0]}:{$property[1]};", - $properties, - )) . '}'; + return '{'.implode('', array_map( + fn (array $property): string => "{$property[0]}:{$property[1]};", + $properties, + )).'}'; } /** - * @param UnionNode $node + * @param UnionNode $node */ private function union(UnionNode $node, EmissionContext $context): string { $members = array_map( - fn($member): string => $this->emit($member, $context), + fn ($member): string => $this->emit($member, $context), $node->nodes, ); @@ -211,7 +213,7 @@ private function union(UnionNode $node, EmissionContext $context): string private function intersection(IntersectionNode $node, EmissionContext $context): string { $members = array_map( - fn($member): string => $this->emit($member, $context), + fn ($member): string => $this->emit($member, $context), $node->nodes, ); @@ -221,10 +223,10 @@ private function intersection(IntersectionNode $node, EmissionContext $context): private function tuple(TupleNode $node, EmissionContext $context): string { $members = array_map( - fn(NodeInterface $member): string => $this->emit($member, $context), + fn (NodeInterface $member): string => $this->emit($member, $context), $node->nodes, ); - return '[' . implode(',', $members) . ']'; + return '['.implode(',', $members).']'; } } diff --git a/src/Typescript/Utils/Syntax.php b/src/Typescript/Utils/Syntax.php index 52c1dc4..4c9793a 100644 --- a/src/Typescript/Utils/Syntax.php +++ b/src/Typescript/Utils/Syntax.php @@ -1,4 +1,6 @@ -"; + return "{$baseType} & Brand<".self::stringLiteral($brandName).'>'; } } diff --git a/src/Utils/Arrays.php b/src/Utils/Arrays.php index 1163e84..d31cb11 100644 --- a/src/Utils/Arrays.php +++ b/src/Utils/Arrays.php @@ -1,4 +1,6 @@ - $array - * @param Closure(TArrayKey, TArrayValue): TValue $callback + * @param array $array + * @param Closure(TArrayKey, TArrayValue): TValue $callback * @return array */ public static function mapWithKeys(array $array, Closure $callback): array @@ -21,6 +23,7 @@ public static function mapWithKeys(array $array, Closure $callback): array foreach ($array as $key => $value) { $mapped[$key] = $callback($key, $value); } + return $mapped; } -} \ No newline at end of file +} diff --git a/src/Utils/Assertions.php b/src/Utils/Assertions.php index 30b1ab3..7c49368 100644 --- a/src/Utils/Assertions.php +++ b/src/Utils/Assertions.php @@ -1,22 +1,27 @@ - $className - * @param mixed $value + * + * @param class-string $className + * * @phpstan-assert TInstance $value + * * @return TInstance */ public static function instanceOf(string $className, mixed $value): mixed { - if (!$value instanceof $className) { + if (! $value instanceof $className) { throw new InvalidArgumentException(\sprintf('Expected instance of %s, got %s', $className, gettype($value))); } @@ -28,10 +33,10 @@ public static function instanceOf(string $className, mixed $value): mixed */ public static function string(mixed $value): string { - if (!is_string($value)) { + if (! is_string($value)) { throw new InvalidArgumentException(\sprintf('Expected string, got %s', gettype($value))); } return $value; } -} \ No newline at end of file +} diff --git a/src/Utils/Dicts.php b/src/Utils/Dicts.php index 2e4343f..1e0126c 100644 --- a/src/Utils/Dicts.php +++ b/src/Utils/Dicts.php @@ -1,4 +1,6 @@ - $dict + * + * @param array $dict * @return array */ #[NoDiscard] public static function filterNullValues(array $dict): array { - return array_filter($dict, fn($value) => $value !== null); + return array_filter($dict, fn ($value) => $value !== null); } -} \ No newline at end of file +} diff --git a/src/Utils/Hashs.php b/src/Utils/Hashs.php index bce095f..6c467be 100644 --- a/src/Utils/Hashs.php +++ b/src/Utils/Hashs.php @@ -1,16 +1,16 @@ - base64_encode(...) - |> (fn($x) => strtr($x, '+/', '-_')) - |> (fn($x) => rtrim($x, '=')); + |> (fn ($x) => strtr($x, '+/', '-_')) + |> (fn ($x) => rtrim($x, '=')); } - -} \ No newline at end of file +} diff --git a/src/Utils/Lists.php b/src/Utils/Lists.php index 558498a..eae0609 100644 --- a/src/Utils/Lists.php +++ b/src/Utils/Lists.php @@ -1,4 +1,6 @@ - $list + * + * @param list $list * @return list */ #[NoDiscard] public static function filterNullValues(array $list): array { - return array_filter($list, fn($value) => $value !== null) |> array_values(...); + return array_filter($list, fn ($value) => $value !== null) |> array_values(...); } /** * array_unique preserves keys, which breaks the list. This does not. * * @template TValue of int|string - * @param list $list + * + * @param list $list * @return list */ #[NoDiscard] @@ -31,13 +35,14 @@ public static function unique(array $list): array } /** - * @param list $list + * @param list $list * @return list */ #[NoDiscard] public static function sorted(array $list): array { usort($list, strcmp(...)); + return $list; } -} \ No newline at end of file +} diff --git a/src/Utils/Namespaces.php b/src/Utils/Namespaces.php index 565b25c..31237ea 100644 --- a/src/Utils/Namespaces.php +++ b/src/Utils/Namespaces.php @@ -1,4 +1,6 @@ - $namespaces + * @param array $namespaces * @return array */ public static function buildNamespaceAliasMap(array $namespaces): array @@ -41,6 +43,7 @@ public static function buildNamespaceAliasMap(array $namespaces): array $map[$alias] = self::withoutLeadingSlash($namespace); } } + return $map; } @@ -50,7 +53,7 @@ private static function withoutLeadingSlash(string $className): string } /** - * @param array $namespacesMap + * @param array $namespacesMap */ public static function toFullyQualifiedClassName(string $className, ?string $namespace, array $namespacesMap): string { @@ -65,19 +68,20 @@ public static function toFullyQualifiedClassName(string $className, ?string $nam $lookupKey = $segments[0]; if (array_key_exists($lookupKey, $namespacesMap)) { $remaining = array_slice($segments, 1); + return $remaining === [] ? $namespacesMap[$lookupKey] - : $namespacesMap[$lookupKey] . '\\' . implode('\\', $remaining); + : $namespacesMap[$lookupKey].'\\'.implode('\\', $remaining); } // If reflection->getType()->getName() is used, it already returns a fully qualified class name. // In case we did not find an import match, we check if the classname is imported anywhere already. If this is the case, we return it. - if (array_any($namespacesMap, fn(string $usedClass) => self::isWithin($className, $usedClass))) { + if (array_any($namespacesMap, fn (string $usedClass) => self::isWithin($className, $usedClass))) { return $className; } - if ($namespace !== null && !self::isWithin($className, $namespace)) { - return $namespace . '\\' . $className; + if ($namespace !== null && ! self::isWithin($className, $namespace)) { + return $namespace.'\\'.$className; } return $className; @@ -92,4 +96,4 @@ private static function isWithin(string $className, string $parent): bool { return $className === $parent || str_starts_with($className, "{$parent}\\"); } -} \ No newline at end of file +} diff --git a/src/Utils/Nodes.php b/src/Utils/Nodes.php index 9353f33..04b9174 100644 --- a/src/Utils/Nodes.php +++ b/src/Utils/Nodes.php @@ -1,4 +1,6 @@ -node; } + return $node; } @@ -29,12 +32,12 @@ public static function unwrapMetadata(NodeInterface $node): NodeInterface while ($node instanceof MetadataNode) { $node = $node->node; } + return $node; } /** - * @param list $nodes - * @return bool + * @param list $nodes */ public static function areAllNodesOfSameStructType(array $nodes): bool { @@ -44,12 +47,13 @@ public static function areAllNodesOfSameStructType(array $nodes): bool $stack = $nodes; while ($node = array_pop($stack)) { if ($node instanceof UnionNode) { - array_push($stack, ... $node->nodes); + array_push($stack, ...$node->nodes); + continue; } $declaredNode = self::getDeclaringNode($node); - if (!$declaredNode instanceof StructNode) { + if (! $declaredNode instanceof StructNode) { return false; } @@ -61,4 +65,4 @@ public static function areAllNodesOfSameStructType(array $nodes): bool return true; } -} \ No newline at end of file +} diff --git a/src/Utils/PHPExport.php b/src/Utils/PHPExport.php index 5d3c353..dedd775 100644 --- a/src/Utils/PHPExport.php +++ b/src/Utils/PHPExport.php @@ -1,4 +1,6 @@ -name; $className = self::absolute($enum::class); + return "{$className}::{$name}"; } /** - * @param array $array - * @return string + * @param array $array */ public static function exportArray(array $array): string { @@ -61,11 +63,12 @@ public static function exportArray(array $array): string return '[]'; } - if (!array_is_list($array)) { + if (! array_is_list($array)) { throw new ParserException('Array must be a list'); } $imploded = implode(',', array_map(self::export(...), $array)); + return "[{$imploded}]"; } @@ -81,9 +84,10 @@ public static function export(mixed $value): string if (is_array($value) && array_is_list($value)) { $values = implode(', ', array_map(self::export(...), $value)); + return "[{$values}]"; } return var_export($value, true); } -} \ No newline at end of file +} diff --git a/src/Utils/PhpDoc.php b/src/Utils/PhpDoc.php index 418cba0..167bb04 100644 --- a/src/Utils/PhpDoc.php +++ b/src/Utils/PhpDoc.php @@ -1,4 +1,6 @@ - '[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*', '{fqcn}' => '\\\\?[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)*', ]; + private const string LOCAL_TYPE_REGEX = "/@phpstan-type\s+(?[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s+(?[^@]+)/m"; + private const string IMPORTED_TYPE_REGEX = '/@phpstan-import-type\s+(?{cn})\s+from\s+(?{fqcn})(\s+as\s+(?{cn}))?/'; + private const string GENERICS_TYPE_REGEX = '/@template(-covariant)?\s+(?{cn})/'; public static function normalize(string $docBlocks): string @@ -23,7 +28,6 @@ private static function compileRegex(string $regex): string } /** - * @param false|string|null $docBlock * @return array */ public static function findImportedTypeDefinition(null|false|string $docBlock): array @@ -46,11 +50,11 @@ public static function findImportedTypeDefinition(null|false|string $docBlock): 'typeName' => $importedTypeName, ]; } + return $importedTypes; } /** - * @param false|string|null $docBlock * @return list */ public static function findGenerics(null|false|string $docBlock): array @@ -67,11 +71,11 @@ public static function findGenerics(null|false|string $docBlock): array PREG_SET_ORDER ); - if (!$result) { + if (! $result) { return []; } - return array_map(fn(array $match): string => $match['genericName'], $matches); + return array_map(fn (array $match): string => $match['genericName'], $matches); } /** @return array */ @@ -89,7 +93,7 @@ public static function findLocallyDefinedTypes(null|false|string $docBlock): arr PREG_SET_ORDER ); - if (!$result) { + if (! $result) { return []; } @@ -100,4 +104,4 @@ public static function findLocallyDefinedTypes(null|false|string $docBlock): arr return $localTypes; } -} \ No newline at end of file +} diff --git a/src/Utils/Reflections.php b/src/Utils/Reflections.php index a3c9308..e84e4da 100644 --- a/src/Utils/Reflections.php +++ b/src/Utils/Reflections.php @@ -1,4 +1,6 @@ -getType()) { - throw new ParserException("No type defined."); + if (! $propertyOrParameter->getType()) { + throw new ParserException('No type defined.'); } $typeString = match (true) { $propertyOrParameter instanceof ReflectionProperty => self::getPropertyTypeString($propertyOrParameter), $propertyOrParameter instanceof ReflectionParameter => self::getParameterTypeString($propertyOrParameter), }; + return trim($typeString); } private static function getParameterTypeString(ReflectionParameter $parameter): string { - if (!$parameter->getType()) { - throw new ParserException("No type defined."); + if (! $parameter->getType()) { + throw new ParserException('No type defined.'); } $declaringFnDoc = $parameter->getDeclaringFunction()->getDocComment(); - if (!$declaringFnDoc) { - return (string)$parameter->getType(); + if (! $declaringFnDoc) { + return (string) $parameter->getType(); } - return Regexes::findParamWithNameDeclaration($declaringFnDoc, $parameter->getName()) ?? (string)$parameter->getType(); + return Regexes::findParamWithNameDeclaration($declaringFnDoc, $parameter->getName()) ?? (string) $parameter->getType(); } private static function getPropertyTypeString(ReflectionProperty $property): string { - if (!$property->hasType()) { - throw new ParserException("No type defined."); + if (! $property->hasType()) { + throw new ParserException('No type defined.'); } if ($property->getDocComment()) { - return Regexes::findFirstVarDeclaration($property->getDocComment()) ?? (string)$property->getType(); + return Regexes::findFirstVarDeclaration($property->getDocComment()) ?? (string) $property->getType(); } - if (!$property->isPromoted()) { - return (string)$property->getType(); + if (! $property->isPromoted()) { + return (string) $property->getType(); } $constructorDocBlock = $property->getDeclaringClass()->getConstructor()?->getDocComment(); - if (!$constructorDocBlock) { - return (string)$property->getType(); + if (! $constructorDocBlock) { + return (string) $property->getType(); } - return Regexes::findParamWithNameDeclaration($constructorDocBlock, $property->getName()) ?? (string)$property->getType(); + return Regexes::findParamWithNameDeclaration($constructorDocBlock, $property->getName()) ?? (string) $property->getType(); } public static function getReturnType(ReflectionMethod|ReflectionFunction $reflection): string { $docBlock = $reflection->getDocComment(); - if (!$docBlock) { - return (string)$reflection->getReturnType(); + if (! $docBlock) { + return (string) $reflection->getReturnType(); } - return Regexes::findReturnTypeDeclaration($docBlock) ?? (string)$reflection->getReturnType(); + return Regexes::findReturnTypeDeclaration($docBlock) ?? (string) $reflection->getReturnType(); } -} \ No newline at end of file +} diff --git a/src/Utils/Regexes.php b/src/Utils/Regexes.php index a716b91..375bfd6 100644 --- a/src/Utils/Regexes.php +++ b/src/Utils/Regexes.php @@ -1,10 +1,13 @@ - true, '[' => true, '(' => true, '<' => true]; + private const array CLOSING_BRACKETS = ['}' => true, ']' => true, ')' => true, '>' => true]; /** Characters that leave a type expression unfinished, so it continues after a line break. */ @@ -22,7 +25,7 @@ public static function findReturnTypeDeclaration(string $docBlocks): ?string public static function findParamWithNameDeclaration(string $docBlocks, string $paramName): ?string { - $variableRegex = '/^(?:[&.]|\s)*\$' . preg_quote($paramName, '/') . '(?![a-zA-Z0-9_\x80-\xff])/'; + $variableRegex = '/^(?:[&.]|\s)*\$'.preg_quote($paramName, '/').'(?![a-zA-Z0-9_\x80-\xff])/'; foreach (self::tags($docBlocks) as [$tagName, $body]) { if ($tagName !== 'param') { @@ -36,6 +39,7 @@ public static function findParamWithNameDeclaration(string $docBlocks, string $p return $type; } + return null; } @@ -50,6 +54,7 @@ private static function findTypeOfTag(string $docBlocks, string $tagName): ?stri return $type; } } + return null; } @@ -71,6 +76,7 @@ private static function tags(string $docBlock): array $tags[] = $current; } $current = [$matches['name'], $matches['body']]; + continue; } @@ -80,6 +86,7 @@ private static function tags(string $docBlock): array $tags[] = $current; } $current = null; + continue; } @@ -100,8 +107,9 @@ private static function lines(string $docBlock): array { $withoutDelimiters = preg_replace('#^\s*/\*\*?|\*/\s*$#', '', $docBlock) ?? $docBlock; $lines = preg_split('/\R/', $withoutDelimiters); + return array_map( - static fn(string $line): string => trim(preg_replace('/^\s*\*/', '', $line) ?? $line), + static fn (string $line): string => trim(preg_replace('/^\s*\*/', '', $line) ?? $line), $lines === false ? [$withoutDelimiters] : $lines, ); } @@ -127,39 +135,46 @@ private static function splitLeadingType(string $body): array $char === $quote => $quote = null, default => null, }; + continue; } if ($char === "'" || $char === '"') { $quote = $char; + continue; } if (isset(self::OPENING_BRACKETS[$char])) { $depth++; + continue; } if (isset(self::CLOSING_BRACKETS[$char])) { $depth = max(0, $depth - 1); + continue; } - if ($depth > 0 || !ctype_space($char)) { + if ($depth > 0 || ! ctype_space($char)) { continue; } $nextIndex = $index + strspn($body, " \t\n\r\v\f", $index); if (self::typeContinues($body, $index, $nextIndex)) { $index = $nextIndex - 1; + continue; } $type = rtrim(substr($body, 0, $index)); + return [$type === '' ? null : $type, ltrim(substr($body, $nextIndex))]; } $type = trim($body); + return [$type === '' ? null : $type, '']; } diff --git a/src/Utils/Strings.php b/src/Utils/Strings.php index f4c894b..1d6359e 100644 --- a/src/Utils/Strings.php +++ b/src/Utils/Strings.php @@ -1,4 +1,6 @@ - (string) $value->value, default => $value->name, }; @@ -27,4 +30,4 @@ public static function toString(UnitEnum|string|\Stringable $value): string return (string) $value; } -} \ No newline at end of file +} diff --git a/tests/Adapters/Laravel/CodeGenCommandNamingTest.php b/tests/Adapters/Laravel/CodeGenCommandNamingTest.php index 020be97..02199a8 100644 --- a/tests/Adapters/Laravel/CodeGenCommandNamingTest.php +++ b/tests/Adapters/Laravel/CodeGenCommandNamingTest.php @@ -1,4 +1,6 @@ -invoke(new CodeGenCommand(), $application, $naming); } test('Class::method resolves through the container and binds the instance', function () { - $closure = customNamingGeneratorFor(NamingRule::class . '::name', NamingRule::class); + $closure = customNamingGeneratorFor(NamingRule::class.'::name', NamingRule::class); $bound = new ReflectionFunction($closure)->getClosureThis(); @@ -50,13 +53,13 @@ function customNamingGeneratorFor(string $naming, ?string $resolves = null): mix }); test('an unknown naming mode ends the run with the list of valid ones', function () { - expect(fn() => customNamingGeneratorFor('nonsense')) + expect(fn () => customNamingGeneratorFor('nonsense')) ->toThrow(CodeGenException::class, "Unknown naming mode 'nonsense'"); }); test('a Class::method naming an unknown class or method is an unknown mode', function (string $naming) { - expect(fn() => customNamingGeneratorFor($naming))->toThrow(CodeGenException::class); + expect(fn () => customNamingGeneratorFor($naming))->toThrow(CodeGenException::class); })->with([ 'App\\Nope::name', - NamingRule::class . '::noSuchMethod', + NamingRule::class.'::noSuchMethod', ]); diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index 634dac2..9d0fc8f 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -17,10 +17,8 @@ use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; use Le0daniel\PhpTsBindings\Server\Server; use Mockery; -use ReflectionException; use Throwable; use TypeError; @@ -47,11 +45,11 @@ $operation = new Operation( 'somekey', $operationDefinition, - fn() => $typeParser->parse('array{name: string}'), - fn() => $typeParser->parse('array{id: string, name: string}'), + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), ); - $controllerInstance = new class() { + $controllerInstance = new class () { public function __construct() { } @@ -110,15 +108,16 @@ public function someMethod(array $input, null $context, Client $client): array $operation = new Operation( 'somekey', $operationDefinition, - fn() => $typeParser->parse('array{name: string}'), - fn() => $typeParser->parse('array{id: string, name: string}'), + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), ); - $controllerInstance = new class() { + $controllerInstance = new class () { public function someMethod(array $input, null $context, Client $client): array { $client->success('Saved'); $client->redirect('/docs/123', true); + return ['id' => '123', 'name' => $input['name']]; } }; @@ -176,11 +175,11 @@ public function someMethod(array $input, null $context, Client $client): array $operation = new Operation( 'somekey', $operationDefinition, - fn() => $typeParser->parse('array{name: string}'), - fn() => $typeParser->parse('array{id: string, name: string}'), + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), ); - $controllerInstance = new class() { + $controllerInstance = new class () { public function __construct() { } @@ -216,7 +215,7 @@ public function someMethod(array $input, null $context, Client $client): array 'success' => false, 'details' => [ 'fields' => [ - '__root' => ['validation.missing_property'] + '__root' => ['validation.missing_property'], ], ], 'code' => 422, @@ -248,11 +247,11 @@ public function someMethod(array $input, null $context, Client $client): array $operation = new Operation( 'somekey', $operationDefinition, - fn() => $typeParser->parse('array{name: string}'), - fn() => $typeParser->parse('array{id: string, name: string}'), + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), ); - $controllerInstance = new class() { + $controllerInstance = new class () { public function someMethod(array $input, null $context, Client $client): array { return ['id' => '123', 'name' => $input['name']]; @@ -304,8 +303,8 @@ function staleMiddlewareController(bool $debug): array $operation = new Operation( 'somekey', $operationDefinition, - fn() => $typeParser->parse('array{name: string}'), - fn() => $typeParser->parse('array{id: string, name: string}'), + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), ); $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); @@ -340,11 +339,11 @@ function staleMiddlewareController(bool $debug): array $operation = new Operation( 'somekey', $operationDefinition, - fn() => $typeParser->parse('array{name: string}'), - fn() => $typeParser->parse('array{id: string, name: string}'), + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), ); - $controllerInstance = new class() { + $controllerInstance = new class () { public function someMethod(array $input, null $context, Client $client): array { return ['id' => '123', 'name' => $input['name']]; @@ -383,11 +382,11 @@ public function someMethod(array $input, null $context, Client $client): array $operation = new Operation( 'somekey', $operationDefinition, - fn() => $typeParser->parse('array{name: string}'), - fn() => $typeParser->parse('array{id: string, name: string}'), + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), ); - $controllerInstance = new class() { + $controllerInstance = new class () { public function someMethod(array $input, null $context, Client $client): array { $client->success('Saved'); diff --git a/tests/Executor/SchemaExecutorTest.php b/tests/Executor/SchemaExecutorTest.php index a999c0d..6ff57e7 100644 --- a/tests/Executor/SchemaExecutorTest.php +++ b/tests/Executor/SchemaExecutorTest.php @@ -62,4 +62,3 @@ expect($result)->toBeInstanceOf(Failure::class); } }); - diff --git a/tests/Feature/FullSchemaTest.php b/tests/Feature/FullSchemaTest.php index 54e5de2..d7a2274 100644 --- a/tests/Feature/FullSchemaTest.php +++ b/tests/Feature/FullSchemaTest.php @@ -14,7 +14,7 @@ use Tests\Feature\Mocks\SortByInput; /** - * @param list $noise + * @param list $noise */ function prepare(string $type, string $mode = 'parse', array $noise = []): Closure { @@ -33,7 +33,7 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu } $registryCode = $optimizer->generateOptimizedCode([ - ... $namedNoisePatterns, + ...$namedNoisePatterns, 'node' => $ast, ]); @@ -56,19 +56,19 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu }; } -test("Schema with Paginated generic", function () { - $schema = prepare(Paginated::class . '', 'serialize'); - expect($schema(new Paginated([(object)['name' => 'leo', "id" => "123", 'other' => "wow"]], 2)))->toBeSuccess(); +test('Schema with Paginated generic', function () { + $schema = prepare(Paginated::class.'', 'serialize'); + expect($schema(new Paginated([(object) ['name' => 'leo', 'id' => '123', 'other' => 'wow']], 2)))->toBeSuccess(); }); -test("Test schema with optional email", function () { +test('Test schema with optional email', function () { $schema = prepare(CreateUserWithOptionalEmail::class); expect($schema(['username' => 'other']))->toBeSuccess(); }); test('Test Create User input schema', function () { - $schema = prepare('string|int|' . CreateUserInput::class); + $schema = prepare('string|int|'.CreateUserInput::class); expect($schema('my string value'))->toBeSuccess() ->and($schema(-123))->toBeSuccess() @@ -100,7 +100,7 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu ->and($createUser->email)->toBe('my@mail.test'); }); -test("Create user input schema", function () { +test('Create user input schema', function () { $execute = prepare(CreateObjectInput::class); expect($execute([]))->toBeFailure() @@ -108,22 +108,22 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu ->and($execute(null))->toBeFailure() ->and($execute([ 'name' => 'my name', - 'options' => [] + 'options' => [], ]))->toBeFailure('validation.invalid_type') ->and($execute([ 'name' => 'my name', 'options' => [ 'type' => 'square', - 'radius' => 10 - ] + 'radius' => 10, + ], ]))->toBeFailure(); $result = $execute([ 'name' => 'my name', 'options' => [ 'type' => 'square', - 'dimensions' => 10 - ] + 'dimensions' => 10, + ], ]); expect($result)->toBeSuccess() ->and($result->value)->toBeInstanceOf(CreateObjectInput::class) @@ -135,8 +135,8 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu 'name' => 'my name', 'options' => [ 'type' => 'circle', - 'radius' => 10 - ] + 'radius' => 10, + ], ]); expect($result)->toBeSuccess() ->and($result->value)->toBeInstanceOf(CreateObjectInput::class) @@ -147,7 +147,7 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu test('Execute parsing with custom collection class', function () { $collectionClass = Collection::class; - $executor = prepare("array", 'parse'); + $executor = prepare('array', 'parse'); $validResult = $executor([ ['id' => 'test'], @@ -159,7 +159,7 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu }); test('Execute parsing with custom collection class as record', function () { - $executor = prepare("array", 'parse'); + $executor = prepare('array', 'parse'); $validResult = $executor(['id' => 123]); @@ -168,12 +168,12 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu }); test('Execute serialization with custom record class', function () { - $executor = prepare("array", 'serialize'); + $executor = prepare('array', 'serialize'); $validResult = $executor(['id' => 123]); expect($validResult)->toBeSuccess() - ->and($validResult->value)->toEqual((object)['id' => 123]); + ->and($validResult->value)->toEqual((object) ['id' => 123]); }); test('serialization with custom collection class', function () { @@ -190,30 +190,30 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu $validResult = $executor([ ['name' => 'leo'], - null + null, ]); expect($validResult)->toBeSuccess() ->and($validResult->value)->toEqual([ - (object)['name' => 'leo'], - null + (object) ['name' => 'leo'], + null, ]); $invalidResult = $executor([ ['name' => null], - null + null, ]); expect($invalidResult)->toBeSuccess() ->and($invalidResult->value)->toEqual([ null, - null + null, ]) - ->and($executor("string"))->toBeFailure(); + ->and($executor('string'))->toBeFailure(); }); -test("failing optimized case", function () { - $executor = prepare('array{other?: string, sortBy?: ' . SortByInput::class . '<"name"|"id"|"email">}', noise: [ - 'array{other?: string, sortBy?: ' . SortByInput::class . '<"random"|"other">}', +test('failing optimized case', function () { + $executor = prepare('array{other?: string, sortBy?: '.SortByInput::class.'<"name"|"id"|"email">}', noise: [ + 'array{other?: string, sortBy?: '.SortByInput::class.'<"random"|"other">}', ]); $result = $executor(['sortBy' => ['by' => 'name', 'direction' => 'asc']]); @@ -222,6 +222,6 @@ function prepare(string $type, string $mode = 'parse', array $noise = []): Closu $result = $executor(['sortBy' => ['by' => 'other', 'direction' => 'asc']]); expect($result)->toBeFailure(); - $result = $executor(['other' => "wow"]); + $result = $executor(['other' => 'wow']); expect($result)->toBeSuccess(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Mocks/CreateObjectInput.php b/tests/Feature/Mocks/CreateObjectInput.php index 6e96934..4d76ae0 100644 --- a/tests/Feature/Mocks/CreateObjectInput.php +++ b/tests/Feature/Mocks/CreateObjectInput.php @@ -1,4 +1,6 @@ - $items + * @param list $items */ public function __construct( public readonly array $items, public readonly int $total, - ) - { + ) { } -} \ No newline at end of file +} diff --git a/tests/Feature/Mocks/SortByInput.php b/tests/Feature/Mocks/SortByInput.php index 51bed7e..b328949 100644 --- a/tests/Feature/Mocks/SortByInput.php +++ b/tests/Feature/Mocks/SortByInput.php @@ -1,4 +1,6 @@ - $columns + * @param list $columns */ public function assertValidColumn(array $columns): void { - if (!in_array($this->by, $columns, true)) { - throw new \InvalidArgumentException('Invalid field: ' . $this->by); + if (! in_array($this->by, $columns, true)) { + throw new \InvalidArgumentException('Invalid field: '.$this->by); } } -} \ No newline at end of file +} diff --git a/tests/Feature/Operations/InvalidNameException.php b/tests/Feature/Operations/InvalidNameException.php index 334757f..fa08e22 100644 --- a/tests/Feature/Operations/InvalidNameException.php +++ b/tests/Feature/Operations/InvalidNameException.php @@ -1,4 +1,6 @@ - $data['email']->toStringValue()]; @@ -40,13 +41,13 @@ public function acceptEmail(array $data): array * declared. The whole `user` branch is nullable, which is exactly the shape that used to be * answered as a 200 with `user: null`. * - * @param array{ping: bool} $data + * @param array{ping: bool} $data * @return array{id: int, user: array{name: string}|null} */ - #[Command("test")] + #[Command('test')] public function badOutput(array $data): array { /** @phpstan-ignore-next-line return.type (deliberately wrong, this is the fixture) */ return ['id' => 1, 'user' => ['name' => 123]]; } -} \ No newline at end of file +} diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index ccddb1c..31eadec 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -1,8 +1,10 @@ -type)->toEqual($cachedResponse->type); } - return $regularResponse; } -test("Exceptions are exposed through middleware", function () { - $result = executeOperation( 'test.run', ['name' => 'Leo']); +test('Exceptions are exposed through middleware', function () { + $result = executeOperation('test.run', ['name' => 'Leo']); expect($result)->toBeInstanceOf(RpcSuccess::class) ->and($result->data) @@ -61,7 +62,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { * messages it chose come out the other side as the 422 the client reads. Nothing along the way - * InvalidInputException, ErrorPresenter, RpcError - is allowed to flatten them back to a key. */ -test("a value object rejecting with ValidationException reaches the client as a 422 naming each message", function () { +test('a value object rejecting with ValidationException reaches the client as a 422 naming each message', function () { $error = executeOperation('test.acceptEmail', ['email' => '']); expect($error)->toBeInstanceOf(RpcError::class) @@ -77,11 +78,11 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { ->toBeInstanceOf(RpcSuccess::class); }); -test("A middleware that does not implement the contract yields an RpcError", function () { +test('A middleware that does not implement the contract yields an RpcError', function () { $server = new Server( EagerlyLoadedOperationRegistry::eagerlyDiscover( - __DIR__ . '/Operations', - keyGenerator: new PlainlyExposedKeyGenerator + __DIR__.'/Operations', + keyGenerator: new PlainlyExposedKeyGenerator() ), configuration: new ServerConfiguration()->withMiddlewares(NotAMiddleware::class), ); @@ -102,11 +103,11 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { ->and($result->previous[0]->getMessage())->toContain(NotAMiddleware::class); }); -test("Middleware emits typescript middleware", function () { +test('Middleware emits typescript middleware', function () { $server = new Server( EagerlyLoadedOperationRegistry::eagerlyDiscover( - __DIR__ . '/Operations', - keyGenerator: new PlainlyExposedKeyGenerator + __DIR__.'/Operations', + keyGenerator: new PlainlyExposedKeyGenerator() ), ); @@ -161,7 +162,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { // middleware registered through ServerConfiguration was ignored by both the presenter and the // generated error union - the exception surfaced as a 500. $registry = EagerlyLoadedOperationRegistry::eagerlyDiscover( - __DIR__ . '/Operations', + __DIR__.'/Operations', keyGenerator: new PlainlyExposedKeyGenerator(), ); $configuration = new ServerConfiguration()->withMiddlewares(GloballyThrowingMiddleware::class); @@ -183,7 +184,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { test('an operation level declaration still wins over a global one for the same exception', function () { $registry = EagerlyLoadedOperationRegistry::eagerlyDiscover( - __DIR__ . '/Operations', + __DIR__.'/Operations', keyGenerator: new PlainlyExposedKeyGenerator(), ); $configuration = new ServerConfiguration()->withMiddlewares(GloballyThrowingMiddleware::class); diff --git a/tests/Mocks/Errors/ErrorOperations.php b/tests/Mocks/Errors/ErrorOperations.php index 51d3d90..acebe93 100644 --- a/tests/Mocks/Errors/ErrorOperations.php +++ b/tests/Mocks/Errors/ErrorOperations.php @@ -1,4 +1,6 @@ - array_last(...); + return $io === IO::INPUT ? "{$base}Input" : $base; } } diff --git a/tests/Mocks/Named/ArticleResource.php b/tests/Mocks/Named/ArticleResource.php index 77ddfd9..7126233 100644 --- a/tests/Mocks/Named/ArticleResource.php +++ b/tests/Mocks/Named/ArticleResource.php @@ -1,4 +1,6 @@ -value; + return (int) $this->value; } } diff --git a/tests/Mocks/ValueObjects/CreateAccountInput.php b/tests/Mocks/ValueObjects/CreateAccountInput.php index f43c490..23ceb21 100644 --- a/tests/Mocks/ValueObjects/CreateAccountInput.php +++ b/tests/Mocks/ValueObjects/CreateAccountInput.php @@ -1,4 +1,6 @@ -extend(Tests\TestCase::class)->in('Feature'); +pest()->extend(TestCase::class)->in('Feature'); /* |-------------------------------------------------------------------------- @@ -48,8 +50,8 @@ $value = $this->value; return $this->toBeInstanceOf(Success::class, implode('', [ - "Failed asserting that result is success with: ", - $value instanceof Failure ? $value->issues->serializeToCompleteString() : 'null' + 'Failed asserting that result is success with: ', + $value instanceof Failure ? $value->issues->serializeToCompleteString() : 'null', ])); }); @@ -58,16 +60,17 @@ $value = $this->value; return $this->toBeInstanceOf(Failure::class) - ->when(!is_null($message), function () use ($value, $message) { - if (array_any($value->issues->allFlat(), fn($issue) => $issue->messageOrLocalizationKey === $message)) { + ->when(! is_null($message), function () use ($value, $message) { + if (array_any($value->issues->allFlat(), fn ($issue) => $issue->messageOrLocalizationKey === $message)) { expect(true)->toBeTrue(); + return; } - $messages = array_map(fn(Issue $issue) => $issue->messageOrLocalizationKey, $value->issues->allFlat()); + $messages = array_map(fn (Issue $issue) => $issue->messageOrLocalizationKey, $value->issues->allFlat()); expect(false)->toBeTrue( - "Failed asserting that result is failure with message: {$message}. Got: " . implode(', ', $messages) + "Failed asserting that result is failure with message: {$message}. Got: ".implode(', ', $messages) ); }); }); @@ -79,15 +82,16 @@ return $this->toBeFailure() ->when(is_string($message), function () use ($value, $message, $path) { $issues = $value->issues->at($path); - if (array_any($issues, fn($issue) => $issue->messageOrLocalizationKey === $message)) { + if (array_any($issues, fn ($issue) => $issue->messageOrLocalizationKey === $message)) { expect(true)->toBeTrue(); + return; } - $messages = array_map(fn(Issue $issue) => $issue->messageOrLocalizationKey, $issues); + $messages = array_map(fn (Issue $issue) => $issue->messageOrLocalizationKey, $issues); expect(false)->toBeTrue( - "Failed asserting that result is failure with message: {$message}. Got: " . implode(', ', $messages) + "Failed asserting that result is failure with message: {$message}. Got: ".implode(', ', $messages) ); }) ->and(count($value->issues->at($path)) >= 1) @@ -105,11 +109,12 @@ | */ -function compareToOptimizedAst(NodeInterface $node) { +function compareToOptimizedAst(NodeInterface $node) +{ $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); expect( @@ -138,7 +143,7 @@ function executeParse(NodeInterface|string $node, mixed $data, ParsingOptions $o $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $optimizedAst = $registry->get('node'); @@ -153,13 +158,15 @@ function executeParse(NodeInterface|string $node, mixed $data, ParsingOptions $o if ($normalResult instanceof Success) { $serializedResult = json_encode($normalResult->value, JSON_THROW_ON_ERROR); $serializedOptimizedResult = json_encode($optimizedResult->value, JSON_THROW_ON_ERROR); - expect($serializedResult)->toEqual($serializedOptimizedResult, "Optimized AST should be equal to the normal AST."); + expect($serializedResult)->toEqual($serializedOptimizedResult, 'Optimized AST should be equal to the normal AST.'); + return $normalResult; } $serializedResult = json_encode($normalResult->issues->serializeToFieldsArray(), JSON_THROW_ON_ERROR); $serializedOptimizedResult = json_encode($optimizedResult->issues->serializeToFieldsArray(), JSON_THROW_ON_ERROR); - expect($serializedResult)->toEqual($serializedOptimizedResult, "Optimized AST should be equal to the normal AST."); + expect($serializedResult)->toEqual($serializedOptimizedResult, 'Optimized AST should be equal to the normal AST.'); + return $normalResult; } @@ -169,7 +176,7 @@ function executeSerialize(NodeInterface|string $node, mixed $data, Serialization $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $optimizedAst = $registry->get('node'); @@ -184,13 +191,15 @@ function executeSerialize(NodeInterface|string $node, mixed $data, Serialization if ($normalResult instanceof Success) { $serializedResult = json_encode($normalResult->value, JSON_THROW_ON_ERROR); $serializedOptimizedResult = json_encode($optimizedResult->value, JSON_THROW_ON_ERROR); - expect($serializedResult)->toEqual($serializedOptimizedResult, "Optimized AST should be equal to the normal AST."); + expect($serializedResult)->toEqual($serializedOptimizedResult, 'Optimized AST should be equal to the normal AST.'); + return $normalResult; } $serializedResult = json_encode($normalResult->issues->serializeToFieldsArray(), JSON_THROW_ON_ERROR); $serializedOptimizedResult = json_encode($optimizedResult->issues->serializeToFieldsArray(), JSON_THROW_ON_ERROR); - expect($serializedResult)->toEqual($serializedOptimizedResult, "Optimized AST should be equal to the normal AST."); + expect($serializedResult)->toEqual($serializedOptimizedResult, 'Optimized AST should be equal to the normal AST.'); + return $normalResult; } @@ -199,7 +208,7 @@ function validateAst(NodeInterface $node): void $optimizer = new ASTOptimizer(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $optimizedAst = $registry->get('node'); diff --git a/tests/Unit/Adapters/Laravel/ArtisanOptionsTest.php b/tests/Unit/Adapters/Laravel/ArtisanOptionsTest.php index 8c66511..3dde199 100644 --- a/tests/Unit/Adapters/Laravel/ArtisanOptionsTest.php +++ b/tests/Unit/Adapters/Laravel/ArtisanOptionsTest.php @@ -1,4 +1,6 @@ - $generators + * @param list $generators * @return list */ function classesOf(array $generators): array { - return array_map(fn(object $generator): string => $generator::class, $generators); + return array_map(fn (object $generator): string => $generator::class, $generators); } /** - * @param string|\Closure(TypedOperation): string $naming + * @param string|\Closure(TypedOperation): string $naming */ function usersModuleFor(string|\Closure $naming): string { @@ -112,7 +114,7 @@ function usersModuleFor(string|\Closure $naming): string ]); test('a closure is accepted in place of a naming mode', function () { - $naming = fn(TypedOperation $operation): string => "do_{$operation->definition->name}"; + $naming = fn (TypedOperation $operation): string => "do_{$operation->definition->name}"; expect(usersModuleFor($naming))->toContain('export async function do_get('); }); diff --git a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php index 6bafdc6..b7a3879 100644 --- a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php +++ b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php @@ -1,4 +1,6 @@ - $file) { foreach ($file->imports as $import) { foreach ([...$import->values, ...$import->types] as $imported) { - if (!str_contains($file->code, $imported)) { + if (! str_contains($file->code, $imported)) { $unused[] = "{$name} imports {$imported} from {$import->from}"; } } @@ -116,7 +118,7 @@ function bindingFiles(): array foreach (bindingFiles() as $file) { foreach ($file->imports as $import) { $name = str_replace('./lib/', '', $import->from); - if ($name !== 'types' && !in_array($name, $emitted, true)) { + if ($name !== 'types' && ! in_array($name, $emitted, true)) { $unknown[] = $import->from; } } diff --git a/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php index b1a07c9..5556c31 100644 --- a/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php +++ b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php @@ -1,4 +1,6 @@ - "'{$type->value}'", + fn (ToastType $type): string => "'{$type->value}'", ToastType::cases(), )); diff --git a/tests/Unit/CodeGen/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php index c2ba5fc..5519b8f 100644 --- a/tests/Unit/CodeGen/EmitQueryKeyTest.php +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -1,4 +1,6 @@ - "orders" . ucfirst($operation->definition->name), + fn (TypedOperation $operation): string => 'orders'.ucfirst($operation->definition->name), ); expect($code)->toContain('export function ordersGetQueryKey(input: OrdersGetInput)'); diff --git a/tests/Unit/CodeGen/EmitTanstackQueryTest.php b/tests/Unit/CodeGen/EmitTanstackQueryTest.php index 70352a8..f70dfd8 100644 --- a/tests/Unit/CodeGen/EmitTanstackQueryTest.php +++ b/tests/Unit/CodeGen/EmitTanstackQueryTest.php @@ -1,4 +1,6 @@ - "orders" . ucfirst($operation->definition->name), + fn (TypedOperation $operation): string => 'orders'.ucfirst($operation->definition->name), )->code; expect($code) diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index 3b28e22..619d55b 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -1,4 +1,6 @@ - '{a:string;}']); - expect(fn() => new EmitTypes()->emitFiles([], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), $registry)) + expect(fn () => new EmitTypes()->emitFiles([], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), $registry)) ->toThrow(UnsupportedTypeException::class, 'collides with a declaration'); })->with([ 'the Brand helper generic' => ['Brand'], @@ -63,8 +65,8 @@ function emitTypesFor(string $inputType, string $outputType): string test('the envelope names the client side channel without describing what is in it', function () { $types = emitTypesFor( - 'array{id: \\' . UserId::class . '}', - 'array{email: \\' . Email::class . '}', + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.'}', ); // The key is the library's own - RpcSuccess::jsonSerialize() writes it - so the envelope says @@ -82,8 +84,8 @@ function emitTypesFor(string $inputType, string $outputType): string test('the branches declare exactly what jsonSerialize can put on each of them', function () { $types = emitTypesFor( - 'array{id: \\' . UserId::class . '}', - 'array{email: \\' . Email::class . '}', + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.'}', ); // __metadata rides both outcomes: it is the core's own, always array, written @@ -97,8 +99,8 @@ function emitTypesFor(string $inputType, string $outputType): string test('attribute brands stay inline and declare no alias, only the Brand helper is exported', function () { $types = emitTypesFor( - 'array{id: \\' . UserId::class . '}', - 'array{email: \\' . Email::class . ', slug: \\' . Slug::class . '}', + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.', slug: \\'.Slug::class.'}', ); expect($types) @@ -110,8 +112,8 @@ function emitTypesFor(string $inputType, string $outputType): string test('named types are exported once, nested aliases and inline brands included', function () { $types = emitTypesFor( - 'array{status: \\' . OrderStatus::class . '}', - '\\' . Order::class, + 'array{status: \\'.OrderStatus::class.'}', + '\\'.Order::class, ); expect($types) @@ -123,7 +125,7 @@ function emitTypesFor(string $inputType, string $outputType): string test('the BrandedString utility type keeps its implicit alias', function () { $types = emitTypesFor( 'array{token: BrandedString<\'token\'>}', - 'array{email: \\' . Email::class . '}', + 'array{email: \\'.Email::class.'}', ); expect($types) diff --git a/tests/Unit/CodeGen/ErrorTypescriptTest.php b/tests/Unit/CodeGen/ErrorTypescriptTest.php index 0a76e0c..a846d2b 100644 --- a/tests/Unit/CodeGen/ErrorTypescriptTest.php +++ b/tests/Unit/CodeGen/ErrorTypescriptTest.php @@ -1,4 +1,6 @@ - $middleware + * @param list $middleware */ function typescriptDefinition(string $methodName = 'declaresThrows', array $middleware = []): Definition { diff --git a/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php b/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php index 261d3ae..2015d06 100644 --- a/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php +++ b/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php @@ -1,4 +1,6 @@ -} $input + * @param array{term: non-empty-string, availability?: Types\Availability, limit?: int<1, 100>} $input * @return array{results: list, total: non-negative-int} */ #[Query('catalog')] @@ -52,7 +54,7 @@ public function prepare(Draft $input): Draft } /** - * @param array{sku: Sku, amount: positive-int, price: Money} $input + * @param array{sku: Sku, amount: positive-int, price: Money} $input * @return array{product: Product, restockedAt: DateTimeImmutable} */ #[Command('catalog')] diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php b/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php index d1a0bb1..76886b1 100644 --- a/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php +++ b/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php @@ -1,4 +1,6 @@ ->} $input + * @param array{term: non-empty-string, page?: positive-int, filters: array>} $input * @return array{term: string, page?: int, filters: array>} */ #[Query('shapes')] @@ -78,7 +80,7 @@ public function roundtrip(array $input): array } /** - * @param array{payload: array{id: ProductId, when: DateTimeString<'Y-m-d'>}, dryRun?: bool} $input + * @param array{payload: array{id: ProductId, when: DateTimeString<'Y-m-d'>}, dryRun?: bool} $input * @return array{accepted: bool, id: ProductId} */ #[Command('shapes')] diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountFilter.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountFilter.php index ff57206..fb9e4c2 100644 --- a/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountFilter.php +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountFilter.php @@ -1,4 +1,6 @@ - array_last(...); + return $io === IO::INPUT ? "{$base}Input" : $base; } } diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/Availability.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/Availability.php index 77567f5..e5a1633 100644 --- a/tests/Unit/CodeGen/Mocks/TsOutput/Types/Availability.php +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/Availability.php @@ -1,4 +1,6 @@ - */ diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProductId.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProductId.php index 32559c6..aedb80a 100644 --- a/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProductId.php +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProductId.php @@ -1,4 +1,6 @@ - OutputDirectory::write($directory, ['users.ts' => new TypescriptFile('export type A = 1;')])) + expect(fn () => OutputDirectory::write($directory, ['users.ts' => new TypescriptFile('export type A = 1;')])) ->toThrow(CodeGenException::class, 'Refusing to overwrite users.ts'); // Refused before anything was touched. diff --git a/tests/Unit/CodeGen/PathsTest.php b/tests/Unit/CodeGen/PathsTest.php index bc6b80c..0ec428c 100644 --- a/tests/Unit/CodeGen/PathsTest.php +++ b/tests/Unit/CodeGen/PathsTest.php @@ -1,4 +1,6 @@ -toBe([], implode(PHP_EOL, [ 'The generated TypeScript fixture is out of date:', - ...array_map(fn(string $issue): string => " - {$issue}", $issues), + ...array_map(fn (string $issue): string => " - {$issue}", $issues), '', 'Run `composer codegen:fixture` to regenerate it and verify it still compiles.', ])); diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index bfab2c6..b33dbe5 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -1,4 +1,6 @@ - $classes - * @param list $generators + * @param list $classes + * @param list $generators * @return array */ function generateFor(array $classes, ?array $generators = null): array @@ -113,7 +115,7 @@ function generateFor(array $classes, ?array $generators = null): array // utils collects queryKey — claimed twice and deduped — alongside throwOnFailure, and the // aliases come from both EmitOperations and EmitQueryKey. Type only exports are on their own // line, which is what verbatimModuleSyntax requires. - expect($operations)->toStartWith(TypescriptFile::MARKER . "\n\n" . <<toStartWith(TypescriptFile::MARKER."\n\n".<<<'TypeScript' import type {OperationOptions} from './lib/OperationClient'; import {executeOperation} from './lib/bindings'; import type {Brand, Customer, Order, OrderStatus} from './lib/types'; @@ -131,7 +133,7 @@ function generateFor(array $classes, ?array $generators = null): array new EmitTypes(), new EmitOperationClientBindings(), new EmitTypeUtils(), - new EmitOperations(fn(TypedOperation $operation): string => "orders" . ucfirst($operation->definition->name)), + new EmitOperations(fn (TypedOperation $operation): string => 'orders'.ucfirst($operation->definition->name)), new EmitQueryKey(), new EmitTanstackQuery(), ])['orders.ts']->toString(); @@ -151,14 +153,14 @@ function generateFor(array $classes, ?array $generators = null): array // once it knows where the file went, and the emitters never learn where that is. $files = generateFor([NamedOperations::class]); - expect($files['lib/bindings.ts']->toString())->toStartWith(TypescriptFile::MARKER . "\n\n" . <<toString())->toStartWith(TypescriptFile::MARKER."\n\n".<<<'TypeScript' import {DefaultClient} from './DefaultClient'; import type {OperationClient, OperationOptions} from './OperationClient'; import type {Result} from './types'; TypeScript); - expect($files['lib/utils.ts']->toString())->toStartWith(TypescriptFile::MARKER . "\n\n" . <<toString())->toStartWith(TypescriptFile::MARKER."\n\n".<<<'TypeScript' import {OperationException} from './OperationException'; import type {Result, Success} from './types'; @@ -227,7 +229,7 @@ function generateFor(array $classes, ?array $generators = null): array }); test('fails the run when a generator depends on one that is not registered', function () { - expect(fn() => generateFor([NamedOperations::class], [ + expect(fn () => generateFor([NamedOperations::class], [ new EmitTypes(), new EmitOperationClientBindings(), new EmitTanstackQuery(), @@ -237,25 +239,25 @@ function generateFor(array $classes, ?array $generators = null): array test('fails the run when a generator imports from one that is not registered', function () { // Nothing declares './lib/types' by hand any more: the import comes from EmitTypes, so a run // without it cannot silently emit an operation module pointing at a file no one writes. - expect(fn() => generateFor([NamedOperations::class], [ + expect(fn () => generateFor([NamedOperations::class], [ new EmitOperationClientBindings(), new EmitOperations(), ]))->toThrow(InvalidGeneratorDependencies::class); }); test('fails the run when two classes resolve to the same name with different shapes', function () { - expect(fn() => generateFor([ConflictingNamedOperations::class])) + expect(fn () => generateFor([ConflictingNamedOperations::class])) ->toThrow(UnsupportedTypeException::class, 'Customer'); }); test('fails the whole run when an operation input has no TypeScript representation', function () { - expect(fn() => generateFor([UnrepresentableOperations::class])) + expect(fn () => generateFor([UnrepresentableOperations::class])) ->toThrow(UnsupportedTypeException::class, 'SomeFileInterface'); }); test('fails the run when one alias would have to describe two shapes', function () { // AstValidator runs before any pass, so this never reaches the emitter. - expect(fn() => generateFor([AsymmetricNamedOperations::class])) + expect(fn () => generateFor([AsymmetricNamedOperations::class])) ->toThrow(ParserException::class, 'resolves to one alias "AsymmetricNamed" for both directions'); }); @@ -280,7 +282,7 @@ function generateFor(array $classes, ?array $generators = null): array test('a query and a command generating the same name in one module is an error', function () { // Both would emit `export async function get` and `export type GetResult` into clash.ts. // Previously this produced invalid TypeScript without a warning. - expect(fn() => generateFor([NameClashOperations::class])) + expect(fn () => generateFor([NameClashOperations::class])) ->toThrow(CodeGenException::class, "Two operations generate the name 'get'"); }); @@ -292,8 +294,8 @@ function generateFor(array $classes, ?array $generators = null): array new EmitOperationClientBindings(), new EmitTypeUtils(), new EmitOperations( - fn(TypedOperation $operation): string => $operation->definition->type->lowerCase() - . ucfirst($operation->definition->name), + fn (TypedOperation $operation): string => $operation->definition->type->lowerCase() + .ucfirst($operation->definition->name), ), ]); diff --git a/tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php b/tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php index 18dfe48..20f81a3 100644 --- a/tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php +++ b/tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php @@ -1,4 +1,6 @@ - new ValidationException([])) + expect(fn () => new ValidationException([])) ->toThrow(InvalidArgumentException::class); }); @@ -40,7 +42,7 @@ $issues = $exception->toIssues(); expect($issues)->toHaveCount(2) - ->and(array_map(fn(Issue $issue) => $issue->messageOrLocalizationKey, $issues)) + ->and(array_map(fn (Issue $issue) => $issue->messageOrLocalizationKey, $issues)) ->toBe(['Is required', 'Must contain an @']) ->and($issues[0]->exception)->toBe($exception) ->and($issues[1]->exception)->toBe($exception); diff --git a/tests/Unit/Executor/Mocks/UserSchema.php b/tests/Unit/Executor/Mocks/UserSchema.php index 39a5ce8..eb19aea 100644 --- a/tests/Unit/Executor/Mocks/UserSchema.php +++ b/tests/Unit/Executor/Mocks/UserSchema.php @@ -1,4 +1,6 @@ -parse(new StringNode(), 42); } catch (\Exception $e) { - $this->fail('A failed parse must be returned, not thrown: ' . $e::class); + $this->fail('A failed parse must be returned, not thrown: '.$e::class); } expect($result)->toBeInstanceOf(Failure::class); diff --git a/tests/Unit/Executor/SchemaExecutorTest.php b/tests/Unit/Executor/SchemaExecutorTest.php index e2beae8..84610e8 100644 --- a/tests/Unit/Executor/SchemaExecutorTest.php +++ b/tests/Unit/Executor/SchemaExecutorTest.php @@ -11,7 +11,6 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\ValueObjectNode; use LogicException; use Stringable; -use ValueError; use Tests\Mocks\ValueObjects\CreateAccountInput; use Tests\Mocks\ValueObjects\Email; use Tests\Mocks\ValueObjects\EmptyValidationValueObject; @@ -21,6 +20,7 @@ use Tests\Mocks\ValueObjects\ValidatedAge; use Tests\Mocks\ValueObjects\ValidatedEmail; use Tests\Unit\Executor\Mocks\UserSchema; +use ValueError; test('parse success', function (string $type, mixed $value, mixed $expected) { $result = executeParse($type, $value); @@ -29,6 +29,7 @@ if (is_object($expected)) { expect($result->value)->toBeInstanceOf(get_class($expected)); expect($result->value)->toEqual($expected); + return; } expect($result->value)->toBe($expected); @@ -38,7 +39,7 @@ ['string[]|null', null, null], ['\DateTime|null', null, null], - ['\DateTimeImmutable|null', '2025-09-10T12:09:01+00:00', DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2025-09-10 12:09:01'),], + ['\DateTimeImmutable|null', '2025-09-10T12:09:01+00:00', DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2025-09-10 12:09:01')], ['string|null', 'my value', 'my value'], ['?string', 'my value', 'my value'], @@ -49,60 +50,60 @@ ['array{0: int,1: string}', [1, 'my value'], [1, 'my value']], ['array{id?: string, name: string}', ['id' => 'my id', 'name' => 'my name'], ['id' => 'my id', 'name' => 'my name']], ['array{id?: string, name: string}', ['name' => 'my name', 'other' => ''], ['name' => 'my name']], - ['object{id?: string, name: string}', ['name' => 'my name', 'other' => ''], (object)['name' => 'my name']], - ['object{id?: string, name: string}|null', ['name' => 'my name', 'other' => ''], (object)['name' => 'my name']], + ['object{id?: string, name: string}', ['name' => 'my name', 'other' => ''], (object) ['name' => 'my name']], + ['object{id?: string, name: string}|null', ['name' => 'my name', 'other' => ''], (object) ['name' => 'my name']], ['object{id?: string, name: string}|null', null, null], ['array', ['my value' => 1], ['my value' => 1]], - [UserSchema::class, (object)['username' => 'my name', 'age' => 1, "email" => "leo@me.test"], new UserSchema(1, 'leo@me.test', 'my name')], - [UserSchema::class, ['username' => 'my name', 'age' => 1, "email" => "leo@me.test"], new UserSchema(1, 'leo@me.test', 'my name')], + [UserSchema::class, (object) ['username' => 'my name', 'age' => 1, 'email' => 'leo@me.test'], new UserSchema(1, 'leo@me.test', 'my name')], + [UserSchema::class, ['username' => 'my name', 'age' => 1, 'email' => 'leo@me.test'], new UserSchema(1, 'leo@me.test', 'my name')], [ '(array{id:positive-int}|array{token:string})&array{reason:string}', - ['id' => 1, "reason" => "my value"], - ['id' => 1, "reason" => "my value"], + ['id' => 1, 'reason' => 'my value'], + ['id' => 1, 'reason' => 'my value'], ], [ '(array{id:positive-int}|array{token:string})&array{reason:string}', - ['token' => "secret", "reason" => "my value"], - ['token' => "secret", "reason" => "my value"], + ['token' => 'secret', 'reason' => 'my value'], + ['token' => 'secret', 'reason' => 'my value'], ], [ '(object{id:positive-int}|object{token:string})&object{reason:string}', - ['id' => 1, "reason" => "my value"], - (object)['id' => 1, "reason" => "my value"], + ['id' => 1, 'reason' => 'my value'], + (object) ['id' => 1, 'reason' => 'my value'], ], [ '(object{id:positive-int}|object{token:string})&object{reason:string}', - ['token' => "secret", "reason" => "my value"], - (object)['token' => "secret", "reason" => "my value"], + ['token' => 'secret', 'reason' => 'my value'], + (object) ['token' => 'secret', 'reason' => 'my value'], ], [ 'Pick', - ['id' => 1, "name" => "my name"], - (object)['id' => 1], + ['id' => 1, 'name' => 'my name'], + (object) ['id' => 1], ], [ 'Pick', - ['id' => 1, "name" => "my name"], + ['id' => 1, 'name' => 'my name'], ['id' => 1], ], [ 'Omit', - ['id' => 1, "name" => "my name"], - (object)["name" => "my name"], + ['id' => 1, 'name' => 'my name'], + (object) ['name' => 'my name'], ], [ 'Omit', - ['id' => 1, "name" => "my name"], - ["name" => "my name"], + ['id' => 1, 'name' => 'my name'], + ['name' => 'my name'], ], // Value objects [Email::class, 'ada@example.test', Email::fromStringValue('ada@example.test')], [UserId::class, 42, UserId::fromIntValue(42)], - ['?\\' . Email::class, null, null], + ['?\\'.Email::class, null, null], [StatusEnum::class, 'active', StatusEnum::ACTIVE], ]); @@ -114,6 +115,7 @@ if (is_object($expected)) { expect($result->value)->toBeInstanceOf(get_class($expected)); expect($result->value)->toEqual($expected); + return; } expect($result->value)->toBe($expected); @@ -139,83 +141,82 @@ public function __toString(): string ['string|int', 1, 1], ['array{int, string}', [1, 'my value'], [1, 'my value']], ['array{0: int,1: string}', [1, 'my value'], [1, 'my value']], - ['array{id?: string, name: string}', ['id' => 'my id', 'name' => 'my name'], (object)['id' => 'my id', 'name' => 'my name']], - ['array{id?: string, name: string}', ['name' => 'my name', 'other' => ''], (object)['name' => 'my name']], - ['object{id?: string, name: string}', ['name' => 'my name', 'other' => ''], (object)['name' => 'my name']], - ['object{id?: string, name: string}|null', ['name' => 'my name', 'other' => ''], (object)['name' => 'my name']], + ['array{id?: string, name: string}', ['id' => 'my id', 'name' => 'my name'], (object) ['id' => 'my id', 'name' => 'my name']], + ['array{id?: string, name: string}', ['name' => 'my name', 'other' => ''], (object) ['name' => 'my name']], + ['object{id?: string, name: string}', ['name' => 'my name', 'other' => ''], (object) ['name' => 'my name']], + ['object{id?: string, name: string}|null', ['name' => 'my name', 'other' => ''], (object) ['name' => 'my name']], ['object{id?: string, name: string}|null', null, null], ['array', ['my value', 'my other value'], ['my value', 'my other value']], - ['array', ['my value' => 1], (object)['my value' => 1]], + ['array', ['my value' => 1], (object) ['my value' => 1]], - [UserSchema::class, new UserSchema(1, 'leo@me.test', 'my name'), (object)['username' => 'my name', 'age' => 1]], + [UserSchema::class, new UserSchema(1, 'leo@me.test', 'my name'), (object) ['username' => 'my name', 'age' => 1]], [ '(array{id:positive-int}|array{token:string})&array{reason:string}', - ['id' => 1, "reason" => "my value"], - (object)['id' => 1, "reason" => "my value"], + ['id' => 1, 'reason' => 'my value'], + (object) ['id' => 1, 'reason' => 'my value'], ], [ '(array{id:positive-int}|array{token:string})&array{reason:string}', - ['token' => "secret", "reason" => "my value"], - (object)['token' => "secret", "reason" => "my value"], + ['token' => 'secret', 'reason' => 'my value'], + (object) ['token' => 'secret', 'reason' => 'my value'], ], [ '(object{id:positive-int}|object{token:string})&object{reason:string}', - ['id' => 1, "reason" => "my value"], - (object)['id' => 1, "reason" => "my value"], + ['id' => 1, 'reason' => 'my value'], + (object) ['id' => 1, 'reason' => 'my value'], ], [ '(object{id:positive-int} | object{token:string}) & object{reason:string}', - ['token' => "secret", "reason" => "my value"], - (object)['token' => "secret", "reason" => "my value"], + ['token' => 'secret', 'reason' => 'my value'], + (object) ['token' => 'secret', 'reason' => 'my value'], ], [ 'Pick', - (object)['id' => 1, "name" => "my name"], - (object)['id' => 1], + (object) ['id' => 1, 'name' => 'my name'], + (object) ['id' => 1], ], [ 'Pick', - ['id' => 1, "name" => "my name"], - (object)['id' => 1], + ['id' => 1, 'name' => 'my name'], + (object) ['id' => 1], ], [ 'Omit', - (object)['id' => 1, "name" => "my name"], - (object)["name" => "my name"], + (object) ['id' => 1, 'name' => 'my name'], + (object) ['name' => 'my name'], ], [ 'Omit', - ['id' => 1, "name" => "my name", "other" => 'string'], - (object)["name" => "my name", "other" => 'string'], + ['id' => 1, 'name' => 'my name', 'other' => 'string'], + (object) ['name' => 'my name', 'other' => 'string'], ], [ - 'Omit< \\' . UserSchema::class . ', "age">', + 'Omit< \\'.UserSchema::class.', "age">', new UserSchema(12, 'email', 'username'), - (object)["username" => "username"], + (object) ['username' => 'username'], ], [ - 'Pick< \\' . UserSchema::class . ', "age">', + 'Pick< \\'.UserSchema::class.', "age">', new UserSchema(12, 'email', 'username'), - (object)['age' => 12], + (object) ['age' => 12], ], // Value objects [Email::class, Email::fromStringValue('ada@example.test'), 'ada@example.test'], [UserId::class, UserId::fromIntValue(42), 42], - ['?\\' . Email::class, null, null], + ['?\\'.Email::class, null, null], // Serializes by backing value, NOT by the enum case name [StatusEnum::class, StatusEnum::INACTIVE, 'inactive'], - ['\\' . Email::class . '[]', [Email::fromStringValue('a@b.test')], ['a@b.test']], + ['\\'.Email::class.'[]', [Email::fromStringValue('a@b.test')], ['a@b.test']], [ - 'array{id: \\' . UserId::class . ', email: \\' . Email::class . '}', + 'array{id: \\'.UserId::class.', email: \\'.Email::class.'}', ['id' => UserId::fromIntValue(7), 'email' => Email::fromStringValue('ada@example.test')], - (object)['id' => 7, 'email' => 'ada@example.test'], + (object) ['id' => 7, 'email' => 'ada@example.test'], ], ]); - test('serialization with partial failures', function () { /** @var Success $result */ $result = executeSerialize('array{name: string|null, other: string}', [ @@ -224,7 +225,7 @@ public function __toString(): string ]); expect($result)->toBeSuccess() - ->and($result->value)->toEqual((object)[ + ->and($result->value)->toEqual((object) [ 'name' => null, 'other' => 'my value', ])->and($result->isPartial())->toBeTrue(); @@ -239,7 +240,7 @@ public function __toString(): string expect($result)->toBeFailureAt('name'); expect($result->issues->serializeToFieldsArray())->toEqual([ 'name' => [ - 'validation.missing_property' + 'validation.missing_property', ], ]); }); @@ -249,7 +250,6 @@ public function __toString(): string * Value objects * --------------------------------------------------------------------------- */ - test('value object rejects the wrong primitive type', function () { expect(executeParse(UserId::class, '42'))->toBeFailure('validation.invalid_type'); expect(executeParse(Email::class, 123))->toBeFailure('validation.invalid_type'); @@ -268,7 +268,7 @@ public function __toString(): string expect(executeParse(UserId::class, -1))->toBeFailure('validation.invalid_value'); $result = executeParse(Email::class, 'not-an-email'); - $messages = array_map(fn(Issue $issue) => $issue->messageOrLocalizationKey, $result->issues->allFlat()); + $messages = array_map(fn (Issue $issue) => $issue->messageOrLocalizationKey, $result->issues->allFlat()); expect($messages)->not->toContain('internal_error') ->and($messages)->not->toContain('validation.invalid_type'); }); @@ -299,8 +299,8 @@ public function __toString(): string }); test('a throwing accessor never escapes the executor', function () { - expect(fn() => executeSerialize( - 'array{a: \\' . ExplodingValueObject::class . '}', + expect(fn () => executeSerialize( + 'array{a: \\'.ExplodingValueObject::class.'}', ['a' => ExplodingValueObject::fromStringValue('x')], ))->not->toThrow(LogicException::class); }); @@ -308,12 +308,12 @@ public function __toString(): string test('a throwing accessor degrades to null at a nullable boundary', function () { /** @var Success $result */ $result = executeSerialize( - 'array{a: ?\\' . ExplodingValueObject::class . '}', + 'array{a: ?\\'.ExplodingValueObject::class.'}', ['a' => ExplodingValueObject::fromStringValue('x')], ); expect($result)->toBeSuccess() - ->and($result->value)->toEqual((object)['a' => null]) + ->and($result->value)->toEqual((object) ['a' => null]) ->and($result->isPartial())->toBeTrue(); }); @@ -321,7 +321,7 @@ public function __toString(): string // Not in the 'parse success' dataset: that helper compares with toBe(), which is identity // based for objects nested inside an array. $struct = executeParse( - 'array{id: \\' . UserId::class . ', email: \\' . Email::class . '}', + 'array{id: \\'.UserId::class.', email: \\'.Email::class.'}', ['id' => 1, 'email' => 'ada@example.test'], ); @@ -331,7 +331,7 @@ public function __toString(): string 'id' => UserId::fromIntValue(1), ]); - $list = executeParse('\\' . Email::class . '[]', ['a@b.test', 'c@d.test']); + $list = executeParse('\\'.Email::class.'[]', ['a@b.test', 'c@d.test']); expect($list)->toBeSuccess() ->and($list->value)->toEqual([ @@ -345,7 +345,6 @@ public function __toString(): string * Value objects rejecting with ValidationException * --------------------------------------------------------------------------- */ - test('a ValidationException names the message the client sees, instead of validation.invalid_value', function () { $result = executeParse(ValidatedAge::class, 12); @@ -377,7 +376,7 @@ public function __toString(): string // Only one property may fail here: StructHandler::parse() returns on the first invalid one, so // a second rejecting field would never be reached. $result = executeParse( - 'array{email: \\' . ValidatedEmail::class . ', age: \\' . ValidatedAge::class . '}', + 'array{email: \\'.ValidatedEmail::class.', age: \\'.ValidatedAge::class.'}', ['email' => '', 'age' => 30], ); @@ -388,7 +387,7 @@ public function __toString(): string }); test('a ValidationException thrown for a list entry is reported at that index', function () { - $result = executeParse('\\' . ValidatedEmail::class . '[]', ['a@b.test', 'nope']); + $result = executeParse('\\'.ValidatedEmail::class.'[]', ['a@b.test', 'nope']); expect($result->issues->serializeToFieldsArray())->toBe([ '1' => ['Email must contain an @'], @@ -419,7 +418,6 @@ public function __toString(): string * DateTimeString * --------------------------------------------------------------------------- */ - test('DateTimeString parses a string into a DateTimeImmutable', function (string $type, string $value, string $expected) { $result = executeParse($type, $value); @@ -505,7 +503,7 @@ public function __toString(): string ]); test('value object issues are reported at the right field path', function () { - $result = executeParse('array{email: \\' . Email::class . '}', ['email' => 'nope']); + $result = executeParse('array{email: \\'.Email::class.'}', ['email' => 'nope']); expect($result)->toBeFailureAt('email', 'validation.invalid_value'); }); @@ -528,8 +526,8 @@ public function __toString(): string }); test('nullable value objects tolerate null at the union boundary', function () { - expect(executeSerialize('?\\' . Email::class, null))->toBeSuccess(); - expect(executeParse('?\\' . Email::class, null))->toBeSuccess(); + expect(executeSerialize('?\\'.Email::class, null))->toBeSuccess(); + expect(executeParse('?\\'.Email::class, null))->toBeSuccess(); }); test('a castable class hydrates and serializes its value object properties', function () { @@ -546,6 +544,5 @@ public function __toString(): string $serialized = executeSerialize(CreateAccountInput::class, $parsed->value); expect($serialized)->toBeSuccess() - ->and($serialized->value)->toEqual((object)['email' => 'ada@example.test', 'ownerId' => 7]); + ->and($serialized->value)->toEqual((object) ['email' => 'ada@example.test', 'ownerId' => 7]); }); - diff --git a/tests/Unit/Executor/StrictnessTest.php b/tests/Unit/Executor/StrictnessTest.php index c4c0013..8295918 100644 --- a/tests/Unit/Executor/StrictnessTest.php +++ b/tests/Unit/Executor/StrictnessTest.php @@ -1,17 +1,19 @@ - $value], new ParsingOptions(coercePrimitives: true)); @@ -90,7 +92,7 @@ 'tuple given a scalar' => ['array{string, int}', 'nope'], 'tuple too short' => ['array{string, int}', ['a']], 'tuple too long' => ['array{string, int}', ['a', 1, true]], - 'enum case literal' => [\Tests\Mocks\ResultEnum::class . '::SUCCESS', 'NOPE'], + 'enum case literal' => [ResultEnum::class.'::SUCCESS', 'NOPE'], ]); test('every serialization failure carries at least one issue', function (string $type, mixed $value) { @@ -102,7 +104,7 @@ 'list given a scalar' => ['list', 'nope'], 'record given a scalar' => ['array', 'nope'], 'tuple given a scalar' => ['array{string, int}', 'nope'], - 'enum given a foreign value' => [\Tests\Mocks\ResultEnum::class, 'NOPE'], + 'enum given a foreign value' => [ResultEnum::class, 'NOPE'], ]); test('serializing a tuple shorter than its arity fails without reading past the end', function () { diff --git a/tests/Unit/Executor/UnionAndEnumDispatchTest.php b/tests/Unit/Executor/UnionAndEnumDispatchTest.php index 2f2fc86..65569ed 100644 --- a/tests/Unit/Executor/UnionAndEnumDispatchTest.php +++ b/tests/Unit/Executor/UnionAndEnumDispatchTest.php @@ -1,4 +1,6 @@ -parse( "array{kind: 'a', a: string}|array{kind: 'b', b: int}|array{kind: 'c', c: bool}", @@ -108,18 +111,18 @@ test('enum values resolve by name and reject unknown names', function () { // Executed directly: the shared helper json_encodes results, which a non-backed enum cannot do. - $node = new TypeParser()->parse(Tests\Mocks\ResultEnum::class); + $node = new TypeParser()->parse(ResultEnum::class); $executor = new SchemaExecutor(); - expect($executor->parse($node, 'SUCCESS')->value)->toBe(Tests\Mocks\ResultEnum::SUCCESS) - ->and($executor->parse($node, 'FAILURE')->value)->toBe(Tests\Mocks\ResultEnum::FAILURE) + expect($executor->parse($node, 'SUCCESS')->value)->toBe(ResultEnum::SUCCESS) + ->and($executor->parse($node, 'FAILURE')->value)->toBe(ResultEnum::FAILURE) ->and($executor->parse($node, 'NOT_A_CASE'))->toBeFailure() ->and($executor->parse($node, 1))->toBeFailure() ->and($executor->parse($node, 'OTHER'))->toBeFailure(); }); test('struct property direction filtering is unchanged by precomputed partitions', function () { - $node = new TypeParser()->parse(Tests\Unit\Executor\Mocks\UserSchema::class); + $node = new TypeParser()->parse(UserSchema::class); expect(executeParse($node, ['username' => 'ada', 'email' => 'ada@example.com', 'age' => 30]))->toBeSuccess(); }); diff --git a/tests/Unit/Parser/ASTOptimizerTest.php b/tests/Unit/Parser/ASTOptimizerTest.php index fad0179..8b528b0 100644 --- a/tests/Unit/Parser/ASTOptimizerTest.php +++ b/tests/Unit/Parser/ASTOptimizerTest.php @@ -1,4 +1,6 @@ - $schemas + * @param array $schemas */ function optimizePooled(array $schemas): CachedTypeRegistry { $parser = new TypeParser(); $nodes = array_map( - static fn(NodeInterface|string $schema) => is_string($schema) ? $parser->parse($schema) : $schema, + static fn (NodeInterface|string $schema) => is_string($schema) ? $parser->parse($schema) : $schema, $schemas, ); @@ -33,6 +36,7 @@ function optimizePooled(array $schemas): CachedTypeRegistry /** @var CachedTypeRegistry $registry */ $registry = eval("return {$code};"); + return $registry; } @@ -40,8 +44,8 @@ function optimizePooled(array $schemas): CachedTypeRegistry * Asserts the optimized schema behaves exactly like the freshly parsed one, in BOTH pool orders — * a collision's direction flips with iteration order, so one order alone can hide the bug. * - * @param array $schemas - * @param array> $probes schema key => values to parse + * @param array $schemas + * @param array> $probes schema key => values to parse */ function assertPooledParity(array $schemas, array $probes): void { @@ -59,7 +63,7 @@ function assertPooledParity(array $schemas, array $probes): void $encoded = json_encode($value, JSON_THROW_ON_ERROR); expect($optimized::class)->toBe( $raw::class, - "Schema '{$key}' with {$encoded} diverged (pool order: " . implode(',', array_keys($ordered)) . ')', + "Schema '{$key}' with {$encoded} diverged (pool order: ".implode(',', array_keys($ordered)).')', ); if ($raw instanceof Success) { @@ -124,7 +128,7 @@ function assertPooledParity(array $schemas, array $probes): void test('C3: unions differing only in discriminator do not share an entry', function () { $discriminated = new TypeParser()->parse("array{kind: 'a', v: string}|array{kind: 'b', v: int}"); - $plain = new Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode($discriminated->nodes); + $plain = new UnionNode($discriminated->nodes); expect($discriminated->exportPhpCode())->not->toBe($plain->exportPhpCode()); @@ -154,12 +158,12 @@ function assertPooledParity(array $schemas, array $probes): void $schemas[$letter] = new TypeParser()->parse("array{{$letter}: string}"); } - expect(fn() => $optimizer->generateOptimizedCode($schemas)) + expect(fn () => $optimizer->generateOptimizedCode($schemas)) ->toThrow(RuntimeException::class, 'collision'); }); test('node keys starting with a hash are rejected so they cannot shadow interned ids', function () { - expect(fn() => new ASTOptimizer()->generateOptimizedCode([ + expect(fn () => new ASTOptimizer()->generateOptimizedCode([ '#leaf_evil' => new TypeParser()->parse('string'), ]))->toThrow(RuntimeException::class, 'MUST not start with a # character'); }); diff --git a/tests/Unit/Parser/Constraints/IntRangeTest.php b/tests/Unit/Parser/Constraints/IntRangeTest.php index 6739cdc..01b2916 100644 --- a/tests/Unit/Parser/Constraints/IntRangeTest.php +++ b/tests/Unit/Parser/Constraints/IntRangeTest.php @@ -1,4 +1,6 @@ -exportPhpCode()) - ->toBe('new \\' . IntRange::class . '(5,10)') + ->toBe('new \\'.IntRange::class.'(5,10)') ->and(new IntRange(min: 1)->exportPhpCode()) - ->toBe('new \\' . IntRange::class . '(1,NULL)'); + ->toBe('new \\'.IntRange::class.'(1,NULL)'); }); it('names its bounds in diagnostics', function () { - expect((string)new IntRange(0, 100))->toBe('IntRange(0, 100)') - ->and((string)new IntRange(min: 1))->toBe('IntRange(1, max)') - ->and((string)new IntRange(max: -1))->toBe('IntRange(min, -1)'); + expect((string) new IntRange(0, 100))->toBe('IntRange(0, 100)') + ->and((string) new IntRange(min: 1))->toBe('IntRange(1, max)') + ->and((string) new IntRange(max: -1))->toBe('IntRange(min, -1)'); }); diff --git a/tests/Unit/Parser/Constraints/ListLengthTest.php b/tests/Unit/Parser/Constraints/ListLengthTest.php index 50fb5b8..c086aeb 100644 --- a/tests/Unit/Parser/Constraints/ListLengthTest.php +++ b/tests/Unit/Parser/Constraints/ListLengthTest.php @@ -1,4 +1,6 @@ -exportPhpCode()) - ->toBe('new \\' . ListLength::class . '(1,NULL)'); + ->toBe('new \\'.ListLength::class.'(1,NULL)'); }); it('names its bounds in diagnostics', function () { - expect((string)new ListLength(min: 1))->toBe('ListLength(1, max)'); + expect((string) new ListLength(min: 1))->toBe('ListLength(1, max)'); }); diff --git a/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php b/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php index d0cf16d..477841d 100644 --- a/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php +++ b/tests/Unit/Parser/Constraints/PhpstanRefinementsTest.php @@ -1,4 +1,6 @@ -', []))->toBeFailure(IssueMessage::INVALID_MIN->value); expect(executeParse('non-empty-list', [1]))->toBeSuccess(); diff --git a/tests/Unit/Parser/Constraints/StringConstraintsTest.php b/tests/Unit/Parser/Constraints/StringConstraintsTest.php index 32d39e7..aaa1b18 100644 --- a/tests/Unit/Parser/Constraints/StringConstraintsTest.php +++ b/tests/Unit/Parser/Constraints/StringConstraintsTest.php @@ -1,4 +1,6 @@ - $issue->messageOrLocalizationKey, + fn ($issue) => $issue->messageOrLocalizationKey, $result->issues->allFlat(), ); diff --git a/tests/Unit/Parser/Data/ParsingContextTest.php b/tests/Unit/Parser/Data/ParsingContextTest.php index 8274565..85f8028 100644 --- a/tests/Unit/Parser/Data/ParsingContextTest.php +++ b/tests/Unit/Parser/Data/ParsingContextTest.php @@ -10,7 +10,7 @@ test('from class reflection', function () { // Reads all the context out of the file. $context = ParsingScope::fromReflectionClass(new ReflectionClass(MyUserClass::class)); - $fromFileContext = ParsingScope::fromFilePath(__DIR__ . '/Stubs/MyUserClass.php'); + $fromFileContext = ParsingScope::fromFilePath(__DIR__.'/Stubs/MyUserClass.php'); expect(serialize($context)) ->toBe(serialize($fromFileContext)) @@ -38,7 +38,7 @@ ]); }); -test("Extensive PHP Doc type declaration", function () { +test('Extensive PHP Doc type declaration', function () { $fromFileContext = ParsingScope::fromClassString(ComplexPhpDoc::class); expect($fromFileContext->localTypes)->toBe([ @@ -49,4 +49,4 @@ 'RejectedInput' => 'array{ id: positive-int, status: OrderStatus::REJECTED, reason: string, tips?: string|null }', 'ChangeOrderStatusInput' => 'ReadyToOrderInput|WaitingOnApprovalInput|OrderedInput|CompletedInput|RejectedInput', ]); -}); \ No newline at end of file +}); diff --git a/tests/Unit/Parser/Data/Stubs/AccountData.php b/tests/Unit/Parser/Data/Stubs/AccountData.php index 9c25d62..dcbd516 100644 --- a/tests/Unit/Parser/Data/Stubs/AccountData.php +++ b/tests/Unit/Parser/Data/Stubs/AccountData.php @@ -1,4 +1,6 @@ -id, $accountData->name); } -} \ No newline at end of file +} diff --git a/tests/Unit/Parser/Data/Stubs/Address.php b/tests/Unit/Parser/Data/Stubs/Address.php index e151cf3..32d7271 100644 --- a/tests/Unit/Parser/Data/Stubs/Address.php +++ b/tests/Unit/Parser/Data/Stubs/Address.php @@ -1,4 +1,6 @@ -image); } -} \ No newline at end of file +} diff --git a/tests/Unit/Parser/Data/Stubs/MyUserClass.php b/tests/Unit/Parser/Data/Stubs/MyUserClass.php index 8a8e2b3..b31d1b8 100644 --- a/tests/Unit/Parser/Data/Stubs/MyUserClass.php +++ b/tests/Unit/Parser/Data/Stubs/MyUserClass.php @@ -1,4 +1,6 @@ -name = 'name'; } -} \ No newline at end of file +} diff --git a/tests/Unit/Parser/Data/Stubs/SomeAbstractClass.php b/tests/Unit/Parser/Data/Stubs/SomeAbstractClass.php index b18ecf2..5c3c90e 100644 --- a/tests/Unit/Parser/Data/Stubs/SomeAbstractClass.php +++ b/tests/Unit/Parser/Data/Stubs/SomeAbstractClass.php @@ -1,9 +1,12 @@ - */ function significant(string $input): array { return array_values(array_map( - fn(Token $token) => "{$token->type->name}({$token->value})", + fn (Token $token) => "{$token->type->name}({$token->value})", array_filter( lex($input), - fn(Token $token) => !$token->isAnyTypeOf(TokenType::WHITESPACE, TokenType::EOF), + fn (Token $token) => ! $token->isAnyTypeOf(TokenType::WHITESPACE, TokenType::EOF), ), )); } /** * One type string per line. + * * @return list */ function lines(string $block): array @@ -42,6 +44,7 @@ function lines(string $block): array /** * The real strings used across the existing test suite, plus complex PHPStan constructs * the old tokenizer cannot express. + * * @return list */ function corpus(): array @@ -130,10 +133,9 @@ class-string * A. Corpus sweep * --------------------------------------------------------------------------------------- */ - test('the token stream is lossless for every type string', function () { foreach (corpus() as $input) { - $roundTrip = implode('', array_map(fn(Token $token) => $token->value, lex($input))); + $roundTrip = implode('', array_map(fn (Token $token) => $token->value, lex($input))); expect($roundTrip)->toBe($input, "Round trip failed for: {$input}"); } }); @@ -166,7 +168,6 @@ class-string * B. String literals * --------------------------------------------------------------------------------------- */ - test('every string literal lexes to exactly one STRING token with quotes and escapes intact', function () { $literals = lines(<<<'STRINGS' 'hello' @@ -211,7 +212,7 @@ class-string test('whitespace inside a string literal is part of the literal, not a WHITESPACE token', function () { $tokens = lex('array{"key something else": int}'); - $whitespace = array_filter($tokens, fn(Token $token) => $token->type === TokenType::WHITESPACE); + $whitespace = array_filter($tokens, fn (Token $token) => $token->type === TokenType::WHITESPACE); // The two spaces inside the key belong to the STRING; only the one after `:` is trivia. expect($whitespace)->toHaveCount(1) @@ -226,7 +227,6 @@ class-string * C. Quoted keys and unsealed shapes * --------------------------------------------------------------------------------------- */ - test('array shape keys may be quoted and may contain spaces', function () { expect(significant('array{"key something else": OtherType, ...}'))->toBe([ 'IDENTIFIER(array)', 'LBRACE({)', 'STRING("key something else")', 'COLON(:)', @@ -278,7 +278,6 @@ class-string * D. The magic that is gone * --------------------------------------------------------------------------------------- */ - test('class constants are three tokens, not one CLASS_CONST', function () { expect(significant('Foo::BAR')) ->toBe(['IDENTIFIER(Foo)', 'DOUBLE_COLON(::)', 'IDENTIFIER(BAR)']) @@ -323,7 +322,6 @@ class-string * E. Identifiers and numbers * --------------------------------------------------------------------------------------- */ - test('hyphenated identifiers do not collide with negative numbers', function () { expect(significant('non-empty-string'))->toBe(['IDENTIFIER(non-empty-string)']) ->and(significant('positive-int'))->toBe(['IDENTIFIER(positive-int)']) @@ -364,7 +362,6 @@ class-string * F. Complex constructs beyond today's grammar * --------------------------------------------------------------------------------------- */ - test('callable signatures lex variables, defaults and variadics', function () { expect(significant('callable(int $a, string ...$rest): bool'))->toBe([ 'IDENTIFIER(callable)', 'LPAREN(()', 'IDENTIFIER(int)', 'VARIABLE($a)', 'COMMA(,)', @@ -415,11 +412,10 @@ class-string * G. Whitespace and multi line input * --------------------------------------------------------------------------------------- */ - test('whitespace is emitted as its own token', function () { $tokens = lex('string | int'); - expect(array_map(fn(Token $token) => $token->type, $tokens))->toBe([ + expect(array_map(fn (Token $token) => $token->type, $tokens))->toBe([ TokenType::IDENTIFIER, TokenType::WHITESPACE, TokenType::PIPE, TokenType::WHITESPACE, TokenType::IDENTIFIER, TokenType::EOF, ])->and($tokens[1]->value)->toBe(' '); @@ -437,7 +433,6 @@ class-string * H. Errors * --------------------------------------------------------------------------------------- */ - test('illegal input raises UnexpectedCharacterException instead of being swallowed', function () { // The old tokenizer lexed "a#b" as IDENTIFIER(a#b) and never complained. $illegal = [ @@ -464,7 +459,7 @@ class-string expect($thrown)->toBeInstanceOf( UnexpectedCharacterException::class, - 'Should have been rejected: ' . json_encode($input), + 'Should have been rejected: '.json_encode($input), ); } }); diff --git a/tests/Unit/Parser/MetadataEliminationTest.php b/tests/Unit/Parser/MetadataEliminationTest.php index 26d07a2..4f6e075 100644 --- a/tests/Unit/Parser/MetadataEliminationTest.php +++ b/tests/Unit/Parser/MetadataEliminationTest.php @@ -1,9 +1,14 @@ -getProperties() as $property) { // UnionNode::$acceptsNull is a lazily populated memo and may be uninitialized. - if (!$property->isInitialized($current)) { + if (! $property->isInitialized($current)) { continue; } @@ -72,11 +78,11 @@ function containsMetadataNode(NodeInterface $node): bool 'deeply nested' => "array{a: array{b: list>}}", 'named class' => Customer::class, 'branded value object' => Email::class, - 'value object with inherited metadata' => \Tests\Mocks\ValueObjects\Inherited\AccountId::class, - 'value object in struct' => 'array{e: ' . Email::class . '}', - 'named class in list' => 'list<' . Customer::class . '>', - 'named class in union' => Customer::class . '|null', - 'named class in record' => 'array', + 'value object with inherited metadata' => AccountId::class, + 'value object in struct' => 'array{e: '.Email::class.'}', + 'named class in list' => 'list<'.Customer::class.'>', + 'named class in union' => Customer::class.'|null', + 'named class in record' => 'array', ]); test('the parser produces metadata for every position under test', function (string $type) { @@ -101,7 +107,7 @@ function containsMetadataNode(NodeInterface $node): bool $node = new TypeParser()->parse("array{token: BrandedString<'tok'>, count: int}"); ['node' => $optimized] = optimizeSingle($node); - $executor = new Le0daniel\PhpTsBindings\Executor\SchemaExecutor(); + $executor = new SchemaExecutor(); $data = ['token' => 'abc', 'count' => 3]; expect($executor->parse($optimized, $data)->value) @@ -113,18 +119,18 @@ function containsMetadataNode(NodeInterface $node): bool expect($node->exportPhpCode())->not->toContain('MetadataNode') ->and($node->exportPhpCode())->toBe(new StringNode()->exportPhpCode()) - ->and((string)$node)->toBe((string)new StringNode()); + ->and((string) $node)->toBe((string) new StringNode()); }); test('MetadataNode rejects being nested', function () { $node = new MetadataNode(new MetadataNode(new StringNode(), null, 'inner'), null, 'outer'); - expect(fn() => $node->validate()) + expect(fn () => $node->validate()) ->toThrow(ParserException::class, 'should not be nested'); }); test('MetadataNode rejects carrying neither a name nor a brand', function () { - expect(fn() => new MetadataNode(new StringNode())->validate()) + expect(fn () => new MetadataNode(new StringNode())->validate()) ->toThrow(ParserException::class, 'meaningless'); }); @@ -136,9 +142,9 @@ function containsMetadataNode(NodeInterface $node): bool }); test('unwrapMetadata keeps constraints attached, unlike getDeclaringNode', function () { - $constrained = new Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode( + $constrained = new ConstraintNode( new StringNode(), - [new \Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\NonEmptyString()], + [new NonEmptyString()], ); $wrapped = new MetadataNode($constrained, null, 'tag'); diff --git a/tests/Unit/Parser/NamedTypeTest.php b/tests/Unit/Parser/NamedTypeTest.php index a74ae66..a2793f4 100644 --- a/tests/Unit/Parser/NamedTypeTest.php +++ b/tests/Unit/Parser/NamedTypeTest.php @@ -1,8 +1,11 @@ -parse(\Tests\Mocks\ValueObjects\CreateAccountInput::class); + $node = new TypeParser()->parse(CreateAccountInput::class); expect($node)->toBeInstanceOf(CustomCastingNode::class); }); @@ -89,26 +93,26 @@ $node = new TypeParser()->parse(Order::class); expect($node)->toBeInstanceOf(MetadataNode::class) - ->and((string)$node)->toBe((string)$node->node) + ->and((string) $node)->toBe((string) $node->node) ->and($node->exportPhpCode())->not->toContain('MetadataNode'); $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); expect($optimizedCode)->not->toContain('MetadataNode') ->and($registry->get('node'))->not->toBeInstanceOf(MetadataNode::class) - ->and((string)$registry->get('node'))->toBe((string)$node); + ->and((string) $registry->get('node'))->toBe((string) $node); }); test('rejects a name that is not a valid TypeScript identifier', function () { - expect(fn() => new TypeParser()->parse(InvalidlyNamed::class)) + expect(fn () => new TypeParser()->parse(InvalidlyNamed::class)) ->toThrow(InvalidStringLiteralException::class, 'not a valid TypeScript identifier'); }); test('rejects a brand tag that is not a valid TypeScript identifier', function () { - expect(fn() => new TypeParser()->parse(InvalidlyBranded::class)) + expect(fn () => new TypeParser()->parse(InvalidlyBranded::class)) ->toThrow(InvalidStringLiteralException::class, 'not a valid TypeScript identifier'); }); @@ -207,7 +211,7 @@ test('rejects a fixed name on an inherited declaration', function () { // Every implementor would share the brand "sharedId" and collapse into one TypeScript type. - expect(fn() => new TypeParser()->parse(SharedExplicitBrandId::class)) + expect(fn () => new TypeParser()->parse(SharedExplicitBrandId::class)) ->toThrow(ParserException::class, 'cannot carry a fixed name'); }); @@ -218,7 +222,7 @@ }); test('two interfaces declaring the same attribute are ambiguous and rejected', function () { - expect(fn() => new TypeParser()->parse(AmbiguousId::class)) + expect(fn () => new TypeParser()->parse(AmbiguousId::class)) ->toThrow(ParserException::class, 'inherits #[Brand] from more than one interface'); }); @@ -259,7 +263,7 @@ ]); test('a naming closure still has to produce a valid TypeScript identifier', function () { - expect(fn() => new TypeParser()->parse(BadClosureId::class)) + expect(fn () => new TypeParser()->parse(BadClosureId::class)) ->toThrow(InvalidStringLiteralException::class, 'not a valid TypeScript identifier'); }); @@ -284,10 +288,10 @@ expect($node)->toBeInstanceOf(MetadataNode::class) ->and($node->name?->isSameForBothDirections())->toBeTrue(); - expect(fn() => AstValidator::validate($node)) + expect(fn () => AstValidator::validate($node)) ->toThrow(ParserException::class, 'resolves to one alias "AsymmetricNamed" for both directions'); }); test('a named class whose properties are all bidirectional validates cleanly', function () { validateAst(new TypeParser()->parse(Customer::class)); -}); \ No newline at end of file +}); diff --git a/tests/Unit/Parser/NodeDiagnosticStringTest.php b/tests/Unit/Parser/NodeDiagnosticStringTest.php index c09c621..521ed4c 100644 --- a/tests/Unit/Parser/NodeDiagnosticStringTest.php +++ b/tests/Unit/Parser/NodeDiagnosticStringTest.php @@ -1,4 +1,6 @@ -not->toBe('string') - ->and((string)$node)->toContain('string') - ->and((string)$node)->toContain('NonEmptyString'); + expect((string) $node)->not->toBe('string') + ->and((string) $node)->toContain('string') + ->and((string) $node)->toContain('NonEmptyString'); }); test('a constraint free node reads as its inner node', function () { - expect((string)new ConstraintNode(new StringNode(), []))->toBe('string'); + expect((string) new ConstraintNode(new StringNode(), []))->toBe('string'); }); test('integer and float literals are distinguishable', function () { - expect((string)new LiteralNode(LiteralType::INT, 1)) - ->not->toBe((string)new LiteralNode(LiteralType::FLOAT, 1.0)); + expect((string) new LiteralNode(LiteralType::INT, 1)) + ->not->toBe((string) new LiteralNode(LiteralType::FLOAT, 1.0)); }); test('a tuple keeps its braces', function () { - expect((string)new TypeParser()->parse('array{0: string, 1: int}')) + expect((string) new TypeParser()->parse('array{0: string, 1: int}')) ->toBe('array{0: string, 1: int}'); }); test('a discriminated union names its discriminator', function () { $discriminated = new TypeParser()->parse("array{kind: 'a', v: string}|array{kind: 'b', v: int}"); - $plain = new Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode($discriminated->nodes); + $plain = new UnionNode($discriminated->nodes); - expect((string)$discriminated)->toContain('kind') - ->and((string)$discriminated)->not->toBe((string)$plain); + expect((string) $discriminated)->toContain('kind') + ->and((string) $discriminated)->not->toBe((string) $plain); }); test('metadata stays transparent, which the elimination guarantee depends on', function () { $inner = new StringNode(); $node = new MetadataNode($inner, NamedType::same('Token'), 'token'); - expect((string)$node)->toBe((string)$inner); + expect((string) $node)->toBe((string) $inner); }); diff --git a/tests/Unit/Parser/OptimizeAndWriteToFileTest.php b/tests/Unit/Parser/OptimizeAndWriteToFileTest.php index f65e8f3..8918f85 100644 --- a/tests/Unit/Parser/OptimizeAndWriteToFileTest.php +++ b/tests/Unit/Parser/OptimizeAndWriteToFileTest.php @@ -1,4 +1,6 @@ -file = sys_get_temp_dir() . '/php-ts-bindings-asts-' . getmypid() . '.php'; + $this->file = sys_get_temp_dir().'/php-ts-bindings-asts-'.getmypid().'.php'; }); afterEach(function () { @@ -27,7 +29,7 @@ $parser = new TypeParser(); new ASTOptimizer()->optimizeAndWriteToFile($this->file, [ - 'account@output' => $parser->parse('\\' . AccountData::class), + 'account@output' => $parser->parse('\\'.AccountData::class), 'scalar@input' => $parser->parse('int'), ]); @@ -58,11 +60,11 @@ // Byte-for-byte reproducibility is what lets a build compare a regenerated cache against the // committed one to decide whether it is stale. test('writing the same schemas twice produces identical bytes', function () { - $second = $this->file . '.second'; + $second = $this->file.'.second'; foreach ([$this->file, $second] as $path) { new ASTOptimizer()->optimizeAndWriteToFile($path, [ - 'account@input' => new TypeParser()->parse('\\' . AccountData::class), + 'account@input' => new TypeParser()->parse('\\'.AccountData::class), ]); } diff --git a/tests/Unit/Parser/OptimizedCodeShapeTest.php b/tests/Unit/Parser/OptimizedCodeShapeTest.php index 7603d15..30c4add 100644 --- a/tests/Unit/Parser/OptimizedCodeShapeTest.php +++ b/tests/Unit/Parser/OptimizedCodeShapeTest.php @@ -1,4 +1,6 @@ -toBeInstanceOf(CachedTypeRegistry::class) - ->and((string)$registry->get('schema0'))->toBe('array{a: string, b: int}'); + ->and((string) $registry->get('schema0'))->toBe('array{a: string, b: int}'); }); test('generation is deterministic: the same input yields byte identical output', function () { @@ -62,14 +63,14 @@ function generateFor(string ...$types): string /** @var CachedTypeRegistry $registry */ $registry = eval("return {$code};"); - expect(fn() => $registry->get('does-not-exist')) + expect(fn () => $registry->get('does-not-exist')) ->toThrow(UnknownTypeKeyException::class, 'Regenerate the optimized schema cache'); }); test('the legacy array shape is rejected rather than silently accepted', function () { // A cache written before identity was fixed carries merged schemas; booting it would run with // constraints silently dropped, so it must fail loudly instead. - expect(fn() => new CachedTypeRegistry(['key' => static fn() => new TypeParser()->parse('string')])) + expect(fn () => new CachedTypeRegistry(['key' => static fn () => new TypeParser()->parse('string')])) ->toThrow(UnknownTypeKeyException::class, 'Regenerate the optimized schema cache'); }); diff --git a/tests/Unit/Parser/StructNodeOrderTest.php b/tests/Unit/Parser/StructNodeOrderTest.php index b5248d0..6df4c3d 100644 --- a/tests/Unit/Parser/StructNodeOrderTest.php +++ b/tests/Unit/Parser/StructNodeOrderTest.php @@ -1,4 +1,6 @@ - new PropertyNode($name, new StringNode(), false), $names), + array_map(static fn (string $name) => new PropertyNode($name, new StringNode(), false), $names), ); } @@ -44,7 +45,7 @@ function structOf(string ...$names): StructNode test('properties are ordered by name', function () { $properties = structOf('zebra', 'alpha', 'middle')->properties; - expect(array_map(static fn(PropertyNode $p) => $p->name, $properties)) + expect(array_map(static fn (PropertyNode $p) => $p->name, $properties)) ->toBe(['alpha', 'middle', 'zebra']); }); @@ -54,7 +55,7 @@ function structOf(string ...$names): StructNode new PropertyNode('field', new IntNode(), false, PropertyType::INPUT), ]); - expect(array_map(static fn(PropertyNode $p) => $p->propertyType, $struct->properties)) + expect(array_map(static fn (PropertyNode $p) => $p->propertyType, $struct->properties)) ->toBe([PropertyType::INPUT, PropertyType::OUTPUT]); }); @@ -74,12 +75,12 @@ function structOf(string ...$names): StructNode test('filter and map preserve canonical order', function () { $struct = structOf('zebra', 'alpha', 'middle'); - $mapped = $struct->map(static fn(PropertyNode $p) => $p->changePropertyType(PropertyType::INPUT)); - $filtered = $struct->filter(static fn(PropertyNode $p) => $p->name !== 'middle'); + $mapped = $struct->map(static fn (PropertyNode $p) => $p->changePropertyType(PropertyType::INPUT)); + $filtered = $struct->filter(static fn (PropertyNode $p) => $p->name !== 'middle'); - expect(array_map(static fn(PropertyNode $p) => $p->name, $mapped->properties)) + expect(array_map(static fn (PropertyNode $p) => $p->name, $mapped->properties)) ->toBe(['alpha', 'middle', 'zebra']) - ->and(array_map(static fn(PropertyNode $p) => $p->name, $filtered->properties)) + ->and(array_map(static fn (PropertyNode $p) => $p->name, $filtered->properties)) ->toBe(['alpha', 'zebra']); }); diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index ad0773e..c50ed4a 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -33,6 +33,7 @@ use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\Nodes\UnionNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Feature\Mocks\Paginated; @@ -46,11 +47,10 @@ use Tests\Unit\Parser\Data\Stubs\UncastableClass; use Tests\Unit\Parser\Data\UserMock; - test('test simple union', function () { $parser = new TypeParser(); - expect($node = $parser->parse("string | int")) + expect($node = $parser->parse('string | int')) ->toBeInstanceOf(UnionNode::class); compareToOptimizedAst($node); @@ -107,7 +107,7 @@ $parser = new TypeParser(); /** @var UnionNode $node */ - $node = $parser->parse("scalar"); + $node = $parser->parse('scalar'); expect($node)->toBeInstanceOf(UnionNode::class); foreach ($node->nodes as $index => $type) { @@ -125,7 +125,7 @@ test('test questionmark nullability support', function () { $parser = new TypeParser(); /** @var UnionNode $node */ - $node = $parser->parse("?float"); + $node = $parser->parse('?float'); expect($node)->toBeInstanceOf(UnionNode::class); @@ -138,14 +138,14 @@ test('test failure on question mark union', function () { $parser = new TypeParser(); - expect(fn() => $parser->parse("?float|null")) - ->toThrow("Cannot mix union with intersection or nullable types. Use brackets to do so. Example: (A&B)|C or null|A|B"); + expect(fn () => $parser->parse('?float|null')) + ->toThrow('Cannot mix union with intersection or nullable types. Use brackets to do so. Example: (A&B)|C or null|A|B'); }); test('test group support of question mark nullability and flattened result', function () { $parser = new TypeParser(); /** @var UnionNode $node */ - $node = $parser->parse("(?float)|string"); + $node = $parser->parse('(?float)|string'); expect($node)->toBeInstanceOf(UnionNode::class); @@ -158,7 +158,7 @@ test('float', function () { $parser = new TypeParser(); - $node = $parser->parse("float"); + $node = $parser->parse('float'); expect($node)->toBeInstanceOf(FloatNode::class); @@ -167,7 +167,7 @@ test('int', function () { $parser = new TypeParser(); - $node = $parser->parse("int"); + $node = $parser->parse('int'); expect($node)->toBeInstanceOf(IntNode::class); @@ -177,7 +177,7 @@ test('Generic Int', function () { $parser = new TypeParser(); - $node = $parser->parse("int<0, 100>"); + $node = $parser->parse('int<0, 100>'); expect($node)->toBeInstanceOf(ConstraintNode::class) ->and($node->constraints[0])->toBeInstanceOf(IntRange::class) @@ -191,7 +191,7 @@ test('Generic Int Min', function () { $parser = new TypeParser(); - $node = $parser->parse("int"); + $node = $parser->parse('int'); // `min` is an absent bound, not PHP_INT_MIN: the type says there is no lower limit. expect($node)->toBeInstanceOf(ConstraintNode::class) @@ -205,7 +205,7 @@ test('Generic Int Max', function () { $parser = new TypeParser(); - $node = $parser->parse("int<-1, max>"); + $node = $parser->parse('int<-1, max>'); expect($node)->toBeInstanceOf(ConstraintNode::class) ->and($node->constraints[0]->min)->toBe(-1) @@ -218,7 +218,7 @@ test('Generic Int Negative Values', function () { $parser = new TypeParser(); - $node = $parser->parse("int<-100, -3>"); + $node = $parser->parse('int<-100, -3>'); expect($node)->toBeInstanceOf(ConstraintNode::class) ->and($node->constraints[0]->min)->toBe(-100) @@ -231,7 +231,7 @@ test('numeric', function () { $parser = new TypeParser(); /** @var UnionNode $node */ - $node = $parser->parse("numeric"); + $node = $parser->parse('numeric'); foreach ($node->nodes as $index => $type) { match ($index) { @@ -246,14 +246,14 @@ test('Global aliases', function () { $parser = new TypeParser( TypeParser::defaultConsumers(new GlobalTypeAliases([ - 'Slug' => fn() => new ConstraintNode( + 'Slug' => fn () => new ConstraintNode( new StringNode(), [new NonEmptyString()], ), ])) ); /** @var ConstraintNode $node */ - $node = $parser->parse("Slug"); + $node = $parser->parse('Slug'); expect($node)->toBeInstanceOf(ConstraintNode::class) ->and($node->constraints[0])->toBeInstanceOf(NonEmptyString::class) @@ -265,7 +265,7 @@ test('positive-int', function () { $parser = new TypeParser(); /** @var ConstraintNode $node */ - $node = $parser->parse("positive-int"); + $node = $parser->parse('positive-int'); expect($node)->toBeInstanceOf(ConstraintNode::class); expect($node->node)->toBeInstanceOf(IntNode::class); @@ -276,7 +276,7 @@ test('Local type resolution', function () { $parser = new TypeParser(); /** @var ConstraintNode $node */ - $node = $parser->parse("AddressInput", ParsingScope::fromClassString(Address::class)); + $node = $parser->parse('AddressInput', ParsingScope::fromClassString(Address::class)); compareToOptimizedAst($node); expect($node)->toBeInstanceOf(StructNode::class); @@ -286,7 +286,7 @@ test('Local imported resolution', function () { $parser = new TypeParser(); /** @var ConstraintNode $node */ - $node = $parser->parse("AddressInputData", ParsingScope::fromClassString(MyUserClass::class)); + $node = $parser->parse('AddressInputData', ParsingScope::fromClassString(MyUserClass::class)); compareToOptimizedAst($node); expect($node)->toBeInstanceOf(StructNode::class); @@ -296,7 +296,7 @@ test('non-negative-int', function () { $parser = new TypeParser(); /** @var ConstraintNode $node */ - $node = $parser->parse("non-negative-int"); + $node = $parser->parse('non-negative-int'); expect($node)->toBeInstanceOf(ConstraintNode::class); expect($node->node)->toBeInstanceOf(IntNode::class); @@ -307,7 +307,7 @@ test('non-positive-int', function () { $parser = new TypeParser(); /** @var ConstraintNode $node */ - $node = $parser->parse("non-positive-int"); + $node = $parser->parse('non-positive-int'); expect($node)->toBeInstanceOf(ConstraintNode::class); expect($node->node)->toBeInstanceOf(IntNode::class); @@ -318,7 +318,7 @@ test('negative-int', function () { $parser = new TypeParser(); /** @var ConstraintNode $node */ - $node = $parser->parse("negative-int"); + $node = $parser->parse('negative-int'); expect($node)->toBeInstanceOf(ConstraintNode::class); expect($node->node)->toBeInstanceOf(IntNode::class); @@ -333,7 +333,7 @@ expect($node)->toBeInstanceOf(ConstraintNode::class) ->and($node->node)->toBeInstanceOf(StringNode::class) - ->and(array_map(fn($constraint) => $constraint::class, $node->constraints)) + ->and(array_map(fn ($constraint) => $constraint::class, $node->constraints)) ->toBe($expectedConstraints); compareToOptimizedAst($node); @@ -353,7 +353,7 @@ test('non-empty-list keeps its minimum', function () { $parser = new TypeParser(); /** @var ConstraintNode $node */ - $node = $parser->parse("non-empty-list"); + $node = $parser->parse('non-empty-list'); expect($node)->toBeInstanceOf(ConstraintNode::class) ->and($node->constraints[0])->toBeInstanceOf(ListLength::class) @@ -390,14 +390,14 @@ test('a bare array or list is rejected rather than degraded', function (string $type) { // PHPStan's bare `array` is array and permits string keys. Modelling it as a // list would drop those keys on the way out, so it fails like bare `object` does. - expect(fn() => new TypeParser()->parse($type)) + expect(fn () => new TypeParser()->parse($type)) ->toThrow(InvalidSyntaxException::class, 'has no single representation'); })->with(['array', 'list', 'non-empty-array', 'non-empty-list']); test('object struct', function () { $parser = new TypeParser(); /** @var StructNode $node */ - $node = $parser->parse("object{a: string, b: int}"); + $node = $parser->parse('object{a: string, b: int}'); expect($node)->toBeInstanceOf(StructNode::class); expect($node->phpType)->toEqual(StructPhpType::OBJECT); @@ -410,7 +410,7 @@ test('array struct', function () { $parser = new TypeParser(); /** @var StructNode $node */ - $node = $parser->parse("array{a: string, b: int}"); + $node = $parser->parse('array{a: string, b: int}'); expect($node)->toBeInstanceOf(StructNode::class); expect($node->phpType)->toEqual(StructPhpType::ARRAY); @@ -423,7 +423,7 @@ test('simplified tuple struct', function () { $parser = new TypeParser(); /** @var TupleNode $node */ - $node = $parser->parse("array{string, int}"); + $node = $parser->parse('array{string, int}'); expect($node)->toBeInstanceOf(TupleNode::class); expect($node->nodes[0])->toBeInstanceOf(StringNode::class); @@ -435,7 +435,7 @@ test('classic tuple struct', function () { $parser = new TypeParser(); /** @var TupleNode $node */ - $node = $parser->parse("array{0:string, 1: int}"); + $node = $parser->parse('array{0:string, 1: int}'); expect($node)->toBeInstanceOf(TupleNode::class); expect($node->nodes[0])->toBeInstanceOf(StringNode::class); @@ -447,7 +447,7 @@ test('List struct', function () { $parser = new TypeParser(); /** @var ListNode $node */ - $node = $parser->parse("array"); + $node = $parser->parse('array'); expect($node)->toBeInstanceOf(ListNode::class); expect($node->node)->toBeInstanceOf(StringNode::class); @@ -458,7 +458,7 @@ test('List by modifier', function () { $parser = new TypeParser(); /** @var ListNode $node */ - $node = $parser->parse("string[]"); + $node = $parser->parse('string[]'); expect($node)->toBeInstanceOf(ListNode::class); expect($node->node)->toBeInstanceOf(StringNode::class); @@ -469,7 +469,7 @@ test('Grouped Modifier', function () { $parser = new TypeParser(); /** @var ListNode $node */ - $node = $parser->parse("(string|int)[]"); + $node = $parser->parse('(string|int)[]'); expect($node)->toBeInstanceOf(ListNode::class); expect($node->node)->toBeInstanceOf(UnionNode::class); @@ -483,7 +483,7 @@ test('Record struct', function () { $parser = new TypeParser(); /** @var RecordNode $node */ - $node = $parser->parse("array"); + $node = $parser->parse('array'); expect($node)->toBeInstanceOf(RecordNode::class); expect($node->node)->toBeInstanceOf(IntNode::class); @@ -492,7 +492,7 @@ }); test('a constrained array key is rejected, the constraint would be silently unenforceable', function (string $type) { - expect(fn() => new TypeParser()->parse($type)) + expect(fn () => new TypeParser()->parse($type)) ->toThrow(InvalidSyntaxException::class, "Array key type must be 'string' or 'int'"); })->with([ 'non-empty-string key' => ['array'], @@ -550,7 +550,7 @@ $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse( - "ResultEnumBase::SUCCESS|ResultEnumBase::FAILURE|ResultEnum::OTHER", + 'ResultEnumBase::SUCCESS|ResultEnumBase::FAILURE|ResultEnum::OTHER', new ParsingScope('SomeName\\Space', [ 'ResultEnumBase' => ResultEnum::class, 'ResultEnum' => ResultEnum::class, @@ -563,7 +563,7 @@ 0 => expect($type->value)->toBe(ResultEnum::SUCCESS), 1 => expect($type->value)->toBe(ResultEnum::FAILURE), 2 => expect($type->value)->toBe('other'), - default => throw new \RuntimeException("Should not be reached"), + default => throw new \RuntimeException('Should not be reached'), }; } @@ -626,7 +626,7 @@ test('Generics parsing', function () { $parser = new TypeParser(); - $node = $parser->parse(Paginated::class . ''); + $node = $parser->parse(Paginated::class.''); expect($node)->toBeInstanceOf(CustomCastingNode::class); compareToOptimizedAst($node); validateAst($node); @@ -655,16 +655,16 @@ validateAst($node); expect(typescriptFor($node, IO::OUTPUT)->type)->toBe('{email:string;name:string;}') - ->and(fn() => typescriptFor($node, IO::INPUT)) + ->and(fn () => typescriptFor($node, IO::INPUT)) ->toThrow(UnsupportedTypeException::class, UncastableClass::class); }); test('fails on missing or too many generics', function () { $parser = new TypeParser(); - expect(fn() => $parser->parse(Paginated::class . '')) + expect(fn () => $parser->parse(Paginated::class.'')) ->toThrow('Number of generics does not match. Expected 1 , got 2.') - ->and(fn() => $parser->parse(Paginated::class)) + ->and(fn () => $parser->parse(Paginated::class)) ->toThrow('Number of generics does not match. Expected 1 , got 0.'); }); @@ -685,7 +685,7 @@ ->and($node->hasProperty('id'))->toBeTrue() ->and($node->getProperty('id')->propertyType)->toEqual(PropertyType::BOTH) ->and($node->phpType)->toEqual(StructPhpType::OBJECT); - ; + compareToOptimizedAst($node); }); @@ -714,7 +714,7 @@ $parser = new TypeParser(); /** @var StructNode $node */ - $node = $parser->parse("Pick<" . UserMock::class . ", 'username'>"); + $node = $parser->parse('Pick<'.UserMock::class.", 'username'>"); expect($node)->toBeInstanceOf(StructNode::class) ->and($node->properties)->toHaveCount(1) ->and($node->hasProperty('username'))->toBeTrue() @@ -724,7 +724,7 @@ compareToOptimizedAst($node); /** @var StructNode $node */ - $node = $parser->parse("Omit<" . UserMock::class . ", 'username'>"); + $node = $parser->parse('Omit<'.UserMock::class.", 'username'>"); expect($node)->toBeInstanceOf(StructNode::class) ->and($node->properties)->toHaveCount(2) ->and($node->getProperty('age')->propertyType)->toEqual(PropertyType::BOTH) @@ -747,33 +747,33 @@ 'Omit multiple' => ['{email:string;id:string;}', 'Omit'], 'Pick from object' => ['{name:string;}', 'Pick'], 'Omit from object' => ['{id:string;}', 'Omit'], - 'Pick from class' => ['{username:string;}', 'Pick<' . UserMock::class . ', "username">'], - 'Omit from class' => ['{email:string;username:string;}', 'Omit<' . UserMock::class . ', "age">'], + 'Pick from class' => ['{username:string;}', 'Pick<'.UserMock::class.', "username">'], + 'Omit from class' => ['{email:string;username:string;}', 'Omit<'.UserMock::class.', "age">'], 'Simple Pick with optional' => ['{name?:(string|null);}', 'Pick'], 'Simple Omit with optional' => ['{id?:string;}', 'Omit'], ]); -test("parse interface properties", function () { +test('parse interface properties', function () { $parser = new TypeParser(); $node = $parser->parse(SomeFileInterface::class); compareToOptimizedAst($node); expect(typescriptFor($node, IO::OUTPUT)->type)->toBe('{id:number;url:string;}') - ->and(fn() => typescriptFor($node, IO::INPUT)) + ->and(fn () => typescriptFor($node, IO::INPUT)) ->toThrow(UnsupportedTypeException::class, SomeFileInterface::class); }); -test("parse abstract class properties", function () { +test('parse abstract class properties', function () { $parser = new TypeParser(); $node = $parser->parse(SomeAbstractClass::class); compareToOptimizedAst($node); expect(typescriptFor($node, IO::OUTPUT)->type)->toBe('{email:string;id:number;}') - ->and(fn() => typescriptFor($node, IO::INPUT)) + ->and(fn () => typescriptFor($node, IO::INPUT)) ->toThrow(UnsupportedTypeException::class, SomeAbstractClass::class); }); -test("parse BrandedInt correctly", function () { +test('parse BrandedInt correctly', function () { $parser = new TypeParser(); $node = $parser->parse("BrandedInt<'wow'>"); compareToOptimizedAst($node); @@ -785,7 +785,7 @@ } }); -test("parse BrandedString correctly", function () { +test('parse BrandedString correctly', function () { $parser = new TypeParser(); $node = $parser->parse("BrandedString<'wow'>"); compareToOptimizedAst($node); @@ -798,9 +798,9 @@ }); test('rejects a branded utility tag that is not a valid TypeScript identifier', function (string $type) { - expect(fn() => new TypeParser()->parse($type)) + expect(fn () => new TypeParser()->parse($type)) ->toThrow( - \Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException::class, + InvalidStringLiteralException::class, 'not a valid TypeScript identifier', ); })->with([ @@ -817,12 +817,12 @@ ->and($string->brand)->toBe('wow') ->and($string->name?->outputName)->toBe('Wow') ->and($string->node)->toBeInstanceOf(StringNode::class) - ->and((string)$string)->toBe('string') + ->and((string) $string)->toBe('string') ->and($string->exportPhpCode())->not->toContain('wow') ->and($int)->toBeInstanceOf(MetadataNode::class) ->and($int->brand)->toBe('wow') ->and($int->node)->toBeInstanceOf(IntNode::class) - ->and((string)$int)->toBe('int') + ->and((string) $int)->toBe('int') ->and($int->exportPhpCode())->not->toContain('wow'); }); @@ -853,8 +853,8 @@ // Both produce the same node, so they share a hash and dedupe into one registry entry. $parser = new TypeParser(); - expect((string)$parser->parse('DateTimeString')) - ->toBe((string)$parser->parse('\DateTimeImmutable')); + expect((string) $parser->parse('DateTimeString')) + ->toBe((string) $parser->parse('\DateTimeImmutable')); }); test('DateTimeString takes the format from its single generic', function (string $type, string $expectedFormat) { @@ -913,7 +913,7 @@ ]); test('DateTimeString rejects invalid generics', function (string $type) { - expect(fn() => new TypeParser()->parse($type))->toThrow(InvalidSyntaxException::class); + expect(fn () => new TypeParser()->parse($type))->toThrow(InvalidSyntaxException::class); })->with([ 'empty generics' => ['DateTimeString<>'], 'two generics' => ["DateTimeString<'Y-m-d','H:i'>"], @@ -931,7 +931,6 @@ * become parseable once TypeParser runs on Parser\Lexer\Lexer. * --------------------------------------------------------------------------- */ - test('Array shape keys may be double quoted', function () { /** @var StructNode $node */ $node = new TypeParser()->parse('array{"key something else": string, b: int}'); @@ -997,11 +996,12 @@ $warnings = []; set_error_handler(function (int $severity, string $message) use (&$warnings): bool { $warnings[] = $message; + return true; }); try { - expect(fn() => new TypeParser()->parse('Foo::')) + expect(fn () => new TypeParser()->parse('Foo::')) ->toThrow(InvalidSyntaxException::class); } finally { restore_error_handler(); @@ -1013,22 +1013,22 @@ test('Illegal characters raise InvalidSyntaxException, not a lexer exception', function () { // Regexes::findFirstVarDeclaration() used to leak the closing */ out of single line // docblocks. It no longer does, but the parser stays defensive about it. - expect(fn() => new TypeParser()->parse('array{id: string} */')) + expect(fn () => new TypeParser()->parse('array{id: string} */')) ->toThrow(InvalidSyntaxException::class) - ->and(fn() => new TypeParser()->parse('a#b')) + ->and(fn () => new TypeParser()->parse('a#b')) ->toThrow(InvalidSyntaxException::class) - ->and(fn() => new TypeParser()->parse('%')) + ->and(fn () => new TypeParser()->parse('%')) ->toThrow(InvalidSyntaxException::class) - ->and(fn() => new TypeParser()->parse("array{'unterminated: int}")) + ->and(fn () => new TypeParser()->parse("array{'unterminated: int}")) ->toThrow(InvalidSyntaxException::class); }); test('A truncated array shape is a syntax error, not a PHP Error', function () { // Before the migration this crashed with: // "Call to a member function isAnyTypeOf() on null". - expect(fn() => new TypeParser()->parse('array{')) + expect(fn () => new TypeParser()->parse('array{')) ->toThrow(InvalidSyntaxException::class) - ->and(fn() => new TypeParser()->parse('array{a')) + ->and(fn () => new TypeParser()->parse('array{a')) ->toThrow(InvalidSyntaxException::class); }); @@ -1040,7 +1040,6 @@ * not support them, and this test pins that boundary. * --------------------------------------------------------------------------- */ - test('Constructs the lexer accepts but the parser does not support', function () { $unsupported = [ 'array{foo: int, ...}', @@ -1055,7 +1054,7 @@ ]; foreach ($unsupported as $type) { - expect(fn() => new TypeParser()->parse($type)) + expect(fn () => new TypeParser()->parse($type)) ->toThrow(InvalidSyntaxException::class, message: "Should reject: {$type}"); } }); @@ -1065,7 +1064,6 @@ * Lexer migration: regression guards * --------------------------------------------------------------------------- */ - test('Literal booleans stay literals and null stays a built in', function () { // true/false used to be their own TokenType::BOOL; they are plain IDENTIFIERs now. /** @var UnionNode $node */ diff --git a/tests/Unit/Parser/ValueObjectConsumerTest.php b/tests/Unit/Parser/ValueObjectConsumerTest.php index ecb56bd..c4c4b7a 100644 --- a/tests/Unit/Parser/ValueObjectConsumerTest.php +++ b/tests/Unit/Parser/ValueObjectConsumerTest.php @@ -1,4 +1,6 @@ -with([ - 'nullable' => ['?\\' . Email::class, UnionNode::class], - 'array shorthand' => ['\\' . Email::class . '[]', ListNode::class], - 'list generic' => ['list<\\' . Email::class . '>', ListNode::class], - 'union' => ['\\' . Email::class . '|null', UnionNode::class], - 'record' => ['array', RecordNode::class], - 'struct' => ['array{id: \\' . UserId::class . ', email: \\' . Email::class . '}', StructNode::class], - 'object struct' => ['object{id: \\' . UserId::class . '}', StructNode::class], + 'nullable' => ['?\\'.Email::class, UnionNode::class], + 'array shorthand' => ['\\'.Email::class.'[]', ListNode::class], + 'list generic' => ['list<\\'.Email::class.'>', ListNode::class], + 'union' => ['\\'.Email::class.'|null', UnionNode::class], + 'record' => ['array', RecordNode::class], + 'struct' => ['array{id: \\'.UserId::class.', email: \\'.Email::class.'}', StructNode::class], + 'object struct' => ['object{id: \\'.UserId::class.'}', StructNode::class], ]); test('rejects a class implementing both value object interfaces', function () { - expect(fn() => new TypeParser()->parse(AmbiguousValueObject::class)) + expect(fn () => new TypeParser()->parse(AmbiguousValueObject::class)) ->toThrow('must implement either StringValueObject or IntValueObject, not both'); }); test('rejects an abstract value object', function () { - expect(fn() => new TypeParser()->parse(AbstractValueObject::class)) + expect(fn () => new TypeParser()->parse(AbstractValueObject::class)) ->toThrow('must be instantiable'); }); diff --git a/tests/Unit/PhpStan/Mocks/MyTestClass.php b/tests/Unit/PhpStan/Mocks/MyTestClass.php index 96b7bc9..cb7957a 100644 --- a/tests/Unit/PhpStan/Mocks/MyTestClass.php +++ b/tests/Unit/PhpStan/Mocks/MyTestClass.php @@ -1,9 +1,14 @@ -assertFileAsserts($assertType, $file, ...$args); } public static function getAdditionalConfigFiles(): array { // path to your project's phpstan.neon, or extension.neon in case of custom extension packages - return [__DIR__ . '/../../../extension.neon']; + return [__DIR__.'/../../../extension.neon']; } -} \ No newline at end of file +} diff --git a/tests/Unit/PhpStan/data/types.php b/tests/Unit/PhpStan/data/types.php index 09a8eb4..6ed0b84 100644 --- a/tests/Unit/PhpStan/data/types.php +++ b/tests/Unit/PhpStan/data/types.php @@ -1,133 +1,155 @@ - $s + * @param Pick $s * @return void */ -function pick(object $s) { - assertType("object{id: string, name: string}", $s); +function pick(object $s) +{ + assertType('object{id: string, name: string}', $s); } /** - * @param Pick $s + * @param Pick $s * @return void */ -function pickWithOptional(object $s) { - assertType("object{id: string, name?: string}", $s); +function pickWithOptional(object $s) +{ + assertType('object{id: string, name?: string}', $s); } /** - * @param Pick $s + * @param Pick $s * @return void */ -function pickArray(array $s) { - assertType("array{id: string, name: string}", $s); +function pickArray(array $s) +{ + assertType('array{id: string, name: string}', $s); } /** - * @param Pick $s + * @param Pick $s * @return void */ -function pickArrayWithOptional(array $s) { - assertType("array{id: string, name?: string}", $s); +function pickArrayWithOptional(array $s) +{ + assertType('array{id: string, name?: string}', $s); } /** - * @param Pick<\Tests\Unit\PhpStan\Mocks\MyTestClass, 'id'|'name'> $s + * @param Pick $s * @return void */ -function pickObject(object $s) { - assertType("object{id: string, name: string}", $s); +function pickObject(object $s) +{ + assertType('object{id: string, name: string}', $s); } /** - * @param Omit $s + * @param Omit $s * @return void */ -function omit(object $s) { - assertType("object{other: string}", $s); +function omit(object $s) +{ + assertType('object{other: string}', $s); } /** - * @param Omit $s + * @param Omit $s * @return void */ -function omitArray($s) { - assertType("array{other: string}", $s); +function omitArray($s) +{ + assertType('array{other: string}', $s); } /** - * @param Omit $s + * @param Omit $s * @return void */ -function omitArrayWithOptional($s) { - assertType("array{other?: string}", $s); +function omitArrayWithOptional($s) +{ + assertType('array{other?: string}', $s); } /** - * @param Omit<\Tests\Unit\PhpStan\Mocks\MyTestClass, 'id'|'name'> $s + * @param Omit $s * @return void */ -function omitObject(object $s) { - assertType("object{other: string}", $s); +function omitObject(object $s) +{ + assertType('object{other: string}', $s); } /** - * @param BrandedInt<"personId"> $i + * @param BrandedInt<"personId"> $i */ -function brandedInt(int $i): int { - assertType("int", $i); +function brandedInt(int $i): int +{ + assertType('int', $i); + return $i; } /** - * @param BrandedString<"accountId"> $i + * @param BrandedString<"accountId"> $i */ -function brandedString(string $i): string { - assertType("string", $i); +function brandedString(string $i): string +{ + assertType('string', $i); + return $i; } /** - * @param DateTimeString $d + * @param DateTimeString $d */ -function dateTimeStringDefault(object $d): void { - assertType("DateTimeImmutable", $d); +function dateTimeStringDefault(object $d): void +{ + assertType('DateTimeImmutable', $d); } /** - * @param DateTimeString<'Y-m-d'> $d + * @param DateTimeString<'Y-m-d'> $d */ -function dateTimeStringWithFormat(object $d): void { - assertType("DateTimeImmutable", $d); +function dateTimeStringWithFormat(object $d): void +{ + assertType('DateTimeImmutable', $d); } /** - * @param DateTimeString<'Y-m-d\TH:i:sP'> $d + * @param DateTimeString<'Y-m-d\TH:i:sP'> $d */ -function dateTimeStringWithEscapedFormat(object $d): void { - assertType("DateTimeImmutable", $d); +function dateTimeStringWithEscapedFormat(object $d): void +{ + assertType('DateTimeImmutable', $d); } /** - * @param DateTimeString|null $d + * @param DateTimeString|null $d */ -function dateTimeStringNullable(?object $d): void { - assertType("DateTimeImmutable|null", $d); +function dateTimeStringNullable(?object $d): void +{ + assertType('DateTimeImmutable|null', $d); } /** - * @param list> $d + * @param list> $d */ -function dateTimeStringList(array $d): void { - assertType("list", $d); +function dateTimeStringList(array $d): void +{ + assertType('list', $d); } /** - * @param array{createdAt: DateTimeString<'Y-m-d'>} $d + * @param array{createdAt: DateTimeString<'Y-m-d'>} $d */ -function dateTimeStringInStruct(array $d): void { - assertType("array{createdAt: DateTimeImmutable}", $d); -} \ No newline at end of file +function dateTimeStringInStruct(array $d): void +{ + assertType('array{createdAt: DateTimeImmutable}', $d); +} diff --git a/tests/Unit/Reflection/FileReflectorTest.php b/tests/Unit/Reflection/FileReflectorTest.php index 6416977..c3f71d4 100644 --- a/tests/Unit/Reflection/FileReflectorTest.php +++ b/tests/Unit/Reflection/FileReflectorTest.php @@ -1,4 +1,6 @@ -getDeclaredClass()->getName())->toBe(ClassConstantBeforeDeclaration::class); }); test('the namespace is read from the file', function () { - $reflector = new FileReflector(__DIR__ . '/Fixtures/ClassConstantBeforeDeclaration.php'); + $reflector = new FileReflector(__DIR__.'/Fixtures/ClassConstantBeforeDeclaration.php'); expect($reflector->getDeclaredClass()->getNamespaceName())->toBe('Tests\\Unit\\Reflection\\Fixtures'); }); diff --git a/tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php b/tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php index 6edce0e..82261df 100644 --- a/tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php +++ b/tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php @@ -1,4 +1,6 @@ -toBe('array{ theme: string, notifications: array{ email: bool, }, }') ->and(TypeReflector::reflectReturnType($reflection->getMethod('serialize'))) ->toBe('array{ id: non-empty-string, roles: list, }'); -}); \ No newline at end of file +}); diff --git a/tests/Unit/Server/Client/NullClientTest.php b/tests/Unit/Server/Client/NullClientTest.php index f2ade48..f02cf04 100644 --- a/tests/Unit/Server/Client/NullClientTest.php +++ b/tests/Unit/Server/Client/NullClientTest.php @@ -1,4 +1,6 @@ - $middleware + * @param list $middleware */ function errorDefinition(string $methodName = 'declaresThrows', array $middleware = []): Definition { diff --git a/tests/Unit/Server/KeyGeneratorTest.php b/tests/Unit/Server/KeyGeneratorTest.php index 91e26ee..7d4a444 100644 --- a/tests/Unit/Server/KeyGeneratorTest.php +++ b/tests/Unit/Server/KeyGeneratorTest.php @@ -1,4 +1,6 @@ -discover(new ReflectionClass($class)); + return $discovery; } final class ClientInContextSlot { /** - * @param array{a: string} $input + * @param array{a: string} $input * @return array{a: string} */ #[Command('bad')] @@ -34,7 +37,7 @@ public function run(array $input, Client $client): array final class TooManyParameters { /** - * @param array{a: string} $input + * @param array{a: string} $input * @return array{a: string} */ #[Command('bad')] @@ -47,7 +50,7 @@ public function run(array $input, mixed $context, Client $client, string $extra) final class WrongClientType { /** - * @param array{a: string} $input + * @param array{a: string} $input * @return array{a: string} */ #[Command('bad')] @@ -60,7 +63,7 @@ public function run(array $input, mixed $context, string $client): array final class ValidPrefixes { /** - * @param array{a: string} $input + * @param array{a: string} $input * @return array{a: string} */ #[Command('ok', 'inputOnly')] @@ -70,7 +73,7 @@ public function inputOnly(array $input): array } /** - * @param array{a: string} $input + * @param array{a: string} $input * @return array{a: string} */ #[Command('ok', 'withContext')] @@ -80,7 +83,7 @@ public function withContext(array $input, mixed $context): array } /** - * @param array{a: string} $input + * @param array{a: string} $input * @return array{a: string} */ #[Command('ok', 'withClient')] @@ -94,7 +97,7 @@ public function withClient(array $input, mixed $context, Client $client): array final class StackedMiddleware { /** - * @param array{a: string} $input + * @param array{a: string} $input * @return array{a: string} */ #[Command('stacked')] @@ -111,17 +114,17 @@ public function run(array $input): array test('a Client in the context slot is rejected with the reason', function () { // It would silently receive the context and die with a TypeError naming neither. - expect(fn() => discover(ClientInContextSlot::class)) + expect(fn () => discover(ClientInContextSlot::class)) ->toThrow(SchemaException::class, 'the second argument is the context'); }); test('more parameters than the handler contract has are rejected', function () { - expect(fn() => discover(TooManyParameters::class)) + expect(fn () => discover(TooManyParameters::class)) ->toThrow(SchemaException::class, 'may declare a prefix of those'); }); test('a third parameter that cannot accept a Client is rejected', function () { - expect(fn() => discover(WrongClientType::class)) + expect(fn () => discover(WrongClientType::class)) ->toThrow(SchemaException::class, 'which is the client'); }); diff --git a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php index fc76826..b28a88e 100644 --- a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php +++ b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php @@ -1,4 +1,6 @@ -> $middlewares - * @param Closure(mixed): (RpcSuccess|RpcError) $destination - * @param (Closure(Throwable): RpcError)|null $onError + * @param list> $middlewares + * @param Closure(mixed): (RpcSuccess|RpcError) $destination + * @param (Closure(Throwable): RpcError)|null $onError */ function runPipeline(array $middlewares, Closure $destination, ?Closure $onError = null): RpcSuccess|RpcError { return new ContextualPipeline( middlewares: $middlewares, - onError: $onError ?? fn(Throwable $throwable): RpcError => new RpcError( + onError: $onError ?? fn (Throwable $throwable): RpcError => new RpcError( ErrorType::INTERNAL_ERROR, $throwable, ['type' => 'PRESENTED'], @@ -53,14 +55,14 @@ function runPipeline(array $middlewares, Closure $destination, ?Closure $onError } /** - * @param Closure(mixed, Closure(mixed): (RpcSuccess|RpcError), string, ResolveInfo, Client): (RpcSuccess|RpcError) $handle + * @param Closure(mixed, Closure(mixed): (RpcSuccess|RpcError), string, ResolveInfo, Client): (RpcSuccess|RpcError) $handle * @return MiddlewareContract */ function middleware(Closure $handle): MiddlewareContract { - return new class($handle) implements MiddlewareContract { + return new class ($handle) implements MiddlewareContract { /** - * @param Closure(mixed, Closure(mixed): (RpcSuccess|RpcError), string, ResolveInfo, Client): (RpcSuccess|RpcError) $handle + * @param Closure(mixed, Closure(mixed): (RpcSuccess|RpcError), string, ResolveInfo, Client): (RpcSuccess|RpcError) $handle */ public function __construct(private readonly Closure $handle) { @@ -79,7 +81,7 @@ function succeed(mixed $data = 'ok'): RpcSuccess } test('the destination runs when there is no middleware', function () { - $result = runPipeline([], fn(mixed $input): RpcSuccess => succeed($input)); + $result = runPipeline([], fn (mixed $input): RpcSuccess => succeed($input)); expect($result)->toBeInstanceOf(RpcSuccess::class) ->and($result->data)->toBe('input'); @@ -88,10 +90,10 @@ function succeed(mixed $data = 'ok'): RpcSuccess test('middlewares wrap the destination as an onion', function () { $result = runPipeline( [ - middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit first')), - middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit second')), + middleware(fn (mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit first')), + middleware(fn (mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit second')), ], - fn(mixed $input): RpcSuccess|RpcError => trace(succeed($input), 'destination'), + fn (mixed $input): RpcSuccess|RpcError => trace(succeed($input), 'destination'), ); expect($result->metadata['trace'])->toBe(['destination', 'exit second', 'exit first']); @@ -104,10 +106,11 @@ function succeed(mixed $data = 'ok'): RpcSuccess [ middleware(function (mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client) use (&$seen): RpcSuccess|RpcError { $seen = [$input, $context, $info->fullyQualifiedName, $client::class]; + return $next($input); }), ], - fn(mixed $input): RpcSuccess => succeed($input), + fn (mixed $input): RpcSuccess => succeed($input), ); expect($result)->toBeInstanceOf(RpcSuccess::class) @@ -118,9 +121,10 @@ function succeed(mixed $data = 'ok'): RpcSuccess $destinationRan = false; $result = runPipeline( - [middleware(fn(): RpcSuccess => succeed('short circuited'))], + [middleware(fn (): RpcSuccess => succeed('short circuited'))], function () use (&$destinationRan): RpcSuccess { $destinationRan = true; + return succeed(); }, ); @@ -132,12 +136,12 @@ function () use (&$destinationRan): RpcSuccess { test('a throwing middleware becomes an RpcError handed back to the enclosing middleware', function () { $result = runPipeline( [ - middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit outer')), + middleware(fn (mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit outer')), middleware(function (): RpcSuccess|RpcError { throw new RuntimeException('inner exploded'); }), ], - fn(): RpcSuccess => succeed(), + fn (): RpcSuccess => succeed(), ); // The outer ring keeps running: it saw an RpcError as the return value of $next(), not an exception. @@ -149,7 +153,7 @@ function () use (&$destinationRan): RpcSuccess { test('a throwing destination becomes an RpcError handed back to the innermost middleware', function () { $result = runPipeline( - [middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit outer'))], + [middleware(fn (mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit outer'))], function (): RpcSuccess { throw new RuntimeException('destination exploded'); }, @@ -165,17 +169,18 @@ function (): RpcSuccess { $result = runPipeline( [ - middleware(fn(mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit outer')), - middleware(fn(): RpcError => new RpcError( + middleware(fn (mixed $input, Closure $next): RpcSuccess|RpcError => trace($next($input), 'exit outer')), + middleware(fn (): RpcError => new RpcError( ErrorType::AUTHORIZATION_ERROR, new RuntimeException('denied'), ['type' => 'FORBIDDEN'], pipelineResolveInfo(), )), ], - fn(): RpcSuccess => succeed(), + fn (): RpcSuccess => succeed(), function (Throwable $throwable) use (&$presented): RpcError { $presented++; + return new RpcError(ErrorType::INTERNAL_ERROR, $throwable, ['type' => 'PRESENTED'], pipelineResolveInfo()); }, ); @@ -194,7 +199,7 @@ function (Throwable $throwable) use (&$presented): RpcError { throw new RuntimeException('inner exploded'); }), ], - fn(): RpcSuccess => succeed(), + fn (): RpcSuccess => succeed(), function (): RpcError { throw new RuntimeException('the presenter is broken too'); }, diff --git a/tests/Unit/Typescript/AliasRegistryTest.php b/tests/Unit/Typescript/AliasRegistryTest.php index 4506931..012305e 100644 --- a/tests/Unit/Typescript/AliasRegistryTest.php +++ b/tests/Unit/Typescript/AliasRegistryTest.php @@ -1,4 +1,6 @@ -set('Email', 'string & Brand<"email">'); - expect(fn() => $registry->set('Email', 'number & Brand<"email">')) + expect(fn () => $registry->set('Email', 'number & Brand<"email">')) ->toThrow(UnsupportedTypeException::class, 'Type alias Email has conflicting definitions'); }); @@ -49,22 +51,22 @@ $registry = new AliasRegistry(['Email' => 'string & Brand<"email">']); // Duplicate keys collapse inside an array literal, so the last one simply wins. - expect(fn() => new AliasRegistry([...$registry->toArray(), 'Email' => 'number'])) + expect(fn () => new AliasRegistry([...$registry->toArray(), 'Email' => 'number'])) ->not->toThrow(UnsupportedTypeException::class); - expect(fn() => $registry->set('Email', 'number')) + expect(fn () => $registry->set('Email', 'number')) ->toThrow(UnsupportedTypeException::class); }); test('throws when reading an alias that was never defined', function () { $registry = new AliasRegistry(['Email' => 'string & Brand<"email">']); - expect(fn() => $registry->get('Missing')) + expect(fn () => $registry->get('Missing')) ->toThrow(UnknownAliasException::class, "Unknown type alias 'Missing'. Call has() before get(). Known aliases: Email."); }); test('names no aliases when reading from an empty registry', function () { - expect(fn() => new AliasRegistry()->get('Missing')) + expect(fn () => new AliasRegistry()->get('Missing')) ->toThrow(UnknownAliasException::class, 'Known aliases: none.'); }); diff --git a/tests/Unit/Typescript/Code/TypescriptFileTest.php b/tests/Unit/Typescript/Code/TypescriptFileTest.php index 7c8b8c0..fcd8729 100644 --- a/tests/Unit/Typescript/Code/TypescriptFileTest.php +++ b/tests/Unit/Typescript/Code/TypescriptFileTest.php @@ -1,4 +1,6 @@ -toString(); expect($rendered)->toStartWith($prefix); @@ -57,7 +59,7 @@ function renderedBody(TypescriptFile $file): string expect(renderedBody($file))->toBe( "import type {Brand} from './lib/types';\n" - . "import {isBrand} from './lib/types';\n" + ."import {isBrand} from './lib/types';\n" ); }); @@ -99,8 +101,8 @@ function renderedBody(TypescriptFile $file): string expect(renderedBody($file))->toBe( "import type {Brand} from './lib/types';\n" - . "import {queryKey} from './lib/utils';\n" - . "import {useQuery} from '@tanstack/react-query';\n" + ."import {queryKey} from './lib/utils';\n" + ."import {useQuery} from '@tanstack/react-query';\n" ); }); @@ -119,7 +121,7 @@ function renderedBody(TypescriptFile $file): string TypescriptImport::mixed('./lib/types', [ ' type Order', 'type Brand ', - ' SomeValue' + ' SomeValue', ]), ]); @@ -153,7 +155,7 @@ function renderedBody(TypescriptFile $file): string expect(renderedBody($file))->toBe( "import type {Order} from './lib/types';\n" - . "import {Status} from './lib/types';\n" + ."import {Status} from './lib/types';\n" ); }); @@ -163,7 +165,7 @@ function renderedBody(TypescriptFile $file): string TypescriptImport::values('./types', 'isOrder'), TypescriptImport::values('@tanstack/react-query', 'useQuery'), ])->withModulesResolvedBy( - fn(string $from): string => str_starts_with($from, './lib/') ? './' . substr($from, 6) : $from, + fn (string $from): string => str_starts_with($from, './lib/') ? './'.substr($from, 6) : $from, ); // The two specifiers name one module once resolved, so they render as one import and not as a @@ -171,16 +173,16 @@ function renderedBody(TypescriptFile $file): string expect($file->imports)->toHaveCount(2) ->and(renderedBody($file))->toBe( "import type {Order} from './types';\n" - . "import {isOrder} from './types';\n" - . "import {useQuery} from '@tanstack/react-query';\n" - . "\nconst a = 1;\n" + ."import {isOrder} from './types';\n" + ."import {useQuery} from '@tanstack/react-query';\n" + ."\nconst a = 1;\n" ); }); test('withModulesResolvedBy returns a new file and leaves the original untouched', function () { $original = new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')]); - $resolved = $original->withModulesResolvedBy(fn(string $from): string => './types'); + $resolved = $original->withModulesResolvedBy(fn (string $from): string => './types'); expect($resolved)->not->toBe($original) ->and($original->imports[0]->from)->toBe('./lib/types') @@ -220,8 +222,8 @@ function renderedBody(TypescriptFile $file): string expect(renderedBody($file))->toBe( "import type {Brand, Order} from './lib/types';\n" - . "import {queryKey} from './lib/utils';\n" - . "\nconst a = 1;\n\nconst b = 2;\n" + ."import {queryKey} from './lib/utils';\n" + ."\nconst a = 1;\n\nconst b = 2;\n" ); }); @@ -274,7 +276,7 @@ function renderedBody(TypescriptFile $file): string })->with([ 'plain' => ['const a = 1;'], 'padded with newlines' => ["\nconst a = 1;\n\n"], - 'indented' => [" const a = 1;"], + 'indented' => [' const a = 1;'], 'empty' => [''], ]); @@ -307,7 +309,7 @@ function renderedBody(TypescriptFile $file): string TypescriptImport::values('./lib/utils', 'queryKey'), TypescriptImport::types('./lib/types', ['Order', 'Brand']), ])->append(new TypescriptFile( - <<toBe(<<toBe(<<<'TypeScript' import type {Brand, Order, OrderStatus} from './lib/types'; import {queryKey} from './lib/utils'; @@ -334,17 +336,17 @@ function renderedBody(TypescriptFile $file): string $file = new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')]); expect($file)->toBeInstanceOf(Stringable::class) - ->and((string)$file)->toBe($file->toString()); + ->and((string) $file)->toBe($file->toString()); }); test('every rendered file opens with the marker', function (TypescriptFile $file) { - expect($file->toString())->toStartWith(TypescriptFile::MARKER . "\n") + expect($file->toString())->toStartWith(TypescriptFile::MARKER."\n") ->and(TypescriptFile::isGenerated($file->toString()))->toBeTrue(); })->with([ - 'empty' => [fn() => new TypescriptFile()], - 'code only' => [fn() => new TypescriptFile('const a = 1;')], - 'imports only' => [fn() => new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')])], - 'both' => [fn() => new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')])], + 'empty' => [fn () => new TypescriptFile()], + 'code only' => [fn () => new TypescriptFile('const a = 1;')], + 'imports only' => [fn () => new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')])], + 'both' => [fn () => new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')])], ]); test('the marker carries nothing that varies between runs', function () { diff --git a/tests/Unit/Typescript/Code/TypescriptImportTest.php b/tests/Unit/Typescript/Code/TypescriptImportTest.php index f2e706b..17747f1 100644 --- a/tests/Unit/Typescript/Code/TypescriptImportTest.php +++ b/tests/Unit/Typescript/Code/TypescriptImportTest.php @@ -1,4 +1,6 @@ - new TypescriptImport('')) + expect(fn () => new TypescriptImport('')) ->toThrow(CodeGenException::class, 'cannot be written as a TypeScript module specifier'); }); test('rejects a module specifier that could not be written as a string literal', function (string $from) { - expect(fn() => TypescriptImport::values($from, 'a')) + expect(fn () => TypescriptImport::values($from, 'a')) ->toThrow(CodeGenException::class, 'cannot be written as a TypeScript module specifier'); })->with([ 'single quote' => ["./li'b"], @@ -94,9 +96,9 @@ ]); test('rejects a name that is not a valid TypeScript identifier', function (string $name) { - expect(fn() => TypescriptImport::values('./lib/types', $name)) + expect(fn () => TypescriptImport::values('./lib/types', $name)) ->toThrow(InvalidStringLiteralException::class, 'is not a valid TypeScript identifier') - ->and(fn() => TypescriptImport::types('./lib/types', $name)) + ->and(fn () => TypescriptImport::types('./lib/types', $name)) ->toThrow(InvalidStringLiteralException::class, 'is not a valid TypeScript identifier'); })->with([ 'empty' => [''], @@ -118,7 +120,7 @@ }); test('names the module in the error message so the bad import can be found', function () { - expect(fn() => TypescriptImport::types('./lib/types', 'foo-bar')) + expect(fn () => TypescriptImport::types('./lib/types', 'foo-bar')) ->toThrow(InvalidStringLiteralException::class, "imported from './lib/types'"); }); @@ -147,7 +149,7 @@ }); test('refuses to merge imports of different modules', function () { - expect(fn() => TypescriptImport::types('./lib/types', 'Brand') + expect(fn () => TypescriptImport::types('./lib/types', 'Brand') ->merge(TypescriptImport::types('./lib/utils', 'Brand'))) ->toThrow(CodeGenException::class, 'different modules'); }); diff --git a/tests/Unit/Typescript/NamedTypesTest.php b/tests/Unit/Typescript/NamedTypesTest.php index 371f584..c80eb86 100644 --- a/tests/Unit/Typescript/NamedTypesTest.php +++ b/tests/Unit/Typescript/NamedTypesTest.php @@ -1,7 +1,10 @@ -parse('array{order: \\' . Order::class . '}'); + $node = new TypeParser()->parse('array{order: \\'.Order::class.'}'); $result = typescriptFor($node, IO::OUTPUT); expect($result->type)->toBe('{order:Order;}') @@ -141,7 +144,7 @@ expect($generator->toTypescript($node, IO::INPUT, $shared)->type)->toBe('AsymmetricNamed'); - expect(fn() => $generator->toTypescript($node, IO::OUTPUT, $shared)) + expect(fn () => $generator->toTypescript($node, IO::OUTPUT, $shared)) ->toThrow(UnsupportedTypeException::class, 'AsymmetricNamed'); }); @@ -165,13 +168,13 @@ expect($result->type)->toBe('PublicResource') ->and($result->registry->toArray())->toBe(['PublicResource' => '{url:string;}']); - expect(fn() => typescriptFor($node, IO::INPUT)) + expect(fn () => typescriptFor($node, IO::INPUT)) ->toThrow(UnsupportedTypeException::class, PublicResource::class); }); test('Pick over a named class produces a new shape and drops the alias', function () { $result = typescriptFor( - new TypeParser()->parse('Pick<\\' . Customer::class . ", 'name'>"), + new TypeParser()->parse('Pick<\\'.Customer::class.", 'name'>"), IO::OUTPUT, ); @@ -188,13 +191,13 @@ NamedType::same('Cycle'), ); - expect(fn() => new TypescriptGenerator()->toTypescript($outer, IO::OUTPUT)) + expect(fn () => new TypescriptGenerator()->toTypescript($outer, IO::OUTPUT)) ->toThrow(UnsupportedTypeException::class, 'Cycle'); }); test('siblings inheriting one declaration emit distinct aliases in both directions', function () { $node = new TypeParser()->parse( - 'array{account: \\' . AccountId::class . ', brand: \\' . BrandId::class . '}', + 'array{account: \\'.AccountId::class.', brand: \\'.BrandId::class.'}', ); foreach ([IO::INPUT, IO::OUTPUT] as $io) { @@ -212,7 +215,7 @@ $node = new TypeParser()->parse(Order::class); $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $result = new TypescriptGenerator()->toTypescript($registry->get('node'), IO::OUTPUT); diff --git a/tests/Unit/Typescript/OptimizedAstTest.php b/tests/Unit/Typescript/OptimizedAstTest.php index ef7cf2f..89cbf0d 100644 --- a/tests/Unit/Typescript/OptimizedAstTest.php +++ b/tests/Unit/Typescript/OptimizedAstTest.php @@ -1,9 +1,12 @@ -generateOptimizedCode(['node' => $ast]); - /** @var \Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $generator = new TypescriptGenerator(); @@ -43,46 +46,46 @@ function toDefinition(string $typeString, ?IO $io = null): string test('Simple union type', function () { expect(toDefinition('array{name: string}|string')) - ->toBe("({name:string;}|string)"); + ->toBe('({name:string;}|string)'); }); test('Optional Fields', function () { expect(toDefinition('array{name?: string}|string')) - ->toBe("({name?:string;}|string)"); + ->toBe('({name?:string;}|string)'); }); test('Array type returns object', function () { expect(toDefinition('array{name: string}')) - ->toBe("{name:string;}"); + ->toBe('{name:string;}'); }); test('Object type returns object', function () { expect(toDefinition('object{name: string}')) - ->toBe("{name:string;}"); + ->toBe('{name:string;}'); }); test('Custom class type input', function () { expect(toDefinition(UserSchema::class, IO::INPUT)) - ->toBe("{age:number;email:string;username:string;}"); + ->toBe('{age:number;email:string;username:string;}'); }); test('Custom class type output', function () { expect(toDefinition(UserSchema::class, IO::OUTPUT)) - ->toBe("{age:number;username:string;}"); + ->toBe('{age:number;username:string;}'); }); test('scalar', function () { expect(toDefinition('scalar')) - ->toBe("(number|boolean|string)"); + ->toBe('(number|boolean|string)'); }); test('intersection type with union', function () { expect(toDefinition('(array{id: positive-int}|array{token: string})&array{reason: string}')) - ->toBe("(({id:number;}|{token:string;})&{reason:string;})"); + ->toBe('(({id:number;}|{token:string;})&{reason:string;})'); }); test('Complex union intersection', function () { - expect(toDefinition('((array{id: positive-int}|array{token: string})&array{reason: string})|' . UserSchema::class, IO::INPUT)) - ->toBe("((({id:number;}|{token:string;})&{reason:string;})|{age:number;email:string;username:string;})"); + expect(toDefinition('((array{id: positive-int}|array{token: string})&array{reason: string})|'.UserSchema::class, IO::INPUT)) + ->toBe('((({id:number;}|{token:string;})&{reason:string;})|{age:number;email:string;username:string;})'); }); }); diff --git a/tests/Unit/Typescript/Stubs/EmptyEnum.php b/tests/Unit/Typescript/Stubs/EmptyEnum.php index b60997a..75cf044 100644 --- a/tests/Unit/Typescript/Stubs/EmptyEnum.php +++ b/tests/Unit/Typescript/Stubs/EmptyEnum.php @@ -1,4 +1,6 @@ -parse($type) : $type; + return new TypescriptGenerator()->toTypescript($node, $io, $sharedRegistry); } @@ -45,6 +47,7 @@ function typescriptOfBoth(string|NodeInterface $type): string $output = typescriptOf($type, IO::OUTPUT); expect($input->type)->toBe($output->type) ->and($input->registry->toArray())->toBe($output->registry->toArray()); + return $input->type; } @@ -76,7 +79,7 @@ function typescriptOfBoth(string|NodeInterface $type): string 'float literal' => ['1.5', '1.5'], 'true' => ['true', 'true'], 'false' => ['false', 'false'], - 'enum case literal uses the case name' => ['\\' . ResultEnum::class . '::SUCCESS', '"SUCCESS"'], + 'enum case literal uses the case name' => ['\\'.ResultEnum::class.'::SUCCESS', '"SUCCESS"'], ]); test('escapes string literals for typescript', function (string $type, string $expected) { @@ -89,11 +92,11 @@ function typescriptOfBoth(string|NodeInterface $type): string ]); test('emits an enum as a union of its case names', function () { - expect(typescriptOfBoth('\\' . ResultEnum::class))->toBe('("SUCCESS"|"FAILURE")'); + expect(typescriptOfBoth('\\'.ResultEnum::class))->toBe('("SUCCESS"|"FAILURE")'); }); test('throws for an enum without cases', function () { - expect(fn() => typescriptOf(new EnumNode(EmptyEnum::class))) + expect(fn () => typescriptOf(new EnumNode(EmptyEnum::class))) ->toThrow(UnsupportedTypeException::class, 'declares no cases'); }); @@ -151,17 +154,17 @@ function typescriptOfBoth(string|NodeInterface $type): string expect($result->type)->toBe($expectedType) ->and($result->registry->isEmpty())->toBeTrue(); })->with([ - 'string value object' => ['\\' . Email::class, '(string & Brand<"email">)'], - 'int value object with an explicit brand' => ['\\' . UserId::class, '(number & Brand<"customerId">)'], - 'unbranded value object stays a plain string' => ['\\' . Slug::class, 'string'], + 'string value object' => ['\\'.Email::class, '(string & Brand<"email">)'], + 'int value object with an explicit brand' => ['\\'.UserId::class, '(number & Brand<"customerId">)'], + 'unbranded value object stays a plain string' => ['\\'.Slug::class, 'string'], 'inside a struct' => [ - '\\' . CreateAccountInput::class, + '\\'.CreateAccountInput::class, '{email:(string & Brand<"email">);ownerId:(number & Brand<"customerId">);}', ], - 'inside a list' => ['list<\\' . Email::class . '>', 'Array<(string & Brand<"email">)>'], - 'inside a union' => ['?\\' . Email::class, '(null|(string & Brand<"email">))'], + 'inside a list' => ['list<\\'.Email::class.'>', 'Array<(string & Brand<"email">)>'], + 'inside a union' => ['?\\'.Email::class, '(null|(string & Brand<"email">))'], 'inside a record' => [ - 'array', + 'array', 'Record)>', ], ]); @@ -169,7 +172,7 @@ function typescriptOfBoth(string|NodeInterface $type): string test('the BrandedString and BrandedInt utilities keep their implicit alias', function ( string $type, string $expectedType, - array $expectedAliases, + array $expectedAliases, ) { $result = typescriptOf($type); @@ -193,7 +196,7 @@ function typescriptOfBoth(string|NodeInterface $type): string }); test('throws when one brand resolves to two different definitions', function () { - expect(fn() => typescriptOf("array{a: BrandedString<'token'>, b: BrandedInt<'token'>}")) + expect(fn () => typescriptOf("array{a: BrandedString<'token'>, b: BrandedInt<'token'>}")) ->toThrow(UnsupportedTypeException::class, 'Token'); }); @@ -236,12 +239,12 @@ function typescriptOfBoth(string|NodeInterface $type): string test('throws when the incoming registry already binds an alias to something else', function () { $shared = new AliasRegistry(['Email' => '(number & Brand<"email">)']); - expect(fn() => typescriptOf("BrandedString<'email'>", IO::INPUT, $shared)) + expect(fn () => typescriptOf("BrandedString<'email'>", IO::INPUT, $shared)) ->toThrow(UnsupportedTypeException::class, 'Email'); }); test('filters struct properties by direction', function () { - $type = '\\' . UserSchema::class; + $type = '\\'.UserSchema::class; expect(typescriptOf($type, IO::INPUT)->type)->toBe('{age:number;email:string;username:string;}') ->and(typescriptOf($type, IO::OUTPUT)->type)->toBe('{age:number;username:string;}'); @@ -257,9 +260,9 @@ function typescriptOfBoth(string|NodeInterface $type): string }); test('throws for an uncastable class on input but emits it on output', function (string $class, string $output) { - $type = '\\' . $class; + $type = '\\'.$class; - expect(fn() => typescriptOf($type, IO::INPUT)) + expect(fn () => typescriptOf($type, IO::INPUT)) ->toThrow(UnsupportedTypeException::class, $class); expect(typescriptOf($type, IO::OUTPUT)->type)->toBe($output); @@ -271,10 +274,10 @@ function typescriptOfBoth(string|NodeInterface $type): string ]); test('throws for nodes it cannot represent', function (NodeInterface $node) { - expect(fn() => typescriptOf($node))->toThrow(UnsupportedTypeException::class); + expect(fn () => typescriptOf($node))->toThrow(UnsupportedTypeException::class); })->with([ 'ReferencedNode' => [new ReferencedNode('#leaf_abc', 'string', 'registry')], - 'unknown node implementation' => [new class implements NodeInterface { + 'unknown node implementation' => [new class () implements NodeInterface { public function __toString(): string { return 'unknown'; diff --git a/tests/Unit/Typescript/Utils/SyntaxTest.php b/tests/Unit/Typescript/Utils/SyntaxTest.php index b3241cd..9d4e3d6 100644 --- a/tests/Unit/Typescript/Utils/SyntaxTest.php +++ b/tests/Unit/Typescript/Utils/SyntaxTest.php @@ -1,4 +1,6 @@ - Syntax::moduleSpecifier($specifier)) + expect(fn () => Syntax::moduleSpecifier($specifier)) ->toThrow(CodeGenException::class, 'cannot be written as a TypeScript module specifier'); })->with([ 'empty' => [''], diff --git a/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php b/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php index ad02bc5..2d59d1e 100644 --- a/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php +++ b/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php @@ -1,13 +1,14 @@ - 'id', 'roles' => []]; } -} \ No newline at end of file +} diff --git a/tests/Unit/Utils/NamespacesTest.php b/tests/Unit/Utils/NamespacesTest.php index 5c5394c..56c63af 100644 --- a/tests/Unit/Utils/NamespacesTest.php +++ b/tests/Unit/Utils/NamespacesTest.php @@ -9,7 +9,6 @@ * toFullyQualifiedClassName() a map that buildNamespaceAliasMap() can actually produce. A * hand-written map that cannot occur is how the doubled-segment bug stayed hidden. */ - test('a leading backslash means the name is already fully qualified', function () { expect(Namespaces::toFullyQualifiedClassName('\\Bar', 'Foo', []))->toBe('Bar'); }); diff --git a/tests/Unit/Utils/NodesTest.php b/tests/Unit/Utils/NodesTest.php index 571c541..6b23a93 100644 --- a/tests/Unit/Utils/NodesTest.php +++ b/tests/Unit/Utils/NodesTest.php @@ -65,4 +65,4 @@ [new PropertyNode('name', new StringNode(), false)] ), ]))->toBeTrue(); -}); \ No newline at end of file +}); diff --git a/tests/Unit/Utils/PHPExportTest.php b/tests/Unit/Utils/PHPExportTest.php index ba15001..b91d6d0 100644 --- a/tests/Unit/Utils/PHPExportTest.php +++ b/tests/Unit/Utils/PHPExportTest.php @@ -1,4 +1,6 @@ -dir = sys_get_temp_dir() . '/php-ts-bindings-export-' . getmypid(); - if (!is_dir($this->dir)) { + $this->dir = sys_get_temp_dir().'/php-ts-bindings-export-'.getmypid(); + if (! is_dir($this->dir)) { mkdir($this->dir, 0o777, true); } }); afterEach(function () { - foreach (glob($this->dir . '/*') ?: [] as $file) { + foreach (glob($this->dir.'/*') ?: [] as $file) { unlink($file); } @rmdir($this->dir); }); test('writes the file', function () { - $target = $this->dir . '/out.php'; + $target = $this->dir.'/out.php'; PHPExport::writeFileAtomically($target, 'toBe('dir . '/out.php'; + $target = $this->dir.'/out.php'; file_put_contents($target, 'old'); PHPExport::writeFileAtomically($target, 'new'); @@ -39,15 +41,15 @@ * reader must see either the whole old file or the whole new one - never a partial write. */ test('leaves no temporary file behind', function () { - $target = $this->dir . '/out.php'; + $target = $this->dir.'/out.php'; PHPExport::writeFileAtomically($target, str_repeat('x', 200_000)); - expect(glob($this->dir . '/*'))->toBe([$target]); + expect(glob($this->dir.'/*'))->toBe([$target]); }); test('a reader never observes a partially written file', function () { - $target = $this->dir . '/out.php'; - $complete = 'dir.'/out.php'; + $complete = ' PHPExport::writeFileAtomically($this->dir . '/missing/out.php', 'x')) + expect(fn () => PHPExport::writeFileAtomically($this->dir.'/missing/out.php', 'x')) ->toThrow(ParserException::class, 'not a writable directory'); }); diff --git a/tests/Unit/Utils/PhpDocTest.php b/tests/Unit/Utils/PhpDocTest.php index 3da4328..73ac183 100644 --- a/tests/Unit/Utils/PhpDocTest.php +++ b/tests/Unit/Utils/PhpDocTest.php @@ -6,8 +6,9 @@ test('normalize', function () { - expect(PhpDoc::normalize(" /** @var string */"))->toBe(' @var string') - ->and(PhpDoc::normalize(<<toBe(' @var string') + ->and(PhpDoc::normalize( + <<<'DOC' /** * @phpstan-type ReadyToOrderInput array{ * id: positive-int, @@ -40,7 +41,8 @@ * @phpstan-type ChangeOrderStatusInput ReadyToOrderInput|WaitingOnApprovalInput|OrderedInput|CompletedInput|RejectedInput */ DOC - ))->toBe(<<toBe( + <<<'TEXT' @phpstan-type ReadyToOrderInput array{ id: positive-int, @@ -72,7 +74,8 @@ }); test('Find local defined types', function () { - expect(PhpDoc::findImportedTypeDefinition(<<toEqual([ 'MyType' => 'object{id: ID}', 'Other' => 'array{id: string}', - 'MultiLine' => 'array{ id: string, status: OrderStatus::READY_TO_ORDER, }' + 'MultiLine' => 'array{ id: string, status: OrderStatus::READY_TO_ORDER, }', ]); }); test('Find Generics', function () { - expect(PhpDoc::findGenerics(<<toEqual(['T', 'O']); -}); \ No newline at end of file +}); diff --git a/tests/Unit/Utils/ReflectionsTest.php b/tests/Unit/Utils/ReflectionsTest.php index faafd21..67e8803 100644 --- a/tests/Unit/Utils/ReflectionsTest.php +++ b/tests/Unit/Utils/ReflectionsTest.php @@ -50,4 +50,4 @@ $reflectionClass->getMethod('serializeDeeply') ) )->toBe('array{ id: non-empty-string, roles: list, }'); -}); \ No newline at end of file +}); diff --git a/tests/Unit/Utils/RegexesTest.php b/tests/Unit/Utils/RegexesTest.php index f9fadff..501686d 100644 --- a/tests/Unit/Utils/RegexesTest.php +++ b/tests/Unit/Utils/RegexesTest.php @@ -1,4 +1,6 @@ - Date: Fri, 7 Aug 2026 08:03:19 +0200 Subject: [PATCH 071/101] Remove `Reflections` utility and its associated tests - Deleted `Reflections` utility class and the related test files. - Replaced its functionality by enhancing `TypeReflector` with improved parsing and reflection logic. - Added comprehensive tests for `TypeReflector` to ensure compatibility with modern PHPDoc types and native type resolution. - Updated dependent files to reflect the transition from `Reflections` to `TypeReflector`. --- .../Helpers/Consumers/DateTimeConsumer.php | 18 +-- src/Parser/Helpers/ParsingScope.php | 42 ++++++- src/Reflection/FileReflector.php | 106 ++++++++++++++---- src/Reflection/TypeReflector.php | 84 +++++++++++--- .../EagerlyLoadedOperationRegistry.php | 12 +- src/Utils/Namespaces.php | 44 +++----- src/Utils/Reflections.php | 74 ------------ .../Mocks/ConflictingNamedOperations.php | 6 +- .../Mocks/Inherited/BaseOperations.php | 25 +++++ .../Mocks/Inherited/InheritedResult.php | 13 +++ .../CodeGen/Mocks/InheritingOperations.php | 14 +++ .../TypescriptServerCodeGeneratorTest.php | 12 ++ tests/Unit/Parser/Data/ParsingContextTest.php | 4 +- tests/Unit/Parser/TypeParserTest.php | 11 +- tests/Unit/Reflection/FileReflectorTest.php | 59 ++++++++++ .../Fixtures/EveryUseStatementShape.php | 49 ++++++++ tests/Unit/Reflection/Fixtures/SomeTrait.php | 13 +++ .../Unit/Reflection/Mocks/NativeTypesMock.php | 94 ++++++++++++++++ tests/Unit/Reflection/TypeReflectionTest.php | 82 +++++++++++++- .../Unit/Utils/Mocks/ReflectionsUtilMock.php | 45 -------- tests/Unit/Utils/NamespacesTest.php | 57 ++++++---- tests/Unit/Utils/ReflectionsTest.php | 53 --------- 22 files changed, 634 insertions(+), 283 deletions(-) delete mode 100644 src/Utils/Reflections.php create mode 100644 tests/Unit/CodeGen/Mocks/Inherited/BaseOperations.php create mode 100644 tests/Unit/CodeGen/Mocks/Inherited/InheritedResult.php create mode 100644 tests/Unit/CodeGen/Mocks/InheritingOperations.php create mode 100644 tests/Unit/Reflection/Fixtures/EveryUseStatementShape.php create mode 100644 tests/Unit/Reflection/Fixtures/SomeTrait.php create mode 100644 tests/Unit/Reflection/Mocks/NativeTypesMock.php delete mode 100644 tests/Unit/Utils/Mocks/ReflectionsUtilMock.php delete mode 100644 tests/Unit/Utils/ReflectionsTest.php diff --git a/src/Parser/Helpers/Consumers/DateTimeConsumer.php b/src/Parser/Helpers/Consumers/DateTimeConsumer.php index 54bdfc3..dd436d7 100644 --- a/src/Parser/Helpers/Consumers/DateTimeConsumer.php +++ b/src/Parser/Helpers/Consumers/DateTimeConsumer.php @@ -23,28 +23,16 @@ public function canConsume(ParserState $state): bool } $token = $state->current(); - $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($token->value); - // Built in classes are harder to catch as the fully qualified class name might - // be prefixed with the current namespace. - if (is_a($fullyQualifiedClassName, DateTimeInterface::class, true)) { - return true; - } - - return class_exists($token->value, false) && is_a($token->value, DateTimeInterface::class, true); + return is_a($state->context->toFullyQualifiedClassName($token->value), DateTimeInterface::class, true); } #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { - $token = $state->current(); - $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($token->value); - $state->advance(); - /** @var class-string $className */ - $className = is_a($fullyQualifiedClassName, DateTimeInterface::class, true) - ? $fullyQualifiedClassName - : $token->value; + $className = $state->context->toFullyQualifiedClassName($state->current()->value); + $state->advance(); return new DateTimeNode($className); } diff --git a/src/Parser/Helpers/ParsingScope.php b/src/Parser/Helpers/ParsingScope.php index 0c8fbdb..095e575 100644 --- a/src/Parser/Helpers/ParsingScope.php +++ b/src/Parser/Helpers/ParsingScope.php @@ -10,6 +10,7 @@ use Le0daniel\PhpTsBindings\Utils; use ReflectionClass; use ReflectionException; +use ReflectionMethod; use ReflectionParameter; use ReflectionProperty; @@ -18,20 +19,31 @@ */ final readonly class ParsingScope { + /** + * Alias => fully qualified name, keyed lowercase. PHP resolves `use` aliases case + * insensitively, so the keys are normalized here rather than trusted: a hand-written map - + * this is public API - would otherwise silently miss on the wrong casing. + * + * @var array + */ + public array $usedNamespaceMap; + /** * @param array $usedNamespaceMap * @param array $localTypes * @param array $importedTypes * @param array $generics + * @param class-string|null $declaredInClass */ public function __construct( public ?string $namespace = null, - public array $usedNamespaceMap = [], + array $usedNamespaceMap = [], public array $localTypes = [], public array $importedTypes = [], public array $generics = [], public ?string $declaredInClass = null, ) { + $this->usedNamespaceMap = array_change_key_case($usedNamespaceMap); } /** @@ -99,6 +111,34 @@ public function descendIntoDeclaringClass(ReflectionProperty|ReflectionParameter return self::fromReflectionClass($declaringClass); } + /** + * A method's PHPDoc is written where the method is, which for an inherited or trait-composed + * method is not the class it was reached through. The file is taken from the method itself + * rather than from getDeclaringClass(), because for a trait method that reports the composing + * class while the `use` statements the PHPDoc relies on live in the trait's file. + */ + public function descendIntoDeclaringFileOf(ReflectionMethod $method): self + { + $fileName = $method->getFileName(); + if ($fileName === false || $fileName === $this->declaringFile()) { + return $this; + } + + // ToDo: Identify the generics that should be passed down. Currently ignored. + return self::fromFilePath($fileName); + } + + private function declaringFile(): ?string + { + if ($this->declaredInClass === null) { + return null; + } + + $fileName = new ReflectionClass($this->declaredInClass)->getFileName(); + + return $fileName === false ? null : $fileName; + } + /** * @param list $generics * diff --git a/src/Reflection/FileReflector.php b/src/Reflection/FileReflector.php index 3709d05..5b18484 100644 --- a/src/Reflection/FileReflector.php +++ b/src/Reflection/FileReflector.php @@ -64,24 +64,45 @@ public function getUsedNamespaces(): array $tokens = $this->tokens(); $namespaces = []; $numTokens = count($tokens); + $depth = 0; for ($i = 0; $i < $numTokens; $i++) { $token = $tokens[$i]; + // A group use's own braces never reach here: parseUseStatement consumes them and + // returns the index of the closing `;`. + if ($token === '{') { + $depth++; + + continue; + } + if ($token === '}') { + $depth--; + + continue; + } + if (! is_array($token) || $token[0] !== T_USE) { continue; } - // Skip `use function` and `use const` + // Imports are top level statements. Inside a body, `use` composes a trait. + if ($depth > 0) { + continue; + } + + // Skip `use function` and `use const`. A null means the next token is punctuation, + // which for a `use` only ever means the `(` of a closure's capture list - not an + // import, and reading it as one would scan into the closure body. $nextToken = self::peekNextSignificantToken($tokens, $i, $numTokens); - if ($nextToken && in_array($nextToken[0], [T_FUNCTION, T_CONST], true)) { + if ($nextToken === null || in_array($nextToken[0], [T_FUNCTION, T_CONST], true)) { continue; } - [$fullyQualifiedClassName, $alias, $i] = self::parseUseStatement($tokens, $i, $numTokens); + [$imports, $i] = self::parseUseStatement($tokens, $i, $numTokens); - if ($fullyQualifiedClassName) { - if ($alias) { + foreach ($imports as [$fullyQualifiedClassName, $alias]) { + if ($alias !== null) { $namespaces[$fullyQualifiedClassName] = $alias; } else { $namespaces[] = $fullyQualifiedClassName; @@ -262,37 +283,80 @@ private static function peekNextSignificantToken(array $tokens, int $currentInde } /** + * Reads one `use` statement, from its T_USE token up to the terminating `;`. + * + * A single import yields one entry; a group (`use App\Data\{Order, Customer as C};`) yields one + * per member, each carrying its own alias. The leading name arrives as T_STRING when it has a + * single segment, T_NAME_QUALIFIED otherwise, and T_NAME_FULLY_QUALIFIED when it was written + * with a leading backslash - all three have to be read or the import is silently lost. + * * @param list $tokens - * @return array{string, string|null, int} + * @return array{list, int} The imports and the index of the `;`. */ private static function parseUseStatement(array $tokens, int $startIndex, int $maxIndex): array { - $fullyQualifiedClassname = ''; + $imports = []; + $prefix = ''; + $name = null; $alias = null; + $expectAlias = false; $i = $startIndex + 1; - while ($i < $maxIndex) { + for (; $i < $maxIndex; $i++) { $token = $tokens[$i]; + if ($token === ';') { break; } - if (is_array($token)) { - switch ($token[0]) { - case T_NAME_QUALIFIED: - $fullyQualifiedClassname = $token[1]; - break; - case T_AS: - $aliasToken = self::peekNextSignificantToken($tokens, $i, $maxIndex); - if ($aliasToken && $aliasToken[0] === T_STRING) { - $alias = $aliasToken[1]; - } - break; + // Everything read so far is the group's shared prefix; the members follow. + if ($token === '{') { + $prefix = $name === null ? '' : "{$name}\\"; + $name = null; + $alias = null; + + continue; + } + + if ($token === ',') { + if ($name !== null) { + $imports[] = [$prefix.$name, $alias]; } + $name = null; + $alias = null; + $expectAlias = false; + + continue; } - $i++; + + if (! is_array($token)) { + continue; + } + + if ($token[0] === T_AS) { + $expectAlias = true; + + continue; + } + + if (! in_array($token[0], [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED], true)) { + continue; + } + + if ($expectAlias) { + $alias = $token[1]; + $expectAlias = false; + + continue; + } + + $name = $token[1]; + } + + if ($name !== null) { + $imports[] = [$prefix.$name, $alias]; } - return [$fullyQualifiedClassname, $alias, $i]; + return [$imports, $i]; } } diff --git a/src/Reflection/TypeReflector.php b/src/Reflection/TypeReflector.php index cfeacfe..dfae987 100644 --- a/src/Reflection/TypeReflector.php +++ b/src/Reflection/TypeReflector.php @@ -7,63 +7,113 @@ use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Utils\Regexes; use ReflectionFunction; +use ReflectionIntersectionType; use ReflectionMethod; +use ReflectionNamedType; use ReflectionParameter; use ReflectionProperty; +use ReflectionType; +use ReflectionUnionType; final readonly class TypeReflector { public static function reflectProperty(ReflectionProperty $property): string { - if (! $property->getType()) { + $type = $property->getType(); + if (! $type) { throw new ParserException('No type defined.'); } - if ($property->getDocComment() && $type = Regexes::findFirstVarDeclaration($property->getDocComment())) { - return trim($type); + if ($property->getDocComment() && $docBlockType = Regexes::findFirstVarDeclaration($property->getDocComment())) { + return trim($docBlockType); } if (! $property->isPromoted()) { - return (string) $property->getType(); + return self::toTypeString($type); } $constructorDocBlock = $property->getDeclaringClass()->getConstructor()?->getDocComment(); - if ($constructorDocBlock && $type = Regexes::findParamWithNameDeclaration($constructorDocBlock, $property->getName())) { - return trim($type); + if ($constructorDocBlock && $docBlockType = Regexes::findParamWithNameDeclaration($constructorDocBlock, $property->getName())) { + return trim($docBlockType); } - return (string) $property->getType(); + return self::toTypeString($type); } public static function reflectParameter(ReflectionParameter $parameter): string { - if (! $parameter->getType()) { + $type = $parameter->getType(); + if (! $type) { throw new ParserException('No type defined.'); } $declaringDocBlock = $parameter->getDeclaringFunction()->getDocComment(); if (! $declaringDocBlock) { - return (string) $parameter->getType(); + return self::toTypeString($type); } - return trim( - Regexes::findParamWithNameDeclaration($declaringDocBlock, $parameter->getName()) ?? (string) $parameter->getType() - ); + $docBlockType = Regexes::findParamWithNameDeclaration($declaringDocBlock, $parameter->getName()); + + return $docBlockType === null ? self::toTypeString($type) : trim($docBlockType); } public static function reflectReturnType(ReflectionFunction|ReflectionMethod $returnable): string { - if (! $returnable->hasReturnType()) { + $type = $returnable->getReturnType(); + if (! $type) { throw new ParserException('No return type defined.'); } $docBlock = $returnable->getDocComment(); if (! $docBlock) { - return (string) $returnable->getReturnType(); + return self::toTypeString($type); } - return trim( - Regexes::findReturnTypeDeclaration($docBlock) ?? (string) $returnable->getReturnType() - ); + $docBlockType = Regexes::findReturnTypeDeclaration($docBlock); + + return $docBlockType === null ? self::toTypeString($type) : trim($docBlockType); + } + + /** + * Reflection reports class names fully qualified but drops the leading backslash that says so, + * leaving `App\Models\User` indistinguishable from a name the parser still has to resolve + * against the declaring file's namespace and imports - which is how it ended up resolving to + * `Current\Namespace\App\Models\User`. Putting the backslash back marks the name as absolute. + * + * This mirrors how PHPStan hands a native type to its own parser: it never round-trips through + * a string, it emits a FullyQualified node straight from the ReflectionType. + */ + private static function toTypeString(ReflectionType $type): string + { + return match (true) { + $type instanceof ReflectionUnionType => implode('|', array_map( + // Reflection reports a DNF type as a union with an intersection member. Without the + // parentheses `(A&B)|null` would read back as `A & (B|null)`. + fn (ReflectionType $member): string => $member instanceof ReflectionIntersectionType + ? '('.self::toTypeString($member).')' + : self::toTypeString($member), + $type->getTypes(), + )), + $type instanceof ReflectionIntersectionType => implode( + '&', + array_map(self::toTypeString(...), $type->getTypes()), + ), + $type instanceof ReflectionNamedType => self::namedTypeToString($type), + default => (string) $type, + }; + } + + private static function namedTypeToString(ReflectionNamedType $type): string + { + $name = $type->getName(); + + // `self` and `parent` are resolved by PHP itself, so `static` is the only non-builtin name + // reflection reports that does not name a class. + $qualified = $type->isBuiltin() || $name === 'static' ? $name : "\\{$name}"; + + // mixed and null carry allowsNull() on their own; neither `?mixed` nor `?null` is a type. + return $type->allowsNull() && $name !== 'mixed' && $name !== 'null' + ? "?{$qualified}" + : $qualified; } } diff --git a/src/Server/Operations/EagerlyLoadedOperationRegistry.php b/src/Server/Operations/EagerlyLoadedOperationRegistry.php index 4a720b1..1479d41 100644 --- a/src/Server/Operations/EagerlyLoadedOperationRegistry.php +++ b/src/Server/Operations/EagerlyLoadedOperationRegistry.php @@ -97,11 +97,17 @@ private static function registryFromDiscovery( // Lazily execute the parsing. $factories[$fullyQualifiedKey] = static function () use ($definition, $parser, $key) { $classReflection = new ReflectionClass($definition->fullyQualifiedClassName); - $inputParameter = $classReflection->getMethod($definition->methodName)->getParameters()[0]; + $method = $classReflection->getMethod($definition->methodName); + $inputParameter = $method->getParameters()[0]; + + // A class can register a method it inherited, whose PHPDoc was written against a + // different file's namespace and imports. @param and @return share that one + // docblock, so both resolve in the file the method is written in. + $parsingContext = ParsingScope::fromReflectionClass($classReflection) + ->descendIntoDeclaringFileOf($method); - $parsingContext = ParsingScope::fromReflectionClass($classReflection); $input = fn () => $parser->parse(TypeReflector::reflectParameter($inputParameter), $parsingContext); - $output = fn () => $parser->parse(TypeReflector::reflectReturnType($classReflection->getMethod($definition->methodName)), $parsingContext); + $output = fn () => $parser->parse(TypeReflector::reflectReturnType($method), $parsingContext); return new Operation($key, $definition, $input, $output); }; diff --git a/src/Utils/Namespaces.php b/src/Utils/Namespaces.php index 31237ea..23fef56 100644 --- a/src/Utils/Namespaces.php +++ b/src/Utils/Namespaces.php @@ -20,12 +20,14 @@ * Will Return * ``` * [ - * 'Models' => 'App\Models', - * 'User' => 'App\Models\User', - * 'UserContract' => 'App\Contracts\User', + * 'models' => 'App\Models', + * 'user' => 'App\Models\User', + * 'usercontract' => 'App\Contracts\User', * ] * ``` * + * Keys are lowercased because PHP resolves `use` aliases case insensitively. + * * Names come from a file's parsed `use` statements, so they are strings that look like class * names but are not verified to name anything. Typing them class-string would be a guarantee * this cannot make - resolution happens later, against the consumers that actually need a class. @@ -38,9 +40,9 @@ public static function buildNamespaceAliasMap(array $namespaces): array $map = []; foreach ($namespaces as $namespace => $alias) { if (is_int($namespace)) { - $map[Strings::classBaseName($alias)] = self::withoutLeadingSlash($alias); + $map[strtolower(Strings::classBaseName($alias))] = self::withoutLeadingSlash($alias); } else { - $map[$alias] = self::withoutLeadingSlash($namespace); + $map[strtolower($alias)] = self::withoutLeadingSlash($namespace); } } @@ -53,6 +55,14 @@ private static function withoutLeadingSlash(string $className): string } /** + * PHP's name resolution and nothing else, matching PHPStan's NameScope::resolveStringName(). + * + * There is deliberately no "this already looks fully qualified" check: a qualified name whose + * first segment is not imported is relative, however absolute it looks, and guessing otherwise + * makes resolution depend on which unrelated classes a file happens to import. Reflection is the + * one source that hands over names that really are absolute, and TypeReflector marks those with + * a leading backslash before they ever get here. + * * @param array $namespacesMap */ public static function toFullyQualifiedClassName(string $className, ?string $namespace, array $namespacesMap): string @@ -65,7 +75,7 @@ public static function toFullyQualifiedClassName(string $className, ?string $nam // the alias are appended: `use App\Models;` plus `Models\User` is App\Models\User, not // App\Models\Models\User. $segments = explode('\\', $className); - $lookupKey = $segments[0]; + $lookupKey = strtolower($segments[0]); if (array_key_exists($lookupKey, $namespacesMap)) { $remaining = array_slice($segments, 1); @@ -74,26 +84,6 @@ public static function toFullyQualifiedClassName(string $className, ?string $nam : $namespacesMap[$lookupKey].'\\'.implode('\\', $remaining); } - // If reflection->getType()->getName() is used, it already returns a fully qualified class name. - // In case we did not find an import match, we check if the classname is imported anywhere already. If this is the case, we return it. - if (array_any($namespacesMap, fn (string $usedClass) => self::isWithin($className, $usedClass))) { - return $className; - } - - if ($namespace !== null && ! self::isWithin($className, $namespace)) { - return $namespace.'\\'.$className; - } - - return $className; - } - - /** - * Whether $className is $parent itself or sits below it, compared on a namespace boundary. - * A raw prefix test would put `Application` inside `App`, and `App\Models\UserProfile` inside - * `App\Models\User`. - */ - private static function isWithin(string $className, string $parent): bool - { - return $className === $parent || str_starts_with($className, "{$parent}\\"); + return $namespace === null ? $className : $namespace.'\\'.$className; } } diff --git a/src/Utils/Reflections.php b/src/Utils/Reflections.php deleted file mode 100644 index e84e4da..0000000 --- a/src/Utils/Reflections.php +++ /dev/null @@ -1,74 +0,0 @@ -getType()) { - throw new ParserException('No type defined.'); - } - - $typeString = match (true) { - $propertyOrParameter instanceof ReflectionProperty => self::getPropertyTypeString($propertyOrParameter), - $propertyOrParameter instanceof ReflectionParameter => self::getParameterTypeString($propertyOrParameter), - }; - - return trim($typeString); - } - - private static function getParameterTypeString(ReflectionParameter $parameter): string - { - if (! $parameter->getType()) { - throw new ParserException('No type defined.'); - } - - $declaringFnDoc = $parameter->getDeclaringFunction()->getDocComment(); - if (! $declaringFnDoc) { - return (string) $parameter->getType(); - } - - return Regexes::findParamWithNameDeclaration($declaringFnDoc, $parameter->getName()) ?? (string) $parameter->getType(); - } - - private static function getPropertyTypeString(ReflectionProperty $property): string - { - if (! $property->hasType()) { - throw new ParserException('No type defined.'); - } - - if ($property->getDocComment()) { - return Regexes::findFirstVarDeclaration($property->getDocComment()) ?? (string) $property->getType(); - } - - if (! $property->isPromoted()) { - return (string) $property->getType(); - } - - $constructorDocBlock = $property->getDeclaringClass()->getConstructor()?->getDocComment(); - if (! $constructorDocBlock) { - return (string) $property->getType(); - } - - return Regexes::findParamWithNameDeclaration($constructorDocBlock, $property->getName()) ?? (string) $property->getType(); - } - - public static function getReturnType(ReflectionMethod|ReflectionFunction $reflection): string - { - $docBlock = $reflection->getDocComment(); - if (! $docBlock) { - return (string) $reflection->getReturnType(); - } - - return Regexes::findReturnTypeDeclaration($docBlock) ?? (string) $reflection->getReturnType(); - } -} diff --git a/tests/Unit/CodeGen/Mocks/ConflictingNamedOperations.php b/tests/Unit/CodeGen/Mocks/ConflictingNamedOperations.php index 508fa4f..e3449c8 100644 --- a/tests/Unit/CodeGen/Mocks/ConflictingNamedOperations.php +++ b/tests/Unit/CodeGen/Mocks/ConflictingNamedOperations.php @@ -1,8 +1,10 @@ -toThrow(UnsupportedTypeException::class, 'Customer'); }); +test('an inherited operation resolves its PHPDoc against the file that declares it', function () { + // InheritingOperations registers a method it does not declare, and neither its file nor its + // namespace knows InheritedResult. Taking the scope from the registered class looked for + // Tests\Unit\CodeGen\Mocks\InheritedResult and found nothing. + $operations = generateFor([InheritingOperations::class])['inherited.ts']->toString(); + + expect($operations) + ->toContain('export type GetInput = {result:{label:string;};};') + ->toContain('export type GetResult = {label:string;};'); +}); + test('fails the whole run when an operation input has no TypeScript representation', function () { expect(fn () => generateFor([UnrepresentableOperations::class])) ->toThrow(UnsupportedTypeException::class, 'SomeFileInterface'); diff --git a/tests/Unit/Parser/Data/ParsingContextTest.php b/tests/Unit/Parser/Data/ParsingContextTest.php index 85f8028..b55de03 100644 --- a/tests/Unit/Parser/Data/ParsingContextTest.php +++ b/tests/Unit/Parser/Data/ParsingContextTest.php @@ -18,8 +18,8 @@ ->toBe('Tests\\Unit\\Parser\\Data\\Stubs') ->and($context->usedNamespaceMap) ->toBe([ - 'Optimizer' => 'Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer', - 'TypeParser' => 'Le0daniel\PhpTsBindings\Parser\TypeParser', + 'optimizer' => 'Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer', + 'typeparser' => 'Le0daniel\PhpTsBindings\Parser\TypeParser', ]) ->and($context->localTypes) ->toBe([ diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index c50ed4a..33fd03f 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -540,10 +540,19 @@ test('Test date time with a namespace', function () { $parser = new TypeParser(); + + // Inside a namespace a bare `DateTime` needs an import, exactly as PHP resolves it - the + // built-in classes get no special treatment. Written absolute it resolves on its own. /** @var UnionNode $node */ - $node = $parser->parse(\DateTime::class, new ParsingScope('SomeName\\Space')); + $node = $parser->parse(\DateTime::class, new ParsingScope('SomeName\\Space', ['DateTime' => \DateTime::class])); expect($node)->toBeInstanceOf(DateTimeNode::class); compareToOptimizedAst($node); + + $absolute = $parser->parse('\\'.\DateTime::class, new ParsingScope('SomeName\\Space')); + expect($absolute)->toBeInstanceOf(DateTimeNode::class); + + expect(fn () => $parser->parse(\DateTime::class, new ParsingScope('SomeName\\Space'))) + ->toThrow(InvalidSyntaxException::class); }); test('Test EnumCase and class const literal', function () { diff --git a/tests/Unit/Reflection/FileReflectorTest.php b/tests/Unit/Reflection/FileReflectorTest.php index c3f71d4..87f246b 100644 --- a/tests/Unit/Reflection/FileReflectorTest.php +++ b/tests/Unit/Reflection/FileReflectorTest.php @@ -4,7 +4,11 @@ namespace Tests\Unit\Reflection; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\DateTimeNode; +use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\FileReflector; +use Le0daniel\PhpTsBindings\Utils\Namespaces; use Tests\Unit\Reflection\Fixtures\ClassConstantBeforeDeclaration; test('a ::class constant above the declaration is not mistaken for it', function () { @@ -20,3 +24,58 @@ expect($reflector->getDeclaredClass()->getNamespaceName())->toBe('Tests\\Unit\\Reflection\\Fixtures'); }); + +/** + * Anything missing from this map silently becomes a name resolved against the file's own namespace, + * which is how `use DateTimeImmutable;` used to produce Some\Namespace\DateTimeImmutable. + */ +test('every use statement shape lands in the alias map', function () { + $reflector = new FileReflector(__DIR__.'/Fixtures/EveryUseStatementShape.php'); + + expect(Namespaces::buildNamespaceAliasMap($reflector->getUsedNamespaces()))->toBe([ + // Single segment: the token is a plain T_STRING, not T_NAME_QUALIFIED. + 'arrayobject' => 'ArrayObject', + 'counted' => 'Countable', + 'datetimeimmutable' => 'DateTimeImmutable', + // Group use: one entry per member, each honouring its own alias. + 'astoptimizer' => 'Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer', + 'scope' => 'Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope', + 'typeparser' => 'Le0daniel\PhpTsBindings\Parser\TypeParser', + 'ns' => 'Le0daniel\PhpTsBindings\Utils\Namespaces', + ]); +}); + +test('trait uses and closure captures are not imports', function () { + $reflector = new FileReflector(__DIR__.'/Fixtures/EveryUseStatementShape.php'); + $map = Namespaces::buildNamespaceAliasMap($reflector->getUsedNamespaces()); + + // `use SomeTrait;` inside the class body and `function () use ($offset)` both start with T_USE. + // A scan that reads the closure capture runs on to the next `;` and picks a qualified name out + // of the body, so NotAnImport is the canary for that. + expect($map)->not->toHaveKey('sometrait') + ->and($map)->not->toHaveKey('notanimport') + ->and($map)->not->toHaveKey('offset') + // `use function` and `use const` are not type imports either. + ->and($map)->not->toHaveKey('array_map') + ->and($map)->not->toHaveKey('php_eol'); +}); + +test('a single segment import resolves instead of being prefixed with the namespace', function () { + $scope = ParsingScope::fromFilePath(__DIR__.'/Fixtures/EveryUseStatementShape.php'); + + expect($scope->toFullyQualifiedClassName('DateTimeImmutable'))->toBe('DateTimeImmutable') + ->and($scope->toFullyQualifiedClassName('Counted'))->toBe('Countable') + ->and($scope->toFullyQualifiedClassName('Scope')) + ->toBe('Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope') + // Not imported, so it stays relative to the file. + ->and($scope->toFullyQualifiedClassName('NotAnImport')) + ->toBe('Tests\Unit\Reflection\Fixtures\NotAnImport'); +}); + +test('a single segment import carries a date time all the way through the parser', function () { + // DateTimeConsumer used to compensate for the dropped import by testing class_exists() against + // the raw token; with the import map complete it resolves through the scope like anything else. + $scope = ParsingScope::fromFilePath(__DIR__.'/Fixtures/EveryUseStatementShape.php'); + + expect(new TypeParser()->parse('DateTimeImmutable', $scope))->toBeInstanceOf(DateTimeNode::class); +}); diff --git a/tests/Unit/Reflection/Fixtures/EveryUseStatementShape.php b/tests/Unit/Reflection/Fixtures/EveryUseStatementShape.php new file mode 100644 index 0000000..aea4c36 --- /dev/null +++ b/tests/Unit/Reflection/Fixtures/EveryUseStatementShape.php @@ -0,0 +1,49 @@ + */ + public ArrayObject $items; + + public DateTimeImmutable $createdAt; + + public function run(int $offset): callable + { + $scope = new Scope(); + $names = array_map( + fn (string $name): string => Ns::toFullyQualifiedClassName($name, null, []), + [Counted::class, ASTOptimizer::class, TypeParser::class], + ); + + return function () use ($offset, $scope, $names): string { + // Deliberately a T_NAME_QUALIFIED token: a scan that reads the closure capture as a use + // statement runs on to the next `;` and picks this up as an import. + return Nested\NotAnImport::class.PHP_EOL.$offset.implode('', $names).$scope::class; + }; + } +} diff --git a/tests/Unit/Reflection/Fixtures/SomeTrait.php b/tests/Unit/Reflection/Fixtures/SomeTrait.php new file mode 100644 index 0000000..40cfc80 --- /dev/null +++ b/tests/Unit/Reflection/Fixtures/SomeTrait.php @@ -0,0 +1,13 @@ +and(TypeReflector::reflectProperty($reflection->getProperty('name'))) ->toBe('non-empty-string') ->and(TypeReflector::reflectProperty($reflection->getProperty('birthdate'))) - ->toBe('DateTimeInterface'); + ->toBe('\DateTimeInterface'); }); test('from reflection parameter', function () { @@ -24,7 +28,7 @@ expect(TypeReflector::reflectParameter($parameters[0])) ->toBe('non-empty-string') ->and(TypeReflector::reflectParameter($parameters[1])) - ->toBe('DateTimeInterface'); + ->toBe('\DateTimeInterface'); }); test('from reflection method', function () { @@ -49,3 +53,77 @@ ->and(TypeReflector::reflectReturnType($reflection->getMethod('serialize'))) ->toBe('array{ id: non-empty-string, roles: list, }'); }); + +/** + * PHP stringifies a reflection type with the leading backslash stripped, which is indistinguishable + * from a name that still has to be resolved against the declaring file. Every class name coming out + * of reflection is already fully qualified, so it is emitted as such. + */ +test('native class names are emitted fully qualified', function (string $method, string $expected) { + $reflection = new ReflectionClass(NativeTypesMock::class); + + expect(TypeReflector::reflectReturnType($reflection->getMethod($method)))->toBe($expected); +})->with([ + 'unimported class' => ['outOfNamespace', '\Tests\Mocks\Named\Conflict\Customer'], + 'nullable class' => ['nullableClass', '?\Tests\Mocks\Named\Conflict\Customer'], + 'explicit null union' => ['explicitNullUnion', '?\Tests\Mocks\Named\Conflict\Customer'], + 'union' => ['union', '\Tests\Mocks\Named\Customer|\Tests\Mocks\Named\Conflict\Customer'], + 'union with a builtin' => ['mixedUnion', '\Tests\Mocks\Named\Customer|string'], + 'intersection' => ['intersection', '\Countable&\Stringable'], + // Reflection reports DNF as a union with an intersection member; without the parentheses the + // string would read as `\Countable & (\Stringable|null)`. + 'disjunctive normal form' => ['disjunctiveNormalForm', '(\Countable&\Stringable)|null'], + // self is resolved by PHP itself, so it arrives as a real class name. + 'self' => ['itself', '\Tests\Unit\Reflection\Mocks\NativeTypesMock'], +]); + +test('builtin types are left untouched', function (string $method, string $expected) { + $reflection = new ReflectionClass(NativeTypesMock::class); + + expect(TypeReflector::reflectReturnType($reflection->getMethod($method)))->toBe($expected); +})->with([ + 'string' => ['builtin', 'string'], + 'nullable string' => ['nullableBuiltin', '?string'], + // mixed and null report allowsNull() themselves; `?mixed` is not a type. + 'mixed' => ['anything', 'mixed'], + 'void' => ['nothing', 'void'], +]); + +test('native property and parameter types are emitted fully qualified too', function () { + $reflection = new ReflectionClass(NativeTypesMock::class); + $parameters = $reflection->getMethod('parameters')->getParameters(); + + expect(TypeReflector::reflectProperty($reflection->getProperty('unimported'))) + ->toBe('\DateTimeInterface') + ->and(TypeReflector::reflectProperty($reflection->getProperty('imported'))) + ->toBe('\Tests\Mocks\Named\Conflict\Customer') + ->and(TypeReflector::reflectProperty($reflection->getProperty('nullableClass'))) + ->toBe('?\Tests\Mocks\Named\Conflict\Customer') + ->and(TypeReflector::reflectProperty($reflection->getProperty('builtin'))) + ->toBe('string') + ->and(TypeReflector::reflectProperty($reflection->getProperty('nullableBuiltin'))) + ->toBe('?string') + ->and(TypeReflector::reflectProperty($reflection->getProperty('anything'))) + ->toBe('mixed') + ->and(TypeReflector::reflectParameter($parameters[0])) + ->toBe('\Tests\Mocks\Named\Conflict\Customer') + ->and(TypeReflector::reflectParameter($parameters[1])) + ->toBe('?string'); +}); + +/** + * The regression this all exists for: a native return type whose class the declaring file does not + * import used to be resolved a second time, producing + * Tests\Unit\Reflection\Mocks\Tests\Mocks\Named\Conflict\Customer and a "No parser found." error. + */ +test('a native return type resolves without a PHPDoc to lean on', function () { + $reflection = new ReflectionClass(NativeTypesMock::class); + $scope = ParsingScope::fromReflectionClass($reflection); + + $node = new TypeParser()->parse( + TypeReflector::reflectReturnType($reflection->getMethod('outOfNamespace')), + $scope, + ); + + expect($node)->toBeInstanceOf(NodeInterface::class); +}); diff --git a/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php b/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php deleted file mode 100644 index 2d59d1e..0000000 --- a/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php +++ /dev/null @@ -1,45 +0,0 @@ -, - * } - */ - public function serializeDeeply(): array - { - return ['id' => 'id', 'roles' => []]; - } -} diff --git a/tests/Unit/Utils/NamespacesTest.php b/tests/Unit/Utils/NamespacesTest.php index 56c63af..ecb4b32 100644 --- a/tests/Unit/Utils/NamespacesTest.php +++ b/tests/Unit/Utils/NamespacesTest.php @@ -8,6 +8,11 @@ * The maps under test are built from a file's `use` statements, so every case here feeds * toFullyQualifiedClassName() a map that buildNamespaceAliasMap() can actually produce. A * hand-written map that cannot occur is how the doubled-segment bug stayed hidden. + * + * These are PHP's name resolution rules and nothing more, matching PHPStan's + * NameScope::resolveStringName(): a leading backslash means absolute, a first segment matching an + * import is substituted, and anything else is relative to the current namespace. Reflection-derived + * names never reach here without a leading backslash - TypeReflector puts one on. */ test('a leading backslash means the name is already fully qualified', function () { expect(Namespaces::toFullyQualifiedClassName('\\Bar', 'Foo', []))->toBe('Bar'); @@ -17,6 +22,10 @@ expect(Namespaces::toFullyQualifiedClassName('Bar', 'Foo', []))->toBe('Foo\\Bar'); }); +test('an unimported name in the global namespace is left alone', function () { + expect(Namespaces::toFullyQualifiedClassName('Bar', null, []))->toBe('Bar'); +}); + test('an imported class resolves to what it was imported as', function () { $map = Namespaces::buildNamespaceAliasMap(['App\\Utils\\MyClass']); @@ -29,6 +38,13 @@ expect(Namespaces::toFullyQualifiedClassName('UserContract', 'Foo', $map))->toBe('App\\Contracts\\User'); }); +test('imports are matched case insensitively, as PHP resolves them', function () { + $map = Namespaces::buildNamespaceAliasMap(['App\\Utils\\MyClass', 'App\\Contracts\\User' => 'UserContract']); + + expect(Namespaces::toFullyQualifiedClassName('myclass', 'Foo', $map))->toBe('App\\Utils\\MyClass') + ->and(Namespaces::toFullyQualifiedClassName('USERCONTRACT', 'Foo', $map))->toBe('App\\Contracts\\User'); +}); + test('a sub path of an imported namespace appends only the remaining segments', function () { // use App\Models; then Models\User - the alias contributes App\Models, and only what follows // it is appended. Concatenating the whole short name produced App\Models\Models\User. @@ -46,26 +62,26 @@ ->toBe('App\\Models\\User\\Profile'); }); -test('the current namespace is matched on a segment boundary', function () { - // 'Application' merely starts with the text 'App'; it is not in the App namespace, so it has - // to be resolved against it like any other unimported name. - expect(Namespaces::toFullyQualifiedClassName('Application', 'App', []))->toBe('App\\Application') - ->and(Namespaces::toFullyQualifiedClassName('App\\Models\\User', 'App', []))->toBe('App\\Models\\User') - ->and(Namespaces::toFullyQualifiedClassName('App', 'App', []))->toBe('App'); -}); - -test('an already qualified name inside an imported namespace is left alone', function () { +/** + * Only the FIRST segment is ever matched against the imports; a qualified name whose first segment + * is not imported is relative, however fully qualified it looks. Guessing otherwise is what made + * `Tests\Mocks\Named\Customer` resolve and `Tests\Mocks\Named\Conflict\Customer` fail in the same + * file, purely because of which one happened to sit under an import. + */ +test('a qualified name whose first segment is not imported is still relative', function () { $map = Namespaces::buildNamespaceAliasMap(['App\\Models\\User']); - expect(Namespaces::toFullyQualifiedClassName('App\\Models\\User', 'Foo', $map))->toBe('App\\Models\\User'); + expect(Namespaces::toFullyQualifiedClassName('App\\Models\\User', 'Foo', $map)) + ->toBe('Foo\\App\\Models\\User') + ->and(Namespaces::toFullyQualifiedClassName('App\\Models\\UserProfile', 'Foo', $map)) + ->toBe('Foo\\App\\Models\\UserProfile'); }); -test('an imported name is matched on a segment boundary too', function () { - // 'App\Models\UserProfile' starts with the imported 'App\Models\User' as text only. - $map = Namespaces::buildNamespaceAliasMap(['App\\Models\\User']); - - expect(Namespaces::toFullyQualifiedClassName('App\\Models\\UserProfile', 'Foo', $map)) - ->toBe('Foo\\App\\Models\\UserProfile'); +test('the current namespace is prefixed even when the name already sits below it', function () { + expect(Namespaces::toFullyQualifiedClassName('Application', 'App', []))->toBe('App\\Application') + ->and(Namespaces::toFullyQualifiedClassName('App\\Models\\User', 'App', [])) + ->toBe('App\\App\\Models\\User') + ->and(Namespaces::toFullyQualifiedClassName('App', 'App', []))->toBe('App\\App'); }); test('build namespace alias map', function () { @@ -78,11 +94,12 @@ '\\App\\Utils\\Arrays' => 'Arr', ]; + // Keys are lowercased: PHP resolves `use` aliases case insensitively. $expectedMap = [ - 'User' => 'App\\Models\\User', - 'Payments' => 'App\\Services\\PaymentService', - 'Strings' => 'App\\Utils\\Strings', - 'Arr' => 'App\\Utils\\Arrays', + 'user' => 'App\\Models\\User', + 'payments' => 'App\\Services\\PaymentService', + 'strings' => 'App\\Utils\\Strings', + 'arr' => 'App\\Utils\\Arrays', ]; expect(Namespaces::buildNamespaceAliasMap($namespaces))->toEqual($expectedMap); diff --git a/tests/Unit/Utils/ReflectionsTest.php b/tests/Unit/Utils/ReflectionsTest.php deleted file mode 100644 index 67e8803..0000000 --- a/tests/Unit/Utils/ReflectionsTest.php +++ /dev/null @@ -1,53 +0,0 @@ -getProperty('name')) - )->toBe('string'); - - expect( - Reflections::getDocBlockExtendedType($reflectionClass->getProperty('age')) - )->toBe('array{amount: string, birthdate: \DateTime}'); - - expect( - Reflections::getDocBlockExtendedType( - $reflectionClass->getConstructor()->getParameters()[2] - ) - )->toBe('object{name: string, other: string}'); - - expect( - Reflections::getReturnType( - $reflectionClass->getMethod('serialize') - ) - )->toBe('array{string, int}'); -}); - -test('get doc block extended type of multiline declarations', function () { - - $reflectionClass = new ReflectionClass(ReflectionsUtilMock::class); - - expect( - Reflections::getDocBlockExtendedType($reflectionClass->getProperty('settings')) - )->toBe('array{ theme: string, notifications: array{ email: bool, }, }'); - - expect( - Reflections::getDocBlockExtendedType( - $reflectionClass->getConstructor()->getParameters()[3] - ) - )->toBe('array{ theme: string, notifications: array{ email: bool, }, }'); - - expect( - Reflections::getReturnType( - $reflectionClass->getMethod('serializeDeeply') - ) - )->toBe('array{ id: non-empty-string, roles: list, }'); -}); From c3932248c3077d19f352372f3eaab4b10d97a045 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 7 Aug 2026 11:46:04 +0200 Subject: [PATCH 072/101] Refactor error handling to introduce `domainErrors`, enhance TypeScript client typing, and improve clarity - Replaced `errorTypeName` with `domainErrorTypeName` to better represent domain-specific error declarations. - Updated TypeScript `Failure` type to distinguish between client and server errors, introducing the `CLIENT_ERROR` category with proper handling for aborted requests. - Enhanced `TypedOperation` to include `domainErrors` for server-side configuration and precise error mapping. - Improved error generation logic in `EmitOperations` and `EmitOperationClientBindings` to align with updated error handling and TypeScript type safety. - Updated test cases to validate the new `Failure` structure, ensuring unmapped error categories are properly excluded. - Adjusted server metadata to include configuration for better error handling in code generation. - Revised documentation to reflect updated error categories, domain error declarations, and client error handling. --- README.md | 34 ++- docs/errors.md | 98 ++++++++- docs/typescript-client.md | 18 +- .../Laravel/Commands/CodeGenCommand.php | 6 +- .../EmitOperationClientBindings.php | 114 ++++++---- src/CodeGen/CodeGenerators/EmitOperations.php | 32 ++- src/CodeGen/CodeGenerators/EmitTypeMap.php | 7 +- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 19 +- src/CodeGen/CodeGenerators/EmitTypes.php | 49 ++++- src/CodeGen/Data/ServerMetadata.php | 16 ++ src/CodeGen/Data/TypedOperation.php | 17 +- src/CodeGen/TypescriptServerCodeGenerator.php | 44 ++-- src/CodeGen/Utils/ErrorTypescript.php | 162 +++++++++++--- src/Server/Server.php | 6 + tests/Feature/ServerTest.php | 8 +- tests/Unit/CodeGen/CodeGeneratorsTest.php | 3 +- .../EmitOperationClientBindingsTest.php | 67 +++++- .../CodeGen/EmitOperationsSpaClientTest.php | 3 +- tests/Unit/CodeGen/EmitQueryKeyTest.php | 7 +- tests/Unit/CodeGen/EmitTanstackQueryTest.php | 11 +- tests/Unit/CodeGen/EmitTypeUtilsTest.php | 19 +- tests/Unit/CodeGen/EmitTypesTest.php | 88 +++++++- tests/Unit/CodeGen/ErrorTypescriptTest.php | 199 +++++++++++++----- tests/Unit/CodeGen/TsOutputFixture.php | 3 +- .../TypescriptServerCodeGeneratorTest.php | 55 ++++- tests/ts-output/generated/accounts.ts | 12 +- tests/ts-output/generated/catalog.ts | 16 +- .../ts-output/generated/lib/DefaultClient.ts | 77 ++++--- .../generated/lib/OperationClient.ts | 8 +- .../generated/lib/OperationException.ts | 27 +-- tests/ts-output/generated/lib/bindings.ts | 2 +- tests/ts-output/generated/lib/type-map.ts | 4 +- tests/ts-output/generated/lib/types.ts | 18 +- tests/ts-output/generated/lib/utils.ts | 19 +- tests/ts-output/generated/shapes.ts | 12 +- tests/ts-output/src/usage.ts | 64 +++++- 36 files changed, 1018 insertions(+), 326 deletions(-) diff --git a/README.md b/README.md index 8c62cf5..00b6e68 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ way it does. Each subsystem has its own reference. |---|---| | [Types](docs/types.md) | The supported PHPStan subset, refinements, utility types, value objects, `#[Castable]`, brands and named types. | | [Operations](docs/operations.md) | The attributes, the handler contract, middleware, `ServerConfiguration`. | -| [Errors](docs/errors.md) | The six categories, exposing a domain error, the generated union, and the exceptions this library throws. | +| [Errors](docs/errors.md) | The six categories, the client error, exposing a domain error, the generated union, and the exceptions this library throws. | | [The server](docs/server.md) | `Server`, operation keys, registries, DI, serving HTTP, preloading, the production cache, extension points. | | [The TypeScript client](docs/typescript-client.md) | What codegen writes, the envelope, the transport, all eight generators, writing your own. | | [Client directives](docs/client-directives.md) | The optional `Client` side channel for toasts, redirects and cache invalidation. | @@ -174,7 +174,7 @@ full wiring, dependency injection and error reporting. ```typescript export type GetResult = {email:(string & Brand<"email">);slug:string;}; export type GetInput = {id:(number & Brand<"customerId">);}; -export type GetError = /* the operation's error union */; +export type GetDomainErrors = /* the names this operation exposed, or never */; export async function get(input: GetInput, options?: OperationOptions) { /* ... */ } ``` @@ -188,7 +188,7 @@ const result = await get({id: userId}); if (result.success) { result.data.email; // (string & Brand<"email">) } else { - result.type; // "INVALID_INPUT" | "NOT_FOUND" | "INTERNAL_ERROR" | ... + result.type; // "INVALID_INPUT" | "NOT_FOUND" | "INTERNAL_ERROR" | "CLIENT_ERROR" | ... } ``` @@ -303,7 +303,7 @@ out of the shipped bundle, and that is all. See [operation keys](docs/server.md# ## Errors -Every failure the client can see is one of six categories: +Every failure the server can produce is one of six categories: | Code | `type` | When | |---|---|---| @@ -318,6 +318,10 @@ The table is in resolution order, and the first match wins. That order is why `D second to last: an exception you have explicitly mapped onto a category stays in that category even when it is named for the client. +A client has one more failure available to it, and no server sends it: `CLIENT_ERROR`, code 0, for +the request that never arrived. It carries the exception that stopped it under `cause` instead of +`details`. + Exposing a domain error takes both a declaration and a name — `#[Throws]` on the operation, and either `as:` on that declaration or `#[ExposeAs]` on the exception class: @@ -331,15 +335,27 @@ public function create(array $input): array { /* ... */ } {"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"type": "invalid-name"}} ``` -Which categories an operation can produce is what the generated union says, and it says nothing else: +Because the catalogue is closed, `Failure` is the union of what your server can produce rather than a +hole for whatever a call site passes. The only thing an operation adds to it is which exceptions it +exposed, so that is the only thing it takes: ```typescript -export type CreateError = - {code: 422, type: "INVALID_INPUT", details: {fields: Record}} - | {code: 404, type: "NOT_FOUND"} - | {code: 500, type: "INTERNAL_ERROR"}; +export type Failure = {success: false, __metadata?: Record} + & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError); ``` +```typescript +export type CreateDomainErrors = never; +export type LockDomainErrors = "account_locked"|"quota_exceeded"; +``` + +That is all an operation module declares about errors — name the envelope as `Failure` +where you need it. `never` is not an absence to handle: `DomainError` erases itself on it, so an +operation that exposes nothing has no 400 branch at all and `result.code === 400` will not compile +against it. Naming the branches also means a consumer can write +`(error: ClientError | InternalError) => boolean` once and reuse it, instead of restating a literal +shape at every call site. + `details` appears only where the category cannot say everything on its own — `INVALID_INPUT` carries `fields`, `DOMAIN_ERROR` carries `type` — and is absent everywhere else, which is exactly what the generated branches declare. diff --git a/docs/errors.md b/docs/errors.md index db796cb..152e90d 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -7,13 +7,14 @@ The short version lives in the [README](../README.md); this is the full picture. - [The six categories](#the-six-categories) - [Exposing a domain error](#exposing-a-domain-error) - [The generated error union](#the-generated-error-union) +- [The client error](#the-client-error) - [When `details` appears](#when-details-appears) - [Your own validation](#your-own-validation) - [Exceptions this library throws](#exceptions-this-library-throws) ## The six categories -Every failure the client can see is one of six: +Every failure the server can produce is one of six: | Code | `type` | When | |---|---|---| @@ -36,6 +37,11 @@ which of *its* exceptions belong in which category, with [`ServerConfiguration::withExceptions()`](operations.md#serverconfiguration) — not which categories exist. +Six is what a *server* can answer. A client has one more failure available to it — the request that +never arrived — and that one is [`CLIENT_ERROR`](#the-client-error), code 0. It is deliberately not +an `ErrorType`: nothing on the server can produce it, and giving `ErrorPresenter` a case for it would +be claiming otherwise. + ## Exposing a domain error **It takes a declaration and a name.** The operation declares that it can throw the exception, and @@ -73,19 +79,88 @@ exception, the operation's name wins. ## The generated error union -Because both the runtime and the code generator read those attributes from the same place, the -generated error union cannot drift from the responses it describes. An operation that declares -nothing gets: +Every branch is declared once, in the generated types file, as a named envelope: + +```typescript +export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}; +export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"}; +export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; +export type NotFoundError = {code: 404, type: "NOT_FOUND"}; +export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}}; +export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; +export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error}; +``` + +Because the catalogue is closed, `Failure` is the *union* of the ones your server can produce rather +than a hole for whatever a call site passes in: + +```typescript +export type Failure = {success: false, __metadata?: Record} + & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError); +export type Result = Success | Failure; +``` + +The 401 and 403 branches appear in it only once you have actually mapped exceptions onto them, so the +union describes what *this* server can really produce. What varies per operation is one thing only — +the names it exposed — so that is the one thing `Failure` is parameterised on, and the one thing an +operation module declares. An operation that declares nothing gets: + +```typescript +export type CreateDomainErrors = never; +``` + +and one that exposes two exceptions gets: + +```typescript +export type LockDomainErrors = "account_locked"|"quota_exceeded"; +``` + +There is no generated `Error` alias: `Failure` already says it, and a second +name for it would be one more place the same fact is written down. + +`never` is not an absence a consumer has to handle. `DomainError` erases itself on it, which is why +its declaration is a conditional: an operation exposing nothing has no 400 branch at all, and + +```typescript +const result = await create(input); +if (!result.success && result.code === 400) { /* ... */ } +// ~~~~~~~~~~~~~~~~~~~~ no overlap — this does not compile +``` + +The brackets in `[TType] extends [never]` stop the conditional distributing, so two exposed names +stay one branch carrying a union under `details.type` rather than splitting into two. + +Because both the runtime and the code generator read the same attributes from the same place, none of +this can drift from the responses it describes. Naming the branches is also what lets a consumer +write one handler and reuse it, instead of restating a literal shape at every call site: + +```typescript +function isWorthRetrying(error: ClientError | InternalError): boolean { /* ... */ } +``` + +## The client error + +**`CLIENT_ERROR` is the branch no server sends.** The request never got there — the network was +down, the response was not JSON, the call was cancelled — so there is no envelope to report and the +client mints one, with code 0 and the exception itself under `cause`: ```typescript -export type CreateError = - {code: 422, type: "INVALID_INPUT", details: {fields: Record}} - | {code: 404, type: "NOT_FOUND"} - | {code: 500, type: "INTERNAL_ERROR"}; +const result = await lock({id}); +if (!result.success && result.code === 0) { + console.error(result.cause.message); // cause: Error +} ``` -The 401 and 403 branches appear only once you have actually mapped exceptions onto them, so the -union describes what this server can really produce. +It is carried rather than summarised, which matters for cancellation: `throwOnFailure` rethrows an +`AbortError` as the `DOMException` it was, so a Tanstack refetch aborting its predecessor is not +reported as a failed query. A re-wrapped copy would no longer be that exception. + +Every client can produce it, and no signature has to say so: the branch is part of every `Failure`, +so `OperationClient.execute` returning `Promise>` already includes it whatever +the operation exposed. There is nothing for an implementation to remember to add. + +Reached through `OperationException`, the envelope is `e.cause` and the original exception is +`e.cause.cause`; `e.isClientError` is the shorter way to ask. ## When `details` appears @@ -95,6 +170,9 @@ domain error it is. For the other four, `code` and `type` are the whole answer a under `details` would put the same string on the wire twice, so the key is absent — and the generated branch has no such property, so narrowing on `type` will not offer you one. +`CLIENT_ERROR` has no `details` either. What it carries instead is `cause`, and that is a live +`Error` rather than anything that came off the wire — the one branch whose payload was never JSON. + This is why `jsonSerialize()` is the only thing that gets the envelope exactly right: it omits the key rather than sending `null`, which is what the generated union declares. diff --git a/docs/typescript-client.md b/docs/typescript-client.md index 33841d5..a3c49d2 100644 --- a/docs/typescript-client.md +++ b/docs/typescript-client.md @@ -45,8 +45,9 @@ Every call resolves to: ```typescript export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} -export type Failure = {success: false, __metadata?: Record} & E; -export type Result = Success | Failure; +export type Failure = {success: false, __metadata?: Record} + & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError); +export type Result = Success | Failure; ``` That is the whole envelope, and both extra keys are the library's own — `jsonSerialize()` writes @@ -60,8 +61,13 @@ belongs to whichever [`Client`](client-directives.md) produced it, and a failure directives at all. Naming it without describing it is the honest half: you know to look there, and the guard that shipped with your client is what makes it typed. +The failure branch is the union of what *this* server's [error catalogue](errors.md#the-generated-error-union) +holds — which categories are in it depends on how you mapped your exceptions — and the only thing an +operation adds is which domain errors it exposed. + Alongside them, each operation gets its own three types — `Input`, `Result` and -`Error` — and `Error` is [the union of what that operation can really produce](errors.md#the-generated-error-union). +`DomainErrors`, the names that operation exposed or `never`. Where you need the failure branch +named, it is `Failure<DomainErrors>`. ## Wiring up the transport @@ -97,8 +103,8 @@ transport serves. `{fqn}` is where the operation key goes, and both are required `throwOnFailure(result)`, from `lib/utils.ts`, narrows a `Result` to its success branch and throws an `OperationException` otherwise, for call sites that would rather not branch. A `catch` variable is -`unknown` in TypeScript whatever was thrown, so name the operation's error union at the guard to get -it back: +`unknown` in TypeScript whatever was thrown, so name what the operation exposed at the guard to get +it back — the rest of the catalogue is the server's and needs no naming: ```typescript try { @@ -106,7 +112,7 @@ try { throwOnFailure(result); return result.data; } catch (e) { - if (OperationException.is(e)) { + if (OperationException.is(e)) { e.cause.type; // "INVALID_INPUT" | "NOT_FOUND" | ... e.code; // the HTTP code, 500 if the payload had none } diff --git a/src/Adapters/Laravel/Commands/CodeGenCommand.php b/src/Adapters/Laravel/Commands/CodeGenCommand.php index f87e7d6..def3225 100644 --- a/src/Adapters/Laravel/Commands/CodeGenCommand.php +++ b/src/Adapters/Laravel/Commands/CodeGenCommand.php @@ -92,7 +92,11 @@ public function handle( } try { - $metadata = new ServerMetadata($queryRoute->uri(), $commandRoute->uri()); + $metadata = new ServerMetadata( + $queryRoute->uri(), + $commandRoute->uri(), + $server->configuration + ); $codeGenerator = new TypescriptServerCodeGenerator( $this->getGeneratorsFromInput($application), diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index a39dfcc..6f25e20 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -117,20 +117,29 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi * Moves a request and resolves to the envelope. A server may put more next to the data, and it * travels through untouched — describing it here would tie every transport to one Client * implementation's schema. Reach for the guard the implementation ships instead. + * + * The only thing an operation adds to the error catalogue is which domain errors it exposed, so that + * is all this takes. ClientError needs no mention: a request can fail before it reaches the server, + * and Failure carries that branch whatever the operation declared. */ export interface OperationClient { - execute( + execute( type: "command"|"query", key: string, input: unknown, options?: OperationOptions - ): Promise>; + ): Promise>; } TypeScript, [ $this->types->importFromTypes(types: ['Result']), ]), self::DEFAULT_CLIENT_FILE => new TypescriptFile(<<<'TypeScript' -export type Hook = (result: Result) => Promise | void; +/** + * A hook sees the envelope of any operation, so it is typed against the widest domain union rather + * than any one operation's. Every category is still there to discriminate on — the catalogue is the + * server's, not the operation's. + */ +export type Hook = (result: Result) => Promise | void; export class DefaultClient implements OperationClient { @@ -167,7 +176,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi }).join('&'); } - private async callHooks>(result: T) { + private async callHooks>(result: T) { try { await Promise.all(this.hooks.map(hook => hook(result))); return result; @@ -177,7 +186,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } } - async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { + async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{fqn}', key)}`; @@ -198,34 +207,46 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi headers['Content-Type'] = 'application/json'; } - const queryParams = type === 'query' && input && typeof input === 'object' - ? `?${this.createJsonEncodedQueryParams(input)}` - : ''; - - const response = await this.fetcher(`${fullPath}${queryParams}`, { - method: type === 'query' ? 'GET' : 'POST', - signal, - headers, - body: type === 'command' ? JSON.stringify(input) : undefined, - }); - - const json = await response.json(); - if (!json || typeof json !== 'object') { - throw new Error('Invalid response body. Could not parse json correctly.'); - } - - // Spread first: whatever the server put next to the envelope — a client's directives, say — - // rides along untyped rather than being dropped by a transport that never knew about it. - if (response.ok) { - return await this.callHooks({...json, success: true} as Success); + try { + const queryParams = type === 'query' && input && typeof input === 'object' + ? `?${this.createJsonEncodedQueryParams(input)}` + : ''; + + const response = await this.fetcher(`${fullPath}${queryParams}`, { + method: type === 'query' ? 'GET' : 'POST', + signal, + headers, + body: type === 'command' ? JSON.stringify(input) : undefined, + }); + + const json = await response.json(); + if (!json || typeof json !== 'object') { + throw new Error('Invalid response body. Could not parse json correctly.'); + } + + // Spread first: whatever the server put next to the envelope — a client's directives, say — + // rides along untyped rather than being dropped by a transport that never knew about it. + if (response.ok) { + return await this.callHooks({...json, success: true} as Success); + } + + return await this.callHooks({ + ...json, + success: false, + code: json?.code ?? response.status, + type: json?.type ?? 'INTERNAL_ERROR' + } as Failure); + } catch (e: unknown) { + // Anything thrown between here and the response being read: the request never completed, + // so there is no server error to report and the cause is the answer. It is carried as + // itself rather than summarised — throwOnFailure rethrows an AbortError exactly, and a + // re-wrapped copy would no longer be that DOMException. + // + // No type argument: this branch is in every Failure, whatever the operation exposed. + const cause = e instanceof Error ? e : new Error(String(e)); + const envelop = {success: false, code: 0, type: 'CLIENT_ERROR', cause} satisfies Failure; + return await this.callHooks(envelop); } - - return await this.callHooks({ - ...json, - success: false, - code: json?.code ?? response.status, - type: json?.type ?? 'INTERNAL_ERROR' - } as Failure); } registerHook(hook: Hook): () => void { @@ -242,27 +263,30 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi ]), self::OPERATION_EXCEPTION_FILE => new TypescriptFile(<<<'TypeScript' /** - * Generic over the operation's error union, so `e.cause.type` narrows to the branches the - * operation can actually produce rather than to any. + * Generic over the names the operation exposed, so `e.cause.details.type` narrows to those rather + * than to any string. The rest of the catalogue is the server's and needs no naming here. */ -export class OperationException extends Error { - public readonly cause: Failure; +export class OperationException extends Error { + public readonly cause: Failure; - get code(): number { - const code = this.cause.code; - if (!code || typeof code !== 'number' || Number.isNaN(code)) { - return 500; - } + /** + * The request never reached the server, so nothing here came off the wire and `cause.cause` + * holds the exception that actually stopped it. + */ + get isClientError(): boolean { + return this.cause.code === 0; + } - return code; + get code(): number { + return this.cause.code; } - constructor(cause: Failure) { + constructor(cause: Failure) { super(`Operation failed with code ${cause.code}`); this.cause = cause; } - public static is(e: unknown): e is OperationException { + public static is(e: unknown): e is OperationException { return e instanceof OperationException; } } @@ -287,7 +311,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi client = operationClient; } -export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise> { +export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise> { if (options?.client) { return await options.client.execute(type, key, input, options); } diff --git a/src/CodeGen/CodeGenerators/EmitOperations.php b/src/CodeGen/CodeGenerators/EmitOperations.php index da57fc8..8c40628 100644 --- a/src/CodeGen/CodeGenerators/EmitOperations.php +++ b/src/CodeGen/CodeGenerators/EmitOperations.php @@ -115,23 +115,33 @@ public function resultTypeName(TypedOperation $operation): string return $this->baseTypeName($operation).'Result'; } - public function errorTypeName(TypedOperation $operation): string + /** + * The names the operation exposed, and the whole of what it contributes to its own error type - + * the rest of the catalogue is the server's, and the types file already declares it as Failure. + * A consumer that wants the envelope named writes Failure, so emitting that + * alias here as well would only be a second name for a type spelled out of one word. + */ + public function domainErrorTypeName(TypedOperation $operation): string { - return $this->baseTypeName($operation).'Error'; + return $this->baseTypeName($operation).'DomainErrors'; } /** * The types below reference named types by their alias, which lives in the generated types - * file: every alias the operation's registries carry is imported. Brand is imported - * unconditionally — inline brands reference it, yet it is never a registry key — and a linter - * drops it where unused. + * file: every alias the operation's registries carry is imported. Nothing from the error + * catalogue is among them - a module names none of it. Brand is imported unconditionally — + * inline brands reference it, yet it is never a registry key — and a linter drops it where + * unused. * * @return list */ private function aliasImports(TypedOperation $operation): array { return [ - $this->types->importFromTypes(types: ['Brand', ...$operation->usedAliases()]), + $this->types->importFromTypes(types: [ + 'Brand', + ...$operation->usedAliases(), + ]), ]; } @@ -142,7 +152,7 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata $name = $this->operationName($operation); $resultTypeName = $this->resultTypeName($operation); $resultInputTypeName = $this->inputTypeName($operation); - $errorTypeName = $this->errorTypeName($operation); + $domainErrorTypeName = $this->domainErrorTypeName($operation); $imports = [ $this->bindings->importFromBindings(values: ['executeOperation']), @@ -163,11 +173,11 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata <<outputDef->type}; export type {$resultInputTypeName} = null; -export type {$errorTypeName} = {$operation->errorDef->type}; +export type {$domainErrorTypeName} = {$operation->domainErrors}; {$docBlock} export async function {$name}(options?: OperationOptions) { - return await executeOperation<{$resultInputTypeName}, {$resultTypeName}, {$errorTypeName}>( + return await executeOperation<{$resultInputTypeName}, {$resultTypeName}, {$domainErrorTypeName}>( '{$definition->type->lowerCase()}', '{$operation->key}', null, @@ -183,11 +193,11 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata <<outputDef->type}; export type {$resultInputTypeName} = {$operation->inputDef->type}; -export type {$errorTypeName} = {$operation->errorDef->type}; +export type {$domainErrorTypeName} = {$operation->domainErrors}; {$docBlock} export async function {$name}(input: {$resultInputTypeName}, options?: OperationOptions) { - return await executeOperation<{$resultInputTypeName}, {$resultTypeName}, {$errorTypeName}>( + return await executeOperation<{$resultInputTypeName}, {$resultTypeName}, {$domainErrorTypeName}>( '{$definition->type->lowerCase()}', '{$operation->key}', input, diff --git a/src/CodeGen/CodeGenerators/EmitTypeMap.php b/src/CodeGen/CodeGenerators/EmitTypeMap.php index 7dd30fb..4e1b755 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeMap.php +++ b/src/CodeGen/CodeGenerators/EmitTypeMap.php @@ -32,7 +32,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi $carry[$operation->definition->type->lowerCase()][$operation->definition->fullyQualifiedName()] = [ 'input' => $operation->inputDef->type, 'output' => $operation->outputDef->type, - 'errors' => $operation->errorDef->type, + 'errors' => "Failure<{$operation->domainErrors}>", ]; return $carry; @@ -49,7 +49,8 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi // Written next to the types file rather than standing on its own: the map inlines the // aliases EmitTypes declares, and they only resolve while it sits next to them. Brand is // imported unconditionally — an inlined brand references it, yet it is never a registry - // key — and a linter drops it where unused. + // key — and a linter drops it where unused. So is Failure, which every operation's error + // entry is written in terms of. return [ 'type-map' => new TypescriptFile( <<emitTypes->importFromTypes(types: ['Brand', ...$registry->usedAliases()]), + $this->emitTypes->importFromTypes(types: ['Brand', 'Failure', ...$registry->usedAliases()]), ] ), ]; diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index 9778772..4a1c9be 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -100,14 +100,21 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather * catch than branch. * - * The error union is deliberately not inferred here: a catch clause variable is `unknown` in - * TypeScript whatever was thrown, so no signature on this function could carry E to the catch. - * Name it there instead - `OperationException.is(e)` types `e.cause` for you. + * The exposed names are deliberately not inferred here: a catch clause variable is `unknown` in + * TypeScript whatever was thrown, so no signature on this function could carry them to the catch. + * Name them there instead - `OperationException.is(e)` types `e.cause` for you. */ -export function throwOnFailure(result: Result): asserts result is Success { - if (!result.success) { - throw new OperationException(result); +export function throwOnFailure(result: Result): asserts result is Success { + if (result.success) { + return; } + + // Client errors are thrown as-is. + if (result.type === "CLIENT_ERROR") { + throw result.cause; + } + + throw new OperationException(result); } TypeScript, [ $this->types->importFromTypes(types: ['Result', 'Success']), diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index b6b842e..89034d8 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; +use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; @@ -21,14 +22,23 @@ /** * Declarations this file always contains. An alias claiming one of these names would generate * a second, conflicting declaration right next to them. + * + * The error envelopes are asked of the catalogue rather than copied out of it: a branch added + * there and forgotten here is a user alias free to shadow it. + * + * @return list */ - private const array RESERVED_ALIASES = [ - 'Brand', - 'Success', - 'Failure', - 'Result', - 'OperationNamespaces', - ]; + private static function reservedAliases(): array + { + return [ + 'Brand', + 'Success', + 'Failure', + 'Result', + 'OperationNamespaces', + ...ErrorTypescript::envelopeNames(), + ]; + } /** * Every declaration above lives in this file, so importing one is asking here for it. Not @@ -53,8 +63,9 @@ public function importFromTypes(array $values = [], array $types = []): Typescri #[Override] public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { + $reserved = self::reservedAliases(); foreach ($registry->usedAliases() as $alias) { - if (in_array($alias, self::RESERVED_ALIASES, true)) { + if (in_array($alias, $reserved, true)) { throw UnsupportedTypeException::reservedAlias($alias); } } @@ -68,6 +79,16 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } } + // Declared here and referenced everywhere else: Failure names these rather than restating + // their shapes, and only this file resolves the names. + $errorEnvelopes = ErrorTypescript::envelopeDeclarations(); + + // Which of them Failure is a union of depends on how this server maps exceptions onto them, + // which is why it is emitted per run rather than written out here. + $failureUnion = ErrorTypescript::failureUnion($metadata->configuration); + $domainTypeParameter = ErrorTypescript::DOMAIN_TYPE_PARAMETER; + $noDomainTypes = ErrorTypescript::NO_DOMAIN_TYPES; + // The shared registry holds every alias any pass produced; the types file declares them // all, so every operation file can import any key of its own definitions' registries. $aliasTypeString = implode("\n", Arrays::mapWithKeys( @@ -79,9 +100,17 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi self::TYPES_FILE => new TypescriptFile(<<generateNamespaceUnion($uniqueNamespaces)}; +/* + * The finite error catalogue. Every failure is one of these, which is why Failure below is their + * union rather than a hole for one. DomainError is the only branch whose payload varies per + * operation - the names that operation exposed - and the only one declared conditionally: on + * `{$noDomainTypes}` it collapses, so an operation exposing nothing has no 400 branch to narrow to. + */ +{$errorEnvelopes} + export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} -export type Failure = {success: false, __metadata?: Record} & E; -export type Result = Success | Failure; +export type Failure<{$domainTypeParameter} extends string = {$noDomainTypes}> = {success: false, __metadata?: Record} & ({$failureUnion}); +export type Result = Success | Failure<{$domainTypeParameter}>; declare const __brand: unique symbol; export type Brand = {readonly [__brand]: TBrand;}; diff --git a/src/CodeGen/Data/ServerMetadata.php b/src/CodeGen/Data/ServerMetadata.php index 6d89f48..72ea374 100644 --- a/src/CodeGen/Data/ServerMetadata.php +++ b/src/CodeGen/Data/ServerMetadata.php @@ -5,12 +5,23 @@ namespace Le0daniel\PhpTsBindings\CodeGen\Data; use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; +use NoDiscard; final readonly class ServerMetadata { + /** + * @param ServerConfiguration $configuration Which categories the generated Failure is a union + * of depends on how this server maps exceptions, so + * the generators that emit it need to see it. The + * run fills it in from the Server; the default is + * the unconfigured catalogue, which is what a + * generator invoked on its own would produce anyway. + */ public function __construct( public string $queryUrl, public string $commandUrl, + public ServerConfiguration $configuration, ) { if (! str_contains($this->queryUrl, '{fqn}')) { throw new CodeGenException('Query URL must contain {fqn} placeholder'); @@ -20,4 +31,9 @@ public function __construct( } } + #[NoDiscard] + public function withConfiguration(ServerConfiguration $configuration): self + { + return new self($this->queryUrl, $this->commandUrl, $configuration); + } } diff --git a/src/CodeGen/Data/TypedOperation.php b/src/CodeGen/Data/TypedOperation.php index 561b15f..29e10d4 100644 --- a/src/CodeGen/Data/TypedOperation.php +++ b/src/CodeGen/Data/TypedOperation.php @@ -27,19 +27,27 @@ final class TypedOperation } /** - * Each definition carries its own registry with every alias it relies on: what the operation's - * file imports, and what the generated types file declares (via the run's shared registry). + * The two schema derived definitions each carry their own registry with every alias they rely + * on: what the operation's file imports, and what the generated types file declares (via the + * run's shared registry). + * + * @param string $domainErrors The TypeScript union of the names this operation exposed, e.g. + * `"account_locked"|"quota_exceeded"`, or `never` where it exposed + * nothing. Everything else about its error type is the server's, + * and lives in the Failure the types file declares. */ public function __construct( public readonly Typescript $inputDef, public readonly Typescript $outputDef, - public readonly Typescript $errorDef, + public readonly string $domainErrors, public readonly Operation $operation, ) { } /** - * The aliases the operation's own file references, ready to import. + * The aliases the operation's own file references, ready to import. Failure is not among them - + * it is a declaration the types file always contains, not a registry entry - and every generated + * module imports it unconditionally. * * @return list sorted */ @@ -48,7 +56,6 @@ public function usedAliases(): array $aliases = array_values(array_unique([ ...$this->inputDef->registry->usedAliases(), ...$this->outputDef->registry->usedAliases(), - ...$this->errorDef->registry->usedAliases(), ])); sort($aliases); diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index 285cd5a..e2b4c92 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -19,7 +19,6 @@ use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; -use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; @@ -32,12 +31,12 @@ private const string VALID_MODULE_NAME = '/^[a-zA-Z0-9_\-]+$/'; /** - * @param array $generators + * @param array $generators * * @throws InvalidGeneratorDependencies */ public function __construct( - private array $generators, + private array $generators, private TypescriptGenerator $typescriptGenerator = new TypescriptGenerator(), ) { $this->resolveGeneratorDependencies(); @@ -61,13 +60,13 @@ private function resolveGeneratorDependencies(): void } foreach ($this->generators as $generator) { - if (! $generator instanceof DependsOn) { + if (!$generator instanceof DependsOn) { continue; } foreach ($generator->dependsOnGenerator() as $className) { - if (! array_key_exists($className, $instances)) { - $issues[] = 'Generator '.$generator::class." depends on {$className} which is not registered."; + if (!array_key_exists($className, $instances)) { + $issues[] = 'Generator ' . $generator::class . " depends on {$className} which is not registered."; } } } @@ -77,20 +76,20 @@ private function resolveGeneratorDependencies(): void } foreach ($this->generators as $generator) { - if (! $generator instanceof DependsOn) { + if (!$generator instanceof DependsOn) { continue; } // Each generator sees what it declared and nothing else, so a dependency it never asked // for cannot quietly become one it relies on. - $generator->setDependencies( - array_intersect_key($instances, array_flip($generator->dependsOnGenerator())), - ); + array_flip($generator->dependsOnGenerator()) + |> (static fn ($x) => array_intersect_key($instances, $x)) + |> $generator->setDependencies(...); } } /** - * @param list $ignore + * @param list $ignore * @return array */ public function generate(Server $server, ServerMetadata $metadata, array $ignore = []): array @@ -102,8 +101,8 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore */ $filteredDefinitions = array_filter( $server->registry->all(), - fn (Operation $operation): bool => ! in_array($operation->definition->namespace, $ignore, true) - && ! in_array($operation->definition->fullyQualifiedName(), $ignore, true), + fn (Operation $operation): bool => !in_array($operation->definition->namespace, $ignore, true) + && !in_array($operation->definition->fullyQualifiedName(), $ignore, true), ) |> array_values(...); // Cross-operation and cross-direction alias conflicts are only caught when every pass hands @@ -123,8 +122,7 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore return new TypedOperation( inputDef: $this->typescriptGenerator->toTypescript($inputNode, IO::INPUT, $registry), outputDef: $this->typescriptGenerator->toTypescript($outputNode, IO::OUTPUT, $registry), - errorDef: ErrorTypescript::forOperation($server->configuration, $operation->definition) - |> Typescript::fromRawString(...), + domainErrors: ErrorTypescript::domainTypesFor($server->configuration, $operation->definition), operation: $operation, ); }, $filteredDefinitions); @@ -153,8 +151,8 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore } /** - * @param list $definitions - * @param AliasRegistry $registry The run's shared registry, holding every alias any pass produced. + * @param list $definitions + * @param AliasRegistry $registry The run's shared registry, holding every alias any pass produced. * @return array */ private function generateLibFiles(array $definitions, ServerMetadata $metadata, AliasRegistry $registry): array @@ -162,11 +160,11 @@ private function generateLibFiles(array $definitions, ServerMetadata $metadata, return array_reduce( $this->generators, /** - * @param array $carry + * @param array $carry * @return array */ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry): array { - if (! $codeGenerator instanceof GeneratesLibFiles) { + if (!$codeGenerator instanceof GeneratesLibFiles) { return $carry; } @@ -193,7 +191,7 @@ function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry) } /** - * @param list $definitions + * @param list $definitions * @return array */ private function generateOperationDefinitions(array $definitions, ServerMetadata $metadata): array @@ -209,8 +207,8 @@ private function generateOperationDefinitions(array $definitions, ServerMetadata if (preg_match(self::VALID_MODULE_NAME, $namespace) !== 1) { throw new CodeGenException( "Invalid namespace '{$namespace}' on " - ."{$operationData->definition->fullyQualifiedClassName}::{$operationData->definition->methodName}. " - .'A namespace becomes a module file name and must only contain a-z, A-Z, 0-9, - and _.' + . "{$operationData->definition->fullyQualifiedClassName}::{$operationData->definition->methodName}. " + . 'A namespace becomes a module file name and must only contain a-z, A-Z, 0-9, - and _.' ); } @@ -221,7 +219,7 @@ private function generateOperationDefinitions(array $definitions, ServerMetadata $file = $operationFiles[$fileKey] ?? new TypescriptFile(); foreach ($this->generators as $codeGenerator) { - if (! $codeGenerator instanceof GeneratesOperationCode) { + if (!$codeGenerator instanceof GeneratesOperationCode) { continue; } diff --git a/src/CodeGen/Utils/ErrorTypescript.php b/src/CodeGen/Utils/ErrorTypescript.php index d6c4196..cb767e8 100644 --- a/src/CodeGen/Utils/ErrorTypescript.php +++ b/src/CodeGen/Utils/ErrorTypescript.php @@ -8,77 +8,169 @@ use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\Errors\ExposedExceptions; +use Le0daniel\PhpTsBindings\Utils\Arrays; use ReflectionException; /** * The TypeScript face of the server's finite error catalogue. * + * Each branch is declared once, as a named envelope in the generated types file, and Failure is the + * union of the ones this server can produce. The shapes therefore live here rather than at every use + * site, and a consumer can name a branch — `NotFoundError` — instead of restating its literal. + * + * That the catalogue is closed is what lets Failure be that union rather than take one: the only + * thing varying per operation is which exceptions it exposed, so the names of those are the only + * thing Failure is parameterised on. + * * The runtime counterpart is Server\Errors\ErrorPresenter, and the branches below appear in its - * resolution order. Only reachable branches are emitted: the two auth categories exist solely + * resolution order. Only reachable branches are unioned: the two auth categories exist solely * because exceptions were mapped onto them, and a domain error only exists where an operation * declares an exception via #[Throws] that resolves to a name - its own `as`, or #[ExposeAs] on the * exception class. Everything else the server produces on its own. */ final readonly class ErrorTypescript { - private const string INVALID_INPUT_DETAILS = '{fields: Record}'; + /** + * The one branch no server sends: the request never got there, so a client hands this back + * instead. It has no ErrorType case for the same reason, and it is the only envelope whose + * payload is a live object rather than something that came off the wire. + */ + private const string CLIENT_ENVELOPE = 'ClientError'; /** - * @throws ReflectionException + * What Failure names its type parameter. The union below is written in terms of it, so the + * declaration and the branch that carries it cannot disagree about the name. + */ + public const string DOMAIN_TYPE_PARAMETER = 'TDomainType'; + + /** + * What an operation exposing nothing instantiates the domain branch with. DomainError erases + * itself on it, so such an operation's Failure has no 400 branch at all. + */ + public const string NO_DOMAIN_TYPES = 'never'; + + /** + * Envelope name => [type parameters, declaration], in ErrorPresenter resolution order. + * + * The domain branch is the only one whose payload depends on the operation - the names of the + * exceptions it exposed - so it is the only one that takes a type argument. Every other category + * says the same thing for every operation on the server. + * + * Its conditional is what makes `never` mean the branch is gone rather than a 400 whose name is + * uninhabited. The wrapping brackets keep it from distributing, so two exposed names stay one + * member with a union under `details.type` instead of becoming two members. + * + * @var array + */ + private const array ENVELOPES = [ + 'InvalidInputError' => ['', '{code: 422, type: "INVALID_INPUT", details: {fields: Record}}'], + 'AuthenticationError' => ['', '{code: 401, type: "AUTHENTICATION_ERROR"}'], + 'AuthorizationError' => ['', '{code: 403, type: "AUTHORIZATION_ERROR"}'], + 'NotFoundError' => ['', '{code: 404, type: "NOT_FOUND"}'], + 'DomainError' => ['', '[TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}}'], + 'InternalError' => ['', '{code: 500, type: "INTERNAL_ERROR"}'], + self::CLIENT_ENVELOPE => ['', '{code: 0, type: "CLIENT_ERROR", cause: Error}'], + ]; + + /** + * Every name this catalogue occupies in the types file. What EmitTypes reserves, so a #[Named] + * type claiming one of them fails instead of generating a second, conflicting declaration. + * + * @return list */ - public static function forOperation(ServerConfiguration $configuration, Definition $definition): string + public static function envelopeNames(): array { - $branches = [ - self::branch(ErrorType::INVALID_INPUT, self::INVALID_INPUT_DETAILS), - ]; + return array_keys(self::ENVELOPES); + } + + /** + * The declarations, for the file that holds them. Nothing else may restate a shape: a second + * copy is free to drift from the one operations are typed against. + * + * All of them are declared, including the ones this server cannot reach. They are names a + * consumer writes a handler against, and reserving a name it might not use costs nothing - + * whereas a union naming an unreachable branch would claim the server can produce it. + */ + public static function envelopeDeclarations(): string + { + return implode("\n", Arrays::mapWithKeys( + self::ENVELOPES, + /** @param array{string, string} $envelope */ + static fn (string $name, array $envelope): string => "export type {$name}{$envelope[0]} = {$envelope[1]};", + )); + } + + /** + * What Failure is, for this server: every category it can produce, in resolution order, closed + * by the branch a client mints when the request never got there. + * + * The domain branch is unconditional here, unlike the two auth ones. It carries the type + * parameter, and an operation exposing nothing instantiates that with `never` - which erases the + * branch already, so gating it a second time would only mean saying `never` twice. + */ + public static function failureUnion(ServerConfiguration $configuration): string + { + /** @var list $categories */ + $categories = [ErrorType::INVALID_INPUT]; if (count($configuration->unauthenticatedExceptions) !== 0) { - $branches[] = self::branch(ErrorType::AUTHENTICATION_ERROR); + $categories[] = ErrorType::AUTHENTICATION_ERROR; } if (count($configuration->unauthorizedExceptions) !== 0) { - $branches[] = self::branch(ErrorType::AUTHORIZATION_ERROR); + $categories[] = ErrorType::AUTHORIZATION_ERROR; } - $branches[] = self::branch(ErrorType::NOT_FOUND); + $categories[] = ErrorType::NOT_FOUND; + $categories[] = ErrorType::DOMAIN_ERROR; + $categories[] = ErrorType::INTERNAL_ERROR; - if ($domainDetails = self::domainDetails($configuration, $definition)) { - $branches[] = self::branch(ErrorType::DOMAIN_ERROR, $domainDetails); - } + $references = array_map( + static fn (ErrorType $type): string => $type === ErrorType::DOMAIN_ERROR + ? self::envelopeFor($type).'<'.self::DOMAIN_TYPE_PARAMETER.'>' + : self::envelopeFor($type), + $categories, + ); - $branches[] = self::branch(ErrorType::INTERNAL_ERROR); + // Closed here rather than by a caller: what a client can hand back belongs to the same union + // as what the server can, so the union has one owner and one set of tests. + $references[] = self::CLIENT_ENVELOPE; - return implode('|', $branches); + return implode('|', $references); } /** - * @throws ReflectionException + * Exhaustive on purpose: a category added to ErrorType without an envelope to carry it fails + * here rather than generating a union that quietly cannot describe it. */ - private static function domainDetails(ServerConfiguration $configuration, Definition $definition): ?string + private static function envelopeFor(ErrorType $type): string { - $exposedTypes = ExposedExceptions::exposedTypesFor($definition, $configuration); - if (count($exposedTypes) === 0) { - return null; - } - - return implode('|', array_map(static function (string $exposedType): string { - $type = json_encode($exposedType, JSON_THROW_ON_ERROR); - - return "{type: {$type}}"; - }, $exposedTypes)); + return match ($type) { + ErrorType::INVALID_INPUT => 'InvalidInputError', + ErrorType::AUTHENTICATION_ERROR => 'AuthenticationError', + ErrorType::AUTHORIZATION_ERROR => 'AuthorizationError', + ErrorType::NOT_FOUND => 'NotFoundError', + ErrorType::DOMAIN_ERROR => 'DomainError', + ErrorType::INTERNAL_ERROR => 'InternalError', + }; } /** - * No `details` at all where the category is the whole answer: the server omits the key rather - * than restate the type under it, and the branch has to say so or narrowing on `type` would - * hand back a property that is never on the wire. + * The literal union one operation instantiates the domain branch with, or `never` where it + * exposes nothing and the branch is unreachable. + * + * @throws ReflectionException */ - private static function branch(ErrorType $type, ?string $details = null): string + public static function domainTypesFor(ServerConfiguration $configuration, Definition $definition): string { - $name = json_encode($type->name, JSON_THROW_ON_ERROR); + $exposedTypes = ExposedExceptions::exposedTypesFor($definition, $configuration); + if (count($exposedTypes) === 0) { + return self::NO_DOMAIN_TYPES; + } - return $details === null - ? "{code: {$type->value}, type: {$name}}" - : "{code: {$type->value}, type: {$name}, details: {$details}}"; + return implode('|', array_map( + static fn (string $exposedType): string => json_encode($exposedType, JSON_THROW_ON_ERROR), + $exposedTypes, + )); } } diff --git a/src/Server/Server.php b/src/Server/Server.php index 3598625..0f9474f 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -4,6 +4,7 @@ namespace Le0daniel\PhpTsBindings\Server; +use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Contracts\ServerAdapter; @@ -46,6 +47,11 @@ public function __construct( $this->errorPresenter = new ErrorPresenter($configuration); } + public function toMetadata(string $queryRoute, string $commandRoute): ServerMetadata + { + return new ServerMetadata($queryRoute, $commandRoute, $this->configuration); + } + public function query(string $name, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { if (! $this->registry->has(OperationType::QUERY, $name)) { diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 31eadec..6e8c4bb 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -112,9 +112,9 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError ); $operation = $server->registry->get(OperationType::COMMAND, 'test.run'); - $union = ErrorTypescript::forOperation($server->configuration, $operation->definition); + $domainErrors = ErrorTypescript::domainTypesFor($server->configuration, $operation->definition); - expect($union)->toContain('{code: 400, type: "DOMAIN_ERROR", details: {type: "invalid_name"}}'); + expect($domainErrors)->toBe('"invalid_name"'); }); /** * The cached registry pools every operation's schemas together, so these cases only mean anything @@ -174,12 +174,12 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError ->and($error->type)->toBe(ErrorType::DOMAIN_ERROR) ->and($error->details)->toEqual(['type' => 'global_middleware_failed']); - $errorUnion = ErrorTypescript::forOperation( + $domainErrors = ErrorTypescript::domainTypesFor( $configuration, $registry->get(OperationType::COMMAND, 'test.run')->definition, ); - expect($errorUnion)->toContain('"global_middleware_failed"'); + expect($domainErrors)->toContain('"global_middleware_failed"'); }); test('an operation level declaration still wins over a global one for the same exception', function () { diff --git a/tests/Unit/CodeGen/CodeGeneratorsTest.php b/tests/Unit/CodeGen/CodeGeneratorsTest.php index df84d73..5e1e6da 100644 --- a/tests/Unit/CodeGen/CodeGeneratorsTest.php +++ b/tests/Unit/CodeGen/CodeGeneratorsTest.php @@ -16,6 +16,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Server; @@ -44,7 +45,7 @@ function usersModuleFor(string|\Closure $naming): string $files = new TypescriptServerCodeGenerator( CodeGenerators::fromDefaults($naming), - )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); + )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration())); return $files['users.ts']->toString(); } diff --git a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php index b7a3879..7d2315e 100644 --- a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php +++ b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php @@ -7,6 +7,7 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; @@ -26,7 +27,7 @@ function bindingFiles(): array return $emitter->emitFiles( [], - new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), new AliasRegistry(), ); } @@ -63,6 +64,70 @@ function bindingFiles(): array ]], ]); +/** + * A request can fail before it ever reaches the server, so the transport can always hand back the + * client envelope — and it needs no mention in any of these signatures, because the branch is part of + * every Failure whatever the operation exposed. What the caller chooses is only which domain names + * the 400 branch carries, so that is all the transport takes. + */ +test('the transport takes the exposed names, not a failure shape', function (string $file, string $signature) { + expect(bindingFiles()[$file]->toString())->toContain($signature) + ->and(bindingFiles()[$file]->toString())->not->toContain('{code: number}'); +})->with([ + 'the interface' => ['OperationClient', 'execute('], + 'the implementation' => ['DefaultClient', 'options?: OperationOptions): Promise>'], + 'the binding' => ['bindings', 'options?: OperationOptions & {client?: OperationClient}): Promise>'], +]); + +/** + * Nothing narrows the catalogue down to one branch here, so nothing has to name one: the exception + * and the hook see whatever the server can produce. + */ +test('a hook and the exception are typed against the whole catalogue', function () { + expect(bindingFiles()['DefaultClient']->toString()) + ->toContain('export type Hook = (result: Result) => Promise | void;') + ->toContain('private async callHooks>(result: T) {') + ->and(bindingFiles()['OperationException']->toString()) + ->toContain('export class OperationException extends Error {') + ->toContain('public readonly cause: Failure;') + ->toContain('public static is(e: unknown): e is OperationException {'); +}); + +/** + * Whatever went wrong is carried, not summarised: an AbortError has to arrive as the DOMException it + * was, because throwOnFailure rethrows exactly that one and a re-wrapped copy would not be it. + */ +test('a throw anywhere in the request becomes the client envelope, keeping the original as its cause', function () { + expect(bindingFiles()['DefaultClient']->toString()) + ->toContain('const cause = e instanceof Error ? e : new Error(String(e));') + ->toContain("const envelop = {success: false, code: 0, type: 'CLIENT_ERROR', cause} satisfies Failure;") + ->toContain('return await this.callHooks(envelop);'); +}); + +/** + * The shape is declared once, in the types file, and referenced everywhere else. A second literal + * here would be a second definition free to drift from the one operations are typed against. + */ +/** + * Zero is a real code, assigned by the client itself. The fallback guards a malformed envelope — a + * code that is not a number — and a falsy check would fold the client branch into it, reporting a + * request that never left as a 500 while isClientError says otherwise. + */ +test('the exception reports the client code rather than treating zero as missing', function () { + expect(bindingFiles()['OperationException']->toString()) + ->toContain('return this.cause.code === 0;') + ->not->toContain('!code ||'); +}); + +test('no client file restates the envelope type it imports', function () { + // The runtime literal it constructs is not the declaration: that one lives in the types file, + // and a second copy here would be free to drift from what operations are typed against. + foreach (bindingFiles() as $file) { + expect($file->code)->not->toContain('type: "CLIENT_ERROR"') + ->and($file->code)->not->toContain('cause: Error'); + } +}); + /** * The transport moves a request and returns the envelope. Whatever a Client implementation puts next * to the data is that implementation's business, and naming it here would make the interface an diff --git a/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php index 5556c31..59ecab6 100644 --- a/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php +++ b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationsSpaClient; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\Data\ToastType; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; @@ -20,7 +21,7 @@ function spaClientFiles(): array { return new EmitOperationsSpaClient()->emitFiles( [], - new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), new AliasRegistry(), ); } diff --git a/tests/Unit/CodeGen/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php index 5519b8f..3ef9f23 100644 --- a/tests/Unit/CodeGen/EmitQueryKeyTest.php +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -14,6 +14,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Tests\Mocks\ValueObjects\Email; @@ -36,7 +37,7 @@ function queryKeyCodeFor(TypedOperation $typedOperation, ?Closure $nameGenerator $file = $emitter->generateOperationCode( $typedOperation, - new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), ); return [$file->code, $file->toString()]; @@ -58,7 +59,7 @@ function queryOperation(): Operation [$code, $rendered] = queryKeyCodeFor(new TypedOperation( new Typescript('{status:OrderStatus;}', new AliasRegistry(['OrderStatus' => '"OPEN"|"SHIPPED"'])), new Typescript('Order', new AliasRegistry(['Order' => '{id:number;}'])), - Typescript::fromRawString(''), + 'never', queryOperation(), )); @@ -75,7 +76,7 @@ function queryOperation(): Operation new TypedOperation( Typescript::fromRawString('{id:number;}'), Typescript::fromRawString('string'), - Typescript::fromRawString(''), + 'never', queryOperation(), ), fn (TypedOperation $operation): string => 'orders'.ucfirst($operation->definition->name), diff --git a/tests/Unit/CodeGen/EmitTanstackQueryTest.php b/tests/Unit/CodeGen/EmitTanstackQueryTest.php index f70dfd8..321346b 100644 --- a/tests/Unit/CodeGen/EmitTanstackQueryTest.php +++ b/tests/Unit/CodeGen/EmitTanstackQueryTest.php @@ -15,6 +15,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; use Tests\Mocks\ValueObjects\Email; @@ -36,7 +37,7 @@ function tanstackCodeFor(TypedOperation $typedOperation, ?Closure $nameGenerator return $emitter->generateOperationCode( $typedOperation, - new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), ); } @@ -56,7 +57,7 @@ function tanstackOperation(OperationType $type = OperationType::QUERY): Operatio $code = tanstackCodeFor(new TypedOperation( Typescript::fromRawString('{id:number;}'), Typescript::fromRawString('string'), - Typescript::fromRawString(''), + 'never', tanstackOperation(), ))->code; @@ -75,7 +76,7 @@ function tanstackOperation(OperationType $type = OperationType::QUERY): Operatio new TypedOperation( Typescript::fromRawString('{id:number;}'), Typescript::fromRawString('string'), - Typescript::fromRawString(''), + 'never', tanstackOperation(), ), fn (TypedOperation $operation): string => 'orders'.ucfirst($operation->definition->name), @@ -94,7 +95,7 @@ function tanstackOperation(OperationType $type = OperationType::QUERY): Operatio $code = tanstackCodeFor(new TypedOperation( Typescript::fromRawString('null'), Typescript::fromRawString('string'), - Typescript::fromRawString(''), + 'never', tanstackOperation(), ))->code; @@ -110,7 +111,7 @@ function tanstackOperation(OperationType $type = OperationType::QUERY): Operatio expect(tanstackCodeFor(new TypedOperation( Typescript::fromRawString('{id:number;}'), Typescript::fromRawString('string'), - Typescript::fromRawString(''), + 'never', tanstackOperation(OperationType::COMMAND), )))->toBeNull(); }); diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index 619d55b..bbd8c85 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -14,7 +14,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\Mocks\ValueObjects\Email; @@ -43,8 +43,8 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp ]); $files = $emitter->emitFiles( - [new TypedOperation($input, $output, Typescript::fromRawString(''), $operation)], - new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + [new TypedOperation($input, $output, 'never', $operation)], + new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), $registry, ); @@ -59,10 +59,21 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp // It narrows the envelope; it knows nothing about how a request was made. Keeping it here means // a project generating no transport bindings at all still gets it. expect(emitUtilsFor()) - ->toContain('export function throwOnFailure(result: Result): asserts result is Success') + ->toContain('export function throwOnFailure(result: Result): asserts result is Success') ->toContain('throw new OperationException(result);'); }); +/** + * A cancelled request is not a failed operation. Tanstack aborts the in flight query on every + * refetch, and an OperationException raised for that would surface as a rendered error instead of + * the refetch it actually was, so the original DOMException is rethrown untouched. + */ +test('an aborted request is rethrown as itself rather than wrapped', function () { + expect(emitUtilsFor()) + ->toContain('if (result.type === "CLIENT_ERROR") {') + ->toContain('throw result.cause;'); +}); + test('the utils carry no knowledge of any specific client implementation', function () { // Directive guards belong to the client that emits the directives, not to the shared utils. expect(emitUtilsFor()) diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index b0daf10..1aab686 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -7,12 +7,13 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; +use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; @@ -42,8 +43,8 @@ function emitTypesFor(string $inputType, string $outputType): string $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); $files = new EmitTypes()->emitFiles( - [new TypedOperation($input, $output, Typescript::fromRawString(''), $operation)], - new ServerMetadata('/query/{fqn}', '/command/{fqn}'), + [new TypedOperation($input, $output, 'never', $operation)], + new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), $registry, ); @@ -53,7 +54,7 @@ function emitTypesFor(string $inputType, string $outputType): string test('rejects an alias colliding with a declaration the types file always contains', function (string $alias) { $registry = new AliasRegistry([$alias => '{a:string;}']); - expect(fn () => new EmitTypes()->emitFiles([], new ServerMetadata('/query/{fqn}', '/command/{fqn}'), $registry)) + expect(fn () => new EmitTypes()->emitFiles([], new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), $registry)) ->toThrow(UnsupportedTypeException::class, 'collides with a declaration'); })->with([ 'the Brand helper generic' => ['Brand'], @@ -61,8 +62,83 @@ function emitTypesFor(string $inputType, string $outputType): string 'the success branch' => ['Success'], 'the failure branch' => ['Failure'], 'the namespace union' => ['OperationNamespaces'], + 'the invalid input envelope' => ['InvalidInputError'], + 'the authentication envelope' => ['AuthenticationError'], + 'the authorization envelope' => ['AuthorizationError'], + 'the not found envelope' => ['NotFoundError'], + 'the domain envelope' => ['DomainError'], + 'the internal envelope' => ['InternalError'], + 'the client envelope' => ['ClientError'], ]); +/** + * The reserved list is the catalogue itself, not a copy of it: a name added to one and forgotten in + * the other is a user alias that silently generates a second, conflicting declaration. + */ +test('every envelope the catalogue declares is reserved', function () { + foreach (ErrorTypescript::envelopeNames() as $name) { + expect(fn () => new EmitTypes()->emitFiles( + [], + new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new AliasRegistry([$name => '{a:string;}']), + ))->toThrow(UnsupportedTypeException::class, 'collides with a declaration'); + } +}); + +/** + * Failure is a union of references, so the shapes have to be declared here or nothing resolves them. + * Generic over the exposed exception names for the one branch that varies. + */ +test('the finite error catalogue is declared in the types file', function () { + $types = emitTypesFor( + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.'}', + ); + + expect($types) + ->toContain('export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}};') + ->toContain('export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"};') + ->toContain('export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"};') + ->toContain('export type NotFoundError = {code: 404, type: "NOT_FOUND"};') + ->toContain('export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}};') + ->toContain('export type InternalError = {code: 500, type: "INTERNAL_ERROR"};') + ->toContain('export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error};'); +}); + +/** + * The catalogue is closed, so Failure is the union of what this server can produce rather than a + * hole for whatever a caller passes. What remains parameterised is the only thing an operation can + * add to it: the names it exposed. + */ +test('Failure is the union of the categories the server can produce, not a type parameter', function () { + $types = emitTypesFor( + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.'}', + ); + + expect($types) + ->toContain('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError);') + ->not->toContain('{code: number}'); +}); + +/** + * Declared unconditionally, referenced only where reachable: naming a branch this server cannot + * produce would claim it can, while reserving the name costs nothing. + */ +test('an unmapped auth category is declared but stays out of Failure', function () { + $types = emitTypesFor( + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.'}', + ); + + preg_match('/^export type Failure.*$/m', $types, $matches); + + expect($types)->toContain('export type AuthenticationError =') + ->toContain('export type AuthorizationError =') + ->and($matches[0])->not->toContain('AuthenticationError') + ->and($matches[0])->not->toContain('AuthorizationError'); +}); + test('the envelope names the client side channel without describing what is in it', function () { $types = emitTypesFor( 'array{id: \\'.UserId::class.'}', @@ -74,7 +150,7 @@ function emitTypesFor(string $inputType, string $outputType): string // belongs to the implementation that emits it, which for the one this library ships is // lib/client-operations-spa.ts. expect($types) - ->toContain('export type Result = Success | Failure;') + ->toContain('export type Result = Success | Failure;') ->toContain('__client?: unknown') ->not->toContain('operations-spa') ->not->toContain('OperationsClientPayload') @@ -94,7 +170,7 @@ function emitTypesFor(string $inputType, string $outputType): string // because jsonSerialize() leaves either key off when there is nothing to say. expect($types) ->toContain('export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record}') - ->toContain('export type Failure = {success: false, __metadata?: Record} & E;'); + ->toContain('export type Failure = {success: false, __metadata?: Record} & ('); }); test('attribute brands stay inline and declare no alias, only the Brand helper is exported', function () { diff --git a/tests/Unit/CodeGen/ErrorTypescriptTest.php b/tests/Unit/CodeGen/ErrorTypescriptTest.php index a846d2b..84f7e78 100644 --- a/tests/Unit/CodeGen/ErrorTypescriptTest.php +++ b/tests/Unit/CodeGen/ErrorTypescriptTest.php @@ -27,89 +27,182 @@ function typescriptDefinition(string $methodName = 'declaresThrows', array $midd ); } -// Only the two categories that have something to add carry details; for the rest the category is -// the whole answer and the server omits the key. -const INVALID_INPUT_BRANCH = '{code: 422, type: "INVALID_INPUT", details: {fields: Record}}'; -const UNAUTHENTICATED_BRANCH = '{code: 401, type: "AUTHENTICATION_ERROR"}'; -const UNAUTHORIZED_BRANCH = '{code: 403, type: "AUTHORIZATION_ERROR"}'; -const NOT_FOUND_BRANCH = '{code: 404, type: "NOT_FOUND"}'; -const INTERNAL_BRANCH = '{code: 500, type: "INTERNAL_ERROR"}'; - -test('an unconfigured server only emits the branches it can actually produce', function () { - $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresNothing')); - - expect($union)->toBe(implode('|', [ - INVALID_INPUT_BRANCH, - NOT_FOUND_BRANCH, - INTERNAL_BRANCH, +// Every branch is declared once in the generated types file, and Failure is the union of the ones +// this server can produce. Only the domain branch varies per operation, which is why it is the only +// envelope that takes a type argument - and the only thing an operation contributes to its own +// error type. +const INVALID_INPUT_ENVELOPE = 'InvalidInputError'; +const UNAUTHENTICATED_ENVELOPE = 'AuthenticationError'; +const UNAUTHORIZED_ENVELOPE = 'AuthorizationError'; +const NOT_FOUND_ENVELOPE = 'NotFoundError'; +const DOMAIN_ENVELOPE = 'DomainError'; +const INTERNAL_ENVELOPE = 'InternalError'; +const CLIENT_ENVELOPE = 'ClientError'; + +/* What Failure is, per server. */ + +test('an unconfigured server only unions the branches it can actually produce', function () { + expect(ErrorTypescript::failureUnion(new ServerConfiguration()))->toBe(implode('|', [ + INVALID_INPUT_ENVELOPE, + NOT_FOUND_ENVELOPE, + DOMAIN_ENVELOPE, + INTERNAL_ENVELOPE, + CLIENT_ENVELOPE, ])); }); test('the authentication branch appears once unauthenticated exceptions are configured', function () { $configuration = new ServerConfiguration()->withExceptions(unauthenticated: [RecordMissingException::class]); - $union = ErrorTypescript::forOperation($configuration, typescriptDefinition('declaresNothing')); - - expect($union)->toBe(implode('|', [ - INVALID_INPUT_BRANCH, - UNAUTHENTICATED_BRANCH, - NOT_FOUND_BRANCH, - INTERNAL_BRANCH, + expect(ErrorTypescript::failureUnion($configuration))->toBe(implode('|', [ + INVALID_INPUT_ENVELOPE, + UNAUTHENTICATED_ENVELOPE, + NOT_FOUND_ENVELOPE, + DOMAIN_ENVELOPE, + INTERNAL_ENVELOPE, + CLIENT_ENVELOPE, ])); }); test('the authorization branch appears once unauthorized exceptions are configured', function () { $configuration = new ServerConfiguration()->withExceptions(unauthorized: [RecordMissingException::class]); - $union = ErrorTypescript::forOperation($configuration, typescriptDefinition('declaresNothing')); - - expect($union)->toBe(implode('|', [ - INVALID_INPUT_BRANCH, - UNAUTHORIZED_BRANCH, - NOT_FOUND_BRANCH, - INTERNAL_BRANCH, + expect(ErrorTypescript::failureUnion($configuration))->toBe(implode('|', [ + INVALID_INPUT_ENVELOPE, + UNAUTHORIZED_ENVELOPE, + NOT_FOUND_ENVELOPE, + DOMAIN_ENVELOPE, + INTERNAL_ENVELOPE, + CLIENT_ENVELOPE, ])); }); -test('the domain branch lists every exposed exception the operation declares, just before the catch all', function () { - $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresThrows', [ThrowingMiddleware::class])); +/** + * Unlike the auth branches, this one is not gated on anything the server was configured with: an + * operation exposing nothing instantiates it with `never`, and the declaration erases it there. A + * second gate here would only mean saying `never` twice. + */ +test('the domain branch is always in the union, carrying the parameter Failure declares', function () { + expect(ErrorTypescript::failureUnion(new ServerConfiguration())) + ->toContain('DomainError<'.ErrorTypescript::DOMAIN_TYPE_PARAMETER.'>'); +}); - expect($union)->toBe(implode('|', [ - INVALID_INPUT_BRANCH, - NOT_FOUND_BRANCH, - '{code: 400, type: "DOMAIN_ERROR", details: {type: "domain_failure"}|{type: "middleware_failure"}}', - INTERNAL_BRANCH, - ])); +/** + * The one branch no server ever sends: the request never got there. It is appended here rather than + * by a caller so the whole union has a single owner, and so what a client can hand back is pinned by + * the same tests as what the server can. + */ +test('the client envelope closes the union, whatever the server is configured with', function (ServerConfiguration $configuration) { + $union = ErrorTypescript::failureUnion($configuration); + + expect($union)->toEndWith('|'.CLIENT_ENVELOPE) + ->and(substr_count($union, CLIENT_ENVELOPE))->toBe(1); +})->with([ + 'nothing configured' => [new ServerConfiguration()], + 'auth configured' => [ + new ServerConfiguration()->withExceptions(unauthenticated: [RecordMissingException::class]), + ], + 'both configured' => [ + new ServerConfiguration()->withExceptions( + unauthenticated: [RecordMissingException::class], + unauthorized: [RecordMissingException::class], + ), + ], +]); + +test('every name the union references is a name the catalogue declares', function () { + $referenced = explode('|', ErrorTypescript::failureUnion( + new ServerConfiguration()->withExceptions( + unauthenticated: [RecordMissingException::class], + unauthorized: [RecordMissingException::class], + ), + )); + + foreach ($referenced as $reference) { + expect(ErrorTypescript::envelopeNames())->toContain(strtok($reference, '<')); + } +}); + +/* What an operation contributes to it. */ + +test('the domain types list every exposed exception the operation declares', function () { + $domainTypes = ErrorTypescript::domainTypesFor( + new ServerConfiguration(), + typescriptDefinition('declaresThrows', [ThrowingMiddleware::class]), + ); + + expect($domainTypes)->toBe('"domain_failure"|"middleware_failure"'); }); -test('the domain branch is named by the as of a Throws, not by the ExposeAs it overrides', function () { - $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresRenamedThrows')); +test('a domain type is named by the as of a Throws, not by the ExposeAs it overrides', function () { + $domainTypes = ErrorTypescript::domainTypesFor(new ServerConfiguration(), typescriptDefinition('declaresRenamedThrows')); - expect($union)->toContain('{code: 400, type: "DOMAIN_ERROR", details: {type: "renamed_failure"}|{type: "overridden_failure"}}') - ->and($union)->not->toContain('domain_failure'); + expect($domainTypes)->toBe('"renamed_failure"|"overridden_failure"') + ->and($domainTypes)->not->toContain('domain_failure'); }); test('an exception declared by both the operation and a middleware appears once', function () { - $union = ErrorTypescript::forOperation( + $domainTypes = ErrorTypescript::domainTypesFor( new ServerConfiguration(), typescriptDefinition('declaresRenamedThrows', [RenamingMiddleware::class]), ); - expect($union)->toContain('{code: 400, type: "DOMAIN_ERROR", details: {type: "renamed_failure"}|{type: "overridden_failure"}|{type: "renamed_middleware_failure"}}') - ->and($union)->not->toContain('middleware_name'); + expect($domainTypes)->toBe('"renamed_failure"|"overridden_failure"|"renamed_middleware_failure"') + ->and($domainTypes)->not->toContain('middleware_name'); }); -test('an operation declaring nothing exposable emits no domain branch', function () { - $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresNothing')); +/** + * `never` is not an absence the caller has to handle - it is what makes the 400 branch vanish from + * that operation's Failure, so the erasure and the emptiness are the same fact. + */ +test('an operation declaring nothing exposable exposes never', function () { + expect(ErrorTypescript::domainTypesFor(new ServerConfiguration(), typescriptDefinition('declaresNothing'))) + ->toBe(ErrorTypescript::NO_DOMAIN_TYPES); +}); + +test('a #[Throws] lacking ExposeAs contributes no name', function () { + // declaresThrows declares UnexposedException alongside ExposedDomainException, so only the + // exposed one may be listed. + $domainTypes = ErrorTypescript::domainTypesFor(new ServerConfiguration(), typescriptDefinition('declaresThrows')); + + expect($domainTypes)->toBe('"domain_failure"') + ->and($domainTypes)->not->toContain('UnexposedException'); +}); + +/* The declarations themselves. */ - expect($union)->not->toContain('DOMAIN_ERROR'); +test('the catalogue declares one envelope per branch, and the client one the server never sends', function () { + expect(ErrorTypescript::envelopeDeclarations()) + ->toContain('export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}};') + ->toContain('export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"};') + ->toContain('export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"};') + ->toContain('export type NotFoundError = {code: 404, type: "NOT_FOUND"};') + ->toContain('export type InternalError = {code: 500, type: "INTERNAL_ERROR"};') + ->toContain('export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error};'); }); -test('an operation whose only #[Throws] lacks ExposeAs emits no domain branch', function () { - // declaresThrows declares UnexposedException alongside ExposedDomainException, so the branch - // must list only the exposed one. - $union = ErrorTypescript::forOperation(new ServerConfiguration(), typescriptDefinition('declaresThrows')); +/** + * The conditional is what makes `never` mean "no 400 branch" rather than "a 400 whose name is + * uninhabited". The brackets keep it from distributing, so two exposed names stay one member with a + * union under details.type instead of splitting into two. + */ +test('the domain envelope erases itself rather than describing an uninhabited 400', function () { + expect(ErrorTypescript::envelopeDeclarations()) + ->toContain('export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}};'); +}); + +/** + * Declared even where unreachable: a name a consumer writes a handler against costs nothing to + * reserve, whereas a Failure naming a branch this server cannot produce would be a lie. + */ +test('every name the catalogue lists is a name it declares', function () { + foreach (ErrorTypescript::envelopeNames() as $name) { + expect(ErrorTypescript::envelopeDeclarations())->toContain("export type {$name}"); + } +}); - expect($union)->toContain('{code: 400, type: "DOMAIN_ERROR", details: {type: "domain_failure"}}') - ->and($union)->not->toContain('UnexposedException'); +test('an envelope is named without its type argument, which is what an import statement takes', function () { + foreach (ErrorTypescript::envelopeNames() as $name) { + expect($name)->not->toContain('<'); + } }); diff --git a/tests/Unit/CodeGen/TsOutputFixture.php b/tests/Unit/CodeGen/TsOutputFixture.php index 122f13d..c4aa633 100644 --- a/tests/Unit/CodeGen/TsOutputFixture.php +++ b/tests/Unit/CodeGen/TsOutputFixture.php @@ -7,6 +7,7 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Server; @@ -52,6 +53,6 @@ public static function generate(): array return new TypescriptServerCodeGenerator( CodeGenerators::fromDefaults('name', with: ['type-map', 'tanstack-query', 'query-key']), - )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); + )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration())); } } diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index ff0f654..5d9b735 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -18,6 +18,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Exceptions\InvalidGeneratorDependencies; use Le0daniel\PhpTsBindings\CodeGen\TypescriptServerCodeGenerator; use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\ParserException; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Server; @@ -50,7 +51,7 @@ function generateFor(array $classes, ?array $generators = null): array new EmitTypeUtils(), new EmitOperations(), ], - )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); + )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration())); } test('attribute brands declare no aliases in lib/types.ts, only the Brand helper', function () { @@ -76,6 +77,58 @@ function generateFor(array $classes, ?array $generators = null): array ->toContain("import type {Brand} from './lib/types';"); }); +/** + * The catalogue is the server's, so an operation module names none of it — not even Failure. All it + * declares is which names it exposed, and `never` where it exposed nothing, which erases the 400 + * branch of the Failure those names are eventually handed to. + */ +test('an operation module declares only what it adds to the catalogue', function () { + $operations = generateFor([UserOperations::class])['users.ts']->toString(); + + expect($operations) + ->toContain('export type GetDomainErrors = never;') + ->toContain('export type CreateDomainErrors = never;') + ->toContain('executeOperation(') + // Nothing from the catalogue is written down here, so nothing here can drift from it. That + // includes Failure: a `GetError = Failure` alias would be a second name for + // a type already spelled out of one word. + ->not->toContain('GetError') + ->not->toContain('Failure') + ->not->toContain('InvalidInputError') + ->not->toContain('NotFoundError') + ->not->toContain('InternalError') + ->not->toContain('ClientError') + ->not->toContain('AuthenticationError') + ->not->toContain('AuthorizationError') + ->not->toContain('DomainError<'); +}); + +/** + * Nothing maps onto the two auth categories on this server, so neither is reachable — and a Failure + * naming a branch the server cannot produce would say otherwise. The declarations stay: they are + * names a consumer may still write a handler against. + */ +test('the failure union names only the categories this server can produce', function () { + $types = generateFor([UserOperations::class])['lib/types.ts']->toString(); + preg_match('/^export type Failure.*$/m', $types, $matches); + + expect($matches[0]) + ->toBe('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError);') + ->and($types)->toContain('export type AuthenticationError =') + ->toContain('export type AuthorizationError ='); +}); + +test('the branch shapes are declared once, not restated per operation', function () { + $files = generateFor([UserOperations::class]); + + expect($files['lib/types.ts']->toString()) + ->toContain('export type NotFoundError = {code: 404, type: "NOT_FOUND"};') + ->and($files['users.ts']->toString()) + ->not->toContain('"NOT_FOUND"') + ->not->toContain('"INTERNAL_ERROR"') + ->not->toContain('Record'); +}); + test('declares named types once in lib/types.ts, nested aliases and inline brands included', function () { $files = generateFor([NamedOperations::class]); diff --git a/tests/ts-output/generated/accounts.ts b/tests/ts-output/generated/accounts.ts index 209e6ae..fff93ac 100644 --- a/tests/ts-output/generated/accounts.ts +++ b/tests/ts-output/generated/accounts.ts @@ -9,7 +9,7 @@ import {queryOptions, useQuery} from '@tanstack/react-query'; export type FindResult = {id:number;term:string;}; export type FindInput = {availability?:(null|Availability);term:string;}; -export type FindError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}}|{code: 500, type: "INTERNAL_ERROR"}; +export type FindDomainErrors = "account_locked"; /** * Type: QUERY @@ -18,7 +18,7 @@ export type FindError = {code: 422, type: "INVALID_INPUT", details: {fields: Rec * @php Tests\Unit\CodeGen\Mocks\TsOutput\AccountOperations::find */ export async function find(input: FindInput, options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'query', 'accounts.find', input, @@ -51,7 +51,7 @@ export function findQueryKey(input: FindInput) { export type LockResult = {locked:true;}; export type LockInput = {id:number;}; -export type LockError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}|{type: "quota_exceeded"}}|{code: 500, type: "INTERNAL_ERROR"}; +export type LockDomainErrors = "account_locked"|"quota_exceeded"; /** * Type: COMMAND @@ -60,7 +60,7 @@ export type LockError = {code: 422, type: "INVALID_INPUT", details: {fields: Rec * @php Tests\Unit\CodeGen\Mocks\TsOutput\AccountOperations::lock */ export async function lock(input: LockInput, options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'command', 'accounts.lock', input, @@ -70,7 +70,7 @@ export async function lock(input: LockInput, options?: OperationOptions) { export type UnlockResult = {unlocked:true;}; export type UnlockInput = {id:number;}; -export type UnlockError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; +export type UnlockDomainErrors = never; /** * Type: COMMAND @@ -79,7 +79,7 @@ export type UnlockError = {code: 422, type: "INVALID_INPUT", details: {fields: R * @php Tests\Unit\CodeGen\Mocks\TsOutput\AccountOperations::unlock */ export async function unlock(input: UnlockInput, options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'command', 'accounts.unlock', input, diff --git a/tests/ts-output/generated/catalog.ts b/tests/ts-output/generated/catalog.ts index 527f609..b633c73 100644 --- a/tests/ts-output/generated/catalog.ts +++ b/tests/ts-output/generated/catalog.ts @@ -9,7 +9,7 @@ import {queryOptions, useQuery} from '@tanstack/react-query'; export type PrepareResult = Draft; export type PrepareInput = DraftInput; -export type PrepareError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; +export type PrepareDomainErrors = never; /** * Type: QUERY @@ -18,7 +18,7 @@ export type PrepareError = {code: 422, type: "INVALID_INPUT", details: {fields: * @php Tests\Unit\CodeGen\Mocks\TsOutput\CatalogOperations::prepare */ export async function prepare(input: PrepareInput, options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'query', 'catalog.prepare', input, @@ -51,7 +51,7 @@ export function prepareQueryKey(input: PrepareInput) { export type ProductResult = Product; export type ProductInput = {id:(number & Brand<"productId">);}; -export type ProductError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; +export type ProductDomainErrors = never; /** * Type: QUERY @@ -60,7 +60,7 @@ export type ProductError = {code: 422, type: "INVALID_INPUT", details: {fields: * @php Tests\Unit\CodeGen\Mocks\TsOutput\CatalogOperations::product */ export async function product(input: ProductInput, options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'query', 'catalog.product', input, @@ -93,7 +93,7 @@ export function productQueryKey(input: ProductInput) { export type RestockResult = {product:Product;restockedAt:string;}; export type RestockInput = {amount:number;price:Money;sku:Sku;}; -export type RestockError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; +export type RestockDomainErrors = never; /** * Type: COMMAND @@ -102,7 +102,7 @@ export type RestockError = {code: 422, type: "INVALID_INPUT", details: {fields: * @php Tests\Unit\CodeGen\Mocks\TsOutput\CatalogOperations::restock */ export async function restock(input: RestockInput, options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'command', 'catalog.restock', input, @@ -112,7 +112,7 @@ export async function restock(input: RestockInput, options?: OperationOptions) { export type SearchResult = {results:Array;total:number;}; export type SearchInput = {availability?:Availability;limit?:number;term:string;}; -export type SearchError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; +export type SearchDomainErrors = never; /** * Type: QUERY @@ -121,7 +121,7 @@ export type SearchError = {code: 422, type: "INVALID_INPUT", details: {fields: R * @php Tests\Unit\CodeGen\Mocks\TsOutput\CatalogOperations::search */ export async function search(input: SearchInput, options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'query', 'catalog.search', input, diff --git a/tests/ts-output/generated/lib/DefaultClient.ts b/tests/ts-output/generated/lib/DefaultClient.ts index 7a04d90..3bb8d16 100644 --- a/tests/ts-output/generated/lib/DefaultClient.ts +++ b/tests/ts-output/generated/lib/DefaultClient.ts @@ -3,7 +3,12 @@ import type {OperationClient, OperationOptions} from './OperationClient'; import type {Failure, Result, Success} from './types'; -export type Hook = (result: Result) => Promise | void; +/** + * A hook sees the envelope of any operation, so it is typed against the widest domain union rather + * than any one operation's. Every category is still there to discriminate on — the catalogue is the + * server's, not the operation's. + */ +export type Hook = (result: Result) => Promise | void; export class DefaultClient implements OperationClient { @@ -40,7 +45,7 @@ export class DefaultClient implements OperationClient { }).join('&'); } - private async callHooks>(result: T) { + private async callHooks>(result: T) { try { await Promise.all(this.hooks.map(hook => hook(result))); return result; @@ -50,7 +55,7 @@ export class DefaultClient implements OperationClient { } } - async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { + async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{fqn}', key)}`; @@ -71,34 +76,46 @@ export class DefaultClient implements OperationClient { headers['Content-Type'] = 'application/json'; } - const queryParams = type === 'query' && input && typeof input === 'object' - ? `?${this.createJsonEncodedQueryParams(input)}` - : ''; - - const response = await this.fetcher(`${fullPath}${queryParams}`, { - method: type === 'query' ? 'GET' : 'POST', - signal, - headers, - body: type === 'command' ? JSON.stringify(input) : undefined, - }); - - const json = await response.json(); - if (!json || typeof json !== 'object') { - throw new Error('Invalid response body. Could not parse json correctly.'); - } - - // Spread first: whatever the server put next to the envelope — a client's directives, say — - // rides along untyped rather than being dropped by a transport that never knew about it. - if (response.ok) { - return await this.callHooks({...json, success: true} as Success); + try { + const queryParams = type === 'query' && input && typeof input === 'object' + ? `?${this.createJsonEncodedQueryParams(input)}` + : ''; + + const response = await this.fetcher(`${fullPath}${queryParams}`, { + method: type === 'query' ? 'GET' : 'POST', + signal, + headers, + body: type === 'command' ? JSON.stringify(input) : undefined, + }); + + const json = await response.json(); + if (!json || typeof json !== 'object') { + throw new Error('Invalid response body. Could not parse json correctly.'); + } + + // Spread first: whatever the server put next to the envelope — a client's directives, say — + // rides along untyped rather than being dropped by a transport that never knew about it. + if (response.ok) { + return await this.callHooks({...json, success: true} as Success); + } + + return await this.callHooks({ + ...json, + success: false, + code: json?.code ?? response.status, + type: json?.type ?? 'INTERNAL_ERROR' + } as Failure); + } catch (e: unknown) { + // Anything thrown between here and the response being read: the request never completed, + // so there is no server error to report and the cause is the answer. It is carried as + // itself rather than summarised — throwOnFailure rethrows an AbortError exactly, and a + // re-wrapped copy would no longer be that DOMException. + // + // No type argument: this branch is in every Failure, whatever the operation exposed. + const cause = e instanceof Error ? e : new Error(String(e)); + const envelop = {success: false, code: 0, type: 'CLIENT_ERROR', cause} satisfies Failure; + return await this.callHooks(envelop); } - - return await this.callHooks({ - ...json, - success: false, - code: json?.code ?? response.status, - type: json?.type ?? 'INTERNAL_ERROR' - } as Failure); } registerHook(hook: Hook): () => void { diff --git a/tests/ts-output/generated/lib/OperationClient.ts b/tests/ts-output/generated/lib/OperationClient.ts index 077d1ed..58b81d9 100644 --- a/tests/ts-output/generated/lib/OperationClient.ts +++ b/tests/ts-output/generated/lib/OperationClient.ts @@ -8,12 +8,16 @@ export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client * Moves a request and resolves to the envelope. A server may put more next to the data, and it * travels through untouched — describing it here would tie every transport to one Client * implementation's schema. Reach for the guard the implementation ships instead. + * + * The only thing an operation adds to the error catalogue is which domain errors it exposed, so that + * is all this takes. ClientError needs no mention: a request can fail before it reaches the server, + * and Failure carries that branch whatever the operation declared. */ export interface OperationClient { - execute( + execute( type: "command"|"query", key: string, input: unknown, options?: OperationOptions - ): Promise>; + ): Promise>; } diff --git a/tests/ts-output/generated/lib/OperationException.ts b/tests/ts-output/generated/lib/OperationException.ts index 3356968..6d8e25a 100644 --- a/tests/ts-output/generated/lib/OperationException.ts +++ b/tests/ts-output/generated/lib/OperationException.ts @@ -3,27 +3,30 @@ import type {Failure} from './types'; /** - * Generic over the operation's error union, so `e.cause.type` narrows to the branches the - * operation can actually produce rather than to any. + * Generic over the names the operation exposed, so `e.cause.details.type` narrows to those rather + * than to any string. The rest of the catalogue is the server's and needs no naming here. */ -export class OperationException extends Error { - public readonly cause: Failure; +export class OperationException extends Error { + public readonly cause: Failure; - get code(): number { - const code = this.cause.code; - if (!code || typeof code !== 'number' || Number.isNaN(code)) { - return 500; - } + /** + * The request never reached the server, so nothing here came off the wire and `cause.cause` + * holds the exception that actually stopped it. + */ + get isClientError(): boolean { + return this.cause.code === 0; + } - return code; + get code(): number { + return this.cause.code; } - constructor(cause: Failure) { + constructor(cause: Failure) { super(`Operation failed with code ${cause.code}`); this.cause = cause; } - public static is(e: unknown): e is OperationException { + public static is(e: unknown): e is OperationException { return e instanceof OperationException; } } diff --git a/tests/ts-output/generated/lib/bindings.ts b/tests/ts-output/generated/lib/bindings.ts index 90c202a..68ce809 100644 --- a/tests/ts-output/generated/lib/bindings.ts +++ b/tests/ts-output/generated/lib/bindings.ts @@ -21,7 +21,7 @@ export function setClient(operationClient: OperationClient|null): void { client = operationClient; } -export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise> { +export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise> { if (options?.client) { return await options.client.execute(type, key, input, options); } diff --git a/tests/ts-output/generated/lib/type-map.ts b/tests/ts-output/generated/lib/type-map.ts index 75e93e0..7f28f4e 100644 --- a/tests/ts-output/generated/lib/type-map.ts +++ b/tests/ts-output/generated/lib/type-map.ts @@ -1,8 +1,8 @@ // generated by: php-ts-bindings -import type {Availability, Brand, Draft, DraftInput, Money, Product, Sku} from './types'; +import type {Availability, Brand, Draft, DraftInput, Failure, Money, Product, Sku} from './types'; /** * Full type map of all operations, input and output types. */ -export type TypeMap = {query: {'accounts.find': {input: {availability?:(null|Availability);term:string;}, output: {id:number;term:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}}|{code: 500, type: "INTERNAL_ERROR"}};'catalog.prepare': {input: DraftInput, output: Draft, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'catalog.product': {input: {id:(number & Brand<"productId">);}, output: Product, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'catalog.search': {input: {availability?:Availability;limit?:number;term:string;}, output: {results:Array;total:number;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'shapes.defaults': {input: null, output: {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'shapes.roundtrip': {input: {filters:Record>;page?:number;term:string;}, output: {filters:Record>;page?:number;term:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}}};command: {'accounts.lock': {input: {id:number;}, output: {locked:true;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 400, type: "DOMAIN_ERROR", details: {type: "account_locked"}|{type: "quota_exceeded"}}|{code: 500, type: "INTERNAL_ERROR"}};'accounts.unlock': {input: {id:number;}, output: {unlocked:true;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'catalog.restock': {input: {amount:number;price:Money;sku:Sku;}, output: {product:Product;restockedAt:string;}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}};'shapes.submit': {input: {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}, output: {accepted:boolean;id:(number & Brand<"productId">);}, errors: {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}}}}; +export type TypeMap = {query: {'accounts.find': {input: {availability?:(null|Availability);term:string;}, output: {id:number;term:string;}, errors: Failure<"account_locked">};'catalog.prepare': {input: DraftInput, output: Draft, errors: Failure};'catalog.product': {input: {id:(number & Brand<"productId">);}, output: Product, errors: Failure};'catalog.search': {input: {availability?:Availability;limit?:number;term:string;}, output: {results:Array;total:number;}, errors: Failure};'shapes.defaults': {input: null, output: {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}, errors: Failure};'shapes.roundtrip': {input: {filters:Record>;page?:number;term:string;}, output: {filters:Record>;page?:number;term:string;}, errors: Failure}};command: {'accounts.lock': {input: {id:number;}, output: {locked:true;}, errors: Failure<"account_locked"|"quota_exceeded">};'accounts.unlock': {input: {id:number;}, output: {unlocked:true;}, errors: Failure};'catalog.restock': {input: {amount:number;price:Money;sku:Sku;}, output: {product:Product;restockedAt:string;}, errors: Failure};'shapes.submit': {input: {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}, output: {accepted:boolean;id:(number & Brand<"productId">);}, errors: Failure}}}; diff --git a/tests/ts-output/generated/lib/types.ts b/tests/ts-output/generated/lib/types.ts index 3db32f1..6e6b04d 100644 --- a/tests/ts-output/generated/lib/types.ts +++ b/tests/ts-output/generated/lib/types.ts @@ -2,9 +2,23 @@ export type OperationNamespaces = 'accounts'|'catalog'|'shapes'; +/* + * The finite error catalogue. Every failure is one of these, which is why Failure below is their + * union rather than a hole for one. DomainError is the only branch whose payload varies per + * operation - the names that operation exposed - and the only one declared conditionally: on + * `never` it collapses, so an operation exposing nothing has no 400 branch to narrow to. + */ +export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}; +export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"}; +export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; +export type NotFoundError = {code: 404, type: "NOT_FOUND"}; +export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}}; +export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; +export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error}; + export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} -export type Failure = {success: false, __metadata?: Record} & E; -export type Result = Success | Failure; +export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError); +export type Result = Success | Failure; declare const __brand: unique symbol; export type Brand = {readonly [__brand]: TBrand;}; diff --git a/tests/ts-output/generated/lib/utils.ts b/tests/ts-output/generated/lib/utils.ts index 69f0aba..0a6210c 100644 --- a/tests/ts-output/generated/lib/utils.ts +++ b/tests/ts-output/generated/lib/utils.ts @@ -13,12 +13,19 @@ export function queryKey(ns: QueryNamespaces, ...args: unknown[]): [string, ...u * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather * catch than branch. * - * The error union is deliberately not inferred here: a catch clause variable is `unknown` in - * TypeScript whatever was thrown, so no signature on this function could carry E to the catch. - * Name it there instead - `OperationException.is(e)` types `e.cause` for you. + * The exposed names are deliberately not inferred here: a catch clause variable is `unknown` in + * TypeScript whatever was thrown, so no signature on this function could carry them to the catch. + * Name them there instead - `OperationException.is(e)` types `e.cause` for you. */ -export function throwOnFailure(result: Result): asserts result is Success { - if (!result.success) { - throw new OperationException(result); +export function throwOnFailure(result: Result): asserts result is Success { + if (result.success) { + return; } + + // Client errors are thrown as-is. + if (result.type === "CLIENT_ERROR") { + throw result.cause; + } + + throw new OperationException(result); } diff --git a/tests/ts-output/generated/shapes.ts b/tests/ts-output/generated/shapes.ts index 806e992..d858048 100644 --- a/tests/ts-output/generated/shapes.ts +++ b/tests/ts-output/generated/shapes.ts @@ -9,7 +9,7 @@ import {queryOptions, useQuery} from '@tanstack/react-query'; export type DefaultsResult = {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}; export type DefaultsInput = null; -export type DefaultsError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; +export type DefaultsDomainErrors = never; /** * Type: QUERY @@ -18,7 +18,7 @@ export type DefaultsError = {code: 422, type: "INVALID_INPUT", details: {fields: * @php Tests\Unit\CodeGen\Mocks\TsOutput\ShapeOperations::defaults */ export async function defaults(options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'query', 'shapes.defaults', null, @@ -51,7 +51,7 @@ export function defaultsQueryKey() { export type RoundtripResult = {filters:Record>;page?:number;term:string;}; export type RoundtripInput = {filters:Record>;page?:number;term:string;}; -export type RoundtripError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; +export type RoundtripDomainErrors = never; /** * Type: QUERY @@ -60,7 +60,7 @@ export type RoundtripError = {code: 422, type: "INVALID_INPUT", details: {fields * @php Tests\Unit\CodeGen\Mocks\TsOutput\ShapeOperations::roundtrip */ export async function roundtrip(input: RoundtripInput, options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'query', 'shapes.roundtrip', input, @@ -93,7 +93,7 @@ export function roundtripQueryKey(input: RoundtripInput) { export type SubmitResult = {accepted:boolean;id:(number & Brand<"productId">);}; export type SubmitInput = {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}; -export type SubmitError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}|{code: 404, type: "NOT_FOUND"}|{code: 500, type: "INTERNAL_ERROR"}; +export type SubmitDomainErrors = never; /** * Type: COMMAND @@ -102,7 +102,7 @@ export type SubmitError = {code: 422, type: "INVALID_INPUT", details: {fields: R * @php Tests\Unit\CodeGen\Mocks\TsOutput\ShapeOperations::submit */ export async function submit(input: SubmitInput, options?: OperationOptions) { - return await executeOperation( + return await executeOperation( 'command', 'shapes.submit', input, diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index fd81b8b..9cdbf87 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -6,13 +6,13 @@ * Nothing here runs. It exists to be typechecked by `composer codegen:fixture`. */ import {find, lock} from '../generated/accounts'; -import type {ProductError} from '../generated/catalog'; +import type {ProductDomainErrors} from '../generated/catalog'; import {prepare, product, productQueryKey, productQueryOptions, restock, search, useProductQuery} from '../generated/catalog'; import {createDefaultClient, setClient} from '../generated/lib/bindings'; import type {OperationsClientPayload} from '../generated/lib/client-operations-spa'; import {containsOperationSpaPayload} from '../generated/lib/client-operations-spa'; import {OperationException} from '../generated/lib/OperationException'; -import type {Brand, Product} from '../generated/lib/types'; +import type {Brand, ClientError, Failure, InternalError, Product} from '../generated/lib/types'; import type {TypeMap} from '../generated/lib/type-map'; import {throwOnFailure} from '../generated/lib/utils'; import {defaults, submit, useDefaultsQuery} from '../generated/shapes'; @@ -38,6 +38,11 @@ export async function readProduct(): Promise { case 422: console.warn('invalid input', result.details.fields); return null; + case 0: + // The one branch no server sent. The request never got there, so what went wrong is the + // exception itself rather than anything that came off the wire, and it arrives intact. + console.error('request failed', result.cause.message); + return null; case 404: case 500: // `details` only exists where the category cannot say everything on its own. Here it @@ -49,6 +54,49 @@ export async function readProduct(): Promise { } } +/** + * The catalogue is the whole server's, but a 400 is not: `catalog.product` exposes no exception, so + * its Failure is instantiated with `never` and the branch is gone rather than present-but-empty. + * The comparison below has nothing to overlap with, which is the guarantee — an operation cannot be + * asked about a failure it can never produce. + */ +export async function productHasNoDomainError(): Promise { + const result = await product({id: productId}); + if (result.success) { + return; + } + + // @ts-expect-error + if (result.code === 400) { + console.debug(result); + } +} + +/** + * Every branch is a named type declared once, so a handler can be written against the ones it cares + * about and reused across operations — rather than each call site restating the literal shape. + */ +function isWorthRetrying(error: ClientError | InternalError): boolean { + // A cancelled request is not a failure worth repeating; anything else that never reached the + // server is. Narrowing the parameter on `code` reaches `cause`, which only one branch has. + return error.code === 500 || !(error.cause instanceof DOMException); +} + +export async function readProductOrRetryLater(): Promise { + const result = await product({id: productId}); + if (result.success) { + return result.data; + } + + // The compiler checks that these two branches are the ones the helper accepts, instead of + // taking the call site's word for it. + if (result.code === 0 || result.code === 500) { + return isWorthRetrying(result) ? 'retry' : null; + } + + return null; +} + /** * throwOnFailure narrows the same union by asserting, for code that would rather catch than branch. */ @@ -58,11 +106,13 @@ export async function readProductOrThrow(): Promise { throwOnFailure(result); return result.data; } catch (error) { - // A catch clause variable is `unknown` whatever was thrown, so the operation's error union - // is named here rather than inferred. OperationException is generic over it, which is what - // makes `cause.type` the discriminated union instead of any. - if (OperationException.is(error)) { - const failureType: ProductError['type'] = error.cause.type; + // A catch clause variable is `unknown` whatever was thrown, so what the operation exposed is + // named here rather than inferred. OperationException is generic over exactly that — the rest + // of the catalogue is the server's and needs no naming. + if (OperationException.is(error)) { + // No Error is generated: the envelope is Failure with the operation's names in it, + // which is the whole of what an alias for it would have said. + const failureType: Failure['type'] = error.cause.type; console.error('operation failed', error.code, failureType); if (error.cause.code === 422) { From 9b57803bd76f88d3e3a4f943fbe5011c0d453b22 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 10 Aug 2026 11:14:28 +0200 Subject: [PATCH 073/101] Add `OutputDirectory::clear` and `Assertions::true` for safer directory cleanup --- src/CodeGen/Utils/OutputDirectory.php | 12 ++++++++++++ src/Utils/Assertions.php | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/CodeGen/Utils/OutputDirectory.php b/src/CodeGen/Utils/OutputDirectory.php index 10ce101..ef3518a 100644 --- a/src/CodeGen/Utils/OutputDirectory.php +++ b/src/CodeGen/Utils/OutputDirectory.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Exceptions\CodeGenException; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; +use Le0daniel\PhpTsBindings\Utils\Assertions; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; @@ -17,6 +18,17 @@ */ final class OutputDirectory { + public static function clear(string $directory): void + { + foreach (self::existingFileNames($directory) as $fileName) { + $realpath = "{$directory}/{$fileName}" |> realpath(...) |> Assertions::string(...); + + if (self::isGeneratedFile($realpath)) { + unlink($realpath) |> Assertions::true(...); + } + } + } + /** * @param array $files Keys are paths relative to the directory. */ diff --git a/src/Utils/Assertions.php b/src/Utils/Assertions.php index 7c49368..c476694 100644 --- a/src/Utils/Assertions.php +++ b/src/Utils/Assertions.php @@ -39,4 +39,17 @@ public static function string(mixed $value): string return $value; } + + /** + * @phpstan-assert true $value + * @param mixed $value + * @return true + */ + public static function true(mixed $value): true + { + if ($value !== true) { + throw new InvalidArgumentException(\sprintf('Expected true, got %s', gettype($value))); + } + return true; + } } From 6b88ce5b56bd410fc2dc5abe2276dcd1e31dbb79 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 10 Aug 2026 13:46:47 +0200 Subject: [PATCH 074/101] Refactor `OutputDirectory` for safer file handling and improved directory operations - Simplified `clear` logic with streamlined file deletion and marker checks. - Moved directory cleanup earlier in the `write` process to handle stale files. - Added safeguards for nested directory creation and proper file overwrites. - Standardized spacing for consistency in conditions and function calls. --- src/CodeGen/Utils/OutputDirectory.php | 55 ++++++++++++++------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/CodeGen/Utils/OutputDirectory.php b/src/CodeGen/Utils/OutputDirectory.php index ef3518a..5402902 100644 --- a/src/CodeGen/Utils/OutputDirectory.php +++ b/src/CodeGen/Utils/OutputDirectory.php @@ -21,48 +21,49 @@ final class OutputDirectory public static function clear(string $directory): void { foreach (self::existingFileNames($directory) as $fileName) { - $realpath = "{$directory}/{$fileName}" |> realpath(...) |> Assertions::string(...); - - if (self::isGeneratedFile($realpath)) { - unlink($realpath) |> Assertions::true(...); - } + "{$directory}/{$fileName}" + |> realpath(...) + |> Assertions::string(...) + |> unlink(...) + |> Assertions::true(...); } } /** - * @param array $files Keys are paths relative to the directory. + * @param array $files Keys are paths relative to the directory. */ public static function write(string $directory, array $files): void { + // Everything generated is rewritten, so a module left over from an operation that no longer + // exists would otherwise keep importing types that are gone. Only files carrying the marker + // are removed - the directory may legitimately hold TypeScript nobody here wrote. + self::clear($directory); + // A file about to be written that is already there unmarked is hand written TypeScript // whose name collides with a generated module. Overwriting it silently is the one case // the marker cannot recover from, so it is refused before anything is touched. foreach ($files as $fileName => $file) { $filePath = "{$directory}/{$fileName}"; - if (file_exists($filePath) && ! self::isGeneratedFile($filePath)) { + if (file_exists($filePath)) { throw new CodeGenException( "Refusing to overwrite {$fileName}: it exists but does not carry the " - ."'".TypescriptFile::MARKER."' marker, so it was not written by this " - .'library. Either it is hand written and the output belongs somewhere else, ' - .'or it predates the marker - delete the output directory once and generate ' - .'again.' + . "'" . TypescriptFile::MARKER . "' marker, so it was not written by this " + . 'library. Either it is hand written and the output belongs somewhere else, ' + . 'or it predates the marker - delete the output directory once and generate ' + . 'again.' ); } } - // Everything generated is rewritten, so a module left over from an operation that no longer - // exists would otherwise keep importing types that are gone. Only files carrying the marker - // are removed - the directory may legitimately hold TypeScript nobody here wrote. - foreach (self::existingFileNames($directory) as $fileName) { - unlink("{$directory}/{$fileName}"); - } + foreach ($files as $fileName => $file) { + $fullPath = "{$directory}/{$fileName}"; + $directoryPath = dirname($fullPath); - if (! is_dir("{$directory}/lib")) { - mkdir("{$directory}/lib", 0777, true); - } + if (!file_exists($directoryPath) && !is_dir($directoryPath)) { + mkdir($directoryPath, 0777, true); + } - foreach ($files as $fileName => $file) { - file_put_contents("{$directory}/{$fileName}", $file->toString()); + file_put_contents($fullPath, $file->toString()); } } @@ -70,7 +71,7 @@ public static function write(string $directory, array $files): void * The check the writer makes unnecessary, without touching the directory: every named file * exists with exactly the generated bytes, and no other TypeScript file is present. * - * @param array $files Keys are paths relative to the directory. + * @param array $files Keys are paths relative to the directory. * @return list Empty when the directory matches. One human readable issue per problem. */ public static function verify(string $directory, array $files): array @@ -79,7 +80,7 @@ public static function verify(string $directory, array $files): array foreach ($files as $fileName => $file) { $filePath = "{$directory}/{$fileName}"; - if (! file_exists($filePath)) { + if (!file_exists($filePath)) { $issues[] = "File {$fileName} is missing."; continue; @@ -93,7 +94,7 @@ public static function verify(string $directory, array $files): array // A removed operation leaves its module behind. Comparing content alone reports that // directory as correct, which is exactly the drift this is meant to catch. foreach (self::existingFileNames($directory) as $fileName) { - if (! array_key_exists($fileName, $files)) { + if (!array_key_exists($fileName, $files)) { $issues[] = "File {$fileName} is not generated anymore and should be deleted."; } } @@ -127,12 +128,12 @@ private static function existingFileNames(string $directory): array /** @var SplFileInfo $file */ foreach ($iterator as $file) { - if ($file->isDir() || ! str_ends_with($file->getBasename(), '.ts')) { + if ($file->isDir() || !str_ends_with($file->getBasename(), '.ts')) { continue; } $realPath = $file->getRealPath(); - if ($realPath === false || ! self::isGeneratedFile($realPath)) { + if ($realPath === false || !self::isGeneratedFile($realPath)) { continue; } From c8e9f32b5f6c59daba7e7987476c524b6154a804 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 10 Aug 2026 15:35:47 +0200 Subject: [PATCH 075/101] Introduce `ClientFactory` for configurable client creation and update Laravel adapter - Added `ClientFactory` interface and default `OperationClientFactory` with `createClientFromHttpRequest` method. - Updated Laravel adapter to support configurable client creation via `client` config key. - Replaced inline client header logic with `ClientFactory` for improved flexibility and maintainability. - Enhanced documentation to detail the new `ClientFactory` configuration and default behavior. - Added comprehensive tests for `OperationClientFactory` to validate client selection logic. --- docs/client-directives.md | 3 +- docs/laravel.md | 30 ++++++++- .../Laravel/Contracts/ClientFactory.php | 18 ++++++ .../Laravel/LaravelHttpController.php | 20 ++---- .../Laravel/LaravelServiceProvider.php | 29 ++++++--- .../Laravel/OperationClientFactory.php | 27 ++++++++ src/Adapters/Laravel/config/config.php | 12 ++++ .../Laravel/LaravelHttpControllerTest.php | 64 ++++++++++++++++++- .../Laravel/OperationClientFactoryTest.php | 33 ++++++++++ 9 files changed, 207 insertions(+), 29 deletions(-) create mode 100644 src/Adapters/Laravel/Contracts/ClientFactory.php create mode 100644 src/Adapters/Laravel/OperationClientFactory.php create mode 100644 tests/Adapters/Laravel/OperationClientFactoryTest.php diff --git a/docs/client-directives.md b/docs/client-directives.md index a7ee627..457bf5d 100644 --- a/docs/client-directives.md +++ b/docs/client-directives.md @@ -31,7 +31,8 @@ a contract. `OperationSPAClient` is meant to be picked when the request carries `X-Client-Id: operations-spa` — exactly that header, exactly that value — and any other request gets a `NullClient` whose every method is a no-op, so handlers never need to know which kind is on the other end, and nothing warns when a directive goes nowhere. Choosing between them is the transport's -job; the core never inspects a request. The [Laravel adapter](laravel.md#requests) does it for you. +job; the core never inspects a request. The [Laravel adapter](laravel.md#requests) delegates that +choice to a `ClientFactory`, defaulting to exactly this header rule. ## The payload diff --git a/docs/laravel.md b/docs/laravel.md index 95077b7..4e91992 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -12,6 +12,7 @@ operation is see [operations](operations.md), for what the error categories mean - [Configuration](#configuration) - [Routes](#routes) - [Context](#context) +- [Client](#client) - [What the adapter decides for you](#what-the-adapter-decides-for-you) - [Artisan commands](#artisan-commands) - [Production](#production) @@ -52,6 +53,7 @@ php artisan operations:codegen resources/js/operations |---|---|---| | `discovery_path` | `app_path('Operations')` | Where operations are discovered. A string or a list of them. | | `context` | `null` | A `ContextFactory` class, building the `$context` every handler receives from the request. | +| `client` | `null` | A `ClientFactory` class, building the `Client` every handler receives from the request. `null` uses `OperationClientFactory`. | | `key.mode` | `obfuscate` | `obfuscate`, `plain`, or `custom` with `key.className`. | | `key.pepper` | `"none"` | Salt for `obfuscate` — the literal string `none`, not "no pepper". | | `key.className` | `null` | An `OperationKeyGenerator`, required for `custom` and ignored otherwise. | @@ -118,6 +120,31 @@ final class OperationContextFactory implements ContextFactory The class is resolved through the container, so it may take constructor arguments. Leave the config `null` and every handler receives `null` as its context. +## Client + +`client` names a class implementing `ClientFactory`, whose single method builds the `Client` every +handler and middleware receives — the collector for [client directives](client-directives.md): + +```php +use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ClientFactory; + +final class MobileAwareClientFactory implements ClientFactory +{ + public function createClientFromHttpRequest(Request $request): Client + { + return match ($request->header('X-Client-Id')) { + 'operations-spa' => new OperationSPAClient(), + 'mobile-app' => new MobileClient(), + default => new NullClient(), + }; + } +} +``` + +The class is resolved through the container, so it may take constructor arguments. Leave the config +`null` and the default `OperationClientFactory` applies: the header `X-Client-Id: operations-spa` +selects `OperationSPAClient`, every other request gets a `NullClient`. + ## What the adapter decides for you Everything the provider and the HTTP controller pick without asking. @@ -155,7 +182,8 @@ Everything the provider and the HTTP controller pick without asking. - Empty input of either kind becomes `null`. - **`X-Client-Id: operations-spa`** — exactly that value — selects `OperationSPAClient`. Every other request gets a `NullClient`, and [client directives](client-directives.md) go nowhere - without warning. The generated client sends the header on every call. + without warning. The generated client sends the header on every call. This is the default + `OperationClientFactory`; the [`client` config key](#client) replaces it. ### Responses diff --git a/src/Adapters/Laravel/Contracts/ClientFactory.php b/src/Adapters/Laravel/Contracts/ClientFactory.php new file mode 100644 index 0000000..8dd737d --- /dev/null +++ b/src/Adapters/Laravel/Contracts/ClientFactory.php @@ -0,0 +1,18 @@ +gatherInputFromRequest(OperationType::QUERY, $request), context: $this->contextFactory?->createContextFromHttpRequest($request), - client: $this->createClient($request), + client: $this->clientFactory->createClientFromHttpRequest($request), ) |> $this->reportExceptions(...) |> $this->produceJsonResponse(...); @@ -73,7 +70,7 @@ public function handleHttpCommandRequest(string $fqn, Http\Request $request): Js $fqn, input: $this->gatherInputFromRequest(OperationType::COMMAND, $request), context: $this->contextFactory?->createContextFromHttpRequest($request), - client: $this->createClient($request), + client: $this->clientFactory->createClientFromHttpRequest($request), ) |> $this->reportExceptions(...) |> $this->produceJsonResponse(...); @@ -93,15 +90,6 @@ private function reportExceptions(RpcResult $result): RpcResult return $result; } - private function createClient(Http\Request $request): Client - { - if ($request->header(self::CLIENT_ID_HEADER) === 'operations-spa') { - return new OperationSPAClient(); - } - - return new NullClient(); - } - /** * @return array|null */ diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index d482a36..6565bfa 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -13,6 +13,8 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\CodeGenCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\ListCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\OptimizeCommand; +use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ClientFactory; +use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; @@ -74,10 +76,10 @@ private static function keyGeneratorFrom(Application $app): OperationKeyGenerato private static function customKeyGenerator(Application $app, mixed $className): OperationKeyGenerator { - if (! is_string($className) || $className === '') { + if (!is_string($className) || $className === '') { throw new InvalidArgumentException( "operations.key.mode is 'custom', so operations.key.className must name a class " - .'implementing '.OperationKeyGenerator::class.'.' + . 'implementing ' . OperationKeyGenerator::class . '.' ); } @@ -85,9 +87,10 @@ private static function customKeyGenerator(Application $app, mixed $className): } public static function serverFactory( - Application $app, + Application $app, ?OperationRegistry $operations, - ): Server { + ): Server + { $config = $app->make('config'); $operations ??= EagerlyLoadedOperationRegistry::eagerlyDiscover( @@ -129,7 +132,7 @@ public function register(): void return self::serverFactory( $app, - $isRepositoryCached ? require (base_path('bootstrap/cache/operations.php')) : null + $isRepositoryCached ? require(base_path('bootstrap/cache/operations.php')) : null ); }); @@ -143,12 +146,20 @@ public function register(): void // We bind the default server to the default laravel Http Controller. $this->app->bind(LaravelHttpController::class, function (Application $app): LaravelHttpController { $config = $app->make('config'); - $context = $config->get('operations.context'); + + /** @var class-string $contextFactoryClassName */ + $contextFactoryClassName = $config->get('operations.context'); + + /** @var class-string|null $clientFactoryClassName */ + $clientFactoryClassName = $config->get('operations.client'); return new LaravelHttpController( $app->make(self::DEFAULT_SERVER), $app->make(ExceptionHandler::class), - $context ? $app->make($context) : null, + $contextFactoryClassName ? $app->make($contextFactoryClassName) : null, + $clientFactoryClassName === null + ? new OperationClientFactory() + : $app->make($clientFactoryClassName), $config->get('app.debug', false), ); }); @@ -160,11 +171,11 @@ public function register(): void public function boot(): void { $this->publishes([ - __DIR__.'/config/config.php' => config_path('operations.php'), + __DIR__ . '/config/config.php' => config_path('operations.php'), ]); $this->mergeConfigFrom( - __DIR__.'/config/config.php', + __DIR__ . '/config/config.php', 'operations' ); diff --git a/src/Adapters/Laravel/OperationClientFactory.php b/src/Adapters/Laravel/OperationClientFactory.php new file mode 100644 index 0000000..d6bdf89 --- /dev/null +++ b/src/Adapters/Laravel/OperationClientFactory.php @@ -0,0 +1,27 @@ +header(self::CLIENT_ID_HEADER) === 'operations-spa') { + return new OperationSPAClient(); + } + + return new NullClient(); + } +} diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index ab55b31..c7ea716 100644 --- a/src/Adapters/Laravel/config/config.php +++ b/src/Adapters/Laravel/config/config.php @@ -8,6 +8,7 @@ use Illuminate\Database\RecordNotFoundException; use Illuminate\Database\RecordsNotFoundException; use Illuminate\Session\TokenMismatchException; +use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ClientFactory; use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; @@ -27,6 +28,17 @@ */ 'context' => null, + /** + * Define a class name used to create the client for all operations. + * It must implement Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ClientFactory + * + * Null uses the OperationClientFactory: the header `X-Client-Id: operations-spa` + * selects the OperationSPAClient, everything else gets the NullClient. + * + * @see ClientFactory + */ + 'client' => null, + /** * Defines the ID length to use for the cache keys. Usually 10 is enough. If you face * collisions, increase the number diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index 9d0fc8f..5f01d7e 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -8,11 +8,14 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ClientFactory; use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; +use Le0daniel\PhpTsBindings\Adapters\Laravel\OperationClientFactory; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Adapters\PsrContainerAdapter; +use Le0daniel\PhpTsBindings\Server\Client\OperationSPAClient; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; use Le0daniel\PhpTsBindings\Server\Data\Operation; @@ -125,7 +128,7 @@ public function someMethod(array $input, null $context, Client $client): array $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); - $request->headers->set(LaravelHttpController::CLIENT_ID_HEADER, 'operations-spa'); + $request->headers->set(OperationClientFactory::CLIENT_ID_HEADER, 'operations-spa'); $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); $controller = new LaravelHttpController( @@ -376,7 +379,7 @@ public function someMethod(array $input, null $context, Client $client): array $exceptionHandler = Mockery::mock(ExceptionHandler::class); $app = Mockery::mock(Application::class); $request = Request::create('/query/docs.method', 'GET', ['name' => 'some_value']); - $request->headers->set(LaravelHttpController::CLIENT_ID_HEADER, 'operations-spa'); + $request->headers->set(OperationClientFactory::CLIENT_ID_HEADER, 'operations-spa'); $operationDefinition = new Definition(OperationType::QUERY, 'MyClass', 'someMethod', 'method', 'docs', []); $operation = new Operation( @@ -415,3 +418,60 @@ public function someMethod(array $input, null $context, Client $client): array 'type' => 'INTERNAL_ERROR', ]); }); + +test('a custom client factory decides the client, not the header', function () { + $fcn = 'docs.method'; + + $typeParser = new TypeParser(); + $operationRegistry = Mockery::mock(OperationRegistry::class); + $exceptionHandler = Mockery::mock(ExceptionHandler::class); + $app = Mockery::mock(Application::class); + // No X-Client-Id header: the default factory would pick the NullClient here. + $request = Request::create('/query/docs.method', 'GET', ['name' => 'some_value']); + + $operationDefinition = new Definition(OperationType::QUERY, 'MyClass', 'someMethod', 'method', 'docs', []); + $operation = new Operation( + 'somekey', + $operationDefinition, + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), + ); + + $controllerInstance = new class () { + public function someMethod(array $input, null $context, Client $client): array + { + $client->success('Saved'); + + return ['id' => '123', 'name' => $input['name']]; + } + }; + + $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); + $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); + $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); + + $clientFactory = new class () implements ClientFactory { + public function createClientFromHttpRequest(Request $request): Client + { + return new OperationSPAClient(); + } + }; + + $response = new LaravelHttpController( + new Server($operationRegistry, new PsrContainerAdapter(container: $app)), + $exceptionHandler, + null, + clientFactory: $clientFactory, + )->handleHttpQueryRequest($fcn, $request); + + expect($response->getData(true))->toEqual([ + 'success' => true, + 'data' => ['id' => '123', 'name' => 'some_value'], + '__client' => [ + 'toasts' => [ + ['type' => 'success', 'message' => 'Saved'], + ], + 'type' => 'operations-spa', + ], + ]); +}); diff --git a/tests/Adapters/Laravel/OperationClientFactoryTest.php b/tests/Adapters/Laravel/OperationClientFactoryTest.php new file mode 100644 index 0000000..9d8c25d --- /dev/null +++ b/tests/Adapters/Laravel/OperationClientFactoryTest.php @@ -0,0 +1,33 @@ +headers->set(OperationClientFactory::CLIENT_ID_HEADER, 'operations-spa'); + + expect(new OperationClientFactory()->createClientFromHttpRequest($request)) + ->toBeInstanceOf(OperationSPAClient::class); +}); + +test('a request without a client id gets the NullClient', function () { + $request = Request::create('/query/docs.method', 'GET'); + + expect(new OperationClientFactory()->createClientFromHttpRequest($request)) + ->toBeInstanceOf(NullClient::class); +}); + +test('an unknown client id gets the NullClient', function () { + $request = Request::create('/query/docs.method', 'GET'); + $request->headers->set(OperationClientFactory::CLIENT_ID_HEADER, 'operations-spa-2'); + + expect(new OperationClientFactory()->createClientFromHttpRequest($request)) + ->toBeInstanceOf(NullClient::class); +}); From 216905164d379e0538f3c8d017c8ba30b1682e07 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Mon, 10 Aug 2026 16:54:40 +0200 Subject: [PATCH 076/101] Standardize method formatting in `LaravelServiceProvider::serverFactory` definition --- src/Adapters/Laravel/LaravelServiceProvider.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 6565bfa..50f5c47 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -89,8 +89,7 @@ private static function customKeyGenerator(Application $app, mixed $className): public static function serverFactory( Application $app, ?OperationRegistry $operations, - ): Server - { + ): Server { $config = $app->make('config'); $operations ??= EagerlyLoadedOperationRegistry::eagerlyDiscover( From 472037f4eac541556e9ad0110f1244d79decd15a Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 11 Aug 2026 11:39:11 +0200 Subject: [PATCH 077/101] Add extensive unit tests and mock classes for error handling and domain error classifications - Implemented `ErrorClassifier` for precise error categorization. - Added tests for `ThrowAttributeResolver` and `ErrorClassifier`, validating cases like authentication, authorization, domain errors, and fallback logic. - Introduced diverse mock classes (`RequiresLoginInterface`, `SessionExpiredException`, `NamedDomainExposedException`, etc.) for error handling scenarios. - Refactored `Throws` and `ExposeAs` attributes to improve validation and type distinction (e.g., `ErrorType`, `name` support). - Standardized naming logic in middleware and improved domain error handling declarations. - Updated documentation and comments for new error handling behavior. --- src/Contracts/Attributes/ExposeAs.php | 26 +- src/Contracts/Attributes/Throws.php | 71 ++++-- src/Server/Errors/ErrorClassifier.php | 56 +++++ src/Server/Errors/ExposedExceptions.php | 2 +- src/Server/Errors/ThrowAttributeResolver.php | 127 ++++++++++ .../Mocks/GloballyThrowingMiddleware.php | 2 +- tests/Mocks/Errors/ErrorOperations.php | 4 +- tests/Mocks/Errors/RenamingMiddleware.php | 6 +- .../Server/Errors/ErrorClassifierTest.php | 128 ++++++++++ .../Mocks/DuplicateNamingMiddleware.php | 19 ++ .../Errors/Mocks/InvalidExposeAsException.php | 16 ++ .../Mocks/NamedDomainExposedException.php | 16 ++ .../Server/Errors/Mocks/NamingMiddleware.php | 19 ++ .../Errors/Mocks/NotFoundExposedException.php | 17 ++ .../Errors/Mocks/RequiresLoginInterface.php | 12 + .../Errors/Mocks/SessionExpiredException.php | 14 ++ .../Errors/Mocks/ThrowResolverOperations.php | 97 ++++++++ .../Errors/Mocks/UnauthenticatedException.php | 14 ++ .../Errors/Mocks/UnauthorizedException.php | 14 ++ .../Errors/Mocks/UnnamedTypeMiddleware.php | 20 ++ .../Errors/ThrowAttributeResolverTest.php | 230 ++++++++++++++++++ 21 files changed, 881 insertions(+), 29 deletions(-) create mode 100644 src/Server/Errors/ErrorClassifier.php create mode 100644 src/Server/Errors/ThrowAttributeResolver.php create mode 100644 tests/Unit/Server/Errors/ErrorClassifierTest.php create mode 100644 tests/Unit/Server/Errors/Mocks/DuplicateNamingMiddleware.php create mode 100644 tests/Unit/Server/Errors/Mocks/InvalidExposeAsException.php create mode 100644 tests/Unit/Server/Errors/Mocks/NamedDomainExposedException.php create mode 100644 tests/Unit/Server/Errors/Mocks/NamingMiddleware.php create mode 100644 tests/Unit/Server/Errors/Mocks/NotFoundExposedException.php create mode 100644 tests/Unit/Server/Errors/Mocks/RequiresLoginInterface.php create mode 100644 tests/Unit/Server/Errors/Mocks/SessionExpiredException.php create mode 100644 tests/Unit/Server/Errors/Mocks/ThrowResolverOperations.php create mode 100644 tests/Unit/Server/Errors/Mocks/UnauthenticatedException.php create mode 100644 tests/Unit/Server/Errors/Mocks/UnauthorizedException.php create mode 100644 tests/Unit/Server/Errors/Mocks/UnnamedTypeMiddleware.php create mode 100644 tests/Unit/Server/Errors/ThrowAttributeResolverTest.php diff --git a/src/Contracts/Attributes/ExposeAs.php b/src/Contracts/Attributes/ExposeAs.php index b15ce6d..5b9f6a1 100644 --- a/src/Contracts/Attributes/ExposeAs.php +++ b/src/Contracts/Attributes/ExposeAs.php @@ -5,18 +5,30 @@ namespace Le0daniel\PhpTsBindings\Contracts\Attributes; use Attribute; +use Le0daniel\PhpTsBindings\Server\Data\ErrorType; -/** - * Marks an exception as Exposable to the client, under the given type name. - * - * This is the exception's own name, used by every operation that declares it via #[Throws]. A - * #[Throws(..., as: ...)] naming it at the declaration site overrides this one. - */ #[Attribute(Attribute::TARGET_CLASS)] final readonly class ExposeAs { public function __construct( - public string $type + public ErrorType $type = ErrorType::DOMAIN_ERROR, + public ?string $name = null, ) { } + + public function isValid(): bool + { + if ( + ($this->type === ErrorType::DOMAIN_ERROR && $this->name === null) || + ($this->type !== ErrorType::DOMAIN_ERROR && $this->name !== null) + ) { + return false; + } + + if ($this->type === ErrorType::INVALID_INPUT) { + return false; + } + + return true; + } } diff --git a/src/Contracts/Attributes/Throws.php b/src/Contracts/Attributes/Throws.php index 30e9bea..ec18411 100644 --- a/src/Contracts/Attributes/Throws.php +++ b/src/Contracts/Attributes/Throws.php @@ -5,28 +5,69 @@ namespace Le0daniel\PhpTsBindings\Contracts\Attributes; use Attribute; +use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use ReflectionClass; use Throwable; -/** - * Used to declare which exceptions an endpoint can throw. If no exception is explicitly declared, - * a 500 Internal Server error is returned to the client. - * - * A declared exception is only exposed to the client once it has a name to be exposed under. That - * name comes from `as`, or - when `as` is omitted - from the ExposeAs attribute on the exception - * class itself. `as` always wins, and exposes the exception whether or not its class carries - * ExposeAs: the exception may be one you cannot annotate, or one worth naming differently here. - * An exception with neither stays a 500. - */ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final readonly class Throws { + public ?ErrorType $type; + /** - * @param class-string $exceptionClass - * @param non-empty-string|null $as + * @param class-string $exceptionClass + * @param non-empty-string|null $name */ public function __construct( - public string $exceptionClass, - public ?string $as = null, - ) { + public string $exceptionClass, + ?ErrorType $type = null, + public ?string $name = null, + ) + { + $this->type = $type ?? ($this->name ? ErrorType::DOMAIN_ERROR : null); + } + + /** + * @internal + */ + public function requiresThrowableReflection(): bool + { + return $this->type === null; + } + + /** + * @internal + */ + public function getExposedAsOrNullThroughReflection(): ExposeAs|null + { + /** @var ReflectionClass $reflection */ + $reflection = new ReflectionClass($this->exceptionClass); + $attribute = $reflection->getAttributes(ExposeAs::class); + if (count($attribute) !== 1) { + return null; + } + + /** @var ExposeAs $instance */ + $instance = $attribute[0]->newInstance(); + return $instance; + } + + /** + * @internal + */ + public function isValid(): bool + { + if ( + ($this->type === ErrorType::DOMAIN_ERROR && $this->name === null) || + ($this->type !== ErrorType::DOMAIN_ERROR && $this->name !== null) + ) { + return false; + } + + if ($this->type === ErrorType::INVALID_INPUT) { + return false; + } + + return true; } } diff --git a/src/Server/Errors/ErrorClassifier.php b/src/Server/Errors/ErrorClassifier.php new file mode 100644 index 0000000..fc31d35 --- /dev/null +++ b/src/Server/Errors/ErrorClassifier.php @@ -0,0 +1,56 @@ + $authenticationExceptions + * @param list $authorizationExceptions + * @param list $notFoundExceptions + */ + public function __construct( + public array $authenticationExceptions, + public array $authorizationExceptions, + public array $notFoundExceptions, + ) + { + } + + /** + * @param Throwable|class-string $exception + * @return ErrorType + */ + public function classify(Throwable|string $exception): ErrorType + { + $className = is_string($exception) ? $exception : $exception::class; + + return match (true) { + // Exact match for invalid input + $className === InvalidInputException::class => ErrorType::INVALID_INPUT, + $className === OperationNotFoundException::class => ErrorType::NOT_FOUND, + $this->matchesAny($className, $this->authenticationExceptions) => ErrorType::AUTHENTICATION_ERROR, + $this->matchesAny($className, $this->authorizationExceptions) => ErrorType::AUTHORIZATION_ERROR, + $this->matchesAny($className, $this->notFoundExceptions) => ErrorType::NOT_FOUND, + default => ErrorType::INTERNAL_ERROR, + }; + } + + /** + * @param class-string $className + * @param list $exceptions + * @return bool + */ + private function matchesAny(string $className, array $exceptions): bool + { + return array_any( + $exceptions, + static fn($exception) => is_a($className, $exception, true) + ); + } +} \ No newline at end of file diff --git a/src/Server/Errors/ExposedExceptions.php b/src/Server/Errors/ExposedExceptions.php index 02e3d7a..3e31d38 100644 --- a/src/Server/Errors/ExposedExceptions.php +++ b/src/Server/Errors/ExposedExceptions.php @@ -66,7 +66,7 @@ public static function declaredFor(Definition $definition, ServerConfiguration $ // The first name given wins. The operation is reflected before the middleware wrapping // it, so an operation states its own contract first; and because only a name displaces // null, a bare #[Throws] never silences an `as` declared elsewhere for the same class. - $declared[$throws->exceptionClass] ??= $throws->as ?? self::exposeAsOf($throws->exceptionClass); + $declared[$throws->exceptionClass] ??= $throws->name ?? self::exposeAsOf($throws->exceptionClass); } return $declared; diff --git a/src/Server/Errors/ThrowAttributeResolver.php b/src/Server/Errors/ThrowAttributeResolver.php new file mode 100644 index 0000000..53c6a9a --- /dev/null +++ b/src/Server/Errors/ThrowAttributeResolver.php @@ -0,0 +1,127 @@ + + * @throws ReflectionException + */ + public static function collectDomainErrorNamesFromDefinition( + Definition $definition, + ): array + { + $reflections = [ + new ReflectionMethod($definition->fullyQualifiedClassName, $definition->methodName), + ... array_map(static fn($className) => new ReflectionMethod($className, 'handle'), $definition->middleware), + ]; + + $names = []; + foreach ($reflections as $reflection) { + $data = self::resolveReflection($reflection, allowDomainErrors: true)['data']; + foreach ($data as $exceptionDeclaration) { + if (isset($exceptionDeclaration['name'])) { + $names[] = $exceptionDeclaration['name']; + } + } + } + + return $names |> Lists::unique(...); + } + + /** + * @param ReflectionClass|ReflectionMethod $reflection + * @param bool $allowDomainErrors + * @return array{data: array, issues: list} + */ + public static function resolveReflection( + ReflectionClass|ReflectionMethod $reflection, + bool $allowDomainErrors, + ): array + { + $issues = []; + $exceptions = []; + + $throwing = self::throwableAttributes($reflection); + + foreach ($throwing as $throws) { + if (array_key_exists($throws->exceptionClass, $exceptions)) { + $issues[] = "Exception ({$throws->exceptionClass}) is already declared."; + continue; + } + + if (!$throws->isValid()) { + $issues[] = "#[Throw] attribute declaration is not valid."; + continue; + } + + // Retrieve the correct declaration + $declaration = $throws->requiresThrowableReflection() + ? $throws->getExposedAsOrNullThroughReflection() + : $throws; + + if (!$declaration) { + $issues[] = "#[ExposeAs] not present on thrown class: {$throws->exceptionClass}."; + continue; + } + + if (!$declaration->isValid()) { + $attributeName = $declaration::class + |> (static fn($name) => explode('\\', $name)) + |> array_last(...); + + $issues[] = "#[{$attributeName}] attribute declaration is not valid."; + continue; + } + + // Always ignore InvalidInput + if ($declaration->type === null || $declaration->type === ErrorType::INVALID_INPUT) { + $issues[] = "A declaration of type '{$declaration->type?->name}' is not valid."; + continue; + } + + // Always ignore DomainError if not allowed + if ($declaration->type === ErrorType::DOMAIN_ERROR && !$allowDomainErrors) { + $issues[] = "Domain errors not allowed in this scope."; + continue; + } + + $definition = ['type' => $declaration->type]; + if ($declaration->name) { + $definition['name'] = $declaration->name; + } + $exceptions[$throws->exceptionClass] = $definition; + } + + return [ + "data" => $exceptions, + "issues" => $issues, + ]; + } + + /** + * @param ReflectionClass|ReflectionMethod $reflection + * @return list + */ + private static function throwableAttributes( + ReflectionClass|ReflectionMethod $reflection, + ): array + { + $attributes = $reflection->getAttributes(Throws::class); + return array_map( + static fn(ReflectionAttribute $attribute): Throws => $attribute->newInstance(), + $attributes, + ); + } +} \ No newline at end of file diff --git a/tests/Feature/Mocks/GloballyThrowingMiddleware.php b/tests/Feature/Mocks/GloballyThrowingMiddleware.php index 988006b..b6efdaf 100644 --- a/tests/Feature/Mocks/GloballyThrowingMiddleware.php +++ b/tests/Feature/Mocks/GloballyThrowingMiddleware.php @@ -20,7 +20,7 @@ */ final class GloballyThrowingMiddleware implements MiddlewareContract { - #[Throws(GlobalMiddlewareException::class, as: 'global_middleware_failed')] + #[Throws(GlobalMiddlewareException::class, name: 'global_middleware_failed')] public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { if (is_array($input) && ($input['name'] ?? null) === 'global-boom') { diff --git a/tests/Mocks/Errors/ErrorOperations.php b/tests/Mocks/Errors/ErrorOperations.php index acebe93..75e4214 100644 --- a/tests/Mocks/Errors/ErrorOperations.php +++ b/tests/Mocks/Errors/ErrorOperations.php @@ -22,8 +22,8 @@ public function declaresThrows(): void * Naming at the declaration site: UnexposedException carries no #[ExposeAs] and is exposed * anyway, while ExposedDomainException's own name is overridden. */ - #[Throws(UnexposedException::class, as: 'renamed_failure')] - #[Throws(ExposedDomainException::class, as: 'overridden_failure')] + #[Throws(UnexposedException::class, name: 'renamed_failure')] + #[Throws(ExposedDomainException::class, name: 'overridden_failure')] public function declaresRenamedThrows(): void { } diff --git a/tests/Mocks/Errors/RenamingMiddleware.php b/tests/Mocks/Errors/RenamingMiddleware.php index 7487c11..1a9dfb0 100644 --- a/tests/Mocks/Errors/RenamingMiddleware.php +++ b/tests/Mocks/Errors/RenamingMiddleware.php @@ -20,9 +20,9 @@ */ final class RenamingMiddleware implements MiddlewareContract { - #[Throws(MiddlewareDomainException::class, as: 'renamed_middleware_failure')] - #[Throws(ExposedDomainException::class, as: 'middleware_name')] - #[Throws(UnexposedException::class, as: 'middleware_named_it')] + #[Throws(MiddlewareDomainException::class, name: 'renamed_middleware_failure')] + #[Throws(ExposedDomainException::class, name: 'middleware_name')] + #[Throws(UnexposedException::class, name: 'middleware_named_it')] public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { return $next($input); diff --git a/tests/Unit/Server/Errors/ErrorClassifierTest.php b/tests/Unit/Server/Errors/ErrorClassifierTest.php new file mode 100644 index 0000000..c655110 --- /dev/null +++ b/tests/Unit/Server/Errors/ErrorClassifierTest.php @@ -0,0 +1,128 @@ + $exception + */ +function classifyError(Throwable|string $exception): ErrorType +{ + return new ErrorClassifier( + authenticationExceptions: [UnauthenticatedException::class, RequiresLoginInterface::class], + authorizationExceptions: [UnauthorizedException::class], + notFoundExceptions: [RecordMissingException::class], + )->classify($exception); +} + +function invalidInputException(): InvalidInputException +{ + return new InvalidInputException(new Failure(Issues::fromMessages(['name' => 'Is required']))); +} + +test('an InvalidInputException instance is classified as invalid input without any configuration', function () { + $classifier = new ErrorClassifier([], [], []); + + expect($classifier->classify(invalidInputException()))->toBe(ErrorType::INVALID_INPUT); +}); + +test('the InvalidInputException class-string is classified as invalid input without any configuration', function () { + $classifier = new ErrorClassifier([], [], []); + + expect($classifier->classify(InvalidInputException::class))->toBe(ErrorType::INVALID_INPUT); +}); + +test('invalid input wins even when InvalidInputException is listed in a configured category', function () { + $classifier = new ErrorClassifier([], [], [InvalidInputException::class]); + + expect($classifier->classify(invalidInputException()))->toBe(ErrorType::INVALID_INPUT); +}); + +test('a configured authentication exception is classified as an authentication error', function (Throwable|string $exception) { + expect(classifyError($exception))->toBe(ErrorType::AUTHENTICATION_ERROR); +})->with([ + 'instance' => [new UnauthenticatedException()], + 'class-string' => [UnauthenticatedException::class], +]); + +test('an exception implementing a configured interface matches through that interface', function () { + expect(classifyError(new SessionExpiredException()))->toBe(ErrorType::AUTHENTICATION_ERROR); +}); + +test('a configured authorization exception is classified as an authorization error', function () { + expect(classifyError(new UnauthorizedException()))->toBe(ErrorType::AUTHORIZATION_ERROR); +}); + +test('a configured not found exception is classified as not found', function () { + expect(classifyError(new RecordMissingException()))->toBe(ErrorType::NOT_FOUND); +}); + +test('a subclass of a configured exception matches its category', function () { + expect(classifyError(new UserMissingException()))->toBe(ErrorType::NOT_FOUND); +}); + +test('an exception not present in any list falls back to an internal error', function (Throwable $exception) { + expect(classifyError($exception))->toBe(ErrorType::INTERNAL_ERROR); +})->with([ + 'unlisted exception' => [new UnexposedException()], + 'runtime exception' => [new RuntimeException('Something failed')], +]); + +test('with no configured lists every regular exception is an internal error', function () { + $classifier = new ErrorClassifier([], [], []); + + expect($classifier->classify(new RuntimeException('Something failed')))->toBe(ErrorType::INTERNAL_ERROR); +}); + +test('authentication wins when a class is configured as both authentication and authorization', function () { + $classifier = new ErrorClassifier( + [UnauthenticatedException::class], + [UnauthenticatedException::class], + [], + ); + + expect($classifier->classify(new UnauthenticatedException()))->toBe(ErrorType::AUTHENTICATION_ERROR); +}); + +test('authorization wins when a class is configured as both authorization and not found', function () { + $classifier = new ErrorClassifier( + [], + [UnauthorizedException::class], + [UnauthorizedException::class], + ); + + expect($classifier->classify(new UnauthorizedException()))->toBe(ErrorType::AUTHORIZATION_ERROR); +}); + +test('listing a subclass does not cover its parent class', function () { + $classifier = new ErrorClassifier([], [], [UserMissingException::class]); + + expect($classifier->classify(new RecordMissingException()))->toBe(ErrorType::INTERNAL_ERROR); +}); + +test('an unknown class-string never throws and falls back to an internal error', function () { + // @phpstan-ignore-next-line -- tests intentionally pass a class that does not exist. + expect(classifyError('App\DoesNotExist'))->toBe(ErrorType::INTERNAL_ERROR); +}); + +test('an OperationNotFoundException is classified as not found without any configuration', function (Throwable|string $exception) { + $classifier = new ErrorClassifier([], [], []); + + expect($classifier->classify($exception))->toBe(ErrorType::NOT_FOUND); +})->with([ + 'instance' => [new OperationNotFoundException('Operation not found')], + 'class-string' => [OperationNotFoundException::class], +]); diff --git a/tests/Unit/Server/Errors/Mocks/DuplicateNamingMiddleware.php b/tests/Unit/Server/Errors/Mocks/DuplicateNamingMiddleware.php new file mode 100644 index 0000000..a349a2c --- /dev/null +++ b/tests/Unit/Server/Errors/Mocks/DuplicateNamingMiddleware.php @@ -0,0 +1,19 @@ +, issues: list} + */ +function resolveThrows(string $method, bool $allowDomainErrors = true): array +{ + return ThrowAttributeResolver::resolveReflection( + new ReflectionMethod(ThrowResolverOperations::class, $method), + $allowDomainErrors, + ); +} + +/** + * @param list $middleware + * @return list + */ +function domainErrorNamesFor(string $methodName, array $middleware = []): array +{ + return ThrowAttributeResolver::collectDomainErrorNamesFromDefinition(new Definition( + OperationType::COMMAND, + ThrowResolverOperations::class, + $methodName, + 'test', + 'errors', + // @phpstan-ignore-next-line -- tests intentionally pass classes that only carry a handle method. + $middleware, + )); +} + +test('a method without Throws attributes resolves to nothing', function () { + expect(resolveThrows('declaresNothing'))->toBe(['data' => [], 'issues' => []]); +}); + +test('a ReflectionClass is accepted and resolves to nothing, Throws only targets methods', function () { + $result = ThrowAttributeResolver::resolveReflection( + new ReflectionClass(ThrowResolverOperations::class), + true, + ); + + expect($result)->toBe(['data' => [], 'issues' => []]); +}); + +test('a Throws with an explicit type resolves to that type without a name', function () { + expect(resolveThrows('declaresExplicitNotFound'))->toBe([ + 'data' => [UnexposedException::class => ['type' => ErrorType::NOT_FOUND]], + 'issues' => [], + ]); +}); + +test('a Throws with only a name resolves to a named domain error', function () { + expect(resolveThrows('declaresNamedDomainError'))->toBe([ + 'data' => [UnexposedException::class => ['type' => ErrorType::DOMAIN_ERROR, 'name' => 'direct_name']], + 'issues' => [], + ]); +}); + +test('a Throws with an explicit domain type and a name keeps both', function () { + expect(resolveThrows('declaresExplicitDomainWithName'))->toBe([ + 'data' => [UnexposedException::class => ['type' => ErrorType::DOMAIN_ERROR, 'name' => 'explicit_domain']], + 'issues' => [], + ]); +}); + +test('a bare Throws takes its type from the ExposeAs on the exception', function () { + expect(resolveThrows('declaresViaExposeAsNotFound'))->toBe([ + 'data' => [NotFoundExposedException::class => ['type' => ErrorType::NOT_FOUND]], + 'issues' => [], + ]); +}); + +test('a bare Throws takes the name from the ExposeAs on the exception', function () { + expect(resolveThrows('declaresViaExposeAsNamedDomain'))->toBe([ + 'data' => [NamedDomainExposedException::class => ['type' => ErrorType::DOMAIN_ERROR, 'name' => 'exposed_domain_name']], + 'issues' => [], + ]); +}); + +test('a bare Throws on an exception without ExposeAs is reported', function () { + expect(resolveThrows('declaresUnexposed'))->toBe([ + 'data' => [], + 'issues' => ['#[ExposeAs] not present on thrown class: ' . UnexposedException::class . '.'], + ]); +}); + +test('a bare Throws on an exception with an invalid ExposeAs is reported', function () { + expect(resolveThrows('declaresInvalidExposeAs'))->toBe([ + 'data' => [], + 'issues' => ['#[ExposeAs] attribute declaration is not valid.'], + ]); +}); + +test('a domain error declaration is rejected when domain errors are not allowed', function (string $method) { + expect(resolveThrows($method, allowDomainErrors: false))->toBe([ + 'data' => [], + 'issues' => ['Domain errors not allowed in this scope.'], + ]); +})->with([ + 'named directly on the Throws' => 'declaresNamedDomainError', + 'resolved through the ExposeAs on the exception' => 'declaresViaExposeAsNamedDomain', +]); + +test('non-domain declarations resolve even when domain errors are not allowed', function (string $method, string $exceptionClass) { + expect(resolveThrows($method, allowDomainErrors: false))->toBe([ + 'data' => [$exceptionClass => ['type' => ErrorType::NOT_FOUND]], + 'issues' => [], + ]); +})->with([ + 'explicit type' => ['declaresExplicitNotFound', UnexposedException::class], + 'type from ExposeAs' => ['declaresViaExposeAsNotFound', NotFoundExposedException::class], +]); + +test('an invalid Throws declaration is reported and not resolved', function (string $method) { + expect(resolveThrows($method))->toBe([ + 'data' => [], + 'issues' => ['#[Throw] attribute declaration is not valid.'], + ]); +})->with([ + 'a domain error without a name' => 'declaresDomainWithoutName', + 'a name on a non-domain type' => 'declaresNamedNotFound', + // INVALID_INPUT is already rejected by isValid(), so it surfaces the generic message. + 'the invalid input type' => 'declaresInvalidInput', +]); + +test('a duplicate declaration for the same exception is reported and the first one wins', function () { + expect(resolveThrows('declaresDuplicate'))->toBe([ + 'data' => [UnexposedException::class => ['type' => ErrorType::NOT_FOUND]], + 'issues' => ['Exception (' . UnexposedException::class . ') is already declared.'], + ]); +}); + +test('a failed declaration does not block a later one for the same exception', function () { + // The duplicate guard only tracks declarations that resolved: the invalid first + // attempt is reported, and the second is not a duplicate — the first successful wins. + expect(resolveThrows('declaresDuplicateAfterInvalid'))->toBe([ + 'data' => [UnexposedException::class => ['type' => ErrorType::NOT_FOUND]], + 'issues' => ['#[Throw] attribute declaration is not valid.'], + ]); +}); + +test('valid and invalid declarations on one method aggregate independently in declaration order', function () { + expect(resolveThrows('declaresMixed'))->toBe([ + 'data' => [ + UnexposedException::class => ['type' => ErrorType::NOT_FOUND], + NamedDomainExposedException::class => ['type' => ErrorType::DOMAIN_ERROR, 'name' => 'exposed_domain_name'], + ], + 'issues' => [ + '#[Throw] attribute declaration is not valid.', + '#[ExposeAs] not present on thrown class: ' . RecordMissingException::class . '.', + ], + ]); +}); + +test('a bare Throws pointing at a nonexistent class escapes as a ReflectionException', function () { + resolveThrows('declaresNonexistentClass'); +})->throws(ReflectionException::class); + +test('a definition declaring nothing collects no domain error names', function () { + expect(domainErrorNamesFor('declaresNothing'))->toBe([]); +}); + +test('a declaration without a name never contributes a domain error name', function () { + expect(domainErrorNamesFor('declaresExplicitNotFound'))->toBe([]); +}); + +test('a name declared directly on the Throws is collected', function () { + expect(domainErrorNamesFor('declaresNamedDomainError'))->toBe(['direct_name']); +}); + +test('a name resolved through the ExposeAs on the exception is collected', function () { + expect(domainErrorNamesFor('declaresViaExposeAsNamedDomain'))->toBe(['exposed_domain_name']); +}); + +test('only the named declarations of a mixed method are collected, its issues are discarded', function () { + expect(domainErrorNamesFor('declaresMixed'))->toBe(['exposed_domain_name']); +}); + +test('a definition whose declarations all fail collects nothing instead of erroring', function () { + expect(domainErrorNamesFor('declaresUnexposed'))->toBe([]); +}); + +test('a name silenced by the duplicate guard is not collected', function () { + // declaresDuplicate resolves to the first, unnamed declaration; the named duplicate lost. + expect(domainErrorNamesFor('declaresDuplicate'))->toBe([]); +}); + +test('names declared on a middleware handle method are collected', function () { + expect(domainErrorNamesFor('declaresNothing', [NamingMiddleware::class]))->toBe(['middleware_name']); +}); + +test('the operation names come before the middleware names', function () { + expect(domainErrorNamesFor('declaresNamedDomainError', [NamingMiddleware::class])) + ->toBe(['direct_name', 'middleware_name']); +}); + +test('middleware names are collected in middleware order', function () { + expect(domainErrorNamesFor('declaresNothing', [NamingMiddleware::class, DuplicateNamingMiddleware::class])) + ->toBe(['middleware_name', 'direct_name']) + ->and(domainErrorNamesFor('declaresNothing', [DuplicateNamingMiddleware::class, NamingMiddleware::class])) + ->toBe(['direct_name', 'middleware_name']); +}); + +test('a name shared between the operation and a middleware appears once', function () { + // DuplicateNamingMiddleware repeats the operation's 'direct_name'; the list stays deduplicated and re-indexed. + expect(domainErrorNamesFor('declaresNamedDomainError', [DuplicateNamingMiddleware::class, NamingMiddleware::class])) + ->toBe(['direct_name', 'middleware_name']); +}); + +test('a middleware declaring an unnamed type contributes no domain error name', function () { + expect(domainErrorNamesFor('declaresNothing', [UnnamedTypeMiddleware::class]))->toBe([]); +}); + +test('a nonexistent middleware class escapes as a ReflectionException', function () { + domainErrorNamesFor('declaresNothing', ['Tests\Unit\Server\Errors\Mocks\DoesNotExist']); +})->throws(ReflectionException::class); From c823f492e8f0eb10b8a0749febc2607729bdc8ed Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Tue, 11 Aug 2026 14:40:39 +0200 Subject: [PATCH 078/101] Remove `ErrorPresenter` and `ExposedExceptions` classes, along with related tests, and introduce new error handling setup for scope-specific declarations and improved domain error categorization. --- README.md | 39 +-- docs/errors.md | 78 ++++-- docs/operations.md | 15 +- src/Adapters/Laravel/config/config.php | 10 +- .../EmitOperationClientBindings.php | 2 +- src/CodeGen/CodeGenerators/EmitTypes.php | 61 ++-- src/CodeGen/TypescriptServerCodeGenerator.php | 23 +- src/CodeGen/Utils/ErrorTypescript.php | 164 +---------- src/Contracts/Attributes/Throws.php | 3 +- .../Data/Exceptions/InvalidInputException.php | 2 +- src/Server/Data/RpcError.php | 6 +- src/Server/Errors/ErrorClassifier.php | 11 +- src/Server/Errors/ErrorPresenter.php | 133 --------- src/Server/Errors/ExceptionScope.php | 33 +++ src/Server/Errors/ExposedExceptions.php | 102 ------- src/Server/Errors/ThrowAttributeResolver.php | 21 +- src/Server/Pipeline/ContextualPipeline.php | 39 ++- src/Server/Server.php | 191 +++++++++---- .../Laravel/LaravelHttpControllerTest.php | 21 +- .../ErrorHandling/ConflictException.php | 15 + .../DecoyDeclaringMiddleware.php | 28 ++ .../ErrorHandling/ErrorScopeOperations.php | 104 +++++++ .../ErrorHandling/ForbiddenException.php | 11 + tests/Feature/ErrorHandling/GoneException.php | 11 + .../ErrorHandling/MiddlewareOwnException.php | 11 + .../MissingResourceException.php | 11 + .../ErrorHandling/SelfDeclaringMiddleware.php | 27 ++ .../ErrorHandling/SessionExpiredException.php | 11 + .../Feature/ErrorHandling/SharedException.php | 15 + .../Feature/ErrorHandling/TeapotException.php | 11 + .../ErrorHandling/TokenExpiredException.php | 13 + .../UndeclaredThrowingMiddleware.php | 26 ++ .../Mocks/GloballyThrowingMiddleware.php | 6 +- .../Operations/InvalidNameException.php | 2 +- tests/Feature/ServerErrorHandlingTest.php | 119 ++++++++ tests/Feature/ServerTest.php | 41 ++- tests/Mocks/Errors/ExposedDomainException.php | 2 +- .../Errors/MiddlewareDomainException.php | 2 +- .../Errors/UndeclaredExposedException.php | 2 +- tests/Unit/CodeGen/EmitTypesTest.php | 72 +++-- tests/Unit/CodeGen/ErrorTypescriptTest.php | 158 +---------- .../TsOutput/Types/AccountLockedException.php | 2 +- .../TsOutput/Types/QuotaExceededException.php | 2 +- .../TypescriptServerCodeGeneratorTest.php | 29 +- tests/Unit/Server/Data/RpcErrorTest.php | 8 +- .../Unit/Server/Errors/ErrorPresenterTest.php | 264 ------------------ .../Pipeline/ContextualPipelineTest.php | 70 ++++- .../generated/lib/OperationException.ts | 2 +- tests/ts-output/generated/lib/types.ts | 4 +- tests/ts-output/src/usage.ts | 6 +- 50 files changed, 956 insertions(+), 1083 deletions(-) delete mode 100644 src/Server/Errors/ErrorPresenter.php create mode 100644 src/Server/Errors/ExceptionScope.php delete mode 100644 src/Server/Errors/ExposedExceptions.php create mode 100644 tests/Feature/ErrorHandling/ConflictException.php create mode 100644 tests/Feature/ErrorHandling/DecoyDeclaringMiddleware.php create mode 100644 tests/Feature/ErrorHandling/ErrorScopeOperations.php create mode 100644 tests/Feature/ErrorHandling/ForbiddenException.php create mode 100644 tests/Feature/ErrorHandling/GoneException.php create mode 100644 tests/Feature/ErrorHandling/MiddlewareOwnException.php create mode 100644 tests/Feature/ErrorHandling/MissingResourceException.php create mode 100644 tests/Feature/ErrorHandling/SelfDeclaringMiddleware.php create mode 100644 tests/Feature/ErrorHandling/SessionExpiredException.php create mode 100644 tests/Feature/ErrorHandling/SharedException.php create mode 100644 tests/Feature/ErrorHandling/TeapotException.php create mode 100644 tests/Feature/ErrorHandling/TokenExpiredException.php create mode 100644 tests/Feature/ErrorHandling/UndeclaredThrowingMiddleware.php create mode 100644 tests/Feature/ServerErrorHandlingTest.php delete mode 100644 tests/Unit/Server/Errors/ErrorPresenterTest.php diff --git a/README.md b/README.md index 00b6e68..06b3a6e 100644 --- a/README.md +++ b/README.md @@ -263,17 +263,22 @@ is proven before your handler sees it. Output is your own code, so a mismatch is something the client is asked to handle — and refinements are checked on the way in only, because static analysis already established them on the way out. -**`query()` and `command()` are total.** Every `Throwable` — including one thrown while resolving -your handler, or while working out how to present another error — comes back as an `RpcError`. -`$next()` inside a middleware never throws either, so post-processing runs whether the operation -succeeded or failed. A transport never needs a `try`. +**`query()` and `command()` return an `RpcError` for every failure of your operation.** An +exception from a handler or middleware — including one thrown while resolving your handler — comes +back as an `RpcError`, and `$next()` inside a middleware never throws, so post-processing runs +whether the operation succeeded or failed. The one thing that escapes as an exception is a failure +of error presentation itself (a stale class name failing reflection): that is a bug in the setup, +not a request, and burying it in a substitute 500 would only hide it. **Six error categories, and nothing is exposed by accident.** Surfacing a domain error takes a `#[Throws]` declaration *and* a name; everything unrecognised is a 500. The category list is closed on purpose — it is what the server needs to run, not an extension point. -**Runtime and codegen read the same attributes.** `ErrorPresenter` and the TypeScript error union -consult one source, so the generated union cannot describe responses the server does not produce. +**Runtime and codegen read the same attributes.** The server and the TypeScript error union consult +one source — the `#[Throws]` declarations resolved per scope — so the generated union cannot +describe responses the server does not produce. A declaration covers throws from its own scope only: +the operation method, or the middleware that declared it. A middleware registered globally through +`ServerConfiguration` cannot expose domain errors at all. **Codegen is a build step.** The client lives in your repo, nothing is published to npm, and `OutputDirectory` only ever touches files carrying its own marker — so it cannot delete or overwrite @@ -314,34 +319,34 @@ Every failure the server can produce is one of six categories: | 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* gave a name | | 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | -The table is in resolution order, and the first match wins. That order is why `DOMAIN_ERROR` sits -second to last: an exception you have explicitly mapped onto a category stays in that category even -when it is named for the client. +The scope that threw is consulted first: a `#[Throws]` declaration on the throwing method — the +operation handler or a middleware's `handle()` — decides the category, and only where that scope +declared nothing do the configured category lists apply. Everything unrecognised is a 500. A client has one more failure available to it, and no server sends it: `CLIENT_ERROR`, code 0, for the request that never arrived. It carries the exception that stopped it under `cause` instead of `details`. -Exposing a domain error takes both a declaration and a name — `#[Throws]` on the operation, and -either `as:` on that declaration or `#[ExposeAs]` on the exception class: +Exposing a domain error takes both a declaration and a name — `#[Throws]` on the throwing scope, and +either `name:` on that declaration or `#[ExposeAs]` on the exception class: ```php #[Command('users')] -#[Throws(InvalidNameException::class, as: 'invalid-name')] +#[Throws(InvalidNameException::class, name: 'invalid-name')] public function create(array $input): array { /* ... */ } ``` ```json -{"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"type": "invalid-name"}} +{"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"name": "invalid-name"}} ``` -Because the catalogue is closed, `Failure` is the union of what your server can produce rather than a -hole for whatever a call site passes. The only thing an operation adds to it is which exceptions it -exposed, so that is the only thing it takes: +Because the catalogue is closed, `Failure` is the union of all of it rather than a hole for whatever +a call site passes. The only thing an operation adds to it is which exceptions it exposed, so that +is the only thing it takes: ```typescript export type Failure = {success: false, __metadata?: Record} - & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError); + & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); ``` ```typescript diff --git a/docs/errors.md b/docs/errors.md index 152e90d..c494f82 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -25,10 +25,12 @@ Every failure the server can produce is one of six: | 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* gave a name | | 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | -The table is in resolution order, and the first match wins. That order is why `DOMAIN_ERROR` sits -second to last: an exception you have explicitly mapped onto a category stays in that category even -when it is named for the client. Anything unrecognised is a 500 — an exception is never exposed by -accident. +The scope that threw is consulted first. A `#[Throws]` declaration on the throwing method — the +operation handler, or the `handle()` of a middleware the operation declared — decides the category, +whether that is a named `DOMAIN_ERROR` or an explicit mapping like +`#[Throws(GoneException::class, type: ErrorType::NOT_FOUND)]`. Only where the throwing scope +declared nothing do the configured category lists apply. Anything unrecognised is a 500 — an +exception is never exposed by accident. The catalogue is closed. It is `ErrorType`, an `int`-backed enum whose value *is* the HTTP status code, which is why `$result->type->value` and `$result->statusCode` cannot disagree, and why @@ -39,16 +41,16 @@ exist. Six is what a *server* can answer. A client has one more failure available to it — the request that never arrived — and that one is [`CLIENT_ERROR`](#the-client-error), code 0. It is deliberately not -an `ErrorType`: nothing on the server can produce it, and giving `ErrorPresenter` a case for it would +an `ErrorType`: nothing on the server can produce it, and giving the server a case for it would be claiming otherwise. ## Exposing a domain error -**It takes a declaration and a name.** The operation declares that it can throw the exception, and +**It takes a declaration and a name.** The scope that throws the exception declares it, and something gives that exception a name the client sees. The exception can carry its own: ```php -#[ExposeAs('invalid_name')] +#[ExposeAs(name: 'invalid_name')] final class InvalidNameException extends Exception {} #[Command('users')] @@ -57,25 +59,36 @@ public function create(array $input): array { /* ... */ } ``` ```json -{"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"type": "invalid_name"}} +{"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"name": "invalid_name"}} ``` -Or the declaration can name it on the spot with `as`, which needs no `#[ExposeAs]` at all — the +Or the declaration can name it on the spot with `name:`, which needs no `#[ExposeAs]` at all — the point being that the exception does not have to be yours to annotate: ```php #[Command('users')] -#[Throws(InvalidNameException::class, as: 'invalid-name')] +#[Throws(InvalidNameException::class, name: 'invalid-name')] public function create(array $input): array { /* ... */ } ``` -`as` always wins over `#[ExposeAs]`, so the same exception can read differently per operation. What -`as` does not do is skip the declaration: an exception no operation declares with `#[Throws]` is -still a 500, and so is one that is declared but named nowhere. - -`#[Throws]` on a [middleware's](operations.md#middleware) `handle()` counts as a declaration for -every operation that middleware wraps. When an operation and its middleware declare the same -exception, the operation's name wins. +`name:` always wins over `#[ExposeAs]`, so the same exception can read differently per operation. +What `name:` does not do is skip the declaration: an exception the throwing scope does not declare +with `#[Throws]` is still a 500, and so is one that is declared but named nowhere. + +**A declaration covers throws from its own scope only.** `#[Throws]` on the operation method covers +what the handler throws; `#[Throws]` on a [middleware's](operations.md#middleware) `handle()` covers +what that middleware throws. An exception the handler declares but a middleware throws — or the +other way around — is a 500: the declaration and the throw did not come from the same place. Each +scope names its own throws, so the same exception class can surface under a different name per +scope, and the generated union carries every name any scope of the operation can produce. + +A middleware registered globally through +[`ServerConfiguration::withMiddlewares()`](operations.md#serverconfiguration) cannot expose domain +errors at all: it runs for every operation, so a domain vocabulary there would leak into all of +them. The runtime ignores such a declaration — the exception surfaces as a 500 — and code +generation refuses it outright, naming the middleware. A global middleware may still map an +exception onto a non-domain category, e.g. `#[Throws(ExpiredException::class, type: +ErrorType::AUTHENTICATION_ERROR)]`. ## The generated error union @@ -86,24 +99,24 @@ export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fie export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"}; export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; export type NotFoundError = {code: 404, type: "NOT_FOUND"}; -export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}}; +export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}}; export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error}; ``` -Because the catalogue is closed, `Failure` is the *union* of the ones your server can produce rather -than a hole for whatever a call site passes in: +Because the catalogue is closed, `Failure` is the *union* of all of it rather than a hole for +whatever a call site passes in: ```typescript export type Failure = {success: false, __metadata?: Record} - & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError); + & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); export type Result = Success | Failure; ``` -The 401 and 403 branches appear in it only once you have actually mapped exceptions onto them, so the -union describes what *this* server can really produce. What varies per operation is one thing only — -the names it exposed — so that is the one thing `Failure` is parameterised on, and the one thing an -operation module declares. An operation that declares nothing gets: +Every branch is always in the union: which of an application's exceptions land in which category is +runtime configuration, and the union does not shrink around it. What varies per operation is one +thing only — the names it exposed — so that is the one thing `Failure` is parameterised on, and the +one thing an operation module declares. An operation that declares nothing gets: ```typescript export type CreateDomainErrors = never; @@ -128,7 +141,7 @@ if (!result.success && result.code === 400) { /* ... */ } ``` The brackets in `[TType] extends [never]` stop the conditional distributing, so two exposed names -stay one branch carrying a union under `details.type` rather than splitting into two. +stay one branch carrying a union under `details.name` rather than splitting into two. Because both the runtime and the code generator read the same attributes from the same place, none of this can drift from the responses it describes. Naming the branches is also what lets a consumer @@ -165,7 +178,7 @@ Reached through `OperationException`, the envelope is `e.cause` and the original ## When `details` appears **`details` only appears where the category cannot say everything on its own**, which is exactly two -of the six: `INVALID_INPUT` carries `fields`, and `DOMAIN_ERROR` carries the `type` naming which +of the six: `INVALID_INPUT` carries `fields`, and `DOMAIN_ERROR` carries the `name` naming which domain error it is. For the other four, `code` and `type` are the whole answer and restating it under `details` would put the same string on the wire twice, so the key is absent — and the generated branch has no such property, so narrowing on `type` will not offer you one. @@ -214,7 +227,7 @@ had to remember to. **Something else decides it — that is a domain error.** "Already taken" needs the database; "the account is locked" needs the account. The input was well formed and the request still cannot proceed, which is a 400, not a 422. Declare it with `#[Throws]` and give it a name, per -[Exposing a domain error](#exposing-a-domain-error). The client gets `details.type` naming which +[Exposing a domain error](#exposing-a-domain-error). The client gets `details.name` naming which rule failed, and — unlike a free-text message — the generated union makes it a case it must handle. ## Exceptions this library throws @@ -235,6 +248,9 @@ a [value object](types.md#value-objects) rejected a value. It never escapes the reaches a client as itself, only as the issues it produced. Making it a `SchemaException` would mean that catching a server fault also caught a user typing their email wrong. -Nothing is thrown out of `Server::query()` or `Server::command()` — both are total, and every -`Throwable` comes back as an `RpcError`. The exceptions above surface at discovery, at parse time or -during code generation instead, which is to say: at build time, not at request time. +A `Throwable` from a handler or middleware never escapes `Server::query()` or `Server::command()` — +it comes back as an `RpcError`. What does escape is a failure of error presentation itself, e.g. a +stale class name failing reflection while the throwing scope's declarations are read: that is a bug +in the setup, and it surfaces as the exception it is rather than as a substitute 500. The +exceptions above surface at discovery, at parse time or during code generation instead, which is to +say: at build time, not at request time. diff --git a/docs/operations.md b/docs/operations.md index eeaf090..1f8a847 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -17,8 +17,8 @@ to all of them. The short version lives in the [README](../README.md); this is t | `#[Query(namespace, name)]` | method | A read operation, served over GET. | | `#[Command(namespace, name)]` | method | A write operation, served over POST. | | `#[Middleware(class)]` | class, method, repeatable | Middleware to run around this operation. | -| `#[Throws(ExceptionClass, as: ?string)]` | method, repeatable | Declares an exception the operation may throw, optionally naming it for the client. | -| `#[ExposeAs(type)]` | exception class | The exception's own name, for every operation that declares it. | +| `#[Throws(ExceptionClass, type: ?ErrorType, name: ?string)]` | method, repeatable | Declares an exception this method may throw — a named domain error, or an explicit category mapping. | +| `#[ExposeAs(type: ErrorType, name: ?string)]` | exception class | The exception's own category and name, for every scope that declares it. | | `#[Optional]` | property, parameter | The field may be absent from input. | | `#[Castable(strategy)]` | class | A plain class may be built from input. | | `#[Brand(name)]` | class | Makes the generated TypeScript type opaque. | @@ -140,10 +140,13 @@ new ServerConfiguration()->withMiddlewares(AuthMiddleware::class, LoggingMiddlew method-level, and within each group they run in declaration order. The first one listed is the first to see the input and the last to see the result. -`#[Throws]` on a middleware's `handle()` contributes to the error union of every operation it wraps — -globally configured or attached with `#[Middleware]`, both count — so the generated TypeScript knows -about middleware failures too. It takes `as` like any other declaration, and when an operation and -its middleware declare the same exception, the operation's name wins. +`#[Throws]` on a middleware's `handle()` covers what that middleware itself throws — a declaration +never covers a throw from another scope. A middleware attached with `#[Middleware]` contributes its +named domain errors to the error union of every operation that declared it, so the generated +TypeScript knows about middleware failures too. A globally configured middleware cannot expose +domain errors at all: such a declaration is ignored at runtime and refused by code generation. It +takes `name:` like any other declaration, and since each scope names its own throws, the same +exception can surface under a different name per scope — the union carries every name. Middleware can also attach metadata to whichever result it is holding, with `withMetadata()` / `appendMetadata()`. It travels to the client under `__metadata` on both branches — see diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index c7ea716..b324ebc 100644 --- a/src/Adapters/Laravel/config/config.php +++ b/src/Adapters/Laravel/config/config.php @@ -83,6 +83,11 @@ * A list of global middleware class names run on every single Operation (Query and Command). * Every class must implement Le0daniel\PhpTsBindings\Contracts\MiddlewareContract. * + * A global middleware cannot expose domain errors: a #[Throws(..., name: ...)] on its handle() + * would leak one operation's vocabulary into all of them, so the declaration is ignored at + * runtime and refused by code generation. Mapping onto a non-domain category - e.g. + * #[Throws(Expired::class, type: ErrorType::AUTHENTICATION_ERROR)] - is fine. + * * $next() always hands back an RpcSuccess or an RpcError - a failure further in is converted * before it reaches you, so post-processing runs either way. * @@ -102,10 +107,11 @@ /** * Map your exceptions onto the server's built-in error categories. Anything not listed here and - * neither marked with #[ExposeAs] nor named via #[Throws(..., as: ...)] is reported to the + * neither marked with #[ExposeAs] nor named via #[Throws(..., name: ...)] is reported to the * client as an internal error. * - * Matching is instanceof: listing a base class covers every subclass of it. + * Matching is instanceof: listing a base class covers every subclass of it. A #[Throws] + * declaration on the throwing scope wins over these lists. */ 'exceptions' => [ 'unauthenticated' => [ diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 6f25e20..be2b950 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -263,7 +263,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi ]), self::OPERATION_EXCEPTION_FILE => new TypescriptFile(<<<'TypeScript' /** - * Generic over the names the operation exposed, so `e.cause.details.type` narrows to those rather + * Generic over the names the operation exposed, so `e.cause.details.name` narrows to those rather * than to any string. The rest of the catalogue is the server's and needs no naming here. */ export class OperationException extends Error { diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 89034d8..7a393ed 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -6,7 +6,6 @@ use Le0daniel\PhpTsBindings\CodeGen\Contracts\GeneratesLibFiles; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; -use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\CodeGen\Utils\Paths; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptImport; @@ -21,24 +20,25 @@ /** * Declarations this file always contains. An alias claiming one of these names would generate - * a second, conflicting declaration right next to them. + * a second, conflicting declaration right next to them. The envelope names mirror the + * declarations in the heredoc below - a branch added there needs its name added here. * - * The error envelopes are asked of the catalogue rather than copied out of it: a branch added - * there and forgotten here is a user alias free to shadow it. - * - * @return list + * @var list */ - private static function reservedAliases(): array - { - return [ - 'Brand', - 'Success', - 'Failure', - 'Result', - 'OperationNamespaces', - ...ErrorTypescript::envelopeNames(), - ]; - } + private const array RESERVED_ALIASES = [ + 'Brand', + 'Success', + 'Failure', + 'Result', + 'OperationNamespaces', + 'InvalidInputError', + 'AuthenticationError', + 'AuthorizationError', + 'NotFoundError', + 'DomainError', + 'InternalError', + 'ClientError', + ]; /** * Every declaration above lives in this file, so importing one is asking here for it. Not @@ -63,9 +63,8 @@ public function importFromTypes(array $values = [], array $types = []): Typescri #[Override] public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { - $reserved = self::reservedAliases(); foreach ($registry->usedAliases() as $alias) { - if (in_array($alias, $reserved, true)) { + if (in_array($alias, self::RESERVED_ALIASES, true)) { throw UnsupportedTypeException::reservedAlias($alias); } } @@ -79,16 +78,6 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } } - // Declared here and referenced everywhere else: Failure names these rather than restating - // their shapes, and only this file resolves the names. - $errorEnvelopes = ErrorTypescript::envelopeDeclarations(); - - // Which of them Failure is a union of depends on how this server maps exceptions onto them, - // which is why it is emitted per run rather than written out here. - $failureUnion = ErrorTypescript::failureUnion($metadata->configuration); - $domainTypeParameter = ErrorTypescript::DOMAIN_TYPE_PARAMETER; - $noDomainTypes = ErrorTypescript::NO_DOMAIN_TYPES; - // The shared registry holds every alias any pass produced; the types file declares them // all, so every operation file can import any key of its own definitions' registries. $aliasTypeString = implode("\n", Arrays::mapWithKeys( @@ -104,13 +93,19 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi * The finite error catalogue. Every failure is one of these, which is why Failure below is their * union rather than a hole for one. DomainError is the only branch whose payload varies per * operation - the names that operation exposed - and the only one declared conditionally: on - * `{$noDomainTypes}` it collapses, so an operation exposing nothing has no 400 branch to narrow to. + * `never` it collapses, so an operation exposing nothing has no 400 branch to narrow to. */ -{$errorEnvelopes} +export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}}; +export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"}; +export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; +export type NotFoundError = {code: 404, type: "NOT_FOUND"}; +export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}}; +export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; +export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error}; export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} -export type Failure<{$domainTypeParameter} extends string = {$noDomainTypes}> = {success: false, __metadata?: Record} & ({$failureUnion}); -export type Result = Success | Failure<{$domainTypeParameter}>; +export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); +export type Result = Success | Failure; declare const __brand: unique symbol; export type Brand = {readonly [__brand]: TBrand;}; diff --git a/src/CodeGen/TypescriptServerCodeGenerator.php b/src/CodeGen/TypescriptServerCodeGenerator.php index e2b4c92..c3af88a 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -17,10 +17,12 @@ use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\Helpers\AstValidator; use Le0daniel\PhpTsBindings\Server\Data\Operation; +use Le0daniel\PhpTsBindings\Server\Errors\ThrowAttributeResolver; use Le0daniel\PhpTsBindings\Server\Server; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; +use ReflectionMethod; final readonly class TypescriptServerCodeGenerator { @@ -94,6 +96,23 @@ private function resolveGeneratorDependencies(): void */ public function generate(Server $server, ServerMetadata $metadata, array $ignore = []): array { + // A globally configured middleware runs for every operation, so its #[Throws] declarations + // take part in no operation's vocabulary: a domain error there would leak one operation's + // names into all of them. The runtime silently ignores such a declaration and answers 500, + // which is exactly why it is refused loudly here, at build time. + foreach ($server->configuration->middleware as $middlewareClass) { + $issues = ThrowAttributeResolver::resolveReflection( + new ReflectionMethod($middlewareClass, 'handle'), + allowDomainErrors: false, + )['issues']; + + if (count($issues) > 0) { + throw new CodeGenException( + "Invalid #[Throws] declarations on globally configured middleware {$middlewareClass}: ".implode(' ', $issues), + ); + } + } + /** * Filter out some operations that are not needed. * @@ -112,7 +131,7 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore // Bound once: inputNode()/outputNode() run the parse closure on every call, so asking twice // parses every schema in the run twice. - $definitions = array_map(function (Operation $operation) use ($server, $registry): TypedOperation { + $definitions = array_map(function (Operation $operation) use ($registry): TypedOperation { $inputNode = $operation->inputNode(); $outputNode = $operation->outputNode(); @@ -122,7 +141,7 @@ public function generate(Server $server, ServerMetadata $metadata, array $ignore return new TypedOperation( inputDef: $this->typescriptGenerator->toTypescript($inputNode, IO::INPUT, $registry), outputDef: $this->typescriptGenerator->toTypescript($outputNode, IO::OUTPUT, $registry), - domainErrors: ErrorTypescript::domainTypesFor($server->configuration, $operation->definition), + domainErrors: ErrorTypescript::domainTypesFor($operation->definition), operation: $operation, ); }, $filteredDefinitions); diff --git a/src/CodeGen/Utils/ErrorTypescript.php b/src/CodeGen/Utils/ErrorTypescript.php index cb767e8..f07eebf 100644 --- a/src/CodeGen/Utils/ErrorTypescript.php +++ b/src/CodeGen/Utils/ErrorTypescript.php @@ -5,172 +5,36 @@ namespace Le0daniel\PhpTsBindings\CodeGen\Utils; use Le0daniel\PhpTsBindings\Server\Data\Definition; -use Le0daniel\PhpTsBindings\Server\Data\ErrorType; -use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; -use Le0daniel\PhpTsBindings\Server\Errors\ExposedExceptions; -use Le0daniel\PhpTsBindings\Utils\Arrays; +use Le0daniel\PhpTsBindings\Server\Errors\ThrowAttributeResolver; use ReflectionException; /** - * The TypeScript face of the server's finite error catalogue. + * The one piece of error TypeScript that varies per operation: the literal union of domain error + * names its scopes expose. The static error catalogue - the envelope declarations and the Failure + * union - lives directly in EmitTypes, next to the file that declares it. * - * Each branch is declared once, as a named envelope in the generated types file, and Failure is the - * union of the ones this server can produce. The shapes therefore live here rather than at every use - * site, and a consumer can name a branch — `NotFoundError` — instead of restating its literal. - * - * That the catalogue is closed is what lets Failure be that union rather than take one: the only - * thing varying per operation is which exceptions it exposed, so the names of those are the only - * thing Failure is parameterised on. - * - * The runtime counterpart is Server\Errors\ErrorPresenter, and the branches below appear in its - * resolution order. Only reachable branches are unioned: the two auth categories exist solely - * because exceptions were mapped onto them, and a domain error only exists where an operation - * declares an exception via #[Throws] that resolves to a name - its own `as`, or #[ExposeAs] on the - * exception class. Everything else the server produces on its own. + * The runtime counterpart is the Server itself, which resolves the category from the throwing + * scope's #[Throws] declarations and falls back to Server\Errors\ErrorClassifier. */ final readonly class ErrorTypescript { - /** - * The one branch no server sends: the request never got there, so a client hands this back - * instead. It has no ErrorType case for the same reason, and it is the only envelope whose - * payload is a live object rather than something that came off the wire. - */ - private const string CLIENT_ENVELOPE = 'ClientError'; - - /** - * What Failure names its type parameter. The union below is written in terms of it, so the - * declaration and the branch that carries it cannot disagree about the name. - */ - public const string DOMAIN_TYPE_PARAMETER = 'TDomainType'; - - /** - * What an operation exposing nothing instantiates the domain branch with. DomainError erases - * itself on it, so such an operation's Failure has no 400 branch at all. - */ - public const string NO_DOMAIN_TYPES = 'never'; - - /** - * Envelope name => [type parameters, declaration], in ErrorPresenter resolution order. - * - * The domain branch is the only one whose payload depends on the operation - the names of the - * exceptions it exposed - so it is the only one that takes a type argument. Every other category - * says the same thing for every operation on the server. - * - * Its conditional is what makes `never` mean the branch is gone rather than a 400 whose name is - * uninhabited. The wrapping brackets keep it from distributing, so two exposed names stay one - * member with a union under `details.type` instead of becoming two members. - * - * @var array - */ - private const array ENVELOPES = [ - 'InvalidInputError' => ['', '{code: 422, type: "INVALID_INPUT", details: {fields: Record}}'], - 'AuthenticationError' => ['', '{code: 401, type: "AUTHENTICATION_ERROR"}'], - 'AuthorizationError' => ['', '{code: 403, type: "AUTHORIZATION_ERROR"}'], - 'NotFoundError' => ['', '{code: 404, type: "NOT_FOUND"}'], - 'DomainError' => ['', '[TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}}'], - 'InternalError' => ['', '{code: 500, type: "INTERNAL_ERROR"}'], - self::CLIENT_ENVELOPE => ['', '{code: 0, type: "CLIENT_ERROR", cause: Error}'], - ]; - - /** - * Every name this catalogue occupies in the types file. What EmitTypes reserves, so a #[Named] - * type claiming one of them fails instead of generating a second, conflicting declaration. - * - * @return list - */ - public static function envelopeNames(): array - { - return array_keys(self::ENVELOPES); - } - - /** - * The declarations, for the file that holds them. Nothing else may restate a shape: a second - * copy is free to drift from the one operations are typed against. - * - * All of them are declared, including the ones this server cannot reach. They are names a - * consumer writes a handler against, and reserving a name it might not use costs nothing - - * whereas a union naming an unreachable branch would claim the server can produce it. - */ - public static function envelopeDeclarations(): string - { - return implode("\n", Arrays::mapWithKeys( - self::ENVELOPES, - /** @param array{string, string} $envelope */ - static fn (string $name, array $envelope): string => "export type {$name}{$envelope[0]} = {$envelope[1]};", - )); - } - - /** - * What Failure is, for this server: every category it can produce, in resolution order, closed - * by the branch a client mints when the request never got there. - * - * The domain branch is unconditional here, unlike the two auth ones. It carries the type - * parameter, and an operation exposing nothing instantiates that with `never` - which erases the - * branch already, so gating it a second time would only mean saying `never` twice. - */ - public static function failureUnion(ServerConfiguration $configuration): string - { - /** @var list $categories */ - $categories = [ErrorType::INVALID_INPUT]; - - if (count($configuration->unauthenticatedExceptions) !== 0) { - $categories[] = ErrorType::AUTHENTICATION_ERROR; - } - - if (count($configuration->unauthorizedExceptions) !== 0) { - $categories[] = ErrorType::AUTHORIZATION_ERROR; - } - - $categories[] = ErrorType::NOT_FOUND; - $categories[] = ErrorType::DOMAIN_ERROR; - $categories[] = ErrorType::INTERNAL_ERROR; - - $references = array_map( - static fn (ErrorType $type): string => $type === ErrorType::DOMAIN_ERROR - ? self::envelopeFor($type).'<'.self::DOMAIN_TYPE_PARAMETER.'>' - : self::envelopeFor($type), - $categories, - ); - - // Closed here rather than by a caller: what a client can hand back belongs to the same union - // as what the server can, so the union has one owner and one set of tests. - $references[] = self::CLIENT_ENVELOPE; - - return implode('|', $references); - } - - /** - * Exhaustive on purpose: a category added to ErrorType without an envelope to carry it fails - * here rather than generating a union that quietly cannot describe it. - */ - private static function envelopeFor(ErrorType $type): string - { - return match ($type) { - ErrorType::INVALID_INPUT => 'InvalidInputError', - ErrorType::AUTHENTICATION_ERROR => 'AuthenticationError', - ErrorType::AUTHORIZATION_ERROR => 'AuthorizationError', - ErrorType::NOT_FOUND => 'NotFoundError', - ErrorType::DOMAIN_ERROR => 'DomainError', - ErrorType::INTERNAL_ERROR => 'InternalError', - }; - } - /** * The literal union one operation instantiates the domain branch with, or `never` where it - * exposes nothing and the branch is unreachable. + * exposes nothing - DomainError erases itself on that, so such an operation's Failure has no + * 400 branch at all. * * @throws ReflectionException */ - public static function domainTypesFor(ServerConfiguration $configuration, Definition $definition): string + public static function domainTypesFor(Definition $definition): string { - $exposedTypes = ExposedExceptions::exposedTypesFor($definition, $configuration); - if (count($exposedTypes) === 0) { - return self::NO_DOMAIN_TYPES; + $names = ThrowAttributeResolver::collectDomainErrorNamesFromDefinition($definition); + if (count($names) === 0) { + return 'never'; } return implode('|', array_map( - static fn (string $exposedType): string => json_encode($exposedType, JSON_THROW_ON_ERROR), - $exposedTypes, + static fn (string $name): string => json_encode($name, JSON_THROW_ON_ERROR), + $names, )); } } diff --git a/src/Contracts/Attributes/Throws.php b/src/Contracts/Attributes/Throws.php index ec18411..344a028 100644 --- a/src/Contracts/Attributes/Throws.php +++ b/src/Contracts/Attributes/Throws.php @@ -22,8 +22,7 @@ public function __construct( public string $exceptionClass, ?ErrorType $type = null, public ?string $name = null, - ) - { + ) { $this->type = $type ?? ($this->name ? ErrorType::DOMAIN_ERROR : null); } diff --git a/src/Server/Data/Exceptions/InvalidInputException.php b/src/Server/Data/Exceptions/InvalidInputException.php index 933a4a9..fd5ba44 100644 --- a/src/Server/Data/Exceptions/InvalidInputException.php +++ b/src/Server/Data/Exceptions/InvalidInputException.php @@ -11,7 +11,7 @@ * @internal This class in internal and should not be used outside of the library. * It strictly represents a failure to validate input data. * - * The server constructs it from a parse Failure and ErrorPresenter is its only reader, turning it + * The server constructs it from a parse Failure and is also its only reader, turning it * into the 422 that carries `details.fields`. Never throw it: a 422 is the schema's verdict on the * input and nothing else. A rule the schema cannot express belongs in a value object throwing * ValidationException, or - when it needs context the input alone cannot give - in a domain error diff --git a/src/Server/Data/RpcError.php b/src/Server/Data/RpcError.php index ce7abc2..71b6f9d 100644 --- a/src/Server/Data/RpcError.php +++ b/src/Server/Data/RpcError.php @@ -20,10 +20,8 @@ final class RpcError implements RpcResult * @param Throwable $cause The most recent failure - the one that decided this result. On an * ordinary error that is simply the exception the application threw. * @param list $previous Everything that failed before $cause, oldest first. Empty on - * every ordinary error, and non empty only when handling one failure produced another: a - * stale #[Middleware] class name makes ExposedExceptions throw while categorising, and the - * result is then an INTERNAL_ERROR because the catalogue could not be consulted, not because - * the original deserved a 500. Reporters want all of them. + * every ordinary error; a transport chaining one failure behind another keeps both here, + * because reporters want all of them. * @param array $metadata * * @internal Constructed by the server. Applications receive one, they do not build one. diff --git a/src/Server/Errors/ErrorClassifier.php b/src/Server/Errors/ErrorClassifier.php index fc31d35..8048fbf 100644 --- a/src/Server/Errors/ErrorClassifier.php +++ b/src/Server/Errors/ErrorClassifier.php @@ -1,4 +1,6 @@ - is_a($className, $exception, true) + static fn ($exception) => is_a($className, $exception, true) ); } -} \ No newline at end of file +} diff --git a/src/Server/Errors/ErrorPresenter.php b/src/Server/Errors/ErrorPresenter.php deleted file mode 100644 index deb7e71..0000000 --- a/src/Server/Errors/ErrorPresenter.php +++ /dev/null @@ -1,133 +0,0 @@ -resolve($throwable, $definition); - - return new RpcError($type, $throwable, $details, $info); - } catch (Throwable $presentationFailure) { - // Losing this one is expensive to debug: a stale middleware class name makes - // ExposedExceptions throw, and from then on every exception from the operation - // degrades to an internal error with no #[Throws] mapping ever applying again, with - // nothing anywhere saying why. It is the most recent failure and the one that decided - // the category, so it is the cause; what the application threw is what came before it. - return self::internalError($presentationFailure, $info, [$throwable]); - } - } - - /** - * The last resort shape, for when presenting itself fails. - * - * @param list $previous - */ - public static function internalError( - Throwable $throwable, - ?ResolveInfo $info, - array $previous = [], - ): RpcError { - return new RpcError( - ErrorType::INTERNAL_ERROR, - $throwable, - details: null, - resolveInfo: $info, - previous: $previous, - ); - } - - /** - * `details` carries what the category alone cannot say, and nothing else. Only two categories - * have anything to add: which fields failed validation, and which domain error this is. For the - * rest the category *is* the whole answer, and restating it under `details.type` would be the - * same string twice on the wire - so they get null, and Dicts::filterNullValues() drops the key. - * - * @return array{ErrorType, array|null} - */ - private function resolve(Throwable $throwable, ?Definition $definition): array - { - if ($throwable instanceof InvalidInputException) { - return [ErrorType::INVALID_INPUT, [ - 'fields' => $throwable->failure->issues->serializeToFieldsArray(), - ]]; - } - - if ($this->matchesAny($throwable, $this->configuration->unauthenticatedExceptions)) { - return [ErrorType::AUTHENTICATION_ERROR, null]; - } - - if ($this->matchesAny($throwable, $this->configuration->unauthorizedExceptions)) { - return [ErrorType::AUTHORIZATION_ERROR, null]; - } - - if ($throwable instanceof OperationNotFoundException || $this->matchesAny($throwable, $this->configuration->notFoundExceptions)) { - return [ErrorType::NOT_FOUND, null]; - } - - // The one place a `type` under details is not a repeat: the category is DOMAIN_ERROR for - // all of them, and this is which one. - if ($definition && $exposedType = $this->exposedTypeOf($throwable, $definition)) { - return [ErrorType::DOMAIN_ERROR, ['type' => $exposedType]]; - } - - return [ErrorType::INTERNAL_ERROR, null]; - } - - /** - * @param list> $classNames - */ - private function matchesAny(Throwable $throwable, array $classNames): bool - { - return array_any($classNames, static fn (string $className): bool => $throwable instanceof $className); - } - - /** - * An exception is a domain error only if the operation declares it via #[Throws] and that - * declaration resolves to a name - from its own `as`, or from #[ExposeAs] on the exception. - * Declared but unnamed is null, and falls through to the catch all. - */ - private function exposedTypeOf(Throwable $throwable, Definition $definition): ?string - { - return ExposedExceptions::declaredFor($definition, $this->configuration)[$throwable::class] ?? null; - } -} diff --git a/src/Server/Errors/ExceptionScope.php b/src/Server/Errors/ExceptionScope.php new file mode 100644 index 0000000..787b356 --- /dev/null +++ b/src/Server/Errors/ExceptionScope.php @@ -0,0 +1,33 @@ +className, $this->methodName); + } +} diff --git a/src/Server/Errors/ExposedExceptions.php b/src/Server/Errors/ExposedExceptions.php deleted file mode 100644 index 3e31d38..0000000 --- a/src/Server/Errors/ExposedExceptions.php +++ /dev/null @@ -1,102 +0,0 @@ -, string|null> - * - * @throws ReflectionException - */ - public static function declaredFor(Definition $definition, ServerConfiguration $configuration): array - { - $attributes = new ReflectionMethod($definition->fullyQualifiedClassName, $definition->methodName) - ->getAttributes(Throws::class); - - $middlewareClassNames = [ - ...$definition->middleware, - ...$configuration->middleware, - ]; - - foreach ($middlewareClassNames as $middlewareClassName) { - $middlewareAttributes = new ReflectionMethod($middlewareClassName, 'handle') - ->getAttributes(Throws::class); - - if (count($middlewareAttributes) > 0) { - array_push($attributes, ...$middlewareAttributes); - } - } - - $declared = []; - foreach ($attributes as $attribute) { - /** @var Throws $throws */ - $throws = $attribute->newInstance(); - - // The first name given wins. The operation is reflected before the middleware wrapping - // it, so an operation states its own contract first; and because only a name displaces - // null, a bare #[Throws] never silences an `as` declared elsewhere for the same class. - $declared[$throws->exceptionClass] ??= $throws->name ?? self::exposeAsOf($throws->exceptionClass); - } - - return $declared; - } - - /** - * The name an exception class gives itself, used when no #[Throws] names it. - * - * @param class-string $exceptionClass - */ - private static function exposeAsOf(string $exceptionClass): ?string - { - $attributes = new ReflectionClass($exceptionClass)->getAttributes(ExposeAs::class); - - return count($attributes) === 0 - ? null - : $attributes[0]->newInstance()->type; - } - - /** - * The exposed names of every exception the operation declares, in declaration order. - * - * @return list - * - * @throws ReflectionException - */ - public static function exposedTypesFor(Definition $definition, ServerConfiguration $configuration): array - { - return array_values(self::declaredFor($definition, $configuration)) - |> Lists::filterNullValues(...) - |> Lists::unique(...); - } -} diff --git a/src/Server/Errors/ThrowAttributeResolver.php b/src/Server/Errors/ThrowAttributeResolver.php index 53c6a9a..a32decc 100644 --- a/src/Server/Errors/ThrowAttributeResolver.php +++ b/src/Server/Errors/ThrowAttributeResolver.php @@ -1,4 +1,6 @@ -fullyQualifiedClassName, $definition->methodName), - ... array_map(static fn($className) => new ReflectionMethod($className, 'handle'), $definition->middleware), + ... array_map(static fn ($className) => new ReflectionMethod($className, 'handle'), $definition->middleware), ]; $names = []; @@ -48,8 +49,7 @@ public static function collectDomainErrorNamesFromDefinition( public static function resolveReflection( ReflectionClass|ReflectionMethod $reflection, bool $allowDomainErrors, - ): array - { + ): array { $issues = []; $exceptions = []; @@ -78,7 +78,7 @@ public static function resolveReflection( if (!$declaration->isValid()) { $attributeName = $declaration::class - |> (static fn($name) => explode('\\', $name)) + |> (static fn ($name) => explode('\\', $name)) |> array_last(...); $issues[] = "#[{$attributeName}] attribute declaration is not valid."; @@ -116,12 +116,11 @@ public static function resolveReflection( */ private static function throwableAttributes( ReflectionClass|ReflectionMethod $reflection, - ): array - { + ): array { $attributes = $reflection->getAttributes(Throws::class); return array_map( - static fn(ReflectionAttribute $attribute): Throws => $attribute->newInstance(), + static fn (ReflectionAttribute $attribute): Throws => $attribute->newInstance(), $attributes, ); } -} \ No newline at end of file +} diff --git a/src/Server/Pipeline/ContextualPipeline.php b/src/Server/Pipeline/ContextualPipeline.php index 6baebce..95f8470 100644 --- a/src/Server/Pipeline/ContextualPipeline.php +++ b/src/Server/Pipeline/ContextualPipeline.php @@ -10,21 +10,25 @@ use Le0daniel\PhpTsBindings\Server\Data\ResolveInfo; use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; -use Le0daniel\PhpTsBindings\Server\Errors\ErrorPresenter; +use Le0daniel\PhpTsBindings\Server\Errors\ExceptionScope; use Throwable; /** * Runs the middlewares as an onion around the destination, first middleware outermost. * - * INVARIANT: nothing escapes this pipeline as a Throwable. Every ring - and the destination - - * is wrapped, so a failure is turned into an RpcError right where it happened and handed back to - * the enclosing middleware as the return value of its $next() call. The stack is never unwound - * past a middleware, which means outer rings always get to run their post-processing on the error. + * A failure inside a ring or the destination is turned into an RpcError right where it happened + * and handed back to the enclosing middleware as the return value of its $next() call. The stack + * is never unwound past a middleware, which means outer rings always get to run their + * post-processing on the error. * - * The conversion goes through $onError, so failures are presented the same way whether they come - * from a middleware or from the operation itself. If $onError fails too there is nobody left to - * ask, so the pipeline falls back to a bare INTERNAL_ERROR rather than letting the request crash - - * carrying the failure it was asked to present in `previous`, so neither of the two is lost. + * The conversion goes through $onError, together with the scope that threw: the full class name + * and method name of the middleware whose handle() ring caught the exception, or null for the + * destination - the pipeline only knows its middlewares, and whoever built the destination is the + * one who knows what it wraps and presents its scope itself. + * + * $onError must never throw - presenting an error is the server's job, and there is nobody here + * to ask for an envelope when presenting itself fails. If it throws anyway, the pipeline lets it + * escape rather than burying the bug in a substitute error. * * @phpstan-import-type Next from MiddlewareContract * @@ -34,7 +38,7 @@ { /** * @param list> $middlewares - * @param Closure(Throwable): RpcError $onError + * @param Closure(Throwable, ExceptionScope|null): RpcError $onError * @param Closure(mixed): (RpcSuccess|RpcError) $destination */ public function __construct( @@ -49,11 +53,11 @@ public function __construct( */ public function execute(mixed $input, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError { - $next = function (mixed $input) use ($info): RpcSuccess|RpcError { + $next = function (mixed $input): RpcSuccess|RpcError { try { return ($this->destination)($input); } catch (Throwable $throwable) { - return $this->toRpcError($throwable, $info); + return ($this->onError)($throwable, null); } }; @@ -76,17 +80,8 @@ private function ring(MiddlewareContract $middleware, Closure $next, mixed $cont try { return $middleware->handle($input, $next, $context, $info, $client); } catch (Throwable $throwable) { - return $this->toRpcError($throwable, $info); + return ($this->onError)($throwable, new ExceptionScope($middleware::class, 'handle')); } }; } - - private function toRpcError(Throwable $throwable, ResolveInfo $info): RpcError - { - try { - return ($this->onError)($throwable); - } catch (Throwable $failedToPresent) { - return ErrorPresenter::internalError($failedToPresent, $info, [$throwable]); - } - } } diff --git a/src/Server/Server.php b/src/Server/Server.php index 0f9474f..aff5426 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -4,7 +4,6 @@ namespace Le0daniel\PhpTsBindings\Server; -use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Contracts\ServerAdapter; @@ -13,6 +12,7 @@ use Le0daniel\PhpTsBindings\Executor\Data\SerializationOptions; use Le0daniel\PhpTsBindings\Executor\SchemaExecutor; use Le0daniel\PhpTsBindings\Server\Adapters\NewInstanceAdapter; +use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidOutputException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\OperationNotFoundException; @@ -22,8 +22,11 @@ use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; -use Le0daniel\PhpTsBindings\Server\Errors\ErrorPresenter; +use Le0daniel\PhpTsBindings\Server\Errors\ErrorClassifier; +use Le0daniel\PhpTsBindings\Server\Errors\ExceptionScope; +use Le0daniel\PhpTsBindings\Server\Errors\ThrowAttributeResolver; use Le0daniel\PhpTsBindings\Server\Pipeline\ContextualPipeline; +use ReflectionException; use Throwable; final readonly class Server @@ -31,34 +34,32 @@ public SchemaExecutor $executor; /** - * Error presentation is not an extension point: the catalogue is finite and the server needs it - * to run. What an application configures is which of its exceptions belong in which category. - * - * @see ErrorPresenter + * Error categorisation is not an extension point: the catalogue is finite and the server needs + * it to run. What an application configures is which of its exceptions belong in which + * category, via ServerConfiguration::withExceptions(). Everything unrecognised is an internal + * error - an exception only reaches the client on purpose, never by accident. */ - private ErrorPresenter $errorPresenter; + private ErrorClassifier $classifier; public function __construct( - public OperationRegistry $registry, - private ServerAdapter $adapter = new NewInstanceAdapter(), + public OperationRegistry $registry, + private ServerAdapter $adapter = new NewInstanceAdapter(), public ServerConfiguration $configuration = new ServerConfiguration(), ) { $this->executor = new SchemaExecutor(); - $this->errorPresenter = new ErrorPresenter($configuration); - } - - public function toMetadata(string $queryRoute, string $commandRoute): ServerMetadata - { - return new ServerMetadata($queryRoute, $commandRoute, $this->configuration); + $this->classifier = new ErrorClassifier( + authenticationExceptions: $configuration->unauthenticatedExceptions, + authorizationExceptions: $configuration->unauthorizedExceptions, + notFoundExceptions: $configuration->notFoundExceptions, + ); } public function query(string $name, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { - if (! $this->registry->has(OperationType::QUERY, $name)) { - return $this->errorPresenter->present( + if (!$this->registry->has(OperationType::QUERY, $name)) { + return $this->present( new OperationNotFoundException("Operation with name: {$name} was not found."), null, - null, ); } @@ -67,11 +68,10 @@ public function query(string $name, mixed $input, mixed $context, Client $client public function command(string $name, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { - if (! $this->registry->has(OperationType::COMMAND, $name)) { - return $this->errorPresenter->present( + if (!$this->registry->has(OperationType::COMMAND, $name)) { + return $this->present( new OperationNotFoundException("Operation with name: {$name} was not found."), null, - null, ); } @@ -96,60 +96,127 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli // Resolving happens before the pipeline exists, so it needs its own guard to keep // query()/command() total: a missing container binding or a class that is not a - // middleware must surface as an RpcError, not as an uncaught exception. + // middleware must surface as an RpcError, not as an uncaught exception. Nothing was + // executing yet, so there is no scope whose declarations could apply. try { $middlewares = array_map(fn ($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); $controllerClass = $this->adapter->createController($operation->definition->fullyQualifiedClassName); } catch (Throwable $throwable) { - return $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo); + return $this->present($throwable, $resolveInfo); } return new ContextualPipeline( middlewares: $middlewares, - onError: fn (Throwable $throwable): RpcError => $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo), + onError: fn (Throwable $throwable, ?ExceptionScope $scope): RpcError => $this->present( + $throwable, + $resolveInfo, + $scope, + ), destination: function (mixed $input) use ($controllerClass, $client, $operation, $context, $resolveInfo): RpcSuccess|RpcError { + $inputValidationResult = $this + ->executor + ->parse($operation->inputNode(), $input, new ParsingOptions( + coercePrimitives: $operation->definition->type === OperationType::QUERY + ? $this->configuration->coerceQueryInput + : false, + )); + + if ($inputValidationResult instanceof Failure) { + return $this->present( + new InvalidInputException($inputValidationResult), + $resolveInfo, + ); + } + + // Caught here, not by the pipeline: the pipeline only knows its middlewares, so + // the handler's scope - the one whose #[Throws] declarations apply - is supplied + // by the server itself. try { - $inputValidationResult = $this - ->executor - ->parse($operation->inputNode(), $input, new ParsingOptions( - coercePrimitives: $operation->definition->type === OperationType::QUERY - ? $this->configuration->coerceQueryInput - : false, - )); - - if ($inputValidationResult instanceof Failure) { - return $this->errorPresenter->present( - new InvalidInputException($inputValidationResult), - $operation->definition, - $resolveInfo, - ); - } - - // partialFailures is off on purpose: it substitutes null wherever a value fails - // to serialize under a null-accepting union, which would answer 200 with data - // the operation never produced. An output that does not match its declared type - // is a bug in the application, and the client is told so. - $serializedResult = $this->executor - ->serialize( - $operation->outputNode(), - /** @phpstan-ignore-next-line method.dynamicName */ - $controllerClass->{$operation->definition->methodName}($inputValidationResult->value, $context, $client), - new SerializationOptions(partialFailures: false), - ); - - if ($serializedResult instanceof Failure) { - return $this->errorPresenter->present( - new InvalidOutputException($serializedResult), - $operation->definition, - $resolveInfo, - ); - } - - return new RpcSuccess($serializedResult->value, $client, $resolveInfo); + /** @phpstan-ignore-next-line method.dynamicName */ + $result = $controllerClass->{$operation->definition->methodName}($inputValidationResult->value, $context, $client); } catch (Throwable $throwable) { - return $this->errorPresenter->present($throwable, $operation->definition, $resolveInfo); + return $this->present( + $throwable, + $resolveInfo, + new ExceptionScope($operation->definition->fullyQualifiedClassName, $operation->definition->methodName), + ); } + + // partialFailures is off on purpose: it substitutes null wherever a value fails + // to serialize under a null-accepting union, which would answer 200 with data + // the operation never produced. An output that does not match its declared type + // is a bug in the application, and the client is told so. + $serializedResult = $this->executor + ->serialize( + $operation->outputNode(), + $result, + new SerializationOptions(partialFailures: false), + ); + + if ($serializedResult instanceof Failure) { + // Deliberately not scope resolved: an output mismatch is a bug, never a + // declarable category. + return $this->present( + new InvalidOutputException($serializedResult), + $resolveInfo, + ); + } + + return new RpcSuccess($serializedResult->value, $client, $resolveInfo); }, )->execute($input, $context, $resolveInfo, $client); } + + /** + * The category comes from the throwing scope's own #[Throws]/#[ExposeAs] declarations first, + * and from the configured classifier lists only where that scope declared nothing - the scope + * that threw knows best what its own exception means. The details then restate what the + * category alone cannot say: which fields failed validation, or which domain error this is. + * + * Presenting may itself throw (a stale class name failing reflection): that is a bug in the + * setup, not a request-time condition, and it is allowed to escape. + * + * @param ExceptionScope|null $scope the scope the exception came from, or null when nothing + * was executing (unknown operation, resolution failure) or the failure is the server's own + * (input/output mismatch): only the classifier applies. + * + * @throws ReflectionException + */ + private function present( + Throwable $throwable, + ?ResolveInfo $info, + ?ExceptionScope $scope = null, + ): RpcError { + // If a scope is given, try to resolve the exception within its own declarations. A + // globally configured middleware may not expose domain errors - it runs for every + // operation, so a domain vocabulary there would leak into all of them. + if ($scope) { + $definedExceptions = ThrowAttributeResolver::resolveReflection( + $scope->toReflection(), + allowDomainErrors: !in_array($scope->className, $this->configuration->middleware, true), + )['data']; + + foreach ($definedExceptions as $className => $presentConfig) { + if ($throwable instanceof $className) { + return new RpcError( + type: $presentConfig['type'], + cause: $throwable, + details: isset($presentConfig['name']) ? ['name' => $presentConfig['name']] : null, + resolveInfo: $info, + ); + } + } + } + + $type = $this->classifier->classify($throwable); + + return new RpcError( + type: $type, + cause: $throwable, + details: $type === ErrorType::INVALID_INPUT && $throwable instanceof InvalidInputException + ? ['fields' => $throwable->failure->issues->serializeToFieldsArray()] + : null, + resolveInfo: $info, + ); + } } diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index 5f01d7e..e7a01c1 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -381,14 +381,6 @@ public function someMethod(array $input, null $context, Client $client): array $request = Request::create('/query/docs.method', 'GET', ['name' => 'some_value']); $request->headers->set(OperationClientFactory::CLIENT_ID_HEADER, 'operations-spa'); - $operationDefinition = new Definition(OperationType::QUERY, 'MyClass', 'someMethod', 'method', 'docs', []); - $operation = new Operation( - 'somekey', - $operationDefinition, - fn () => $typeParser->parse('array{name: string}'), - fn () => $typeParser->parse('array{id: string, name: string}'), - ); - $controllerInstance = new class () { public function someMethod(array $input, null $context, Client $client): array { @@ -396,10 +388,21 @@ public function someMethod(array $input, null $context, Client $client): array $client->redirect('/docs/123'); $client->invalidate('docs'); - throw new RuntimeException('the save did not happen after all'); + throw new \RuntimeException('the save did not happen after all'); } }; + // The real class name, not a placeholder: the handler throws, so the server reflects this + // scope's #[Throws] declarations - a class that does not exist would escape as a + // ReflectionException instead of presenting the 500. + $operationDefinition = new Definition(OperationType::QUERY, $controllerInstance::class, 'someMethod', 'method', 'docs', []); + $operation = new Operation( + 'somekey', + $operationDefinition, + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), + ); + $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); diff --git a/tests/Feature/ErrorHandling/ConflictException.php b/tests/Feature/ErrorHandling/ConflictException.php new file mode 100644 index 0000000..3f4dd80 --- /dev/null +++ b/tests/Feature/ErrorHandling/ConflictException.php @@ -0,0 +1,15 @@ + + */ +final class DecoyDeclaringMiddleware implements MiddlewareContract +{ + #[Throws(SharedException::class, name: 'shared_from_middleware')] + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + { + return $next($input); + } +} diff --git a/tests/Feature/ErrorHandling/ErrorScopeOperations.php b/tests/Feature/ErrorHandling/ErrorScopeOperations.php new file mode 100644 index 0000000..33f779b --- /dev/null +++ b/tests/Feature/ErrorHandling/ErrorScopeOperations.php @@ -0,0 +1,104 @@ + true]; + } + + /** + * @param array{value: string} $data + * @return array{ok: bool} + */ + #[Command('errors')] + #[Middleware(DecoyDeclaringMiddleware::class)] + public function throwsWhatOnlyMiddlewareDeclares(array $data): array + { + throw new SharedException(); + } + + /** + * @param array{value: string} $data + * @return array{ok: bool} + */ + #[Command('errors')] + #[Middleware(SelfDeclaringMiddleware::class)] + public function middlewareOwnDomainError(array $data): array + { + return ['ok' => true]; + } + + /** + * @param array{value: string} $data + * @return array{ok: bool} + */ + #[Command('errors')] + #[Throws(MissingResourceException::class, type: ErrorType::NOT_FOUND)] + public function throwsMappedNotFound(array $data): array + { + throw new MissingResourceException(); + } + + /** + * @param array{value: string} $data + * @return array{ok: bool} + */ + #[Command('errors')] + public function throwsUnclassified(array $data): array + { + throw match ($data['value']) { + 'unauthenticated' => new SessionExpiredException(), + 'unauthenticated-subclass' => new TokenExpiredException(), + 'unauthorized' => new ForbiddenException(), + 'not-found' => new GoneException(), + default => new RuntimeException('plain boom'), + }; + } + + /** + * @param array{value: string} $data + * @return array{ok: bool} + */ + #[Command('errors')] + #[Throws(ConflictException::class, name: 'conflict')] + public function throwsDeclaredAndConfigured(array $data): array + { + throw new ConflictException(); + } +} diff --git a/tests/Feature/ErrorHandling/ForbiddenException.php b/tests/Feature/ErrorHandling/ForbiddenException.php new file mode 100644 index 0000000..9bcaab7 --- /dev/null +++ b/tests/Feature/ErrorHandling/ForbiddenException.php @@ -0,0 +1,11 @@ + + */ +final class SelfDeclaringMiddleware implements MiddlewareContract +{ + #[Throws(MiddlewareOwnException::class, name: 'middleware_own_error')] + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + { + throw new MiddlewareOwnException(); + } +} diff --git a/tests/Feature/ErrorHandling/SessionExpiredException.php b/tests/Feature/ErrorHandling/SessionExpiredException.php new file mode 100644 index 0000000..f7096af --- /dev/null +++ b/tests/Feature/ErrorHandling/SessionExpiredException.php @@ -0,0 +1,11 @@ + + */ +final class UndeclaredThrowingMiddleware implements MiddlewareContract +{ + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + { + throw new SharedException(); + } +} diff --git a/tests/Feature/Mocks/GloballyThrowingMiddleware.php b/tests/Feature/Mocks/GloballyThrowingMiddleware.php index b6efdaf..2747b65 100644 --- a/tests/Feature/Mocks/GloballyThrowingMiddleware.php +++ b/tests/Feature/Mocks/GloballyThrowingMiddleware.php @@ -13,8 +13,10 @@ use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; /** - * Registered through ServerConfiguration::withMiddlewares() rather than #[Middleware], which is - * the case that never reached ExposedExceptions. + * Registered through ServerConfiguration::withMiddlewares() rather than #[Middleware]. It runs for + * every operation, which is exactly why its #[Throws(..., name: ...)] must be ignored: a global + * middleware cannot contribute domain errors, so this throw surfaces as a 500 and codegen refuses + * the declaration outright. * * @implements MiddlewareContract */ diff --git a/tests/Feature/Operations/InvalidNameException.php b/tests/Feature/Operations/InvalidNameException.php index fa08e22..c39ef7d 100644 --- a/tests/Feature/Operations/InvalidNameException.php +++ b/tests/Feature/Operations/InvalidNameException.php @@ -6,7 +6,7 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\ExposeAs; -#[ExposeAs('invalid_name')] +#[ExposeAs(name: 'invalid_name')] final class InvalidNameException extends \Exception { } diff --git a/tests/Feature/ServerErrorHandlingTest.php b/tests/Feature/ServerErrorHandlingTest.php new file mode 100644 index 0000000..0b083f0 --- /dev/null +++ b/tests/Feature/ServerErrorHandlingTest.php @@ -0,0 +1,119 @@ +command($name, ['value' => $value], null, new NullClient()); +} + +test('a domain error declared on the handler and thrown by it carries its name', function () { + $error = executeErrorOperation('errors.throwsDeclaredDomainError'); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->statusCode)->toBe(400) + ->and($error->details)->toEqual(['name' => 'teapot']); +}); + +test('a declaration on the handler does not cover a middleware throwing the same exception', function () { + $error = executeErrorOperation('errors.declaresButMiddlewareThrows'); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($error->details)->toBeNull() + ->and($error->cause)->toBeInstanceOf(SharedException::class); +}); + +test('a declaration on a middleware does not cover the handler throwing the same exception', function () { + $error = executeErrorOperation('errors.throwsWhatOnlyMiddlewareDeclares'); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($error->details)->toBeNull() + ->and($error->cause)->toBeInstanceOf(SharedException::class); +}); + +test('a middleware naming its own throw yields that domain error', function () { + $error = executeErrorOperation('errors.middlewareOwnDomainError'); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->details)->toEqual(['name' => 'middleware_own_error']); +}); + +test('a #[Throws] with an explicit category maps the throw for its own scope', function () { + $error = executeErrorOperation('errors.throwsMappedNotFound'); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::NOT_FOUND) + ->and($error->statusCode)->toBe(404) + ->and($error->details)->toBeNull(); +}); + +test('the configured lists classify what no scope declared', function (string $value, ErrorType $expected) { + $configuration = new ServerConfiguration()->withExceptions( + notFound: [GoneException::class], + unauthenticated: [SessionExpiredException::class], + unauthorized: [ForbiddenException::class], + ); + + $error = executeErrorOperation('errors.throwsUnclassified', $value, $configuration); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe($expected) + ->and($error->details)->toBeNull(); +})->with([ + 'unauthenticated' => ['unauthenticated', ErrorType::AUTHENTICATION_ERROR], + 'unauthenticated subclass' => ['unauthenticated-subclass', ErrorType::AUTHENTICATION_ERROR], + 'unauthorized' => ['unauthorized', ErrorType::AUTHORIZATION_ERROR], + 'not found' => ['not-found', ErrorType::NOT_FOUND], + 'unrecognised stays internal' => ['anything-else', ErrorType::INTERNAL_ERROR], +]); + +test('the throwing scope declaration wins over a configured category for the same exception', function () { + // The deleted ErrorPresenter resolved the configured lists first, so a listed exception stayed + // in its category even when named. Deliberately inverted: the scope that threw knows best what + // its own exception means, and the lists are the fallback. + $configuration = new ServerConfiguration()->withExceptions(unauthenticated: [ConflictException::class]); + + $error = executeErrorOperation('errors.throwsDeclaredAndConfigured', configuration: $configuration); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::DOMAIN_ERROR) + ->and($error->details)->toEqual(['name' => 'conflict']); +}); + +test('an unknown operation is not found with no resolve info', function () { + $error = errorHandlingServer()->command('errors.doesNotExist', ['value' => 'x'], null, new NullClient()); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::NOT_FOUND) + ->and($error->details)->toBeNull() + ->and($error->resolveInfo)->toBeNull(); +}); diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 6e8c4bb..00e7ed5 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -53,14 +53,14 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError expect($error)->toBeInstanceOf(RpcError::class) ->and($error->type)->toBe(ErrorType::DOMAIN_ERROR) ->and($error->details)->toEqual([ - 'type' => 'invalid_name', + 'name' => 'invalid_name', ]); }); /** * The end of the road for a ValidationException: the value object rejects, the parse fails, and the * messages it chose come out the other side as the 422 the client reads. Nothing along the way - - * InvalidInputException, ErrorPresenter, RpcError - is allowed to flatten them back to a key. + * InvalidInputException, the server's presentation, RpcError - is allowed to flatten them back to a key. */ test('a value object rejecting with ValidationException reaches the client as a 422 naming each message', function () { $error = executeOperation('test.acceptEmail', ['email' => '']); @@ -92,15 +92,13 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError // Named, not a TypeError from inside the adapter: the class-string is checked before // anything is constructed, so the message says which class and which contract. // - // Two things fail here, and the chain keeps both: the class is rejected as a middleware, and - // then reflecting the same class to work out what the operation exposes fails as well. The - // second one is the most recent and is what made this a 500, so it is the cause. + // Nothing was executing yet, so no scope's #[Throws] declarations are consulted and nothing is + // reflected: the rejection itself is the cause, with no secondary failure to chain. expect($result)->toBeInstanceOf(RpcError::class) ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($result->cause)->toBeInstanceOf(ReflectionException::class) - ->and($result->previous)->toHaveCount(1) - ->and($result->previous[0])->toBeInstanceOf(TypeError::class) - ->and($result->previous[0]->getMessage())->toContain(NotAMiddleware::class); + ->and($result->cause)->toBeInstanceOf(TypeError::class) + ->and($result->cause->getMessage())->toContain(NotAMiddleware::class) + ->and($result->previous)->toBe([]); }); test('Middleware emits typescript middleware', function () { @@ -112,7 +110,7 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError ); $operation = $server->registry->get(OperationType::COMMAND, 'test.run'); - $domainErrors = ErrorTypescript::domainTypesFor($server->configuration, $operation->definition); + $domainErrors = ErrorTypescript::domainTypesFor($operation->definition); expect($domainErrors)->toBe('"invalid_name"'); }); @@ -157,10 +155,11 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError ->and($result->cause)->toBeInstanceOf(InvalidOutputException::class); }); -test('a globally configured middleware contributes its #[Throws] to the runtime and the codegen', function () { - // Definition::$middleware only ever held what #[Middleware] put there, so a #[Throws] on a - // middleware registered through ServerConfiguration was ignored by both the presenter and the - // generated error union - the exception surfaced as a 500. +test('a globally configured middleware cannot contribute domain errors', function () { + // Domain errors belong to the operation: its own method or the middleware it declared via + // #[Middleware]. A middleware registered through ServerConfiguration applies to every operation, + // so a #[Throws(..., name: ...)] there would leak one operation's vocabulary into all of them - + // the declaration is ignored, the exception surfaces as a 500, and the union never names it. $registry = EagerlyLoadedOperationRegistry::eagerlyDiscover( __DIR__.'/Operations', keyGenerator: new PlainlyExposedKeyGenerator(), @@ -171,27 +170,27 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError $error = $server->command('test.run', ['name' => 'global-boom'], null, new NullClient()); expect($error)->toBeInstanceOf(RpcError::class) - ->and($error->type)->toBe(ErrorType::DOMAIN_ERROR) - ->and($error->details)->toEqual(['type' => 'global_middleware_failed']); + ->and($error->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($error->details)->toBeNull(); $domainErrors = ErrorTypescript::domainTypesFor( - $configuration, $registry->get(OperationType::COMMAND, 'test.run')->definition, ); - expect($domainErrors)->toContain('"global_middleware_failed"'); + expect($domainErrors)->not->toContain('"global_middleware_failed"'); }); -test('an operation level declaration still wins over a global one for the same exception', function () { +test('an operation scoped middleware still names its own throw with a global middleware present', function () { $registry = EagerlyLoadedOperationRegistry::eagerlyDiscover( __DIR__.'/Operations', keyGenerator: new PlainlyExposedKeyGenerator(), ); $configuration = new ServerConfiguration()->withMiddlewares(GloballyThrowingMiddleware::class); - // test.run declares InvalidNameException itself; the global middleware must not displace it. + // NameCheckingMiddleware is declared by test.run via #[Middleware] and throws from its own + // ring: the global middleware contributes nothing and displaces nothing. $error = new Server($registry, configuration: $configuration) ->command('test.run', ['name' => 'invalid'], null, new NullClient()); - expect($error->details)->toEqual(['type' => 'invalid_name']); + expect($error->details)->toEqual(['name' => 'invalid_name']); }); diff --git a/tests/Mocks/Errors/ExposedDomainException.php b/tests/Mocks/Errors/ExposedDomainException.php index 7f607c6..f1cdb6d 100644 --- a/tests/Mocks/Errors/ExposedDomainException.php +++ b/tests/Mocks/Errors/ExposedDomainException.php @@ -7,7 +7,7 @@ use Exception; use Le0daniel\PhpTsBindings\Contracts\Attributes\ExposeAs; -#[ExposeAs('domain_failure')] +#[ExposeAs(name: 'domain_failure')] final class ExposedDomainException extends Exception { } diff --git a/tests/Mocks/Errors/MiddlewareDomainException.php b/tests/Mocks/Errors/MiddlewareDomainException.php index c4c37ba..542283c 100644 --- a/tests/Mocks/Errors/MiddlewareDomainException.php +++ b/tests/Mocks/Errors/MiddlewareDomainException.php @@ -7,7 +7,7 @@ use Exception; use Le0daniel\PhpTsBindings\Contracts\Attributes\ExposeAs; -#[ExposeAs('middleware_failure')] +#[ExposeAs(name: 'middleware_failure')] final class MiddlewareDomainException extends Exception { } diff --git a/tests/Mocks/Errors/UndeclaredExposedException.php b/tests/Mocks/Errors/UndeclaredExposedException.php index 97725c8..8a2a195 100644 --- a/tests/Mocks/Errors/UndeclaredExposedException.php +++ b/tests/Mocks/Errors/UndeclaredExposedException.php @@ -10,7 +10,7 @@ /** * Carries #[ExposeAs], but no operation declares it via #[Throws]: it must not become a domain error. */ -#[ExposeAs('undeclared_failure')] +#[ExposeAs(name: 'undeclared_failure')] final class UndeclaredExposedException extends Exception { } diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 1aab686..f00ef90 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -7,8 +7,8 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\CodeGen\Data\TypedOperation; -use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\Data\IO; +use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\Operation; @@ -72,11 +72,21 @@ function emitTypesFor(string $inputType, string $outputType): string ]); /** - * The reserved list is the catalogue itself, not a copy of it: a name added to one and forgotten in - * the other is a user alias that silently generates a second, conflicting declaration. + * The reserved list and the declarations are two literals in EmitTypes, so this is the guard + * against drift between them: a declaration added to the heredoc and forgotten in the reserved + * list is a user alias that silently generates a second, conflicting declaration. */ -test('every envelope the catalogue declares is reserved', function () { - foreach (ErrorTypescript::envelopeNames() as $name) { +test('every declaration the types file always contains is reserved', function () { + $types = new EmitTypes()->emitFiles( + [], + new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new AliasRegistry(), + )['types']->toString(); + + preg_match_all('/^export type (\w+)/m', $types, $matches); + expect($matches[1])->not->toBe([]); + + foreach ($matches[1] as $name) { expect(fn () => new EmitTypes()->emitFiles( [], new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), @@ -100,32 +110,33 @@ function emitTypesFor(string $inputType, string $outputType): string ->toContain('export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"};') ->toContain('export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"};') ->toContain('export type NotFoundError = {code: 404, type: "NOT_FOUND"};') - ->toContain('export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}};') + ->toContain('export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}};') ->toContain('export type InternalError = {code: 500, type: "INTERNAL_ERROR"};') ->toContain('export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error};'); }); /** - * The catalogue is closed, so Failure is the union of what this server can produce rather than a - * hole for whatever a caller passes. What remains parameterised is the only thing an operation can - * add to it: the names it exposed. + * The catalogue is closed, so Failure is the union of all of it rather than a hole for whatever a + * caller passes. What remains parameterised is the only thing an operation can add to it: the + * names it exposed. */ -test('Failure is the union of the categories the server can produce, not a type parameter', function () { +test('Failure is the union of the whole catalogue, not a type parameter', function () { $types = emitTypesFor( 'array{id: \\'.UserId::class.'}', 'array{email: \\'.Email::class.'}', ); expect($types) - ->toContain('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError);') + ->toContain('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError);') ->not->toContain('{code: number}'); }); /** - * Declared unconditionally, referenced only where reachable: naming a branch this server cannot - * produce would claim it can, while reserving the name costs nothing. + * Which of an application's exceptions land in which category is runtime configuration, and the + * union does not shrink around it: every branch is always reachable, whatever the server was + * configured with. */ -test('an unmapped auth category is declared but stays out of Failure', function () { +test('the auth branches are in Failure without any exceptions mapped onto them', function () { $types = emitTypesFor( 'array{id: \\'.UserId::class.'}', 'array{email: \\'.Email::class.'}', @@ -133,10 +144,35 @@ function emitTypesFor(string $inputType, string $outputType): string preg_match('/^export type Failure.*$/m', $types, $matches); - expect($types)->toContain('export type AuthenticationError =') - ->toContain('export type AuthorizationError =') - ->and($matches[0])->not->toContain('AuthenticationError') - ->and($matches[0])->not->toContain('AuthorizationError'); + expect($matches[0])->toContain('AuthenticationError') + ->toContain('AuthorizationError'); +}); + +test('every name the failure union references is declared in the same file', function () { + $types = emitTypesFor( + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.'}', + ); + + preg_match('/^export type Failure.*& \((.*)\);$/m', $types, $matches); + expect($matches[1] ?? '')->not->toBe(''); + + foreach (explode('|', $matches[1]) as $reference) { + expect($types)->toContain('export type '.strtok($reference, '<')); + } +}); + +test('every ErrorType case has an envelope carrying its discriminant', function () { + // The catalogue is a plain literal, so this is the guard against a category added to + // ErrorType without a TypeScript shape to describe it. + $types = emitTypesFor( + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.'}', + ); + + foreach (ErrorType::cases() as $type) { + expect($types)->toContain('type: "'.$type->name.'"'); + } }); test('the envelope names the client side channel without describing what is in it', function () { diff --git a/tests/Unit/CodeGen/ErrorTypescriptTest.php b/tests/Unit/CodeGen/ErrorTypescriptTest.php index 84f7e78..e989b4d 100644 --- a/tests/Unit/CodeGen/ErrorTypescriptTest.php +++ b/tests/Unit/CodeGen/ErrorTypescriptTest.php @@ -5,9 +5,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Tests\Mocks\Errors\ErrorOperations; -use Tests\Mocks\Errors\RecordMissingException; use Tests\Mocks\Errors\RenamingMiddleware; use Tests\Mocks\Errors\ThrowingMiddleware; @@ -27,128 +25,30 @@ function typescriptDefinition(string $methodName = 'declaresThrows', array $midd ); } -// Every branch is declared once in the generated types file, and Failure is the union of the ones -// this server can produce. Only the domain branch varies per operation, which is why it is the only -// envelope that takes a type argument - and the only thing an operation contributes to its own -// error type. -const INVALID_INPUT_ENVELOPE = 'InvalidInputError'; -const UNAUTHENTICATED_ENVELOPE = 'AuthenticationError'; -const UNAUTHORIZED_ENVELOPE = 'AuthorizationError'; -const NOT_FOUND_ENVELOPE = 'NotFoundError'; -const DOMAIN_ENVELOPE = 'DomainError'; -const INTERNAL_ENVELOPE = 'InternalError'; -const CLIENT_ENVELOPE = 'ClientError'; - -/* What Failure is, per server. */ - -test('an unconfigured server only unions the branches it can actually produce', function () { - expect(ErrorTypescript::failureUnion(new ServerConfiguration()))->toBe(implode('|', [ - INVALID_INPUT_ENVELOPE, - NOT_FOUND_ENVELOPE, - DOMAIN_ENVELOPE, - INTERNAL_ENVELOPE, - CLIENT_ENVELOPE, - ])); -}); - -test('the authentication branch appears once unauthenticated exceptions are configured', function () { - $configuration = new ServerConfiguration()->withExceptions(unauthenticated: [RecordMissingException::class]); - - expect(ErrorTypescript::failureUnion($configuration))->toBe(implode('|', [ - INVALID_INPUT_ENVELOPE, - UNAUTHENTICATED_ENVELOPE, - NOT_FOUND_ENVELOPE, - DOMAIN_ENVELOPE, - INTERNAL_ENVELOPE, - CLIENT_ENVELOPE, - ])); -}); - -test('the authorization branch appears once unauthorized exceptions are configured', function () { - $configuration = new ServerConfiguration()->withExceptions(unauthorized: [RecordMissingException::class]); - - expect(ErrorTypescript::failureUnion($configuration))->toBe(implode('|', [ - INVALID_INPUT_ENVELOPE, - UNAUTHORIZED_ENVELOPE, - NOT_FOUND_ENVELOPE, - DOMAIN_ENVELOPE, - INTERNAL_ENVELOPE, - CLIENT_ENVELOPE, - ])); -}); - -/** - * Unlike the auth branches, this one is not gated on anything the server was configured with: an - * operation exposing nothing instantiates it with `never`, and the declaration erases it there. A - * second gate here would only mean saying `never` twice. - */ -test('the domain branch is always in the union, carrying the parameter Failure declares', function () { - expect(ErrorTypescript::failureUnion(new ServerConfiguration())) - ->toContain('DomainError<'.ErrorTypescript::DOMAIN_TYPE_PARAMETER.'>'); -}); - -/** - * The one branch no server ever sends: the request never got there. It is appended here rather than - * by a caller so the whole union has a single owner, and so what a client can hand back is pinned by - * the same tests as what the server can. - */ -test('the client envelope closes the union, whatever the server is configured with', function (ServerConfiguration $configuration) { - $union = ErrorTypescript::failureUnion($configuration); - - expect($union)->toEndWith('|'.CLIENT_ENVELOPE) - ->and(substr_count($union, CLIENT_ENVELOPE))->toBe(1); -})->with([ - 'nothing configured' => [new ServerConfiguration()], - 'auth configured' => [ - new ServerConfiguration()->withExceptions(unauthenticated: [RecordMissingException::class]), - ], - 'both configured' => [ - new ServerConfiguration()->withExceptions( - unauthenticated: [RecordMissingException::class], - unauthorized: [RecordMissingException::class], - ), - ], -]); - -test('every name the union references is a name the catalogue declares', function () { - $referenced = explode('|', ErrorTypescript::failureUnion( - new ServerConfiguration()->withExceptions( - unauthenticated: [RecordMissingException::class], - unauthorized: [RecordMissingException::class], - ), - )); - - foreach ($referenced as $reference) { - expect(ErrorTypescript::envelopeNames())->toContain(strtok($reference, '<')); - } -}); - -/* What an operation contributes to it. */ - test('the domain types list every exposed exception the operation declares', function () { $domainTypes = ErrorTypescript::domainTypesFor( - new ServerConfiguration(), typescriptDefinition('declaresThrows', [ThrowingMiddleware::class]), ); expect($domainTypes)->toBe('"domain_failure"|"middleware_failure"'); }); -test('a domain type is named by the as of a Throws, not by the ExposeAs it overrides', function () { - $domainTypes = ErrorTypescript::domainTypesFor(new ServerConfiguration(), typescriptDefinition('declaresRenamedThrows')); +test('a domain type is named by the name of a Throws, not by the ExposeAs it overrides', function () { + $domainTypes = ErrorTypescript::domainTypesFor(typescriptDefinition('declaresRenamedThrows')); expect($domainTypes)->toBe('"renamed_failure"|"overridden_failure"') ->and($domainTypes)->not->toContain('domain_failure'); }); -test('an exception declared by both the operation and a middleware appears once', function () { +test('each scope contributes its own name for a shared exception, and the union carries both', function () { + // At runtime the name depends on which scope threw: the operation throwing + // ExposedDomainException answers overridden_failure, the middleware throwing the same class + // answers middleware_name. Both are reachable, so both belong in the union. $domainTypes = ErrorTypescript::domainTypesFor( - new ServerConfiguration(), typescriptDefinition('declaresRenamedThrows', [RenamingMiddleware::class]), ); - expect($domainTypes)->toBe('"renamed_failure"|"overridden_failure"|"renamed_middleware_failure"') - ->and($domainTypes)->not->toContain('middleware_name'); + expect($domainTypes)->toBe('"renamed_failure"|"overridden_failure"|"renamed_middleware_failure"|"middleware_name"|"middleware_named_it"'); }); /** @@ -156,53 +56,15 @@ function typescriptDefinition(string $methodName = 'declaresThrows', array $midd * that operation's Failure, so the erasure and the emptiness are the same fact. */ test('an operation declaring nothing exposable exposes never', function () { - expect(ErrorTypescript::domainTypesFor(new ServerConfiguration(), typescriptDefinition('declaresNothing'))) - ->toBe(ErrorTypescript::NO_DOMAIN_TYPES); + expect(ErrorTypescript::domainTypesFor(typescriptDefinition('declaresNothing'))) + ->toBe('never'); }); test('a #[Throws] lacking ExposeAs contributes no name', function () { // declaresThrows declares UnexposedException alongside ExposedDomainException, so only the // exposed one may be listed. - $domainTypes = ErrorTypescript::domainTypesFor(new ServerConfiguration(), typescriptDefinition('declaresThrows')); + $domainTypes = ErrorTypescript::domainTypesFor(typescriptDefinition('declaresThrows')); expect($domainTypes)->toBe('"domain_failure"') ->and($domainTypes)->not->toContain('UnexposedException'); }); - -/* The declarations themselves. */ - -test('the catalogue declares one envelope per branch, and the client one the server never sends', function () { - expect(ErrorTypescript::envelopeDeclarations()) - ->toContain('export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fields: Record}};') - ->toContain('export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"};') - ->toContain('export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"};') - ->toContain('export type NotFoundError = {code: 404, type: "NOT_FOUND"};') - ->toContain('export type InternalError = {code: 500, type: "INTERNAL_ERROR"};') - ->toContain('export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error};'); -}); - -/** - * The conditional is what makes `never` mean "no 400 branch" rather than "a 400 whose name is - * uninhabited". The brackets keep it from distributing, so two exposed names stay one member with a - * union under details.type instead of splitting into two. - */ -test('the domain envelope erases itself rather than describing an uninhabited 400', function () { - expect(ErrorTypescript::envelopeDeclarations()) - ->toContain('export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}};'); -}); - -/** - * Declared even where unreachable: a name a consumer writes a handler against costs nothing to - * reserve, whereas a Failure naming a branch this server cannot produce would be a lie. - */ -test('every name the catalogue lists is a name it declares', function () { - foreach (ErrorTypescript::envelopeNames() as $name) { - expect(ErrorTypescript::envelopeDeclarations())->toContain("export type {$name}"); - } -}); - -test('an envelope is named without its type argument, which is what an import statement takes', function () { - foreach (ErrorTypescript::envelopeNames() as $name) { - expect($name)->not->toContain('<'); - } -}); diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountLockedException.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountLockedException.php index fedeb9f..4713508 100644 --- a/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountLockedException.php +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountLockedException.php @@ -10,7 +10,7 @@ /** * Declared with #[Throws] and exposed, so it becomes one branch of the operation's DOMAIN_ERROR. */ -#[ExposeAs('account_locked')] +#[ExposeAs(name: 'account_locked')] final class AccountLockedException extends Exception { } diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/Types/QuotaExceededException.php b/tests/Unit/CodeGen/Mocks/TsOutput/Types/QuotaExceededException.php index b9910c0..4eb6708 100644 --- a/tests/Unit/CodeGen/Mocks/TsOutput/Types/QuotaExceededException.php +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/QuotaExceededException.php @@ -11,7 +11,7 @@ * The second exposed exception of one operation: the DOMAIN_ERROR details become a union, which is * what the client discriminates on. */ -#[ExposeAs('quota_exceeded')] +#[ExposeAs(name: 'quota_exceeded')] final class QuotaExceededException extends Exception { } diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 5d9b735..369ce6a 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -29,6 +29,7 @@ use Tests\Unit\CodeGen\Mocks\NameClashOperations; use Tests\Unit\CodeGen\Mocks\NamedOperations; use Tests\Unit\CodeGen\Mocks\InheritingOperations; +use Tests\Feature\Mocks\GloballyThrowingMiddleware; use Tests\Unit\CodeGen\Mocks\PerDirectionNamedOperations; use Tests\Unit\CodeGen\Mocks\UnrepresentableOperations; use Tests\Unit\CodeGen\Mocks\UserOperations; @@ -104,18 +105,15 @@ function generateFor(array $classes, ?array $generators = null): array }); /** - * Nothing maps onto the two auth categories on this server, so neither is reachable — and a Failure - * naming a branch the server cannot produce would say otherwise. The declarations stay: they are - * names a consumer may still write a handler against. + * Every category is always in the union: which of an application's exceptions land in which + * category is runtime configuration, and the union does not shrink around it. */ -test('the failure union names only the categories this server can produce', function () { +test('the failure union always names the whole catalogue', function () { $types = generateFor([UserOperations::class])['lib/types.ts']->toString(); preg_match('/^export type Failure.*$/m', $types, $matches); expect($matches[0]) - ->toBe('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError);') - ->and($types)->toContain('export type AuthenticationError =') - ->toContain('export type AuthorizationError ='); + ->toBe('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError);'); }); test('the branch shapes are declared once, not restated per operation', function () { @@ -299,6 +297,23 @@ function generateFor(array $classes, ?array $generators = null): array ]))->toThrow(InvalidGeneratorDependencies::class); }); +test('fails the run when a globally configured middleware declares a domain error', function () { + // The runtime silently ignores the declaration and answers 500, so build time is where a + // domain error on a global middleware gets refused loudly, naming the middleware. + $server = new Server( + EagerlyLoadedOperationRegistry::withClasses([UserOperations::class], keyGenerator: new PlainlyExposedKeyGenerator()), + configuration: new ServerConfiguration()->withMiddlewares(GloballyThrowingMiddleware::class), + ); + + expect(fn () => new TypescriptServerCodeGenerator([ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations(), + ])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}', $server->configuration))) + ->toThrow(CodeGenException::class, GloballyThrowingMiddleware::class); +}); + test('fails the run when two classes resolve to the same name with different shapes', function () { expect(fn () => generateFor([ConflictingNamedOperations::class])) ->toThrow(UnsupportedTypeException::class, 'Customer'); diff --git a/tests/Unit/Server/Data/RpcErrorTest.php b/tests/Unit/Server/Data/RpcErrorTest.php index bb6a1c4..6a43c92 100644 --- a/tests/Unit/Server/Data/RpcErrorTest.php +++ b/tests/Unit/Server/Data/RpcErrorTest.php @@ -27,7 +27,7 @@ function errorInfo(): ResolveInfo test('a category that says everything on its own emits no details key', function (ErrorType $type) { $error = new RpcError($type, new RuntimeException('nope'), null, errorInfo()); - // Restating the category under `details.type` would be the same string on the wire twice, and + // Restating the category under `details` would be the same string on the wire twice, and // the generated branch declares no such property - narrowing on `type` must not offer one. expect($error->jsonSerialize())->not->toHaveKey('details'); })->with([ @@ -48,7 +48,7 @@ function errorInfo(): ResolveInfo $domain = new RpcError( ErrorType::DOMAIN_ERROR, new RuntimeException('nope'), - ['type' => 'invalid_name'], + ['name' => 'invalid_name'], errorInfo(), ); @@ -61,7 +61,7 @@ function errorInfo(): ResolveInfo 'success' => false, 'code' => 400, 'type' => 'DOMAIN_ERROR', - 'details' => ['type' => 'invalid_name'], + 'details' => ['name' => 'invalid_name'], ]); }); @@ -81,7 +81,7 @@ function errorInfo(): ResolveInfo // An error result holds no Client at all, and that is the point: a handler that toasts "Saved" // and then throws must not have that toast reach the browser. Whatever was queued before the // failure is dropped with the request. - $error = new RpcError(ErrorType::DOMAIN_ERROR, new RuntimeException('nope'), ['type' => 'x'], errorInfo()) + $error = new RpcError(ErrorType::DOMAIN_ERROR, new RuntimeException('nope'), ['name' => 'x'], errorInfo()) ->withMetadata(['some' => 'metadata']); expect($error->jsonSerialize())->not->toHaveKey('__client') diff --git a/tests/Unit/Server/Errors/ErrorPresenterTest.php b/tests/Unit/Server/Errors/ErrorPresenterTest.php deleted file mode 100644 index a62f71e..0000000 --- a/tests/Unit/Server/Errors/ErrorPresenterTest.php +++ /dev/null @@ -1,264 +0,0 @@ - $middleware - */ -function errorDefinition(string $methodName = 'declaresThrows', array $middleware = []): Definition -{ - return new Definition( - OperationType::COMMAND, - ErrorOperations::class, - $methodName, - 'test', - 'errors', - // @phpstan-ignore-next-line -- tests intentionally pass unresolvable class names. - $middleware, - ); -} - -function errorResolveInfo(): ResolveInfo -{ - return new ResolveInfo('errors', 'test', OperationType::COMMAND, ErrorOperations::class, 'declaresThrows', []); -} - -test('invalid input yields a 422 carrying the field issues', function () { - $exception = new InvalidInputException(new Failure(Issues::fromMessages(['name' => 'Is required']))); - - $error = new ErrorPresenter(new ServerConfiguration()) - ->present($exception, errorDefinition(), errorResolveInfo()); - - expect($error->type)->toBe(ErrorType::INVALID_INPUT) - ->and($error->cause)->toBe($exception) - // The fields and nothing else: the category already says this is INVALID_INPUT. - ->and($error->details)->toEqual([ - 'fields' => $exception->failure->issues->serializeToFieldsArray(), - ]); -}); - -test('a configured unauthenticated exception yields a 401', function () { - $configuration = new ServerConfiguration()->withExceptions(unauthenticated: [RecordMissingException::class]); - - $error = new ErrorPresenter($configuration) - ->present(new RecordMissingException(), errorDefinition(), null); - - expect($error->type)->toBe(ErrorType::AUTHENTICATION_ERROR) - ->and($error->details)->toBeNull(); -}); - -test('a configured unauthorized exception yields a 403', function () { - $configuration = new ServerConfiguration()->withExceptions(unauthorized: [RecordMissingException::class]); - - $error = new ErrorPresenter($configuration) - ->present(new RecordMissingException(), errorDefinition(), null); - - expect($error->type)->toBe(ErrorType::AUTHORIZATION_ERROR) - ->and($error->details)->toBeNull(); -}); - -test('a configured not found exception yields a 404', function () { - $configuration = new ServerConfiguration()->withExceptions(notFound: [RecordMissingException::class]); - - $error = new ErrorPresenter($configuration) - ->present(new RecordMissingException(), errorDefinition(), null); - - expect($error->type)->toBe(ErrorType::NOT_FOUND) - ->and($error->details)->toBeNull(); -}); - -test('subclasses of a configured exception match, matching is instanceof and not exact class', function () { - $configuration = new ServerConfiguration()->withExceptions(notFound: [RecordMissingException::class]); - - $error = new ErrorPresenter($configuration) - ->present(new UserMissingException(), errorDefinition(), null); - - expect($error->type)->toBe(ErrorType::NOT_FOUND); -}); - -test('an unknown operation yields a 404 without a definition to reflect on', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new OperationNotFoundException('nope'), null, null); - - expect($error->type)->toBe(ErrorType::NOT_FOUND) - ->and($error->details)->toBeNull() - ->and($error->resolveInfo)->toBeNull(); -}); - -test('an exposed exception declared on the operation yields a 400 named after the ExposeAs type', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new ExposedDomainException(), errorDefinition(), null); - - expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) - ->and($error->details)->toEqual(['type' => 'domain_failure']); -}); - -test('an exposed exception declared on a middleware yields a 400', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new MiddlewareDomainException(), errorDefinition('declaresNothing', [ThrowingMiddleware::class]), null); - - expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) - ->and($error->details)->toEqual(['type' => 'middleware_failure']); -}); - -test('the as name of a Throws exposes an exception that carries no ExposeAs', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new UnexposedException(), errorDefinition('declaresRenamedThrows'), null); - - expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) - ->and($error->details)->toEqual(['type' => 'renamed_failure']); -}); - -test('the as name of a Throws wins over the ExposeAs on the exception', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new ExposedDomainException(), errorDefinition('declaresRenamedThrows'), null); - - expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) - ->and($error->details)->toEqual(['type' => 'overridden_failure']); -}); - -test('a middleware can name the exceptions it declares too', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new MiddlewareDomainException(), errorDefinition('declaresNothing', [RenamingMiddleware::class]), null); - - expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) - ->and($error->details)->toEqual(['type' => 'renamed_middleware_failure']); -}); - -test('the operation names an exception before the middleware wrapping it does', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new ExposedDomainException(), errorDefinition('declaresRenamedThrows', [RenamingMiddleware::class]), null); - - expect($error->details)->toEqual(['type' => 'overridden_failure']); -}); - -test('a Throws without a name never silences one that has a name', function () { - // declaresThrows declares UnexposedException without naming it; the middleware does name it. - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new UnexposedException(), errorDefinition('declaresThrows', [RenamingMiddleware::class]), null); - - expect($error->type)->toBe(ErrorType::DOMAIN_ERROR) - ->and($error->details)->toEqual(['type' => 'middleware_named_it']); -}); - -test('an as name does not exempt an exception from the configured categories', function () { - $configuration = new ServerConfiguration()->withExceptions(notFound: [UnexposedException::class]); - - $error = new ErrorPresenter($configuration) - ->present(new UnexposedException(), errorDefinition('declaresRenamedThrows'), null); - - expect($error->type)->toBe(ErrorType::NOT_FOUND); -}); - -test('a declared exception without ExposeAs falls through to the catch all', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new UnexposedException(), errorDefinition(), null); - - expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($error->details)->toBeNull(); -}); - -test('an ExposeAs exception the operation never declares falls through to the catch all', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new UndeclaredExposedException(), errorDefinition(), null); - - expect($error->type)->toBe(ErrorType::INTERNAL_ERROR); -}); - -test('an unmapped exception yields a 500 and keeps its cause and resolve info', function () { - $exception = new RuntimeException('boom'); - $info = errorResolveInfo(); - - $error = new ErrorPresenter(new ServerConfiguration())->present($exception, errorDefinition(), $info); - - expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($error->details)->toBeNull() - ->and($error->cause)->toBe($exception) - ->and($error->previous)->toBe([]) - ->and($error->resolveInfo)->toBe($info); -}); - -test('the configured categories are resolved before the exposed domain error', function () { - $configuration = new ServerConfiguration()->withExceptions(notFound: [ExposedDomainException::class]); - - $error = new ErrorPresenter($configuration) - ->present(new ExposedDomainException(), errorDefinition(), null); - - expect($error->type)->toBe(ErrorType::NOT_FOUND); -}); - -test('authentication and authorization are resolved before not found', function () { - $configuration = new ServerConfiguration()->withExceptions( - notFound: [RecordMissingException::class], - unauthorized: [RecordMissingException::class], - ); - - $error = new ErrorPresenter($configuration) - ->present(new RecordMissingException(), errorDefinition(), null); - - expect($error->type)->toBe(ErrorType::AUTHORIZATION_ERROR); -}); - -test('a definition that cannot be reflected yields a 500 instead of escaping', function () { - $error = new ErrorPresenter(new ServerConfiguration()) - ->present(new ExposedDomainException(), errorDefinition('declaresNothing', ['Tests\Mocks\Errors\DoesNotExist']), null); - - expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($error->details)->toBeNull(); -}); - -test('a failure to present becomes the cause and pushes the original into previous', function () { - $exception = new ExposedDomainException(); - - $error = new ErrorPresenter(new ServerConfiguration()) - ->present($exception, errorDefinition('declaresNothing', ['Tests\Mocks\Errors\DoesNotExist']), null); - - // The reflection failure is the most recent thing that went wrong, so it is the cause; the - // exception the application threw is what came before it. - expect($error->cause)->not->toBe($exception) - ->and($error->previous)->toBe([$exception]); -}); - -test('internalError produces the last resort shape', function () { - $exception = new RuntimeException('presenter blew up'); - $info = errorResolveInfo(); - - $error = ErrorPresenter::internalError($exception, $info); - - expect($error->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($error->details)->toBeNull() - ->and($error->cause)->toBe($exception) - ->and($error->previous)->toBe([]) - ->and($error->resolveInfo)->toBe($info); -}); - -test('internalError carries the previous failures oldest first', function () { - $original = new RuntimeException('the application blew up'); - $latest = new RuntimeException('presenting it blew up too'); - - $error = ErrorPresenter::internalError($latest, errorResolveInfo(), [$original]); - - expect($error->cause)->toBe($latest) - ->and($error->previous)->toBe([$original]) - ->and($error->throwableChain())->toBe([$original, $latest]); -}); diff --git a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php index b28a88e..df3d7cd 100644 --- a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php +++ b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php @@ -13,6 +13,7 @@ use Le0daniel\PhpTsBindings\Server\Data\ResolveInfo; use Le0daniel\PhpTsBindings\Server\Data\RpcError; use Le0daniel\PhpTsBindings\Server\Data\RpcSuccess; +use Le0daniel\PhpTsBindings\Server\Errors\ExceptionScope; use Le0daniel\PhpTsBindings\Server\Pipeline\ContextualPipeline; use RuntimeException; use stdClass; @@ -38,13 +39,13 @@ function trace(RpcSuccess|RpcError $result, string $entry): RpcSuccess|RpcError /** * @param list> $middlewares * @param Closure(mixed): (RpcSuccess|RpcError) $destination - * @param (Closure(Throwable): RpcError)|null $onError + * @param (Closure(Throwable, ExceptionScope): RpcError)|null $onError */ function runPipeline(array $middlewares, Closure $destination, ?Closure $onError = null): RpcSuccess|RpcError { return new ContextualPipeline( middlewares: $middlewares, - onError: $onError ?? fn (Throwable $throwable): RpcError => new RpcError( + onError: $onError ?? fn (Throwable $throwable, ?ExceptionScope $scope): RpcError => new RpcError( ErrorType::INTERNAL_ERROR, $throwable, ['type' => 'PRESENTED'], @@ -164,6 +165,51 @@ function (): RpcSuccess { ->and($result->cause->getMessage())->toBe('destination exploded'); }); +test('onError receives the full scope of the middleware whose ring threw', function () { + $seenScope = null; + $throwing = middleware(function (): RpcSuccess|RpcError { + throw new RuntimeException('inner exploded'); + }); + + runPipeline( + [ + middleware(fn (mixed $input, Closure $next): RpcSuccess|RpcError => $next($input)), + $throwing, + ], + fn (): RpcSuccess => succeed(), + function (Throwable $throwable, ExceptionScope $scope) use (&$seenScope): RpcError { + $seenScope = $scope; + + return new RpcError(ErrorType::INTERNAL_ERROR, $throwable, null, pipelineResolveInfo()); + }, + ); + + expect($seenScope)->toBeInstanceOf(ExceptionScope::class) + ->and($seenScope->className)->toBe($throwing::class) + ->and($seenScope->methodName)->toBe('handle'); +}); + +test('onError receives null when the destination threw', function () { + $seenScope = null; + + runPipeline( + // The middleware between the destination and the surface must not become the scope: the + // conversion happens where the throw happened, not at the outermost ring. + [middleware(fn (mixed $input, Closure $next): RpcSuccess|RpcError => $next($input))], + function (): RpcSuccess { + throw new RuntimeException('destination exploded'); + }, + function (Throwable $throwable, ?ExceptionScope $scope) use (&$seenScope): RpcError { + $seenScope = $scope; + + return new RpcError(ErrorType::INTERNAL_ERROR, $throwable, null, pipelineResolveInfo()); + }, + ); + + // The class and method come from the ResolveInfo the pipeline executes under. + expect($seenScope)->toBeNull(); +}); + test('an RpcError returned by a middleware travels outward untouched', function () { $presented = 0; @@ -178,7 +224,7 @@ function (): RpcSuccess { )), ], fn (): RpcSuccess => succeed(), - function (Throwable $throwable) use (&$presented): RpcError { + function (Throwable $throwable, ExceptionScope $scope) use (&$presented): RpcError { $presented++; return new RpcError(ErrorType::INTERNAL_ERROR, $throwable, ['type' => 'PRESENTED'], pipelineResolveInfo()); @@ -192,8 +238,11 @@ function (Throwable $throwable) use (&$presented): RpcError { ->and($presented)->toBe(0); }); -test('the pipeline still returns an RpcError when the error handler itself fails', function () { - $result = runPipeline( +test('a throwing error handler escapes the pipeline', function () { + // onError must never throw - presenting is the server's job and there is nobody here to ask + // for an envelope when presenting itself fails. Substituting one would only bury the bug, so + // the failure escapes as itself. + expect(fn () => runPipeline( [ middleware(function (): RpcSuccess|RpcError { throw new RuntimeException('inner exploded'); @@ -203,14 +252,5 @@ function (Throwable $throwable) use (&$presented): RpcError { function (): RpcError { throw new RuntimeException('the presenter is broken too'); }, - ); - - expect($result)->toBeInstanceOf(RpcError::class) - ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) - ->and($result->details)->toBeNull() - ->and($result->cause->getMessage())->toBe('the presenter is broken too') - // The failure that got the presenter called is not lost just because the presenter failed. - ->and($result->previous)->toHaveCount(1) - ->and($result->previous[0]->getMessage())->toBe('inner exploded') - ->and($result->resolveInfo?->fullyQualifiedName)->toBe('test.operation'); + ))->toThrow(RuntimeException::class, 'the presenter is broken too'); }); diff --git a/tests/ts-output/generated/lib/OperationException.ts b/tests/ts-output/generated/lib/OperationException.ts index 6d8e25a..bc8e501 100644 --- a/tests/ts-output/generated/lib/OperationException.ts +++ b/tests/ts-output/generated/lib/OperationException.ts @@ -3,7 +3,7 @@ import type {Failure} from './types'; /** - * Generic over the names the operation exposed, so `e.cause.details.type` narrows to those rather + * Generic over the names the operation exposed, so `e.cause.details.name` narrows to those rather * than to any string. The rest of the catalogue is the server's and needs no naming here. */ export class OperationException extends Error { diff --git a/tests/ts-output/generated/lib/types.ts b/tests/ts-output/generated/lib/types.ts index 6e6b04d..6cea3b9 100644 --- a/tests/ts-output/generated/lib/types.ts +++ b/tests/ts-output/generated/lib/types.ts @@ -12,12 +12,12 @@ export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fie export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"}; export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; export type NotFoundError = {code: 404, type: "NOT_FOUND"}; -export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {type: TType}}; +export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}}; export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error}; export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} -export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError); +export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); export type Result = Success | Failure; declare const __brand: unique symbol; diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index 9cdbf87..e7f3a33 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -43,6 +43,8 @@ export async function readProduct(): Promise { // exception itself rather than anything that came off the wire, and it arrives intact. console.error('request failed', result.cause.message); return null; + case 401: + case 403: case 404: case 500: // `details` only exists where the category cannot say everything on its own. Here it @@ -177,8 +179,8 @@ export async function lockAccount(id: number): Promise Date: Tue, 11 Aug 2026 17:21:44 +0200 Subject: [PATCH 079/101] Enhance `CLIENT_ERROR` handling in TypeScript client to include raw HTTP response details, improve TypeScript typing accuracy, and update documentation and test coverage accordingly. --- README.md | 8 +- docs/errors.md | 42 +++++--- docs/typescript-client.md | 13 ++- .../EmitOperationClientBindings.php | 51 ++++++---- src/CodeGen/CodeGenerators/EmitTypeUtils.php | 36 +++++++ src/CodeGen/CodeGenerators/EmitTypes.php | 2 +- .../Exceptions/InvalidOutputException.php | 3 + src/Server/Server.php | 95 +++++++++++-------- .../EmitOperationClientBindingsTest.php | 58 ++++++++--- tests/Unit/CodeGen/EmitTypeUtilsTest.php | 28 ++++++ tests/Unit/CodeGen/EmitTypesTest.php | 2 +- .../Mocks/TsOutput/ShapeOperations.php | 2 +- .../ts-output/generated/lib/DefaultClient.ts | 30 +++--- .../generated/lib/OperationException.ts | 10 +- tests/ts-output/generated/lib/types.ts | 2 +- tests/ts-output/generated/lib/utils.ts | 36 +++++++ tests/ts-output/src/usage.ts | 64 ++++++++++++- 17 files changed, 368 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 06b3a6e..8285a6b 100644 --- a/README.md +++ b/README.md @@ -324,8 +324,12 @@ operation handler or a middleware's `handle()` — decides the category, and onl declared nothing do the configured category lists apply. Everything unrecognised is a 500. A client has one more failure available to it, and no server sends it: `CLIENT_ERROR`, code 0, for -the request that never arrived. It carries the exception that stopped it under `cause` instead of -`details`. +the request that never arrived — or was answered by something other than the server, like a CSRF +middleware's 419 or a proxy's error page. The generated client never trusts the HTTP status line: +only a body carrying the envelope above is reported as the server's answer (the check is +`isValidEnvelop` from `lib/utils.ts`), and anything else becomes `CLIENT_ERROR`, carrying the +exception that stopped it under `cause` instead of `details`, plus the raw `response` +(`httpStatusCode`, and `jsonResponse` when the body parsed as JSON) when HTTP answered at all. Exposing a domain error takes both a declaration and a name — `#[Throws]` on the throwing scope, and either `name:` on that declaration or `#[ExposeAs]` on the exception class: diff --git a/docs/errors.md b/docs/errors.md index c494f82..2745651 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -40,9 +40,9 @@ which of *its* exceptions belong in which category, with exist. Six is what a *server* can answer. A client has one more failure available to it — the request that -never arrived — and that one is [`CLIENT_ERROR`](#the-client-error), code 0. It is deliberately not -an `ErrorType`: nothing on the server can produce it, and giving the server a case for it would -be claiming otherwise. +never arrived, or was answered by something other than the server — and that one is +[`CLIENT_ERROR`](#the-client-error), code 0. It is deliberately not an `ErrorType`: nothing on the +server can produce it, and giving the server a case for it would be claiming otherwise. ## Exposing a domain error @@ -101,7 +101,7 @@ export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; export type NotFoundError = {code: 404, type: "NOT_FOUND"}; export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}}; export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; -export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error}; +export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error, response?: {httpStatusCode: number, jsonResponse?: unknown}}; ``` Because the catalogue is closed, `Failure` is the *union* of all of it rather than a hole for @@ -153,27 +153,40 @@ function isWorthRetrying(error: ClientError | InternalError): boolean { /* ... * ## The client error -**`CLIENT_ERROR` is the branch no server sends.** The request never got there — the network was -down, the response was not JSON, the call was cancelled — so there is no envelope to report and the -client mints one, with code 0 and the exception itself under `cause`: +**`CLIENT_ERROR` is the branch no server sends.** The request never got there — or something +answered in the server's place. The network was down, the call was cancelled, a CSRF middleware +answered 419 with its own JSON, a proxy answered 502 with an HTML page, a framework wrapped a 200 +around garbage. `DefaultClient` never consults the status line: every response goes through +`isValidEnvelop` from `lib/utils.ts`, and only a body that is the server's own envelope — `success`, +and on failure a known `type` with the `code` that type owns — is reported as the server's answer. +Anything else mints this branch, with code 0 and the exception itself under `cause`: ```typescript const result = await lock({id}); if (!result.success && result.code === 0) { - console.error(result.cause.message); // cause: Error + console.error(result.cause.message); // cause: Error + console.warn(result.response?.httpStatusCode); // 419, when HTTP answered at all + console.debug(result.response?.jsonResponse); // the body, when it parsed as JSON } ``` -It is carried rather than summarised, which matters for cancellation: `throwOnFailure` rethrows an -`AbortError` as the `DOMException` it was, so a Tanstack refetch aborting its predecessor is not -reported as a failed query. A re-wrapped copy would no longer be that exception. +When an HTTP response did arrive, what was received survives under `response`: `httpStatusCode` +always, `jsonResponse` only when the body parsed as JSON. A request that never completed has no +`response` key at all, so its presence is what separates "something answered wrongly" from "nothing +answered". It lives on the envelope only — `throwOnFailure` rethrows the bare `cause`, so there is +no `response` to find in a catch block. + +The cause is carried rather than summarised, which matters for cancellation: `throwOnFailure` +rethrows an `AbortError` as the `DOMException` it was, so a Tanstack refetch aborting its +predecessor is not reported as a failed query. A re-wrapped copy would no longer be that exception. Every client can produce it, and no signature has to say so: the branch is part of every `Failure`, so `OperationClient.execute` returning `Promise>` already includes it whatever the operation exposed. There is nothing for an implementation to remember to add. Reached through `OperationException`, the envelope is `e.cause` and the original exception is -`e.cause.cause`; `e.isClientError` is the shorter way to ask. +`e.cause.cause`; `e.isClientError()` is the shorter way to ask — a type guard, so past it `e.cause` +*is* the client branch. ## When `details` appears @@ -183,8 +196,9 @@ domain error it is. For the other four, `code` and `type` are the whole answer a under `details` would put the same string on the wire twice, so the key is absent — and the generated branch has no such property, so narrowing on `type` will not offer you one. -`CLIENT_ERROR` has no `details` either. What it carries instead is `cause`, and that is a live -`Error` rather than anything that came off the wire — the one branch whose payload was never JSON. +`CLIENT_ERROR` has no `details` either. What it carries instead is `cause` — a live `Error` rather +than anything that came off the wire — and, when an HTTP response did arrive, the raw `response` +next to it. This is why `jsonSerialize()` is the only thing that gets the envelope exactly right: it omits the key rather than sending `null`, which is what the generated union declares. diff --git a/docs/typescript-client.md b/docs/typescript-client.md index a3c49d2..211deb7 100644 --- a/docs/typescript-client.md +++ b/docs/typescript-client.md @@ -46,7 +46,7 @@ Every call resolves to: ```typescript export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} export type Failure = {success: false, __metadata?: Record} - & (InvalidInputError|NotFoundError|DomainError|InternalError|ClientError); + & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); export type Result = Success | Failure; ``` @@ -96,6 +96,15 @@ runs a callback on every response and returns a function that unregisters it. Sw transport by implementing `OperationClient` — `setClient()` and the per-call `options.client` both take one. +The status line is never consulted. Anything between the browser and the handler — a CSRF +middleware, a throttler, a proxy's error page — can write both a status and a body, so every +response goes through `isValidEnvelop` from `lib/utils.ts` instead: a valid envelope (success or +failure) is returned exactly as parsed, whatever the status said, and anything else becomes +[`CLIENT_ERROR`](errors.md#the-client-error) with the raw `response` (`httpStatusCode`, and +`jsonResponse` when the body parsed as JSON) attached. The guard is exported, so a payload from +anywhere else — SSR state, a cache — can be believed or refused the same way before being read as +an envelope. + The URLs come from `ServerMetadata('/query/{fqn}', '/command/{fqn}')`, the two routes *your* transport serves. `{fqn}` is where the operation key goes, and both are required to contain it. @@ -114,7 +123,7 @@ try { } catch (e) { if (OperationException.is(e)) { e.cause.type; // "INVALID_INPUT" | "NOT_FOUND" | ... - e.code; // the HTTP code, 500 if the payload had none + e.code; // the category's code — only a validated envelope ever gets here } throw e; } diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index be2b950..a41a542 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -15,8 +15,8 @@ use Override; /** - * Not readonly: the EmitTypes its files import from is injected after construction, which is the - * only way it can be the same instance the generator runs. + * Not readonly: the EmitTypes and EmitTypeUtils its files import from are injected after + * construction, which is the only way they can be the same instances the generator runs. */ final class EmitOperationClientBindings implements DependsOn, GeneratesLibFiles { @@ -30,6 +30,8 @@ final class EmitOperationClientBindings implements DependsOn, GeneratesLibFiles private EmitTypes $types; + private EmitTypeUtils $utils; + /** * One method per file this generator writes, so nothing outside spells a file name it does not * own. Not static: reaching them means declaring the dependency, and a declared dependency that @@ -91,6 +93,7 @@ public function dependsOnGenerator(): array { return [ EmitTypes::class, + EmitTypeUtils::class, ]; } @@ -101,6 +104,10 @@ public function setDependencies(array $dependencies): void EmitTypes::class, $dependencies[EmitTypes::class] ?? null, ); + $this->utils = Assertions::instanceOf( + EmitTypeUtils::class, + $dependencies[EmitTypeUtils::class] ?? null, + ); } /** @@ -219,23 +226,24 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi body: type === 'command' ? JSON.stringify(input) : undefined, }); - const json = await response.json(); - if (!json || typeof json !== 'object') { - throw new Error('Invalid response body. Could not parse json correctly.'); - } - - // Spread first: whatever the server put next to the envelope — a client's directives, say — - // rides along untyped rather than being dropped by a transport that never knew about it. - if (response.ok) { - return await this.callHooks({...json, success: true} as Success); + // The status line is never consulted: anything between the browser and the handler can + // write one, so only the body can prove the server answered. A valid envelope is + // returned exactly as parsed, success or failure — whatever the server put next to it + // (a client's directives, say) rides along untouched. + const json: unknown = await response.json().catch(() => undefined); + if (isValidEnvelop(json)) { + return await this.callHooks(json as Result); } return await this.callHooks({ - ...json, success: false, - code: json?.code ?? response.status, - type: json?.type ?? 'INTERNAL_ERROR' - } as Failure); + code: 0, + type: 'CLIENT_ERROR', + cause: new Error(`Invalid response envelope (HTTP status ${response.status})`), + response: json === undefined + ? {httpStatusCode: response.status} + : {httpStatusCode: response.status, jsonResponse: json}, + } satisfies Failure); } catch (e: unknown) { // Anything thrown between here and the response being read: the request never completed, // so there is no server error to report and the cause is the answer. It is carried as @@ -259,7 +267,8 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } TypeScript, [ $this->importFromOperationClient(types: ['OperationClient', 'OperationOptions']), - $this->types->importFromTypes(types: ['Failure', 'Result', 'Success']), + $this->types->importFromTypes(types: ['Failure', 'Result']), + $this->utils->importFromUtils(values: ['isValidEnvelop']), ]), self::OPERATION_EXCEPTION_FILE => new TypescriptFile(<<<'TypeScript' /** @@ -270,10 +279,12 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi public readonly cause: Failure; /** - * The request never reached the server, so nothing here came off the wire and `cause.cause` - * holds the exception that actually stopped it. + * No server answered this one — the request never left, or what came back was not the server's + * envelope — so nothing on it came off the wire and `cause.cause` holds the exception that + * stopped it. A method rather than a getter, because TypeScript allows a type predicate only on + * a function: calling it narrows `cause` to the client branch. */ - get isClientError(): boolean { + public isClientError(): this is OperationException & {cause: Failure & ClientError} { return this.cause.code === 0; } @@ -291,7 +302,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi } } TypeScript, [ - $this->types->importFromTypes(types: ['Failure']), + $this->types->importFromTypes(types: ['ClientError', 'Failure']), ]), self::BINDINGS_FILE => new TypescriptFile(<< = { + DOMAIN_ERROR: 400, + AUTHENTICATION_ERROR: 401, + AUTHORIZATION_ERROR: 403, + NOT_FOUND: 404, + INVALID_INPUT: 422, + INTERNAL_ERROR: 500, +}; + +/** + * Whether a value is an envelope the server can have sent. Anything between the browser and the + * handler — a CSRF middleware, a proxy error page — can answer with a status and a body, so + * `success`, `type` and `code` have to be present and agree with the catalogue before a body is + * believed. The typeof check on `code` is load-bearing: an unknown type looked up in the map + * yields undefined, and a missing code must not match it. + */ +export function isValidEnvelop(value: unknown): value is Result { + if (!value || typeof value !== 'object') { + return false; + } + + const {success, code, type} = value as Record; + if (success === true) { + return 'data' in value; + } + + return success === false + && typeof type === 'string' + && typeof code === 'number' + && SERVER_ERROR_CODES[type] === code; +} + /** * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather * catch than branch. diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 7a393ed..20379f6 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -101,7 +101,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi export type NotFoundError = {code: 404, type: "NOT_FOUND"}; export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}}; export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; -export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error}; +export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error, response?: {httpStatusCode: number, jsonResponse?: unknown}}; export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); diff --git a/src/Server/Data/Exceptions/InvalidOutputException.php b/src/Server/Data/Exceptions/InvalidOutputException.php index 46b5880..1e253c8 100644 --- a/src/Server/Data/Exceptions/InvalidOutputException.php +++ b/src/Server/Data/Exceptions/InvalidOutputException.php @@ -8,6 +8,9 @@ use Le0daniel\PhpTsBindings\Executor\Data\Issues; use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; +/** + * @internal + */ final class InvalidOutputException extends SchemaException { public Issues $issues { diff --git a/src/Server/Server.php b/src/Server/Server.php index aff5426..69444e9 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -45,7 +45,8 @@ public function __construct( public OperationRegistry $registry, private ServerAdapter $adapter = new NewInstanceAdapter(), public ServerConfiguration $configuration = new ServerConfiguration(), - ) { + ) + { $this->executor = new SchemaExecutor(); $this->classifier = new ErrorClassifier( authenticationExceptions: $configuration->unauthenticatedExceptions, @@ -54,28 +55,28 @@ public function __construct( ); } - public function query(string $name, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess + public function query(string $key, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { - if (!$this->registry->has(OperationType::QUERY, $name)) { + if (!$this->registry->has(OperationType::QUERY, $key)) { return $this->present( - new OperationNotFoundException("Operation with name: {$name} was not found."), + new OperationNotFoundException("Operation with key: {$key} was not found."), null, ); } - return $this->execute($this->registry->get(OperationType::QUERY, $name), $input, $context, $client); + return $this->execute($this->registry->get(OperationType::QUERY, $key), $input, $context, $client); } - public function command(string $name, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess + public function command(string $key, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { - if (!$this->registry->has(OperationType::COMMAND, $name)) { + if (!$this->registry->has(OperationType::COMMAND, $key)) { return $this->present( - new OperationNotFoundException("Operation with name: {$name} was not found."), + new OperationNotFoundException("Operation with key: {$key} was not found."), null, ); } - return $this->execute($this->registry->get(OperationType::COMMAND, $name), $input, $context, $client); + return $this->execute($this->registry->get(OperationType::COMMAND, $key), $input, $context, $client); } private function execute(Operation $operation, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess @@ -99,7 +100,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli // middleware must surface as an RpcError, not as an uncaught exception. Nothing was // executing yet, so there is no scope whose declarations could apply. try { - $middlewares = array_map(fn ($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); + $middlewares = array_map(fn($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); $controllerClass = $this->adapter->createController($operation->definition->fullyQualifiedClassName); } catch (Throwable $throwable) { return $this->present($throwable, $resolveInfo); @@ -107,7 +108,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli return new ContextualPipeline( middlewares: $middlewares, - onError: fn (Throwable $throwable, ?ExceptionScope $scope): RpcError => $this->present( + onError: fn(Throwable $throwable, ?ExceptionScope $scope): RpcError => $this->present( $throwable, $resolveInfo, $scope, @@ -176,47 +177,57 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli * Presenting may itself throw (a stale class name failing reflection): that is a bug in the * setup, not a request-time condition, and it is allowed to escape. * - * @param ExceptionScope|null $scope the scope the exception came from, or null when nothing + * @param ExceptionScope|null $scope the scope the exception came from, or null when nothing * was executing (unknown operation, resolution failure) or the failure is the server's own * (input/output mismatch): only the classifier applies. * * @throws ReflectionException */ private function present( - Throwable $throwable, - ?ResolveInfo $info, + Throwable $throwable, + ?ResolveInfo $info, ?ExceptionScope $scope = null, - ): RpcError { - // If a scope is given, try to resolve the exception within its own declarations. A - // globally configured middleware may not expose domain errors - it runs for every - // operation, so a domain vocabulary there would leak into all of them. - if ($scope) { - $definedExceptions = ThrowAttributeResolver::resolveReflection( - $scope->toReflection(), - allowDomainErrors: !in_array($scope->className, $this->configuration->middleware, true), - )['data']; - - foreach ($definedExceptions as $className => $presentConfig) { - if ($throwable instanceof $className) { - return new RpcError( - type: $presentConfig['type'], - cause: $throwable, - details: isset($presentConfig['name']) ? ['name' => $presentConfig['name']] : null, - resolveInfo: $info, - ); + ): RpcError + { + try { + // If a scope is given, try to resolve the exception within its own declarations. A + // globally configured middleware may not expose domain errors - it runs for every + // operation, so a domain vocabulary there would leak into all of them. + if ($scope) { + $definedExceptions = ThrowAttributeResolver::resolveReflection( + $scope->toReflection(), + allowDomainErrors: !in_array($scope->className, $this->configuration->middleware, true), + )['data']; + + foreach ($definedExceptions as $className => $presentConfig) { + if ($throwable instanceof $className) { + return new RpcError( + type: $presentConfig['type'], + cause: $throwable, + details: isset($presentConfig['name']) ? ['name' => $presentConfig['name']] : null, + resolveInfo: $info, + ); + } } } - } - $type = $this->classifier->classify($throwable); + $type = $this->classifier->classify($throwable); - return new RpcError( - type: $type, - cause: $throwable, - details: $type === ErrorType::INVALID_INPUT && $throwable instanceof InvalidInputException - ? ['fields' => $throwable->failure->issues->serializeToFieldsArray()] - : null, - resolveInfo: $info, - ); + return new RpcError( + type: $type, + cause: $throwable, + details: $type === ErrorType::INVALID_INPUT && $throwable instanceof InvalidInputException + ? ['fields' => $throwable->failure->issues->serializeToFieldsArray()] + : null, + resolveInfo: $info, + ); + } catch (Throwable $throwable) { + return new RpcError( + type: ErrorType::INTERNAL_ERROR, + cause: $throwable, + details: null, + resolveInfo: $info, + ); + } } } diff --git a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php index 7d2315e..93c3120 100644 --- a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php +++ b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitOperationClientBindings; use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypes; +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators\EmitTypeUtils; use Le0daniel\PhpTsBindings\CodeGen\Data\ServerMetadata; use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Typescript\Code\TypescriptFile; @@ -23,7 +24,10 @@ function bindingFiles(): array { $emitter = new EmitOperationClientBindings(); - $emitter->setDependencies([EmitTypes::class => new EmitTypes()]); + $emitter->setDependencies([ + EmitTypes::class => new EmitTypes(), + EmitTypeUtils::class => new EmitTypeUtils(), + ]); return $emitter->emitFiles( [], @@ -50,10 +54,11 @@ function bindingFiles(): array ]], 'DefaultClient' => ['DefaultClient', [ './lib/OperationClient' => ['values' => [], 'types' => ['OperationClient', 'OperationOptions']], - './lib/types' => ['values' => [], 'types' => ['Failure', 'Result', 'Success']], + './lib/types' => ['values' => [], 'types' => ['Failure', 'Result']], + './lib/utils' => ['values' => ['isValidEnvelop'], 'types' => []], ]], 'OperationException' => ['OperationException', [ - './lib/types' => ['values' => [], 'types' => ['Failure']], + './lib/types' => ['values' => [], 'types' => ['ClientError', 'Failure']], ]], // DefaultClient is constructed, so it is a value import; a type only import would leave // `new DefaultClient(...)` referencing nothing at runtime. @@ -105,20 +110,49 @@ function bindingFiles(): array }); /** - * The shape is declared once, in the types file, and referenced everywhere else. A second literal - * here would be a second definition free to drift from the one operations are typed against. + * The status line is never consulted: a CSRF middleware answering 419 with its own JSON, a proxy + * answering 502 with an HTML page, or a framework writing a 200 around garbage all set whatever + * status they like. Only the body can prove the server answered, so every response — ok or not — + * goes through the envelope guard and is returned exactly as parsed when it passes. + */ +test('every response goes through the envelope guard, whatever the status line said', function () { + expect(bindingFiles()['DefaultClient']->toString()) + ->toContain('const json: unknown = await response.json().catch(() => undefined);') + ->toContain('if (isValidEnvelop(json)) {') + ->toContain('return await this.callHooks(json as Result);') + ->not->toContain('response.ok') + ->not->toContain('json?.code ?? response.status') + ->not->toContain("json?.type ?? 'INTERNAL_ERROR'"); +}); + +/** + * What was actually received survives on the minted envelope: the status always, the parsed body + * only when there was one to parse — an absent key rather than an undefined value. */ +test('a response that is not the envelope becomes the client branch carrying what was received', function () { + expect(bindingFiles()['DefaultClient']->toString()) + ->toContain('cause: new Error(`Invalid response envelope (HTTP status ${response.status})`),') + ->toContain('? {httpStatusCode: response.status}') + ->toContain(': {httpStatusCode: response.status, jsonResponse: json},'); +}); + /** - * Zero is a real code, assigned by the client itself. The fallback guards a malformed envelope — a - * code that is not a number — and a falsy check would fold the client branch into it, reporting a - * request that never left as a 500 while isClientError says otherwise. + * Zero is a real code, assigned by the client itself. A method rather than a getter, because + * TypeScript allows a type predicate only on a function — and the predicate is what narrows + * `cause` to the client branch at the call site. */ -test('the exception reports the client code rather than treating zero as missing', function () { +test('the exception narrows to the client branch through a type-guard method', function () { expect(bindingFiles()['OperationException']->toString()) + ->toContain('public isClientError(): this is OperationException & {cause: Failure & ClientError} {') ->toContain('return this.cause.code === 0;') + ->not->toContain('get isClientError') ->not->toContain('!code ||'); }); +/** + * The shape is declared once, in the types file, and referenced everywhere else. A second literal + * here would be a second definition free to drift from the one operations are typed against. + */ test('no client file restates the envelope type it imports', function () { // The runtime literal it constructs is not the declaration: that one lives in the types file, // and a second copy here would be free to drift from what operations are typed against. @@ -176,14 +210,16 @@ function bindingFiles(): array expect($raw)->toBe([]); }); -test('every import names a file this generator emits, or the types file', function () { +// utils is allowed alongside types: the envelope guard the transport gates every response through +// is the utils generator's, declared as a dependency the same way EmitTypes is. +test('every import names a file this generator emits, the types file, or the utils file', function () { $emitted = array_keys(bindingFiles()); $unknown = []; foreach (bindingFiles() as $file) { foreach ($file->imports as $import) { $name = str_replace('./lib/', '', $import->from); - if ($name !== 'types' && ! in_array($name, $emitted, true)) { + if ($name !== 'types' && $name !== 'utils' && ! in_array($name, $emitted, true)) { $unknown[] = $import->from; } } diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index bbd8c85..3da643d 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -12,6 +12,7 @@ use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Data\Definition; +use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; @@ -83,6 +84,33 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp ->not->toContain('ClientRedirect'); }); +/** + * The guard is a public util, not transport-private: what the transport itself trusts, an + * application can reuse on any payload claiming to be an envelope. CLIENT_ERROR has no entry on + * purpose — that branch is minted by the client itself, so a body claiming it is never believed. + */ +test('isValidEnvelop only believes what the server can actually send', function () { + expect(emitUtilsFor()) + ->toContain('export function isValidEnvelop(value: unknown): value is Result {') + ->toContain('const SERVER_ERROR_CODES: Record = {') + ->toContain("&& typeof code === 'number'") + ->toContain('&& SERVER_ERROR_CODES[type] === code;') + ->not->toContain('CLIENT_ERROR: 0'); +}); + +/** + * The map in the emitted guard and the ErrorType enum are two literals, so this is the guard + * against drift between them: a case added to the enum and forgotten in the heredoc would make the + * client refuse a category the server really answers with. + */ +test('the guard map mirrors the ErrorType catalogue', function () { + $utils = emitUtilsFor(); + + foreach (ErrorType::cases() as $case) { + expect($utils)->toContain("{$case->name}: {$case->value},"); + } +}); + test('imports the envelope as types and the exception as a value', function () { // './lib/x' is what an emitter writes — the way a module at the output root reaches it. utils.ts // lands inside lib/ and reaches a sibling directly, which the orchestrator resolves; that form diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index f00ef90..8f80f94 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -112,7 +112,7 @@ function emitTypesFor(string $inputType, string $outputType): string ->toContain('export type NotFoundError = {code: 404, type: "NOT_FOUND"};') ->toContain('export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}};') ->toContain('export type InternalError = {code: 500, type: "INTERNAL_ERROR"};') - ->toContain('export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error};'); + ->toContain('export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error, response?: {httpStatusCode: number, jsonResponse?: unknown}};'); }); /** diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php b/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php index 76886b1..074b89e 100644 --- a/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php +++ b/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php @@ -31,7 +31,7 @@ final class ShapeOperations * literal: 'fixed', * answer: 42, * always: true, - * tags: list, + * tags: string[], * lookup: array, * pair: array{string, int}, * either: string|int, diff --git a/tests/ts-output/generated/lib/DefaultClient.ts b/tests/ts-output/generated/lib/DefaultClient.ts index 3bb8d16..ae5f610 100644 --- a/tests/ts-output/generated/lib/DefaultClient.ts +++ b/tests/ts-output/generated/lib/DefaultClient.ts @@ -1,7 +1,8 @@ // generated by: php-ts-bindings import type {OperationClient, OperationOptions} from './OperationClient'; -import type {Failure, Result, Success} from './types'; +import type {Failure, Result} from './types'; +import {isValidEnvelop} from './utils'; /** * A hook sees the envelope of any operation, so it is typed against the widest domain union rather @@ -88,23 +89,24 @@ export class DefaultClient implements OperationClient { body: type === 'command' ? JSON.stringify(input) : undefined, }); - const json = await response.json(); - if (!json || typeof json !== 'object') { - throw new Error('Invalid response body. Could not parse json correctly.'); - } - - // Spread first: whatever the server put next to the envelope — a client's directives, say — - // rides along untyped rather than being dropped by a transport that never knew about it. - if (response.ok) { - return await this.callHooks({...json, success: true} as Success); + // The status line is never consulted: anything between the browser and the handler can + // write one, so only the body can prove the server answered. A valid envelope is + // returned exactly as parsed, success or failure — whatever the server put next to it + // (a client's directives, say) rides along untouched. + const json: unknown = await response.json().catch(() => undefined); + if (isValidEnvelop(json)) { + return await this.callHooks(json as Result); } return await this.callHooks({ - ...json, success: false, - code: json?.code ?? response.status, - type: json?.type ?? 'INTERNAL_ERROR' - } as Failure); + code: 0, + type: 'CLIENT_ERROR', + cause: new Error(`Invalid response envelope (HTTP status ${response.status})`), + response: json === undefined + ? {httpStatusCode: response.status} + : {httpStatusCode: response.status, jsonResponse: json}, + } satisfies Failure); } catch (e: unknown) { // Anything thrown between here and the response being read: the request never completed, // so there is no server error to report and the cause is the answer. It is carried as diff --git a/tests/ts-output/generated/lib/OperationException.ts b/tests/ts-output/generated/lib/OperationException.ts index bc8e501..d99e797 100644 --- a/tests/ts-output/generated/lib/OperationException.ts +++ b/tests/ts-output/generated/lib/OperationException.ts @@ -1,6 +1,6 @@ // generated by: php-ts-bindings -import type {Failure} from './types'; +import type {ClientError, Failure} from './types'; /** * Generic over the names the operation exposed, so `e.cause.details.name` narrows to those rather @@ -10,10 +10,12 @@ export class OperationException extends Err public readonly cause: Failure; /** - * The request never reached the server, so nothing here came off the wire and `cause.cause` - * holds the exception that actually stopped it. + * No server answered this one — the request never left, or what came back was not the server's + * envelope — so nothing on it came off the wire and `cause.cause` holds the exception that + * stopped it. A method rather than a getter, because TypeScript allows a type predicate only on + * a function: calling it narrows `cause` to the client branch. */ - get isClientError(): boolean { + public isClientError(): this is OperationException & {cause: Failure & ClientError} { return this.cause.code === 0; } diff --git a/tests/ts-output/generated/lib/types.ts b/tests/ts-output/generated/lib/types.ts index 6cea3b9..a4f5d8b 100644 --- a/tests/ts-output/generated/lib/types.ts +++ b/tests/ts-output/generated/lib/types.ts @@ -14,7 +14,7 @@ export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; export type NotFoundError = {code: 404, type: "NOT_FOUND"}; export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}}; export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; -export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error}; +export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error, response?: {httpStatusCode: number, jsonResponse?: unknown}}; export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); diff --git a/tests/ts-output/generated/lib/utils.ts b/tests/ts-output/generated/lib/utils.ts index 0a6210c..dd59c58 100644 --- a/tests/ts-output/generated/lib/utils.ts +++ b/tests/ts-output/generated/lib/utils.ts @@ -9,6 +9,42 @@ export function queryKey(ns: QueryNamespaces, ...args: unknown[]): [string, ...u return [ns, ...args]; } +/** + * The wire discriminants a server can actually answer with, by name. CLIENT_ERROR has no entry on + * purpose: that branch is minted by the client itself, so a body claiming it is never believed. + */ +const SERVER_ERROR_CODES: Record = { + DOMAIN_ERROR: 400, + AUTHENTICATION_ERROR: 401, + AUTHORIZATION_ERROR: 403, + NOT_FOUND: 404, + INVALID_INPUT: 422, + INTERNAL_ERROR: 500, +}; + +/** + * Whether a value is an envelope the server can have sent. Anything between the browser and the + * handler — a CSRF middleware, a proxy error page — can answer with a status and a body, so + * `success`, `type` and `code` have to be present and agree with the catalogue before a body is + * believed. The typeof check on `code` is load-bearing: an unknown type looked up in the map + * yields undefined, and a missing code must not match it. + */ +export function isValidEnvelop(value: unknown): value is Result { + if (!value || typeof value !== 'object') { + return false; + } + + const {success, code, type} = value as Record; + if (success === true) { + return 'data' in value; + } + + return success === false + && typeof type === 'string' + && typeof code === 'number' + && SERVER_ERROR_CODES[type] === code; +} + /** * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather * catch than branch. diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index e7f3a33..ef3c56b 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -14,7 +14,7 @@ import {containsOperationSpaPayload} from '../generated/lib/client-operations-sp import {OperationException} from '../generated/lib/OperationException'; import type {Brand, ClientError, Failure, InternalError, Product} from '../generated/lib/types'; import type {TypeMap} from '../generated/lib/type-map'; -import {throwOnFailure} from '../generated/lib/utils'; +import {isValidEnvelop, throwOnFailure} from '../generated/lib/utils'; import {defaults, submit, useDefaultsQuery} from '../generated/shapes'; setClient(createDefaultClient(fetch)); @@ -296,3 +296,65 @@ export async function clientChannelIsNamedButNotDescribed(id: number): Promise { + const result = await product({id: productId}); + if (result.success) { + return; + } + + if (result.code === 0) { + // Only the client branch carries it, and it is optional: a request that never left has no + // response at all, and a non-JSON body has no jsonResponse. + const status: number | undefined = result.response?.httpStatusCode; + const body: unknown = result.response?.jsonResponse; + console.warn('not a server answer', status, body, result.cause.message); + return; + } + + // A real server failure has nothing raw to show — the envelope is the answer. + // @ts-expect-error + console.debug(result.response); +} + +/** + * The guard the transport itself trusts is exported, so a payload from anywhere else — SSR state, + * a cache — can be believed (or not) the same way, and past it the value is the envelope. + */ +export function readEmbeddedEnvelope(raw: unknown): unknown { + if (!isValidEnvelop(raw)) { + return null; + } + + return raw.success ? raw.data : raw.code; +} + +/** + * isClientError is a method and a type guard: past it, `cause` *is* the client branch — which a + * getter could never say, because TypeScript allows a predicate only on a function. + */ +export function reportFailure(error: OperationException): string { + // Before the guard the union still holds every branch, so the client-only keys are not there. + // @ts-expect-error + console.debug(error.cause.response); + + // The old getter shape is gone; an unmigrated call site reads a truthy function and fails to + // compile rather than silently taking every failure for a client one. + // @ts-expect-error + if (error.isClientError) { + console.debug('unreachable'); + } + + if (error.isClientError()) { + const cause: Error = error.cause.cause; + const status: number | undefined = error.cause.response?.httpStatusCode; + error.cause.type satisfies 'CLIENT_ERROR'; + return `${cause.message} (${status ?? 'no response'})`; + } + + return error.message; +} From d5c50aa77b1973b381387e7031a425c20dff762a Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 12 Aug 2026 13:24:40 +0200 Subject: [PATCH 080/101] Update `README.md` to clarify library goals, expand architecture details, and improve explanation of core concepts and server-client communication. --- README.md | 256 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 152 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 8285a6b..1acf2f7 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,13 @@ Type-safe RPC between a PHP backend and a TypeScript frontend, driven by the types you have already written. -You annotate a method with `#[Query]` or `#[Command]` and type its input and output with PHPStan -annotations. From that, this library validates every incoming request against those types, -serializes the response to match them, and generates a TypeScript client whose call signatures are -those same types. There is no schema to declare, no resource class to maintain, and no second source -of truth to keep in sync — the PHPStan type *is* the contract. +The goal is what server actions give a Next.js app — call a typed function on the client, have it +run on the server — built for PHP, and split explicitly into **queries** and **commands** (CQRS: +queries read, commands write). You annotate a method with `#[Query]` or `#[Command]` and type its +input and output with PHPStan annotations. From that, this library validates every incoming request +against those types, serializes the response to match them, and generates a TypeScript client whose +call signatures are those same types. There is no schema to declare, no resource class to maintain, +and no second source of truth to keep in sync — the PHPStan type *is* the contract. ```php /** @@ -25,15 +27,28 @@ export type GetResult = {email:(string & Brand<"email">);slug:string;}; const result = await get({id: userId}); ``` -**What it is not.** Not a validator — it proves the types your code declares, and nothing beyond -them. Not an ORM serializer. Not a schema DSL. If a rule cannot be expressed as a PHPStan type, this -library will not check it for you; [value objects](docs/types.md#value-objects) are where such rules -belong. - -Requires **PHP 8.5** and nothing else — no dependencies, no framework coupling. A first-party -[Laravel adapter](docs/laravel.md) ships in the box and is entirely optional. - ---- +Requires **PHP 8.5** and nothing else — no dependencies, no framework coupling on either side. Pair +it with React, Angular or vanilla TypeScript on the front, and any PHP framework or none on the +back. A first-party [Laravel adapter](docs/laravel.md) ships in the box and is entirely optional. + +## What this library is not + +Every line here is a decision, not a gap — [the decisions](#the-decisions) below carries the +reasoning for each. Read this before investing; it is the complete list of hard edges. + +- **Not a validator.** It proves the types your code declares, and nothing beyond them. Rich + validation is still needed — and it is not a middleware concern: it belongs in + [value objects](docs/types.md#value-objects) and DTOs. +- **Not an ORM serializer.** It does not work with Eloquent models or Laravel Collections, on + purpose. Return plain PHP objects a type checker can see through. +- **Not a framework.** No routing, no transport, no HTTP layer decided for you — and JSON only: + streams, files and other non-JSON responses are out of scope. +- **Not frontend-opinionated.** The generated client is plain TypeScript with zero runtime + dependencies; wire it into React, Angular, vanilla — anything. +- **Not useful without PHPStan.** Run it at level 6 or above, or the annotations this library + trusts as the contract are unchecked claims. +- **Not all of PHPStan.** The parser understands a deliberate subset; bare `array` and bare + `object` are rejected outright. [→ Types](#types) ## Documentation @@ -50,9 +65,8 @@ way it does. Each subsystem has its own reference. | [Client directives](docs/client-directives.md) | The optional `Client` side channel for toasts, redirects and cache invalidation. | | [The Laravel adapter](docs/laravel.md) | Config, routes, context, the `operations:*` artisan commands. | -On this page: [Install](#install) · [Quickstart](#quickstart) · [Core concepts](#core-concepts) · -[Design decisions](#design-decisions) · [Errors](#errors) · [Types](#types) · -[Contributing](#contributing) +On this page: [Install](#install) · [Quickstart](#quickstart) · [Architecture](#architecture) · +[Errors](#errors) · [Types](#types) · [Contributing](#contributing) ## Install @@ -70,6 +84,12 @@ includes: - vendor/le0daniel/php-ts-bindings/extension.neon ``` +The extension is half of it; the level is the other half. This library proves at runtime exactly +what your annotations claim — but only PHPStan proves that your annotations match your code. Run it +at **level 6 or above** (level 6 is where missing parameter and return types start being reported; +this library itself holds level 8). Below that, the contract your client compiles against has no +witness on the PHP side. + **On Laravel**, the service provider is auto-discovered, `config/operations.php` is publishable, and four `operations:*` artisan commands handle discovery, code generation and the production cache. The adapter wires this library up, it does not replace it — everything on this page still applies. @@ -138,7 +158,11 @@ $server = new Server( $files = new TypescriptServerCodeGenerator( CodeGenerators::fromDefaults('name'), -)->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}')); +)->generate($server, new ServerMetadata( + '/query/{fqn}', + '/command/{fqn}', + $server->configuration, +)); OutputDirectory::write(__DIR__ . '/resources/js/operations', $files); ``` @@ -149,7 +173,8 @@ opt-in — and what it returns is a plain list, so you can append your own or sk your own array. See [the generators](docs/typescript-client.md#generators) for the whole menu. The two URLs are the routes *your* transport serves; `{fqn}` is where the operation key goes, and -both are required to contain it. +both are required to contain it. The configuration rides along because which error categories the +generated `Failure` union names depends on how this server maps exceptions. **Serve those two routes.** One GET for queries, one POST for commands. `jsonSerialize()` is the whole envelope the generated client reads, so a transport is two lines: @@ -167,7 +192,9 @@ respondJson($result->statusCode, $result->jsonSerialize()); ``` Neither call ever throws — see [the server](docs/server.md#serving-operations-over-http) for the -full wiring, dependency injection and error reporting. +full wiring, dependency injection and error reporting. `$myContext` and that `NullClient` are two of +the three arguments every handler receives; [the three arguments](#the-three-arguments) below is +what they mean. **You get a `users.ts` module**, matching the namespace: @@ -195,7 +222,9 @@ if (result.success) { Passing `{id: 1}` is a compile error: `number` is not assignable to `UserId`'s branded type. Sending it anyway is a 422 at runtime, because the server proves the same type it published. -## Core concepts +## Architecture + +### One request through the server **`Server`** takes a registry of operations and runs one. Both methods are total — every `Throwable`, including a failure resolving your handler, comes back as an `RpcError`: @@ -205,63 +234,102 @@ public function query(string $name, mixed $input, mixed $context, Client $client public function command(string $name, mixed $input, mixed $context, Client $client): RpcSuccess|RpcError ``` +**Input is parsed, output is serialized.** Input arrives from outside, so every claim its type +makes is proven before your handler sees it — a mismatch is a 422. Output is your own code, so an +output that does not match its type is a 500 rather than something the client is asked to handle. +The PHPStan *refinements* on top of a type (`positive-int`, `non-empty-string`) are checked on the +way in only, because static analysis already established them on the way out. + **`$name` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns `namespace` + `name` into what the client calls: `PlainlyExposedKeyGenerator` gives literal keys, -`HashSha256KeyGenerator` opaque ones. The generated TypeScript always embeds whichever key the server -produced, so this only matters when you call the server by hand — but +`HashSha256KeyGenerator` opaque ones — which is not a security boundary, it only keeps your +operation names out of the shipped bundle. The generated TypeScript always embeds whichever key the +server produced, so this only matters when you call the server by hand — but [pass one explicitly](docs/server.md#operation-keys), because the discovery default is peppered with a publicly known string. -**`OperationRegistry`** holds the operations. `EagerlyLoadedOperationRegistry` discovers them by -scanning directories; schemas are parsed lazily, per operation, on first use. +**`OperationRegistry`** holds the operations: `EagerlyLoadedOperationRegistry` discovers them by +scanning directories (schemas are parsed lazily, per operation, on first use), and `CachedOperationRegistry` is the compiled form for [production](docs/server.md#production). - **`ServerAdapter`** builds your handler classes and middleware — two methods, and the seam for -dependency injection. `NewInstanceAdapter` is the default (`new $className()`, no constructor -arguments); `PsrContainerAdapter` resolves both through a PSR-11 container. A failure to resolve is -caught and returned as an `RpcError`, which is part of what keeps the server total. +dependency injection: `NewInstanceAdapter` is the default, `PsrContainerAdapter` resolves through a +PSR-11 container. A failure to resolve is caught and returned as an `RpcError`, which is part of +what keeps the server total. -**The handler contract.** Your method is called with exactly three arguments, positionally: - -```php -public function get(array $input, MyContext $context, Client $client): array -``` +**`RpcResult`** is the interface both outcomes implement. It carries `statusCode` — 200 on success, +the error category's own code otherwise — and it is `JsonSerializable`: `jsonSerialize()` produces +the whole envelope the generated client reads, so a transport is a status code and a body. +Middleware can attach metadata; it travels under `__metadata` and the library puts nothing in it. -`$input` is the parsed, validated input — already hydrated into whatever your type declares. **Its -type is the whole input contract.** `$context` is whatever you passed to `Server::query()`; the -library never touches it. `$client` is the [side channel](docs/client-directives.md) back to the -frontend. You may declare a prefix of the three, but not a subset. An operation that takes no input -types its parameter as `null`, and every generator drops the argument. +**[→ The server](docs/server.md)** for keys, registries, HTTP, preloading and the production cache. +**[→ Operations](docs/operations.md)** for the attributes, the full signature rules and middleware. -**Input is parsed, output is serialized.** Input is untrusted, so every claim its type makes is -proven. Output is checked against its declared type too, and an output that does not match is a 500. -The PHPStan *refinements* on top of the type are not re-checked on the way out, because static -analysis already established those. +### The three arguments -**`RpcResult`** is the interface both outcomes implement. It carries `statusCode` — 200 on success, -the error category's own code otherwise — `resolveInfo`, `metadata`, and it is `JsonSerializable`: -`jsonSerialize()` produces the whole envelope the generated client reads. A middleware can attach -metadata with `withMetadata()` / `appendMetadata()`; it travels under `__metadata` and the library -puts nothing in it. +Your method is called with exactly three arguments, positionally — input, context, client: -**[→ Operations](docs/operations.md)** for the attributes, the full signature rules and middleware. -**[→ The server](docs/server.md)** for keys, registries, HTTP, preloading and the production cache. +```php +public function get(array $input, MyContext $context, Client $client): array +``` -## Design decisions +You may declare a prefix of the three, never a subset: `($input)` and `($input, $context)` are +fine, skipping `$context` to reach `$client` is not. An operation that takes no input types its +parameter as `null`, and every generator drops the argument. + +**`$input` is the request.** Parsed, validated, hydrated into whatever your type declares. Its +PHPStan type is the whole input contract — by the time your handler runs, every claim that type +makes has been proven. + +**`$context` is everything else the operation needs.** To the library it is `mixed`: whatever you +pass to `Server::query()` arrives untouched. Put in it what your operations require — data from the +actual request, the authenticated user, the tenant. On Laravel, a +[`ContextFactory`](docs/laravel.md#context) builds it per request. + +**`$client` is the side channel back to the frontend.** Not for data — for what rides alongside it: +toasts, redirects, cache invalidations. The interface is closed — `toast()`, the `success()` / +`error()` / `warning()` / `alert()` / `info()` shorthands, `redirect()` and `invalidate()`; there is +no arbitrary-directive method. And the library ships the channel, not the behavior: nothing pops up +until *you* implement the hooks on the frontend — +[`registerHook()`](docs/typescript-client.md#wiring-up-the-transport) on the generated client sees +every envelope, and `containsOperationSpaPayload()` narrows the deliberately-`unknown` `__client` +into typed toasts, a redirect and invalidation keys. What a toast looks like, or what an +invalidation invalidates, is your frontend's decision. +**[→ Client directives](docs/client-directives.md)** + +**Context and client are the only two mutable things in the pipeline.** Both are created once per +request and passed through as-is — middleware and handler see and mutate the same two objects, and +they are the designated seams for state and side effects. Everything else moves by value: the input +is a freshly parsed value, and what comes back is a new envelope. + +### The decisions **The PHPStan type is the contract.** No schema DSL, no resource class, no generated PHP to keep beside your code. The annotation you already wrote for static analysis is the one the runtime proves and the generator emits, so there is no second source of truth that can drift. **It is not a validator.** It proves the types your code declares and nothing beyond them. A rule -that is not a type — "this email is not already taken" — belongs in a -[value object](docs/types.md#value-objects) or your own code, and can still -[ride the same 422](docs/errors.md#your-own-validation). - -**Input is parsed, output is serialized.** Input arrives from outside and every claim its type makes -is proven before your handler sees it. Output is your own code, so a mismatch is a 500 rather than -something the client is asked to handle — and refinements are checked on the way in only, because -static analysis already established them on the way out. +that is not a type — "this email is not already taken" — still needs writing, and it is not a +middleware concern: middleware is invisible to the type system and to the reader of the operation. +Rich validation belongs in [value objects](docs/types.md#value-objects) and DTOs, where the rule +travels with the type and can still [ride the same 422](docs/errors.md#your-own-validation). That +is the whole answer; there is no validation extension point because there is nothing to extend. + +**JSON is the transport, and PHP's `array` is two types.** The wire format forces a decision PHP +never makes you make: `array` is both a list and a dictionary, and JSON must pick `[]` or `{}`. So +bare `array` is rejected rather than guessed at — `list`, `array` and `array` +say which of the two you meant. The same commitment bounds the library: JSON in, JSON out, and +streams, files and other non-JSON responses are out of scope rather than unimplemented. + +**No Eloquent, no Collections — on purpose.** What an Eloquent model or a Collection actually +contains is invisible to the type checker, so a contract built on one would be a guess. This +library does not work with them and is not intended to. Return plain PHP objects and shapes PHPStan +can see through, and treat the operation as your view layer: a fully typed mapping from model or +DTO to the response shape the client compiles against. + +**PHPStan at level 6 or above is assumed.** The runtime proves what the annotation claims — but +only PHPStan proves the annotation against your code. Below level 6, missing parameter and return +types go unreported, and the type safety this library promises quietly stops being checked anywhere. +Most of the value is only real if static analysis is actually run, and run strictly. **`query()` and `command()` return an `RpcError` for every failure of your operation.** An exception from a handler or middleware — including one thrown while resolving your handler — comes @@ -299,12 +367,12 @@ leaving each transport to remember. constructor parameter is not a change to the generated type. Enums travel as their **case names**, not their backing values, unless the class opts in by implementing `StringValueObject`. -**Zero dependencies, no framework coupling.** Every integration point is an interface — -`ServerAdapter`, `OperationKeyGenerator`, `OperationRegistry`, `Client`, and the generator contracts. -Laravel is an adapter over those seams, not a requirement. - -One thing obfuscated operation keys are *not* is a security boundary: they keep your operation names -out of the shipped bundle, and that is all. See [operation keys](docs/server.md#operation-keys). +**A few interfaces at the seams, attributes everywhere else.** Every integration point is an +interface — `ServerAdapter`, `OperationKeyGenerator`, `OperationRegistry`, `Client`, and the +generator contracts — and those are the only contracts this library forces on you. Everything you +declare on your own code is an attribute (`#[Query]`, `#[Command]`, `#[Throws]`, `#[Brand]`, …), so +an operations class is plain PHP that extends nothing. Zero dependencies either way, and no +framework chosen for you: Laravel is an adapter over those seams, not a requirement. ## Errors @@ -323,13 +391,11 @@ The scope that threw is consulted first: a `#[Throws]` declaration on the throwi operation handler or a middleware's `handle()` — decides the category, and only where that scope declared nothing do the configured category lists apply. Everything unrecognised is a 500. -A client has one more failure available to it, and no server sends it: `CLIENT_ERROR`, code 0, for -the request that never arrived — or was answered by something other than the server, like a CSRF -middleware's 419 or a proxy's error page. The generated client never trusts the HTTP status line: -only a body carrying the envelope above is reported as the server's answer (the check is -`isValidEnvelop` from `lib/utils.ts`), and anything else becomes `CLIENT_ERROR`, carrying the -exception that stopped it under `cause` instead of `details`, plus the raw `response` -(`httpStatusCode`, and `jsonResponse` when the body parsed as JSON) when HTTP answered at all. +A client has one more failure available to it, and no server sends it: `CLIENT_ERROR`, code 0, +minted by the generated client for the request that never got a real answer. The client never +trusts the HTTP status line — only a body carrying the envelope counts as the server's answer, so a +proxy's error page or a CSRF middleware's 419 becomes `CLIENT_ERROR`, carrying the cause and the +raw response. [→ The client error](docs/errors.md#the-client-error) Exposing a domain error takes both a declaration and a name — `#[Throws]` on the throwing scope, and either `name:` on that declaration or `#[ExposeAs]` on the exception class: @@ -344,33 +410,18 @@ public function create(array $input): array { /* ... */ } {"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"name": "invalid-name"}} ``` -Because the catalogue is closed, `Failure` is the union of all of it rather than a hole for whatever -a call site passes. The only thing an operation adds to it is which exceptions it exposed, so that -is the only thing it takes: +The generated `Failure` is a closed union — the catalogue above, parameterised only on the +domain-error names an operation exposed. Two consequences worth knowing before you meet them: an +operation that exposes nothing gets `never`, which *erases* the 400 branch entirely — against such +an operation, `result.code === 400` will not even compile — and every branch is a named type, so a +handler like `(error: ClientError | InternalError) => boolean` is written once and reused. `details` +exists only where the category cannot say everything on its own: `INVALID_INPUT` carries `fields`, +`DOMAIN_ERROR` carries the name, everything else has none. -```typescript -export type Failure = {success: false, __metadata?: Record} - & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); -``` - -```typescript -export type CreateDomainErrors = never; -export type LockDomainErrors = "account_locked"|"quota_exceeded"; -``` - -That is all an operation module declares about errors — name the envelope as `Failure` -where you need it. `never` is not an absence to handle: `DomainError` erases itself on it, so an -operation that exposes nothing has no 400 branch at all and `result.code === 400` will not compile -against it. Naming the branches also means a consumer can write -`(error: ClientError | InternalError) => boolean` once and reuse it, instead of restating a literal -shape at every call site. - -`details` appears only where the category cannot say everything on its own — `INVALID_INPUT` carries -`fields`, `DOMAIN_ERROR` carries `type` — and is absent everywhere else, which is exactly what the -generated branches declare. - -**[→ Errors](docs/errors.md)** — the full mechanics, `InvalidInputException::createFromMessages()` -for your own validation, and the exception hierarchy this library throws at build time. +**[→ Errors](docs/errors.md)** — the full mechanics, the generated union, where your own validation +lives (a value object throwing `ValidationException` rides the same 422; anything the value alone +cannot decide is a named domain error — there is no hand-built 422), and the exceptions this +library throws at build time. ## Types @@ -392,12 +443,9 @@ Most of what PHPStan can express about a shape, this library can parse, serializ | `positive-int`, `non-empty-string`, … | `number`, `string` — refinement enforced server-side | Object properties are emitted in a canonical order, sorted by name — which is why `age` comes first -above. Declaration order does not reach the client, so reordering a PHP property is not a change to -the generated type. - -**An enum travels as its case names, not its backing values.** `MyEnum` emits `("OPEN"|"SHIPPED")` -even when it is `enum MyEnum: string { case OPEN = 'open'; }`. A backed enum that should travel as -its backing value opts in by implementing `StringValueObject`. +above. An enum travels as its **case names**, never its backing values — `"OPEN"`, not `'open'` — +unless the class implements `StringValueObject`; [the decisions](#the-decisions) has the reasoning +for both. Local and imported types work too: `@phpstan-type` and `@phpstan-import-type` are resolved against the declaring class, as are `use` statements and generics. From ae9939a5e5bd2298ca3f117d670e248eee6b1456 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 12 Aug 2026 14:47:09 +0200 Subject: [PATCH 081/101] Add `RATE_LIMITED` error category with RetryIn resolver, update error handling and documentation - Introduced the `RATE_LIMITED` (429) error category for rate-limiting scenarios, with optional `RetryInResolver` for dynamic `retryIn` resolution. - Enhanced `ServerConfiguration` to support resolver configuration and exception --- README.md | 10 +- docs/errors.md | 31 +++++-- docs/laravel.md | 2 + docs/operations.md | 17 +++- docs/typescript-client.md | 4 +- .../Laravel/Contracts/RetryInResolver.php | 16 ++++ .../Laravel/LaravelHttpController.php | 26 +++++- .../Laravel/LaravelServiceProvider.php | 26 ++++-- src/Adapters/Laravel/config/config.php | 19 ++++ src/CodeGen/CodeGenerators/EmitTypeUtils.php | 7 +- src/CodeGen/CodeGenerators/EmitTypes.php | 4 +- src/Server/Data/ErrorType.php | 1 + src/Server/Data/ServerConfiguration.php | 24 +++++ src/Server/Errors/ErrorClassifier.php | 3 + src/Server/Server.php | 37 ++++++-- .../Laravel/LaravelHttpControllerTest.php | 92 +++++++++++++++++++ .../ErrorHandling/ErrorScopeOperations.php | 12 +++ .../TooManyRequestsException.php | 14 +++ tests/Feature/ServerErrorHandlingTest.php | 52 +++++++++++ tests/Unit/CodeGen/EmitTypeUtilsTest.php | 4 +- tests/Unit/CodeGen/EmitTypesTest.php | 4 +- .../TypescriptServerCodeGeneratorTest.php | 3 +- tests/Unit/Server/Data/RpcErrorTest.php | 28 +++++- .../Server/Data/ServerConfigurationTest.php | 68 ++++++++++++++ .../Server/Errors/ErrorClassifierTest.php | 34 +++++-- .../Errors/Mocks/ThrowResolverOperations.php | 5 + .../Errors/Mocks/TooManyAttemptsException.php | 14 +++ .../Errors/ThrowAttributeResolverTest.php | 9 +- tests/ts-output/generated/lib/types.ts | 3 +- tests/ts-output/generated/lib/utils.ts | 7 +- tests/ts-output/src/usage.ts | 7 ++ 31 files changed, 525 insertions(+), 58 deletions(-) create mode 100644 src/Adapters/Laravel/Contracts/RetryInResolver.php create mode 100644 tests/Feature/ErrorHandling/TooManyRequestsException.php create mode 100644 tests/Unit/Server/Data/ServerConfigurationTest.php create mode 100644 tests/Unit/Server/Errors/Mocks/TooManyAttemptsException.php diff --git a/README.md b/README.md index 1acf2f7..7e0723a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ way it does. Each subsystem has its own reference. |---|---| | [Types](docs/types.md) | The supported PHPStan subset, refinements, utility types, value objects, `#[Castable]`, brands and named types. | | [Operations](docs/operations.md) | The attributes, the handler contract, middleware, `ServerConfiguration`. | -| [Errors](docs/errors.md) | The six categories, the client error, exposing a domain error, the generated union, and the exceptions this library throws. | +| [Errors](docs/errors.md) | The seven categories, the client error, exposing a domain error, the generated union, and the exceptions this library throws. | | [The server](docs/server.md) | `Server`, operation keys, registries, DI, serving HTTP, preloading, the production cache, extension points. | | [The TypeScript client](docs/typescript-client.md) | What codegen writes, the envelope, the transport, all eight generators, writing your own. | | [Client directives](docs/client-directives.md) | The optional `Client` side channel for toasts, redirects and cache invalidation. | @@ -338,7 +338,7 @@ whether the operation succeeded or failed. The one thing that escapes as an exce of error presentation itself (a stale class name failing reflection): that is a bug in the setup, not a request, and burying it in a substitute 500 would only hide it. -**Six error categories, and nothing is exposed by accident.** Surfacing a domain error takes a +**Seven error categories, and nothing is exposed by accident.** Surfacing a domain error takes a `#[Throws]` declaration *and* a name; everything unrecognised is a 500. The category list is closed on purpose — it is what the server needs to run, not an extension point. @@ -376,7 +376,7 @@ framework chosen for you: Laravel is an adapter over those seams, not a requirem ## Errors -Every failure the server can produce is one of six categories: +Every failure the server can produce is one of seven categories: | Code | `type` | When | |---|---|---| @@ -384,6 +384,7 @@ Every failure the server can produce is one of six categories: | 401 | `AUTHENTICATION_ERROR` | An exception you mapped as unauthenticated | | 403 | `AUTHORIZATION_ERROR` | An exception you mapped as unauthorized | | 404 | `NOT_FOUND` | Unknown operation, or an exception you mapped as not-found | +| 429 | `RATE_LIMITED` | An exception you mapped as rate-limited | | 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* gave a name | | 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | @@ -416,7 +417,8 @@ operation that exposes nothing gets `never`, which *erases* the 400 branch entir an operation, `result.code === 400` will not even compile — and every branch is a named type, so a handler like `(error: ClientError | InternalError) => boolean` is written once and reused. `details` exists only where the category cannot say everything on its own: `INVALID_INPUT` carries `fields`, -`DOMAIN_ERROR` carries the name, everything else has none. +`DOMAIN_ERROR` carries the name, `RATE_LIMITED` always carries `retryIn` (seconds, or `null` when +unknown — a resolver configures the value, never the shape), everything else has none. **[→ Errors](docs/errors.md)** — the full mechanics, the generated union, where your own validation lives (a value object throwing `ValidationException` rides the same 422; anything the value alone diff --git a/docs/errors.md b/docs/errors.md index 2745651..1686ade 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -4,7 +4,7 @@ Two error models live in this library, and they never meet. One is the finite se client can see; the other is the exceptions the library itself throws, all of them at build time. The short version lives in the [README](../README.md); this is the full picture. -- [The six categories](#the-six-categories) +- [The seven categories](#the-seven-categories) - [Exposing a domain error](#exposing-a-domain-error) - [The generated error union](#the-generated-error-union) - [The client error](#the-client-error) @@ -12,9 +12,9 @@ The short version lives in the [README](../README.md); this is the full picture. - [Your own validation](#your-own-validation) - [Exceptions this library throws](#exceptions-this-library-throws) -## The six categories +## The seven categories -Every failure the server can produce is one of six: +Every failure the server can produce is one of seven: | Code | `type` | When | |---|---|---| @@ -22,6 +22,7 @@ Every failure the server can produce is one of six: | 401 | `AUTHENTICATION_ERROR` | An exception you mapped as unauthenticated | | 403 | `AUTHORIZATION_ERROR` | An exception you mapped as unauthorized | | 404 | `NOT_FOUND` | Unknown operation, or an exception you mapped as not-found | +| 429 | `RATE_LIMITED` | An exception you mapped as rate-limited | | 400 | `DOMAIN_ERROR` | An exception you declared with `#[Throws]` *and* gave a name | | 500 | `INTERNAL_ERROR` | Anything else, including an output that did not match its type | @@ -39,7 +40,7 @@ which of *its* exceptions belong in which category, with [`ServerConfiguration::withExceptions()`](operations.md#serverconfiguration) — not which categories exist. -Six is what a *server* can answer. A client has one more failure available to it — the request that +Seven is what a *server* can answer. A client has one more failure available to it — the request that never arrived, or was answered by something other than the server — and that one is [`CLIENT_ERROR`](#the-client-error), code 0. It is deliberately not an `ErrorType`: nothing on the server can produce it, and giving the server a case for it would be claiming otherwise. @@ -99,6 +100,7 @@ export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fie export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"}; export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; export type NotFoundError = {code: 404, type: "NOT_FOUND"}; +export type RateLimitedError = {code: 429, type: "RATE_LIMITED", details: {retryIn: number | null}}; export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}}; export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error, response?: {httpStatusCode: number, jsonResponse?: unknown}}; @@ -109,7 +111,7 @@ whatever a call site passes in: ```typescript export type Failure = {success: false, __metadata?: Record} - & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); + & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|RateLimitedError|DomainError|InternalError|ClientError); export type Result = Success | Failure; ``` @@ -190,11 +192,20 @@ Reached through `OperationException`, the envelope is `e.cause` and the original ## When `details` appears -**`details` only appears where the category cannot say everything on its own**, which is exactly two -of the six: `INVALID_INPUT` carries `fields`, and `DOMAIN_ERROR` carries the `name` naming which -domain error it is. For the other four, `code` and `type` are the whole answer and restating it -under `details` would put the same string on the wire twice, so the key is absent — and the -generated branch has no such property, so narrowing on `type` will not offer you one. +**`details` only appears where the category cannot say everything on its own**, which is exactly +three of the seven: `INVALID_INPUT` carries `fields`, `DOMAIN_ERROR` carries the `name` naming which +domain error it is, and `RATE_LIMITED` carries `retryIn`. For the other four, `code` and `type` are +the whole answer and restating it under `details` would put the same string on the wire twice, so +the key is absent — and the generated branch has no such property, so narrowing on `type` will not +offer you one. + +`RATE_LIMITED` is the one deliberate exception to "only where it says something": its `details` is +*always* present, with `retryIn` as the seconds until a retry may succeed or `null` when the server +could not tell. The branch's shape must not depend on runtime configuration — configuring a resolver +with [`ServerConfiguration::withRetryInResolver()`](operations.md#serverconfiguration) changes the +value, never the shape. The resolver receives the throwable that surfaced as rate-limited and is +consulted only after the category is resolved, whether that happened through the configured list or +a `#[Throws(..., type: ErrorType::RATE_LIMITED)]` declaration. `CLIENT_ERROR` has no `details` either. What it carries instead is `cause` — a live `Error` rather than anything that came off the wire — and, when an HTTP response did arrive, the raw `response` diff --git a/docs/laravel.md b/docs/laravel.md index 4e91992..7860a31 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -61,6 +61,8 @@ php artisan operations:codegen resources/js/operations | `exceptions.unauthenticated` | `AuthenticationException` | Mapped to 401. | | `exceptions.unauthorized` | `TokenMismatchException`, `AuthorizationException` | Mapped to 403. | | `exceptions.not_found` | `ModelNotFoundException`, `RecordNotFoundException`, `RecordsNotFoundException` | Mapped to 404. | +| `exceptions.rate_limited` | `[]` | Mapped to 429. Only matters for throttling inside a handler — Laravel's route-level throttle middleware answers before the operation runs. | +| `retry_in_resolver` | `null` | A `RetryInResolver` class name resolving `details.retryIn` (seconds) for 429s. When it returns a number, the HTTP controller also sets a standard `Retry-After` header. | | `cache.idLength` | `10` | Id length used by the production cache. | Exception matching is `instanceof`, so listing a base class covers its subclasses. An unrecognised diff --git a/docs/operations.md b/docs/operations.md index 1f8a847..47edb87 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -167,12 +167,27 @@ new ServerConfiguration()->withExceptions( notFound: [EntityNotFoundException::class], unauthenticated: [NotLoggedInException::class], unauthorized: [ForbiddenException::class], + rateLimited: [TooManyRequestsException::class], ) ``` -Without this, nothing produces a 401, 403 or 404 except an unknown operation — every other +Without this, nothing produces a 401, 403, 404 or 429 except an unknown operation — every other exception is a 500. +`withRetryInResolver()` gives the `RATE_LIMITED` category its `retryIn`: a closure receiving the +throwable and returning the seconds until a retry may succeed, or `null` when unknown. It is +consulted only after an error resolved as rate-limited — through the list above or a +`#[Throws(..., type: ErrorType::RATE_LIMITED)]` declaration — and without one, `details.retryIn` is +simply `null`; the [branch's shape never changes](errors.md#when-details-appears): + +```php +new ServerConfiguration()->withRetryInResolver( + fn (Throwable $throwable): ?int => $throwable instanceof TooManyRequestsException + ? $throwable->retryAfterSeconds + : null, +) +``` + `coerceQueryInput` (default `false`) applies to **queries only**, and exists because a URL carries no types. The generated client JSON-encodes each query value, so a transport that decodes it again receives `?id=1` as the integer `1` and has nothing to coerce. Turn this on when requests reach you diff --git a/docs/typescript-client.md b/docs/typescript-client.md index 211deb7..7cf5a60 100644 --- a/docs/typescript-client.md +++ b/docs/typescript-client.md @@ -103,7 +103,9 @@ failure) is returned exactly as parsed, whatever the status said, and anything e [`CLIENT_ERROR`](errors.md#the-client-error) with the raw `response` (`httpStatusCode`, and `jsonResponse` when the body parsed as JSON) attached. The guard is exported, so a payload from anywhere else — SSR state, a cache — can be believed or refused the same way before being read as -an envelope. +an envelope. That cuts both ways for throttling: a gateway or route middleware answering 429 with +its *own* body is `CLIENT_ERROR` like any other imposter — only the server's own envelope narrows +to `RATE_LIMITED`. The URLs come from `ServerMetadata('/query/{fqn}', '/command/{fqn}')`, the two routes *your* transport serves. `{fqn}` is where the operation key goes, and both are required to contain it. diff --git a/src/Adapters/Laravel/Contracts/RetryInResolver.php b/src/Adapters/Laravel/Contracts/RetryInResolver.php new file mode 100644 index 0000000..19d88ba --- /dev/null +++ b/src/Adapters/Laravel/Contracts/RetryInResolver.php @@ -0,0 +1,16 @@ +jsonSerialize(); if (! $this->debug) { - return new JsonResponse($jsonResponse, status: $result->statusCode); + return new JsonResponse($jsonResponse, status: $result->statusCode, headers: self::headersFor($result)); } // We append some general debug information @@ -169,7 +170,28 @@ private function produceJsonResponse(RpcResult $result): JsonResponse return new JsonResponse( $jsonResponse, - status: $result->statusCode + status: $result->statusCode, + headers: self::headersFor($result), ); } + + /** + * The standard header next to the envelope's own field: proxies and generic HTTP clients + * read the header, the generated client reads details.retryIn. + * + * @return array + */ + private static function headersFor(RpcResult $result): array + { + if ( + $result instanceof RpcError + && $result->type === ErrorType::RATE_LIMITED + && is_array($result->details) + && is_int($result->details['retryIn'] ?? null) + ) { + return ['Retry-After' => (string) $result->details['retryIn']]; + } + + return []; + } } diff --git a/src/Adapters/Laravel/LaravelServiceProvider.php b/src/Adapters/Laravel/LaravelServiceProvider.php index 50f5c47..77aa605 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -15,6 +15,7 @@ use Le0daniel\PhpTsBindings\Adapters\Laravel\Commands\OptimizeCommand; use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ClientFactory; use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; +use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\RetryInResolver; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; @@ -101,16 +102,27 @@ public static function serverFactory( /** @var list> $middlewares */ $middlewares = $config->get('operations.middleware', []) |> array_values(...); + $configuration = new ServerConfiguration() + ->withMiddlewares(...$middlewares) + ->withExceptions( + notFound: $config->get('operations.exceptions.not_found', []), + unauthenticated: $config->get('operations.exceptions.unauthenticated', []), + unauthorized: $config->get('operations.exceptions.unauthorized', []), + rateLimited: $config->get('operations.exceptions.rate_limited', []), + ); + + /** @var class-string|null $retryInResolverClassName */ + $retryInResolverClassName = $config->get('operations.retry_in_resolver'); + if ($retryInResolverClassName !== null) { + /** @var RetryInResolver $retryInResolver */ + $retryInResolver = $app->make($retryInResolverClassName); + $configuration = $configuration->withRetryInResolver($retryInResolver->resolveRetryInSeconds(...)); + } + return new Server( registry: $operations, adapter: new PsrContainerAdapter(container: $app), - configuration: new ServerConfiguration() - ->withMiddlewares(...$middlewares) - ->withExceptions( - notFound: $config->get('operations.exceptions.not_found', []), - unauthenticated: $config->get('operations.exceptions.unauthenticated', []), - unauthorized: $config->get('operations.exceptions.unauthorized', []), - ), + configuration: $configuration, ); } diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index b324ebc..af82a2a 100644 --- a/src/Adapters/Laravel/config/config.php +++ b/src/Adapters/Laravel/config/config.php @@ -10,6 +10,7 @@ use Illuminate\Session\TokenMismatchException; use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ClientFactory; use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\ContextFactory; +use Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\RetryInResolver; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; @@ -126,5 +127,23 @@ RecordNotFoundException::class, RecordsNotFoundException::class, ], + + /** + * The typical entry is Illuminate\Http\Exceptions\ThrottleRequestsException - but only + * for throttling inside a handler (e.g. RateLimiter::attempt). Laravel's route-level + * throttle middleware answers before the operation runs, so no envelope forms there. + */ + 'rate_limited' => [], ], + + /** + * Define a class name resolving the seconds until a rate limited request may be retried. + * It must implement Le0daniel\PhpTsBindings\Adapters\Laravel\Contracts\RetryInResolver. + * + * Null leaves retryIn null: the RATE_LIMITED envelope always carries details.retryIn, + * this only decides whether it has a value. + * + * @see RetryInResolver + */ + 'retry_in_resolver' => null, ]; diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index 822ee76..be8f6c5 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -100,14 +100,15 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi * The wire discriminants a server can actually answer with, by name. CLIENT_ERROR has no entry on * purpose: that branch is minted by the client itself, so a body claiming it is never believed. */ -const SERVER_ERROR_CODES: Record = { +const SERVER_ERROR_CODES = { DOMAIN_ERROR: 400, AUTHENTICATION_ERROR: 401, AUTHORIZATION_ERROR: 403, NOT_FOUND: 404, INVALID_INPUT: 422, + RATE_LIMITED: 429, INTERNAL_ERROR: 500, -}; +} as const; /** * Whether a value is an envelope the server can have sent. Anything between the browser and the @@ -129,7 +130,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi return success === false && typeof type === 'string' && typeof code === 'number' - && SERVER_ERROR_CODES[type] === code; + && SERVER_ERROR_CODES[type as keyof typeof SERVER_ERROR_CODES] === code; } /** diff --git a/src/CodeGen/CodeGenerators/EmitTypes.php b/src/CodeGen/CodeGenerators/EmitTypes.php index 20379f6..3d13518 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -35,6 +35,7 @@ 'AuthenticationError', 'AuthorizationError', 'NotFoundError', + 'RateLimitedError', 'DomainError', 'InternalError', 'ClientError', @@ -99,12 +100,13 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"}; export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; export type NotFoundError = {code: 404, type: "NOT_FOUND"}; +export type RateLimitedError = {code: 429, type: "RATE_LIMITED", details: {retryIn: number | null}}; export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}}; export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error, response?: {httpStatusCode: number, jsonResponse?: unknown}}; export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} -export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); +export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|RateLimitedError|DomainError|InternalError|ClientError); export type Result = Success | Failure; declare const __brand: unique symbol; diff --git a/src/Server/Data/ErrorType.php b/src/Server/Data/ErrorType.php index 1a8d9b7..dec7b2e 100644 --- a/src/Server/Data/ErrorType.php +++ b/src/Server/Data/ErrorType.php @@ -11,5 +11,6 @@ enum ErrorType: int case AUTHORIZATION_ERROR = 403; case NOT_FOUND = 404; case INVALID_INPUT = 422; + case RATE_LIMITED = 429; case INTERNAL_ERROR = 500; } diff --git a/src/Server/Data/ServerConfiguration.php b/src/Server/Data/ServerConfiguration.php index 00cc672..4fdf2cf 100644 --- a/src/Server/Data/ServerConfiguration.php +++ b/src/Server/Data/ServerConfiguration.php @@ -4,6 +4,7 @@ namespace Le0daniel\PhpTsBindings\Server\Data; +use Closure; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use NoDiscard; use Throwable; @@ -18,6 +19,10 @@ * @param list> $notFoundExceptions * @param list> $unauthenticatedExceptions * @param list> $unauthorizedExceptions + * @param list> $rateLimitedExceptions + * @param (Closure(Throwable): (int|null))|null $resolveRetryIn + * Given the throwable that surfaced as RATE_LIMITED, the seconds until a retry may + * succeed, or null when unknown. Consulted only after the category is resolved. */ public function __construct( public bool $coerceQueryInput = false, @@ -25,6 +30,8 @@ public function __construct( public array $notFoundExceptions = [], public array $unauthenticatedExceptions = [], public array $unauthorizedExceptions = [], + public array $rateLimitedExceptions = [], + public ?Closure $resolveRetryIn = null, ) { } @@ -42,6 +49,8 @@ public function withMiddlewares(string ...$middlewares): self notFoundExceptions: $this->notFoundExceptions, unauthenticatedExceptions: $this->unauthenticatedExceptions, unauthorizedExceptions: $this->unauthorizedExceptions, + rateLimitedExceptions: $this->rateLimitedExceptions, + resolveRetryIn: $this->resolveRetryIn, ); } @@ -51,12 +60,14 @@ public function withMiddlewares(string ...$middlewares): self * @param list> $notFound * @param list> $unauthenticated * @param list> $unauthorized + * @param list> $rateLimited */ #[NoDiscard] public function withExceptions( array $notFound = [], array $unauthenticated = [], array $unauthorized = [], + array $rateLimited = [], ): self { return new self( coerceQueryInput: $this->coerceQueryInput, @@ -64,6 +75,19 @@ public function withExceptions( notFoundExceptions: [...$this->notFoundExceptions, ...$notFound], unauthenticatedExceptions: [...$this->unauthenticatedExceptions, ...$unauthenticated], unauthorizedExceptions: [...$this->unauthorizedExceptions, ...$unauthorized], + rateLimitedExceptions: [...$this->rateLimitedExceptions, ...$rateLimited], + resolveRetryIn: $this->resolveRetryIn, ); } + + /** + * @param Closure(Throwable): (int|null) $resolveRetryIn + */ + #[NoDiscard] + public function withRetryInResolver(Closure $resolveRetryIn): self + { + return clone($this, [ + "resolveRetryIn" => $resolveRetryIn, + ]); + } } diff --git a/src/Server/Errors/ErrorClassifier.php b/src/Server/Errors/ErrorClassifier.php index 8048fbf..690ddb1 100644 --- a/src/Server/Errors/ErrorClassifier.php +++ b/src/Server/Errors/ErrorClassifier.php @@ -15,11 +15,13 @@ * @param list $authenticationExceptions * @param list $authorizationExceptions * @param list $notFoundExceptions + * @param list $rateLimitedExceptions */ public function __construct( public array $authenticationExceptions, public array $authorizationExceptions, public array $notFoundExceptions, + public array $rateLimitedExceptions, ) { } @@ -38,6 +40,7 @@ public function classify(Throwable|string $exception): ErrorType $this->matchesAny($className, $this->authenticationExceptions) => ErrorType::AUTHENTICATION_ERROR, $this->matchesAny($className, $this->authorizationExceptions) => ErrorType::AUTHORIZATION_ERROR, $this->matchesAny($className, $this->notFoundExceptions) => ErrorType::NOT_FOUND, + $this->matchesAny($className, $this->rateLimitedExceptions) => ErrorType::RATE_LIMITED, default => ErrorType::INTERNAL_ERROR, }; } diff --git a/src/Server/Server.php b/src/Server/Server.php index 69444e9..e408724 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -26,6 +26,7 @@ use Le0daniel\PhpTsBindings\Server\Errors\ExceptionScope; use Le0daniel\PhpTsBindings\Server\Errors\ThrowAttributeResolver; use Le0daniel\PhpTsBindings\Server\Pipeline\ContextualPipeline; +use Le0daniel\PhpTsBindings\Utils\Assertions; use ReflectionException; use Throwable; @@ -45,13 +46,13 @@ public function __construct( public OperationRegistry $registry, private ServerAdapter $adapter = new NewInstanceAdapter(), public ServerConfiguration $configuration = new ServerConfiguration(), - ) - { + ) { $this->executor = new SchemaExecutor(); $this->classifier = new ErrorClassifier( authenticationExceptions: $configuration->unauthenticatedExceptions, authorizationExceptions: $configuration->unauthorizedExceptions, notFoundExceptions: $configuration->notFoundExceptions, + rateLimitedExceptions: $configuration->rateLimitedExceptions, ); } @@ -100,7 +101,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli // middleware must surface as an RpcError, not as an uncaught exception. Nothing was // executing yet, so there is no scope whose declarations could apply. try { - $middlewares = array_map(fn($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); + $middlewares = array_map(fn ($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); $controllerClass = $this->adapter->createController($operation->definition->fullyQualifiedClassName); } catch (Throwable $throwable) { return $this->present($throwable, $resolveInfo); @@ -108,7 +109,7 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli return new ContextualPipeline( middlewares: $middlewares, - onError: fn(Throwable $throwable, ?ExceptionScope $scope): RpcError => $this->present( + onError: fn (Throwable $throwable, ?ExceptionScope $scope): RpcError => $this->present( $throwable, $resolveInfo, $scope, @@ -187,8 +188,7 @@ private function present( Throwable $throwable, ?ResolveInfo $info, ?ExceptionScope $scope = null, - ): RpcError - { + ): RpcError { try { // If a scope is given, try to resolve the exception within its own declarations. A // globally configured middleware may not expose domain errors - it runs for every @@ -204,7 +204,7 @@ private function present( return new RpcError( type: $presentConfig['type'], cause: $throwable, - details: isset($presentConfig['name']) ? ['name' => $presentConfig['name']] : null, + details: $this->detailsFor($presentConfig['type'], $throwable, $presentConfig['name'] ?? null), resolveInfo: $info, ); } @@ -216,9 +216,7 @@ private function present( return new RpcError( type: $type, cause: $throwable, - details: $type === ErrorType::INVALID_INPUT && $throwable instanceof InvalidInputException - ? ['fields' => $throwable->failure->issues->serializeToFieldsArray()] - : null, + details: $this->detailsFor($type, $throwable), resolveInfo: $info, ); } catch (Throwable $throwable) { @@ -230,4 +228,23 @@ private function present( ); } } + + /** + * Shapes the details from the already resolved category - never from the exception class + * alone. RATE_LIMITED always carries {retryIn: ?int}: the branch's shape must not depend on + * whether a resolver is configured, only the value may. + * + * @return array|null + */ + private function detailsFor(ErrorType $type, Throwable $throwable, ?string $domainName = null): ?array + { + return match ($type) { + ErrorType::DOMAIN_ERROR => ['name' => Assertions::string($domainName)], + ErrorType::INVALID_INPUT => $throwable instanceof InvalidInputException + ? ['fields' => $throwable->failure->issues->serializeToFieldsArray()] + : null, + ErrorType::RATE_LIMITED => ['retryIn' => $this->configuration->resolveRetryIn?->__invoke($throwable)], + default => null, + }; + } } diff --git a/tests/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index e7a01c1..be3f8fb 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -20,6 +20,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\Server; use Mockery; use Throwable; @@ -422,6 +423,97 @@ public function someMethod(array $input, null $context, Client $client): array ]); }); +/** + * A controller whose handler throws an exception the configuration lists as rate limited. + * + * @return array{LaravelHttpController, Request, string} + */ +function rateLimitedController(ServerConfiguration $configuration): array +{ + $fcn = 'docs.method'; + + $typeParser = new TypeParser(); + $operationRegistry = Mockery::mock(OperationRegistry::class); + $exceptionHandler = Mockery::mock(ExceptionHandler::class); + $app = Mockery::mock(Application::class); + $request = Request::create('/query/docs.method', 'GET', ['name' => 'some_value']); + + $controllerInstance = new class () { + public function someMethod(array $input, null $context, Client $client): array + { + throw new \RuntimeException('too many attempts'); + } + }; + + // The real class name: the handler throws, so the server reflects this scope's #[Throws] + // declarations before falling back to the configured lists. + $operationDefinition = new Definition(OperationType::QUERY, $controllerInstance::class, 'someMethod', 'method', 'docs', []); + $operation = new Operation( + 'somekey', + $operationDefinition, + fn () => $typeParser->parse('array{name: string}'), + fn () => $typeParser->parse('array{id: string, name: string}'), + ); + + $operationRegistry->shouldReceive('has')->with(OperationType::QUERY, $fcn)->andReturn(true); + $operationRegistry->shouldReceive('get')->with(OperationType::QUERY, $fcn)->andReturn($operation); + $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); + $exceptionHandler->shouldReceive('report')->andReturnNull(); + + $controller = new LaravelHttpController( + new Server($operationRegistry, new PsrContainerAdapter(container: $app), $configuration), + $exceptionHandler, + null, + ); + + return [$controller, $request, $fcn]; +} + +test('a rate limited failure answers with the Retry-After header when retryIn is known', function () { + $configuration = new ServerConfiguration() + ->withExceptions(rateLimited: [\RuntimeException::class]) + ->withRetryInResolver(fn (Throwable $throwable): ?int => 30); + + [$controller, $request, $fcn] = rateLimitedController($configuration); + $response = $controller->handleHttpQueryRequest($fcn, $request); + + expect($response->getStatusCode())->toBe(429) + ->and($response->headers->get('Retry-After'))->toBe('30') + ->and($response->getData(true))->toEqual([ + 'success' => false, + 'code' => 429, + 'type' => 'RATE_LIMITED', + 'details' => ['retryIn' => 30], + ]); +}); + +test('a rate limited failure without a known retryIn ships the null in the body and no header', function () { + // Retry-After has no way to say "unknown", so the header is only set when there is a number. + // The envelope's shape is unaffected: details.retryIn is present either way. + $configuration = new ServerConfiguration()->withExceptions(rateLimited: [\RuntimeException::class]); + + [$controller, $request, $fcn] = rateLimitedController($configuration); + $response = $controller->handleHttpQueryRequest($fcn, $request); + + expect($response->getStatusCode())->toBe(429) + ->and($response->headers->has('Retry-After'))->toBeFalse() + ->and($response->getData(true))->toEqual([ + 'success' => false, + 'code' => 429, + 'type' => 'RATE_LIMITED', + 'details' => ['retryIn' => null], + ]); +}); + +test('other failures never carry a Retry-After header', function () { + // Unlisted, so the throw stays an internal error - and the header belongs to 429 alone. + [$controller, $request, $fcn] = rateLimitedController(new ServerConfiguration()); + $response = $controller->handleHttpQueryRequest($fcn, $request); + + expect($response->getStatusCode())->toBe(500) + ->and($response->headers->has('Retry-After'))->toBeFalse(); +}); + test('a custom client factory decides the client, not the header', function () { $fcn = 'docs.method'; diff --git a/tests/Feature/ErrorHandling/ErrorScopeOperations.php b/tests/Feature/ErrorHandling/ErrorScopeOperations.php index 33f779b..02c06ca 100644 --- a/tests/Feature/ErrorHandling/ErrorScopeOperations.php +++ b/tests/Feature/ErrorHandling/ErrorScopeOperations.php @@ -87,10 +87,22 @@ public function throwsUnclassified(array $data): array 'unauthenticated-subclass' => new TokenExpiredException(), 'unauthorized' => new ForbiddenException(), 'not-found' => new GoneException(), + 'rate-limited' => new TooManyRequestsException(), default => new RuntimeException('plain boom'), }; } + /** + * @param array{value: string} $data + * @return array{ok: bool} + */ + #[Command('errors')] + #[Throws(TooManyRequestsException::class, type: ErrorType::RATE_LIMITED)] + public function throwsMappedRateLimited(array $data): array + { + throw new TooManyRequestsException(); + } + /** * @param array{value: string} $data * @return array{ok: bool} diff --git a/tests/Feature/ErrorHandling/TooManyRequestsException.php b/tests/Feature/ErrorHandling/TooManyRequestsException.php new file mode 100644 index 0000000..7c1a3df --- /dev/null +++ b/tests/Feature/ErrorHandling/TooManyRequestsException.php @@ -0,0 +1,14 @@ +and($error->details)->toEqual(['name' => 'conflict']); }); +test('a listed rate limited exception carries the resolved retryIn', function () { + $configuration = new ServerConfiguration() + ->withExceptions(rateLimited: [TooManyRequestsException::class]) + ->withRetryInResolver(fn (Throwable $throwable): ?int => 30); + + $error = executeErrorOperation('errors.throwsUnclassified', 'rate-limited', $configuration); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::RATE_LIMITED) + ->and($error->statusCode)->toBe(429) + ->and($error->details)->toBe(['retryIn' => 30]); +}); + +test('a rate limited error without a resolver still carries the details with a null retryIn', function () { + // The branch always declares {retryIn: number | null} - configuring a resolver must change + // the value, never the shape. + $configuration = new ServerConfiguration()->withExceptions(rateLimited: [TooManyRequestsException::class]); + + $error = executeErrorOperation('errors.throwsUnclassified', 'rate-limited', $configuration); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::RATE_LIMITED) + ->and($error->details)->toBe(['retryIn' => null]); +}); + +test('a #[Throws] mapping to rate limited gets the resolved retryIn like the configured list does', function () { + $configuration = new ServerConfiguration() + ->withRetryInResolver(fn (Throwable $throwable): ?int => $throwable instanceof TooManyRequestsException ? 12 : null); + + $error = executeErrorOperation('errors.throwsMappedRateLimited', configuration: $configuration); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::RATE_LIMITED) + ->and($error->statusCode)->toBe(429) + ->and($error->details)->toBe(['retryIn' => 12]); +}); + +test('a throwing retryIn resolver surfaces as an internal error, not a broken rate limit', function () { + // Presentation has one safety net: whatever fails while shaping the error becomes a 500. + // A buggy resolver is a server bug and must not ship a half-formed 429. + $configuration = new ServerConfiguration() + ->withExceptions(rateLimited: [TooManyRequestsException::class]) + ->withRetryInResolver(fn (Throwable $throwable): ?int => throw new LogicException('resolver bug')); + + $error = executeErrorOperation('errors.throwsUnclassified', 'rate-limited', $configuration); + + expect($error)->toBeInstanceOf(RpcError::class) + ->and($error->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($error->details)->toBeNull(); +}); + test('an unknown operation is not found with no resolve info', function () { $error = errorHandlingServer()->command('errors.doesNotExist', ['value' => 'x'], null, new NullClient()); diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index 3da643d..0f3cc87 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -92,9 +92,9 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp test('isValidEnvelop only believes what the server can actually send', function () { expect(emitUtilsFor()) ->toContain('export function isValidEnvelop(value: unknown): value is Result {') - ->toContain('const SERVER_ERROR_CODES: Record = {') + ->toContain('const SERVER_ERROR_CODES = {') ->toContain("&& typeof code === 'number'") - ->toContain('&& SERVER_ERROR_CODES[type] === code;') + ->toContain('&& SERVER_ERROR_CODES[type as keyof typeof SERVER_ERROR_CODES] === code;') ->not->toContain('CLIENT_ERROR: 0'); }); diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 8f80f94..0d5b8e0 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -66,6 +66,7 @@ function emitTypesFor(string $inputType, string $outputType): string 'the authentication envelope' => ['AuthenticationError'], 'the authorization envelope' => ['AuthorizationError'], 'the not found envelope' => ['NotFoundError'], + 'the rate limited envelope' => ['RateLimitedError'], 'the domain envelope' => ['DomainError'], 'the internal envelope' => ['InternalError'], 'the client envelope' => ['ClientError'], @@ -110,6 +111,7 @@ function emitTypesFor(string $inputType, string $outputType): string ->toContain('export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"};') ->toContain('export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"};') ->toContain('export type NotFoundError = {code: 404, type: "NOT_FOUND"};') + ->toContain('export type RateLimitedError = {code: 429, type: "RATE_LIMITED", details: {retryIn: number | null}};') ->toContain('export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}};') ->toContain('export type InternalError = {code: 500, type: "INTERNAL_ERROR"};') ->toContain('export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error, response?: {httpStatusCode: number, jsonResponse?: unknown}};'); @@ -127,7 +129,7 @@ function emitTypesFor(string $inputType, string $outputType): string ); expect($types) - ->toContain('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError);') + ->toContain('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|RateLimitedError|DomainError|InternalError|ClientError);') ->not->toContain('{code: number}'); }); diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 369ce6a..0b7b2e2 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -101,6 +101,7 @@ function generateFor(array $classes, ?array $generators = null): array ->not->toContain('ClientError') ->not->toContain('AuthenticationError') ->not->toContain('AuthorizationError') + ->not->toContain('RateLimitedError') ->not->toContain('DomainError<'); }); @@ -113,7 +114,7 @@ function generateFor(array $classes, ?array $generators = null): array preg_match('/^export type Failure.*$/m', $types, $matches); expect($matches[0]) - ->toBe('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError);'); + ->toBe('export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|RateLimitedError|DomainError|InternalError|ClientError);'); }); test('the branch shapes are declared once, not restated per operation', function () { diff --git a/tests/Unit/Server/Data/RpcErrorTest.php b/tests/Unit/Server/Data/RpcErrorTest.php index 6a43c92..8fe1e1b 100644 --- a/tests/Unit/Server/Data/RpcErrorTest.php +++ b/tests/Unit/Server/Data/RpcErrorTest.php @@ -37,7 +37,7 @@ function errorInfo(): ResolveInfo 'internal' => [ErrorType::INTERNAL_ERROR], ]); -test('the two categories the code alone cannot describe carry their details', function () { +test('the three categories the code alone cannot describe carry their details', function () { $invalidInput = new RpcError( ErrorType::INVALID_INPUT, new RuntimeException('bad'), @@ -52,6 +52,13 @@ function errorInfo(): ResolveInfo errorInfo(), ); + $rateLimited = new RpcError( + ErrorType::RATE_LIMITED, + new RuntimeException('slow down'), + ['retryIn' => 30], + errorInfo(), + ); + expect($invalidInput->jsonSerialize())->toBe([ 'success' => false, 'code' => 422, @@ -62,6 +69,25 @@ function errorInfo(): ResolveInfo 'code' => 400, 'type' => 'DOMAIN_ERROR', 'details' => ['name' => 'invalid_name'], + ])->and($rateLimited->jsonSerialize())->toBe([ + 'success' => false, + 'code' => 429, + 'type' => 'RATE_LIMITED', + 'details' => ['retryIn' => 30], + ]); +}); + +test('a rate limited error without a known retryIn still ships the details key', function () { + // The RATE_LIMITED branch always declares {retryIn: number | null} - its shape must not + // depend on whether a resolver is configured. Null filtering is top level only, so the + // nested null survives onto the wire on purpose. + $error = new RpcError(ErrorType::RATE_LIMITED, new RuntimeException('slow down'), ['retryIn' => null], errorInfo()); + + expect($error->jsonSerialize())->toBe([ + 'success' => false, + 'code' => 429, + 'type' => 'RATE_LIMITED', + 'details' => ['retryIn' => null], ]); }); diff --git a/tests/Unit/Server/Data/ServerConfigurationTest.php b/tests/Unit/Server/Data/ServerConfigurationTest.php new file mode 100644 index 0000000..47fc4f7 --- /dev/null +++ b/tests/Unit/Server/Data/ServerConfigurationTest.php @@ -0,0 +1,68 @@ + 30, + ); +} + +test('adding middlewares preserves every other field', function () { + $before = fullyConfigured(); + $after = $before->withMiddlewares(); + + expect($after->coerceQueryInput)->toBe($before->coerceQueryInput) + ->and($after->notFoundExceptions)->toBe($before->notFoundExceptions) + ->and($after->unauthenticatedExceptions)->toBe($before->unauthenticatedExceptions) + ->and($after->unauthorizedExceptions)->toBe($before->unauthorizedExceptions) + ->and($after->rateLimitedExceptions)->toBe($before->rateLimitedExceptions) + ->and($after->resolveRetryIn)->toBe($before->resolveRetryIn); +}); + +test('adding exceptions appends per category and preserves every other field', function () { + $before = fullyConfigured(); + $after = $before->withExceptions( + notFound: [TooManyAttemptsException::class], + rateLimited: [RecordMissingException::class], + ); + + expect($after->notFoundExceptions)->toBe([RecordMissingException::class, TooManyAttemptsException::class]) + ->and($after->rateLimitedExceptions)->toBe([TooManyAttemptsException::class, RecordMissingException::class]) + ->and($after->unauthenticatedExceptions)->toBe($before->unauthenticatedExceptions) + ->and($after->unauthorizedExceptions)->toBe($before->unauthorizedExceptions) + ->and($after->coerceQueryInput)->toBe($before->coerceQueryInput) + ->and($after->middleware)->toBe($before->middleware) + ->and($after->resolveRetryIn)->toBe($before->resolveRetryIn); +}); + +test('setting the retryIn resolver preserves every other field', function () { + $before = fullyConfigured(); + $resolver = static fn (Throwable $throwable): ?int => null; + $after = $before->withRetryInResolver($resolver); + + expect($after->resolveRetryIn)->toBe($resolver) + ->and($after->coerceQueryInput)->toBe($before->coerceQueryInput) + ->and($after->middleware)->toBe($before->middleware) + ->and($after->notFoundExceptions)->toBe($before->notFoundExceptions) + ->and($after->unauthenticatedExceptions)->toBe($before->unauthenticatedExceptions) + ->and($after->unauthorizedExceptions)->toBe($before->unauthorizedExceptions) + ->and($after->rateLimitedExceptions)->toBe($before->rateLimitedExceptions); +}); diff --git a/tests/Unit/Server/Errors/ErrorClassifierTest.php b/tests/Unit/Server/Errors/ErrorClassifierTest.php index c655110..9d84b17 100644 --- a/tests/Unit/Server/Errors/ErrorClassifierTest.php +++ b/tests/Unit/Server/Errors/ErrorClassifierTest.php @@ -13,6 +13,7 @@ use Tests\Mocks\Errors\UserMissingException; use Tests\Unit\Server\Errors\Mocks\RequiresLoginInterface; use Tests\Unit\Server\Errors\Mocks\SessionExpiredException; +use Tests\Unit\Server\Errors\Mocks\TooManyAttemptsException; use Tests\Unit\Server\Errors\Mocks\UnauthenticatedException; use Tests\Unit\Server\Errors\Mocks\UnauthorizedException; @@ -25,6 +26,7 @@ function classifyError(Throwable|string $exception): ErrorType authenticationExceptions: [UnauthenticatedException::class, RequiresLoginInterface::class], authorizationExceptions: [UnauthorizedException::class], notFoundExceptions: [RecordMissingException::class], + rateLimitedExceptions: [TooManyAttemptsException::class], )->classify($exception); } @@ -34,19 +36,19 @@ function invalidInputException(): InvalidInputException } test('an InvalidInputException instance is classified as invalid input without any configuration', function () { - $classifier = new ErrorClassifier([], [], []); + $classifier = new ErrorClassifier([], [], [], []); expect($classifier->classify(invalidInputException()))->toBe(ErrorType::INVALID_INPUT); }); test('the InvalidInputException class-string is classified as invalid input without any configuration', function () { - $classifier = new ErrorClassifier([], [], []); + $classifier = new ErrorClassifier([], [], [], []); expect($classifier->classify(InvalidInputException::class))->toBe(ErrorType::INVALID_INPUT); }); test('invalid input wins even when InvalidInputException is listed in a configured category', function () { - $classifier = new ErrorClassifier([], [], [InvalidInputException::class]); + $classifier = new ErrorClassifier([], [], [InvalidInputException::class], []); expect($classifier->classify(invalidInputException()))->toBe(ErrorType::INVALID_INPUT); }); @@ -82,7 +84,7 @@ function invalidInputException(): InvalidInputException ]); test('with no configured lists every regular exception is an internal error', function () { - $classifier = new ErrorClassifier([], [], []); + $classifier = new ErrorClassifier([], [], [], []); expect($classifier->classify(new RuntimeException('Something failed')))->toBe(ErrorType::INTERNAL_ERROR); }); @@ -92,6 +94,7 @@ function invalidInputException(): InvalidInputException [UnauthenticatedException::class], [UnauthenticatedException::class], [], + [], ); expect($classifier->classify(new UnauthenticatedException()))->toBe(ErrorType::AUTHENTICATION_ERROR); @@ -102,13 +105,32 @@ function invalidInputException(): InvalidInputException [], [UnauthorizedException::class], [UnauthorizedException::class], + [], ); expect($classifier->classify(new UnauthorizedException()))->toBe(ErrorType::AUTHORIZATION_ERROR); }); +test('a configured rate limited exception is classified as rate limited', function (Throwable|string $exception) { + expect(classifyError($exception))->toBe(ErrorType::RATE_LIMITED); +})->with([ + 'instance' => [new TooManyAttemptsException()], + 'class-string' => [TooManyAttemptsException::class], +]); + +test('not found wins when a class is configured as both not found and rate limited', function () { + $classifier = new ErrorClassifier( + [], + [], + [TooManyAttemptsException::class], + [TooManyAttemptsException::class], + ); + + expect($classifier->classify(new TooManyAttemptsException()))->toBe(ErrorType::NOT_FOUND); +}); + test('listing a subclass does not cover its parent class', function () { - $classifier = new ErrorClassifier([], [], [UserMissingException::class]); + $classifier = new ErrorClassifier([], [], [UserMissingException::class], []); expect($classifier->classify(new RecordMissingException()))->toBe(ErrorType::INTERNAL_ERROR); }); @@ -119,7 +141,7 @@ function invalidInputException(): InvalidInputException }); test('an OperationNotFoundException is classified as not found without any configuration', function (Throwable|string $exception) { - $classifier = new ErrorClassifier([], [], []); + $classifier = new ErrorClassifier([], [], [], []); expect($classifier->classify($exception))->toBe(ErrorType::NOT_FOUND); })->with([ diff --git a/tests/Unit/Server/Errors/Mocks/ThrowResolverOperations.php b/tests/Unit/Server/Errors/Mocks/ThrowResolverOperations.php index d6fe01e..d06d882 100644 --- a/tests/Unit/Server/Errors/Mocks/ThrowResolverOperations.php +++ b/tests/Unit/Server/Errors/Mocks/ThrowResolverOperations.php @@ -24,6 +24,11 @@ public function declaresExplicitNotFound(): void { } + #[Throws(UnexposedException::class, ErrorType::RATE_LIMITED)] + public function declaresExplicitRateLimited(): void + { + } + #[Throws(UnexposedException::class, name: 'direct_name')] public function declaresNamedDomainError(): void { diff --git a/tests/Unit/Server/Errors/Mocks/TooManyAttemptsException.php b/tests/Unit/Server/Errors/Mocks/TooManyAttemptsException.php new file mode 100644 index 0000000..2668cd2 --- /dev/null +++ b/tests/Unit/Server/Errors/Mocks/TooManyAttemptsException.php @@ -0,0 +1,14 @@ + 'declaresViaExposeAsNamedDomain', ]); -test('non-domain declarations resolve even when domain errors are not allowed', function (string $method, string $exceptionClass) { +test('non-domain declarations resolve even when domain errors are not allowed', function (string $method, string $exceptionClass, ErrorType $type) { expect(resolveThrows($method, allowDomainErrors: false))->toBe([ - 'data' => [$exceptionClass => ['type' => ErrorType::NOT_FOUND]], + 'data' => [$exceptionClass => ['type' => $type]], 'issues' => [], ]); })->with([ - 'explicit type' => ['declaresExplicitNotFound', UnexposedException::class], - 'type from ExposeAs' => ['declaresViaExposeAsNotFound', NotFoundExposedException::class], + 'explicit type' => ['declaresExplicitNotFound', UnexposedException::class, ErrorType::NOT_FOUND], + 'type from ExposeAs' => ['declaresViaExposeAsNotFound', NotFoundExposedException::class, ErrorType::NOT_FOUND], + 'explicit rate limited' => ['declaresExplicitRateLimited', UnexposedException::class, ErrorType::RATE_LIMITED], ]); test('an invalid Throws declaration is reported and not resolved', function (string $method) { diff --git a/tests/ts-output/generated/lib/types.ts b/tests/ts-output/generated/lib/types.ts index a4f5d8b..35560ef 100644 --- a/tests/ts-output/generated/lib/types.ts +++ b/tests/ts-output/generated/lib/types.ts @@ -12,12 +12,13 @@ export type InvalidInputError = {code: 422, type: "INVALID_INPUT", details: {fie export type AuthenticationError = {code: 401, type: "AUTHENTICATION_ERROR"}; export type AuthorizationError = {code: 403, type: "AUTHORIZATION_ERROR"}; export type NotFoundError = {code: 404, type: "NOT_FOUND"}; +export type RateLimitedError = {code: 429, type: "RATE_LIMITED", details: {retryIn: number | null}}; export type DomainError = [TType] extends [never] ? never : {code: 400, type: "DOMAIN_ERROR", details: {name: TType}}; export type InternalError = {code: 500, type: "INTERNAL_ERROR"}; export type ClientError = {code: 0, type: "CLIENT_ERROR", cause: Error, response?: {httpStatusCode: number, jsonResponse?: unknown}}; export type Success = {success: true, data: T, __client?: unknown, __metadata?: Record} -export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|DomainError|InternalError|ClientError); +export type Failure = {success: false, __metadata?: Record} & (InvalidInputError|AuthenticationError|AuthorizationError|NotFoundError|RateLimitedError|DomainError|InternalError|ClientError); export type Result = Success | Failure; declare const __brand: unique symbol; diff --git a/tests/ts-output/generated/lib/utils.ts b/tests/ts-output/generated/lib/utils.ts index dd59c58..63f36a1 100644 --- a/tests/ts-output/generated/lib/utils.ts +++ b/tests/ts-output/generated/lib/utils.ts @@ -13,14 +13,15 @@ export function queryKey(ns: QueryNamespaces, ...args: unknown[]): [string, ...u * The wire discriminants a server can actually answer with, by name. CLIENT_ERROR has no entry on * purpose: that branch is minted by the client itself, so a body claiming it is never believed. */ -const SERVER_ERROR_CODES: Record = { +const SERVER_ERROR_CODES = { DOMAIN_ERROR: 400, AUTHENTICATION_ERROR: 401, AUTHORIZATION_ERROR: 403, NOT_FOUND: 404, INVALID_INPUT: 422, + RATE_LIMITED: 429, INTERNAL_ERROR: 500, -}; +} as const; /** * Whether a value is an envelope the server can have sent. Anything between the browser and the @@ -42,7 +43,7 @@ export function isValidEnvelop(value: unknown): value is Result return success === false && typeof type === 'string' && typeof code === 'number' - && SERVER_ERROR_CODES[type] === code; + && SERVER_ERROR_CODES[type as keyof typeof SERVER_ERROR_CODES] === code; } /** diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index ef3c56b..fa5cea8 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -38,6 +38,13 @@ export async function readProduct(): Promise { case 422: console.warn('invalid input', result.details.fields); return null; + case 429: { + // The one branch that says when to come back. `details` is always declared - the + // server could not know a retryIn is a null value, never a missing key. + const retryIn: number | null = result.details.retryIn; + console.warn('rate limited', retryIn); + return null; + } case 0: // The one branch no server sent. The request never got there, so what went wrong is the // exception itself rather than anything that came off the wire, and it arrives intact. From 0ebaffc341bf5b4044b1bedcc05bb0bea1b9ec5e Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Wed, 12 Aug 2026 17:04:28 +0200 Subject: [PATCH 082/101] Remove `NodeInterface` from `EnumNode` and `ValueObjectNode` to simplify class hierarchy --- src/Parser/Nodes/Leaf/EnumNode.php | 5 ++--- src/Parser/Nodes/Leaf/ValueObjectNode.php | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Parser/Nodes/Leaf/EnumNode.php b/src/Parser/Nodes/Leaf/EnumNode.php index 83bf604..79ff01b 100644 --- a/src/Parser/Nodes/Leaf/EnumNode.php +++ b/src/Parser/Nodes/Leaf/EnumNode.php @@ -9,12 +9,11 @@ use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; -use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; use Override; use UnitEnum; -final class EnumNode implements LeafNode, NodeInterface +final class EnumNode implements LeafNode { /** @var array */ private array $cases; @@ -82,7 +81,7 @@ public function parseValue(mixed $value, ExecutionContext $context): UnitEnum|Va #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { - if (! is_a($value, $this->enumClassName)) { + if (!is_object($value) ||! is_a($value, $this->enumClassName)) { $context->addIssue(Issue::invalidType($this->enumClassName, $value)); return Value::INVALID; diff --git a/src/Parser/Nodes/Leaf/ValueObjectNode.php b/src/Parser/Nodes/Leaf/ValueObjectNode.php index f5c5534..9e5b30e 100644 --- a/src/Parser/Nodes/Leaf/ValueObjectNode.php +++ b/src/Parser/Nodes/Leaf/ValueObjectNode.php @@ -13,7 +13,6 @@ use Le0daniel\PhpTsBindings\Executor\Exceptions\ValidationException; use Le0daniel\PhpTsBindings\Parser\Contracts\Coercible; use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; -use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BackingType; use Le0daniel\PhpTsBindings\Utils\PHPExport; use Override; @@ -26,7 +25,7 @@ * indistinguishable from its backing primitive; a #[Brand] (carried by a wrapping MetadataNode) * is what keeps the two apart on the TypeScript side. */ -final readonly class ValueObjectNode implements Coercible, LeafNode, NodeInterface +final readonly class ValueObjectNode implements Coercible, LeafNode { /** * @param class-string $className From 414631fe080f31e0b6b378647d01ac2de05f843f Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 07:38:36 +0200 Subject: [PATCH 083/101] Implement `RecordKey` helper for record key validation and serialization - Add `RecordKey` to validate if a node is usable as a key (`isUsableAsKey`) and to check closed key sets (`isClosedKeySet`). - Introduce `literalKeyValue` to handle stringified JSON object keys. - Update `ArrayConsumer` to integrate `RecordKey` for stricter key validation during parsing. - Add extensive unit tests for `RecordKey` to cover key validation scenarios. - Ensure JSON output differentiates between lists and records in serialization. - Extend test coverage for executor and parser with a focus on record handling. --- README.md | 97 ++++++- docs/types.md | 53 +++- src/Executor/Data/Context.php | 13 +- src/Executor/Data/Issues.php | 5 +- src/Executor/Handlers/RecordHandler.php | 109 +++++--- src/Parser/Helpers/ASTOptimizer.php | 1 + src/Parser/Helpers/AstValidator.php | 4 +- .../Helpers/Consumers/ArrayConsumer.php | 54 ++-- src/Parser/Helpers/RecordKey.php | 97 +++++++ src/Parser/Nodes/Leaf/EnumNode.php | 2 +- src/Parser/Nodes/Leaf/IntNode.php | 3 +- src/Parser/Nodes/Leaf/LiteralNode.php | 3 +- src/Parser/Nodes/RecordNode.php | 36 ++- src/Typescript/TypescriptGenerator.php | 50 +++- src/Utils/Nodes.php | 14 - tests/Feature/Operations/TestClass.php | 22 ++ tests/Feature/ServerTest.php | 19 ++ tests/Pest.php | 15 ++ .../Mocks/TsOutput/ShapeOperations.php | 4 + tests/Unit/Executor/ContextPathTest.php | 28 +- tests/Unit/Executor/RecordWireShapeTest.php | 239 ++++++++++++++++++ tests/Unit/Executor/SchemaExecutorTest.php | 20 +- tests/Unit/Parser/MetadataEliminationTest.php | 18 +- tests/Unit/Parser/RecordKeyTest.php | 103 ++++++++ tests/Unit/Parser/TypeParserTest.php | 92 ++++++- .../Typescript/TypescriptGeneratorTest.php | 37 ++- tests/ts-output/generated/lib/type-map.ts | 2 +- tests/ts-output/generated/shapes.ts | 2 +- tests/ts-output/src/usage.ts | 12 +- 29 files changed, 1037 insertions(+), 117 deletions(-) create mode 100644 src/Parser/Helpers/RecordKey.php create mode 100644 tests/Unit/Executor/RecordWireShapeTest.php create mode 100644 tests/Unit/Parser/RecordKeyTest.php diff --git a/README.md b/README.md index 7e0723a..601a8ca 100644 --- a/README.md +++ b/README.md @@ -435,8 +435,9 @@ Most of what PHPStan can express about a shape, this library can parse, serializ | `numeric`, `scalar` | `(number)`, `(number\|boolean\|string)` | | `'foo'`, `123`, `true`, `MyEnum::CASE` | `"foo"`, `123`, `true`, `"CASE"` | | `array{name: string, age?: int}`, `object{name: string}` | `{age?:number;name:string;}` | -| `list`, `T[]`, `array` | `Array` | -| `array` | `Record` | +| `list`, `T[]` | `Array` | +| `array`, `array`, `array` | `Record` | +| `array<'a'\|'b', T>` | `Partial>` | | `array{string, int}` | `[string,number]` | | `A\|B`, `?T` | `(A\|B)`, `(null\|T)` | | `A&B` (shapes of the same kind) | `({a:string;}&{b:number;})` | @@ -456,13 +457,101 @@ the declaring class, as are `use` statements and generics. `InvalidSyntaxException` when the schema is parsed — `class-string`, `key-of`, `callable(…)`, unsealed `array{foo: int, ...}`, conditional types and more. Two traps worth knowing up front: bare `object` is a syntax error, not an alias for `unknown` (write `object{…}` with the shape), and bare -`array` is not `Array` — PHPStan reads it as `array`, which permits string -keys, so write `list`, `array` or `array`. +`array` is the same — nothing in it says what the elements are, so write `list`, `T[]`, +`array` or `array`. **[→ Full type reference](docs/types.md)** — refinements, utility types (`Pick`, `Omit`, `BrandedString`, `DateTimeString`), value objects, `#[Castable]`, brands and named types, and the [full list of what is not supported](docs/types.md#not-supported). +### Arrays: one PHP structure, two JavaScript ones + +This is the part of the mapping where the two languages genuinely disagree, and where the library +is at its most opinionated. It is worth understanding before you write your first `@return`. + +A PHP array is an ordered hash map. JSON has two collections — `[…]` and `{…}` — and `json_encode` +picks between them by looking at the keys it happens to find *at that moment*: + +```php +json_encode([0 => $a, 1 => $b]); // ["…","…"] a JSON array +json_encode([1 => $a, 2 => $b]); // {"1":…,"2":…} a JSON object +json_encode([]); // [] an array, even for a type that is conceptually a map +``` + +Same declared type. Different wire shape, decided by the data. That is a client type nobody can +write down, and it is the source of the most tedious bug in any PHP/TypeScript codebase: + +```php +/** @return array */ +public function active(): array +{ + return array_filter($this->users, fn (User $u) => $u->isActive()); +} +``` + +`array_filter` preserves keys. On Monday every user is active, the keys run `0,1,2`, and the +response is `[{…},{…}]`. On Tuesday user `1` deactivates, the keys run `0,2`, and the same endpoint +answers `{"0":{…},"2":{…}}`. The client's `User[]` compiled fine and `.map` now throws in +production. Nothing in PHP warned you, because nothing in PHP was wrong. + +**So the declared type decides the wire shape, never the data.** + +| You write | You get | Why | +|---|---|---| +| `list`, `non-empty-list` | `Array` | `list` is the one PHPStan type that *promises* keys `0..n-1` | +| `T[]` | `Array` | by convention — see below | +| `array`, `array` | `Record<…>` | an array is a map until proven otherwise | + +A record serializes as a JSON object **always** — including when it is empty, which is why the +serializer hands back a `stdClass` rather than a PHP array. `{}` and `[]` are not interchangeable +to a client, and `[]` is what a naive `json_encode` would have produced. + +**`array` is not a list.** It is the case people expect to bend, and it is exactly the one +that must not. `list` promises contiguous `0..n-1`; `array` promises only that the keys +are integers — entity ids, sparse indices, whatever `array_column($rows, null, 'id')` returned. So +it maps to `Record`: + +```php +/** @return array */ → Record +``` + +The key is `string` and not `number` because a JSON object key *is* a string — `Record` +reads better at a call site and then lies about what `Object.keys()` hands back. Nothing is lost on +the way back in: PHP folds a numeric string key into an int the moment it lands in an array, so +`{"42": …}` parses straight back to `[42 => …]` with an int key. + +**That folding is also why a key is never coerced.** A PHP array is a hash map, and every route in +— `json_decode($json, true)`, `get_object_vars()`, an array built in PHP — has already folded +`"42"` into `42` before the executor sees it. So the key is checked exactly as it arrives, which is +exactly what it will be stored under, and the parsed array cannot disagree with the type that +declared it. The consequence is worth knowing up front: `array` handed `{"1": …}` +**rejects** the key, because PHP has no string key `'1'` to give and accepting it would answer an +`array` under a signature promising string keys. Only a canonical integer folds, so `"01"`, +`" 1"` and `"1.5"` are genuine string keys and stay accepted. + +**`T[]` is the deliberate exception.** PHPStan reads `T[]` as `array`, so by the rule +above it would be a record. It is not. In practice nobody writes `string[]` meaning a hash map — +it is universally read as "a list of strings", and honouring the letter of the spec here would +break the reasonable expectation of every codebase that uses it. This is an opinionated deviation, +and it is the only one. + +**A known key set gets `Partial`.** When the keys are literals, TypeScript can say more than +`string`: + +```php +/** @return array<'draft'|'live', int> */ → Partial> +``` + +`Record<'draft'|'live', number>` would demand *both* keys be present. A PHP array keyed by +`'draft'|'live'` promises neither, so `Partial` is what is true — reading `counts.draft` gives +`number | undefined`, and building one as input lets you omit a key. Keys outside the set are +rejected when parsing input. + +Refinements on the key work too and are enforced per entry on the way in: +`array` rejects the `""` key, `array` rejects `"0"`. Brands +on a key are dropped from the emitted type — the key travels as a property name, and a branded key +type would force a cast on every `Object.keys()` result. + ## Contributing ```bash diff --git a/docs/types.md b/docs/types.md index 68393c8..d44dbe4 100644 --- a/docs/types.md +++ b/docs/types.md @@ -34,8 +34,10 @@ Everything this library knows how to parse, serialize and emit. The short versio | `array{name: string}`, `object{name: string}` | `{name:string;}` | | `array{name?: string}` | `{name?:string;}` | | `array{a: array{b: string}}` | `{a:{b:string;};}` | -| `list`, `string[]`, `array` | `Array` | +| `list`, `string[]` | `Array` | +| `array`, `array` | `Record` | | `array` | `Record` | +| `array<'a'\|'b', int>` | `Partial>` | | `array{string, int}` | `[string,number]` | | `array{name: string}\|string` | `({name:string;}\|string)` | | `?string` | `(null\|string)` | @@ -80,6 +82,11 @@ Some PHPStan types narrow a PHP type further than PHP itself can express: `posit | `non-empty-list` | `array` | at least one element | | `non-empty-array` | `array` | at least one element | +A refinement on a **record key** is enforced the same way, one entry at a time: +`array` rejects the `""` key and `array` rejects `"0"`. Keys +are checked exactly as PHP hands them over — see +[record keys](#record-keys-are-checked-as-php-folded-them) for why nothing coerces them first. + A refinement disappears in TypeScript — `positive-int` is `number` — because TypeScript cannot express it either. It is enforced on the server. @@ -108,6 +115,41 @@ Serialization still enforces *types*: a `string` where an `int` is declared fail is not repaired into one — a near miss like the numeric string `"1.5"` for a `float` is reported, not cast. Only the PHPStan refinement on top of the type is skipped. +### Record keys are checked as PHP folded them + +A JSON object key travels as a string, so it looks like `array` could never see the `int` +it declared. It does, and nothing in this library coerces it: **a PHP array is a hash map that +folds a canonical decimal integer string into an `int` the moment it becomes a key**, and every +route in has already done that before a key is checked. `json_decode($json, true)`, +`get_object_vars()` on the object form, and an array built in PHP all agree — `{"42": …}` is +already `[42 => …]`, while `{"abc": …}` is still `['abc' => …]`. + +So the key is handed to the key type exactly as it arrives, which is also exactly what +`$record[$key]` will store it under. That is what keeps the parsed array equal to the type that +declared it, and it has one consequence worth knowing: + +```php +/** @param array $input */ // handed {"1": "a"} → rejected +``` + +PHP has no string key `'1'` to give — it would fold to `int 1` — so accepting it would answer an +`array` under a signature promising string keys. The key is rejected with +`validation.invalid_key_type` instead. Declare `array` when the keys are ids. + +Only a *canonical* integer folds, so a key that merely looks numeric is still a string key and is +accepted by `array` unchanged: + +| Key | PHP stores | `array` | `array` | +|---|---|---|---| +| `"42"`, `"-1"` | `int` | rejected | accepted | +| `"01"`, `" 1"`, `"+1"`, `"-0"` | `string` | accepted | rejected | +| `"1.5"`, `""`, `"abc"` | `string` | accepted | rejected | +| `"9223372036854775808"` (wider than an int) | `string` | accepted | rejected | + +The same rule applies to the key *type*. `array<'1'|'2', V>` describes keys no PHP array can hold, +so it is a syntax error rather than a type that silently matches nothing — write `array<1|2, V>`. +`array<'01', V>` is fine, because `'01'` is a genuine string key. + `SerializationOptions::$partialFailures` (on by default for direct `SchemaExecutor` callers) is the one exception to "a failure fails": with it on, a value that cannot be serialized under a null-accepting union is replaced with `null` and the result comes back as a `Success` whose @@ -142,9 +184,9 @@ Foo default generic arguments ($x is int ? string : bool) conditional types ``` -PHPStan reads a bare `array` as `array`, which permits string keys, so there is no one -TypeScript type it means: `Array` would be wrong for a keyed array and would drop its keys -on the way out. Write `list`, `array` or `array`. +Nothing in a bare `array` says what the elements are, and unlike `array` there is not even a +value type to fall back on. It fails like bare `object` does. Write `list`, `T[]`, +`array` or `array`. ### Not recognised at all @@ -167,7 +209,8 @@ The traps — each is accepted by PHPStan and rejected here: | You write | What happens | |---|---| | `object` | Syntax error, "Expected brace". Bare `object` is not `unknown`; write `object{…}`. | -| `array` | Rejected. A refined key type is not silently loosened to `string`. | +| `array` | A record, not a list. Only `list` and `T[]` promise a packed `0..n-1` array — see [the README](../README.md#arrays-one-php-structure-two-javascript-ones). | +| `array`, `array` | Rejected. A key must be `string`, `int` or a union of string/int literals — nothing else fits in front of `=>` in PHP either. | | `array{2: string, 5: int}` | Rejected. Integer-keyed tuples must run sequentially from `0`. | | `object{0: string}` | Rejected. Object-shape keys must be identifiers or quoted strings. | | `list{int, string}` | Psalm's keyed-list syntax. Write `array{int, string}`. | diff --git a/src/Executor/Data/Context.php b/src/Executor/Data/Context.php index 6d68d5e..17c419a 100644 --- a/src/Executor/Data/Context.php +++ b/src/Executor/Data/Context.php @@ -21,7 +21,11 @@ public function __construct( private array $path = []; /** - * @var array> + * Keyed by the path the issue was recorded at. The keys are written as strings and can be read + * back as ints: a single segment path made of digits - the first element of a list, the '0' + * key of a record - is what PHP folds, and array-key is the only type that says so. + * + * @var array> */ public private(set) array $issues = []; @@ -55,6 +59,10 @@ public function addIssue(Issue $issue): void * Matching is by path segment, not by string prefix: 'items.0' merely starts with the text * 'item' and must survive, while the root path is spelled '__root' and is a prefix of no * nested path at all, so a raw prefix test would clear nothing there. + * + * The cast is load bearing. A single segment path made of digits - the first element of a + * list, the '0' key of a record - is written as the string '0' and read back as the int 0, + * because that is what PHP does to array keys. Comparing it as it comes out is a TypeError. */ public function removeCurrentIssues(): void { @@ -65,7 +73,8 @@ public function removeCurrentIssues(): void } $current = $this->pathAsString(); - foreach ($this->issues as $path => $issues) { + foreach (array_keys($this->issues) as $path) { + $path = (string) $path; if ($path === $current || str_starts_with($path, "{$current}.")) { unset($this->issues[$path]); } diff --git a/src/Executor/Data/Issues.php b/src/Executor/Data/Issues.php index 17d6135..3cef890 100644 --- a/src/Executor/Data/Issues.php +++ b/src/Executor/Data/Issues.php @@ -9,7 +9,10 @@ public const string ROOT_PATH = '__root'; /** - * @param array> $issuesMap + * Paths are written as strings and read back as array keys, so a path of digits - 'items.0' is + * nested and stays a string, but a bare '0' is not - comes back as an int. See Context::$issues. + * + * @param array> $issuesMap */ public function __construct( public readonly array $issuesMap = [], diff --git a/src/Executor/Handlers/RecordHandler.php b/src/Executor/Handlers/RecordHandler.php index d3dc9fe..3c09daa 100644 --- a/src/Executor/Handlers/RecordHandler.php +++ b/src/Executor/Handlers/RecordHandler.php @@ -20,31 +20,32 @@ */ final readonly class RecordHandler implements Handler { + /** + * The cast to stdClass on the last line is the whole point of the type. A PHP array is both of + * JSON's collections at once, and json_encode picks between them by looking at the keys it + * finds: `[0 => 'a', 1 => 'b']` encodes as `["a","b"]` and `[]` encodes as `[]`, so a record + * whose keys happened to run 0..n-1 would reach the client as an array and break a + * `Record` that was correct on every other request. Handing back an object takes + * that decision away from the data. + * + * Keys are not validated here. Serialization never re-checks what the application produced - + * the same rule SchemaExecutor states for constraints - and PHP guarantees a key is int|string, + * both of which are a JSON object key. + */ #[Override] public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): stdClass|Value { - assert($node instanceof RecordNode); - - if (! is_iterable($value)) { - $context->addIssue(Issue::invalidType('iterable', $value)); + /** @var RecordNode $node */ + if (! is_array($value) && ! $value instanceof stdClass) { + $context->addIssue(Issue::invalidType('array', $value)); return Value::INVALID; } + $value = (array) $value; + $values = []; foreach ($value as $key => $item) { - if (! is_string($key)) { - $context->addIssue(new Issue( - IssueMessage::INVALID_KEY_TYPE, - [ - 'message' => 'Record keys must be strings, got: '.gettype($key), - 'keyValue' => $key, - ] - )); - - return Value::INVALID; - } - $context->enterPath($key); $result = $executor->executeSerialize($node->node, $item, $context); $context->leavePath(); @@ -59,33 +60,31 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E } /** - * @return array|Value::INVALID + * @return array|Value::INVALID */ #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): array|Value { - assert($node instanceof RecordNode); + /** @var RecordNode $node */ - if (! is_array($value)) { + if (! is_array($value) && ! $value instanceof stdClass) { $context->addIssue(Issue::invalidType('array', $value)); - return Value::INVALID; } + // We ensure and cast to an array. + $value = (array) $value; + $record = []; foreach ($value as $key => $item) { - if (! is_string($key)) { - $context->addIssue(new Issue( - IssueMessage::INVALID_KEY_TYPE, - [ - 'message' => 'Record keys must be strings, got: '.gettype($key), - 'keyValue' => $key, - ] - )); + $context->enterPath($key); + $parsedKey = $this->parseKey($node, $key, $context, $executor); + if ($parsedKey === Value::INVALID) { + $context->leavePath(); return Value::INVALID; } - $context->enterPath($key); + $result = $executor->executeParse($node->node, $item, $context); $context->leavePath(); @@ -93,9 +92,59 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu return Value::INVALID; } - $record[$key] = $result; + // PHP folds a numeric string key back into an int here, which is why array + // round trips through a JSON object without anything having to cast it. + $record[$parsedKey] = $result; } return $record; } + + /** + * The key is handed to the key node exactly as it arrives, with no coercion of any kind. + * + * A JSON object key travels as a string, so it looks like the key node can never see the `int` + * it declared - but a PHP array is a hash map that folds a canonical decimal integer string + * into an int the moment it becomes a key, and every route in does that before the handler + * sees anything. `json_decode($j, true)`, `get_object_vars()` on the object form, and an array + * built in PHP all agree: `{"42": …}` is already `[42 => …]`, and `{"abc": …}` is still + * `['abc' => …]`. The coercion is the transport's, and it has already happened. + * + * Which means `$key` is exactly what `$record[$key]` on the way out will store, so validating + * it as it stands is what makes the parsed array match the type that declared it. That is the + * whole point: `array` handed `{"1": …}` fails here rather than quietly returning + * an `array` under a signature promising string keys - PHP has no string key `'1'` to + * give it. + * + * Re-deriving this with filter_var() would be actively wrong, not merely redundant: it reads + * `' 1'`, `'+1'` and `'-0'` as integers where PHP keeps all three as string keys, so an int + * keyed record would fold `' 1'` onto the same slot as `'1'`, and a string keyed one would + * reject a key it can hold perfectly well. + * + * @return Value::INVALID|int|string + */ + private function parseKey(RecordNode $node, int|string $key, Context $context, Executor $executor): Value|int|string + { + $parsedKey = $executor->executeParse($node->keyNode, $key, $context); + if ($parsedKey === Value::INVALID) { + // Whatever the key node recorded on its way to rejecting the key describes a value at + // this path, not a key - a union leaves one such issue per arm. They are dropped for + // the one issue that says which of the two failed. Nothing else has run at this path + // yet, so there is nothing else to lose. + $context->removeCurrentIssues(); + $context->addIssue(new Issue( + IssueMessage::INVALID_KEY_TYPE, + [ + 'message' => "Record key does not match {$node->keyNode}, got: ".var_export($key, true), + 'keyValue' => $key, + ] + )); + + return Value::INVALID; + } + + // Anything a key node parses to is an int or a string by construction - RecordKey admits + // nothing else - so the result is usable as an array key as it stands. + return $parsedKey; + } } diff --git a/src/Parser/Helpers/ASTOptimizer.php b/src/Parser/Helpers/ASTOptimizer.php index 044ffd3..918d15e 100644 --- a/src/Parser/Helpers/ASTOptimizer.php +++ b/src/Parser/Helpers/ASTOptimizer.php @@ -191,6 +191,7 @@ private function dedupeNode(NodeInterface $node): NodeInterface $this->dedupeNode($node->node), ), RecordNode::class => new RecordNode( + $this->dedupeNode($node->keyNode), $this->dedupeNode($node->node), ), TupleNode::class => new TupleNode( diff --git a/src/Parser/Helpers/AstValidator.php b/src/Parser/Helpers/AstValidator.php index 472651c..1e51aa4 100644 --- a/src/Parser/Helpers/AstValidator.php +++ b/src/Parser/Helpers/AstValidator.php @@ -36,7 +36,9 @@ public static function validate(NodeInterface $node): void } match ($current::class) { - ConstraintNode::class, CustomCastingNode::class, ListNode::class, MetadataNode::class, PropertyNode::class, RecordNode::class => $stack[] = $current->node, + ConstraintNode::class, CustomCastingNode::class, ListNode::class, MetadataNode::class, PropertyNode::class => $stack[] = $current->node, + // A record's key is a node of its own and is walked like any other. + RecordNode::class => array_push($stack, $current->keyNode, $current->node), TupleNode::class, IntersectionNode::class, UnionNode::class => array_push($stack, ...$current->nodes), StructNode::class => array_push($stack, ...$current->properties), default => throw new ParserException('Unexpected node: '.$current::class), diff --git a/src/Parser/Helpers/Consumers/ArrayConsumer.php b/src/Parser/Helpers/Consumers/ArrayConsumer.php index 288ed59..4cc2a12 100644 --- a/src/Parser/Helpers/Consumers/ArrayConsumer.php +++ b/src/Parser/Helpers/Consumers/ArrayConsumer.php @@ -9,23 +9,30 @@ use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\ListLength; use Le0daniel\PhpTsBindings\Parser\Helpers\ParserState; +use Le0daniel\PhpTsBindings\Parser\Helpers\RecordKey; use Le0daniel\PhpTsBindings\Parser\Lexer\TokenType; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; -use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\IntNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; use Le0daniel\PhpTsBindings\Parser\Nodes\ListNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\TupleNode; use Le0daniel\PhpTsBindings\Parser\TypeParser; -use Le0daniel\PhpTsBindings\Utils\Nodes; use Override; /** * Most complex consumer. It consumes the php array type which is a bit of everything: - * array => ListNode - * array => RecordNode + * list => ListNode + * array => RecordNode + * array => RecordNode + * array => RecordNode * array{int, int} => TupleNode * array{0: int, 1: int} => TupleNode + * + * The split between the two collections is by keyword, never by key type. `list` is the only + * PHPStan type that promises a packed 0..n-1 array, so it is the only one that becomes a JSON + * array; everything spelled `array<...>` is a record and goes out as a JSON object. `T[]` joins + * `list` in TypeParser::consumeTypeModifiers() - see the README on why that shorthand is read + * pragmatically rather than as PHPStan's array. */ final readonly class ArrayConsumer implements TypeConsumer { @@ -86,37 +93,42 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface // Consuming of the array type identifier $state->advance(); - // No generics. PHPStan reads a bare `array` as array, which permits string - // keys - modelling it as a list is not a widening but a different type, and serialization - // would silently reindex a keyed array. Bare `object` and `iterable` already fail here, - // so this does too rather than emit Array and drop keys on the way out. + // No generics. Nothing here says what the elements are, and unlike `array` there is not + // even a value type to fall back on. Bare `object` and `iterable` already fail here, so + // this does too rather than emit Record and pretend. if (! $state->currentTokenIs(TokenType::LT)) { $state->produceSyntaxError( - "Bare '{$keyword}' has no single representation. Write list, array or array." + "Bare '{$keyword}' has no single representation. Write list, T[], array or array." ); } $generics = $this->consumeGenerics($state, $parser, min: 1, max: $maxGenerics); - if (count($generics) === 1) { + if ($type === 'list') { return $this->applyEmptiness(new ListNode($generics[0]), $isNonEmpty); } - // A branded key (array, V>) is still a string key on the wire. - // Constraints are deliberately NOT unwrapped: a constrained key (array) - // could never be validated at runtime, so it is rejected instead of silently loosened. - $keyType = Nodes::unwrapMetadata($generics[0]); - $node = match (true) { - $keyType instanceof StringNode => new RecordNode($generics[1]), - $keyType instanceof IntNode => new ListNode($generics[1]), - default => $state->produceSyntaxError("Array key type must be 'string' or 'int'. Got: {$keyType}"), - }; + // array is PHPStan's array. A string key node stands in for array-key: + // every PHP array key stringifies, so it accepts all of them, and the wire form of a key + // is a string regardless. + [$keyNode, $valueNode] = count($generics) === 1 + ? [new StringNode(), $generics[0]] + : [$generics[0], $generics[1]]; + + // Brands and refinements on the key are welcome - the executor validates keys per entry, + // so `array` is enforceable rather than silently loosened. What is + // rejected is a key PHP could not hold in front of `=>` in the first place. + if (! RecordKey::isUsableAsKey($keyNode)) { + $state->produceSyntaxError( + "Array key type must be 'string', 'int' or a union of string/int literals. Got: {$keyNode}" + ); + } - return $this->applyEmptiness($node, $isNonEmpty); + return $this->applyEmptiness(new RecordNode($keyNode, $valueNode), $isNonEmpty); } /** - * ListLength counts a RecordNode as readily as a ListNode - `non-empty-array` is a + * ListLength counts a RecordNode as readily as a ListNode - `non-empty-array` is a * record, and both are a plain PHP array by the time the executor sees them. */ private function applyEmptiness(RecordNode|ListNode $node, bool $isNonEmpty): NodeInterface diff --git a/src/Parser/Helpers/RecordKey.php b/src/Parser/Helpers/RecordKey.php new file mode 100644 index 0000000..791d828 --- /dev/null +++ b/src/Parser/Helpers/RecordKey.php @@ -0,0 +1,97 @@ +, V>`) and a refinement (`array`) do not change + * what a key *is*; they only add a runtime check the executor runs on top of it. + */ +final readonly class RecordKey +{ + /** + * A key has to be something PHP can actually put in front of `=>`. `string` and `int` cover + * the open sets, a string or int literal names one key, and a union composes them. Everything + * else - an enum case, a value object, a bool, a shape - has no array-key form in PHP either, + * so it is rejected at parse time rather than failing per entry at runtime. + */ + public static function isUsableAsKey(NodeInterface $node): bool + { + $declaring = Nodes::getDeclaringNode($node); + + return match (true) { + $declaring instanceof StringNode, $declaring instanceof IntNode => true, + $declaring instanceof LiteralNode => self::isKeyLiteral($declaring), + $declaring instanceof UnionNode => array_all($declaring->nodes, self::isUsableAsKey(...)), + default => false, + }; + } + + /** + * Whether the key set is known in full - every arm names one key. Only then can TypeScript say + * more than `string`, and only then is `Partial>` the honest emission. + */ + public static function isClosedKeySet(NodeInterface $node): bool + { + $declaring = Nodes::getDeclaringNode($node); + + return match (true) { + $declaring instanceof LiteralNode => self::isKeyLiteral($declaring), + $declaring instanceof UnionNode => array_all($declaring->nodes, self::isClosedKeySet(...)), + default => false, + }; + } + + /** + * The literal, as a JSON object key spells it. An int literal is included: `array<1|2, V>` + * arrives as `{"1": ...}`, so the key set is `"1"|"2"` for the same reason `array` is + * `Record`. + */ + public static function literalKeyValue(LiteralNode $node): string + { + $value = $node->value; + assert(is_string($value) || is_int($value)); + + return (string) $value; + } + + /** + * A string literal is only a key PHP can hold if PHP would not fold it into an int first. It + * folds a canonical decimal integer and nothing else, so `'01'`, `' 1'` and `'+1'` are all + * genuine string keys while `'1'` is not one at all - `array<'1'|'2', V>` describes a set of + * keys no PHP array can contain, and would silently match nothing. + */ + private static function isKeyLiteral(LiteralNode $node): bool + { + return match ($node->type) { + LiteralType::INT => true, + LiteralType::STRING => ! self::foldsToInt($node->stringValue()), + default => false, + }; + } + + /** + * PHP's own rule for turning a string array key into an int, expressed the way PHP applies it: + * the key survives the round trip through int only when it was canonical to begin with. That + * rules out leading zeros, a leading '+', surrounding whitespace, '-0', and anything wider + * than the platform int. + */ + private static function foldsToInt(string $value): bool + { + return (string) (int) $value === $value; + } +} diff --git a/src/Parser/Nodes/Leaf/EnumNode.php b/src/Parser/Nodes/Leaf/EnumNode.php index 79ff01b..f50d47a 100644 --- a/src/Parser/Nodes/Leaf/EnumNode.php +++ b/src/Parser/Nodes/Leaf/EnumNode.php @@ -81,7 +81,7 @@ public function parseValue(mixed $value, ExecutionContext $context): UnitEnum|Va #[Override] public function serializeValue(mixed $value, ExecutionContext $context): mixed { - if (!is_object($value) ||! is_a($value, $this->enumClassName)) { + if (!is_object($value) || ! is_a($value, $this->enumClassName)) { $context->addIssue(Issue::invalidType($this->enumClassName, $value)); return Value::INVALID; diff --git a/src/Parser/Nodes/Leaf/IntNode.php b/src/Parser/Nodes/Leaf/IntNode.php index 5591160..f2aa2c7 100644 --- a/src/Parser/Nodes/Leaf/IntNode.php +++ b/src/Parser/Nodes/Leaf/IntNode.php @@ -7,11 +7,10 @@ use Le0daniel\PhpTsBindings\Executor\Contracts\ExecutionContext; use Le0daniel\PhpTsBindings\Parser\Contracts\Coercible; use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; -use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Utils\PHPExport; use Override; -final readonly class IntNode implements Coercible, LeafNode, NodeInterface +final readonly class IntNode implements Coercible, LeafNode { use RejectsInvalidType; diff --git a/src/Parser/Nodes/Leaf/LiteralNode.php b/src/Parser/Nodes/Leaf/LiteralNode.php index c5426eb..28d2572 100644 --- a/src/Parser/Nodes/Leaf/LiteralNode.php +++ b/src/Parser/Nodes/Leaf/LiteralNode.php @@ -10,14 +10,13 @@ use Le0daniel\PhpTsBindings\Executor\Data\IssueMessage; use Le0daniel\PhpTsBindings\Parser\Contracts\Coercible; use Le0daniel\PhpTsBindings\Parser\Contracts\LeafNode; -use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\ParserException; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\LiteralType; use Le0daniel\PhpTsBindings\Utils\PHPExport; use Override; use UnitEnum; -final readonly class LiteralNode implements Coercible, LeafNode, NodeInterface +final readonly class LiteralNode implements Coercible, LeafNode { /** * $type and $value must agree; every method below reads one to interpret the other. Checked here diff --git a/src/Parser/Nodes/RecordNode.php b/src/Parser/Nodes/RecordNode.php index 09189ab..e159d3d 100644 --- a/src/Parser/Nodes/RecordNode.php +++ b/src/Parser/Nodes/RecordNode.php @@ -5,29 +5,59 @@ namespace Le0daniel\PhpTsBindings\Parser\Nodes; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Contracts\ValidatableNode; use Le0daniel\PhpTsBindings\Parser\Contracts\WrapsNode; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\ParserException; +use Le0daniel\PhpTsBindings\Parser\Helpers\RecordKey; use Le0daniel\PhpTsBindings\Utils\PHPExport; use Override; -final readonly class RecordNode implements NodeInterface, WrapsNode +/** + * Every `array<...>` is a record: a JSON object on the wire, whatever its keys happen to look + * like. The key is a node of its own rather than an assumed `string`, so a literal key set + * (`array<'draft'|'live', int>`) and a refined one (`array`) both survive to the + * generator and to the executor. What may stand here is decided in one place, RecordKey. + * + * WrapsNode still exposes the value: a record is a collection of $node, keyed. Callers that walk + * the tree exhaustively - AstValidator, ASTOptimizer - read $keyNode explicitly. + */ +final readonly class RecordNode implements NodeInterface, ValidatableNode, WrapsNode { public function __construct( + public NodeInterface $keyNode, public NodeInterface $node, ) { } + /** + * The parser rejects an unusable key with a syntax error pointing at the offending token, which + * is the better message and covers every schema that comes from a docblock. This covers the + * rest: a hand built AST cannot describe a record keyed by something PHP could not put in front + * of `=>`, because the executor would have no way to honour it. + */ + #[Override] + public function validate(): void + { + if (! RecordKey::isUsableAsKey($this->keyNode)) { + throw new ParserException( + "A record key must be 'string', 'int' or a union of string/int literals. Got: {$this->keyNode}" + ); + } + } + #[Override] public function __toString(): string { - return "arraynode}>"; + return "array<{$this->keyNode},{$this->node}>"; } #[Override] public function exportPhpCode(): string { $classname = PHPExport::absolute(self::class); + $exportedKey = PHPExport::export($this->keyNode); $exportedType = PHPExport::export($this->node); - return "new {$classname}({$exportedType})"; + return "new {$classname}({$exportedKey},{$exportedType})"; } } diff --git a/src/Typescript/TypescriptGenerator.php b/src/Typescript/TypescriptGenerator.php index c84092b..70000af 100644 --- a/src/Typescript/TypescriptGenerator.php +++ b/src/Typescript/TypescriptGenerator.php @@ -6,6 +6,7 @@ use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Helpers\RecordKey; use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; use Le0daniel\PhpTsBindings\Parser\Nodes\CustomCastingNode; use Le0daniel\PhpTsBindings\Parser\Nodes\Data\BackingType; @@ -34,6 +35,7 @@ use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; use Le0daniel\PhpTsBindings\Typescript\Utils\Syntax; +use Le0daniel\PhpTsBindings\Utils\Nodes; use UnitEnum; /** @@ -84,7 +86,7 @@ private function emit(NodeInterface $node, EmissionContext $context): string $node instanceof IntersectionNode => $this->intersection($node, $context), $node instanceof TupleNode => $this->tuple($node, $context), $node instanceof ListNode => "Array<{$this->emit($node->node, $context)}>", - $node instanceof RecordNode => "Recordemit($node->node, $context)}>", + $node instanceof RecordNode => $this->record($node, $context), $node instanceof ConstraintNode => $this->emit($node->node, $context), $node instanceof CustomCastingNode => $this->customCasting($node, $context), @@ -220,6 +222,52 @@ private function intersection(IntersectionNode $node, EmissionContext $context): return implode('&', $members) |> Syntax::wrapInParentheses(...); } + /** + * A JSON object key is a string, so an int keyed array is `Record` and not + * `Record` - the latter would read well at a call site and then lie about what + * Object.keys() hands back. Brands and refinements on the key go the same way: they are proven + * server side and have no shape a key type could carry. + * + * A literal key set is the one case where TypeScript can say more, and there `Partial` carries + * the difference between the two languages. `Record<'a'|'b', V>` demands both keys; a PHP + * array keyed by 'a'|'b' promises neither, and the executor does not require them either. + */ + private function record(RecordNode $node, EmissionContext $context): string + { + $value = $this->emit($node->node, $context); + + if (! RecordKey::isClosedKeySet($node->keyNode)) { + return "Record"; + } + + return "PartialclosedKeyUnion($node->keyNode)},{$value}>>"; + } + + /** + * The members of a closed key set, as a JSON object spells them. An int literal is quoted like + * any other key for the same reason `int` emits `string`: `array<1|2, V>` arrives as + * `{"1": ...}`. + */ + private function closedKeyUnion(NodeInterface $node): string + { + $declaring = Nodes::getDeclaringNode($node); + + if ($declaring instanceof UnionNode) { + $members = array_map( + fn (NodeInterface $member): string => $this->closedKeyUnion($member), + $declaring->nodes, + ); + + return implode('|', array_unique($members)); + } + + // isClosedKeySet() admits nothing but literals and unions of them, and this method is + // reached only behind that check. + assert($declaring instanceof LiteralNode); + + return Syntax::stringLiteral(RecordKey::literalKeyValue($declaring)); + } + private function tuple(TupleNode $node, EmissionContext $context): string { $members = array_map( diff --git a/src/Utils/Nodes.php b/src/Utils/Nodes.php index 04b9174..5f0fd24 100644 --- a/src/Utils/Nodes.php +++ b/src/Utils/Nodes.php @@ -22,20 +22,6 @@ public static function getDeclaringNode(NodeInterface $node): NodeInterface return $node; } - /** - * Strips codegen metadata only. Unlike getDeclaringNode(), constraints stay attached — use - * this where a ConstraintNode must remain visible, e.g. so a constrained array key is - * rejected instead of silently losing its runtime validation. - */ - public static function unwrapMetadata(NodeInterface $node): NodeInterface - { - while ($node instanceof MetadataNode) { - $node = $node->node; - } - - return $node; - } - /** * @param list $nodes */ diff --git a/tests/Feature/Operations/TestClass.php b/tests/Feature/Operations/TestClass.php index b53130f..4916988 100644 --- a/tests/Feature/Operations/TestClass.php +++ b/tests/Feature/Operations/TestClass.php @@ -36,6 +36,28 @@ public function acceptEmail(array $data): array return ['email' => $data['email']->toStringValue()]; } + /** + * The ids run 0, 1, 2, which is precisely when json_encode would render a PHP array as a JSON + * array. `byId` is declared as a record, so it has to answer as an object regardless - the + * client's `Record` is either always true or it is worthless. + * + * @param array{ping: bool} $data + * @return array{byId: array, tags: list, empty: array} + */ + #[Command('test')] + public function packedRecord(array $data): array + { + return [ + 'byId' => [ + 0 => ['name' => 'zero'], + 1 => ['name' => 'one'], + 2 => ['name' => 'two'], + ], + 'tags' => ['a', 'b'], + 'empty' => [], + ]; + } + /** * Returns something its own return type does not describe: `name` is an int where a string is * declared. The whole `user` branch is nullable, which is exactly the shape that used to be diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 00e7ed5..4fc98fc 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -194,3 +194,22 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError expect($error->details)->toEqual(['name' => 'invalid_name']); }); + +test('a record whose keys run 0..n reaches the client as a JSON object', function () { + // The wire shape, proven through the envelope rather than the executor alone. This is what a + // client actually parses, and `byId` is the case that used to arrive as an array whenever the + // ids happened to start at 0 and run contiguously. + $result = executeOperation('test.packedRecord', ['ping' => true]); + + expect($result)->toBeInstanceOf(RpcSuccess::class); + + // Encoding the envelope itself, which is what the HTTP adapter hands to the client. + $body = json_encode($result, JSON_THROW_ON_ERROR); + + expect($body)->toContain('"byId":{"0":{"name":"zero"},"1":{"name":"one"},"2":{"name":"two"}}') + ->and($body)->not->toContain('"byId":[') + // A list is still a list, and an empty record is still an object. + ->and($body)->toContain('"tags":["a","b"]') + ->and($body)->toContain('"empty":{}') + ->and($body)->not->toContain('"empty":['); +}); diff --git a/tests/Pest.php b/tests/Pest.php index f514ea3..26a5048 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -203,6 +203,21 @@ function executeSerialize(NodeInterface|string $node, mixed $data, Serialization return $normalResult; } +/** + * The wire form, not the PHP form. A record and a packed list are the same PHP array, and + * `(object) ['a' => 1]` compares equal to `['a' => 1]`, so a PHP level assertion cannot see the + * difference between `{}` and `[]`. Only json_encode can, and `{}` versus `[]` is the whole + * guarantee a generated `Record` rests on. + */ +function serializedJson(NodeInterface|string $node, mixed $data): string +{ + $result = executeSerialize($node, $data, new SerializationOptions(partialFailures: false)); + expect($result)->toBeSuccess(); + assert($result instanceof Success); + + return json_encode($result->value, JSON_THROW_ON_ERROR); +} + function validateAst(NodeInterface $node): void { $optimizer = new ASTOptimizer(); diff --git a/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php b/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php index 074b89e..659ef57 100644 --- a/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php +++ b/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php @@ -33,6 +33,8 @@ final class ShapeOperations * always: true, * tags: string[], * lookup: array, + * byId: array, + * modes: array<'draft'|'live', int>, * pair: array{string, int}, * either: string|int, * maybe: ?Availability, @@ -57,6 +59,8 @@ public function defaults(null $input): array 'always' => true, 'tags' => [], 'lookup' => [], + 'byId' => [], + 'modes' => [], 'pair' => ['', 0], 'either' => 0, 'maybe' => null, diff --git a/tests/Unit/Executor/ContextPathTest.php b/tests/Unit/Executor/ContextPathTest.php index 184913f..154a5ea 100644 --- a/tests/Unit/Executor/ContextPathTest.php +++ b/tests/Unit/Executor/ContextPathTest.php @@ -63,10 +63,36 @@ function issueAt(Context $context, string ...$path): void test('a numeric path segment is still matched', function () { // PHP coerces the array key '0' to int, which is why the comparison casts back to string. + // The path has to still be entered: standing at the root takes the branch that clears + // everything without comparing anything, and the comparison is what is under test. $context = new Context(); - issueAt($context, '0'); + issueAt($context, '0', 'nested'); + $context->enterPath('0'); + $context->addIssue(new Issue(IssueMessage::INVALID_TYPE)); $context->removeCurrentIssues(); + $context->leavePath(); expect($context->issues)->toBe([]); }); + +test('a numeric path segment does not take a sibling with it', function () { + $context = new Context(); + issueAt($context, '00'); + issueAt($context, '1'); + + $context->enterPath('0'); + $context->addIssue(new Issue(IssueMessage::INVALID_TYPE)); + $context->removeCurrentIssues(); + $context->leavePath(); + + expect(array_keys($context->issues))->toBe(['00', 1]); +}); + +test('a union inside a list discards its rejected arms without blowing up', function () { + // The end to end shape of the same bug: the first element of a list is path '0', the union + // records an issue there for the arm it rejects, and clearing it read the key back as an int. + expect(executeParse('list<(int|string)>', ['a']))->toBeSuccess() + ->and(executeParse('list<(int|string)>', [1, 'a', 2]))->toBeSuccess() + ->and(executeParse('array', [0 => 'a']))->toBeSuccess(); +}); diff --git a/tests/Unit/Executor/RecordWireShapeTest.php b/tests/Unit/Executor/RecordWireShapeTest.php new file mode 100644 index 0000000..4eae91b --- /dev/null +++ b/tests/Unit/Executor/RecordWireShapeTest.php @@ -0,0 +1,239 @@ + 'a', 1 => 'b']` encodes as `["a","b"]` and `['x' => 'a']` as + * `{"x":"a"}` - from the same declared type, on two different requests. + * + * So the shape is decided by the declared type and never by the data: `list` and `T[]` are the + * only things that promise a packed 0..n-1 array, and every `array<...>` is a record that leaves + * as an object even when it is empty. + * + * Everything here asserts the encoded string on purpose. `expect($value)->toEqual((object) [...])` + * passes just as happily against a plain array, which is exactly the bug this file exists to + * catch. + */ +function generatorOf(string ...$values): Generator +{ + yield from $values; +} + +/** + * Records. The first two entries are the ones that matter most: packed int keys are what used to + * degrade into a JSON array, and an empty record is what used to degrade into `[]`. + */ +dataset('record wire shapes', [ + 'packed int keys' => ['array', [0 => 'a', 1 => 'b', 2 => 'c'], '{"0":"a","1":"b","2":"c"}'], + 'empty int keyed record' => ['array', [], '{}'], + 'single int key' => ['array', [0 => 'a'], '{"0":"a"}'], + 'sparse int keys' => ['array', [3 => 'a', 7 => 'b'], '{"3":"a","7":"b"}'], + 'negative int key' => ['array', [-1 => 'a', 0 => 'b'], '{"-1":"a","0":"b"}'], + 'string keys' => ['array', ['a' => 1], '{"a":1}'], + 'empty string keyed record' => ['array', [], '{}'], + // PHP folds '0' into the int 0 on the way in; it is still a JSON object key on the way out. + 'numeric string keys' => ['array', ['0' => 1, '1' => 2], '{"0":1,"1":2}'], + 'implicit array-key' => ['array', ['a', 'b'], '{"0":"a","1":"b"}'], + 'non empty array' => ['non-empty-array', [0 => 'a'], '{"0":"a"}'], + 'literal keys' => ["array<'one'|'two', string>", ['one' => 'a'], '{"one":"a"}'], + 'refined int keys' => ['array', [1 => 'a', 2 => 'b'], '{"1":"a","2":"b"}'], + 'refined string keys' => ['array', ['a' => 1], '{"a":1}'], + + // A record stays an object wherever it sits, and a list nested inside one stays an array. + 'record of lists' => ['array>', [0 => ['a']], '{"0":["a"]}'], + 'record of records' => ['array>', [0 => [0 => 'a']], '{"0":{"0":"a"}}'], + 'record of structs' => ['array', [0 => ['id' => 'x']], '{"0":{"id":"x"}}'], + 'record in a struct' => ['array{items: array}', ['items' => ['a']], '{"items":{"0":"a"}}'], + 'empty record in a struct' => ['array{items: array}', ['items' => []], '{"items":{}}'], +]); + +/** + * Lists, pinning the carve-out from the other side. If one of these ever encodes as an object the + * split has been applied too widely. + */ +dataset('list wire shapes', [ + 'list' => ['list', ['a', 'b'], '["a","b"]'], + 'empty list' => ['list', [], '[]'], + 'shorthand' => ['string[]', ['a', 'b'], '["a","b"]'], + 'grouped shorthand' => ['(string|int)[]', ['a', 1], '["a",1]'], + 'non empty list' => ['non-empty-list', ['a'], '["a"]'], + // ListHandler repacks, which is the point: a list with holes is still a JSON array. + 'list with holes is repacked' => ['list', [2 => 'a', 5 => 'b'], '["a","b"]'], + 'tuple' => ['array{string, int}', ['a', 1], '["a",1]'], + 'list of records' => ['list>', [['a']], '[{"0":"a"}]'], + 'list of string keyed records' => ['list>', [['a' => 1]], '[{"a":1}]'], + 'list of empty records' => ['list>', [[]], '[{}]'], +]); + +test('a record serializes to the expected JSON object', function (string $type, mixed $value, string $expected) { + expect(serializedJson($type, $value))->toBe($expected); +})->with('record wire shapes'); + +test('a list serializes to the expected JSON array', function (string $type, mixed $value, string $expected) { + expect(serializedJson($type, $value))->toBe($expected); +})->with('list wire shapes'); + +/** + * The standing guard. The tables above pin exact strings and will need editing whenever a case is + * added; these two say the thing that must never change, so a collection kind added later cannot + * quietly opt out of it. + */ +test('every record is a JSON object, whatever its keys happen to be', function (string $type, mixed $value) { + expect(serializedJson($type, $value))->toStartWith('{'); +})->with('record wire shapes'); + +test('every list is a JSON array', function (string $type, mixed $value) { + expect(serializedJson($type, $value))->toStartWith('['); +})->with('list wire shapes'); + +/** + * A record serializes anything is_iterable, so a lazily produced collection lands in the same + * object. This one goes through the executor directly rather than serializedJson(): that helper + * runs the schema twice, once as parsed and once through the optimizer, and a Generator only + * traverses once. The optimizer parity of this schema is covered by every other case above. + */ +test('a lazily produced collection serializes to a JSON object', function (callable $make) { + $result = new SchemaExecutor()->serialize( + new TypeParser()->parse('array'), + $make(), + new SerializationOptions(partialFailures: false), + ); + + dump($result); + expect($result)->toBeSuccess() + ->and(json_encode($result->value, JSON_THROW_ON_ERROR))->toBe('{"0":"a","1":"b"}'); +})->with([ + 'array' => [fn () => ['a', 'b']], + 'object' => [fn () => (object)['a', 'b']], +]); + +/** + * A packed int keyed array is the case the whole split exists for, so it is asserted on its own + * rather than only as a row in a table. json_encode of the underlying PHP array is what the client + * would have received before, and it is the wrong shape. + */ +test('a record whose keys run 0..n never degrades into a JSON array', function () { + $value = [0 => 'a', 1 => 'b', 2 => 'c']; + + expect(json_encode($value, JSON_THROW_ON_ERROR))->toBe('["a","b","c"]') + ->and(serializedJson('array', $value))->toBe('{"0":"a","1":"b","2":"c"}'); +}); + +test('an empty record never degrades into a JSON array', function () { + expect(json_encode([], JSON_THROW_ON_ERROR))->toBe('[]') + ->and(serializedJson('array', []))->toBe('{}') + ->and(serializedJson('array', []))->toBe('{}') + ->and(serializedJson('list', []))->toBe('[]'); +}); + +/** + * Serialize, encode, decode, parse. The keys have to come back as the type declared them, or the + * record is lossy in a way `array` would notice the moment it indexed one. + * + * The parse leg runs through both decode modes because the transport uses both: the Laravel + * command path decodes associatively, the query path does not and hands back a stdClass. + */ +test('a record round trips through JSON unchanged', function (string $type, array $value, string $expectedJson) { + $json = serializedJson($type, $value); + expect($json)->toBe($expectedJson); + + foreach ([true, false] as $associative) { + $decoded = json_decode($json, $associative, flags: JSON_THROW_ON_ERROR); + $result = executeParse($type, $decoded); + + expect($result)->toBeSuccess(); + expect($result->value)->toBe($value); + } +})->with([ + 'packed int keys' => ['array', [0 => 'a', 1 => 'b'], '{"0":"a","1":"b"}'], + 'sparse int key' => ['array', [42 => 'a'], '{"42":"a"}'], + 'string keys' => ['array', ['a' => 1], '{"a":1}'], + 'literal keys' => ["array<'one'|'two', string>", ['one' => 'x'], '{"one":"x"}'], + 'refined int keys' => ['array', [1 => 'x'], '{"1":"x"}'], + 'nested record' => ['array>', [0 => ['a' => 1]], '{"0":{"a":1}}'], +]); + +test('a list round trips through JSON unchanged', function (string $type, array $value, string $expectedJson) { + $json = serializedJson($type, $value); + expect($json)->toBe($expectedJson); + + $result = executeParse($type, json_decode($json, true, flags: JSON_THROW_ON_ERROR)); + + expect($result)->toBeSuccess(); + expect($result->value)->toBe($value); +})->with([ + 'list' => ['list', ['a', 'b'], '["a","b"]'], + 'empty list' => ['list', [], '[]'], + 'list of records' => ['list>', [['a' => 1]], '[{"a":1}]'], +]); + +test('an int keyed record comes back with int keys, not string ones', function () { + // PHP folds a numeric string key into an int on assignment, which is why array can + // travel as a JSON object and still be indexed by id on the way back. + $result = executeParse('array', json_decode('{"7":"a","42":"b"}', true, flags: JSON_THROW_ON_ERROR)); + + expect($result)->toBeSuccess() + ->and(array_keys($result->value))->toBe([7, 42]) + ->and(array_keys($result->value))->each->toBeInt(); +}); + +test('a string keyed record keeps its keys as strings', function () { + $result = executeParse('array', ['alpha' => 1, 'beta' => 2]); + + expect($result)->toBeSuccess() + ->and(array_keys($result->value))->toBe(['alpha', 'beta']); +}); + +/** + * Keys are validated one at a time on the way in, which is what makes a refined or literal key + * type worth declaring. Nothing is validated on the way out - serialization never re-checks what + * the application produced, the same rule constraints already follow. + */ +test('a key the type does not admit is rejected', function (string $type, mixed $value, string $path) { + expect(executeParse($type, $value))->toBeFailureAt($path, 'validation.invalid_key_type'); +})->with([ + 'non numeric key for an int keyed record' => ['array', ['abc' => 'a'], 'abc'], + 'unknown literal key' => ["array<'one'|'two', string>", ['three' => 'x'], 'three'], + 'empty key for non-empty-string' => ['array', ['' => 1], ''], + 'zero key for positive-int' => ['array', ['0' => 'x'], '0'], + 'unknown int literal key' => ['array<1|2, string>', ['3' => 'x'], '3'], +]); + +test('a bad key is reported once, not once per union arm', function () { + // The key node records an issue per rejected arm on its way to failing. Those describe a value + // at this path rather than a key, so they are dropped for the one issue that says which of the + // two actually failed. + $result = executeParse("array<'one'|'two'|'three', string>", ['four' => 'x']); + + expect($result)->toBeFailure() + ->and($result->issues->allFlat())->toHaveCount(1) + ->and($result->issues->serializeToCompleteString())->toBe('At four: validation.invalid_key_type'); +}); + +test('serializing does not validate keys', function () { + // Output came out of the application's own code, which PHPStan already analysed against the + // very return type being serialized. Re-checking a key here would pay at runtime for a + // guarantee static analysis has given. + expect(serializedJson("array<'one'|'two', string>", ['three' => 'x']))->toBe('{"three":"x"}') + ->and(serializedJson('array', [0 => 'x']))->toBe('{"0":"x"}'); +}); + +test('a record rejects a value that is not a collection at all', function (string $type, mixed $value) { + expect(executeParse($type, $value))->toBeFailure(); +})->with([ + 'string' => ['array', 'nope'], + 'int' => ['array', 7], + 'bool' => ['array', true], + 'null' => ['array', null], +]); diff --git a/tests/Unit/Executor/SchemaExecutorTest.php b/tests/Unit/Executor/SchemaExecutorTest.php index 84610e8..8a56541 100644 --- a/tests/Unit/Executor/SchemaExecutorTest.php +++ b/tests/Unit/Executor/SchemaExecutorTest.php @@ -54,6 +54,16 @@ ['object{id?: string, name: string}|null', ['name' => 'my name', 'other' => ''], (object) ['name' => 'my name']], ['object{id?: string, name: string}|null', null, null], ['array', ['my value' => 1], ['my value' => 1]], + ['array', [], []], + + // A JSON object key travels as a string; json_decode hands numeric ones back as PHP ints, and + // an int keyed record wants exactly that. + ['array', ['0' => 'a', '1' => 'b'], [0 => 'a', 1 => 'b']], + ['array', ['42' => 'a'], [42 => 'a']], + ["array<'one'|'two', string>", ['one' => 'a'], ['one' => 'a']], + ["array<'one'|'two', string>", ['two' => 'b', 'one' => 'a'], ['two' => 'b', 'one' => 'a']], + ['array', ['a' => 1], ['a' => 1]], + ['array', ['1' => 'x'], [1 => 'x']], [UserSchema::class, (object) ['username' => 'my name', 'age' => 1, 'email' => 'leo@me.test'], new UserSchema(1, 'leo@me.test', 'my name')], [UserSchema::class, ['username' => 'my name', 'age' => 1, 'email' => 'leo@me.test'], new UserSchema(1, 'leo@me.test', 'my name')], @@ -146,8 +156,16 @@ public function __toString(): string ['object{id?: string, name: string}', ['name' => 'my name', 'other' => ''], (object) ['name' => 'my name']], ['object{id?: string, name: string}|null', ['name' => 'my name', 'other' => ''], (object) ['name' => 'my name']], ['object{id?: string, name: string}|null', null, null], - ['array', ['my value', 'my other value'], ['my value', 'my other value']], + // Every array<...> leaves as an object, whatever its keys look like. See RecordWireShapeTest + // for the JSON these actually encode to - that, not the PHP type, is the guarantee. + ['array', ['my value', 'my other value'], (object) ['my value', 'my other value']], ['array', ['my value' => 1], (object) ['my value' => 1]], + ['array', [0 => 'a', 1 => 'b'], (object) [0 => 'a', 1 => 'b']], + ['array', [7 => 'a'], (object) [7 => 'a']], + ['array', [], (object) []], + ["array<'one'|'two', string>", ['one' => 'a'], (object) ['one' => 'a']], + ['list', ['a', 'b'], ['a', 'b']], + ['list', [], []], [UserSchema::class, new UserSchema(1, 'leo@me.test', 'my name'), (object) ['username' => 'my name', 'age' => 1]], diff --git a/tests/Unit/Parser/MetadataEliminationTest.php b/tests/Unit/Parser/MetadataEliminationTest.php index 4f6e075..0ead1de 100644 --- a/tests/Unit/Parser/MetadataEliminationTest.php +++ b/tests/Unit/Parser/MetadataEliminationTest.php @@ -73,6 +73,8 @@ function containsMetadataNode(NodeInterface $node): bool 'struct property' => "array{t: BrandedString<'tok'>}", 'list element' => "list>", 'record value' => "array>", + 'record key' => "array, string>", + 'record key and value' => "array, BrandedString<'tok'>>", 'union member' => "BrandedString<'tok'>|int", 'tuple element' => "array{0: BrandedString<'tok'>, 1: int}", 'deeply nested' => "array{a: array{b: list>}}", @@ -134,20 +136,14 @@ function containsMetadataNode(NodeInterface $node): bool ->toThrow(ParserException::class, 'meaningless'); }); -test('unwrapMetadata strips the wrapper and leaves everything else alone', function () { - $inner = new IntNode(); - - expect(Nodes::unwrapMetadata(new MetadataNode($inner, null, 'tag')))->toBe($inner) - ->and(Nodes::unwrapMetadata($inner))->toBe($inner); -}); - -test('unwrapMetadata keeps constraints attached, unlike getDeclaringNode', function () { +test('getDeclaringNode looks through both wrappers', function () { $constrained = new ConstraintNode( new StringNode(), [new NonEmptyString()], ); - $wrapped = new MetadataNode($constrained, null, 'tag'); - expect(Nodes::unwrapMetadata($wrapped))->toBe($constrained) - ->and(Nodes::getDeclaringNode($wrapped))->toBeInstanceOf(StringNode::class); + expect(Nodes::getDeclaringNode(new MetadataNode($constrained, null, 'tag'))) + ->toBeInstanceOf(StringNode::class) + ->and(Nodes::getDeclaringNode($constrained))->toBeInstanceOf(StringNode::class) + ->and(Nodes::getDeclaringNode($inner = new IntNode()))->toBe($inner); }); diff --git a/tests/Unit/Parser/RecordKeyTest.php b/tests/Unit/Parser/RecordKeyTest.php new file mode 100644 index 0000000..4923ccf --- /dev/null +++ b/tests/Unit/Parser/RecordKeyTest.php @@ -0,0 +1,103 @@ +toBeSuccess() + ->and($result->value)->toBe($expected); +})->with([ + // json_decode already handed these over as ints, and IntNode is what declared them. + 'int keys arrive folded' => ['array', ['1' => 'a', '2' => 'b'], [1 => 'a', 2 => 'b']], + 'negative int key' => ['array', ['-1' => 'a'], [-1 => 'a']], + 'int literal key' => ['array<1|2, string>', ['1' => 'x'], [1 => 'x']], + 'refined int key' => ['array', ['1' => 'x'], [1 => 'x']], + + // These are not canonical integers, so PHP keeps them as strings and a string key node takes + // them unchanged. Coercing with filter_var would have folded the first three into `1` and lost + // them. + 'leading space is a string key' => ['array', [' 1' => 1], [' 1' => 1]], + 'leading zero is a string key' => ['array', ['01' => 1], ['01' => 1]], + 'leading plus is a string key' => ['array', ['+1' => 1], ['+1' => 1]], + 'minus zero is a string key' => ['array', ['-0' => 1], ['-0' => 1]], + 'wider than an int is a string key' => ['array', ['9223372036854775808' => 1], ['9223372036854775808' => 1]], + 'ordinary string key' => ['array', ['abc' => 1], ['abc' => 1]], +]); + +test('a numeric key is rejected by a string keyed record instead of silently becoming an int key', function () { + // The point of validating the key as it stands. PHP has no string key '1' to give, so + // accepting this would answer an array under a signature promising string keys. + $result = executeParse('array', ['1' => 'a']); + + expect($result)->toBeFailureAt('1', 'validation.invalid_key_type'); +}); + +test('a string keyed record still accepts a key that merely looks numeric', function () { + // Only a canonical integer folds, so these stay describable by array. + expect(executeParse('array', ['01' => 'a', '1.5' => 'b', '' => 'c']))->toBeSuccess(); +}); + +test('a non numeric key is rejected by an int keyed record', function () { + expect(executeParse('array', ['abc' => 'a']))->toBeFailureAt('abc', 'validation.invalid_key_type'); +}); + +/** + * The same folding rule read from the other side: a key *type* that describes only keys PHP folds + * away can never match anything, so it is rejected when the schema is parsed rather than matching + * nothing at runtime. + */ +test('a string literal key that PHP would fold is not a usable key type', function (string $type) { + expect(fn () => new TypeParser()->parse($type)) + ->toThrow(InvalidSyntaxException::class, "Array key type must be 'string', 'int' or a union of string/int literals"); +})->with([ + 'single digit' => ["array<'1', string>"], + 'negative' => ["array<'-1', string>"], + 'zero' => ["array<'0', string>"], + 'inside a union' => ["array<'one'|'2', string>"], +]); + +test('a string literal key PHP cannot fold is usable', function (string $type) { + expect(new TypeParser()->parse($type))->toBeInstanceOf(RecordNode::class); +})->with([ + 'leading zero' => ["array<'01', string>"], + 'leading plus' => ["array<'+1', string>"], + 'minus zero' => ["array<'-0', string>"], + 'not a number' => ["array<'one', string>"], + 'decimal' => ["array<'1.5', string>"], +]); + +test('an int literal key is usable and emits as the string a JSON key would carry', function () { + expect(typescriptFor(new TypeParser()->parse('array<1|2, string>'), \Le0daniel\PhpTsBindings\Data\IO::OUTPUT)->type) + ->toBe('Partial>'); +}); + +test('a hand built record rejects a key the executor could not honour', function () { + // The parser catches this with a syntax error pointing at the token. AstValidator is what + // catches an AST that never went through the parser. + expect(fn () => new RecordNode(new BoolNode(), new StringNode())->validate()) + ->toThrow(ParserException::class, "A record key must be 'string', 'int' or a union of string/int literals"); +}); + +test('a hand built record with a usable key validates', function () { + validateAst(new RecordNode(new IntNode(), new StringNode())); + validateAst(new RecordNode(new StringNode(), new StringNode())); +}); diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 33fd03f..730f6de 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -36,8 +36,10 @@ use Le0daniel\PhpTsBindings\Typescript\Exceptions\InvalidStringLiteralException; use Le0daniel\PhpTsBindings\Typescript\Exceptions\UnsupportedTypeException; use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; +use Le0daniel\PhpTsBindings\Utils\Nodes; use Tests\Feature\Mocks\Paginated; use Tests\Mocks\ResultEnum; +use Tests\Mocks\ValueObjects\Email; use Tests\Unit\Parser\Data\Stubs\Address; use Tests\Unit\Parser\Data\Stubs\FullAccount; use Tests\Unit\Parser\Data\Stubs\MyUserClass; @@ -376,7 +378,8 @@ compareToOptimizedAst($node); })->with([ ['non-empty-array', RecordNode::class], - ['non-empty-array', ListNode::class], + ['non-empty-array', RecordNode::class], + ['non-empty-array', RecordNode::class], ]); test('the plain list and array types carry no constraint', function (string $type) { @@ -388,8 +391,8 @@ })->with(['list', 'array', 'array']); test('a bare array or list is rejected rather than degraded', function (string $type) { - // PHPStan's bare `array` is array and permits string keys. Modelling it as a - // list would drop those keys on the way out, so it fails like bare `object` does. + // Nothing here says what the elements are, and unlike array there is not even a value type + // to fall back on, so it fails like bare `object` does. expect(fn () => new TypeParser()->parse($type)) ->toThrow(InvalidSyntaxException::class, 'has no single representation'); })->with(['array', 'list', 'non-empty-array', 'non-empty-list']); @@ -444,12 +447,15 @@ compareToOptimizedAst($node); }); -test('List struct', function () { +test('a single generic array is a record, not a list', function () { $parser = new TypeParser(); - /** @var ListNode $node */ + // PHPStan reads array as array, which permits string keys. Only `list` and + // the T[] shorthand promise a packed array, so this is a record like every other array<...>. + /** @var RecordNode $node */ $node = $parser->parse('array'); - expect($node)->toBeInstanceOf(ListNode::class); + expect($node)->toBeInstanceOf(RecordNode::class); + expect($node->keyNode)->toBeInstanceOf(StringNode::class); expect($node->node)->toBeInstanceOf(StringNode::class); compareToOptimizedAst($node); @@ -486,28 +492,88 @@ $node = $parser->parse('array'); expect($node)->toBeInstanceOf(RecordNode::class); + expect($node->keyNode)->toBeInstanceOf(StringNode::class); expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); -test('a constrained array key is rejected, the constraint would be silently unenforceable', function (string $type) { - expect(fn () => new TypeParser()->parse($type)) - ->toThrow(InvalidSyntaxException::class, "Array key type must be 'string' or 'int'"); +test('every array is a record, whatever its key type', function (string $type, string $keyNodeClass) { + /** @var RecordNode $node */ + $node = new TypeParser()->parse($type); + + expect($node)->toBeInstanceOf(RecordNode::class) + ->and(Nodes::getDeclaringNode($node->keyNode))->toBeInstanceOf($keyNodeClass); + + compareToOptimizedAst($node); +})->with([ + // The one that used to be a list. An int key says nothing about the keys running 0..n-1, so + // it is a record and reaches the client as a JSON object. + 'int key' => ['array', IntNode::class], + 'string key' => ['array', StringNode::class], + 'implicit array-key' => ['array', StringNode::class], + 'literal union key' => ["array<'one'|'two', string>", UnionNode::class], + 'double quoted literal key' => ['array<"one"|"two", string>', UnionNode::class], + 'int literal union key' => ['array<1|2, string>', UnionNode::class], +]); + +test('list and the T[] shorthand are the only things that stay a list', function (string $type) { + // The carve-out, pinned from the other side. `list` promises a packed 0..n-1 array and T[] is + // read the same way by convention; nothing else may collapse into one. + $node = Nodes::getDeclaringNode(new TypeParser()->parse($type)); + + expect($node)->toBeInstanceOf(ListNode::class); + + compareToOptimizedAst($node); +})->with([ + 'list', + 'non-empty-list', + 'string[]', + 'string[ ]', + '(string|int)[]', + 'int[][]', + 'array{a: string}[]', +]); + +test('a refined array key is enforced per entry rather than rejected', function (string $type) { + // Keys are validated one at a time now, so a refinement on the key is something the executor + // can actually prove. It used to be rejected on the grounds that it never could be. + /** @var RecordNode $node */ + $node = new TypeParser()->parse($type); + + expect($node)->toBeInstanceOf(RecordNode::class) + ->and($node->keyNode)->toBeInstanceOf(ConstraintNode::class); + + compareToOptimizedAst($node); })->with([ 'non-empty-string key' => ['array'], 'positive-int key' => ['array'], + 'ranged int key' => ['array, string>'], ]); -test('a branded array key is still a plain string or int key on the wire', function (string $type, string $expected) { +test('a key PHP could not hold is rejected', function (string $type) { + expect(fn () => new TypeParser()->parse($type)) + ->toThrow(InvalidSyntaxException::class, "Array key type must be 'string', 'int' or a union of string/int literals"); +})->with([ + 'bool key' => ['array'], + 'mixed key' => ['array'], + 'null key' => ['array'], + 'float literal key' => ['array<1.5, int>'], + 'enum key' => ['array<\\'.ResultEnum::class.', int>'], + 'value object key' => ['array<\\'.Email::class.', int>'], + 'shape key' => ['array'], + 'partly literal union key' => ["array<'one'|bool, string>"], +]); + +test('a branded array key is a record like any other', function (string $type) { $node = new TypeParser()->parse($type); - expect($node::class)->toBe($expected); + expect($node)->toBeInstanceOf(RecordNode::class); compareToOptimizedAst($node); })->with([ - 'branded string key' => ["array, int>", RecordNode::class], - 'branded int key' => ["array, string>", ListNode::class], + 'branded string key' => ["array, int>"], + 'branded int key' => ["array, string>"], ]); test('Test simple literals', function () { diff --git a/tests/Unit/Typescript/TypescriptGeneratorTest.php b/tests/Unit/Typescript/TypescriptGeneratorTest.php index beab129..06c5fd9 100644 --- a/tests/Unit/Typescript/TypescriptGeneratorTest.php +++ b/tests/Unit/Typescript/TypescriptGeneratorTest.php @@ -124,9 +124,31 @@ function typescriptOfBoth(string|NodeInterface $type): string expect(typescriptOfBoth($type))->toBe($expected); })->with([ 'list' => ['list', 'Array'], + 'non empty list' => ['non-empty-list', 'Array'], 'array shorthand' => ['string[]', 'Array'], - 'int keyed array' => ['array', 'Array'], + 'grouped array shorthand' => ['(string|int)[]', 'Array<(string|number)>'], 'record' => ['array', 'Record'], + + // A JSON object key is a string, so an int keyed array is Record. Record + // would read well at a call site and then lie about what Object.keys() hands back. + 'int keyed array' => ['array', 'Record'], + 'implicit array-key' => ['array', 'Record'], + 'non empty array' => ['non-empty-array', 'Record'], + + // A refinement on the key is proven server side and has no shape a key type could carry. + 'refined string key' => ['array', 'Record'], + 'refined int key' => ['array', 'Record'], + + // Only a closed key set lets TypeScript say more than `string`, and there Partial carries the + // difference: Record<'a'|'b', V> demands both keys, a PHP array keyed by 'a'|'b' promises none. + 'literal key union' => ["array<'one'|'two', string>", 'Partial>'], + 'single literal key' => ["array<'only', string>", 'Partial>'], + 'int literal key union' => ['array<1|2, string>', 'Partial>'], + 'literal key union dedupes' => ["array<'a'|'b'|'a', string>", 'Partial>'], + + 'record of lists' => ['array>', 'Record>'], + 'record of records' => ['array>', 'Record>'], + 'list of records' => ['list>', 'Array>'], 'tuple' => ['array{string, int}', '[string,number]'], 'explicitly keyed tuple' => ['array{0: string, 1: int}', '[string,number]'], ]); @@ -169,6 +191,19 @@ function typescriptOfBoth(string|NodeInterface $type): string ], ]); +test('a brand on a record key is dropped and declares no alias', function (string $type, string $expectedType) { + // The key travels as a property name. A branded key type would force the client to cast every + // Object.keys() result before it could index with one, and since the key is never emitted the + // alias a BrandedString would otherwise register is never collected either. + $result = typescriptOf($type); + + expect($result->type)->toBe($expectedType) + ->and($result->registry->isEmpty())->toBeTrue(); +})->with([ + 'branded string key' => ["array, int>", 'Record'], + 'branded int key' => ["array, string>", 'Record'], +]); + test('the BrandedString and BrandedInt utilities keep their implicit alias', function ( string $type, string $expectedType, diff --git a/tests/ts-output/generated/lib/type-map.ts b/tests/ts-output/generated/lib/type-map.ts index 7f28f4e..2b57ea4 100644 --- a/tests/ts-output/generated/lib/type-map.ts +++ b/tests/ts-output/generated/lib/type-map.ts @@ -5,4 +5,4 @@ import type {Availability, Brand, Draft, DraftInput, Failure, Money, Product, Sk /** * Full type map of all operations, input and output types. */ -export type TypeMap = {query: {'accounts.find': {input: {availability?:(null|Availability);term:string;}, output: {id:number;term:string;}, errors: Failure<"account_locked">};'catalog.prepare': {input: DraftInput, output: Draft, errors: Failure};'catalog.product': {input: {id:(number & Brand<"productId">);}, output: Product, errors: Failure};'catalog.search': {input: {availability?:Availability;limit?:number;term:string;}, output: {results:Array;total:number;}, errors: Failure};'shapes.defaults': {input: null, output: {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}, errors: Failure};'shapes.roundtrip': {input: {filters:Record>;page?:number;term:string;}, output: {filters:Record>;page?:number;term:string;}, errors: Failure}};command: {'accounts.lock': {input: {id:number;}, output: {locked:true;}, errors: Failure<"account_locked"|"quota_exceeded">};'accounts.unlock': {input: {id:number;}, output: {unlocked:true;}, errors: Failure};'catalog.restock': {input: {amount:number;price:Money;sku:Sku;}, output: {product:Product;restockedAt:string;}, errors: Failure};'shapes.submit': {input: {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}, output: {accepted:boolean;id:(number & Brand<"productId">);}, errors: Failure}}}; +export type TypeMap = {query: {'accounts.find': {input: {availability?:(null|Availability);term:string;}, output: {id:number;term:string;}, errors: Failure<"account_locked">};'catalog.prepare': {input: DraftInput, output: Draft, errors: Failure};'catalog.product': {input: {id:(number & Brand<"productId">);}, output: Product, errors: Failure};'catalog.search': {input: {availability?:Availability;limit?:number;term:string;}, output: {results:Array;total:number;}, errors: Failure};'shapes.defaults': {input: null, output: {always:true;answer:42;anything:unknown;byId:Record;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);modes:Partial>;nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}, errors: Failure};'shapes.roundtrip': {input: {filters:Record>;page?:number;term:string;}, output: {filters:Record>;page?:number;term:string;}, errors: Failure}};command: {'accounts.lock': {input: {id:number;}, output: {locked:true;}, errors: Failure<"account_locked"|"quota_exceeded">};'accounts.unlock': {input: {id:number;}, output: {unlocked:true;}, errors: Failure};'catalog.restock': {input: {amount:number;price:Money;sku:Sku;}, output: {product:Product;restockedAt:string;}, errors: Failure};'shapes.submit': {input: {dryRun?:boolean;payload:{id:(number & Brand<"productId">);when:string;};}, output: {accepted:boolean;id:(number & Brand<"productId">);}, errors: Failure}}}; diff --git a/tests/ts-output/generated/shapes.ts b/tests/ts-output/generated/shapes.ts index d858048..f521f5d 100644 --- a/tests/ts-output/generated/shapes.ts +++ b/tests/ts-output/generated/shapes.ts @@ -7,7 +7,7 @@ import {queryKey, throwOnFailure} from './lib/utils'; import type {UseQueryOptions} from '@tanstack/react-query'; import {queryOptions, useQuery} from '@tanstack/react-query'; -export type DefaultsResult = {always:true;answer:42;anything:unknown;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}; +export type DefaultsResult = {always:true;answer:42;anything:unknown;byId:Record;count:number;createdAt:string;day:string;either:(string|number);enabled:boolean;literal:"fixed";lookup:Record;maybe:(null|Availability);modes:Partial>;nested:{deep:{value:string;};};nothing:null;pair:[string,number];products:Array;ratio:number;tags:Array;text:string;}; export type DefaultsInput = null; export type DefaultsDomainErrors = never; diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index fa5cea8..8be52c0 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -171,7 +171,17 @@ export async function readDefaults(): Promise { const pair: [string, number] = result.data.pair; const lookup: Record = result.data.lookup; - console.debug(answer, either, anything, pair, lookup); + // Every array<...> is a record. An int keyed one is Record, because that is what a + // JSON object key is — indexing it needs the id as a string, and `.map` is not on offer. + const byId: Record = result.data.byId; + const one: Product | undefined = byId[String(42)]; + + // A literal key set is Partial, so a key that PHP never promised reads as undefined rather + // than being asserted into existence. + const modes: Partial> = result.data.modes; + const drafts: number | undefined = modes.draft; + + console.debug(answer, either, anything, pair, lookup, one, drafts); return result.data.nested.deep.value; } From 46736a3029cac318d0c1fbe09e17178c61d18c05 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 10:10:31 +0200 Subject: [PATCH 084/101] Add mock and test classes to improve coverage for parsing and reflection logic - Introduced new mocks (`ApiCredentials`, `AuditedNoteInput`, `UpdateProfileInput`) to enhance testing of parsing and hydration scenarios. - Added support for virtual properties in parsing strategies and reflection. - Implemented `PropertiesReflector` for determining property accessibility and hooks. - Expanded tests for `TypeParser` and `UserDefinedObjectConsumer` to validate casting strategies and property handling. - Updated serialization and hydration logic to respect input-output constraints, including virtual and hooked properties. - Simplified handling of uncastable types and enriched related test cases. --- .../Consumers/UserDefinedObjectConsumer.php | 44 +++++---- src/Parser/TypeParser.php | 3 +- src/Reflection/PropertiesReflector.php | 46 +++++++++ tests/Unit/Executor/Mocks/ApiCredentials.php | 30 ++++++ .../Unit/Executor/Mocks/AuditedNoteInput.php | 25 +++++ .../Executor/Mocks/UpdateProfileInput.php | 36 +++++++ tests/Unit/Executor/SchemaExecutorTest.php | 97 +++++++++++++++++++ .../Data/Stubs/CastableAbstractClass.php | 13 +++ .../Data/Stubs/ExplicitNeverCasting.php | 14 +++ .../Data/Stubs/ForcedConstructorCasting.php | 14 +++ .../Parser/UserDefinedObjectConsumerTest.php | 84 ++++++++++++++++ tests/Unit/Parser/ValueObjectConsumerTest.php | 2 +- .../Mocks/PropertyVisibilityShowcase.php | 53 ++++++++++ .../Reflection/PropertiesReflectorTest.php | 24 +++++ 14 files changed, 464 insertions(+), 21 deletions(-) create mode 100644 src/Reflection/PropertiesReflector.php create mode 100644 tests/Unit/Executor/Mocks/ApiCredentials.php create mode 100644 tests/Unit/Executor/Mocks/AuditedNoteInput.php create mode 100644 tests/Unit/Executor/Mocks/UpdateProfileInput.php create mode 100644 tests/Unit/Parser/Data/Stubs/CastableAbstractClass.php create mode 100644 tests/Unit/Parser/Data/Stubs/ExplicitNeverCasting.php create mode 100644 tests/Unit/Parser/Data/Stubs/ForcedConstructorCasting.php create mode 100644 tests/Unit/Parser/UserDefinedObjectConsumerTest.php create mode 100644 tests/Unit/Reflection/Mocks/PropertyVisibilityShowcase.php create mode 100644 tests/Unit/Reflection/PropertiesReflectorTest.php diff --git a/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php index 9b9fd40..3fec5e5 100644 --- a/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php @@ -22,6 +22,7 @@ use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\AttributesReflector; use Le0daniel\PhpTsBindings\Reflection\MetadataAttributes; +use Le0daniel\PhpTsBindings\Reflection\PropertiesReflector; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; use Override; use ReflectionClass; @@ -33,20 +34,19 @@ { use InteractsWithGenerics; - public function __construct( - public readonly bool $allowAllObjectCasting = false - ) { + public function __construct() + { } #[Override] public function canConsume(ParserState $state): bool { - if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { + if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { return false; } $fullyQualifiedClassName = $state->context->toFullyQualifiedClassName($state->current()->value); - if (! class_exists($fullyQualifiedClassName) && ! interface_exists($fullyQualifiedClassName)) { + if (!class_exists($fullyQualifiedClassName) && !interface_exists($fullyQualifiedClassName)) { return false; } @@ -68,20 +68,18 @@ private function determineCastingStrategy(ReflectionClass $class): ObjectCastStr return $instance->strategy ?? $this->findCastingStrategy($class); } - if (! $this->allowAllObjectCasting) { - return ObjectCastStrategy::NEVER; - } - - return $this->findCastingStrategy($class); + return ObjectCastStrategy::NEVER; } /** - * @param ReflectionClass $class + * @param ReflectionClass $class */ private function findCastingStrategy(ReflectionClass $class): ObjectCastStrategy { - $hasConstructor = $class->getConstructor() !== null; - if ($hasConstructor) { + $constructor = $class->getConstructor(); + $constructorArgumentCount = $constructor ? count($constructor->getParameters()) : 0; + + if ($constructorArgumentCount > 0) { return ObjectCastStrategy::CONSTRUCTOR; } @@ -131,7 +129,7 @@ private function allowsOptional(ReflectionProperty|ReflectionParameter $param): } $type = $param->getType(); - if ($type === null || ! $type->allowsNull()) { + if ($type === null || !$type->allowsNull()) { throw new ParserException('Optional parameter must allow null or provide a default value. PHP does not difference between null and undefined.'); } @@ -169,8 +167,10 @@ private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, Ty { $properties = []; foreach ($reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { - if ($property->isReadOnly() || $property->hasHooks()) { - throw new ParserException("Property {$property->name} is not writable"); + $isWritable = PropertiesReflector::isWritableFromPublicScope($property); + $isReadable = PropertiesReflector::isReadableFromPublicScope($property); + if (!$isWritable && !$isReadable) { + continue; } $properties[] = new PropertyNode( @@ -180,7 +180,11 @@ private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, Ty $context->descendIntoDeclaringClass($property) ), isOptional: $this->allowsOptional($property), - propertyType: PropertyType::BOTH, + propertyType: match (true) { + $isWritable && $isReadable => PropertyType::BOTH, + $isWritable => PropertyType::INPUT, + $isReadable => PropertyType::OUTPUT, + }, ); } @@ -192,7 +196,7 @@ private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, Ty } /** - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass * * @throws InvalidSyntaxException */ @@ -221,6 +225,10 @@ private function parseConstructorStrategy(ReflectionClass $reflectionClass, Type } foreach ($reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if (!PropertiesReflector::isReadableFromPublicScope($property)) { + continue; + } + if ($property->isPromoted()) { $index = array_find_key($structProperties, fn (PropertyNode $propertyNode) => $propertyNode->name === $property->getName()); if ($index !== null) { diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 7379840..fe34c46 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -63,7 +63,6 @@ public function __construct( */ public static function defaultConsumers( GlobalTypeAliases $globalTypeAliases = new GlobalTypeAliases(), - bool $allowAllObjectCasting = false, ): array { return [ new LiteralConsumer(), @@ -81,7 +80,7 @@ public static function defaultConsumers( new ValueObjectConsumer(), new EnumConsumer(), new DateTimeConsumer(), - new UserDefinedObjectConsumer($allowAllObjectCasting), + new UserDefinedObjectConsumer(), new UtilsConsumer(), ]; } diff --git a/src/Reflection/PropertiesReflector.php b/src/Reflection/PropertiesReflector.php new file mode 100644 index 0000000..bef96ef --- /dev/null +++ b/src/Reflection/PropertiesReflector.php @@ -0,0 +1,46 @@ +isPublic()) { + return false; + } + + if ($property->isReadOnly()) { + return false; + } + + if ($property->isProtectedSet() || $property->isPrivateSet()) { + return false; + } + + // For virtual hooked properties, a set hook is required to be writable. + if ($property->isVirtual()) { + return $property->hasHook(PropertyHookType::Set); + } + + return true; + } + + public static function isReadableFromPublicScope(ReflectionProperty $property): bool + { + if (!$property->isPublic()) { + return false; + } + + if ($property->isVirtual()) { + return $property->hasHook(PropertyHookType::Get); + } + + return true; + } +} diff --git a/tests/Unit/Executor/Mocks/ApiCredentials.php b/tests/Unit/Executor/Mocks/ApiCredentials.php new file mode 100644 index 0000000..0d9b79e --- /dev/null +++ b/tests/Unit/Executor/Mocks/ApiCredentials.php @@ -0,0 +1,30 @@ +obfuscated = str_repeat('*', strlen($value)); + } + } + + public private(set) string $obfuscated = ''; + + public function __construct( + public readonly string $keyId, + protected string $secret, + ) { + } +} diff --git a/tests/Unit/Executor/Mocks/AuditedNoteInput.php b/tests/Unit/Executor/Mocks/AuditedNoteInput.php new file mode 100644 index 0000000..240bdbb --- /dev/null +++ b/tests/Unit/Executor/Mocks/AuditedNoteInput.php @@ -0,0 +1,25 @@ +recordedBy = 'system'; + } +} diff --git a/tests/Unit/Executor/Mocks/UpdateProfileInput.php b/tests/Unit/Executor/Mocks/UpdateProfileInput.php new file mode 100644 index 0000000..9bd11b0 --- /dev/null +++ b/tests/Unit/Executor/Mocks/UpdateProfileInput.php @@ -0,0 +1,36 @@ + "{$this->firstName} {$this->lastName}"; + } + + public private(set) string $passwordHash = ''; + + public string $password { + set { + $this->passwordHash = strrev($value); + } + } + + public string $displayName { + set => trim($value); + } +} diff --git a/tests/Unit/Executor/SchemaExecutorTest.php b/tests/Unit/Executor/SchemaExecutorTest.php index 8a56541..52e37eb 100644 --- a/tests/Unit/Executor/SchemaExecutorTest.php +++ b/tests/Unit/Executor/SchemaExecutorTest.php @@ -19,7 +19,11 @@ use Tests\Mocks\ValueObjects\UserId; use Tests\Mocks\ValueObjects\ValidatedAge; use Tests\Mocks\ValueObjects\ValidatedEmail; +use Tests\Unit\Executor\Mocks\ApiCredentials; +use Tests\Unit\Executor\Mocks\AuditedNoteInput; +use Tests\Unit\Executor\Mocks\UpdateProfileInput; use Tests\Unit\Executor\Mocks\UserSchema; +use Tests\Unit\Parser\Data\Stubs\UncastableClass; use ValueError; test('parse success', function (string $type, mixed $value, mixed $expected) { @@ -564,3 +568,96 @@ public function __toString(): string expect($serialized)->toBeSuccess() ->and($serialized->value)->toEqual((object) ['email' => 'ada@example.test', 'ownerId' => 7]); }); + +test('assign-properties hydration applies set hooks and drops output-only payload keys', function () { + $parsed = executeParse(UpdateProfileInput::class, [ + 'firstName' => 'Ada', + 'lastName' => 'Lovelace', + 'password' => 'secret', + 'displayName' => ' ada ', + // Assigning any of these would throw; they must be dropped before hydration. + 'fullName' => 'decoy', + 'passwordHash' => 'decoy', + 'unknown' => 'decoy', + ]); + + expect($parsed)->toBeSuccess() + ->and($parsed->value)->toBeInstanceOf(UpdateProfileInput::class) + ->and($parsed->value->firstName)->toBe('Ada') + ->and($parsed->value->lastName)->toBe('Lovelace') + ->and($parsed->value->displayName)->toBe('ada') + ->and($parsed->value->passwordHash)->toBe('terces'); +}); + +test('assign-properties serialization reads get hooks and skips write-only properties', function () { + $profile = new UpdateProfileInput(); + $profile->firstName = 'Ada'; + $profile->lastName = 'Lovelace'; + $profile->password = 'secret'; + $profile->displayName = 'ada'; + + $serialized = executeSerialize(UpdateProfileInput::class, $profile); + + expect($serialized)->toBeSuccess() + ->and($serialized->value)->toEqual((object) [ + 'displayName' => 'ada', + 'firstName' => 'Ada', + 'fullName' => 'Ada Lovelace', + 'lastName' => 'Lovelace', + 'passwordHash' => 'terces', + ]); +}); + +test('a missing write-only virtual property fails the parse', function () { + $result = executeParse(UpdateProfileInput::class, [ + 'firstName' => 'Ada', + 'lastName' => 'Lovelace', + 'displayName' => 'ada', + ]); + + expect($result)->toBeFailure('validation.missing_property'); +}); + +test('the zero-argument constructor runs and readonly output stays server-controlled', function () { + $parsed = executeParse(AuditedNoteInput::class, [ + 'note' => 'first entry', + 'recordedBy' => 'spoofed', + ]); + + expect($parsed)->toBeSuccess() + ->and($parsed->value)->toBeInstanceOf(AuditedNoteInput::class) + ->and($parsed->value->note)->toBe('first entry') + ->and($parsed->value->recordedBy)->toBe('system'); + + $serialized = executeSerialize(AuditedNoteInput::class, $parsed->value); + + expect($serialized)->toBeSuccess() + ->and($serialized->value)->toEqual((object) ['note' => 'first entry', 'recordedBy' => 'system']); +}); + +test('constructor casting hydrates hidden members and serializes only readable properties', function () { + $parsed = executeParse(ApiCredentials::class, [ + 'keyId' => 'key_123', + 'secret' => 'hunter2', + ]); + + expect($parsed)->toBeSuccess() + ->and($parsed->value)->toBeInstanceOf(ApiCredentials::class) + ->and($parsed->value->keyId)->toBe('key_123'); + + $parsed->value->plainSecret = 'hunter2'; + $serialized = executeSerialize(ApiCredentials::class, $parsed->value); + + expect($serialized)->toBeSuccess() + ->and($serialized->value)->toEqual((object) ['keyId' => 'key_123', 'obfuscated' => '*******']); +}); + +test('an uncastable class fails on input but serializes to a plain object', function () { + expect(executeParse(UncastableClass::class, ['email' => 'ada@example.test', 'name' => 'Ada'])) + ->toBeFailure(); + + $serialized = executeSerialize(UncastableClass::class, new UncastableClass('ada@example.test', 'Ada')); + + expect($serialized)->toBeSuccess() + ->and($serialized->value)->toEqual((object) ['email' => 'ada@example.test', 'name' => 'Ada']); +}); diff --git a/tests/Unit/Parser/Data/Stubs/CastableAbstractClass.php b/tests/Unit/Parser/Data/Stubs/CastableAbstractClass.php new file mode 100644 index 0000000..e80366e --- /dev/null +++ b/tests/Unit/Parser/Data/Stubs/CastableAbstractClass.php @@ -0,0 +1,13 @@ +parse($class); + + expect($node)->toBeInstanceOf(CustomCastingNode::class) + ->and($node->strategy)->toBe($strategy); + + compareToOptimizedAst($node); + validateAst($node); +})->with([ + 'constructor with arguments' => [UserSchema::class, ObjectCastStrategy::CONSTRUCTOR], + 'zero-argument constructor assigns properties' => [AuditedNoteInput::class, ObjectCastStrategy::ASSIGN_PROPERTIES], + 'no constructor assigns properties' => [UpdateProfileInput::class, ObjectCastStrategy::ASSIGN_PROPERTIES], + 'explicit strategy wins over inference' => [ExplicitNeverCasting::class, ObjectCastStrategy::NEVER], + 'abstract class is never castable' => [CastableAbstractClass::class, ObjectCastStrategy::NEVER], +]); + +test('forcing the constructor strategy without a constructor fails', function () { + expect(fn () => new TypeParser()->parse(ForcedConstructorCasting::class)) + ->toThrow(ParserException::class, 'declares none'); +}); + +test('assign-properties classifies each property by public-scope accessibility', function () { + /** @var CustomCastingNode $node */ + $node = new TypeParser()->parse(UpdateProfileInput::class); + $struct = $node->node; + + expect($struct->properties)->toHaveCount(6) + ->and($struct->getProperty('firstName')->propertyType)->toBe(PropertyType::BOTH) + ->and($struct->getProperty('lastName')->propertyType)->toBe(PropertyType::BOTH) + ->and($struct->getProperty('displayName')->propertyType)->toBe(PropertyType::BOTH) + ->and($struct->getProperty('fullName')->propertyType)->toBe(PropertyType::OUTPUT) + ->and($struct->getProperty('passwordHash')->propertyType)->toBe(PropertyType::OUTPUT) + ->and($struct->getProperty('password')->propertyType)->toBe(PropertyType::INPUT); + + expect(typescriptFor($node, IO::INPUT)->type) + ->toBe('{displayName:string;firstName:string;lastName:string;password:string;}') + ->and(typescriptFor($node, IO::OUTPUT)->type) + ->toBe('{displayName:string;firstName:string;fullName:string;lastName:string;passwordHash:string;}'); +}); + +test('a readonly property becomes output-only instead of failing the parse', function () { + /** @var CustomCastingNode $node */ + $node = new TypeParser()->parse(AuditedNoteInput::class); + + expect($node->strategy)->toBe(ObjectCastStrategy::ASSIGN_PROPERTIES) + ->and($node->node->getProperty('note')->propertyType)->toBe(PropertyType::BOTH) + ->and($node->node->getProperty('recordedBy')->propertyType)->toBe(PropertyType::OUTPUT); + + expect(typescriptFor($node, IO::INPUT)->type)->toBe('{note:string;}') + ->and(typescriptFor($node, IO::OUTPUT)->type)->toBe('{note:string;recordedBy:string;}'); +}); + +test('constructor strategy hides unreadable and non-public members per direction', function () { + /** @var CustomCastingNode $node */ + $node = new TypeParser()->parse(ApiCredentials::class); + $struct = $node->node; + + expect($node->strategy)->toBe(ObjectCastStrategy::CONSTRUCTOR) + ->and($struct->getProperty('keyId')->propertyType)->toBe(PropertyType::BOTH) + ->and($struct->getProperty('secret')->propertyType)->toBe(PropertyType::INPUT) + ->and($struct->getProperty('obfuscated')->propertyType)->toBe(PropertyType::OUTPUT) + ->and($struct->hasProperty('plainSecret'))->toBeFalse(); + + expect(typescriptFor($node, IO::INPUT)->type)->toBe('{keyId:string;secret:string;}') + ->and(typescriptFor($node, IO::OUTPUT)->type)->toBe('{keyId:string;obfuscated:string;}'); +}); diff --git a/tests/Unit/Parser/ValueObjectConsumerTest.php b/tests/Unit/Parser/ValueObjectConsumerTest.php index c4c4b7a..c3b1edf 100644 --- a/tests/Unit/Parser/ValueObjectConsumerTest.php +++ b/tests/Unit/Parser/ValueObjectConsumerTest.php @@ -126,7 +126,7 @@ }); test('a value object is never treated as a castable object', function () { - $parser = new TypeParser(TypeParser::defaultConsumers(allowAllObjectCasting: true)); + $parser = new TypeParser(TypeParser::defaultConsumers()); expect($parser->parse(Slug::class))->toBeInstanceOf(ValueObjectNode::class); }); diff --git a/tests/Unit/Reflection/Mocks/PropertyVisibilityShowcase.php b/tests/Unit/Reflection/Mocks/PropertyVisibilityShowcase.php new file mode 100644 index 0000000..3de3eb9 --- /dev/null +++ b/tests/Unit/Reflection/Mocks/PropertyVisibilityShowcase.php @@ -0,0 +1,53 @@ + $this->plain; + } + + public string $virtualSetOnly { + set { + $this->plain = $value; + } + } + + public string $virtualGetSet { + get => $this->plain; + set { + $this->plain = $value; + } + } + + public string $backedWithSetHook { + set => trim($value); + } + + public function __construct() + { + $this->readonlyProp = 'readonly'; + $this->privateProp = 'private'; + $this->protectedProp = 'protected'; + } +} diff --git a/tests/Unit/Reflection/PropertiesReflectorTest.php b/tests/Unit/Reflection/PropertiesReflectorTest.php new file mode 100644 index 0000000..16e2d68 --- /dev/null +++ b/tests/Unit/Reflection/PropertiesReflectorTest.php @@ -0,0 +1,24 @@ +toBe($writable) + ->and(PropertiesReflector::isReadableFromPublicScope($reflection))->toBe($readable); +})->with([ + 'plain public' => ['plain', true, true], + 'protected' => ['protectedProp', false, false], + 'private' => ['privateProp', false, false], + 'readonly' => ['readonlyProp', false, true], + 'private(set)' => ['privateSet', false, true], + 'protected(set)' => ['protectedSet', false, true], + 'virtual get-only hook' => ['virtualGetOnly', false, true], + 'virtual set-only hook' => ['virtualSetOnly', true, false], + 'virtual get and set hooks' => ['virtualGetSet', true, true], + 'backed set hook' => ['backedWithSetHook', true, true], +]); From 4b3d9a938739d0cc8b91d9625546a7acb3c8b428 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 10:16:07 +0200 Subject: [PATCH 085/101] Add `CachedOperationRegistry` tests and optimize memoization logic in operation factories - Introduced `CachedOperationRegistryTest` for comprehensive unit testing of cached operation behavior, including partial materialization, memoization, and error handling. - Added `RegistryFixtureOperations` mock for testing query and command operation scenarios. - Refactored `CachedOperationRegistry` to replace legacy one-closure-per-operation format with a centralized match-arm approach, improving performance and memory efficiency. - Enhanced error handling with `OperationNotFoundException::forKey` for unknown operation keys. - Updated PHP code generation in `CachedOperationRegistry::toPhpCode` to streamline key and operation definition logic. --- .../Exceptions/OperationNotFoundException.php | 7 ++ .../Operations/CachedOperationRegistry.php | 68 ++++++++++++++---- .../CachedOperationRegistryTest.php | 72 +++++++++++++++++++ .../Mocks/RegistryFixtureOperations.php | 31 ++++++++ 4 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/Server/Operations/CachedOperationRegistryTest.php create mode 100644 tests/Unit/Server/Operations/Mocks/RegistryFixtureOperations.php diff --git a/src/Server/Data/Exceptions/OperationNotFoundException.php b/src/Server/Data/Exceptions/OperationNotFoundException.php index 77c1238..84cab41 100644 --- a/src/Server/Data/Exceptions/OperationNotFoundException.php +++ b/src/Server/Data/Exceptions/OperationNotFoundException.php @@ -8,4 +8,11 @@ final class OperationNotFoundException extends SchemaException { + public static function forKey(string $key): self + { + return new self( + "Unknown operation key '{$key}'. The operations cache is stale or was written by a " + .'different build. Regenerate the operations cache.', + ); + } } diff --git a/src/Server/Operations/CachedOperationRegistry.php b/src/Server/Operations/CachedOperationRegistry.php index 92b9564..40bc5c1 100644 --- a/src/Server/Operations/CachedOperationRegistry.php +++ b/src/Server/Operations/CachedOperationRegistry.php @@ -6,12 +6,22 @@ use Closure; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; +use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer; +use Le0daniel\PhpTsBindings\Server\Data\Exceptions\OperationNotFoundException; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Utils\PHPExport; use Override; +/** + * Serves operations from generated code, memoizing each one. + * + * Like CachedTypeRegistry, the factory is a single closure wrapping a match over every key rather + * than an array holding one closure per key: the array form allocates one Closure per operation on + * every require of the cache file, while a match arm costs nothing until its key is requested. The + * key table backing has() is a plain literal, which opcache shares across requests. + */ final class CachedOperationRegistry implements OperationRegistry { /** @@ -20,10 +30,29 @@ final class CachedOperationRegistry implements OperationRegistry private array $instances = []; /** - * @param array $operations + * @var Closure(string): Operation */ - public function __construct(private readonly array $operations) - { + private readonly Closure $factory; + + /** + * @param Closure(string): Operation|array $factory The array form is the + * one-closure-per-operation format written by older builds and is rejected: + * this registry answers has() from the key table, which that format never wrote. + * @param array $keys Defaults to empty only so a legacy cache reaches the + * descriptive rejection above instead of an ArgumentCountError. + */ + public function __construct( + Closure|array $factory, + private readonly array $keys = [], + ) { + if (! $factory instanceof Closure) { + throw new SchemaException( + 'The operations cache holds one closure per operation, the format written by an ' + .'older build, and cannot be read. Regenerate the operations cache.', + ); + } + + $this->factory = $factory; } #[Override] @@ -31,7 +60,7 @@ public function has(OperationType $type, string $fullyQualifiedKey): bool { return array_key_exists( $type->registryKey($fullyQualifiedKey), - $this->operations + $this->keys ); } @@ -40,14 +69,14 @@ public function get(OperationType $type, string $fullyQualifiedKey): Operation { $key = $type->registryKey($fullyQualifiedKey); - return $this->instances[$key] ??= $this->operations[$key](); + return $this->instances[$key] ??= ($this->factory)($key); } #[Override] public function all(): array { - foreach ($this->operations as $key => $factory) { - $this->instances[$key] ??= $factory(); + foreach ($this->keys as $key => $present) { + $this->instances[$key] ??= ($this->factory)($key); } return $this->instances; @@ -59,7 +88,8 @@ public static function toPhpCode( ): string { $endpointClass = PHPExport::absolute(Operation::class); - $endpoints = []; + $arms = []; + $keys = []; $asts = []; foreach ($registry->all() as $endpoint) { $operation = $endpoint->definition; @@ -75,8 +105,9 @@ public static function toPhpCode( // The key is computed based on the endpoint key from the operation registry provided. $key = $operation->type->registryKey($endpoint->key); - $endpoints[] = - "'{$key}' => fn() => new {$endpointClass}('{$endpoint->key}', $exportedDefinition, fn() => \$typeRegistry->get('{$inputAstName}'), fn() => \$typeRegistry->get('{$outputAstName}'))"; + $arms[] = + "'{$key}' => new {$endpointClass}('{$endpoint->key}', $exportedDefinition, fn() => \$typeRegistry->get('{$inputAstName}'), fn() => \$typeRegistry->get('{$outputAstName}')),"; + $keys[] = "'{$key}' => true,"; } // The ast optimizer deduplicates all the ASTs, minimizing the nodes required at runtime. @@ -84,16 +115,25 @@ public static function toPhpCode( idLength: $idLength, ); $operationRegistryClass = PHPExport::absolute(CachedOperationRegistry::class); + $notFoundException = PHPExport::absolute(OperationNotFoundException::class); // Operation discovery order depends on the filesystem, so sorting by key is what makes the // generated artifact byte identical across machines. ksort($asts); - sort($endpoints); - $endpointsCode = implode(',', $endpoints); + sort($arms); + sort($keys); + $armsCode = implode(PHP_EOL, $arms); + $keysCode = implode('', $keys); return <<generateOptimizedCode($asts)}; -return new {$operationRegistryClass}([{$endpointsCode}]); +\$typeRegistry = {$optimizer->generateOptimizedCode($asts)}; +return new {$operationRegistryClass}( + static fn (string \$key): {$endpointClass} => match (\$key) { +{$armsCode} + default => throw {$notFoundException}::forKey(\$key), + }, + [{$keysCode}], +); PHP; } diff --git a/tests/Unit/Server/Operations/CachedOperationRegistryTest.php b/tests/Unit/Server/Operations/CachedOperationRegistryTest.php new file mode 100644 index 0000000..0318b94 --- /dev/null +++ b/tests/Unit/Server/Operations/CachedOperationRegistryTest.php @@ -0,0 +1,72 @@ +toBeInstanceOf(CachedOperationRegistry::class) + ->and($cached->has(OperationType::QUERY, 'registry.greet'))->toBeTrue() + ->and($cached->has(OperationType::COMMAND, 'registry.rename'))->toBeTrue() + ->and($cached->has(OperationType::COMMAND, 'registry.greet'))->toBeFalse(); + + $operation = $cached->get(OperationType::QUERY, 'registry.greet'); + + expect($operation)->toBeInstanceOf(Operation::class) + ->and($operation->key)->toBe('registry.greet') + ->and($cached->get(OperationType::QUERY, 'registry.greet'))->toBe($operation); +}); + +test('all() materializes every operation keyed by its registry key', function () { + $cached = eval(compiledRegistryCode()); + + expect($cached->all()) + ->toHaveCount(2) + ->toHaveKeys(['QUERY@registry.greet', 'COMMAND@registry.rename']); +}); + +test('the compiled code shares one factory across operations instead of allocating one closure per entry', function () { + $code = compiledRegistryCode(); + $operationClass = PHPExport::absolute(Operation::class); + + // The legacy shape was `'KEY' => fn() => new Operation(...)`, one closure allocated per + // operation on every require of the cache file. A match arm costs nothing until its key + // is requested. + expect($code)->not->toContain("fn() => new {$operationClass}(") + ->and($code)->toContain("'QUERY@registry.greet' => new {$operationClass}(") + ->and($code)->toContain("'QUERY@registry.greet' => true"); +}); + +test('asking the compiled registry for an unknown key names the key', function () { + $cached = eval(compiledRegistryCode()); + + expect(fn () => $cached->get(OperationType::QUERY, 'registry.missing')) + ->toThrow(OperationNotFoundException::class, 'QUERY@registry.missing'); +}); + +test('a cache in the legacy one-closure-per-operation format is rejected with the reason', function () { + expect(fn () => new CachedOperationRegistry(['QUERY@x' => static fn () => null])) + ->toThrow(SchemaException::class, 'Regenerate the operations cache'); +}); diff --git a/tests/Unit/Server/Operations/Mocks/RegistryFixtureOperations.php b/tests/Unit/Server/Operations/Mocks/RegistryFixtureOperations.php new file mode 100644 index 0000000..5cafa4a --- /dev/null +++ b/tests/Unit/Server/Operations/Mocks/RegistryFixtureOperations.php @@ -0,0 +1,31 @@ + "Hello {$input['name']}"]; + } + + /** + * @param array{name: string} $input + * @return array{name: string} + */ + #[Command('registry')] + public function rename(array $input): array + { + return $input; + } +} From 0c98fee4efa9c1b68992a31d162c7c26324ac3a2 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 10:20:08 +0200 Subject: [PATCH 086/101] Rename `registryKey` to `fullyQualifiedOperationKey` for consistency across operation registries and improve parameter naming. --- src/Contracts/OperationRegistry.php | 4 ++-- src/Server/Data/OperationType.php | 4 ++-- src/Server/Operations/CachedOperationRegistry.php | 10 +++++----- .../Operations/EagerlyLoadedOperationRegistry.php | 10 +++++----- tests/Unit/Server/KeyGeneratorTest.php | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Contracts/OperationRegistry.php b/src/Contracts/OperationRegistry.php index 60deab5..3f516c0 100644 --- a/src/Contracts/OperationRegistry.php +++ b/src/Contracts/OperationRegistry.php @@ -9,9 +9,9 @@ interface OperationRegistry { - public function has(OperationType $type, string $fullyQualifiedKey): bool; + public function has(OperationType $type, string $key): bool; - public function get(OperationType $type, string $fullyQualifiedKey): Operation; + public function get(OperationType $type, string $key): Operation; /** * @return array diff --git a/src/Server/Data/OperationType.php b/src/Server/Data/OperationType.php index 2e918de..af74088 100644 --- a/src/Server/Data/OperationType.php +++ b/src/Server/Data/OperationType.php @@ -22,8 +22,8 @@ public function lowerCase(): string * type is part of the key - and every registry has to spell it the same way, or a cache written * by one cannot be read by the other. */ - public function registryKey(string $fullyQualifiedKey): string + public function fullyQualifiedOperationKey(string $key): string { - return "{$this->name}@{$fullyQualifiedKey}"; + return "{$this->name}@{$key}"; } } diff --git a/src/Server/Operations/CachedOperationRegistry.php b/src/Server/Operations/CachedOperationRegistry.php index 40bc5c1..c6b6969 100644 --- a/src/Server/Operations/CachedOperationRegistry.php +++ b/src/Server/Operations/CachedOperationRegistry.php @@ -56,18 +56,18 @@ public function __construct( } #[Override] - public function has(OperationType $type, string $fullyQualifiedKey): bool + public function has(OperationType $type, string $key): bool { return array_key_exists( - $type->registryKey($fullyQualifiedKey), + $type->fullyQualifiedOperationKey($key), $this->keys ); } #[Override] - public function get(OperationType $type, string $fullyQualifiedKey): Operation + public function get(OperationType $type, string $key): Operation { - $key = $type->registryKey($fullyQualifiedKey); + $key = $type->fullyQualifiedOperationKey($key); return $this->instances[$key] ??= ($this->factory)($key); } @@ -103,7 +103,7 @@ public static function toPhpCode( $exportedDefinition = $endpoint->definition->exportPhpCode(); // The key is computed based on the endpoint key from the operation registry provided. - $key = $operation->type->registryKey($endpoint->key); + $key = $operation->type->fullyQualifiedOperationKey($endpoint->key); $arms[] = "'{$key}' => new {$endpointClass}('{$endpoint->key}', $exportedDefinition, fn() => \$typeRegistry->get('{$inputAstName}'), fn() => \$typeRegistry->get('{$outputAstName}')),"; diff --git a/src/Server/Operations/EagerlyLoadedOperationRegistry.php b/src/Server/Operations/EagerlyLoadedOperationRegistry.php index 1479d41..3d3bf4f 100644 --- a/src/Server/Operations/EagerlyLoadedOperationRegistry.php +++ b/src/Server/Operations/EagerlyLoadedOperationRegistry.php @@ -82,7 +82,7 @@ private static function registryFromDiscovery( $factories = []; foreach ($discovery->operations as $definition) { $key = $keyGenerator->generateKey($definition->namespace, $definition->name); - $fullyQualifiedKey = $definition->type->registryKey($key); + $fullyQualifiedKey = $definition->type->fullyQualifiedOperationKey($key); // Keys can be truncated hashes, so two operations can collide on one. Assigning over // the entry would leave an operation silently unreachable; ASTOptimizer throws for the @@ -135,9 +135,9 @@ public static function withClasses( } #[Override] - public function has(OperationType $type, string $fullyQualifiedKey): bool + public function has(OperationType $type, string $key): bool { - $key = $type->registryKey($fullyQualifiedKey); + $key = $type->fullyQualifiedOperationKey($key); return array_key_exists($key, $this->factories); } @@ -146,9 +146,9 @@ public function has(OperationType $type, string $fullyQualifiedKey): bool * @throws ReflectionException */ #[Override] - public function get(OperationType $type, string $fullyQualifiedKey): Operation + public function get(OperationType $type, string $key): Operation { - $key = $type->registryKey($fullyQualifiedKey); + $key = $type->fullyQualifiedOperationKey($key); return $this->instances[$key] ??= $this->factories[$key](); } diff --git a/tests/Unit/Server/KeyGeneratorTest.php b/tests/Unit/Server/KeyGeneratorTest.php index 7d4a444..084f505 100644 --- a/tests/Unit/Server/KeyGeneratorTest.php +++ b/tests/Unit/Server/KeyGeneratorTest.php @@ -53,6 +53,6 @@ }); test('a query and a command with the same name are distinct registry keys', function () { - expect(OperationType::QUERY->registryKey('users.get')) - ->not->toBe(OperationType::COMMAND->registryKey('users.get')); + expect(OperationType::QUERY->fullyQualifiedOperationKey('users.get')) + ->not->toBe(OperationType::COMMAND->fullyQualifiedOperationKey('users.get')); }); From 2524f46aca574c9a0bdbb9846be07ba0a753ce87 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 10:37:40 +0200 Subject: [PATCH 087/101] Update terminology to replace `{fqn}` with `{key}` for operation keys across documentation, code, and tests to standardize naming and improve clarity. --- README.md | 16 ++++++++-------- docs/laravel.md | 8 ++++---- docs/server.md | 2 +- docs/typescript-client.md | 4 ++-- src/Adapters/Laravel/Commands/ListCommand.php | 2 +- src/Adapters/Laravel/LaravelHttpController.php | 14 +++++++------- .../EmitOperationClientBindings.php | 2 +- src/CodeGen/Data/ServerMetadata.php | 8 ++++---- src/Contracts/OperationKeyGenerator.php | 6 ++++++ src/Server/Preloader.php | 4 ++-- tests/Unit/CodeGen/CodeGeneratorsTest.php | 2 +- .../CodeGen/EmitOperationClientBindingsTest.php | 2 +- .../Unit/CodeGen/EmitOperationsSpaClientTest.php | 2 +- tests/Unit/CodeGen/EmitQueryKeyTest.php | 2 +- tests/Unit/CodeGen/EmitTanstackQueryTest.php | 2 +- tests/Unit/CodeGen/EmitTypeUtilsTest.php | 2 +- tests/Unit/CodeGen/EmitTypesTest.php | 8 ++++---- tests/Unit/CodeGen/TsOutputFixture.php | 2 +- .../TypescriptServerCodeGeneratorTest.php | 4 ++-- tests/ts-output/generated/lib/DefaultClient.ts | 2 +- tests/ts-output/generated/lib/bindings.ts | 2 +- 21 files changed, 51 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 601a8ca..fc58b3f 100644 --- a/README.md +++ b/README.md @@ -159,8 +159,8 @@ $server = new Server( $files = new TypescriptServerCodeGenerator( CodeGenerators::fromDefaults('name'), )->generate($server, new ServerMetadata( - '/query/{fqn}', - '/command/{fqn}', + '/query/{key}', + '/command/{key}', $server->configuration, )); @@ -172,7 +172,7 @@ the rule that names the generated functions. `with:` and `without:` change the s opt-in — and what it returns is a plain list, so you can append your own or skip the factory and pass your own array. See [the generators](docs/typescript-client.md#generators) for the whole menu. -The two URLs are the routes *your* transport serves; `{fqn}` is where the operation key goes, and +The two URLs are the routes *your* transport serves; `{key}` is where the operation key goes, and both are required to contain it. The configuration rides along because which error categories the generated `Failure` union names depends on how this server maps exceptions. @@ -182,11 +182,11 @@ whole envelope the generated client reads, so a transport is two lines: ```php use Le0daniel\PhpTsBindings\Server\Client\NullClient; -// GET /query/{fqn} — each query parameter JSON-decoded back into a value -$result = $server->query($fqn, $input, $myContext, new NullClient()); +// GET /query/{key} — each query parameter JSON-decoded back into a value +$result = $server->query($key, $input, $myContext, new NullClient()); -// POST /command/{fqn} — the JSON body -$result = $server->command($fqn, $input, $myContext, new NullClient()); +// POST /command/{key} — the JSON body +$result = $server->command($key, $input, $myContext, new NullClient()); respondJson($result->statusCode, $result->jsonSerialize()); ``` @@ -240,7 +240,7 @@ output that does not match its type is a 500 rather than something the client is The PHPStan *refinements* on top of a type (`positive-int`, `non-empty-string`) are checked on the way in only, because static analysis already established them on the way out. -**`$name` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns +**`$key` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns `namespace` + `name` into what the client calls: `PlainlyExposedKeyGenerator` gives literal keys, `HashSha256KeyGenerator` opaque ones — which is not a security boundary, it only keeps your operation names out of the shipped bundle. The generated TypeScript always embeds whichever key the diff --git a/docs/laravel.md b/docs/laravel.md index 7860a31..6628e8d 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -84,8 +84,8 @@ operations belong to: use Le0daniel\PhpTsBindings\Adapters\Laravel\LaravelHttpController; Route::middleware('web')->group(function () { - LaravelHttpController::registerQueries(); // GET /query/{fqn} - LaravelHttpController::registerCommands(); // POST /command/{fqn} + LaravelHttpController::registerQueries(); // GET /query/{key} + LaravelHttpController::registerCommands(); // POST /command/{key} }); ``` @@ -98,9 +98,9 @@ Because you register them, the middleware group, authentication, throttling, ses behaviour are entirely your application's choice — the adapter has no opinion and adds nothing to the HTTP kernel. -**The route parameter must stay named `{fqn}`.** The generated client substitutes the operation key +**The route parameter must stay named `{key}`.** The generated client substitutes the operation key into that placeholder literally, so a hand-registered route using any other name yields a client -requesting URLs that still contain `{fqn}` — no error anywhere, just 404s. +requesting URLs that still contain `{key}` — no error anywhere, just 404s. ## Context diff --git a/docs/server.md b/docs/server.md index a5efcbf..41c7d49 100644 --- a/docs/server.md +++ b/docs/server.md @@ -39,7 +39,7 @@ something yourself with the same instance. ## Operation keys -**`$name` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns +**`$key` is the operation's *key*, not its plain name.** An `OperationKeyGenerator` turns `namespace` + `name` into what the client calls. | Generator | Produces | diff --git a/docs/typescript-client.md b/docs/typescript-client.md index 7cf5a60..7555745 100644 --- a/docs/typescript-client.md +++ b/docs/typescript-client.md @@ -107,8 +107,8 @@ an envelope. That cuts both ways for throttling: a gateway or route middleware a its *own* body is `CLIENT_ERROR` like any other imposter — only the server's own envelope narrows to `RATE_LIMITED`. -The URLs come from `ServerMetadata('/query/{fqn}', '/command/{fqn}')`, the two routes *your* -transport serves. `{fqn}` is where the operation key goes, and both are required to contain it. +The URLs come from `ServerMetadata('/query/{key}', '/command/{key}')`, the two routes *your* +transport serves. `{key}` is where the operation key goes, and both are required to contain it. ## Failing loudly diff --git a/src/Adapters/Laravel/Commands/ListCommand.php b/src/Adapters/Laravel/Commands/ListCommand.php index 28fedd8..237d6eb 100644 --- a/src/Adapters/Laravel/Commands/ListCommand.php +++ b/src/Adapters/Laravel/Commands/ListCommand.php @@ -59,6 +59,6 @@ public function handle( private function bindUri(string $uri, Operation $operation): string { - return str_replace('{fqn}', $operation->key, $uri); + return str_replace('{key}', $operation->key, $uri); } } diff --git a/src/Adapters/Laravel/LaravelHttpController.php b/src/Adapters/Laravel/LaravelHttpController.php index dc2cc0d..b65fa91 100644 --- a/src/Adapters/Laravel/LaravelHttpController.php +++ b/src/Adapters/Laravel/LaravelHttpController.php @@ -37,23 +37,23 @@ public function __construct( public static function registerQueries(string $routePrefix = 'query'): Route { - return Facades\Route::get("{$routePrefix}/{fqn}", [self::class, 'handleHttpQueryRequest']) + return Facades\Route::get("{$routePrefix}/{key}", [self::class, 'handleHttpQueryRequest']) ->name(self::QUERY_NAME); } public static function registerCommands(string $routePrefix = 'command'): Route { - return Facades\Route::post("{$routePrefix}/{fqn}", [self::class, 'handleHttpCommandRequest']) + return Facades\Route::post("{$routePrefix}/{key}", [self::class, 'handleHttpCommandRequest']) ->name(self::COMMAND_NAME); } /** * @throws Throwable */ - public function handleHttpQueryRequest(string $fqn, Http\Request $request): JsonResponse + public function handleHttpQueryRequest(string $key, Http\Request $request): JsonResponse { return $this->server->query( - $fqn, + $key, input: $this->gatherInputFromRequest(OperationType::QUERY, $request), context: $this->contextFactory?->createContextFromHttpRequest($request), client: $this->clientFactory->createClientFromHttpRequest($request), @@ -65,10 +65,10 @@ public function handleHttpQueryRequest(string $fqn, Http\Request $request): Json /** * @throws Throwable */ - public function handleHttpCommandRequest(string $fqn, Http\Request $request): JsonResponse + public function handleHttpCommandRequest(string $key, Http\Request $request): JsonResponse { return $this->server->command( - $fqn, + $key, input: $this->gatherInputFromRequest(OperationType::COMMAND, $request), context: $this->contextFactory?->createContextFromHttpRequest($request), client: $this->clientFactory->createClientFromHttpRequest($request), @@ -151,7 +151,7 @@ private function produceJsonResponse(RpcResult $result): JsonResponse $jsonResponse['__resolveInfo'] = [ 'handler' => "{$result->resolveInfo->className}@{$result->resolveInfo->methodName}", 'middleware' => $result->resolveInfo->middleware, - 'fqn' => $result->resolveInfo->fullyQualifiedName, + 'fullyQualifiedName' => $result->resolveInfo->fullyQualifiedName, 'type' => $result->resolveInfo->operationType->name, ]; } diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index a41a542..7a7988b 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -195,7 +195,7 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; - const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{fqn}', key)}`; + const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{key}', key)}`; // Per call wins over the client wide default, and the timeout signal actually fires: a // fresh AbortController is never aborted by anything. diff --git a/src/CodeGen/Data/ServerMetadata.php b/src/CodeGen/Data/ServerMetadata.php index 72ea374..59732fb 100644 --- a/src/CodeGen/Data/ServerMetadata.php +++ b/src/CodeGen/Data/ServerMetadata.php @@ -23,11 +23,11 @@ public function __construct( public string $commandUrl, public ServerConfiguration $configuration, ) { - if (! str_contains($this->queryUrl, '{fqn}')) { - throw new CodeGenException('Query URL must contain {fqn} placeholder'); + if (! str_contains($this->queryUrl, '{key}')) { + throw new CodeGenException('Query URL must contain {key} placeholder'); } - if (! str_contains($this->commandUrl, '{fqn}')) { - throw new CodeGenException('Command URL must contain {fqn} placeholder'); + if (! str_contains($this->commandUrl, '{key}')) { + throw new CodeGenException('Command URL must contain {key} placeholder'); } } diff --git a/src/Contracts/OperationKeyGenerator.php b/src/Contracts/OperationKeyGenerator.php index fae1b62..3a0e6d0 100644 --- a/src/Contracts/OperationKeyGenerator.php +++ b/src/Contracts/OperationKeyGenerator.php @@ -6,5 +6,11 @@ interface OperationKeyGenerator { + /** + * The key must be a pure function of namespace and name: the Preloader re-derives keys from + * exactly these two values, without a Definition in hand. That is why this method deliberately + * does not receive the full Definition - an implementation keying off class or method names + * would produce keys the Preloader can never reconstruct. + */ public function generateKey(string $namespace, string $name): string; } diff --git a/src/Server/Preloader.php b/src/Server/Preloader.php index 4769747..4169b87 100644 --- a/src/Server/Preloader.php +++ b/src/Server/Preloader.php @@ -37,8 +37,8 @@ public function __construct( public function preload(string|UnitEnum $namespace, string $name, mixed $input, mixed $context): array { $namespaceAsString = Strings::toString($namespace); - $fqcn = $this->keyGenerator->generateKey($namespaceAsString, $name); - $result = $this->server->query($fqcn, $input, $context, new NullClient()); + $key = $this->keyGenerator->generateKey($namespaceAsString, $name); + $result = $this->server->query($key, $input, $context, new NullClient()); if (! $result instanceof RpcSuccess) { throw new SchemaException("Failed to preload: {$namespaceAsString}.{$name}"); diff --git a/tests/Unit/CodeGen/CodeGeneratorsTest.php b/tests/Unit/CodeGen/CodeGeneratorsTest.php index 5e1e6da..8bb9e37 100644 --- a/tests/Unit/CodeGen/CodeGeneratorsTest.php +++ b/tests/Unit/CodeGen/CodeGeneratorsTest.php @@ -45,7 +45,7 @@ function usersModuleFor(string|\Closure $naming): string $files = new TypescriptServerCodeGenerator( CodeGenerators::fromDefaults($naming), - )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration())); + )->generate($server, new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration())); return $files['users.ts']->toString(); } diff --git a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php index 93c3120..3206208 100644 --- a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php +++ b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php @@ -31,7 +31,7 @@ function bindingFiles(): array return $emitter->emitFiles( [], - new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), new AliasRegistry(), ); } diff --git a/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php index 59ecab6..70c4b23 100644 --- a/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php +++ b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php @@ -21,7 +21,7 @@ function spaClientFiles(): array { return new EmitOperationsSpaClient()->emitFiles( [], - new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), new AliasRegistry(), ); } diff --git a/tests/Unit/CodeGen/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php index 3ef9f23..6d49b72 100644 --- a/tests/Unit/CodeGen/EmitQueryKeyTest.php +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -37,7 +37,7 @@ function queryKeyCodeFor(TypedOperation $typedOperation, ?Closure $nameGenerator $file = $emitter->generateOperationCode( $typedOperation, - new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), ); return [$file->code, $file->toString()]; diff --git a/tests/Unit/CodeGen/EmitTanstackQueryTest.php b/tests/Unit/CodeGen/EmitTanstackQueryTest.php index 321346b..9c1960c 100644 --- a/tests/Unit/CodeGen/EmitTanstackQueryTest.php +++ b/tests/Unit/CodeGen/EmitTanstackQueryTest.php @@ -37,7 +37,7 @@ function tanstackCodeFor(TypedOperation $typedOperation, ?Closure $nameGenerator return $emitter->generateOperationCode( $typedOperation, - new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), ); } diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php index 0f3cc87..bfd3db3 100644 --- a/tests/Unit/CodeGen/EmitTypeUtilsTest.php +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -45,7 +45,7 @@ function emitUtilsFor(OperationType $type = OperationType::QUERY, string $namesp $files = $emitter->emitFiles( [new TypedOperation($input, $output, 'never', $operation)], - new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), $registry, ); diff --git a/tests/Unit/CodeGen/EmitTypesTest.php b/tests/Unit/CodeGen/EmitTypesTest.php index 0d5b8e0..e8ce945 100644 --- a/tests/Unit/CodeGen/EmitTypesTest.php +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -44,7 +44,7 @@ function emitTypesFor(string $inputType, string $outputType): string $files = new EmitTypes()->emitFiles( [new TypedOperation($input, $output, 'never', $operation)], - new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), $registry, ); @@ -54,7 +54,7 @@ function emitTypesFor(string $inputType, string $outputType): string test('rejects an alias colliding with a declaration the types file always contains', function (string $alias) { $registry = new AliasRegistry([$alias => '{a:string;}']); - expect(fn () => new EmitTypes()->emitFiles([], new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), $registry)) + expect(fn () => new EmitTypes()->emitFiles([], new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), $registry)) ->toThrow(UnsupportedTypeException::class, 'collides with a declaration'); })->with([ 'the Brand helper generic' => ['Brand'], @@ -80,7 +80,7 @@ function emitTypesFor(string $inputType, string $outputType): string test('every declaration the types file always contains is reserved', function () { $types = new EmitTypes()->emitFiles( [], - new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), new AliasRegistry(), )['types']->toString(); @@ -90,7 +90,7 @@ function emitTypesFor(string $inputType, string $outputType): string foreach ($matches[1] as $name) { expect(fn () => new EmitTypes()->emitFiles( [], - new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration()), + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), new AliasRegistry([$name => '{a:string;}']), ))->toThrow(UnsupportedTypeException::class, 'collides with a declaration'); } diff --git a/tests/Unit/CodeGen/TsOutputFixture.php b/tests/Unit/CodeGen/TsOutputFixture.php index c4aa633..48179ba 100644 --- a/tests/Unit/CodeGen/TsOutputFixture.php +++ b/tests/Unit/CodeGen/TsOutputFixture.php @@ -53,6 +53,6 @@ public static function generate(): array return new TypescriptServerCodeGenerator( CodeGenerators::fromDefaults('name', with: ['type-map', 'tanstack-query', 'query-key']), - )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration())); + )->generate($server, new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration())); } } diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 0b7b2e2..9aec8ab 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -52,7 +52,7 @@ function generateFor(array $classes, ?array $generators = null): array new EmitTypeUtils(), new EmitOperations(), ], - )->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}', new ServerConfiguration())); + )->generate($server, new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration())); } test('attribute brands declare no aliases in lib/types.ts, only the Brand helper', function () { @@ -311,7 +311,7 @@ function generateFor(array $classes, ?array $generators = null): array new EmitOperationClientBindings(), new EmitTypeUtils(), new EmitOperations(), - ])->generate($server, new ServerMetadata('/query/{fqn}', '/command/{fqn}', $server->configuration))) + ])->generate($server, new ServerMetadata('/query/{key}', '/command/{key}', $server->configuration))) ->toThrow(CodeGenException::class, GloballyThrowingMiddleware::class); }); diff --git a/tests/ts-output/generated/lib/DefaultClient.ts b/tests/ts-output/generated/lib/DefaultClient.ts index ae5f610..661d522 100644 --- a/tests/ts-output/generated/lib/DefaultClient.ts +++ b/tests/ts-output/generated/lib/DefaultClient.ts @@ -58,7 +58,7 @@ export class DefaultClient implements OperationClient { async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; - const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{fqn}', key)}`; + const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{key}', key)}`; // Per call wins over the client wide default, and the timeout signal actually fires: a // fresh AbortController is never aborted by anything. diff --git a/tests/ts-output/generated/lib/bindings.ts b/tests/ts-output/generated/lib/bindings.ts index 68ce809..b5637a8 100644 --- a/tests/ts-output/generated/lib/bindings.ts +++ b/tests/ts-output/generated/lib/bindings.ts @@ -11,7 +11,7 @@ export function createDefaultClient( options?: {baseUrl?: string; timeoutMs?: number}, ): DefaultClient { return new DefaultClient(fetcher ?? fetch, { - paths: {query: '/query/{fqn}', command: '/command/{fqn}'}, + paths: {query: '/query/{key}', command: '/command/{key}'}, baseUrl: options?.baseUrl ?? '', timeoutMs: options?.timeoutMs ?? 10000, }); From 3518f193858231843a65871f351c7b4a7c297218 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 10:52:23 +0200 Subject: [PATCH 088/101] Add support for dynamic context in `preloadMany` via closure evaluation --- src/Server/Preloader.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Server/Preloader.php b/src/Server/Preloader.php index 4169b87..cfd24a4 100644 --- a/src/Server/Preloader.php +++ b/src/Server/Preloader.php @@ -4,6 +4,7 @@ namespace Le0daniel\PhpTsBindings\Server; +use Closure; use Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator; use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\NullNode; @@ -71,13 +72,22 @@ private function queryKey(string $namespace, string $name, mixed $input): array } /** + * For perloading many, you can pass a closure for the context. This is useful if the context is mutated + * during execution. + * * @param list $preloads + * @param mixed|(Closure():mixed) $context * @return list}> */ public function preloadMany(array $preloads, mixed $context): array { return array_map( - fn (array $preload) => $this->preload($preload['namespace'], $preload['name'], $preload['input'], $context), + fn (array $preload) => $this->preload( + $preload['namespace'], + $preload['name'], + $preload['input'], + $context instanceof Closure ? $context() : $context + ), $preloads ); } From 1307c6df185e77edeed9148159cb9b8193caf356 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 14:15:21 +0200 Subject: [PATCH 089/101] Add integration test fixtures and casting logic for operations and value objects - Introduced comprehensive test suites (`OrderCastingTest`, `OrderErrorsTest`) to validate end-to-end behavior for commands and queries with nested casting, value objects, and optional handling. - Added new fixtures for `CartCommands`, `CheckoutCommands`, `OrderCommands`, and `OrderQueries` to demonstrate command/query input-output flows. - Implemented `IntegrationHarness` for consistent integration testing of cached and non-cached operation behavior. - Expanded type fixtures (`Address`, `Currency`, `LineItemInput`, `Money`, `OrderNumber`, `CustomerProfile`) to support edge case validations. - Improved error handling coverage, including domain exceptions (`OrderAlreadyShippedException`, `OrderNotFoundException`) and runtime exceptions in value objects. - Finalized support for `Castable` attributes and object hydration strategies. --- .../OrderAlreadyShippedException.php | 15 ++ .../Exceptions/OrderNotFoundException.php | 14 ++ .../Fixtures/Operations/CartCommands.php | 72 +++++++ .../Fixtures/Operations/CheckoutCommands.php | 44 ++++ .../Fixtures/Operations/OrderCommands.php | 125 +++++++++++ .../Fixtures/Operations/OrderQueries.php | 199 ++++++++++++++++++ tests/Integration/Fixtures/Types/Address.php | 27 +++ tests/Integration/Fixtures/Types/Currency.php | 28 +++ .../Fixtures/Types/CustomerProfile.php | 20 ++ .../Fixtures/Types/LineItemInput.php | 25 +++ tests/Integration/Fixtures/Types/Money.php | 22 ++ .../Fixtures/Types/OrderNumber.php | 33 +++ .../Fixtures/Types/OrderStatus.php | 16 ++ .../Fixtures/Types/OrderSummary.php | 20 ++ .../Fixtures/Types/PaymentMethod.php | 16 ++ .../Fixtures/Types/PlaceOrderInput.php | 26 +++ tests/Integration/Fixtures/Types/Quantity.php | 32 +++ tests/Integration/Fixtures/Types/Sku.php | 35 +++ tests/Integration/IntegrationHarness.php | 83 ++++++++ tests/Integration/OrderCastingTest.php | 78 +++++++ tests/Integration/OrderErrorsTest.php | 93 ++++++++ tests/Integration/OrderSerializationTest.php | 76 +++++++ tests/Integration/OrderUnionsTest.php | 69 ++++++ tests/Pest.php | 2 +- 24 files changed, 1169 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Fixtures/Exceptions/OrderAlreadyShippedException.php create mode 100644 tests/Integration/Fixtures/Exceptions/OrderNotFoundException.php create mode 100644 tests/Integration/Fixtures/Operations/CartCommands.php create mode 100644 tests/Integration/Fixtures/Operations/CheckoutCommands.php create mode 100644 tests/Integration/Fixtures/Operations/OrderCommands.php create mode 100644 tests/Integration/Fixtures/Operations/OrderQueries.php create mode 100644 tests/Integration/Fixtures/Types/Address.php create mode 100644 tests/Integration/Fixtures/Types/Currency.php create mode 100644 tests/Integration/Fixtures/Types/CustomerProfile.php create mode 100644 tests/Integration/Fixtures/Types/LineItemInput.php create mode 100644 tests/Integration/Fixtures/Types/Money.php create mode 100644 tests/Integration/Fixtures/Types/OrderNumber.php create mode 100644 tests/Integration/Fixtures/Types/OrderStatus.php create mode 100644 tests/Integration/Fixtures/Types/OrderSummary.php create mode 100644 tests/Integration/Fixtures/Types/PaymentMethod.php create mode 100644 tests/Integration/Fixtures/Types/PlaceOrderInput.php create mode 100644 tests/Integration/Fixtures/Types/Quantity.php create mode 100644 tests/Integration/Fixtures/Types/Sku.php create mode 100644 tests/Integration/IntegrationHarness.php create mode 100644 tests/Integration/OrderCastingTest.php create mode 100644 tests/Integration/OrderErrorsTest.php create mode 100644 tests/Integration/OrderSerializationTest.php create mode 100644 tests/Integration/OrderUnionsTest.php diff --git a/tests/Integration/Fixtures/Exceptions/OrderAlreadyShippedException.php b/tests/Integration/Fixtures/Exceptions/OrderAlreadyShippedException.php new file mode 100644 index 0000000..0974783 --- /dev/null +++ b/tests/Integration/Fixtures/Exceptions/OrderAlreadyShippedException.php @@ -0,0 +1,15 @@ +} + */ + #[Command('cart')] + public function addItem(array $input): array + { + $item = $input['item']; + + return [ + 'count' => 1, + 'items' => [ + [ + 'note' => $item->note, + 'quantity' => $item->quantity->toIntValue(), + 'sku' => $item->sku->toStringValue(), + ], + ], + ]; + } + + /** + * A branded string input and a bounded-int refinement, which is checked on parse only. + * + * @param array{code: BrandedString<'voucherCode'>, percent: int<1, 100>} $input + * @return array{applied: bool, discount: Money} + */ + #[Command('cart')] + public function applyVoucher(array $input): array + { + return [ + 'applied' => true, + 'discount' => new Money($input['percent'] * 10, Currency::CHF), + ]; + } + + /** + * Strict Y-m-d parsing in, a tuple of derived dates out. The window derives from the parsed + * input date, so the output stays a pure function of the input. The window tuple uses the + * integer-keyed spelling: the unkeyed form only detects tuples whose first element is a + * single token, so a generic like DateTimeString cannot lead it. + * + * @param array{date: DateTimeString<'Y-m-d'>} $input + * @return array{confirmed: DateTimeString<'Y-m-d'>, window: array{DateTimeString<'Y-m-d'>, DateTimeString<'Y-m-d'>}} + */ + #[Command('cart')] + public function setDeliveryDate(array $input): array + { + $date = $input['date']; + + return [ + 'confirmed' => $date, + 'window' => [$date, $date->modify('+2 days')], + ]; + } +} diff --git a/tests/Integration/Fixtures/Operations/CheckoutCommands.php b/tests/Integration/Fixtures/Operations/CheckoutCommands.php new file mode 100644 index 0000000..441f723 --- /dev/null +++ b/tests/Integration/Fixtures/Operations/CheckoutCommands.php @@ -0,0 +1,44 @@ + ['method' => PaymentMethod::CARD, 'reference' => 'pay-card-'.substr($input['cardNumber'], -4)], + 'invoice' => ['method' => PaymentMethod::INVOICE, 'reference' => 'pay-invoice-'.substr($input['iban'], -4)], + 'twint' => ['method' => PaymentMethod::TWINT, 'reference' => 'pay-twint-'.substr($input['phone'], -3)], + }; + } + + /** + * Int-literal unions and enum-case literals as input types; literal unions on the output. + * + * @param array{level: 1|2|3, status: OrderStatus::PAID|OrderStatus::PENDING} $input + * @return array{flagged: 'high'|'low', level: 1|2|3} + */ + #[Command('checkout')] + public function flagPriority(array $input): array + { + return [ + 'flagged' => $input['level'] === 1 ? 'high' : 'low', + 'level' => $input['level'], + ]; + } +} diff --git a/tests/Integration/Fixtures/Operations/OrderCommands.php b/tests/Integration/Fixtures/Operations/OrderCommands.php new file mode 100644 index 0000000..0ac4576 --- /dev/null +++ b/tests/Integration/Fixtures/Operations/OrderCommands.php @@ -0,0 +1,125 @@ + $item->quantity->toIntValue(), + $input->items, + )); + + return [ + 'itemCount' => count($input->items), + 'orderNumber' => 'ORD-NEW-1', + 'status' => OrderStatus::PENDING, + 'total' => new Money($units * 250, $input->currency), + ]; + } + + /** + * ASSIGN_PROPERTIES in both directions: the parsed Address is returned as-is, so an omitted + * Optional company comes back as null. + * + * @param array{address: Address, orderNumber: non-empty-string} $input + */ + #[Command('orders')] + public function updateShippingAddress(array $input): Address + { + return $input['address']; + } + + /** + * A declared domain exception surfaces as DOMAIN_ERROR with its registered name. + * + * @param array{orderNumber: non-empty-string} $input + * @return array{cancelled: true, orderNumber: string} + */ + #[Command('orders')] + #[Throws(OrderAlreadyShippedException::class, name: 'order_already_shipped')] + public function cancelOrder(array $input): array + { + if ($input['orderNumber'] === 'ORD-SHIPPED') { + throw new OrderAlreadyShippedException('Order has already been shipped'); + } + + return ['cancelled' => true, 'orderNumber' => $input['orderNumber']]; + } + + /** + * A Throws-typed exception maps onto the finite error catalogue: NOT_FOUND, no details. + * + * @param array{amount: Money, orderNumber: non-empty-string} $input + * @return array{refund: Money, status: 'refunded'} + */ + #[Command('orders')] + #[Throws(OrderNotFoundException::class, type: ErrorType::NOT_FOUND)] + public function requestRefund(array $input): array + { + if ($input['orderNumber'] === 'ORD-MISSING') { + throw new OrderNotFoundException("No such order: {$input['orderNumber']}"); + } + + return ['refund' => $input['amount'], 'status' => 'refunded']; + } + + /** + * Deliberately violates its declared output type: the server must answer INTERNAL_ERROR and + * leak nothing about the payload. + * + * @param array{payload: string} $input + * @return array{processed: array{id: int}} + */ + #[Command('orders')] + public function recordPaymentWebhook(array $input): array + { + /** @phpstan-ignore-next-line */ + return ['processed' => ['id' => 'not-an-int']]; + } + + /** + * The attribute name overrides the method name: reachable as orders.archive only. + * + * @param array{orderNumber: non-empty-string} $input + * @return array{archived: bool, orderNumber: string} + */ + #[Command('orders', name: 'archive')] + public function archiveOrder(array $input): array + { + return ['archived' => true, 'orderNumber' => $input['orderNumber']]; + } + + /** + * A record as the whole input, echoed back: enum cases in, case names out, {} stays {}. + * + * @param array $input + * @return array{updated: array} + */ + #[Command('orders')] + public function bulkUpdateStatus(array $input): array + { + return ['updated' => $input]; + } +} diff --git a/tests/Integration/Fixtures/Operations/OrderQueries.php b/tests/Integration/Fixtures/Operations/OrderQueries.php new file mode 100644 index 0000000..b27f816 --- /dev/null +++ b/tests/Integration/Fixtures/Operations/OrderQueries.php @@ -0,0 +1,199 @@ +, + * shippingAddress: Address, + * status: OrderStatus, + * total: Money, + * } + */ + #[Query('orders')] + public function getOrder(array $input): array + { + $address = new Address(); + $address->city = 'Zurich'; + $address->street = 'Bahnhofstrasse 1'; + $address->zip = '8001'; + + return [ + 'createdAt' => new DateTimeImmutable('2024-05-01T12:00:00+00:00'), + 'currency' => Currency::CHF, + 'items' => [ + ['lineTotal' => new Money(1000, Currency::CHF), 'quantity' => 2, 'sku' => 'ABC-123'], + ['lineTotal' => new Money(1495, Currency::CHF), 'quantity' => 1, 'sku' => 'XYZ-999'], + ], + 'shippingAddress' => $address, + 'status' => OrderStatus::PAID, + 'total' => new Money(2495, Currency::CHF), + ]; + } + + /** + * Optional docblock keys with handler-side defaults; the target for query input coercion. + * + * @param array{page?: positive-int, perPage?: int<1, 100>} $input + * @return array{orders: list, page: int, perPage: int} + */ + #[Query('orders')] + public function listOrders(array $input): array + { + return [ + 'orders' => [ + ['orderNumber' => 'ORD-1001', 'status' => OrderStatus::PAID], + ['orderNumber' => 'ORD-1002', 'status' => OrderStatus::PENDING], + ], + 'page' => $input['page'] ?? 1, + 'perPage' => $input['perPage'] ?? 20, + ]; + } + + /** + * Records on the way out: a closed literal key set, and an empty record that must serialize + * as {} and never degrade to []. + * + * @return array{counts: array<'PAID'|'PENDING'|'SHIPPED', int>, emptyByDay: array} + */ + #[Query('orders')] + public function statusCounts(null $input): array + { + return [ + 'counts' => ['PAID' => 2, 'PENDING' => 1, 'SHIPPED' => 0], + 'emptyByDay' => [], + ]; + } + + /** + * Discriminated union on the OUTPUT side: three inline shapes sharing the literal kind. + * + * @param array{stage: 'created'|'delivered'|'shipped'} $input + * @return array{at: DateTimeString<'Y-m-d'>, kind: 'created'}|array{carrier: string, kind: 'shipped', trackingCode: string}|array{kind: 'delivered', signedBy: string|null} + */ + #[Query('orders')] + public function trackingEvent(array $input): array + { + return match ($input['stage']) { + 'created' => ['at' => new DateTimeImmutable('2024-05-01T12:00:00+00:00'), 'kind' => 'created'], + 'shipped' => ['carrier' => 'DHL', 'kind' => 'shipped', 'trackingCode' => 'JJD-0003-9000-7882'], + 'delivered' => ['kind' => 'delivered', 'signedBy' => null], + }; + } + + /** + * Undiscriminated union input: a free-text branch and a struct branch, resolved by + * first-match probing. + * + * @param array{filter: string|array{status: OrderStatus}} $input + * @return list + */ + #[Query('orders')] + public function searchOrders(array $input): array + { + $filter = $input['filter']; + if (is_string($filter)) { + return ['ORD-1001', 'ORD-1003']; + } + + return ['ORD-BY-STATUS-'.$filter['status']->name]; + } + + /** + * A string value object with a non-ValidationException rejection, and a bare scalar as the + * envelope data. + * + * @param array{orderNumber: OrderNumber} $input + * @return string + */ + #[Query('orders')] + public function invoiceFileName(array $input): string + { + return 'invoice-'.$input['orderNumber']->toStringValue().'.pdf'; + } + + /** + * An output-only class (no Castable attribute) as the declared return type. + * + * @param array{orderNumber: non-empty-string} $input + */ + #[Query('orders')] + public function orderSummary(array $input): OrderSummary + { + return new OrderSummary( + orderNumber: $input['orderNumber'], + itemCount: 3, + total: new Money(2495, Currency::CHF), + status: OrderStatus::SHIPPED, + ); + } + + /** + * Pick and Omit projections over a class while the handler returns full instances. + * + * @return array{card: Pick, publicCard: Omit} + */ + #[Query('orders')] + public function customerSnapshot(null $input): array + { + $profile = new CustomerProfile( + email: 'ada@example.com', + name: 'Ada', + notes: 'internal only', + tier: 'gold', + ); + + return ['card' => $profile, 'publicCard' => $profile]; + } + + /** + * A tuple with exact arity, plus a Sku round-trip from input back into the output. + * + * @param array{sku: Sku} $input + * @return array{dimensionsMm: array{int, int, int}, sku: string} + */ + #[Query('orders')] + public function parcelDimensions(array $input): array + { + return [ + 'dimensionsMm' => [300, 200, 50], + 'sku' => $input['sku']->toStringValue(), + ]; + } + + /** + * Branded virtual types: pure codegen metadata, plain string and int at runtime. + * + * @return array{customerId: BrandedInt<'customerId'>, orderId: BrandedString<'orderId'>} + */ + #[Query('orders')] + public function sessionRefs(null $input): array + { + return ['customerId' => 512, 'orderId' => 'ORD-1001']; + } +} diff --git a/tests/Integration/Fixtures/Types/Address.php b/tests/Integration/Fixtures/Types/Address.php new file mode 100644 index 0000000..f5f1cdd --- /dev/null +++ b/tests/Integration/Fixtures/Types/Address.php @@ -0,0 +1,27 @@ +value; + } +} diff --git a/tests/Integration/Fixtures/Types/CustomerProfile.php b/tests/Integration/Fixtures/Types/CustomerProfile.php new file mode 100644 index 0000000..386ce49 --- /dev/null +++ b/tests/Integration/Fixtures/Types/CustomerProfile.php @@ -0,0 +1,20 @@ +value; + } +} diff --git a/tests/Integration/Fixtures/Types/OrderStatus.php b/tests/Integration/Fixtures/Types/OrderStatus.php new file mode 100644 index 0000000..00b6ee3 --- /dev/null +++ b/tests/Integration/Fixtures/Types/OrderStatus.php @@ -0,0 +1,16 @@ + $items + */ + public function __construct( + public Currency $currency, + public array $items, + public Address $shippingAddress, + ) { + } +} diff --git a/tests/Integration/Fixtures/Types/Quantity.php b/tests/Integration/Fixtures/Types/Quantity.php new file mode 100644 index 0000000..bd9ac18 --- /dev/null +++ b/tests/Integration/Fixtures/Types/Quantity.php @@ -0,0 +1,32 @@ + $value]); + } + + return new self($value); + } + + public function toIntValue(): int + { + return $this->value; + } +} diff --git a/tests/Integration/Fixtures/Types/Sku.php b/tests/Integration/Fixtures/Types/Sku.php new file mode 100644 index 0000000..62014bb --- /dev/null +++ b/tests/Integration/Fixtures/Types/Sku.php @@ -0,0 +1,35 @@ + $value]); + } + + return new self($value); + } + + public function toStringValue(): string + { + return $this->value; + } +} diff --git a/tests/Integration/IntegrationHarness.php b/tests/Integration/IntegrationHarness.php new file mode 100644 index 0000000..e103a40 --- /dev/null +++ b/tests/Integration/IntegrationHarness.php @@ -0,0 +1,83 @@ +query($key, $input, null, new NullClient()) + : $server->command($key, $input, null, new NullClient()); + } + + [$eagerResult, $cachedResult] = $results; + $eagerJson = json_encode($eagerResult, JSON_THROW_ON_ERROR); + $cachedJson = json_encode($cachedResult, JSON_THROW_ON_ERROR); + + expect($cachedResult->statusCode)->toBe($eagerResult->statusCode, "Cached registry status code diverges from the eager registry for {$key}"); + expect($cachedJson)->toBe($eagerJson, "Cached registry envelope diverges from the eager registry for {$key}"); + + return $eagerJson; + } + + private static function eagerRegistry(): EagerlyLoadedOperationRegistry + { + return self::$eagerRegistry ??= EagerlyLoadedOperationRegistry::eagerlyDiscover( + __DIR__.'/Fixtures/Operations', + keyGenerator: new PlainlyExposedKeyGenerator(), + ); + } + + private static function cachedRegistry(): CachedOperationRegistry + { + if (self::$cachedRegistry !== null) { + return self::$cachedRegistry; + } + + $file = sys_get_temp_dir().'/php-ts-bindings-integration-'.getmypid().'.php'; + CachedOperationRegistry::writeToCache(self::eagerRegistry(), $file, idLength: 12); + register_shutdown_function(static function () use ($file): void { + @unlink($file); + }); + + return self::$cachedRegistry = require $file; + } +} diff --git a/tests/Integration/OrderCastingTest.php b/tests/Integration/OrderCastingTest.php new file mode 100644 index 0000000..e05abc4 --- /dev/null +++ b/tests/Integration/OrderCastingTest.php @@ -0,0 +1,78 @@ +toBe(json_encode([ + 'success' => true, + 'data' => [ + 'itemCount' => 2, + 'orderNumber' => 'ORD-NEW-1', + 'status' => 'PENDING', + 'total' => ['amount' => 750, 'currency' => 'chf'], + ], + ], JSON_THROW_ON_ERROR)); +}); + +test('updateShippingAddress defaults the Optional company to null when omitted', function () { + expect(IntegrationHarness::commandJson( + 'orders.updateShippingAddress', + '{"address":{"city":"Bern","street":"Marktgasse 5","zip":"3011"},"orderNumber":"ORD-1001"}', + ))->toBe('{"success":true,"data":{"city":"Bern","company":null,"street":"Marktgasse 5","zip":"3011"}}'); +}); + +test('updateShippingAddress echoes the Optional company when provided', function () { + expect(IntegrationHarness::commandJson( + 'orders.updateShippingAddress', + '{"address":{"city":"Bern","company":"ACME AG","street":"Marktgasse 5","zip":"3011"},"orderNumber":"ORD-1001"}', + ))->toBe('{"success":true,"data":{"city":"Bern","company":"ACME AG","street":"Marktgasse 5","zip":"3011"}}'); +}); + +test('addItem defaults the Optional constructor param to null when omitted', function () { + expect(IntegrationHarness::commandJson('cart.addItem', '{"item":{"sku":"ABC-123","quantity":2}}')) + ->toBe('{"success":true,"data":{"count":1,"items":[{"note":null,"quantity":2,"sku":"ABC-123"}]}}'); +}); + +test('addItem passes the Optional constructor param through when provided', function () { + expect(IntegrationHarness::commandJson('cart.addItem', '{"item":{"sku":"ABC-123","quantity":2,"note":"engrave"}}')) + ->toBe('{"success":true,"data":{"count":1,"items":[{"note":"engrave","quantity":2,"sku":"ABC-123"}]}}'); +}); + +test('listOrders applies handler defaults for omitted optional keys', function () { + expect(IntegrationHarness::queryJson('orders.listOrders', '{}')) + ->toBe('{"success":true,"data":{"orders":[{"orderNumber":"ORD-1001","status":"PAID"},{"orderNumber":"ORD-1002","status":"PENDING"}],"page":1,"perPage":20}}'); +}); + +test('a string query param is coerced to int when coercion is enabled', function () { + expect(IntegrationHarness::queryJson('orders.listOrders', '{"page":"2"}', coerceQueryInput: true)) + ->toBe('{"success":true,"data":{"orders":[{"orderNumber":"ORD-1001","status":"PAID"},{"orderNumber":"ORD-1002","status":"PENDING"}],"page":2,"perPage":20}}'); +}); + +test('the same string query param is rejected when coercion is disabled', function () { + expect(IntegrationHarness::queryJson('orders.listOrders', '{"page":"2"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"page":["validation.invalid_type"]}}}'); +}); + +test('a value object rejecting with ValidationException surfaces its message verbatim', function () { + expect(IntegrationHarness::queryJson('orders.parcelDimensions', '{"sku":"nope"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"sku":["Sku must match ABC-123"]}}}'); +}); + +test('an int value object rejects below its minimum at the nested path', function () { + expect(IntegrationHarness::commandJson('cart.addItem', '{"item":{"sku":"ABC-123","quantity":0}}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"item.quantity":["Quantity must be at least 1"]}}}'); +}); + +test('a plain throwable in a value object collapses to the generic invalid_value key', function () { + expect(IntegrationHarness::queryJson('orders.invoiceFileName', '{"orderNumber":"1001"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"orderNumber":["validation.invalid_value"]}}}'); +}); diff --git a/tests/Integration/OrderErrorsTest.php b/tests/Integration/OrderErrorsTest.php new file mode 100644 index 0000000..4cf58ba --- /dev/null +++ b/tests/Integration/OrderErrorsTest.php @@ -0,0 +1,93 @@ +toBe('{"success":true,"data":{"cancelled":true,"orderNumber":"ORD-1001"}}'); +}); + +test('a declared domain exception maps to DOMAIN_ERROR with its registered name', function () { + expect(IntegrationHarness::commandJson('orders.cancelOrder', '{"orderNumber":"ORD-SHIPPED"}')) + ->toBe('{"success":false,"code":400,"type":"DOMAIN_ERROR","details":{"name":"order_already_shipped"}}'); +}); + +test('requestRefund round-trips Money through input and output', function () { + expect(IntegrationHarness::commandJson('orders.requestRefund', '{"amount":{"amount":500,"currency":"eur"},"orderNumber":"ORD-1001"}')) + ->toBe('{"success":true,"data":{"refund":{"amount":500,"currency":"eur"},"status":"refunded"}}'); +}); + +test('a Throws-typed exception maps to NOT_FOUND without details', function () { + expect(IntegrationHarness::commandJson('orders.requestRefund', '{"amount":{"amount":500,"currency":"eur"},"orderNumber":"ORD-MISSING"}')) + ->toBe('{"success":false,"code":404,"type":"NOT_FOUND"}'); +}); + +test('output violating the declared type is an INTERNAL_ERROR without details', function () { + expect(IntegrationHarness::commandJson('orders.recordPaymentWebhook', '{"payload":"evt_1"}')) + ->toBe('{"success":false,"code":500,"type":"INTERNAL_ERROR"}'); +}); + +test('an unknown operation key is NOT_FOUND', function () { + expect(IntegrationHarness::commandJson('orders.doesNotExist', '{}')) + ->toBe('{"success":false,"code":404,"type":"NOT_FOUND"}'); +}); + +test('a query key is not reachable as a command', function () { + expect(IntegrationHarness::commandJson('orders.getOrder', '{"orderNumber":"ORD-1001"}')) + ->toBe('{"success":false,"code":404,"type":"NOT_FOUND"}'); +}); + +test('a command key is not reachable as a query', function () { + expect(IntegrationHarness::queryJson('orders.archive', '{"orderNumber":"ORD-1001"}')) + ->toBe('{"success":false,"code":404,"type":"NOT_FOUND"}'); +}); + +test('a rejection inside a list of castables reports the dot-joined path', function () { + expect(IntegrationHarness::commandJson( + 'orders.placeOrder', + '{"currency":"chf","items":[{"sku":"bad","quantity":2}],"shippingAddress":{"city":"a","street":"x","zip":"1"}}', + ))->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"items.0.sku":["Sku must match ABC-123"]}}}'); +}); + +test('a missing nested property is reported at the enclosing struct path', function () { + // The parser fails fast and attributes a missing key to the struct that lacks it, not to the + // missing key's own path - the parse-side counterpart pinned at the top level by + // LaravelHttpControllerTest's __root assertion. + expect(IntegrationHarness::commandJson( + 'orders.placeOrder', + '{"currency":"chf","items":[{"sku":"ABC-123","quantity":2}],"shippingAddress":{"street":"x","zip":"1"}}', + ))->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"shippingAddress":["validation.missing_property"]}}}'); +}); + +test('an empty non-empty-list is rejected with invalid_min', function () { + expect(IntegrationHarness::commandJson( + 'orders.placeOrder', + '{"currency":"chf","items":[],"shippingAddress":{"city":"a","street":"x","zip":"1"}}', + ))->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"items":["validation.invalid_min"]}}}'); +}); + +test('null input for a required struct is reported at the root path', function () { + expect(IntegrationHarness::commandJson('orders.placeOrder')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"__root":["validation.invalid_type"]}}}'); +}); + +test('a bounded int refinement rejects below the minimum on parse', function () { + expect(IntegrationHarness::commandJson('cart.applyVoucher', '{"code":"SUMMER","percent":0}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"percent":["validation.invalid_min"]}}}'); +}); + +test('the same refinement passes within bounds', function () { + expect(IntegrationHarness::commandJson('cart.applyVoucher', '{"code":"SUMMER","percent":15}')) + ->toBe('{"success":true,"data":{"applied":true,"discount":{"amount":150,"currency":"chf"}}}'); +}); + +test('a malformed date string is rejected by the strict DateTimeString format', function () { + expect(IntegrationHarness::commandJson('cart.setDeliveryDate', '{"date":"01.06.2024"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"date":["validation.invalid_type"]}}}'); +}); diff --git a/tests/Integration/OrderSerializationTest.php b/tests/Integration/OrderSerializationTest.php new file mode 100644 index 0000000..d7d7be9 --- /dev/null +++ b/tests/Integration/OrderSerializationTest.php @@ -0,0 +1,76 @@ +toBe(json_encode([ + 'success' => true, + 'data' => [ + 'createdAt' => '2024-05-01T12:00:00+00:00', + 'currency' => 'chf', + 'items' => [ + ['lineTotal' => ['amount' => 1000, 'currency' => 'chf'], 'quantity' => 2, 'sku' => 'ABC-123'], + ['lineTotal' => ['amount' => 1495, 'currency' => 'chf'], 'quantity' => 1, 'sku' => 'XYZ-999'], + ], + 'shippingAddress' => ['city' => 'Zurich', 'company' => null, 'street' => 'Bahnhofstrasse 1', 'zip' => '8001'], + 'status' => 'PAID', + 'total' => ['amount' => 2495, 'currency' => 'chf'], + ], + ], JSON_THROW_ON_ERROR)); +}); + +test('statusCounts emits a closed record and an empty record as an object', function () { + expect(IntegrationHarness::queryJson('orders.statusCounts')) + ->toBe('{"success":true,"data":{"counts":{"PAID":2,"PENDING":1,"SHIPPED":0},"emptyByDay":{}}}'); +}); + +test('orderSummary serializes an output-only class', function () { + expect(IntegrationHarness::queryJson('orders.orderSummary', '{"orderNumber":"ORD-1001"}')) + ->toBe('{"success":true,"data":{"itemCount":3,"orderNumber":"ORD-1001","status":"SHIPPED","total":{"amount":2495,"currency":"chf"}}}'); +}); + +test('parcelDimensions returns an exact-arity tuple and round-trips the sku', function () { + expect(IntegrationHarness::queryJson('orders.parcelDimensions', '{"sku":"ABC-123"}')) + ->toBe('{"success":true,"data":{"dimensionsMm":[300,200,50],"sku":"ABC-123"}}'); +}); + +test('sessionRefs returns branded types as plain string and int', function () { + expect(IntegrationHarness::queryJson('orders.sessionRefs')) + ->toBe('{"success":true,"data":{"customerId":512,"orderId":"ORD-1001"}}'); +}); + +test('invoiceFileName returns a bare scalar as the envelope data', function () { + expect(IntegrationHarness::queryJson('orders.invoiceFileName', '{"orderNumber":"ORD-1001"}')) + ->toBe('{"success":true,"data":"invoice-ORD-1001.pdf"}'); +}); + +test('customerSnapshot serializes Pick and Omit projections of a full instance', function () { + expect(IntegrationHarness::queryJson('orders.customerSnapshot')) + ->toBe('{"success":true,"data":{"card":{"email":"ada@example.com","name":"Ada"},"publicCard":{"email":"ada@example.com","name":"Ada","tier":"gold"}}}'); +}); + +test('setDeliveryDate round-trips a Y-m-d date and derives a tuple window', function () { + expect(IntegrationHarness::commandJson('cart.setDeliveryDate', '{"date":"2024-06-01"}')) + ->toBe('{"success":true,"data":{"confirmed":"2024-06-01","window":["2024-06-01","2024-06-03"]}}'); +}); + +test('the archive command is reachable under its overridden name only', function () { + expect(IntegrationHarness::commandJson('orders.archive', '{"orderNumber":"ORD-1001"}')) + ->toBe('{"success":true,"data":{"archived":true,"orderNumber":"ORD-1001"}}'); +}); + +test('bulkUpdateStatus echoes a record of enums by case name', function () { + expect(IntegrationHarness::commandJson('orders.bulkUpdateStatus', '{"ORD-1001":"SHIPPED","ORD-1002":"CANCELLED"}')) + ->toBe('{"success":true,"data":{"updated":{"ORD-1001":"SHIPPED","ORD-1002":"CANCELLED"}}}'); +}); + +test('an empty record input stays an empty object and never degrades to an array', function () { + expect(IntegrationHarness::commandJson('orders.bulkUpdateStatus', '{}')) + ->toBe('{"success":true,"data":{"updated":{}}}'); +}); diff --git a/tests/Integration/OrderUnionsTest.php b/tests/Integration/OrderUnionsTest.php new file mode 100644 index 0000000..3cc3ab7 --- /dev/null +++ b/tests/Integration/OrderUnionsTest.php @@ -0,0 +1,69 @@ +toBe('{"success":true,"data":{"at":"2024-05-01","kind":"created"}}'); +}); + +test('trackingEvent serializes the shipped branch of a discriminated union', function () { + expect(IntegrationHarness::queryJson('orders.trackingEvent', '{"stage":"shipped"}')) + ->toBe('{"success":true,"data":{"carrier":"DHL","kind":"shipped","trackingCode":"JJD-0003-9000-7882"}}'); +}); + +test('trackingEvent serializes the delivered branch including an explicit null', function () { + expect(IntegrationHarness::queryJson('orders.trackingEvent', '{"stage":"delivered"}')) + ->toBe('{"success":true,"data":{"kind":"delivered","signedBy":null}}'); +}); + +test('payOrder parses the card branch of a discriminated input union', function () { + expect(IntegrationHarness::commandJson('checkout.payOrder', '{"kind":"card","cardNumber":"4242424242424242"}')) + ->toBe('{"success":true,"data":{"method":"CARD","reference":"pay-card-4242"}}'); +}); + +test('payOrder parses the invoice branch of a discriminated input union', function () { + expect(IntegrationHarness::commandJson('checkout.payOrder', '{"kind":"invoice","iban":"CH9300762011623852957"}')) + ->toBe('{"success":true,"data":{"method":"INVOICE","reference":"pay-invoice-2957"}}'); +}); + +test('payOrder parses the twint branch and serializes the backed enum by case name', function () { + expect(IntegrationHarness::commandJson('checkout.payOrder', '{"kind":"twint","phone":"+41791234567"}')) + ->toBe('{"success":true,"data":{"method":"TWINT","reference":"pay-twint-567"}}'); +}); + +test('payOrder rejects an unknown discriminator value with a root-level 422', function () { + expect(IntegrationHarness::commandJson('checkout.payOrder', '{"kind":"cash"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"__root":["validation.invalid_type"]}}}'); +}); + +test('searchOrders accepts the string branch of an undiscriminated union', function () { + expect(IntegrationHarness::queryJson('orders.searchOrders', '{"filter":"ada"}')) + ->toBe('{"success":true,"data":["ORD-1001","ORD-1003"]}'); +}); + +test('searchOrders accepts the struct branch of an undiscriminated union', function () { + expect(IntegrationHarness::queryJson('orders.searchOrders', '{"filter":{"status":"PENDING"}}')) + ->toBe('{"success":true,"data":["ORD-BY-STATUS-PENDING"]}'); +}); + +test('flagPriority accepts int literals and enum-case literals', function () { + expect(IntegrationHarness::commandJson('checkout.flagPriority', '{"level":1,"status":"PAID"}')) + ->toBe('{"success":true,"data":{"flagged":"high","level":1}}'); +}); + +test('flagPriority rejects an int outside the literal union, one issue per failed arm plus the union itself', function () { + expect(IntegrationHarness::commandJson('checkout.flagPriority', '{"level":4,"status":"PAID"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"level":["validation.invalid_type","validation.invalid_type","validation.invalid_type","validation.invalid_type"]}}}'); +}); + +test('flagPriority rejects an enum case outside the declared case-literal union', function () { + expect(IntegrationHarness::commandJson('checkout.flagPriority', '{"level":1,"status":"SHIPPED"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"status":["validation.invalid_type","validation.invalid_type","validation.invalid_type"]}}}'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 26a5048..081fc14 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -28,7 +28,7 @@ use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; use Tests\TestCase; -pest()->extend(TestCase::class)->in('Feature'); +pest()->extend(TestCase::class)->in('Feature', 'Integration'); /* |-------------------------------------------------------------------------- From 88a18fcb592b2c26de7866cc07af3fa322b5d72c Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 14:29:44 +0200 Subject: [PATCH 090/101] Improve tuple parsing logic, error messaging, and test coverage in `ArrayConsumer` and `TypeParser`. --- .../Helpers/Consumers/ArrayConsumer.php | 34 ++++++++++++------ .../Fixtures/Operations/CartCommands.php | 6 ++-- tests/Unit/Parser/TypeParserTest.php | 36 +++++++++++++++++++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/Parser/Helpers/Consumers/ArrayConsumer.php b/src/Parser/Helpers/Consumers/ArrayConsumer.php index 4cc2a12..58ba499 100644 --- a/src/Parser/Helpers/Consumers/ArrayConsumer.php +++ b/src/Parser/Helpers/Consumers/ArrayConsumer.php @@ -76,16 +76,15 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface // Handle array structures. if ($state->current()->value === 'array' && $state->nextTokenIs(TokenType::LBRACE)) { // Handles: array{0: string, 1: int} => tuple - if ($state->peek(2)?->type === TokenType::INT && $state->peek(3)?->isAnyTypeOf(TokenType::COLON, TokenType::RBRACE)) { + if ($state->peek(2)?->type === TokenType::INT && $state->peek(3)?->is(TokenType::COLON) === true) { return $this->consumeIntegerDeterminedTuple($state, $parser); } - // Handles: array{string,int} => tuple - if ($state->peek(3)?->isAnyTypeOf(TokenType::COMMA, TokenType::RBRACE)) { - return $this->consumeTuple($state, $parser); - } - - $state->produceSyntaxError('Expected array{key: type, ...} or array{key: type, ...} syntax'); + // Everything else is the unkeyed spelling: array{string, int}. Keyed shapes never + // get here because StructConsumer claims them first, and each element is consumed + // by the full parser, so an element may span any number of tokens: + // array{DateTimeString<'Y-m-d'>, int|null, array{int, int}}. + return $this->consumeTuple($state, $parser); } $maxGenerics = $type === 'list' ? 1 : 2; @@ -184,6 +183,11 @@ private function consumeIntegerDeterminedTuple(ParserState $state, TypeParser $p $types[] = $parser->consume($state, TokenType::COMMA, TokenType::RBRACE); } + // Input truncated after a comma leaves the cursor on EOF, which advance() refuses. + if (! $state->currentTokenIs(TokenType::RBRACE)) { + $state->produceSyntaxError('Expected }'); + } + $state->advance(); if ($types === []) { $state->produceSyntaxError('A tuple must declare at least one type.'); @@ -207,8 +211,14 @@ private function consumeTuple(ParserState $state, TypeParser $parser): TupleNode } $state->advance(); + // array{} and a truncated `array{` both land here with no element to consume. + if ($state->currentTokenIs(TokenType::RBRACE) || $state->currentTokenIs(TokenType::EOF)) { + $state->produceSyntaxError('A tuple must declare at least one type.'); + } + $types = []; - while ($state->canAdvance()) { + // The guard above proves the cursor is on a real token, so the body runs at least once. + do { $types[] = $parser->consume($state, TokenType::COMMA, TokenType::RBRACE); if ($state->currentTokenIs(TokenType::RBRACE)) { @@ -224,12 +234,14 @@ private function consumeTuple(ParserState $state, TypeParser $parser): TupleNode $state->produceSyntaxError('Expected comma for union: array{string, int}'); } $state->advance(); + } while ($state->canAdvance()); + + // Input truncated after a comma leaves the cursor on EOF, which advance() refuses. + if (! $state->currentTokenIs(TokenType::RBRACE)) { + $state->produceSyntaxError('Expected }'); } $state->advance(); - if ($types === []) { - $state->produceSyntaxError('A tuple must declare at least one type.'); - } return new TupleNode($types); } diff --git a/tests/Integration/Fixtures/Operations/CartCommands.php b/tests/Integration/Fixtures/Operations/CartCommands.php index 4d3f152..10e1809 100644 --- a/tests/Integration/Fixtures/Operations/CartCommands.php +++ b/tests/Integration/Fixtures/Operations/CartCommands.php @@ -52,9 +52,9 @@ public function applyVoucher(array $input): array /** * Strict Y-m-d parsing in, a tuple of derived dates out. The window derives from the parsed - * input date, so the output stays a pure function of the input. The window tuple uses the - * integer-keyed spelling: the unkeyed form only detects tuples whose first element is a - * single token, so a generic like DateTimeString cannot lead it. + * input date, so the output stays a pure function of the input. The window is an unkeyed + * tuple (array{A, B}) whose elements are generics — exercising tuple elements that span + * more than one token. * * @param array{date: DateTimeString<'Y-m-d'>} $input * @return array{confirmed: DateTimeString<'Y-m-d'>, window: array{DateTimeString<'Y-m-d'>, DateTimeString<'Y-m-d'>}} diff --git a/tests/Unit/Parser/TypeParserTest.php b/tests/Unit/Parser/TypeParserTest.php index 730f6de..3a580d3 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -447,6 +447,37 @@ compareToOptimizedAst($node); }); +test('unkeyed tuple elements may span multiple tokens', function (string $type, string $firstElementNode, int $count) { + $parser = new TypeParser(); + /** @var TupleNode $node */ + $node = $parser->parse($type); + expect($node)->toBeInstanceOf(TupleNode::class) + ->and($node->nodes)->toHaveCount($count) + ->and($node->nodes[0])->toBeInstanceOf($firstElementNode); + + compareToOptimizedAst($node); +})->with([ + 'generic first element' => ["array{DateTimeString<'Y-m-d'>, DateTimeString<'Y-m-d'>}", DateTimeNode::class, 2], + 'single generic element' => ["array{DateTimeString<'Y-m-d'>}", DateTimeNode::class, 1], + 'list first element' => ['array{list, int}', ListNode::class, 2], + 'union first element' => ['array{int|null, int}', UnionNode::class, 2], + 'union first element with tailing comma' => ['array{int|null, int,}', UnionNode::class, 2], + 'literal union first element' => ["array{'a'|'b', int}", UnionNode::class, 2], + 'nullable first element' => ['array{?string, int}', UnionNode::class, 2], + 'bracket list first element' => ['array{string[], int}', ListNode::class, 2], + 'nested tuple first element' => ['array{array{int, int}, int}', TupleNode::class, 2], +]); + +test('an unkeyed tuple can be a struct property', function () { + /** @var StructNode $node */ + $node = new TypeParser()->parse("array{window: array{DateTimeString<'Y-m-d'>, DateTimeString<'Y-m-d'>}}"); + + expect($node)->toBeInstanceOf(StructNode::class) + ->and($node->getProperty('window')?->node)->toBeInstanceOf(TupleNode::class); + + compareToOptimizedAst($node); +}); + test('a single generic array is a record, not a list', function () { $parser = new TypeParser(); // PHPStan reads array as array, which permits string keys. Only `list` and @@ -983,6 +1014,7 @@ 'nullable' => ["DateTimeString<'Y-m-d'>|null", '(string|null)'], 'questionmark nullable' => ["?DateTimeString<'Y-m-d'>", '(null|string)'], 'in a struct' => ["array{createdAt: DateTimeString<'Y-m-d'>}", '{createdAt:string;}'], + 'in a tuple' => ["array{DateTimeString<'Y-m-d'>, DateTimeString<'Y-m-d'>}", '[string,string]'], 'in a list' => ['list', 'Array'], 'bracket list' => ["DateTimeString<'Y-m-d'>[]", 'Array'], ]); @@ -1104,6 +1136,10 @@ expect(fn () => new TypeParser()->parse('array{')) ->toThrow(InvalidSyntaxException::class) ->and(fn () => new TypeParser()->parse('array{a')) + ->toThrow(InvalidSyntaxException::class) + ->and(fn () => new TypeParser()->parse('array{a,')) + ->toThrow(InvalidSyntaxException::class) + ->and(fn () => new TypeParser()->parse('array{0: int,')) ->toThrow(InvalidSyntaxException::class); }); From 9adb55314be4c4895edf1341d0da43f4069d300e Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 16:17:36 +0200 Subject: [PATCH 091/101] Update `Sku` to support dynamic Brand attribute and fix return type in `fromStringValue` --- tests/Integration/Fixtures/Types/Sku.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Fixtures/Types/Sku.php b/tests/Integration/Fixtures/Types/Sku.php index 62014bb..3633e41 100644 --- a/tests/Integration/Fixtures/Types/Sku.php +++ b/tests/Integration/Fixtures/Types/Sku.php @@ -12,14 +12,16 @@ * Rejects with ValidationException so the exact message reaches the 422 fields verbatim. The * Brand attribute is codegen-only metadata and must have zero effect on the runtime envelope. */ -#[Brand] +#[Brand(static function ($className) { + return "sku"; +})] final readonly class Sku implements StringValueObject { private function __construct(public string $value) { } - public static function fromStringValue(string $value): static + public static function fromStringValue(string $value): self { if (preg_match('/^[A-Z]{3}-\d{3}$/', $value) !== 1) { throw new ValidationException('Sku must match ABC-123', ['value' => $value]); From 405affd25988da36c61d716a6c33069895d1fbcd Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 18:01:50 +0200 Subject: [PATCH 092/101] Refactor `CartCommands` and `CheckoutCommands` to improve type annotations, input validation, and return type consistency. --- .../Fixtures/Operations/CartCommands.php | 5 ++++- .../Fixtures/Operations/CheckoutCommands.php | 21 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/Integration/Fixtures/Operations/CartCommands.php b/tests/Integration/Fixtures/Operations/CartCommands.php index 10e1809..9d76286 100644 --- a/tests/Integration/Fixtures/Operations/CartCommands.php +++ b/tests/Integration/Fixtures/Operations/CartCommands.php @@ -16,7 +16,10 @@ final class CartCommands * and both value objects unwrap back to plain scalars on the way out. * * @param array{item: LineItemInput} $input - * @return array{count: int, items: list} + * @return array{ + * count: int, + * items: list + * } */ #[Command('cart')] public function addItem(array $input): array diff --git a/tests/Integration/Fixtures/Operations/CheckoutCommands.php b/tests/Integration/Fixtures/Operations/CheckoutCommands.php index 441f723..bba2b26 100644 --- a/tests/Integration/Fixtures/Operations/CheckoutCommands.php +++ b/tests/Integration/Fixtures/Operations/CheckoutCommands.php @@ -4,17 +4,24 @@ namespace Tests\Integration\Fixtures\Operations; +use InvalidArgumentException; use Le0daniel\PhpTsBindings\Contracts\Attributes\Command; +use stdClass; use Tests\Integration\Fixtures\Types\OrderStatus; use Tests\Integration\Fixtures\Types\PaymentMethod; +/** + * @phpstan-type CardType array{cardNumber: string, kind: 'card'} + * @phpstan-type IBanType array{iban: string, kind: 'invoice'} + * @phpstan-type TwintType array{kind: 'twint', phone: string} + */ final class CheckoutCommands { /** * Discriminated union on the INPUT side: three inline shapes sharing the literal kind. The * output proves a plain backed enum serializes by case name, not backing value. * - * @param array{cardNumber: string, kind: 'card'}|array{iban: string, kind: 'invoice'}|array{kind: 'twint', phone: string} $input + * @param CardType|TwintType|IBanType $input * @return array{method: PaymentMethod, reference: string} */ #[Command('checkout')] @@ -30,15 +37,19 @@ public function payOrder(array $input): array /** * Int-literal unions and enum-case literals as input types; literal unions on the output. * - * @param array{level: 1|2|3, status: OrderStatus::PAID|OrderStatus::PENDING} $input + * @param object{level: 1|2|3, status: OrderStatus::PAID|OrderStatus::PENDING} $input * @return array{flagged: 'high'|'low', level: 1|2|3} */ #[Command('checkout')] - public function flagPriority(array $input): array + public function flagPriority(object $input): array { + if (!$input instanceof stdClass) { + throw new InvalidArgumentException('Expected object'); + } + return [ - 'flagged' => $input['level'] === 1 ? 'high' : 'low', - 'level' => $input['level'], + 'flagged' => $input->level === 1 ? 'high' : 'low', + 'level' => $input->level, ]; } } From a5361a4002acbd28f446f67d9bfc8051d973cf3b Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 18:04:08 +0200 Subject: [PATCH 093/101] Add input validation to `parcelDimensions` query in `OrderQueries` to ensure `sku` is an instance of `Sku`. --- tests/Integration/Fixtures/Operations/OrderQueries.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Integration/Fixtures/Operations/OrderQueries.php b/tests/Integration/Fixtures/Operations/OrderQueries.php index b27f816..2c63ce6 100644 --- a/tests/Integration/Fixtures/Operations/OrderQueries.php +++ b/tests/Integration/Fixtures/Operations/OrderQueries.php @@ -5,6 +5,7 @@ namespace Tests\Integration\Fixtures\Operations; use DateTimeImmutable; +use InvalidArgumentException; use Le0daniel\PhpTsBindings\Contracts\Attributes\Query; use Tests\Integration\Fixtures\Types\Address; use Tests\Integration\Fixtures\Types\Currency; @@ -180,6 +181,10 @@ public function customerSnapshot(null $input): array #[Query('orders')] public function parcelDimensions(array $input): array { + if (!$input['sku'] instanceof Sku) { + throw new InvalidArgumentException('Expected Sku'); + } + return [ 'dimensionsMm' => [300, 200, 50], 'sku' => $input['sku']->toStringValue(), From 3430ed67492cb1471ad960a486ca4a7be7762288 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Thu, 13 Aug 2026 18:08:08 +0200 Subject: [PATCH 094/101] Update `OrderQueries` type annotation to --- tests/Integration/Fixtures/Operations/OrderQueries.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Fixtures/Operations/OrderQueries.php b/tests/Integration/Fixtures/Operations/OrderQueries.php index 2c63ce6..e9da3a9 100644 --- a/tests/Integration/Fixtures/Operations/OrderQueries.php +++ b/tests/Integration/Fixtures/Operations/OrderQueries.php @@ -30,7 +30,11 @@ final class OrderQueries * @return array{ * createdAt: DateTimeImmutable, * currency: Currency, - * items: non-empty-list, + * items: non-empty-list, * shippingAddress: Address, * status: OrderStatus, * total: Money, From af8b9c163acad07facba90e2d36788a88379be8d Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 14 Aug 2026 08:57:57 +0200 Subject: [PATCH 095/101] Add new integration fixtures and test suites for casting, alias resolution, generics, and structural shapes - Introduced new test cases to validate edge conditions for nested casting, union resolution, nullable containers, and virtual getters/setters using classes like `Batch`, `HandlingInstructions`, and `HomeDelivery`. - Added comprehensive test coverage for coercion, input validation, and alias handling in queries (`CatalogQueries`, `InventoryQueries`) and types (`CatalogShared`, `Sku`). - Implemented `CastingGenericsAndAliasesTest`, `CollectionsAndStructuresTest`, and `CoercionAndEdgeModesTest` to confirm behavior consistency across various scenarios. - Extended fixtures with new value objects (`CatalogShared`, `Money`) and container classes (`Batch`, `Paginated`, `HomeDelivery`) to support generics and intersection types. - Updated integration harness to support advanced input scenarios like discriminated unions, indexed paths, and coercion flags during testing. --- .../CastingGenericsAndAliasesTest.php | 101 +++++++++ .../Integration/CoercionAndEdgeModesTest.php | 97 +++++++++ .../CollectionsAndStructuresTest.php | 127 +++++++++++ .../Fixtures/DataShapes/Paginated.php | 30 +++ .../Fixtures/Operations/CatalogQueries.php | 204 ++++++++++++++++++ .../Fixtures/Operations/InventoryQueries.php | 132 ++++++++++++ .../Operations/InventoryRefinements.php | 38 ++++ .../Fixtures/Operations/OrderQueries.php | 4 +- .../Fixtures/Operations/ShippingCommands.php | 190 ++++++++++++++++ tests/Integration/Fixtures/Types/Batch.php | 28 +++ .../Fixtures/Types/CatalogShared.php | 15 ++ .../Fixtures/Types/HandlingInstructions.php | 31 +++ .../Fixtures/Types/HomeDelivery.php | 21 ++ .../Integration/Fixtures/Types/PalletSize.php | 29 +++ .../Fixtures/Types/PickupPoint.php | 21 ++ .../Fixtures/Types/PublicWarehouse.php | 25 +++ .../Fixtures/Types/ShippingClass.php | 17 ++ .../Integration/Fixtures/Types/StockLevel.php | 16 ++ .../Fixtures/Types/WarehouseId.php | 35 +++ tests/Integration/IntegrationHarness.php | 10 + tests/Integration/RefinementsTest.php | 141 ++++++++++++ tests/Integration/ScalarsAndLiteralsTest.php | 115 ++++++++++ 22 files changed, 1425 insertions(+), 2 deletions(-) create mode 100644 tests/Integration/CastingGenericsAndAliasesTest.php create mode 100644 tests/Integration/CoercionAndEdgeModesTest.php create mode 100644 tests/Integration/CollectionsAndStructuresTest.php create mode 100644 tests/Integration/Fixtures/DataShapes/Paginated.php create mode 100644 tests/Integration/Fixtures/Operations/CatalogQueries.php create mode 100644 tests/Integration/Fixtures/Operations/InventoryQueries.php create mode 100644 tests/Integration/Fixtures/Operations/InventoryRefinements.php create mode 100644 tests/Integration/Fixtures/Operations/ShippingCommands.php create mode 100644 tests/Integration/Fixtures/Types/Batch.php create mode 100644 tests/Integration/Fixtures/Types/CatalogShared.php create mode 100644 tests/Integration/Fixtures/Types/HandlingInstructions.php create mode 100644 tests/Integration/Fixtures/Types/HomeDelivery.php create mode 100644 tests/Integration/Fixtures/Types/PalletSize.php create mode 100644 tests/Integration/Fixtures/Types/PickupPoint.php create mode 100644 tests/Integration/Fixtures/Types/PublicWarehouse.php create mode 100644 tests/Integration/Fixtures/Types/ShippingClass.php create mode 100644 tests/Integration/Fixtures/Types/StockLevel.php create mode 100644 tests/Integration/Fixtures/Types/WarehouseId.php create mode 100644 tests/Integration/RefinementsTest.php create mode 100644 tests/Integration/ScalarsAndLiteralsTest.php diff --git a/tests/Integration/CastingGenericsAndAliasesTest.php b/tests/Integration/CastingGenericsAndAliasesTest.php new file mode 100644 index 0000000..00d4b35 --- /dev/null +++ b/tests/Integration/CastingGenericsAndAliasesTest.php @@ -0,0 +1,101 @@ +toBe('{"success":true,"data":{"checksum":"looc-peek","code":"frg","summary":"FRG"}}'); +}); + +test('a virtual set-only property is required on the input side', function () { + expect(IntegrationHarness::commandJson('shipping.registerHandling', '{"instructions":{"code":"frg"}}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"instructions":["validation.missing_property"]}}}'); +}); + +test('a union of castables resolves the first arm by its properties', function () { + expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"locationCode":"ZH-01"},"window":"01.07.2024 08:00"}')) + ->toBe('{"success":true,"data":{"destination":{"locationCode":"ZH-01"},"eta":"2024-07-01T08:00:00+00:00","window":"01.07.2024 08:00"}}'); +}); + +test('a union of castables resolves the second arm by its properties', function () { + expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"street":"Seeweg 2","zip":"8001"},"window":"01.07.2024 08:00"}')) + ->toBe('{"success":true,"data":{"destination":{"street":"Seeweg 2","zip":"8001"},"eta":"2024-07-01T08:00:00+00:00","window":"01.07.2024 08:00"}}'); +}); + +test('first-match probing wins on a shape satisfying both arms and drops unknown keys', function () { + expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"locationCode":"x","street":"y","zip":"z"},"window":"01.07.2024 08:00"}')) + ->toBe('{"success":true,"data":{"destination":{"locationCode":"x"},"eta":"2024-07-01T08:00:00+00:00","window":"01.07.2024 08:00"}}'); +}); + +test('a shape matching neither castable arm reports every arm plus the union', function () { + expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"iban":"x"},"window":"01.07.2024 08:00"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"destination":["validation.missing_property","validation.missing_property","validation.invalid_type"]}}}'); +}); + +test('a DateTimeString with a custom format rejects the default format strictly', function () { + expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"locationCode":"ZH-01"},"window":"2024-07-01"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"window":["validation.invalid_type"]}}}'); +}); + +test('a generic castable binds a different type argument per direction', function () { + expect(IntegrationHarness::commandJson('shipping.dispatchBatch', '{"count":2,"items":["ABC-123","XYZ-999"]}')) + ->toBe('{"success":true,"data":{"count":2,"items":[{"amount":500,"currency":"chf"},{"amount":500,"currency":"chf"}]}}'); +}); + +test('a generic type argument validates its elements at the indexed path', function () { + expect(IntegrationHarness::commandJson('shipping.dispatchBatch', '{"count":1,"items":["bad"]}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"items.0":["Sku must match ABC-123"]}}}'); +}); + +test('an output-only paginated shape computes its virtual getters from the plain properties', function () { + expect(IntegrationHarness::queryJson('catalog.pagedSkus', '{"page":1}')) + ->toBe('{"success":true,"data":{"currentPage":1,"hasNextPage":true,"hasPreviousPage":false,"items":["ABC-123","XYZ-999"],"perPage":2,"total":5}}'); + expect(IntegrationHarness::queryJson('catalog.pagedSkus', '{"page":3}')) + ->toBe('{"success":true,"data":{"currentPage":3,"hasNextPage":false,"hasPreviousPage":true,"items":["ABC-123","XYZ-999"],"perPage":2,"total":5}}'); +}); + +test('the Named attribute has zero runtime effect on either registry', function () { + expect(IntegrationHarness::commandJson('shipping.renameWarehouse', '{"warehouse":{"code":"ZH","region":"east"}}')) + ->toBe('{"success":true,"data":{"code":"ZH","region":"east"}}'); +}); + +test('Pick and Omit on the input side hydrate plain objects with only the projected keys', function () { + expect(IntegrationHarness::commandJson( + 'shipping.updateManifest', + '{"header":{"currency":"eur"},"partial":{"city":"Bern","street":"Marktgasse 4","zip":"3011"}}', + ))->toBe('{"success":true,"data":{"city":"Bern","currency":"eur"}}'); +}); + +test('a class-local alias and a renamed cross-file import resolve in one signature', function () { + expect(IntegrationHarness::queryJson('catalog.aliasedRange', '{"range":{"max":9,"min":1},"skus":["ABC-123"]}')) + ->toBe('{"success":true,"data":{"count":1,"range":{"max":9,"min":1}}}'); +}); + +test('an aliased non-empty list still enforces its constraint', function () { + expect(IntegrationHarness::queryJson('catalog.aliasedRange', '{"range":{"max":9,"min":1},"skus":[]}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"skus":["validation.invalid_min"]}}}'); +}); + +test('a global alias registered on the parser resolves like any other type', function () { + expect(IntegrationHarness::queryJson('catalog.globalTokenEcho', '{"token":"tok-1"}')) + ->toBe('{"success":true,"data":{"token":"tok-1"}}'); +}); + +test('a global alias keeps the refinement it resolves to', function () { + expect(IntegrationHarness::queryJson('catalog.globalTokenEcho', '{"token":""}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"token":["validation.not_empty_string"]}}}'); +}); + +test('a branded string inside a non-empty list stays a plain string with the list constraint', function () { + expect(IntegrationHarness::commandJson('shipping.tagShipment', '{"tags":["fragile"]}')) + ->toBe('{"success":true,"data":{"tags":["fragile"]}}'); + expect(IntegrationHarness::commandJson('shipping.tagShipment', '{"tags":[]}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"tags":["validation.invalid_min"]}}}'); +}); diff --git a/tests/Integration/CoercionAndEdgeModesTest.php b/tests/Integration/CoercionAndEdgeModesTest.php new file mode 100644 index 0000000..7b10482 --- /dev/null +++ b/tests/Integration/CoercionAndEdgeModesTest.php @@ -0,0 +1,97 @@ +toBe('{"success":true,"data":{"inStock":true}}'); + expect(IntegrationHarness::queryJson('inventory.stockFlag', '{"inStock":"0"}', coerceQueryInput: true)) + ->toBe('{"success":true,"data":{"inStock":false}}'); +}); + +test('the same bool string is rejected when coercion is disabled', function () { + expect(IntegrationHarness::queryJson('inventory.stockFlag', '{"inStock":"true"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"inStock":["validation.invalid_type"]}}}'); +}); + +test('a float query param is coerced from its string form when coercion is enabled', function () { + expect(IntegrationHarness::queryJson('inventory.convertWeight', '"2.5"', coerceQueryInput: true)) + ->toBe('{"success":true,"data":2.5}'); +}); + +test('coercion applies per leaf inside nested structs: int, float and bool at once', function () { + expect(IntegrationHarness::queryJson( + 'inventory.warehouseCapacity', + '{"filters":{"includeEmpty":"1","limit":"5","ratio":"1.5"}}', + coerceQueryInput: true, + ))->toBe('{"success":true,"data":{"includeEmpty":true,"limit":5,"ratio":1.5}}'); +}); + +test('without coercion the struct fails fast on its first invalid property in canonical order', function () { + expect(IntegrationHarness::queryJson('inventory.warehouseCapacity', '{"filters":{"includeEmpty":"1","limit":"5","ratio":"1.5"}}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"filters.includeEmpty":["validation.invalid_type"]}}}'); +}); + +test('commands never coerce, even for input a query would accept', function () { + expect(IntegrationHarness::commandJson('cart.applyVoucher', '{"code":"SUMMER","percent":"15"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"percent":["validation.invalid_type"]}}}'); +}); + +test('a nullable DateTimeString output serializes null and a real date', function () { + expect(IntegrationHarness::commandJson('shipping.holdShipment', '{"orderNumber":"ORD-1"}')) + ->toBe('{"success":true,"data":{"until":null}}'); + expect(IntegrationHarness::commandJson('shipping.holdShipment', '{"orderNumber":"ORD-HOLD"}')) + ->toBe('{"success":true,"data":{"until":"2024-09-15"}}'); +}); + +test('an output violating a nullable union answers 500 and never degrades to null', function () { + expect(IntegrationHarness::commandJson('shipping.holdShipment', '{"orderNumber":"ORD-BAD"}')) + ->toBe('{"success":false,"code":500,"type":"INTERNAL_ERROR"}'); +}); + +test('a failing leaf nested in list and castable reports its full dotted path', function () { + expect(IntegrationHarness::commandJson( + 'shipping.estimateCost', + '{"shipments":[{"address":{"city":"Bern","street":"Marktgasse 4","zip":123},"ref":"R-1"}]}', + ))->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"shipments.0.address.zip":["validation.invalid_type"]}}}'); +}); + +test('an optional nullable key distinguishes absent, null and present', function () { + expect(IntegrationHarness::commandJson('shipping.applyCredit', '{"reference":null}')) + ->toBe('{"success":true,"data":{"hadNote":false,"note":null,"reference":null}}'); + expect(IntegrationHarness::commandJson('shipping.applyCredit', '{"note":null,"reference":"ORD-77"}')) + ->toBe('{"success":true,"data":{"hadNote":true,"note":null,"reference":"ORD-77"}}'); +}); + +test('a value-object-or-null union reports the verbatim rejection next to the arm issues', function () { + expect(IntegrationHarness::commandJson('shipping.applyCredit', '{"reference":"1001"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"reference":["validation.invalid_value","validation.invalid_type","validation.invalid_type"]}}}'); +}); + +test('the ?T prefix sugar behaves exactly like the spelled-out null union', function () { + expect(IntegrationHarness::commandJson('shipping.annotateShipment', '{"legacyNote":null,"modernNote":"kept"}')) + ->toBe('{"success":true,"data":{"legacyNote":null,"modernNote":"kept"}}'); + expect(IntegrationHarness::commandJson('shipping.annotateShipment', '{"legacyNote":5,"modernNote":"kept"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"legacyNote":["validation.invalid_type","validation.invalid_type","validation.invalid_type"]}}}'); +}); + +test('optional keys with complex values fall back to handler defaults when absent', function () { + expect(IntegrationHarness::commandJson('shipping.optionalExtras', '{}')) + ->toBe('{"success":true,"data":{"hasFallback":false,"priority":2}}'); + expect(IntegrationHarness::commandJson( + 'shipping.optionalExtras', + '{"fallbackAddress":{"city":"Bern","street":"Marktgasse 4","zip":"3011"},"priority":1}', + ))->toBe('{"success":true,"data":{"hasFallback":true,"priority":1}}'); +}); + +test('a provided optional literal union still validates its arms', function () { + expect(IntegrationHarness::commandJson('shipping.optionalExtras', '{"priority":4}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"priority":["validation.invalid_type","validation.invalid_type","validation.invalid_type","validation.invalid_type"]}}}'); +}); diff --git a/tests/Integration/CollectionsAndStructuresTest.php b/tests/Integration/CollectionsAndStructuresTest.php new file mode 100644 index 0000000..3ce7794 --- /dev/null +++ b/tests/Integration/CollectionsAndStructuresTest.php @@ -0,0 +1,127 @@ +toBe('{"success":true,"data":{"codes":["ABC-123"],"grid":[[1,2],[3]]}}'); +}); + +test('an inner list rejects an assoc array at its indexed path', function () { + expect(IntegrationHarness::queryJson('catalog.relatedSkus', '{"grid":[[1],{"a":2}],"sku":"ABC-123"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"grid.1":["validation.invalid_type"]}}}'); +}); + +test('a list rejects an assoc array at the top level', function () { + expect(IntegrationHarness::queryJson('catalog.relatedSkus', '{"grid":{"a":[1]},"sku":"ABC-123"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"grid":["validation.invalid_type"]}}}'); +}); + +test('a non-empty record round-trips as a JSON object', function () { + expect(IntegrationHarness::queryJson('catalog.priceBuckets', '{"thresholds":{"low":10,"high":90}}')) + ->toBe('{"success":true,"data":{"thresholds":{"low":10,"high":90}}}'); +}); + +test('a non-empty record rejects the empty object on parse', function () { + expect(IntegrationHarness::queryJson('catalog.priceBuckets', '{"thresholds":{}}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"thresholds":["validation.invalid_min"]}}}'); +}); + +test('an int-keyed record accepts numeric JSON keys through PHP key folding', function () { + expect(IntegrationHarness::queryJson('catalog.ratingByStars', '{"votes":{"1":10,"2":5}}')) + ->toBe('{"success":true,"data":{"votes":{"1":10,"2":5}}}'); +}); + +test('an int-keyed record rejects a non-numeric key at the key path', function () { + expect(IntegrationHarness::queryJson('catalog.ratingByStars', '{"votes":{"abc":1}}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"votes.abc":["validation.invalid_key_type"]}}}'); +}); + +test('an index-keyed tuple round-trips as a JSON array', function () { + expect(IntegrationHarness::queryJson('catalog.dimensionsTuple', '{"box":[10,"cm"]}')) + ->toBe('{"success":true,"data":{"box":[10,"cm"]}}'); +}); + +test('a tuple rejects too few elements', function () { + expect(IntegrationHarness::queryJson('catalog.dimensionsTuple', '{"box":[10]}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"box":["validation.invalid_type"]}}}'); +}); + +test('a tuple rejects too many elements', function () { + expect(IntegrationHarness::queryJson('catalog.dimensionsTuple', '{"box":[10,"cm",3]}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"box":["validation.invalid_type"]}}}'); +}); + +test('a tuple of castable, enum and DateTimeString round-trips element by element', function () { + expect(IntegrationHarness::queryJson('catalog.mixedTuple', '{"entry":[{"amount":100,"currency":"chf"},"PAID","2024-06-01"]}')) + ->toBe('{"success":true,"data":{"entry":[{"amount":100,"currency":"chf"},"PAID","2024-06-01"]}}'); +}); + +test('a failing tuple element reports at its index', function () { + expect(IntegrationHarness::queryJson('catalog.mixedTuple', '{"entry":[{"amount":100,"currency":"chf"},"UNKNOWN","2024-06-01"]}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"entry.1":["validation.invalid_type"]}}}'); +}); + +test('a nullable list accepts null and the list alike', function () { + expect(IntegrationHarness::queryJson('catalog.maybeInventory', '{"tags":null}')) + ->toBe('{"success":true,"data":{"tags":null}}'); + expect(IntegrationHarness::queryJson('catalog.maybeInventory', '{"tags":["a","b"]}')) + ->toBe('{"success":true,"data":{"tags":["a","b"]}}'); +}); + +test('a failing element inside a nullable list keeps its deep path next to the union issues', function () { + expect(IntegrationHarness::queryJson('catalog.maybeInventory', '{"tags":["","b"]}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"tags.0":["validation.not_empty_string"],"tags":["validation.invalid_type","validation.invalid_type"]}}}'); +}); + +test('object{} syntax with a quoted key round-trips through stdClass', function () { + expect(IntegrationHarness::queryJson('catalog.describeLabels', '{"content-type":"application/json","count":2}')) + ->toBe('{"success":true,"data":{"content-type":"application\/json","count":2}}'); +}); + +test('a root intersection merges both shapes in and out', function () { + expect(IntegrationHarness::queryJson('catalog.searchFilters', '{"a":1,"b":"x"}')) + ->toBe('{"success":true,"data":{"a":1,"b":"x"}}'); +}); + +test('an intersection missing a property from one arm blames the enclosing root', function () { + expect(IntegrationHarness::queryJson('catalog.searchFilters', '{"a":1}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"__root":["validation.missing_property"]}}}'); +}); + +test('a union inside a list accepts both arms per element', function () { + expect(IntegrationHarness::queryJson('catalog.listOfUnions', '{"values":[1,"two",3]}')) + ->toBe('{"success":true,"data":{"values":[1,"two",3]}}'); +}); + +test('a failing union element reports one issue per arm plus the union at its index', function () { + expect(IntegrationHarness::queryJson('catalog.listOfUnions', '{"values":[true]}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"values.0":["validation.invalid_type","validation.invalid_type","validation.invalid_type"]}}}'); +}); + +test('a record of tuples round-trips object values as fixed-arity arrays', function () { + expect(IntegrationHarness::queryJson('catalog.tupleGrid', '{"points":{"origin":[0,0],"corner":[4,2]}}')) + ->toBe('{"success":true,"data":{"points":{"origin":[0,0],"corner":[4,2]}}}'); +}); + +test('a failing tuple inside a record reports at its record key', function () { + expect(IntegrationHarness::queryJson('catalog.tupleGrid', '{"points":{"corner":[1]}}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"points.corner":["validation.invalid_type"]}}}'); +}); + +test('a discriminated union inside a list resolves per element', function () { + expect(IntegrationHarness::queryJson('catalog.feedEvents', '{"events":[{"kind":"restock","qty":5},{"kind":"sale","ref":"S-1"}]}')) + ->toBe('{"success":true,"data":{"kinds":["restock","sale"],"total":2}}'); +}); + +test('an unknown discriminator inside a list reports at the element index', function () { + expect(IntegrationHarness::queryJson('catalog.feedEvents', '{"events":[{"kind":"restock","qty":5},{"kind":"noop"}]}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"events.1":["validation.invalid_type"]}}}'); +}); diff --git a/tests/Integration/Fixtures/DataShapes/Paginated.php b/tests/Integration/Fixtures/DataShapes/Paginated.php new file mode 100644 index 0000000..f6e091a --- /dev/null +++ b/tests/Integration/Fixtures/DataShapes/Paginated.php @@ -0,0 +1,30 @@ + $this->currentPage * $this->perPage < $this->total; + } + + public bool $hasPreviousPage { + get => $this->currentPage > 1; + } + + /** + * @param list $items + */ + public function __construct( + public readonly array $items, + public readonly int $total, + public readonly int $currentPage, + public readonly int $perPage, + ) { + } +} diff --git a/tests/Integration/Fixtures/Operations/CatalogQueries.php b/tests/Integration/Fixtures/Operations/CatalogQueries.php new file mode 100644 index 0000000..772b426 --- /dev/null +++ b/tests/Integration/Fixtures/Operations/CatalogQueries.php @@ -0,0 +1,204 @@ + + * @phpstan-import-type PriceRange from CatalogShared as Range + */ +final class CatalogQueries +{ + /** + * Postfix array syntax in both directions, including a doubly nested int[][]. + * + * @param array{grid: int[][], sku: Sku} $input + * @return array{codes: Sku[], grid: int[][]} + */ + #[Query('catalog')] + public function relatedSkus(array $input): array + { + return ['codes' => [$input['sku']], 'grid' => $input['grid']]; + } + + /** + * A non-empty record: stays a JSON object and rejects {} on parse. + * + * @param array{thresholds: non-empty-array} $input + * @return array{thresholds: non-empty-array} + */ + #[Query('catalog')] + public function priceBuckets(array $input): array + { + return $input; + } + + /** + * An int-keyed record: JSON object keys arrive as strings, PHP folds numeric ones to ints + * before the handler sees them, and a non-numeric key is an invalid key. + * + * @param array{votes: array} $input + * @return array{votes: array} + */ + #[Query('catalog')] + public function ratingByStars(array $input): array + { + return $input; + } + + /** + * An index-keyed tuple (array{0: ..., 1: ...}) with exact arity in both directions. + * + * @param array{box: array{0: int, 1: string}} $input + * @return array{box: array{0: int, 1: string}} + */ + #[Query('catalog')] + public function dimensionsTuple(array $input): array + { + return $input; + } + + /** + * A tuple whose elements are a castable class, an enum and a generic type: hydrated objects + * on the way in, plain JSON back out. + * + * @param array{entry: array{Money, OrderStatus, DateTimeString<'Y-m-d'>}} $input + * @return array{entry: array{Money, OrderStatus, DateTimeString<'Y-m-d'>}} + */ + #[Query('catalog')] + public function mixedTuple(array $input): array + { + return $input; + } + + /** + * A nullable container: the union arm is the whole list, not its elements. + * + * @param array{tags: list|null} $input + * @return array{tags: list|null} + */ + #[Query('catalog')] + public function maybeInventory(array $input): array + { + return $input; + } + + /** + * object{...} syntax with a quoted key: the handler receives and returns stdClass, and the + * dashed key survives both directions. + * + * @param object{"content-type": string, count: int} $input + * @return object{"content-type": string, count: int} + */ + #[Query('catalog')] + public function describeLabels(object $input): object + { + return $input; + } + + /** + * A root-level intersection of two shapes: parse merges the arms into one array, serialize + * merges them into one object. + * + * @param array{a: int}&array{b: string} $input + * @return array{a: int}&array{b: string} + */ + #[Query('catalog')] + public function searchFilters(array $input): array + { + return $input; + } + + /** + * A union nested inside a list: every element probes int first, then string. + * + * @param array{values: list} $input + * @return array{values: list} + */ + #[Query('catalog')] + public function listOfUnions(array $input): array + { + return $input; + } + + /** + * A record of tuples: object values that are fixed-arity JSON arrays. + * + * @param array{points: array} $input + * @return array{points: array} + */ + #[Query('catalog')] + public function tupleGrid(array $input): array + { + return $input; + } + + /** + * A discriminated union nested inside a list, so a bad element reports at events.N. + * + * @param array{events: list} $input + * @return array{kinds: list, total: int} + */ + #[Query('catalog')] + public function feedEvents(array $input): array + { + return [ + 'kinds' => array_column($input['events'], 'kind'), + 'total' => count($input['events']), + ]; + } + + /** + * A class-local alias (SkuList) next to a cross-file import renamed on arrival (Range). + * + * @param array{range: Range, skus: SkuList} $input + * @return array{count: int, range: Range} + */ + #[Query('catalog')] + public function aliasedRange(array $input): array + { + return ['count' => count($input['skus']), 'range' => $input['range']]; + } + + /** + * ApiToken exists nowhere in this codebase: it is a global alias registered on the custom + * TypeParser the integration harness builds, resolving to non-empty-string. + * + * @param array{token: ApiToken} $input + * @return array{token: ApiToken} + */ + #[Query('catalog')] + public function globalTokenEcho(array $input): array + { + return $input; + } + + /** + * An output-only generic container: no Castable attribute, so Paginated can never be an + * input, and its two virtual getters are computed from the plain properties on the way out. + * + * @param array{page: positive-int} $input + * @return Paginated + */ + #[Query('catalog')] + public function pagedSkus(array $input): Paginated + { + return new Paginated( + items: [Sku::fromStringValue('ABC-123'), Sku::fromStringValue('XYZ-999')], + total: 5, + currentPage: $input['page'], + perPage: 2, + ); + } +} diff --git a/tests/Integration/Fixtures/Operations/InventoryQueries.php b/tests/Integration/Fixtures/Operations/InventoryQueries.php new file mode 100644 index 0000000..b462106 --- /dev/null +++ b/tests/Integration/Fixtures/Operations/InventoryQueries.php @@ -0,0 +1,132 @@ + $input['a'] + $input['b']]; + } + + /** + * One struct covering the literal kinds the other fixtures never touch: float literals, the + * false literal, a bare null member, and class-constant literals resolved at parse time. + * + * @param array{factor: 0.5|1.5, flag: false, legacy: null, mode: ShippingClass::EXPRESS|ShippingClass::STANDARD} $input + * @return array{factor: 0.5|1.5, flag: false, legacy: null, mode: ShippingClass::EXPRESS|ShippingClass::STANDARD} + */ + #[Query('inventory')] + public function literalSampler(array $input): array + { + return $input; + } + + /** + * int, float and bool side by side inside a nested struct: the target for proving query + * coercion applies per-leaf at any depth, not only at the top level. + * + * @param array{filters: array{includeEmpty: bool, limit: int, ratio: float}} $input + * @return array{includeEmpty: bool, limit: int, ratio: float} + */ + #[Query('inventory')] + public function warehouseCapacity(array $input): array + { + return $input['filters']; + } + + /** + * A branded IntValueObject: plain number on the wire both ways, ValidationException message + * verbatim on rejection. + * + * @param array{id: WarehouseId} $input + * @return array{id: WarehouseId, name: string} + */ + #[Query('inventory')] + public function lookupWarehouse(array $input): array + { + return ['id' => $input['id'], 'name' => 'Zurich Hub']; + } + + /** + * The int contrast pair: StockLevel is backed but NOT a value object (case names on the + * wire), PalletSize opts into IntValueObject (backing ints on the wire). + * + * @param array{level: StockLevel, size: PalletSize} $input + * @return array{level: StockLevel, size: PalletSize} + */ + #[Query('inventory')] + public function palletReport(array $input): array + { + return $input; + } +} diff --git a/tests/Integration/Fixtures/Operations/InventoryRefinements.php b/tests/Integration/Fixtures/Operations/InventoryRefinements.php new file mode 100644 index 0000000..13cde84 --- /dev/null +++ b/tests/Integration/Fixtures/Operations/InventoryRefinements.php @@ -0,0 +1,38 @@ + true]; + } + + /** + * The four named int refinements plus both half-open range forms. + * + * @param array{debt: non-positive-int, delta: int, drop: negative-int, floor: int<0, max>, growth: positive-int, level: non-negative-int} $input + * @return array{ok: true} + */ + #[Query('inventory')] + public function boundsCheck(array $input): array + { + return ['ok' => true]; + } +} diff --git a/tests/Integration/Fixtures/Operations/OrderQueries.php b/tests/Integration/Fixtures/Operations/OrderQueries.php index e9da3a9..3dcfee3 100644 --- a/tests/Integration/Fixtures/Operations/OrderQueries.php +++ b/tests/Integration/Fixtures/Operations/OrderQueries.php @@ -52,8 +52,8 @@ public function getOrder(array $input): array 'createdAt' => new DateTimeImmutable('2024-05-01T12:00:00+00:00'), 'currency' => Currency::CHF, 'items' => [ - ['lineTotal' => new Money(1000, Currency::CHF), 'quantity' => 2, 'sku' => 'ABC-123'], - ['lineTotal' => new Money(1495, Currency::CHF), 'quantity' => 1, 'sku' => 'XYZ-999'], + ['lineTotal' => new Money(1000, Currency::CHF), 'quantity' => 2, 'sku' => Sku::fromStringValue('ABC-123')], + ['lineTotal' => new Money(1495, Currency::CHF), 'quantity' => 1, 'sku' => Sku::fromStringValue('XYZ-999')], ], 'shippingAddress' => $address, 'status' => OrderStatus::PAID, diff --git a/tests/Integration/Fixtures/Operations/ShippingCommands.php b/tests/Integration/Fixtures/Operations/ShippingCommands.php new file mode 100644 index 0000000..938f9be --- /dev/null +++ b/tests/Integration/Fixtures/Operations/ShippingCommands.php @@ -0,0 +1,190 @@ +} $input + * @return array{destination: PickupPoint|HomeDelivery, eta: DateTime, window: DateTimeString<'d.m.Y H:i'>} + */ + #[Command('shipping')] + public function scheduleDelivery(array $input): array + { + return [ + 'destination' => $input['destination'], + 'eta' => new DateTime('2024-07-01T08:00:00+00:00'), + 'window' => $input['window'], + ]; + } + + /** + * A generic castable bound to a different type argument per direction: value objects in, + * castables out. + * + * @param Batch $input + * @return Batch + */ + #[Command('shipping')] + public function dispatchBatch(Batch $input): Batch + { + return new Batch( + count: $input->count, + items: array_map(static fn (Sku $sku): Money => new Money(500, Currency::CHF), $input->items), + ); + } + + /** + * The Named attribute on PublicWarehouse is codegen-only: this round-trip pins that it has + * zero runtime effect on either the eager or the cached registry. + * + * @param array{warehouse: PublicWarehouse} $input + */ + #[Command('shipping')] + public function renameWarehouse(array $input): PublicWarehouse + { + return $input['warehouse']; + } + + /** + * Pick and Omit on the INPUT side: the projections rebuild the classes as plain object + * shapes, so the handler receives stdClass instances, never the original classes. + * + * @param array{header: Pick, partial: Omit} $input + * @return array{city: string, currency: Currency} + */ + #[Command('shipping')] + public function updateManifest(array $input): array + { + return [ + 'city' => $input['partial']->city, + 'currency' => $input['header']->currency, + ]; + } + + /** + * Castables nested inside a listed struct so a bad zip reports at shipments.0.address.zip. + * + * @param array{shipments: non-empty-list} $input + * @return array{count: int} + */ + #[Command('shipping')] + public function estimateCost(array $input): array + { + return ['count' => count($input['shipments'])]; + } + + /** + * An optional-and-nullable key next to a required value-object-or-null union: absent, + * null and present are three distinguishable states. + * + * @param array{note?: string|null, reference: OrderNumber|null} $input + * @return array{hadNote: bool, note: string|null, reference: string|null} + */ + #[Command('shipping')] + public function applyCredit(array $input): array + { + return [ + 'hadNote' => array_key_exists('note', $input), + 'note' => $input['note'] ?? null, + 'reference' => $input['reference']?->toStringValue(), + ]; + } + + /** + * A nullable DateTimeString output. The ORD-BAD branch deliberately returns a plain string + * that violates both union arms: the server must answer 500 and never degrade to null, + * because partial failure serialization is off for envelopes. + * + * @param array{orderNumber: non-empty-string} $input + * @return array{until: DateTimeString<'Y-m-d'>|null} + */ + #[Command('shipping')] + public function holdShipment(array $input): array + { + return [ + 'until' => match ($input['orderNumber']) { + 'ORD-BAD' => 'not-a-date', + 'ORD-HOLD' => new DateTimeImmutable('2024-09-15T00:00:00+00:00'), + default => null, + }, + ]; + } + + /** + * A branded string inside a non-empty list: brands are codegen metadata, the runtime sees + * plain strings and the list-length constraint. + * + * @param array{tags: non-empty-list>} $input + * @return array{tags: non-empty-list>} + */ + #[Command('shipping')] + public function tagShipment(array $input): array + { + return $input; + } + + /** + * The ?T prefix sugar next to the spelled-out T|null union: identical runtime behavior. + * + * @param array{legacyNote: ?string, modernNote: string|null} $input + * @return array{legacyNote: ?string, modernNote: string|null} + */ + #[Command('shipping')] + public function annotateShipment(array $input): array + { + return $input; + } + + /** + * Optional keys with complex values: a castable class and a literal union, both absent or + * both present. + * + * @param array{fallbackAddress?: Address, priority?: 1|2|3} $input + * @return array{hasFallback: bool, priority: int} + */ + #[Command('shipping')] + public function optionalExtras(array $input): array + { + return [ + 'hasFallback' => array_key_exists('fallbackAddress', $input), + 'priority' => $input['priority'] ?? 2, + ]; + } +} diff --git a/tests/Integration/Fixtures/Types/Batch.php b/tests/Integration/Fixtures/Types/Batch.php new file mode 100644 index 0000000..e40d9c6 --- /dev/null +++ b/tests/Integration/Fixtures/Types/Batch.php @@ -0,0 +1,28 @@ + in an operation signature binds T for this class's + * own constructor docblock. Generic arguments do not propagate into nested classes, so T is + * used directly on the constructor parameter and nowhere deeper. + * + * @template T + */ +#[Castable(ObjectCastStrategy::CONSTRUCTOR)] +final readonly class Batch +{ + /** + * @param list $items + */ + public function __construct( + public int $count, + public array $items, + ) { + } +} diff --git a/tests/Integration/Fixtures/Types/CatalogShared.php b/tests/Integration/Fixtures/Types/CatalogShared.php new file mode 100644 index 0000000..0ddd630 --- /dev/null +++ b/tests/Integration/Fixtures/Types/CatalogShared.php @@ -0,0 +1,15 @@ +checksum = strrev($value); + } + } + + public string $summary { + get => strtoupper($this->code); + } +} diff --git a/tests/Integration/Fixtures/Types/HomeDelivery.php b/tests/Integration/Fixtures/Types/HomeDelivery.php new file mode 100644 index 0000000..08c082a --- /dev/null +++ b/tests/Integration/Fixtures/Types/HomeDelivery.php @@ -0,0 +1,21 @@ +value; + } +} diff --git a/tests/Integration/Fixtures/Types/PickupPoint.php b/tests/Integration/Fixtures/Types/PickupPoint.php new file mode 100644 index 0000000..db1aad6 --- /dev/null +++ b/tests/Integration/Fixtures/Types/PickupPoint.php @@ -0,0 +1,21 @@ + $value]); + } + + return new self($value); + } + + public function toIntValue(): int + { + return $this->value; + } +} diff --git a/tests/Integration/IntegrationHarness.php b/tests/Integration/IntegrationHarness.php index e103a40..62cb6ba 100644 --- a/tests/Integration/IntegrationHarness.php +++ b/tests/Integration/IntegrationHarness.php @@ -4,6 +4,11 @@ namespace Tests\Integration; +use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; +use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\NonEmptyString; +use Le0daniel\PhpTsBindings\Parser\Nodes\ConstraintNode; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\StringNode; +use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; @@ -60,8 +65,13 @@ private static function execute(OperationType $type, string $key, ?string $json, private static function eagerRegistry(): EagerlyLoadedOperationRegistry { + // One global alias so the fixtures can exercise user-registered aliases end-to-end. It + // only fires on the identifier ApiToken and is inert for every other fixture. return self::$eagerRegistry ??= EagerlyLoadedOperationRegistry::eagerlyDiscover( __DIR__.'/Fixtures/Operations', + parser: new TypeParser(TypeParser::defaultConsumers(new GlobalTypeAliases([ + 'ApiToken' => static fn (): ConstraintNode => new ConstraintNode(new StringNode(), [new NonEmptyString()]), + ]))), keyGenerator: new PlainlyExposedKeyGenerator(), ); } diff --git a/tests/Integration/RefinementsTest.php b/tests/Integration/RefinementsTest.php new file mode 100644 index 0000000..27b1559 --- /dev/null +++ b/tests/Integration/RefinementsTest.php @@ -0,0 +1,141 @@ + '12.5', + 'code' => 'x', + 'comment' => 'ok', + 'label' => 'UP', + 'memo' => 'y', + 'slug' => 'low', + 'tag' => 'tag', + 'ticker' => 'TCK', +]; + +const BOUNDS_CHECK_VALID = [ + 'debt' => 0, + 'delta' => -3, + 'drop' => -1, + 'floor' => 0, + 'growth' => 1, + 'level' => 0, +]; + +function qualityGateWith(string $key, string $value): string +{ + return json_encode([...QUALITY_GATE_VALID, $key => $value], JSON_THROW_ON_ERROR); +} + +function boundsCheckWith(string $key, int $value): string +{ + return json_encode([...BOUNDS_CHECK_VALID, $key => $value], JSON_THROW_ON_ERROR); +} + +function refinementFailure(string $field, string $issueKey): string +{ + return json_encode([ + 'success' => false, + 'code' => 422, + 'type' => 'INVALID_INPUT', + 'details' => ['fields' => [$field => [$issueKey]]], + ], JSON_THROW_ON_ERROR); +} + +test('all eight string refinements pass together', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', json_encode(QUALITY_GATE_VALID, JSON_THROW_ON_ERROR))) + ->toBe('{"success":true,"data":{"ok":true}}'); +}); + +test('all six int refinement forms pass together', function () { + expect(IntegrationHarness::queryJson('inventory.boundsCheck', json_encode(BOUNDS_CHECK_VALID, JSON_THROW_ON_ERROR))) + ->toBe('{"success":true,"data":{"ok":true}}'); +}); + +test('the half-open int ranges accept extreme values on their open side', function () { + expect(IntegrationHarness::queryJson( + 'inventory.boundsCheck', + '{"debt":-5,"delta":-999999,"drop":-2,"floor":999999,"growth":3,"level":7}', + ))->toBe('{"success":true,"data":{"ok":true}}'); +}); + +test('numeric-string rejects a non-numeric value', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', qualityGateWith('amount', '12x'))) + ->toBe(refinementFailure('amount', 'validation.not_numeric_string')); +}); + +test('non-empty-string rejects the empty string', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', qualityGateWith('code', ''))) + ->toBe(refinementFailure('code', 'validation.not_empty_string')); +}); + +test('non-falsy-string rejects the string zero', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', qualityGateWith('comment', '0'))) + ->toBe(refinementFailure('comment', 'validation.falsy_string')); +}); + +test('truthy-string rejects the empty string with the falsy key', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', qualityGateWith('memo', ''))) + ->toBe(refinementFailure('memo', 'validation.falsy_string')); +}); + +test('lowercase-string rejects mixed case', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', qualityGateWith('slug', 'Mixed'))) + ->toBe(refinementFailure('slug', 'validation.not_lowercase_string')); +}); + +test('uppercase-string rejects lowercase', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', qualityGateWith('ticker', 'abc'))) + ->toBe(refinementFailure('ticker', 'validation.not_uppercase_string')); +}); + +test('non-empty-uppercase-string rejects lowercase content', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', qualityGateWith('label', 'abc'))) + ->toBe(refinementFailure('label', 'validation.not_uppercase_string')); +}); + +test('non-empty-lowercase-string reports only the emptiness when the value is empty', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', qualityGateWith('tag', ''))) + ->toBe(refinementFailure('tag', 'validation.not_empty_string')); +}); + +test('non-empty-lowercase-string rejects uppercase content', function () { + expect(IntegrationHarness::queryJson('inventory.qualityGate', qualityGateWith('tag', 'ABC'))) + ->toBe(refinementFailure('tag', 'validation.not_lowercase_string')); +}); + +test('positive-int rejects zero', function () { + expect(IntegrationHarness::queryJson('inventory.boundsCheck', boundsCheckWith('growth', 0))) + ->toBe(refinementFailure('growth', 'validation.invalid_min')); +}); + +test('negative-int rejects zero', function () { + expect(IntegrationHarness::queryJson('inventory.boundsCheck', boundsCheckWith('drop', 0))) + ->toBe(refinementFailure('drop', 'validation.invalid_max')); +}); + +test('non-negative-int rejects minus one', function () { + expect(IntegrationHarness::queryJson('inventory.boundsCheck', boundsCheckWith('level', -1))) + ->toBe(refinementFailure('level', 'validation.invalid_min')); +}); + +test('non-positive-int rejects one', function () { + expect(IntegrationHarness::queryJson('inventory.boundsCheck', boundsCheckWith('debt', 1))) + ->toBe(refinementFailure('debt', 'validation.invalid_max')); +}); + +test('int with an open upper bound rejects below its minimum', function () { + expect(IntegrationHarness::queryJson('inventory.boundsCheck', boundsCheckWith('floor', -1))) + ->toBe(refinementFailure('floor', 'validation.invalid_min')); +}); + +test('int with an open lower bound rejects above its maximum', function () { + expect(IntegrationHarness::queryJson('inventory.boundsCheck', boundsCheckWith('delta', 1))) + ->toBe(refinementFailure('delta', 'validation.invalid_max')); +}); diff --git a/tests/Integration/ScalarsAndLiteralsTest.php b/tests/Integration/ScalarsAndLiteralsTest.php new file mode 100644 index 0000000..a7ddc6c --- /dev/null +++ b/tests/Integration/ScalarsAndLiteralsTest.php @@ -0,0 +1,115 @@ +toBe('{"success":true,"data":2.5}'); +}); + +test('an int passes a float schema and stays a plain number on the wire', function () { + expect(IntegrationHarness::queryJson('inventory.convertWeight', '3')) + ->toBe('{"success":true,"data":3}'); +}); + +test('a float rejects a numeric string without coercion at the root path', function () { + expect(IntegrationHarness::queryJson('inventory.convertWeight', '"2.5"')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"__root":["validation.invalid_type"]}}}'); +}); + +test('a bool input echoes both cases', function () { + expect(IntegrationHarness::queryJson('inventory.stockFlag', '{"inStock":true}')) + ->toBe('{"success":true,"data":{"inStock":true}}'); + expect(IntegrationHarness::queryJson('inventory.stockFlag', '{"inStock":false}')) + ->toBe('{"success":true,"data":{"inStock":false}}'); +}); + +test('a bool rejects a non-boolean string', function () { + expect(IntegrationHarness::queryJson('inventory.stockFlag', '{"inStock":"yes"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"inStock":["validation.invalid_type"]}}}'); +}); + +test('mixed passes arbitrary nested JSON through untouched in both directions', function () { + expect(IntegrationHarness::queryJson('inventory.echoMetadata', '{"meta":{"nested":[1,"a",null]}}')) + ->toBe('{"success":true,"data":{"meta":{"nested":[1,"a",null]}}}'); +}); + +test('the scalar shorthand accepts every scalar arm', function () { + expect(IntegrationHarness::queryJson('inventory.normalizeCode', '{"value":"txt"}')) + ->toBe('{"success":true,"data":{"value":"txt"}}'); + expect(IntegrationHarness::queryJson('inventory.normalizeCode', '{"value":7}')) + ->toBe('{"success":true,"data":{"value":7}}'); +}); + +test('the scalar shorthand rejects an object with one issue per arm plus the union', function () { + expect(IntegrationHarness::queryJson('inventory.normalizeCode', '{"value":{}}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"value":["validation.invalid_type","validation.invalid_type","validation.invalid_type","validation.invalid_type","validation.invalid_type"]}}}'); +}); + +test('the numeric shorthand accepts int and float together', function () { + expect(IntegrationHarness::queryJson('inventory.sumNumeric', '{"a":1,"b":2.5}')) + ->toBe('{"success":true,"data":{"total":3.5}}'); +}); + +test('the numeric shorthand rejects a numeric string', function () { + expect(IntegrationHarness::queryJson('inventory.sumNumeric', '{"a":"1","b":2}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"a":["validation.invalid_type","validation.invalid_type","validation.invalid_type"]}}}'); +}); + +test('float, false, null and class-const literals echo through both directions', function () { + expect(IntegrationHarness::queryJson('inventory.literalSampler', '{"factor":0.5,"flag":false,"legacy":null,"mode":"express"}')) + ->toBe('{"success":true,"data":{"factor":0.5,"flag":false,"legacy":null,"mode":"express"}}'); +}); + +test('a float literal union rejects a float outside the set', function () { + expect(IntegrationHarness::queryJson('inventory.literalSampler', '{"factor":2.5,"flag":false,"legacy":null,"mode":"express"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"factor":["validation.invalid_type","validation.invalid_type","validation.invalid_type"]}}}'); +}); + +test('the false literal rejects true', function () { + expect(IntegrationHarness::queryJson('inventory.literalSampler', '{"factor":0.5,"flag":true,"legacy":null,"mode":"express"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"flag":["validation.invalid_type"]}}}'); +}); + +test('a null struct member rejects a non-null value', function () { + expect(IntegrationHarness::queryJson('inventory.literalSampler', '{"factor":0.5,"flag":false,"legacy":"x","mode":"express"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"legacy":["validation.invalid_type"]}}}'); +}); + +test('a class-const literal union rejects a value outside the constant set', function () { + expect(IntegrationHarness::queryJson('inventory.literalSampler', '{"factor":0.5,"flag":false,"legacy":null,"mode":"overnight"}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"mode":["validation.invalid_type","validation.invalid_type","validation.invalid_type"]}}}'); +}); + +test('a branded IntValueObject is a plain number on the wire in both directions', function () { + expect(IntegrationHarness::queryJson('inventory.lookupWarehouse', '{"id":7}')) + ->toBe('{"success":true,"data":{"id":7,"name":"Zurich Hub"}}'); +}); + +test('an IntValueObject rejecting with ValidationException surfaces its message verbatim', function () { + expect(IntegrationHarness::queryJson('inventory.lookupWarehouse', '{"id":0}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"id":["Warehouse id must be positive"]}}}'); +}); + +test('an int-backed enum serializes by case name while a value-object enum uses its backing int', function () { + expect(IntegrationHarness::queryJson('inventory.palletReport', '{"level":"LOW","size":2}')) + ->toBe('{"success":true,"data":{"level":"LOW","size":2}}'); +}); + +test('a value-object enum collapses an unknown backing int to the generic invalid_value key', function () { + expect(IntegrationHarness::queryJson('inventory.palletReport', '{"level":"LOW","size":9}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"size":["validation.invalid_value"]}}}'); +}); + +test('a plain int-backed enum rejects its backing int because the wire form is the case name', function () { + expect(IntegrationHarness::queryJson('inventory.palletReport', '{"level":1,"size":2}')) + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"level":["validation.invalid_type"]}}}'); +}); From fcd131fb73ca8ddbcf35dfae50f0d0f96becebd3 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 14 Aug 2026 09:47:39 +0200 Subject: [PATCH 096/101] Add benchmark script to measure performance of cached vs eager operation registries - Introduced `tests/benchmark/run.php` to compare registry initialization, schema resolution, and request execution. - Updated `composer.json` to include `benchmark` script with description. - Refactored `IntegrationHarness` to expose `discoverEagerRegistry` and added `CACHE_ID_LENGTH` constant for reuse. - Optimized `Operation` class to memoize input/output nodes for improved performance. --- composer.json | 6 +- src/Server/Data/Operation.php | 42 +++++- tests/Integration/IntegrationHarness.php | 13 +- tests/benchmark/run.php | 162 +++++++++++++++++++++++ 4 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 tests/benchmark/run.php diff --git a/composer.json b/composer.json index 89636d6..d1173dd 100644 --- a/composer.json +++ b/composer.json @@ -68,13 +68,15 @@ "@php tests/ts-output/generate.php", "npm --prefix tests/ts-output install --no-audit --no-fund", "npm --prefix tests/ts-output run typecheck" - ] + ], + "benchmark": "@php tests/benchmark/run.php" }, "scripts-descriptions": { "check:style": "Check formatting with Pint (psr12) without writing. Run fix:style to apply.", "fix:style": "Apply Pint formatting in place.", "check:ts": "Typecheck the committed generated TypeScript with tsc --noEmit. Needs node; check:all does not.", - "codegen:fixture": "Regenerate tests/ts-output/generated and typecheck it with tsc --noEmit. Run after changing a code generator; commit the result." + "codegen:fixture": "Regenerate tests/ts-output/generated and typecheck it with tsc --noEmit. Run after changing a code generator; commit the result.", + "benchmark": "Time cached vs eager operation registries over the integration fixtures. Informational; not part of check:all or CI." }, "extra": { "laravel": { diff --git a/src/Server/Data/Operation.php b/src/Server/Data/Operation.php index 9b4ff69..a604676 100644 --- a/src/Server/Data/Operation.php +++ b/src/Server/Data/Operation.php @@ -7,27 +7,55 @@ use Closure; use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; -final readonly class Operation +final class Operation { + private ?NodeInterface $inputNode = null; + private ?NodeInterface $outputNode = null; + + /** + * @var Closure(): NodeInterface|null + */ + private readonly Closure|null $inputNodeFactory; + + /** + * @var Closure(): NodeInterface|null + */ + private readonly Closure|null $outputNodeFactory; + /** * @param NodeInterface|Closure(): NodeInterface $input * @param NodeInterface|Closure(): NodeInterface $output */ public function __construct( - public string $key, - public Definition $definition, - private NodeInterface|Closure $input, - private NodeInterface|Closure $output, + public readonly string $key, + public readonly Definition $definition, + NodeInterface|Closure $input, + NodeInterface|Closure $output, ) { + if ($input instanceof NodeInterface) { + $this->inputNode = $input; + $this->inputNodeFactory = null; + } else { + $this->inputNodeFactory = $input; + } + + if ($output instanceof NodeInterface) { + $this->outputNode = $output; + $this->outputNodeFactory = null; + } else { + $this->outputNodeFactory = $output; + } } public function inputNode(): NodeInterface { - return $this->input instanceof Closure ? ($this->input)() : $this->input; + /** @phpstan-ignore-next-line */ + return $this->inputNode ??= ($this->inputNodeFactory)(); } public function outputNode(): NodeInterface { - return $this->output instanceof Closure ? ($this->output)() : $this->output; + /** @phpstan-ignore-next-line */ + return $this->outputNode ??= ($this->outputNodeFactory)(); } } diff --git a/tests/Integration/IntegrationHarness.php b/tests/Integration/IntegrationHarness.php index 62cb6ba..5b69460 100644 --- a/tests/Integration/IntegrationHarness.php +++ b/tests/Integration/IntegrationHarness.php @@ -26,6 +26,8 @@ */ final class IntegrationHarness { + public const int CACHE_ID_LENGTH = 12; + private static ?EagerlyLoadedOperationRegistry $eagerRegistry = null; private static ?CachedOperationRegistry $cachedRegistry = null; @@ -63,11 +65,11 @@ private static function execute(OperationType $type, string $key, ?string $json, return $eagerJson; } - private static function eagerRegistry(): EagerlyLoadedOperationRegistry + public static function discoverEagerRegistry(): EagerlyLoadedOperationRegistry { // One global alias so the fixtures can exercise user-registered aliases end-to-end. It // only fires on the identifier ApiToken and is inert for every other fixture. - return self::$eagerRegistry ??= EagerlyLoadedOperationRegistry::eagerlyDiscover( + return EagerlyLoadedOperationRegistry::eagerlyDiscover( __DIR__.'/Fixtures/Operations', parser: new TypeParser(TypeParser::defaultConsumers(new GlobalTypeAliases([ 'ApiToken' => static fn (): ConstraintNode => new ConstraintNode(new StringNode(), [new NonEmptyString()]), @@ -76,6 +78,11 @@ private static function eagerRegistry(): EagerlyLoadedOperationRegistry ); } + private static function eagerRegistry(): EagerlyLoadedOperationRegistry + { + return self::$eagerRegistry ??= self::discoverEagerRegistry(); + } + private static function cachedRegistry(): CachedOperationRegistry { if (self::$cachedRegistry !== null) { @@ -83,7 +90,7 @@ private static function cachedRegistry(): CachedOperationRegistry } $file = sys_get_temp_dir().'/php-ts-bindings-integration-'.getmypid().'.php'; - CachedOperationRegistry::writeToCache(self::eagerRegistry(), $file, idLength: 12); + CachedOperationRegistry::writeToCache(self::eagerRegistry(), $file, idLength: self::CACHE_ID_LENGTH); register_shutdown_function(static function () use ($file): void { @unlink($file); }); diff --git a/tests/benchmark/run.php b/tests/benchmark/run.php new file mode 100644 index 0000000..0771d4e --- /dev/null +++ b/tests/benchmark/run.php @@ -0,0 +1,162 @@ + $times[0], + 'median' => $times[intdiv($samples, 2)], + 'mean' => array_sum($times) / $samples, + ]; +} + +printf("PHP %s | opcache.enable_cli=%s\n", PHP_VERSION, ini_get('opcache.enable_cli') === '1' ? 'on' : 'off'); +if (ini_get('opcache.enable_cli') === '1' && (int) ini_get('opcache.file_update_protection') > 0) { + echo "NOTE: opcache.file_update_protection > 0 keeps opcache from caching the freshly\n"; + echo " written cache file - add -d opcache.file_update_protection=0 for real hits.\n"; +} +if (extension_loaded('xdebug') && getenv('XDEBUG_MODE') !== 'off') { + echo "WARNING: Xdebug is active and distorts every number below.\n"; + echo " Re-run as: XDEBUG_MODE=off composer benchmark\n"; +} + +$cacheFile = sys_get_temp_dir().'/php-ts-bindings-bench-'.getmypid().'.php'; +register_shutdown_function(static function () use ($cacheFile): void { + @unlink($cacheFile); +}); + +// Untimed seed build: warms the autoloader (fixtures, parser, executor classes) so the first +// timed iteration does not pay for class loading. +$seed = IntegrationHarness::discoverEagerRegistry(); + +$start = hrtime(true); +CachedOperationRegistry::writeToCache($seed, $cacheFile, idLength: IntegrationHarness::CACHE_ID_LENGTH); +$codegenMs = (hrtime(true) - $start) / 1_000_000; +$operationCount = count($seed->all()); + +printf("%d operations | cache file %d bytes | codegen: writeToCache() one-off %.1fms\n", $operationCount, filesize($cacheFile) ?: 0, $codegenMs); + +/** @var list $results */ +$results = []; + +$results[] = [ + 'boot: construct registry, schemas unresolved', + measure(WARMUP_BOOT, SAMPLES_BOOT, static fn (): mixed => IntegrationHarness::discoverEagerRegistry()), + measure(WARMUP_BOOT, SAMPLES_BOOT, static fn (): mixed => require $cacheFile), +]; + +$warmAll = static function (callable $boot): void { + foreach ($boot()->all() as $operation) { + $operation->inputNode(); + $operation->outputNode(); + } +}; +$results[] = [ + "warm-all: resolve all {$operationCount} operation schemas", + measure(WARMUP_BOOT, SAMPLES_BOOT, static fn (): mixed => $warmAll(static fn (): mixed => IntegrationHarness::discoverEagerRegistry())), + measure(WARMUP_BOOT, SAMPLES_BOOT, static fn (): mixed => $warmAll(static fn (): mixed => require $cacheFile)), +]; + +$configuration = new ServerConfiguration(); +$client = new NullClient(); +$servers = [ + 'eager' => new Server(IntegrationHarness::discoverEagerRegistry(), configuration: $configuration), + 'cached' => new Server(require $cacheFile, configuration: $configuration), +]; + +$requests = [ + ['inventory.convertWeight', 'query', '2.5', 'bare scalar'], + ['cart.addItem', 'command', '{"item":{"sku":"ABC-123","quantity":2,"note":"engrave"}}', 'nested struct + castables'], + ['orders.getOrder', 'query', '{"orderNumber":"ORD-1001"}', 'serialization-heavy'], + ['catalog.feedEvents', 'query', '{"events":[{"kind":"restock","qty":5},{"kind":"sale","ref":"S-1"}]}', 'union in list'], +]; + +foreach ($requests as [$key, $type, $json, $blurb]) { + $input = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + $stats = []; + foreach ($servers as $name => $server) { + $call = $type === 'query' + ? static fn (): mixed => $server->query($key, $input, null, $client) + : static fn (): mixed => $server->command($key, $input, null, $client); + + $result = $call(); + if ($result->statusCode !== 200) { + fwrite(STDERR, "Aborting: {$key} returned status {$result->statusCode} on the {$name} registry - this would benchmark an error path.\n"); + exit(1); + } + + $stats[$name] = measure(WARMUP_STEADY, SAMPLES_STEADY, $call); + } + $results[] = ["{$type} {$key} - {$blurb}", $stats['eager'], $stats['cached']]; +} + +printf("\n%-50s %11s %11s %11s %11s %9s\n", 'scenario', 'eager min', 'eager med', 'cached min', 'cached med', 'speedup'); +echo str_repeat('-', 108), PHP_EOL; +foreach ($results as [$label, $eager, $cached]) { + printf( + "%-50s %9.4fms %9.4fms %9.4fms %9.4fms %9s\n", + $label, + $eager['min'], + $eager['median'], + $cached['min'], + $cached['median'], + sprintf('x%.1f', $eager['median'] / $cached['median']), + ); +} + +echo PHP_EOL; +echo 'Speedup = eager median / cached median; < 1.0 means the cached path is slower.', PHP_EOL; +echo 'boot/warm-all: fresh registry per iteration ('.SAMPLES_BOOT.' samples). Requests: warm registries ('.SAMPLES_STEADY.' samples).', PHP_EOL; +echo 'Request rows should be near parity - a large gap means eager re-resolves schemas per call.', PHP_EOL; From ce72244b97a0fa38efc0d4253bfb1620b031bbcc Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 14 Aug 2026 09:59:09 +0200 Subject: [PATCH 097/101] Add end-to-end benchmark for full request lifecycle - Extended `tests/benchmark/run.php` to include `end2end` measurements, simulating a full PHP-FPM request lifecycle. - Configured warm-up and sample sizes for end-to-end benchmarks (`WARMUP_E2E`, `SAMPLES_E2E`). - Added checks to validate `orders.getOrder` execution results on both eager and cached registries. - Updated benchmark output to include `end2end` results for comprehensive performance comparison. --- tests/benchmark/run.php | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/benchmark/run.php b/tests/benchmark/run.php index 0771d4e..c6eda1d 100644 --- a/tests/benchmark/run.php +++ b/tests/benchmark/run.php @@ -18,6 +18,8 @@ * - Both registries memoize Operation instances, so boot and warm-all build a fresh registry per * iteration while steady-state deliberately reuses warm ones. * - Warm-all includes boot; warm-all minus boot approximates pure schema resolution cost. + * - The end2end row is one full request lifecycle per iteration (boot registry, resolve the + * schema, execute one complex query) - the cost a share-nothing PHP-FPM request actually pays. * - Request rows on warm registries should be near parity: both paths execute the same node * graph. A large eager/cached gap there means the eager path is re-resolving (re-parsing) * schemas per call instead of serving memoized nodes. @@ -33,6 +35,8 @@ const WARMUP_BOOT = 3; const SAMPLES_BOOT = 30; +const WARMUP_E2E = 5; +const SAMPLES_E2E = 100; const WARMUP_STEADY = 50; const SAMPLES_STEADY = 500; @@ -110,6 +114,26 @@ function measure(int $warmup, int $samples, callable $fn): array $configuration = new ServerConfiguration(); $client = new NullClient(); + +// One full request lifecycle per iteration, the way share-nothing PHP-FPM pays it: construct +// the registry, resolve the schema, execute a single complex query. +$e2eInput = json_decode('{"orderNumber":"ORD-1001"}', true, 512, JSON_THROW_ON_ERROR); +$e2eRequest = static function (callable $boot) use ($e2eInput, $configuration, $client): mixed { + return new Server($boot(), configuration: $configuration)->query('orders.getOrder', $e2eInput, null, $client); +}; +foreach (['eager' => static fn (): mixed => IntegrationHarness::discoverEagerRegistry(), 'cached' => static fn (): mixed => require $cacheFile] as $name => $boot) { + $result = $e2eRequest($boot); + if ($result->statusCode !== 200) { + fwrite(STDERR, "Aborting: end2end orders.getOrder returned status {$result->statusCode} on the {$name} registry.\n"); + exit(1); + } +} +$results[] = [ + 'end2end: boot + one orders.getOrder query', + measure(WARMUP_E2E, SAMPLES_E2E, static fn (): mixed => $e2eRequest(static fn (): mixed => IntegrationHarness::discoverEagerRegistry())), + measure(WARMUP_E2E, SAMPLES_E2E, static fn (): mixed => $e2eRequest(static fn (): mixed => require $cacheFile)), +]; + $servers = [ 'eager' => new Server(IntegrationHarness::discoverEagerRegistry(), configuration: $configuration), 'cached' => new Server(require $cacheFile, configuration: $configuration), @@ -158,5 +182,5 @@ function measure(int $warmup, int $samples, callable $fn): array echo PHP_EOL; echo 'Speedup = eager median / cached median; < 1.0 means the cached path is slower.', PHP_EOL; -echo 'boot/warm-all: fresh registry per iteration ('.SAMPLES_BOOT.' samples). Requests: warm registries ('.SAMPLES_STEADY.' samples).', PHP_EOL; +echo 'boot/warm-all: fresh registry per iteration ('.SAMPLES_BOOT.' samples). end2end: full lifecycle per iteration ('.SAMPLES_E2E.' samples). Requests: warm registries ('.SAMPLES_STEADY.' samples).', PHP_EOL; echo 'Request rows should be near parity - a large gap means eager re-resolves schemas per call.', PHP_EOL; From e4a32641897ab91392969882e209f15837a13628 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 14 Aug 2026 10:30:35 +0200 Subject: [PATCH 098/101] Add `NoOpMiddleware` and integrate middleware into `CartCommands` --- tests/Integration/Fixtures/NoOpMiddleware.php | 20 +++++++++++++++++++ .../Fixtures/Operations/CartCommands.php | 3 +++ 2 files changed, 23 insertions(+) create mode 100644 tests/Integration/Fixtures/NoOpMiddleware.php diff --git a/tests/Integration/Fixtures/NoOpMiddleware.php b/tests/Integration/Fixtures/NoOpMiddleware.php new file mode 100644 index 0000000..30a1218 --- /dev/null +++ b/tests/Integration/Fixtures/NoOpMiddleware.php @@ -0,0 +1,20 @@ + * } */ + #[Middleware(NoOpMiddleware::class)] #[Command('cart')] public function addItem(array $input): array { From 2b022d0c1d505e7c94baf1bfcbf460a462a59e84 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 14 Aug 2026 11:45:37 +0200 Subject: [PATCH 099/101] Add support for per-operation middleware configuration - Introduced `ConfigurableMiddleware` interface to enable middleware with per-operation configurations. - Updated `OperationDiscovery` to support configuration validation for `#[Middleware]` attributes. - Added `MiddlewareDefinition` class to encapsulate middleware details, including class and configuration data. - Enhanced `Server` to pass configured middleware instances to operations during runtime execution. - Implemented tests for configured middleware, ensuring correct behavior with valid, invalid, and container-shared instances. - Extended documentation to include guidelines for configuring middleware using the `#[Middleware]` attribute. --- CLAUDE.md | 17 +++ docs/operations.md | 43 +++++++- src/Adapters/Laravel/Commands/ListCommand.php | 4 +- src/Contracts/Attributes/Middleware.php | 7 +- src/Contracts/ConfigurableMiddleware.php | 32 ++++++ src/Server/Data/Definition.php | 10 +- .../Exceptions/InvalidMiddlewareException.php | 15 ++- src/Server/Data/MiddlewareDefinition.php | 47 ++++++++ src/Server/Errors/ThrowAttributeResolver.php | 2 +- src/Server/Operations/OperationDiscovery.php | 32 +++++- src/Server/Server.php | 40 +++++-- .../Mocks/MutatingPrefixMiddleware.php | 40 +++++++ .../Feature/Operations/ConfiguredGreeting.php | 35 ++++++ .../Operations/PrefixNameMiddleware.php | 36 +++++++ tests/Feature/ServerTest.php | 102 ++++++++++++++++++ tests/Unit/CodeGen/ErrorTypescriptTest.php | 3 +- .../Errors/ThrowAttributeResolverTest.php | 3 +- .../Unit/Server/MiddlewareDefinitionTest.php | 27 +++++ tests/Unit/Server/OperationDiscoveryTest.php | 84 ++++++++++++++- 19 files changed, 556 insertions(+), 23 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/Contracts/ConfigurableMiddleware.php create mode 100644 src/Server/Data/MiddlewareDefinition.php create mode 100644 tests/Feature/Mocks/MutatingPrefixMiddleware.php create mode 100644 tests/Feature/Operations/ConfiguredGreeting.php create mode 100644 tests/Feature/Operations/PrefixNameMiddleware.php create mode 100644 tests/Unit/Server/MiddlewareDefinitionTest.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e5c294 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,17 @@ +# PHP + +- Always use PHP8.5, which is the minimum version this library supports. +- Use the pipe operator when possible. +- Mark Closures as `static` when possible. +- Use `readonly` properties when possible. +- Trust PHPstan. This library is designed to be used with PHPStan (preferably with strict level >= 8). +- Type your code as good as possible. Mixed only when necessary. + +## Development + +Split the work into small units. For each unit: + +1. Write tests first +2. Check if tests fail +3. Implement the change +4. Verify, correct and iterate \ No newline at end of file diff --git a/docs/operations.md b/docs/operations.md index 47edb87..eb5b6a2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -16,7 +16,7 @@ to all of them. The short version lives in the [README](../README.md); this is t |---|---|---| | `#[Query(namespace, name)]` | method | A read operation, served over GET. | | `#[Command(namespace, name)]` | method | A write operation, served over POST. | -| `#[Middleware(class)]` | class, method, repeatable | Middleware to run around this operation. | +| `#[Middleware(class, config?)]` | class, method, repeatable | Middleware to run around this operation, optionally with `array` config. | | `#[Throws(ExceptionClass, type: ?ErrorType, name: ?string)]` | method, repeatable | Declares an exception this method may throw — a named domain error, or an explicit category mapping. | | `#[ExposeAs(type: ErrorType, name: ?string)]` | exception class | The exception's own category and name, for every scope that declares it. | | `#[Optional]` | property, parameter | The field may be absent from input. | @@ -152,6 +152,47 @@ Middleware can also attach metadata to whichever result it is holding, with `wit `appendMetadata()`. It travels to the client under `__metadata` on both branches — see [the envelope](typescript-client.md#the-envelope). +### Configuring middleware per operation + +A middleware that implements `ConfigurableMiddleware` can take per-operation config from the +attribute: + +```php +use Le0daniel\PhpTsBindings\Contracts\ConfigurableMiddleware; + +/** + * @implements ConfigurableMiddleware + */ +final readonly class RateLimitMiddleware implements ConfigurableMiddleware +{ + public function __construct(public int $limit = 60) {} + + public function configure(array $config): static + { + return clone($this, ['limit' => (int) ($config['limit'] ?? $this->limit)]); + } + + // handle() as usual ... +} +``` + +```php +#[Command('users')] +#[Middleware(RateLimitMiddleware::class, config: ['limit' => 10])] +public function create(array $input): array { /* ... */ } +``` + +Config is limited to `array` on purpose: it is exported into the operations cache +as plain PHP code, so it must be data, not behavior. Discovery rejects any other shape, and rejects +config on a middleware that does not implement the contract. + +**`configure()` runs on a private clone and returns the configured instance.** The server clones +whatever the adapter handed out before calling `configure()`, so even a container-shared instance +can never be polluted: mutable classes may assign to `$this` and return it, `readonly` classes +return `clone($this, [...])`. The configurable check happens per instance at runtime too, so an +adapter substituting a non-configurable instance for a configured declaration surfaces as a named +`RpcError` rather than an undefined-method error. + ## ServerConfiguration `ServerConfiguration` carries every server-wide setting, and is where all three are set. diff --git a/src/Adapters/Laravel/Commands/ListCommand.php b/src/Adapters/Laravel/Commands/ListCommand.php index 237d6eb..dde914d 100644 --- a/src/Adapters/Laravel/Commands/ListCommand.php +++ b/src/Adapters/Laravel/Commands/ListCommand.php @@ -42,7 +42,7 @@ public function handle( implode(', ', $queryRoute->methods()), $operation->definition->fullyQualifiedClassName.'@'.$operation->definition->methodName, implode(', ', $queryRoute->gatherMiddleware()), - implode(', ', $operation->definition->middleware), + implode(', ', $operation->definition->middlewareClassNames()), ], OperationType::COMMAND => [ $operation->definition->fullyQualifiedName(), @@ -50,7 +50,7 @@ public function handle( implode(', ', $commandRoute->methods()), $operation->definition->fullyQualifiedClassName.'@'.$operation->definition->methodName, implode(', ', $commandRoute->gatherMiddleware()), - implode(', ', $operation->definition->middleware), + implode(', ', $operation->definition->middlewareClassNames()), ], }, $server->registry->all())); diff --git a/src/Contracts/Attributes/Middleware.php b/src/Contracts/Attributes/Middleware.php index 435b3a7..1df36eb 100644 --- a/src/Contracts/Attributes/Middleware.php +++ b/src/Contracts/Attributes/Middleware.php @@ -12,10 +12,13 @@ * #[Throws] is repeated: they apply outermost first, and every middleware declared on the class * runs outside every middleware declared on the method. * + * Config requires the middleware to implement ConfigurableMiddleware and is limited to + * array - it is exported into the operations cache as plain PHP code. + * * ```php * #[Command('users')] * #[Middleware(AuthMiddleware::class)] - * #[Middleware(NameCheckingMiddleware::class)] + * #[Middleware(RateLimitMiddleware::class, config: ['limit' => 10])] * public function create(array $input): array { } * ``` */ @@ -24,9 +27,11 @@ { /** * @param class-string> $middleware + * @param array $config */ public function __construct( public string $middleware, + public array $config = [], ) { } } diff --git a/src/Contracts/ConfigurableMiddleware.php b/src/Contracts/ConfigurableMiddleware.php new file mode 100644 index 0000000..632ca88 --- /dev/null +++ b/src/Contracts/ConfigurableMiddleware.php @@ -0,0 +1,32 @@ + 10])] + * ``` + * + * Config is restricted to array so it can be exported into the operations cache + * as plain PHP code. + * + * The server calls configure() on a private clone of whatever the adapter handed out, so a + * container-shared instance can never be polluted: mutable classes may assign to $this and return + * it, readonly classes return `clone($this, [...])`. Either way, return the configured instance - + * never spread raw config into a clone, pick the keys explicitly. + * + * @template-contravariant TContext = mixed + * + * @extends MiddlewareContract + */ +interface ConfigurableMiddleware extends MiddlewareContract +{ + /** + * @param array $config + */ + public function configure(array $config): static; +} diff --git a/src/Server/Data/Definition.php b/src/Server/Data/Definition.php index bd44030..fade854 100644 --- a/src/Server/Data/Definition.php +++ b/src/Server/Data/Definition.php @@ -13,7 +13,7 @@ { /** * @param class-string $fullyQualifiedClassName - * @param list>> $middleware + * @param list $middleware */ public function __construct( public OperationType $type, @@ -30,6 +30,14 @@ public function fullyQualifiedName(): string return "{$this->namespace}.{$this->name}"; } + /** + * @return list>> + */ + public function middlewareClassNames(): array + { + return array_map(static fn (MiddlewareDefinition $middleware): string => $middleware->middleware, $this->middleware); + } + #[Override] public function exportPhpCode(): string { diff --git a/src/Server/Data/Exceptions/InvalidMiddlewareException.php b/src/Server/Data/Exceptions/InvalidMiddlewareException.php index 885fc4c..aeae280 100644 --- a/src/Server/Data/Exceptions/InvalidMiddlewareException.php +++ b/src/Server/Data/Exceptions/InvalidMiddlewareException.php @@ -4,11 +4,13 @@ namespace Le0daniel\PhpTsBindings\Server\Data\Exceptions; +use Le0daniel\PhpTsBindings\Contracts\ConfigurableMiddleware; use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; /** - * Thrown when a class registered as middleware does not implement MiddlewareContract. + * Thrown when a class registered as middleware does not implement MiddlewareContract, or is + * given config it cannot accept. * * Middleware is referenced by class-string - through the #[Middleware] attribute or the global * configuration - so the mistake only becomes visible when the operation runs. Saying which class @@ -21,10 +23,15 @@ private function __construct(string $message) parent::__construct($message); } - public static function notAMiddleware(string $className): self + public static function notConfigurable(string $className): self { - $contract = MiddlewareContract::class; + $contract = ConfigurableMiddleware::class; - return new self("Middleware {$className} must implement {$contract}."); + return new self("Middleware {$className} was given config but does not implement {$contract}."); + } + + public static function invalidConfig(string $className, string $key): self + { + return new self("Middleware config for {$className} must be array, entry '{$key}' is not."); } } diff --git a/src/Server/Data/MiddlewareDefinition.php b/src/Server/Data/MiddlewareDefinition.php new file mode 100644 index 0000000..64395fc --- /dev/null +++ b/src/Server/Data/MiddlewareDefinition.php @@ -0,0 +1,47 @@ +> $middleware + * @param array $config + */ + public function __construct( + public string $middleware, + public array $config = [], + ) { + } + + #[Override] + public function exportPhpCode(): string + { + $className = PHPExport::absolute(self::class); + $middleware = PHPExport::export($this->middleware); + + if ($this->config === []) { + return "new {$className}({$middleware})"; + } + + $entries = []; + foreach ($this->config as $key => $value) { + $entries[] = var_export($key, true).' => '.var_export($value, true); + } + $config = '['.implode(', ', $entries).']'; + + return "new {$className}({$middleware}, {$config})"; + } +} diff --git a/src/Server/Errors/ThrowAttributeResolver.php b/src/Server/Errors/ThrowAttributeResolver.php index a32decc..1cb7372 100644 --- a/src/Server/Errors/ThrowAttributeResolver.php +++ b/src/Server/Errors/ThrowAttributeResolver.php @@ -25,7 +25,7 @@ public static function collectDomainErrorNamesFromDefinition( ): array { $reflections = [ new ReflectionMethod($definition->fullyQualifiedClassName, $definition->methodName), - ... array_map(static fn ($className) => new ReflectionMethod($className, 'handle'), $definition->middleware), + ... array_map(static fn ($className) => new ReflectionMethod($className, 'handle'), $definition->middlewareClassNames()), ]; $names = []; diff --git a/src/Server/Operations/OperationDiscovery.php b/src/Server/Operations/OperationDiscovery.php index e99eda1..0d17da3 100644 --- a/src/Server/Operations/OperationDiscovery.php +++ b/src/Server/Operations/OperationDiscovery.php @@ -9,9 +9,11 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Middleware; use Le0daniel\PhpTsBindings\Contracts\Attributes\Query; use Le0daniel\PhpTsBindings\Contracts\Client; -use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; +use Le0daniel\PhpTsBindings\Contracts\ConfigurableMiddleware; use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; use Le0daniel\PhpTsBindings\Server\Data\Definition; +use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; +use Le0daniel\PhpTsBindings\Server\Data\MiddlewareDefinition; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use ReflectionClass; use ReflectionMethod; @@ -154,10 +156,18 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, ...$method->getAttributes(Middleware::class), ]; - /** @var list>> $middlewares */ + /** @var list $middlewares */ $middlewares = []; foreach ($middlewareAttributes as $middlewareAttribute) { - $middlewares[] = $middlewareAttribute->newInstance()->middleware; + $middleware = $middlewareAttribute->newInstance(); + + if ($middleware->config !== [] && ! is_a($middleware->middleware, ConfigurableMiddleware::class, true)) { + throw InvalidMiddlewareException::notConfigurable($middleware->middleware); + } + + self::assertValidConfig($middleware->middleware, $middleware->config); + + $middlewares[] = new MiddlewareDefinition($middleware->middleware, $middleware->config); } return new Definition( @@ -169,4 +179,20 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, $middlewares, ); } + + /** + * The attribute's @param promises array, but discovery reads arbitrary code + * that may never have run through PHPStan - so the promise is verified here, where + * declarations enter the system. The parameter type admits what can actually arrive. + * + * @param array $config + */ + private static function assertValidConfig(string $className, array $config): void + { + foreach ($config as $key => $value) { + if (! is_string($key) || ! is_scalar($value)) { + throw InvalidMiddlewareException::invalidConfig($className, (string) $key); + } + } + } } diff --git a/src/Server/Server.php b/src/Server/Server.php index e408724..a5213e1 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -5,6 +5,8 @@ namespace Le0daniel\PhpTsBindings\Server; use Le0daniel\PhpTsBindings\Contracts\Client; +use Le0daniel\PhpTsBindings\Contracts\ConfigurableMiddleware; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; use Le0daniel\PhpTsBindings\Contracts\OperationRegistry; use Le0daniel\PhpTsBindings\Contracts\ServerAdapter; use Le0daniel\PhpTsBindings\Executor\Data\Failure; @@ -14,8 +16,10 @@ use Le0daniel\PhpTsBindings\Server\Adapters\NewInstanceAdapter; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidInputException; +use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidOutputException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\OperationNotFoundException; +use Le0daniel\PhpTsBindings\Server\Data\MiddlewareDefinition; use Le0daniel\PhpTsBindings\Server\Data\Operation; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\ResolveInfo; @@ -82,18 +86,13 @@ public function command(string $key, mixed $input, mixed $context, Client $clien private function execute(Operation $operation, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { - $middlewareClassNames = [ - ...$this->configuration->middleware, - ...$operation->definition->middleware, - ]; - $resolveInfo = new ResolveInfo( $operation->definition->namespace, $operation->definition->name, $operation->definition->type, $operation->definition->fullyQualifiedClassName, $operation->definition->methodName, - $middlewareClassNames, + [...$this->configuration->middleware, ...$operation->definition->middlewareClassNames()], ); // Resolving happens before the pipeline exists, so it needs its own guard to keep @@ -101,7 +100,11 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli // middleware must surface as an RpcError, not as an uncaught exception. Nothing was // executing yet, so there is no scope whose declarations could apply. try { - $middlewares = array_map(fn ($className) => $this->adapter->createMiddleware($className), $middlewareClassNames); + // Global middleware carries no config, so it skips the configuration path entirely. + $middlewares = [ + ...array_map($this->adapter->createMiddleware(...), $this->configuration->middleware), + ...array_map($this->createMiddleware(...), $operation->definition->middleware), + ]; $controllerClass = $this->adapter->createController($operation->definition->fullyQualifiedClassName); } catch (Throwable $throwable) { return $this->present($throwable, $resolveInfo); @@ -169,6 +172,29 @@ private function execute(Operation $operation, mixed $input, mixed $context, Cli )->execute($input, $context, $resolveInfo, $client); } + /** + * Discovery already proved the declared class implements ConfigurableMiddleware when config + * is present. The check here is about the instance, because the adapter owns instantiation + * and may hand out a decorator or container substitute for that class-string. + * + * configure() runs on a private clone, so even a configure() that mutates $this can never + * leak one operation's config into a container-shared instance. + */ + private function createMiddleware(MiddlewareDefinition $definition): MiddlewareContract + { + $middleware = $this->adapter->createMiddleware($definition->middleware); + + if ($definition->config === []) { + return $middleware; + } + + if (! $middleware instanceof ConfigurableMiddleware) { + throw InvalidMiddlewareException::notConfigurable($middleware::class); + } + + return (clone $middleware)->configure($definition->config); + } + /** * The category comes from the throwing scope's own #[Throws]/#[ExposeAs] declarations first, * and from the configured classifier lists only where that scope declared nothing - the scope diff --git a/tests/Feature/Mocks/MutatingPrefixMiddleware.php b/tests/Feature/Mocks/MutatingPrefixMiddleware.php new file mode 100644 index 0000000..523b814 --- /dev/null +++ b/tests/Feature/Mocks/MutatingPrefixMiddleware.php @@ -0,0 +1,40 @@ + + */ +final class MutatingPrefixMiddleware implements ConfigurableMiddleware +{ + public string $prefix = ''; + + public function configure(array $config): static + { + $this->prefix = (string) ($config['prefix'] ?? $this->prefix); + + return $this; + } + + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + { + if (is_array($input) && is_string($input['name'] ?? null)) { + $input['name'] = $this->prefix.$input['name']; + } + + return $next($input); + } +} diff --git a/tests/Feature/Operations/ConfiguredGreeting.php b/tests/Feature/Operations/ConfiguredGreeting.php new file mode 100644 index 0000000..d9b62d8 --- /dev/null +++ b/tests/Feature/Operations/ConfiguredGreeting.php @@ -0,0 +1,35 @@ + 'Dr. '])] + public function greet(array $input): array + { + return ['message' => "Hello {$input['name']}"]; + } + + /** + * The same middleware without config runs with its constructor defaults. + * + * @param array{name: string} $input + * @return array{message: string} + */ + #[Command('configured')] + #[Middleware(PrefixNameMiddleware::class)] + public function greetPlain(array $input): array + { + return ['message' => "Hello {$input['name']}"]; + } +} diff --git a/tests/Feature/Operations/PrefixNameMiddleware.php b/tests/Feature/Operations/PrefixNameMiddleware.php new file mode 100644 index 0000000..a8fd9fb --- /dev/null +++ b/tests/Feature/Operations/PrefixNameMiddleware.php @@ -0,0 +1,36 @@ + + */ +final readonly class PrefixNameMiddleware implements ConfigurableMiddleware +{ + public function __construct(public string $prefix = '') + { + } + + public function configure(array $config): static + { + return clone($this, ['prefix' => (string) ($config['prefix'] ?? $this->prefix)]); + } + + public function handle(mixed $input, Closure $next, mixed $context, ResolveInfo $info, Client $client): RpcSuccess|RpcError + { + if (is_array($input) && is_string($input['name'] ?? null)) { + $input['name'] = $this->prefix.$input['name']; + } + + return $next($input); + } +} diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 4fc98fc..61dc84c 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -3,8 +3,11 @@ declare(strict_types=1); use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; +use Le0daniel\PhpTsBindings\Contracts\MiddlewareContract; +use Le0daniel\PhpTsBindings\Contracts\ServerAdapter; use Le0daniel\PhpTsBindings\Server\Client\NullClient; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidOutputException; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Data\RpcError; @@ -15,7 +18,10 @@ use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedOperationRegistry; use Le0daniel\PhpTsBindings\Server\Server; use Tests\Feature\Mocks\GloballyThrowingMiddleware; +use Tests\Feature\Mocks\MutatingPrefixMiddleware; use Tests\Feature\Mocks\NotAMiddleware; +use Tests\Feature\Operations\NameCheckingMiddleware; +use Tests\Feature\Operations\PrefixNameMiddleware; function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { @@ -213,3 +219,99 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError ->and($body)->toContain('"empty":{}') ->and($body)->not->toContain('"empty":['); }); + +test('a configured middleware receives its config, identically cached and uncached', function () { + $result = executeOperation('configured.greet', ['name' => 'Ada']); + + expect($result)->toBeInstanceOf(RpcSuccess::class) + ->and($result->data)->toEqual((object) ['message' => 'Hello Dr. Ada']); +}); + +test('the same middleware without config runs with its constructor defaults', function () { + $result = executeOperation('configured.greetPlain', ['name' => 'Ada']); + + expect($result)->toBeInstanceOf(RpcSuccess::class) + ->and($result->data)->toEqual((object) ['message' => 'Hello Ada']); +}); + +test('configure returns a clone, so a container-shared middleware instance stays pristine', function () { + $shared = new PrefixNameMiddleware(); + $server = new Server( + EagerlyLoadedOperationRegistry::eagerlyDiscover(__DIR__.'/Operations', keyGenerator: new PlainlyExposedKeyGenerator()), + adapter: new readonly class ($shared) implements ServerAdapter { + public function __construct(private PrefixNameMiddleware $shared) + { + } + + public function createMiddleware(string $className): MiddlewareContract + { + return $this->shared; + } + + public function createController(string $className): object + { + return new $className(); + } + }, + ); + + $result = $server->command('configured.greet', ['name' => 'Ada'], null, new NullClient()); + + expect($result)->toBeInstanceOf(RpcSuccess::class) + ->and($result->data)->toEqual((object) ['message' => 'Hello Dr. Ada']) + ->and($shared->prefix)->toBe(''); +}); + +test('a mutating configure() cannot pollute a container-shared instance - the server clones first', function () { + $shared = new MutatingPrefixMiddleware(); + $server = new Server( + EagerlyLoadedOperationRegistry::eagerlyDiscover(__DIR__.'/Operations', keyGenerator: new PlainlyExposedKeyGenerator()), + adapter: new readonly class ($shared) implements ServerAdapter { + public function __construct(private MutatingPrefixMiddleware $shared) + { + } + + public function createMiddleware(string $className): MiddlewareContract + { + return $this->shared; + } + + public function createController(string $className): object + { + return new $className(); + } + }, + ); + + $result = $server->command('configured.greet', ['name' => 'Ada'], null, new NullClient()); + + expect($result)->toBeInstanceOf(RpcSuccess::class) + ->and($result->data)->toEqual((object) ['message' => 'Hello Dr. Ada']) + ->and($shared->prefix)->toBe(''); +}); + +test('config with an adapter-substituted instance that is not configurable yields an RpcError', function () { + // Discovery approved the declared class, but the adapter owns instantiation and may hand out + // a substitute or decorator - the instance is what has to be configurable. + $server = new Server( + EagerlyLoadedOperationRegistry::eagerlyDiscover(__DIR__.'/Operations', keyGenerator: new PlainlyExposedKeyGenerator()), + adapter: new readonly class () implements ServerAdapter { + public function createMiddleware(string $className): MiddlewareContract + { + return new NameCheckingMiddleware(); + } + + public function createController(string $className): object + { + return new $className(); + } + }, + ); + + $result = $server->command('configured.greet', ['name' => 'Ada'], null, new NullClient()); + + expect($result)->toBeInstanceOf(RpcError::class) + ->and($result->type)->toBe(ErrorType::INTERNAL_ERROR) + ->and($result->cause)->toBeInstanceOf(InvalidMiddlewareException::class) + ->and($result->cause->getMessage())->toContain(NameCheckingMiddleware::class); +}); diff --git a/tests/Unit/CodeGen/ErrorTypescriptTest.php b/tests/Unit/CodeGen/ErrorTypescriptTest.php index e989b4d..d5f3446 100644 --- a/tests/Unit/CodeGen/ErrorTypescriptTest.php +++ b/tests/Unit/CodeGen/ErrorTypescriptTest.php @@ -4,6 +4,7 @@ use Le0daniel\PhpTsBindings\CodeGen\Utils\ErrorTypescript; use Le0daniel\PhpTsBindings\Server\Data\Definition; +use Le0daniel\PhpTsBindings\Server\Data\MiddlewareDefinition; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Tests\Mocks\Errors\ErrorOperations; use Tests\Mocks\Errors\RenamingMiddleware; @@ -21,7 +22,7 @@ function typescriptDefinition(string $methodName = 'declaresThrows', array $midd 'test', 'errors', // @phpstan-ignore-next-line -- tests intentionally pass unresolvable class names. - $middleware, + array_map(static fn (string $className): MiddlewareDefinition => new MiddlewareDefinition($className), $middleware), ); } diff --git a/tests/Unit/Server/Errors/ThrowAttributeResolverTest.php b/tests/Unit/Server/Errors/ThrowAttributeResolverTest.php index f4daef9..1ef1dcf 100644 --- a/tests/Unit/Server/Errors/ThrowAttributeResolverTest.php +++ b/tests/Unit/Server/Errors/ThrowAttributeResolverTest.php @@ -4,6 +4,7 @@ use Le0daniel\PhpTsBindings\Server\Data\Definition; use Le0daniel\PhpTsBindings\Server\Data\ErrorType; +use Le0daniel\PhpTsBindings\Server\Data\MiddlewareDefinition; use Le0daniel\PhpTsBindings\Server\Data\OperationType; use Le0daniel\PhpTsBindings\Server\Errors\ThrowAttributeResolver; use Tests\Mocks\Errors\RecordMissingException; @@ -39,7 +40,7 @@ function domainErrorNamesFor(string $methodName, array $middleware = []): array 'test', 'errors', // @phpstan-ignore-next-line -- tests intentionally pass classes that only carry a handle method. - $middleware, + array_map(static fn (string $className): MiddlewareDefinition => new MiddlewareDefinition($className), $middleware), )); } diff --git a/tests/Unit/Server/MiddlewareDefinitionTest.php b/tests/Unit/Server/MiddlewareDefinitionTest.php new file mode 100644 index 0000000..889fe60 --- /dev/null +++ b/tests/Unit/Server/MiddlewareDefinitionTest.php @@ -0,0 +1,27 @@ + 'Dr. ', 'enabled' => true, 'limit' => 3]); + + $rebuilt = eval("return {$definition->exportPhpCode()};"); + + expect($rebuilt)->toBeInstanceOf(MiddlewareDefinition::class) + ->and($rebuilt->middleware)->toBe(PrefixNameMiddleware::class) + ->and($rebuilt->config)->toBe(['prefix' => 'Dr. ', 'enabled' => true, 'limit' => 3]); +}); + +test('middleware definition without config exports without a config argument', function () { + $definition = new MiddlewareDefinition(PrefixNameMiddleware::class); + $exported = $definition->exportPhpCode(); + $rebuilt = eval("return {$exported};"); + + expect($exported)->toBe('new \Le0daniel\PhpTsBindings\Server\Data\MiddlewareDefinition('.var_export(PrefixNameMiddleware::class, true).')') + ->and($rebuilt->config)->toBe([]); +}); diff --git a/tests/Unit/Server/OperationDiscoveryTest.php b/tests/Unit/Server/OperationDiscoveryTest.php index 8d22a49..8ed1ac2 100644 --- a/tests/Unit/Server/OperationDiscoveryTest.php +++ b/tests/Unit/Server/OperationDiscoveryTest.php @@ -8,10 +8,12 @@ use Le0daniel\PhpTsBindings\Contracts\Attributes\Middleware; use Le0daniel\PhpTsBindings\Contracts\Client; use Le0daniel\PhpTsBindings\Executor\Exceptions\SchemaException; +use Le0daniel\PhpTsBindings\Server\Data\Exceptions\InvalidMiddlewareException; use Le0daniel\PhpTsBindings\Server\Operations\OperationDiscovery; use ReflectionClass; use Tests\Feature\Mocks\GloballyThrowingMiddleware; use Tests\Feature\Operations\NameCheckingMiddleware; +use Tests\Feature\Operations\PrefixNameMiddleware; function discover(object|string $class): OperationDiscovery { @@ -108,6 +110,62 @@ public function run(array $input): array } } +final class ConfiguredMiddlewareOperation +{ + /** + * @param array{name: string} $input + * @return array{name: string} + */ + #[Command('configured')] + #[Middleware(PrefixNameMiddleware::class, config: ['prefix' => 'Dr. '])] + public function run(array $input): array + { + return $input; + } +} + +final class NotConfigurableOperation +{ + /** + * @param array{name: string} $input + * @return array{name: string} + */ + #[Command('configured')] + #[Middleware(NameCheckingMiddleware::class, config: ['prefix' => 'Dr. '])] + public function run(array $input): array + { + return $input; + } +} + +final class ListConfigOperation +{ + /** + * @param array{name: string} $input + * @return array{name: string} + */ + #[Command('configured')] + #[Middleware(PrefixNameMiddleware::class, config: ['zero-indexed'])] + public function run(array $input): array + { + return $input; + } +} + +final class NestedConfigOperation +{ + /** + * @param array{name: string} $input + * @return array{name: string} + */ + #[Command('configured')] + #[Middleware(PrefixNameMiddleware::class, config: ['options' => ['nested' => true]])] + public function run(array $input): array + { + return $input; + } +} + test('a handler may declare any prefix of (input, context, client)', function () { expect(discover(ValidPrefixes::class)->operations)->toHaveCount(3); }); @@ -132,8 +190,32 @@ public function run(array $input): array // The order is what ContextualPipeline nests them in, so class level wraps method level. $definition = discover(StackedMiddleware::class)->operations |> array_values(...); - expect($definition[0]->middleware)->toBe([ + expect($definition[0]->middlewareClassNames())->toBe([ GloballyThrowingMiddleware::class, NameCheckingMiddleware::class, ]); }); + +test('middleware config is captured on the definition', function () { + $definitions = discover(ConfiguredMiddlewareOperation::class)->operations |> array_values(...); + + expect($definitions[0]->middleware)->toHaveCount(1) + ->and($definitions[0]->middleware[0]->middleware)->toBe(PrefixNameMiddleware::class) + ->and($definitions[0]->middleware[0]->config)->toBe(['prefix' => 'Dr. ']) + ->and($definitions[0]->middlewareClassNames())->toBe([PrefixNameMiddleware::class]); +}); + +test('config on a middleware that does not implement ConfigurableMiddleware is rejected', function () { + expect(fn () => discover(NotConfigurableOperation::class)) + ->toThrow(InvalidMiddlewareException::class, 'ConfigurableMiddleware'); +}); + +test('config with non-string keys is rejected at discovery', function () { + expect(fn () => discover(ListConfigOperation::class)) + ->toThrow(InvalidMiddlewareException::class, 'array'); +}); + +test('config with non-scalar values is rejected at discovery', function () { + expect(fn () => discover(NestedConfigOperation::class)) + ->toThrow(InvalidMiddlewareException::class, 'array'); +}); From ca40018637592d4ebd67245659151fef3e352ae4 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 14 Aug 2026 13:50:21 +0200 Subject: [PATCH 100/101] Refactor client execution flow to centralize response handling - Updated `executeOperation` to handle all response validation, errors, and hooks, ensuring consistent error handling across transports. - Simplified `OperationClient` interface to return raw status and parsed JSON, delegating envelope validation to `executeOperation`. - Removed client-bound hooks, introducing global hooks via new `registerHook` API for unified operation observation. - Enhanced client error reporting to return detailed envelopes instead of throwing, enabling easier downstream handling. - Updated tests and documentation to reflect changes in transport responsibilities and hook registration. --- README.md | 6 +- docs/client-directives.md | 2 +- docs/errors.md | 18 +- docs/typescript-client.md | 32 ++- .../EmitOperationClientBindings.php | 190 ++++++++++-------- .../EmitOperationClientBindingsTest.php | 132 ++++++++---- .../TypescriptServerCodeGeneratorTest.php | 3 +- .../ts-output/generated/lib/DefaultClient.ts | 85 ++------ .../generated/lib/OperationClient.ts | 18 +- tests/ts-output/generated/lib/bindings.ts | 82 +++++++- tests/ts-output/src/usage.ts | 86 +++++++- 11 files changed, 414 insertions(+), 240 deletions(-) diff --git a/README.md b/README.md index fc58b3f..dc399e3 100644 --- a/README.md +++ b/README.md @@ -290,8 +290,8 @@ toasts, redirects, cache invalidations. The interface is closed — `toast()`, t `error()` / `warning()` / `alert()` / `info()` shorthands, `redirect()` and `invalidate()`; there is no arbitrary-directive method. And the library ships the channel, not the behavior: nothing pops up until *you* implement the hooks on the frontend — -[`registerHook()`](docs/typescript-client.md#wiring-up-the-transport) on the generated client sees -every envelope, and `containsOperationSpaPayload()` narrows the deliberately-`unknown` `__client` +[`registerHook()`](docs/typescript-client.md#wiring-up-the-transport) from the generated bindings +sees every envelope, and `containsOperationSpaPayload()` narrows the deliberately-`unknown` `__client` into typed toasts, a redirect and invalidation keys. What a toast looks like, or what an invalidation invalidates, is your frontend's decision. **[→ Client directives](docs/client-directives.md)** @@ -393,7 +393,7 @@ operation handler or a middleware's `handle()` — decides the category, and onl declared nothing do the configured category lists apply. Everything unrecognised is a 500. A client has one more failure available to it, and no server sends it: `CLIENT_ERROR`, code 0, -minted by the generated client for the request that never got a real answer. The client never +minted by the generated bindings for the request that never got a real answer. The client never trusts the HTTP status line — only a body carrying the envelope counts as the server's answer, so a proxy's error page or a CSRF middleware's 419 becomes `CLIENT_ERROR`, carrying the cause and the raw response. [→ The client error](docs/errors.md#the-client-error) diff --git a/docs/client-directives.md b/docs/client-directives.md index 457bf5d..e138e14 100644 --- a/docs/client-directives.md +++ b/docs/client-directives.md @@ -69,7 +69,7 @@ from [the error branch](errors.md) the generated union already gives you. `RpcSuccess::jsonSerialize()` writes it, so `lib/types.ts` says it may be there. The *shape* is not: `Client` is an extension point, and your own implementation may define an entirely different set of directives under a different schema, so neither `lib/types.ts` nor the transport interface commits to -one it cannot know. The payload travels through `DefaultClient` untouched; what is withheld is only +one it cannot know. The payload travels through the transport untouched; what is withheld is only the claim about what it is. ## Reading it on the frontend diff --git a/docs/errors.md b/docs/errors.md index 1686ade..d030dd8 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -158,10 +158,11 @@ function isWorthRetrying(error: ClientError | InternalError): boolean { /* ... * **`CLIENT_ERROR` is the branch no server sends.** The request never got there — or something answered in the server's place. The network was down, the call was cancelled, a CSRF middleware answered 419 with its own JSON, a proxy answered 502 with an HTML page, a framework wrapped a 200 -around garbage. `DefaultClient` never consults the status line: every response goes through -`isValidEnvelop` from `lib/utils.ts`, and only a body that is the server's own envelope — `success`, -and on failure a known `type` with the `code` that type owns — is reported as the server's answer. -Anything else mints this branch, with code 0 and the exception itself under `cause`: +around garbage. The generated `executeOperation` never consults the status line: every body — +whatever transport produced it — goes through `isValidEnvelop` from `lib/utils.ts`, and only a body +that is the server's own envelope — `success`, and on failure a known `type` with the `code` that +type owns — is reported as the server's answer. Anything else mints this branch, with code 0 and the +exception itself under `cause`: ```typescript const result = await lock({id}); @@ -182,9 +183,12 @@ The cause is carried rather than summarised, which matters for cancellation: `th rethrows an `AbortError` as the `DOMException` it was, so a Tanstack refetch aborting its predecessor is not reported as a failed query. A re-wrapped copy would no longer be that exception. -Every client can produce it, and no signature has to say so: the branch is part of every `Failure`, -so `OperationClient.execute` returning `Promise>` already includes it whatever -the operation exposed. There is nothing for an implementation to remember to add. +Every transport can produce it, and no signature has to say so: a transport resolves to the raw +response — `{status, jsonBody}` — or throws, and the generated `executeOperation` mints this branch +from either. There is nothing for an implementation to remember to add, and nothing it can forget: +validation and minting cannot be bypassed. The branch also covers the client that was never wired +up — `executeOperation` resolves rather than rejects, so a missing `setClient()` answers it with +`cause: Error('No client set')`. Reached through `OperationException`, the envelope is `e.cause` and the original exception is `e.cause.cause`; `e.isClientError()` is the shorter way to ask — a type guard, so past it `e.cause` diff --git a/docs/typescript-client.md b/docs/typescript-client.md index 7555745..1de907d 100644 --- a/docs/typescript-client.md +++ b/docs/typescript-client.md @@ -21,7 +21,7 @@ it on disk. Nothing is published to npm; the code lives in your repo. lib/OperationClient.ts the transport interface lib/DefaultClient.ts a fetch implementation of it lib/OperationException.ts - lib/bindings.ts createDefaultClient, setClient, executeOperation + lib/bindings.ts createDefaultClient, setClient, registerHook, executeOperation lib/utils.ts queryKey, throwOnFailure lib/client-operations-spa.ts the OperationSPAClient payload and its guard .ts one module per namespace, one function per operation @@ -80,8 +80,10 @@ setClient(createDefaultClient()); setClient(createDefaultClient(fetch, {baseUrl: 'https://api.example.com', timeoutMs: 5_000})); ``` -`setClient()` sets one module-global client, so it has to run before the first call — otherwise you -get `Error('No client set')` at whichever call site happened to be first. +`setClient()` sets one module-global client, so it has to run before the first call — otherwise +every call resolves to [`CLIENT_ERROR`](errors.md#the-client-error) with +`cause: Error('No client set')`. Nothing throws: the generated `executeOperation` resolves, never +rejects, so a missing client surfaces on the envelope like any other client-side failure. Every generated function takes an optional second argument: @@ -91,15 +93,25 @@ export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client `DefaultClient` sends queries as GET with each input value JSON-encoded into a query parameter, and commands as POST with a JSON body. `signal` and `timeoutMs` are joined into one `AbortSignal`, and a -per-call `timeoutMs` overrides the client's default (10s unless you set one). `registerHook(hook)` -runs a callback on every response and returns a function that unregisters it. Swap the whole -transport by implementing `OperationClient` — `setClient()` and the per-call `options.client` both -take one. +per-call `timeoutMs` overrides the client's default (10s unless you set one). + +Hooks are first-party: `registerHook(hook)` from `lib/bindings.ts` runs a callback on every +operation's envelope — whichever client served it, the module-global one or a per-call +`options.client` — and returns a function that unregisters it. A hook receives the result and the +operation it belongs to, `(result, {type, key}) => ...`, and a hook that throws is logged, never +failing the operation. + +Swap the whole transport by implementing `OperationClient` — `setClient()` and the per-call +`options.client` both take one. A transport only moves bytes: `execute` resolves to +`{status, jsonBody}`, the raw status line and the parsed body, and it is allowed to simply throw — +a network failure, an abort, a bad timeout. Envelope validation, `CLIENT_ERROR` minting, and hooks +all live in `executeOperation`, so a custom transport gets them for free and cannot bypass them. The status line is never consulted. Anything between the browser and the handler — a CSRF -middleware, a throttler, a proxy's error page — can write both a status and a body, so every -response goes through `isValidEnvelop` from `lib/utils.ts` instead: a valid envelope (success or -failure) is returned exactly as parsed, whatever the status said, and anything else becomes +middleware, a throttler, a proxy's error page — can write both a status and a body, so +`executeOperation` gates every body through `isValidEnvelop` from `lib/utils.ts` instead, whatever +transport produced it: a valid envelope (success or failure) is returned exactly as parsed, +whatever the status said, and anything else becomes [`CLIENT_ERROR`](errors.md#the-client-error) with the raw `response` (`httpStatusCode`, and `jsonResponse` when the body parsed as JSON) attached. The guard is exported, so a payload from anywhere else — SSR state, a cache — can be believed or refused the same way before being read as diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 7a7988b..d45bd48 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -121,37 +121,24 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; /** - * Moves a request and resolves to the envelope. A server may put more next to the data, and it - * travels through untouched — describing it here would tie every transport to one Client - * implementation's schema. Reach for the guard the implementation ships instead. - * - * The only thing an operation adds to the error catalogue is which domain errors it exposed, so that - * is all this takes. ClientError needs no mention: a request can fail before it reaches the server, - * and Failure carries that branch whatever the operation declared. + * Moves a request and resolves to what came back: the status line and the body as parsed JSON, with + * no claim about either. Everything that interprets a response — the envelope guard, the client + * branch, the hooks — lives in executeOperation, once, whatever transport is plugged in. An + * implementation only moves bytes, and it is allowed to throw (a network failure, an abort): + * executeOperation turns that into the client branch too. */ export interface OperationClient { - execute( + execute( type: "command"|"query", key: string, input: unknown, options?: OperationOptions - ): Promise>; + ): Promise<{status: number; jsonBody: unknown}>; } -TypeScript, [ - $this->types->importFromTypes(types: ['Result']), - ]), +TypeScript), self::DEFAULT_CLIENT_FILE => new TypescriptFile(<<<'TypeScript' -/** - * A hook sees the envelope of any operation, so it is typed against the widest domain union rather - * than any one operation's. Every category is still there to discriminate on — the catalogue is the - * server's, not the operation's. - */ -export type Hook = (result: Result) => Promise | void; - export class DefaultClient implements OperationClient { - private hooks: Hook[] = []; - constructor( private readonly fetcher: typeof window.fetch, private readonly options: { @@ -183,17 +170,12 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi }).join('&'); } - private async callHooks>(result: T) { - try { - await Promise.all(this.hooks.map(hook => hook(result))); - return result; - } catch (error) { - console.error('Error while calling hooks', error); - return result; - } - } - - async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { + /** + * One honest fetch: no guard, no catch, no observation. Whatever it throws — an abort, a + * network failure, a bad timeout — is executeOperation's to catch, and whatever comes back is + * handed over exactly as received, the status riding along unconsulted next to the parsed body. + */ + async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise<{status: number; jsonBody: unknown}> { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{key}', key)}`; @@ -214,61 +196,24 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi headers['Content-Type'] = 'application/json'; } - try { - const queryParams = type === 'query' && input && typeof input === 'object' - ? `?${this.createJsonEncodedQueryParams(input)}` - : ''; - - const response = await this.fetcher(`${fullPath}${queryParams}`, { - method: type === 'query' ? 'GET' : 'POST', - signal, - headers, - body: type === 'command' ? JSON.stringify(input) : undefined, - }); - - // The status line is never consulted: anything between the browser and the handler can - // write one, so only the body can prove the server answered. A valid envelope is - // returned exactly as parsed, success or failure — whatever the server put next to it - // (a client's directives, say) rides along untouched. - const json: unknown = await response.json().catch(() => undefined); - if (isValidEnvelop(json)) { - return await this.callHooks(json as Result); - } - - return await this.callHooks({ - success: false, - code: 0, - type: 'CLIENT_ERROR', - cause: new Error(`Invalid response envelope (HTTP status ${response.status})`), - response: json === undefined - ? {httpStatusCode: response.status} - : {httpStatusCode: response.status, jsonResponse: json}, - } satisfies Failure); - } catch (e: unknown) { - // Anything thrown between here and the response being read: the request never completed, - // so there is no server error to report and the cause is the answer. It is carried as - // itself rather than summarised — throwOnFailure rethrows an AbortError exactly, and a - // re-wrapped copy would no longer be that DOMException. - // - // No type argument: this branch is in every Failure, whatever the operation exposed. - const cause = e instanceof Error ? e : new Error(String(e)); - const envelop = {success: false, code: 0, type: 'CLIENT_ERROR', cause} satisfies Failure; - return await this.callHooks(envelop); - } - } + const queryParams = type === 'query' && input && typeof input === 'object' + ? `?${this.createJsonEncodedQueryParams(input)}` + : ''; - registerHook(hook: Hook): () => void { - this.hooks.push(hook); - return () => { - this.hooks = this.hooks.filter(h => h !== hook); - } + const response = await this.fetcher(`${fullPath}${queryParams}`, { + method: type === 'query' ? 'GET' : 'POST', + signal, + headers, + body: type === 'command' ? JSON.stringify(input) : undefined, + }); + + const jsonBody: unknown = await response.json(); + return {status: response.status, jsonBody}; } } TypeScript, [ $this->importFromOperationClient(types: ['OperationClient', 'OperationOptions']), - $this->types->importFromTypes(types: ['Failure', 'Result']), - $this->utils->importFromUtils(values: ['isValidEnvelop']), ]), self::OPERATION_EXCEPTION_FILE => new TypescriptFile(<<<'TypeScript' /** @@ -305,7 +250,24 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi $this->types->importFromTypes(types: ['ClientError', 'Failure']), ]), self::BINDINGS_FILE => new TypescriptFile(<<, operation: {type: 'query'|'command'; key: string}) => Promise | void; + +let hooks: Hook[] = []; + +export function registerHook(hook: Hook): () => void { + hooks.push(hook); + return () => { + hooks = hooks.filter(h => h !== hook); + }; +} export function createDefaultClient( fetcher?: typeof window.fetch, @@ -322,23 +284,73 @@ public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegi client = operationClient; } -export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise> { - if (options?.client) { - return await options.client.execute(type, key, input, options); +/** + * A hook that throws never fails the operation: the envelope is the answer, and observing it must + * not change it. + */ +async function callHooks>(result: T, operation: {type: 'query'|'command'; key: string}): Promise { + try { + await Promise.all(hooks.map(hook => hook(result, operation))); + } catch (error) { + console.error('Error while calling hooks', error); } - if (client) { - return await client.execute(type, key, input, options); + return result; +} + +/** + * No type argument: this branch is in every Failure, whatever the operation exposed. + */ +function mintClientError(error: Error, response?: {httpStatusCode: number; jsonResponse?: unknown}): Failure { + return response === undefined + ? {success: false, code: 0, type: 'CLIENT_ERROR', cause: error} + : {success: false, code: 0, type: 'CLIENT_ERROR', cause: error, response}; +} + +/** + * Resolves, never rejects: every outcome — a valid envelope, a body that is not the envelope, a + * transport that threw, no client at all — comes back as an envelope, and the hooks see every one + * of them before the caller does. + * + * The status line is never consulted: anything between the browser and the handler can write one, + * so only a body that is the server's own envelope counts as the server's answer, and a valid one + * is returned exactly as parsed — whatever the server put next to it rides along untouched. + * Whatever a transport threw is carried as itself rather than summarised: an AbortError has to stay + * the DOMException it was for the code that rethrows exactly that one. + */ +export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions): Promise> { + const operation = {type, key}; + const activeClient = options?.client ?? client; + + if (!activeClient) { + return await callHooks(mintClientError(new Error('No client set')), operation); } - throw new Error('No client set'); + try { + const {status, jsonBody} = await activeClient.execute(type, key, input, options); + if (isValidEnvelop(jsonBody)) { + // Narrowed only to the widest envelope: which data rides on success is the operation's + // claim, asserted here once for every call site. + return await callHooks(jsonBody as Result, operation); + } + + return await callHooks(mintClientError( + new Error(`Invalid response envelope (HTTP status \${status})`), + jsonBody === undefined ? {httpStatusCode: status} : {httpStatusCode: status, jsonResponse: jsonBody}, + ), operation); + } catch (e: unknown) { + const cause = e instanceof Error ? e : new Error(String(e)); + return await callHooks(mintClientError(cause), operation); + } } TypeScript, [ - $this->types->importFromTypes(types: ['Result']), + $this->types->importFromTypes(types: ['Failure', 'Result']), $this->importFromOperationClient(types: ['OperationClient', 'OperationOptions']), // Constructed, not just annotated: a type only import would leave // `new DefaultClient(...)` referencing nothing at runtime. $this->importFromDefaultClient(values: ['DefaultClient']), + // The guard every body gates through, whatever transport produced it. + $this->utils->importFromUtils(values: ['isValidEnvelop']), ]), ]; } diff --git a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php index 3206208..1be1191 100644 --- a/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php +++ b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php @@ -49,80 +49,102 @@ function bindingFiles(): array expect($imports)->toBe($expected); })->with([ - 'OperationClient' => ['OperationClient', [ - './lib/types' => ['values' => [], 'types' => ['Result']], - ]], + // A transport resolves to the raw response and never names the envelope, so there is nothing + // for it to reach for. + 'OperationClient' => ['OperationClient', []], 'DefaultClient' => ['DefaultClient', [ './lib/OperationClient' => ['values' => [], 'types' => ['OperationClient', 'OperationOptions']], - './lib/types' => ['values' => [], 'types' => ['Failure', 'Result']], - './lib/utils' => ['values' => ['isValidEnvelop'], 'types' => []], ]], 'OperationException' => ['OperationException', [ './lib/types' => ['values' => [], 'types' => ['ClientError', 'Failure']], ]], // DefaultClient is constructed, so it is a value import; a type only import would leave - // `new DefaultClient(...)` referencing nothing at runtime. + // `new DefaultClient(...)` referencing nothing at runtime. The guard is a value too: it runs + // against every body, whatever transport produced it. 'bindings' => ['bindings', [ './lib/DefaultClient' => ['values' => ['DefaultClient'], 'types' => []], './lib/OperationClient' => ['values' => [], 'types' => ['OperationClient', 'OperationOptions']], - './lib/types' => ['values' => [], 'types' => ['Result']], + './lib/types' => ['values' => [], 'types' => ['Failure', 'Result']], + './lib/utils' => ['values' => ['isValidEnvelop'], 'types' => []], ]], ]); /** - * A request can fail before it ever reaches the server, so the transport can always hand back the - * client envelope — and it needs no mention in any of these signatures, because the branch is part of - * every Failure whatever the operation exposed. What the caller chooses is only which domain names - * the 400 branch carries, so that is all the transport takes. + * A transport moves bytes: it resolves to the status line and the parsed body, with no claim about + * either. Only executeOperation speaks in envelopes — it gates the body through the guard and mints + * the client branch, so what an operation exposed never concerns any transport. */ -test('the transport takes the exposed names, not a failure shape', function (string $file, string $signature) { +test('the transport resolves to the raw response, only the binding speaks in envelopes', function (string $file, string $signature) { expect(bindingFiles()[$file]->toString())->toContain($signature) ->and(bindingFiles()[$file]->toString())->not->toContain('{code: number}'); })->with([ - 'the interface' => ['OperationClient', 'execute('], - 'the implementation' => ['DefaultClient', 'options?: OperationOptions): Promise>'], - 'the binding' => ['bindings', 'options?: OperationOptions & {client?: OperationClient}): Promise>'], + 'the interface' => ['OperationClient', '): Promise<{status: number; jsonBody: unknown}>;'], + 'the implementation' => ['DefaultClient', 'options?: OperationOptions): Promise<{status: number; jsonBody: unknown}>'], + 'the binding' => ['bindings', 'options?: OperationOptions): Promise>'], ]); /** - * Nothing narrows the catalogue down to one branch here, so nothing has to name one: the exception - * and the hook see whatever the server can produce. + * No type parameters and no envelope: nothing an implementation returns is trusted anyway — the + * binding validates the body whoever produced it — so the interface promises nothing it would have + * to take back. */ -test('a hook and the exception are typed against the whole catalogue', function () { +test('the transport takes no type parameters and never names the envelope', function () { + expect(bindingFiles()['OperationClient']->toString()) + ->toContain('execute(') + ->not->toContain('execute<') + ->not->toContain('Result'); +}); + +/** + * The default transport is one honest fetch: no guard, no catch, no observation. Whatever it throws + * is the binding's to catch, and whatever comes back is handed over exactly as received — the + * status riding along unconsulted next to the parsed body. + */ +test('the default transport neither guards, catches, nor observes', function () { expect(bindingFiles()['DefaultClient']->toString()) - ->toContain('export type Hook = (result: Result) => Promise | void;') - ->toContain('private async callHooks>(result: T) {') - ->and(bindingFiles()['OperationException']->toString()) - ->toContain('export class OperationException extends Error {') - ->toContain('public readonly cause: Failure;') - ->toContain('public static is(e: unknown): e is OperationException {'); + ->toContain('const jsonBody: unknown = await response.json();') + ->toContain('return {status: response.status, jsonBody};') + ->not->toContain('isValidEnvelop') + ->not->toContain('try {') + ->not->toContain('CLIENT_ERROR') + ->not->toContain('Hook') + ->not->toContain('registerHook') + ->not->toContain('response.ok'); +}); + +/** + * Hooks are first party: they live in the bindings and see the envelope of every operation, + * whichever client — the module global or a per call options.client — served it. Typed against the + * widest domain union, because a hook observes any operation's envelope, not one operation's. + */ +test('hooks are registered on the bindings and typed against the whole catalogue', function () { + expect(bindingFiles()['bindings']->toString()) + ->toContain("export type Hook = (result: Result, operation: {type: 'query'|'command'; key: string}) => Promise | void;") + ->toContain('export function registerHook(hook: Hook): () => void {') + ->toContain("async function callHooks>(result: T, operation: {type: 'query'|'command'; key: string}): Promise {"); }); /** * Whatever went wrong is carried, not summarised: an AbortError has to arrive as the DOMException it * was, because throwOnFailure rethrows exactly that one and a re-wrapped copy would not be it. */ -test('a throw anywhere in the request becomes the client envelope, keeping the original as its cause', function () { - expect(bindingFiles()['DefaultClient']->toString()) +test('a throw anywhere below becomes the client envelope, keeping the original as its cause', function () { + expect(bindingFiles()['bindings']->toString()) ->toContain('const cause = e instanceof Error ? e : new Error(String(e));') - ->toContain("const envelop = {success: false, code: 0, type: 'CLIENT_ERROR', cause} satisfies Failure;") - ->toContain('return await this.callHooks(envelop);'); + ->toContain('return await callHooks(mintClientError(cause), operation);'); }); /** * The status line is never consulted: a CSRF middleware answering 419 with its own JSON, a proxy * answering 502 with an HTML page, or a framework writing a 200 around garbage all set whatever - * status they like. Only the body can prove the server answered, so every response — ok or not — - * goes through the envelope guard and is returned exactly as parsed when it passes. + * status they like. Only the body can prove the server answered, so every body — from whatever + * transport — goes through the envelope guard and is returned exactly as parsed when it passes. */ test('every response goes through the envelope guard, whatever the status line said', function () { - expect(bindingFiles()['DefaultClient']->toString()) - ->toContain('const json: unknown = await response.json().catch(() => undefined);') - ->toContain('if (isValidEnvelop(json)) {') - ->toContain('return await this.callHooks(json as Result);') - ->not->toContain('response.ok') - ->not->toContain('json?.code ?? response.status') - ->not->toContain("json?.type ?? 'INTERNAL_ERROR'"); + expect(bindingFiles()['bindings']->toString()) + ->toContain('if (isValidEnvelop(jsonBody)) {') + ->toContain('return await callHooks(jsonBody as Result, operation);') + ->not->toContain('response.ok'); }); /** @@ -130,10 +152,38 @@ function bindingFiles(): array * only when there was one to parse — an absent key rather than an undefined value. */ test('a response that is not the envelope becomes the client branch carrying what was received', function () { - expect(bindingFiles()['DefaultClient']->toString()) - ->toContain('cause: new Error(`Invalid response envelope (HTTP status ${response.status})`),') - ->toContain('? {httpStatusCode: response.status}') - ->toContain(': {httpStatusCode: response.status, jsonResponse: json},'); + expect(bindingFiles()['bindings']->toString()) + ->toContain('new Error(`Invalid response envelope (HTTP status ${status})`),') + ->toContain('? {httpStatusCode: status}') + ->toContain(': {httpStatusCode: status, jsonResponse: jsonBody},'); +}); + +/** + * executeOperation resolves, never rejects: no client at all, a transport that threw, a body that is + * not the envelope — each becomes the client branch of the envelope, and the hooks see every one of + * them, the valid answer included. One resolution site is what makes that a guarantee rather than a + * convention each transport reimplements. + */ +test('executeOperation never throws, and hooks see every exit path', function () { + $bindings = bindingFiles()['bindings']->toString(); + + expect($bindings) + ->toContain('const activeClient = options?.client ?? client;') + ->toContain("return await callHooks(mintClientError(new Error('No client set')), operation);") + ->not->toContain("throw new Error('No client set')") + ->not->toContain('& {client?: OperationClient}') + ->and(substr_count($bindings, 'return await callHooks('))->toBe(4); +}); + +/** + * Nothing narrows the catalogue down to one branch here, so nothing has to name one: the exception + * sees whatever the server can produce. + */ +test('the exception is typed against the whole catalogue', function () { + expect(bindingFiles()['OperationException']->toString()) + ->toContain('export class OperationException extends Error {') + ->toContain('public readonly cause: Failure;') + ->toContain('public static is(e: unknown): e is OperationException {'); }); /** diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php index 9aec8ab..00cc39b 100644 --- a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -209,7 +209,8 @@ function generateFor(array $classes, ?array $generators = null): array expect($files['lib/bindings.ts']->toString())->toStartWith(TypescriptFile::MARKER."\n\n".<<<'TypeScript' import {DefaultClient} from './DefaultClient'; import type {OperationClient, OperationOptions} from './OperationClient'; - import type {Result} from './types'; + import type {Failure, Result} from './types'; + import {isValidEnvelop} from './utils'; TypeScript); diff --git a/tests/ts-output/generated/lib/DefaultClient.ts b/tests/ts-output/generated/lib/DefaultClient.ts index 661d522..02fb442 100644 --- a/tests/ts-output/generated/lib/DefaultClient.ts +++ b/tests/ts-output/generated/lib/DefaultClient.ts @@ -1,20 +1,9 @@ // generated by: php-ts-bindings import type {OperationClient, OperationOptions} from './OperationClient'; -import type {Failure, Result} from './types'; -import {isValidEnvelop} from './utils'; - -/** - * A hook sees the envelope of any operation, so it is typed against the widest domain union rather - * than any one operation's. Every category is still there to discriminate on — the catalogue is the - * server's, not the operation's. - */ -export type Hook = (result: Result) => Promise | void; export class DefaultClient implements OperationClient { - private hooks: Hook[] = []; - constructor( private readonly fetcher: typeof window.fetch, private readonly options: { @@ -46,17 +35,12 @@ export class DefaultClient implements OperationClient { }).join('&'); } - private async callHooks>(result: T) { - try { - await Promise.all(this.hooks.map(hook => hook(result))); - return result; - } catch (error) { - console.error('Error while calling hooks', error); - return result; - } - } - - async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise> { + /** + * One honest fetch: no guard, no catch, no observation. Whatever it throws — an abort, a + * network failure, a bad timeout — is executeOperation's to catch, and whatever comes back is + * handed over exactly as received, the status riding along unconsulted next to the parsed body. + */ + async execute(type: "command" | "query", key: string, input: unknown, options?: OperationOptions): Promise<{status: number; jsonBody: unknown}> { const route = this.options.paths[type].substring(0, 1) === '/' ? this.options.paths[type].substring(1) : this.options.paths[type]; const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{key}', key)}`; @@ -77,54 +61,19 @@ export class DefaultClient implements OperationClient { headers['Content-Type'] = 'application/json'; } - try { - const queryParams = type === 'query' && input && typeof input === 'object' - ? `?${this.createJsonEncodedQueryParams(input)}` - : ''; + const queryParams = type === 'query' && input && typeof input === 'object' + ? `?${this.createJsonEncodedQueryParams(input)}` + : ''; - const response = await this.fetcher(`${fullPath}${queryParams}`, { - method: type === 'query' ? 'GET' : 'POST', - signal, - headers, - body: type === 'command' ? JSON.stringify(input) : undefined, - }); + const response = await this.fetcher(`${fullPath}${queryParams}`, { + method: type === 'query' ? 'GET' : 'POST', + signal, + headers, + body: type === 'command' ? JSON.stringify(input) : undefined, + }); - // The status line is never consulted: anything between the browser and the handler can - // write one, so only the body can prove the server answered. A valid envelope is - // returned exactly as parsed, success or failure — whatever the server put next to it - // (a client's directives, say) rides along untouched. - const json: unknown = await response.json().catch(() => undefined); - if (isValidEnvelop(json)) { - return await this.callHooks(json as Result); - } - - return await this.callHooks({ - success: false, - code: 0, - type: 'CLIENT_ERROR', - cause: new Error(`Invalid response envelope (HTTP status ${response.status})`), - response: json === undefined - ? {httpStatusCode: response.status} - : {httpStatusCode: response.status, jsonResponse: json}, - } satisfies Failure); - } catch (e: unknown) { - // Anything thrown between here and the response being read: the request never completed, - // so there is no server error to report and the cause is the answer. It is carried as - // itself rather than summarised — throwOnFailure rethrows an AbortError exactly, and a - // re-wrapped copy would no longer be that DOMException. - // - // No type argument: this branch is in every Failure, whatever the operation exposed. - const cause = e instanceof Error ? e : new Error(String(e)); - const envelop = {success: false, code: 0, type: 'CLIENT_ERROR', cause} satisfies Failure; - return await this.callHooks(envelop); - } - } - - registerHook(hook: Hook): () => void { - this.hooks.push(hook); - return () => { - this.hooks = this.hooks.filter(h => h !== hook); - } + const jsonBody: unknown = await response.json(); + return {status: response.status, jsonBody}; } } diff --git a/tests/ts-output/generated/lib/OperationClient.ts b/tests/ts-output/generated/lib/OperationClient.ts index 58b81d9..1f60227 100644 --- a/tests/ts-output/generated/lib/OperationClient.ts +++ b/tests/ts-output/generated/lib/OperationClient.ts @@ -1,23 +1,19 @@ // generated by: php-ts-bindings -import type {Result} from './types'; - export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; /** - * Moves a request and resolves to the envelope. A server may put more next to the data, and it - * travels through untouched — describing it here would tie every transport to one Client - * implementation's schema. Reach for the guard the implementation ships instead. - * - * The only thing an operation adds to the error catalogue is which domain errors it exposed, so that - * is all this takes. ClientError needs no mention: a request can fail before it reaches the server, - * and Failure carries that branch whatever the operation declared. + * Moves a request and resolves to what came back: the status line and the body as parsed JSON, with + * no claim about either. Everything that interprets a response — the envelope guard, the client + * branch, the hooks — lives in executeOperation, once, whatever transport is plugged in. An + * implementation only moves bytes, and it is allowed to throw (a network failure, an abort): + * executeOperation turns that into the client branch too. */ export interface OperationClient { - execute( + execute( type: "command"|"query", key: string, input: unknown, options?: OperationOptions - ): Promise>; + ): Promise<{status: number; jsonBody: unknown}>; } diff --git a/tests/ts-output/generated/lib/bindings.ts b/tests/ts-output/generated/lib/bindings.ts index b5637a8..67668ed 100644 --- a/tests/ts-output/generated/lib/bindings.ts +++ b/tests/ts-output/generated/lib/bindings.ts @@ -2,9 +2,27 @@ import {DefaultClient} from './DefaultClient'; import type {OperationClient, OperationOptions} from './OperationClient'; -import type {Result} from './types'; +import type {Failure, Result} from './types'; +import {isValidEnvelop} from './utils'; -let client: OperationClient|null; +let client: OperationClient|null = null; + +/** + * A hook sees the envelope of every operation, whichever client — the module global or a per call + * options.client — served it, so it is typed against the widest domain union rather than any one + * operation's. Every category is still there to discriminate on. The second argument names the + * operation the envelope belongs to. + */ +export type Hook = (result: Result, operation: {type: 'query'|'command'; key: string}) => Promise | void; + +let hooks: Hook[] = []; + +export function registerHook(hook: Hook): () => void { + hooks.push(hook); + return () => { + hooks = hooks.filter(h => h !== hook); + }; +} export function createDefaultClient( fetcher?: typeof window.fetch, @@ -21,14 +39,62 @@ export function setClient(operationClient: OperationClient|null): void { client = operationClient; } -export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions & {client?: OperationClient}): Promise> { - if (options?.client) { - return await options.client.execute(type, key, input, options); +/** + * A hook that throws never fails the operation: the envelope is the answer, and observing it must + * not change it. + */ +async function callHooks>(result: T, operation: {type: 'query'|'command'; key: string}): Promise { + try { + await Promise.all(hooks.map(hook => hook(result, operation))); + } catch (error) { + console.error('Error while calling hooks', error); } - if (client) { - return await client.execute(type, key, input, options); + return result; +} + +/** + * No type argument: this branch is in every Failure, whatever the operation exposed. + */ +function mintClientError(error: Error, response?: {httpStatusCode: number; jsonResponse?: unknown}): Failure { + return response === undefined + ? {success: false, code: 0, type: 'CLIENT_ERROR', cause: error} + : {success: false, code: 0, type: 'CLIENT_ERROR', cause: error, response}; +} + +/** + * Resolves, never rejects: every outcome — a valid envelope, a body that is not the envelope, a + * transport that threw, no client at all — comes back as an envelope, and the hooks see every one + * of them before the caller does. + * + * The status line is never consulted: anything between the browser and the handler can write one, + * so only a body that is the server's own envelope counts as the server's answer, and a valid one + * is returned exactly as parsed — whatever the server put next to it rides along untouched. + * Whatever a transport threw is carried as itself rather than summarised: an AbortError has to stay + * the DOMException it was for the code that rethrows exactly that one. + */ +export async function executeOperation(type: 'query'|'command', key: string, input: I, options?: OperationOptions): Promise> { + const operation = {type, key}; + const activeClient = options?.client ?? client; + + if (!activeClient) { + return await callHooks(mintClientError(new Error('No client set')), operation); } - throw new Error('No client set'); + try { + const {status, jsonBody} = await activeClient.execute(type, key, input, options); + if (isValidEnvelop(jsonBody)) { + // Narrowed only to the widest envelope: which data rides on success is the operation's + // claim, asserted here once for every call site. + return await callHooks(jsonBody as Result, operation); + } + + return await callHooks(mintClientError( + new Error(`Invalid response envelope (HTTP status ${status})`), + jsonBody === undefined ? {httpStatusCode: status} : {httpStatusCode: status, jsonResponse: jsonBody}, + ), operation); + } catch (e: unknown) { + const cause = e instanceof Error ? e : new Error(String(e)); + return await callHooks(mintClientError(cause), operation); + } } diff --git a/tests/ts-output/src/usage.ts b/tests/ts-output/src/usage.ts index 8be52c0..159437e 100644 --- a/tests/ts-output/src/usage.ts +++ b/tests/ts-output/src/usage.ts @@ -8,9 +8,11 @@ import {find, lock} from '../generated/accounts'; import type {ProductDomainErrors} from '../generated/catalog'; import {prepare, product, productQueryKey, productQueryOptions, restock, search, useProductQuery} from '../generated/catalog'; -import {createDefaultClient, setClient} from '../generated/lib/bindings'; +import type {Hook} from '../generated/lib/bindings'; +import {createDefaultClient, executeOperation, registerHook, setClient} from '../generated/lib/bindings'; import type {OperationsClientPayload} from '../generated/lib/client-operations-spa'; import {containsOperationSpaPayload} from '../generated/lib/client-operations-spa'; +import type {OperationClient} from '../generated/lib/OperationClient'; import {OperationException} from '../generated/lib/OperationException'; import type {Brand, ClientError, Failure, InternalError, Product} from '../generated/lib/types'; import type {TypeMap} from '../generated/lib/type-map'; @@ -240,6 +242,88 @@ export async function findAccount(signal: AbortSignal): Promise { await find({term: 'leo'}, {signal, timeoutMs: 2500}); } +/** + * Hooks are first party: registered on the bindings, not on any client, so one registration + * observes every operation whichever transport served it. The second argument names the operation + * the envelope belongs to. + */ +export function observeEveryOperation(): () => void { + const hook: Hook = (result, operation) => { + operation.type satisfies 'query' | 'command'; + const key: string = operation.key; + + if (!result.success && result.code === 401) { + console.warn('unauthenticated', key); + } + }; + + const unregister: () => void = registerHook(hook); + return unregister; +} + +/** + * A transport is a function of request to raw response: implementing one takes no envelope + * knowledge at all. The bindings gate whatever it returns, so a stub for a test is three lines — + * and it still goes through the same guard, minting, and hooks as the real one. + */ +export async function readProductThroughStub(): Promise { + const stub: OperationClient = { + async execute(type, key, input, options) { + console.debug(type, key, input, options?.timeoutMs); + return {status: 200, jsonBody: {success: true, data: null}}; + }, + }; + + const result = await product({id: productId}, {client: stub}); + return result.success ? result.data : null; +} + +// An unmigrated transport — one that still resolves to the envelope instead of the raw response — +// fails to compile rather than silently bypassing the gate. +// @ts-expect-error +export const envelopeReturningClient: OperationClient = {async execute() { return {success: true, data: null}; }}; + +/** + * DefaultClient is transport only: hooks live on the bindings, and execute resolves to the raw + * response rather than the envelope. + */ +export async function inspectTransportResponse(): Promise { + const transport = createDefaultClient(fetch); + + // The old surface is gone; an unmigrated registration fails to compile rather than silently + // observing nothing. + // @ts-expect-error + transport.registerHook; + + const raw = await transport.execute('query', 'catalog.product', {id: productId}); + const status: number = raw.status; + const body: unknown = raw.jsonBody; + + // The raw response is not the envelope: believing it takes the guard, not a property read. + // @ts-expect-error + console.debug(raw.success); + + console.debug(status, body); +} + +/** + * executeOperation resolves, never rejects: with no client set it answers the client branch, so a + * caller branches on the envelope instead of wrapping every call in try/catch. + */ +export async function executeDirectly(): Promise { + const stub: OperationClient = { + async execute() { + return {status: 204, jsonBody: undefined}; + }, + }; + + const result = await executeOperation('query', 'shapes.defaults', null, {client: stub, timeoutMs: 100}); + if (!result.success && result.code === 0) { + result.cause satisfies Error; + console.warn('not a server answer', result.response?.httpStatusCode); + } +} + /* The tanstack bindings: a key, options, and the hook built on top of them. */ export const cacheKey = productQueryKey({id: productId}); From 3464f8c90143515f82aa0f6b438c9e9962dac9b8 Mon Sep 17 00:00:00 2001 From: Leo Studer Date: Fri, 14 Aug 2026 14:06:33 +0200 Subject: [PATCH 101/101] Add `MetadataMiddleware` and integrate with `ShippingCommands` - Implemented `MetadataMiddleware` to enable metadata injection in RPC responses. - Updated `ShippingCommands` to utilize `MetadataMiddleware` with per-operation configuration. - Adjusted `RpcSuccess` to maintain metadata order in serialized output. - Enhanced integration tests to validate metadata appending for successful and error responses. - Fixed unit test in `RpcSuccessTest` to ensure metadata is correctly serialized. --- src/Server/Data/RpcSuccess.php | 2 +- .../CastingGenericsAndAliasesTest.php | 14 ++++----- .../Fixtures/MetadataMiddleware.php | 30 +++++++++++++++++++ .../Fixtures/Operations/ShippingCommands.php | 4 +++ tests/Unit/Server/Data/RpcSuccessTest.php | 6 ++-- 5 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 tests/Integration/Fixtures/MetadataMiddleware.php diff --git a/src/Server/Data/RpcSuccess.php b/src/Server/Data/RpcSuccess.php index bb2c65c..c50a191 100644 --- a/src/Server/Data/RpcSuccess.php +++ b/src/Server/Data/RpcSuccess.php @@ -71,9 +71,9 @@ public function jsonSerialize(): array ]); return [ - ...$metadata, 'success' => true, 'data' => $this->data, + ...$metadata, ]; } } diff --git a/tests/Integration/CastingGenericsAndAliasesTest.php b/tests/Integration/CastingGenericsAndAliasesTest.php index 00d4b35..7ba13bf 100644 --- a/tests/Integration/CastingGenericsAndAliasesTest.php +++ b/tests/Integration/CastingGenericsAndAliasesTest.php @@ -11,37 +11,37 @@ */ test('property hooks land per direction: raw in, checksum and summary out', function () { expect(IntegrationHarness::commandJson('shipping.registerHandling', '{"instructions":{"code":"frg","raw":"keep-cool"}}')) - ->toBe('{"success":true,"data":{"checksum":"looc-peek","code":"frg","summary":"FRG"}}'); + ->toBe('{"success":true,"data":{"checksum":"looc-peek","code":"frg","summary":"FRG"},"__metadata":{"key":"default1"}}'); }); test('a virtual set-only property is required on the input side', function () { expect(IntegrationHarness::commandJson('shipping.registerHandling', '{"instructions":{"code":"frg"}}')) - ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"instructions":["validation.missing_property"]}}}'); + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"instructions":["validation.missing_property"]}},"__metadata":{"key":"default1"}}'); }); test('a union of castables resolves the first arm by its properties', function () { expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"locationCode":"ZH-01"},"window":"01.07.2024 08:00"}')) - ->toBe('{"success":true,"data":{"destination":{"locationCode":"ZH-01"},"eta":"2024-07-01T08:00:00+00:00","window":"01.07.2024 08:00"}}'); + ->toBe('{"success":true,"data":{"destination":{"locationCode":"ZH-01"},"eta":"2024-07-01T08:00:00+00:00","window":"01.07.2024 08:00"},"__metadata":{"key":"wow"}}'); }); test('a union of castables resolves the second arm by its properties', function () { expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"street":"Seeweg 2","zip":"8001"},"window":"01.07.2024 08:00"}')) - ->toBe('{"success":true,"data":{"destination":{"street":"Seeweg 2","zip":"8001"},"eta":"2024-07-01T08:00:00+00:00","window":"01.07.2024 08:00"}}'); + ->toBe('{"success":true,"data":{"destination":{"street":"Seeweg 2","zip":"8001"},"eta":"2024-07-01T08:00:00+00:00","window":"01.07.2024 08:00"},"__metadata":{"key":"wow"}}'); }); test('first-match probing wins on a shape satisfying both arms and drops unknown keys', function () { expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"locationCode":"x","street":"y","zip":"z"},"window":"01.07.2024 08:00"}')) - ->toBe('{"success":true,"data":{"destination":{"locationCode":"x"},"eta":"2024-07-01T08:00:00+00:00","window":"01.07.2024 08:00"}}'); + ->toBe('{"success":true,"data":{"destination":{"locationCode":"x"},"eta":"2024-07-01T08:00:00+00:00","window":"01.07.2024 08:00"},"__metadata":{"key":"wow"}}'); }); test('a shape matching neither castable arm reports every arm plus the union', function () { expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"iban":"x"},"window":"01.07.2024 08:00"}')) - ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"destination":["validation.missing_property","validation.missing_property","validation.invalid_type"]}}}'); + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"destination":["validation.missing_property","validation.missing_property","validation.invalid_type"]}},"__metadata":{"key":"wow"}}'); }); test('a DateTimeString with a custom format rejects the default format strictly', function () { expect(IntegrationHarness::commandJson('shipping.scheduleDelivery', '{"destination":{"locationCode":"ZH-01"},"window":"2024-07-01"}')) - ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"window":["validation.invalid_type"]}}}'); + ->toBe('{"success":false,"code":422,"type":"INVALID_INPUT","details":{"fields":{"window":["validation.invalid_type"]}},"__metadata":{"key":"wow"}}'); }); test('a generic castable binds a different type argument per direction', function () { diff --git a/tests/Integration/Fixtures/MetadataMiddleware.php b/tests/Integration/Fixtures/MetadataMiddleware.php new file mode 100644 index 0000000..052f417 --- /dev/null +++ b/tests/Integration/Fixtures/MetadataMiddleware.php @@ -0,0 +1,30 @@ +appendMetadata(['key' => $this->value]); + } + + public function configure(array $config): self + { + $this->value = $config['value'] ?? 'default'; + return $this; + } +} diff --git a/tests/Integration/Fixtures/Operations/ShippingCommands.php b/tests/Integration/Fixtures/Operations/ShippingCommands.php index 938f9be..c959d7e 100644 --- a/tests/Integration/Fixtures/Operations/ShippingCommands.php +++ b/tests/Integration/Fixtures/Operations/ShippingCommands.php @@ -7,6 +7,8 @@ use DateTime; use DateTimeImmutable; use Le0daniel\PhpTsBindings\Contracts\Attributes\Command; +use Le0daniel\PhpTsBindings\Contracts\Attributes\Middleware; +use Tests\Integration\Fixtures\MetadataMiddleware; use Tests\Integration\Fixtures\Types\Address; use Tests\Integration\Fixtures\Types\Batch; use Tests\Integration\Fixtures\Types\Currency; @@ -32,6 +34,7 @@ final class ShippingCommands * @param array{instructions: HandlingInstructions} $input */ #[Command('shipping')] + #[Middleware(MetadataMiddleware::class)] public function registerHandling(array $input): HandlingInstructions { return $input['instructions']; @@ -45,6 +48,7 @@ public function registerHandling(array $input): HandlingInstructions * @return array{destination: PickupPoint|HomeDelivery, eta: DateTime, window: DateTimeString<'d.m.Y H:i'>} */ #[Command('shipping')] + #[Middleware(MetadataMiddleware::class, ['value' => 'wow'])] public function scheduleDelivery(array $input): array { return [ diff --git a/tests/Unit/Server/Data/RpcSuccessTest.php b/tests/Unit/Server/Data/RpcSuccessTest.php index f80398c..4dde4f4 100644 --- a/tests/Unit/Server/Data/RpcSuccessTest.php +++ b/tests/Unit/Server/Data/RpcSuccessTest.php @@ -55,6 +55,8 @@ function successResolveInfo(): ResolveInfo $result = new RpcSuccess(['id' => '123'], $client, successResolveInfo()); expect($result->jsonSerialize())->toBe([ + 'success' => true, + 'data' => ['id' => '123'], '__client' => [ 'redirect' => ['url' => '/users/123', 'reload' => false], 'toasts' => [ @@ -62,8 +64,6 @@ function successResolveInfo(): ResolveInfo ], 'type' => 'operations-spa', ], - 'success' => true, - 'data' => ['id' => '123'], ]); }); @@ -72,9 +72,9 @@ function successResolveInfo(): ResolveInfo expect($result->jsonSerialize())->not->toHaveKey('__metadata') ->and($result->appendMetadata(['durationMs' => 12])->jsonSerialize())->toBe([ - '__metadata' => ['durationMs' => 12], 'success' => true, 'data' => 'ok', + '__metadata' => ['durationMs' => 12], ]); });