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/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/README.md b/README.md index 0abc5af..dc399e3 100644 --- a/README.md +++ b/README.md @@ -1,205 +1,572 @@ # 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. +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. -## Motivation - -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. +```php +/** + * @param array{id: UserId} $input + * @return array{email: Email, slug: Slug} + */ +#[Query('users')] +public function get(array $input): array { /* ... */ } +``` -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. +```typescript +export type GetInput = {id:(number & Brand<"customerId">);}; +export type GetResult = {email:(string & Brand<"email">);slug:string;}; -This made me think, why is there no such thing in PHP? +const result = await get({id: userId}); +``` -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. +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 + +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 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. | +| [The Laravel adapter](docs/laravel.md) | Config, routes, context, the `operations:*` artisan commands. | + +On this page: [Install](#install) · [Quickstart](#quickstart) · [Architecture](#architecture) · +[Errors](#errors) · [Types](#types) · [Contributing](#contributing) + +## Install + +```bash +composer require le0daniel/php-ts-bindings +``` -## Installation +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: -``` -composer require le0daniel/php-ts-bindings +```neon +# phpstan.neon +includes: + - vendor/le0daniel/php-ts-bindings/extension.neon ``` -## Usage +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. -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. +**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)** -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. +## Quickstart -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. +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. ```php -use Le0daniel\PhpTsBindings\Server\Server; -use Le0daniel\PhpTsBindings\Server\Operations\EagerlyLoadedRegistry; -use Le0daniel\PhpTsBindings\Contracts\Client; -use Le0daniel\PhpTsBindings\Contracts\Attributes\Query; -use Le0daniel\PhpTsBindings\Server\KeyGenerators\PlainlyExposedKeyGenerator; -use Le0daniel\PhpTsBindings\Contracts\Attributes\Throws; +namespace App\Operations; -$server = new Server( - EagerlyLoadedRegistry::eagerlyDiscover('your/directory', keyGenerator: new PlainlyExposedKeyGenerator()) -); - -$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. +`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. -### Laravel Default Integration +**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. -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. +```php +use Le0daniel\PhpTsBindings\CodeGen\CodeGenerators; +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( + CodeGenerators::fromDefaults('name'), +)->generate($server, new ServerMetadata( + '/query/{key}', + '/command/{key}', + $server->configuration, +)); + +OutputDirectory::write(__DIR__ . '/resources/js/operations', $files); +``` -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. +`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. -## Type Parsing +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. + +**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\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; - -$typeString = TypeReflector::reflectParameter( - new ReflectionParameter() -); // string|array|object{name: string} - -$parser = new TypeParser(); -$ast = $parser->parse( - $typeString, - // The parsing context is needed for Type Imports and used classes. - ParsingContext::fromClassString(MyClassDeclaringThisParameter::class) -); +use Le0daniel\PhpTsBindings\Server\Client\NullClient; + +// GET /query/{key} — each query parameter JSON-decoded back into a value +$result = $server->query($key, $input, $myContext, new NullClient()); -$inputDefinition = new TypescriptDefinitionGenerator()->toDefinition($ast, DefinitionTarget::INPUT); -// => string|Record|{name: string;} +// POST /command/{key} — the JSON body +$result = $server->command($key, $input, $myContext, new NullClient()); + +respondJson($result->statusCode, $result->jsonSerialize()); +``` -$outputDefinition = new TypescriptDefinitionGenerator()->toDefinition($ast, DefinitionTarget::OUTPUT); -// => string|Record|{name: string;} +Neither call ever throws — see [the server](docs/server.md#serving-operations-over-http) for the +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. -$executor = new SchemaExecutor() +**You get a `users.ts` module**, matching the namespace: -// Execute against some input or output. -$parsed = $executor->parse($node, ['key' => 'value']); -$serialized = $executor->serialize($node, "my string"); +```typescript +export type GetResult = {email:(string & Brand<"email">);slug:string;}; +export type GetInput = {id:(number & Brand<"customerId">);}; +export type GetDomainErrors = /* the names this operation exposed, or never */; + +export async function get(input: GetInput, options?: OperationOptions) { /* ... */ } ``` -## Validating AST +and call it: + +```typescript +import {get} from './operations/users'; -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. +const result = await get({id: userId}); +if (result.success) { + result.data.email; // (string & Brand<"email">) +} else { + result.type; // "INVALID_INPUT" | "NOT_FOUND" | "INTERNAL_ERROR" | "CLIENT_ERROR" | ... +} +``` -## Running in Production +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. -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. +## 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`: ```php -use Le0daniel\PhpTsBindings\Parser\ASTOptimizer; - -$optimizer = new ASTOptimizer(); -$optimizer->optimizeAndWriteToFile( - 'asts.php', - [ - 'MyClass@methodname@input' => $ast, - 'MyClass@methodname@output' => $otherAst, - ], -); +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 ``` -To use the optimized ASTs, you can simply require the file in your project and use the optimized ASTs. +**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. + +**`$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 +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), 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, `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. + +**`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. + +**[→ 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. + +### The three arguments + +Your method is called with exactly three arguments, positionally — input, context, client: ```php -use Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry; +public function get(array $input, MyContext $context, Client $client): array +``` -/** @var CachedTypeRegistry $registry */ -$registry = require 'asts.php'; +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) 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)** + +**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" — 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 +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. + +**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. + +**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 +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 +first-party, the schema belongs to whichever `Client` produced it, and claiming to know it would be +a lie. + +**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. + +**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`. + +**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 + +Every failure the server can produce is one of seven 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 | +| 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 | + +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, +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) + +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: -$ast = $registry->get('MyClass@methodname@input'); -$otherAst = $registry->get('MyClass@methodname@output'); +```php +#[Command('users')] +#[Throws(InvalidNameException::class, name: 'invalid-name')] +public function create(array $input): array { /* ... */ } ``` -## Extending the Parser +```json +{"success": false, "code": 400, "type": "DOMAIN_ERROR", "details": {"name": "invalid-name"}} +``` -The parser is quite simple and can be extended to support more specific types with custom parsers. +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, `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 +cannot decide is a named domain error — there is no hand-built 422), and the exceptions this +library throws at build time. + +## Types + +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}`, `object{name: string}` | `{age?:number;name:string;}` | +| `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;})` | +| `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. 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. + +**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 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*: -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 +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 -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(); - } +/** @return array */ +public function active(): array +{ + return array_filter($this->users, fn (User $u) => $u->isActive()); } - -$parser = new TypeParser( - parsers: TypeParser::getDefaultParsers( - prepend: [ - new CarbonDateTimeParser(), - ]; - ), -); ``` -By default, the parser uses the following parsers: +`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.** -- 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. +| 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 | -If you don't want to use any of the default parsers, you can pass an empty array to the constructor of TypeParser. +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 -new TypeParser(parsers: []); -``` \ No newline at end of file +/** @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 +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 e4d7831..d1173dd 100644 --- a/composer.json +++ b/composer.json @@ -1,16 +1,32 @@ { "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.4" + "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" + "phpstan/phpstan-strict-rules": "^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.", + "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": { @@ -37,10 +53,30 @@ "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", + "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.", + "benchmark": "Time cached vs eager operation registries over the integration fixtures. Informational; not part of check:all or CI." }, "extra": { "laravel": { diff --git a/composer.lock b/composer.lock index abd684f..b7d35ae 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": "81eb9ae809db3650d70ca1671112c396", "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,104 @@ "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/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.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 +1936,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 +1999,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 +2034,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 +2106,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-07-12T15:29:16+00:00" }, { "name": "league/config", @@ -1970,16 +2192,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 +2269,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 +2318,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 +2343,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 +2364,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 +2376,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 +2407,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 +2466,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 +2474,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 +2500,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 +2550,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 +2558,7 @@ "type": "github" } ], - "time": "2025-12-07T16:03:21+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "mockery/mockery", @@ -2586,16 +2808,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 +2841,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 +2884,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 +2909,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 +2930,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 +2974,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 +3001,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 +3065,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 +3122,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 +3220,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 +3280,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 +3291,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 +3307,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 +3374,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, @@ -3190,7 +3414,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 +3426,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 +3500,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 +3554,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 +3566,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 +3873,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 +3890,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 +3901,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 +3932,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 +3990,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 +4071,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 +4112,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 +4131,6 @@ "conflict": { "phpstan/phpstan-shim": "*" }, - "default-branch": true, "bin": [ "phpstan", "phpstan.phar" @@ -3921,6 +4145,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -3943,31 +4178,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 +4223,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 +4252,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 +4298,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 +4318,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 +4371,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 +4579,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 +4602,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 +4657,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 +5201,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 +5273,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 +5324,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 +5344,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 +5365,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 +5416,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 +5436,7 @@ "type": "tidelift" } ], - "time": "2025-08-20T11:27:00+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -5330,23 +5565,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 +5589,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -5382,7 +5617,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 +5637,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 +5707,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 +5727,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 +5781,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 +5801,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 +5851,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 +6065,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 +6110,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 +6130,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -5993,20 +6240,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 +6293,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 +6313,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 +6393,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 +6413,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 +6462,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 +6482,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 +6508,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -6284,7 +6533,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 +6544,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 +6614,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 +6634,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 +6700,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 +6720,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 +6747,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -6527,7 +6780,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 +6791,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 +6848,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 +6868,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 +6929,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 +6949,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 +7038,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 +7058,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 +7118,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 +7138,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 +7204,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 +7224,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 +7287,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 +7307,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 +7369,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 +7389,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 +7456,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 +7476,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 +7541,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 +7561,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 +7626,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 +7646,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 +7710,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 +7730,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 +7761,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, "classmap": [ "Resources/stubs" @@ -7542,7 +7781,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 +7790,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 +7810,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 +7841,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -7622,7 +7861,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 +7870,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 +7890,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 +7921,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" + "Symfony\\Polyfill\\Php86\\": "" }, "classmap": [ "Resources/stubs" @@ -7702,7 +7941,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 +7950,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 +7970,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 +8033,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 +8053,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 +8098,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 +8118,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 +8178,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 +8198,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 +8229,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8031,7 +8265,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 +8285,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 +8355,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 +8375,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 +8448,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 +8468,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 +8494,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8296,7 +8530,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 +8550,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 +8608,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 +8628,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 +8695,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 +8715,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 +8772,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 +8883,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 +8951,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 +8963,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 +9013,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 +9037,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 +9066,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 +9101,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/docs/client-directives.md b/docs/client-directives.md new file mode 100644 index 0000000..e138e14 --- /dev/null +++ b/docs/client-directives.md @@ -0,0 +1,99 @@ +# 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) delegates that +choice to a `ClientFactory`, defaulting to exactly this header rule. + +## 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 the transport 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..d030dd8 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,285 @@ +# 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 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) +- [When `details` appears](#when-details-appears) +- [Your own validation](#your-own-validation) +- [Exceptions this library throws](#exceptions-this-library-throws) + +## The seven categories + +Every failure the server can produce is one of seven: + +| 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 | +| 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 | + +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 +`$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. + +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. + +## Exposing a domain error + +**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(name: '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": {"name": "invalid_name"}} +``` + +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, name: 'invalid-name')] +public function create(array $input): array { /* ... */ } +``` + +`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 + +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 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}}; +``` + +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|AuthenticationError|AuthorizationError|NotFoundError|RateLimitedError|DomainError|InternalError|ClientError); +export type Result = Success | Failure; +``` + +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; +``` + +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.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 +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 — 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. 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}); +if (!result.success && result.code === 0) { + 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 +} +``` + +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 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` +*is* the client branch. + +## When `details` appears + +**`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` +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. + +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 + +**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 +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); +} +``` + +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.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 + +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; 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. + +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/laravel.md b/docs/laravel.md new file mode 100644 index 0000000..6628e8d --- /dev/null +++ b/docs/laravel.md @@ -0,0 +1,343 @@ +# 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 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) +- [Routes](#routes) +- [Context](#context) +- [Client](#client) +- [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. | +| `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. | +| `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. | +| `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 +`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/{key} + LaravelHttpController::registerCommands(); // POST /command/{key} +}); +``` + +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 `{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 `{key}` — 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. + +## 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. + +### 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`](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. +- **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](client-directives.md) go nowhere + without warning. The generated client sends the header on every call. This is the default + `OperationClientFactory`; the [`client` config key](#client) replaces it. + +### 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. +- **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 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 + default, so a `$request->validate()` inside a handler lands in a 500. Map it onto a category + 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 + +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, 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. +> +> 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 + +| 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 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 | +|---|---|---| +| `types` | `EmitTypes` | on | +| `bindings` | `EmitOperationClientBindings` | on | +| `utils` | `EmitTypeUtils` | on | +| `operations-spa` | `EmitOperationsSpaClient` | on | +| `operations` | `EmitOperations` | on | +| `type-map` | `EmitTypeMap` | off | +| `tanstack-query` | `EmitTanstackQuery` | off | +| `query-key` | `EmitQueryKey` | off | + +`--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 | +|---|---| +| `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](server.md#production) for the optimizer underneath, which is usable on its own. + +## Preloading + +[`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 +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/operations.md b/docs/operations.md new file mode 100644 index 0000000..eb5b6a2 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,244 @@ +# 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, 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. | +| `#[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()` 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 +[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. + +`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], + rateLimited: [TooManyRequestsException::class], +) +``` + +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 +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..41c7d49 --- /dev/null +++ b/docs/server.md @@ -0,0 +1,263 @@ +# 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 + +**`$key` 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). | + +`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/types.md b/docs/types.md new file mode 100644 index 0000000..d44dbe4 --- /dev/null +++ b/docs/types.md @@ -0,0 +1,663 @@ +# 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) +- [Not supported](#not-supported) +- [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` | `Record` | +| `array` | `Record` | +| `array<'a'\|'b', int>` | `Partial>` | +| `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)`. + +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). + +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 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 | + +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. + +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 +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, 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. + +### 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 +`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 + +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 bare array / list / non-empty-array, without generics +list +non-empty-array +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 +``` + +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 + +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` | 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}`. | +| `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 +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. + +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: + +```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`. + +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\Helpers\AstValidator; + +AstValidator::validate($node); +``` + +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. diff --git a/docs/typescript-client.md b/docs/typescript-client.md new file mode 100644 index 0000000..1de907d --- /dev/null +++ b/docs/typescript-client.md @@ -0,0 +1,228 @@ +# 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, 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 +``` + +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 +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} + & (InvalidInputError|AuthenticationError|AuthorizationError|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 +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. + +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 +`DomainErrors`, the names that operation exposed or `never`. Where you need the failure branch +named, it is `Failure<DomainErrors>`. + +## 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 +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: + +```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). + +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 +`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 +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/{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 + +`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 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 { + 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 category's code — only a validated envelope ever gets here + } + throw e; +} +``` + +## Generators + +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. [`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 + +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: + +```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 +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. 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/phpstan.neon b/phpstan.neon index 4ea24e5..9cb1f4b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,25 +1,17 @@ includes: - extension.neon + - vendor/phpstan/phpstan-strict-rules/rules.neon parameters: + strictRules: + booleansInConditions: false paths: - src/ - # ToDo: Enable this in the future - reportPossiblyNonexistentGeneralArrayOffset: false - checkMissingCallableSignature: false - checkBenevolentUnionTypes: true - reportPossiblyNonexistentConstantArrayOffset: true - # Level 10 is the highest level - level: 6 - -# typeAliases: -# Email: 'non-empty-string' + level: 8 -# ignoreErrors: -# - '#class-string#' - -# -# excludePaths: -# - ./src/Adapters/* + reportPossiblyNonexistentGeneralArrayOffset: false + reportPossiblyNonexistentConstantArrayOffset: true + checkMissingCallableSignature: true + checkBenevolentUnionTypes: true 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/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}' - . '{--no-branded-types}' - . '{--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'; @@ -50,14 +42,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) @@ -76,22 +71,35 @@ final class CodeGenCommand extends Command * @throws BindingResolutionException */ public function handle( - #[Give(LaravelServiceProvider::DEFAULT_SERVER)] Server $server, - Router $router, - Application $application, - ): int - { + Router $router, + Application $application, + ): int { + // Always get a fresh server + $server = LaravelServiceProvider::serverFactory( + $application, + operations: null, + ); + + $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( - $router->getRoutes()->getByName(LaravelHttpController::QUERY_NAME)->uri(), - $router->getRoutes()->getByName(LaravelHttpController::COMMAND_NAME)->uri(), + $queryRoute->uri(), + $commandRoute->uri(), + $server->configuration ); $codeGenerator = new TypescriptServerCodeGenerator( $this->getGeneratorsFromInput($application), - new TypescriptDefinitionGenerator( - emitBrandedTypes: $this->option('no-branded-types') === false - ), ); $files = $codeGenerator->generate( @@ -104,129 +112,78 @@ 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()); - $directory = str_starts_with('/', $this->argument('directory')) - ? $this->argument('directory') - : base_path($this->argument('directory')); + 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()); - if ($this->option('verify')) { - $this->info("Verify generated code only."); - return $this->verifyContentOnly($directory, $files); + return 1; } - $this->writeFiles($directory, $files); - 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, - }; + $target = ArtisanOptions::asString($this->argument('directory')) ?? ''; + if ($target === '') { + $this->error('A target directory is required.'); - if ($nameGenerator) { - return $nameGenerator; + return 1; } - $possibleClassNameAndMethod = $this->option('naming'); - $parts = explode('::', $possibleClassNameAndMethod, 2); + // 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 (count($parts) === 2 && class_exists($parts[0]) && method_exists($parts[0], $parts[1])) { - return Closure::fromCallable([$application->make($parts[0]), $parts[1]]); + if ($this->option('verify')) { + $this->info('Verify generated code only.'); + + return $this->verifyContentOnly($directory, $files); } - $this->error("Unknown naming mode {$this->option('naming')}."); - exit(1); - } + try { + OutputDirectory::write($directory, $files); + } catch (CodeGenException $exception) { + // Refusing to overwrite a file this library did not write. + $this->error($exception->getMessage()); - /** - * @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; + return 1; } + + return 0; } /** - * @param string $directory - * @param array $files - * @return int + * @param array $files */ 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)) { + if (count($issues) > 0) { $count = count($issues); $this->error("Found {$count} issue(s):"); foreach ($issues as $issue) { $this->info($issue); } + return 1; } - $this->line("All files are correct. No issues found."); - 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); - } + $this->line('All files are correct. No issues found.'); - foreach ($this->iterateFiles($directory, $files) as $filePath => $file) { - file_put_contents($filePath, $file->toString()); - } + return 0; } /** * @return list + * * @throws BindingResolutionException */ private function getGeneratorsFromInput(Application $application): array @@ -234,55 +191,56 @@ 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; - } - return $default; - }; + $namingGeneratorName = ($this->option('naming') ?? 'name') |> Assertions::string(...); - $namingGenerator = $this->getNamingGenerator($application); + $namingGenerator = match ($namingGeneratorName) { + 'fqn','operation-prefix','namespace-postfix','name' => CodeGenerators::namingGenerator($namingGeneratorName), + default => $this->customNamingGenerator($application, $namingGeneratorName), + }; - $generators = array_filter([ - $includeGenerator('types', true) ? new EmitTypes() : null, - $includeGenerator('bindings', true) ? new EmitOperationClientBindings() : null, - $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, - ], fn($value) => $value !== null); + $defaultGenerators = CodeGenerators::fromDefaults( + $namingGenerator, + with: $with, + without: $without, + ); $customGenerators = array_map( - fn(string $className) => $application->make($className), + fn (string $className) => $application->make($className), ArtisanOptions::expandOptionsArrayCommaSeparated($this->option('custom')) ); // @phpstan-ignore-next-line arrayValues.list return array_values([ - ...$generators, + ...$defaultGenerators, ...$customGenerators, ]); } - private function clearDirectory(string $directory): void + /** + * 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 { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory) - ); + $parts = explode('::', $naming, 2); - /** @var SplFileInfo $file */ - foreach ($iterator as $file) { - if ($file->isDir() || !str_ends_with($file->getBasename(), '.ts')) { - continue; - } + if (count($parts) === 2 && class_exists($parts[0]) && method_exists($parts[0], $parts[1])) { + $instance = $application->make($parts[0]); - if ($file->getRealPath()) { - unlink($file->getRealPath()); - } + /* @phpstan-ignore-next-line method.dynamicName */ + 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/Adapters/Laravel/Commands/ListCommand.php b/src/Adapters/Laravel/Commands/ListCommand.php index 5fe913f..dde914d 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); - 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([ - '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), + implode(', ', $operation->definition->middlewareClassNames()), ], OperationType::COMMAND => [ $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), + implode(', ', $operation->definition->middlewareClassNames()), ], }, $server->registry->all())); @@ -58,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); } -} \ No newline at end of file +} diff --git a/src/Adapters/Laravel/Commands/OptimizeCommand.php b/src/Adapters/Laravel/Commands/OptimizeCommand.php index 71ce66d..a398805 100644 --- a/src/Adapters/Laravel/Commands/OptimizeCommand.php +++ b/src/Adapters/Laravel/Commands/OptimizeCommand.php @@ -1,37 +1,69 @@ -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 an EagerlyLoadedOperationRegistry'); + } + + $idLength = ArtisanOptions::asPositiveInt( + $this->option('id-length'), + config('operations.cache.idLength'), + ); + + 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')); - require base_path('bootstrap/cache/operations.php'); - } catch (\Throwable $e) { - unlink(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) { + $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; } return 0; } -} \ No newline at end of file +} 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 @@ +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 { - $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( + $key, + input: $this->gatherInputFromRequest(OperationType::QUERY, $request), + context: $this->contextFactory?->createContextFromHttpRequest($request), + client: $this->clientFactory->createClientFromHttpRequest($request), + ) + |> $this->reportExceptions(...) + |> $this->produceJsonResponse(...); } /** * @throws Throwable */ - public function handleHttpCommandRequest(string $fqn, Http\Request $request): JsonResponse + public function handleHttpCommandRequest(string $key, 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->produceJsonResponse($result, $client); + return $this->server->command( + $key, + input: $this->gatherInputFromRequest(OperationType::COMMAND, $request), + context: $this->contextFactory?->createContextFromHttpRequest($request), + client: $this->clientFactory->createClientFromHttpRequest($request), + ) + |> $this->reportExceptions(...) + |> $this->produceJsonResponse(...); } - private function createClient(Http\Request $request): Client + private function reportExceptions(RpcResult $result): RpcResult { - if ($request->header(self::CLIENT_ID_HEADER) === 'operations-spa') { - return new OperationSPAClient(); + if ($result instanceof RpcError) { + // 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 new NullClient(); + return $result; } /** @@ -99,7 +97,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) { @@ -109,89 +115,83 @@ private function gatherInputFromRequest(OperationType $type, Http\Request $reque OperationType::COMMAND => $request->json()->all(), }; - return empty($inputData) ? null : $inputData; + return count($inputData) === 0 ? null : $inputData; } /** - * @param array $response - * @param Client $client + * 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 function appendClientDirectives(array $response, Client $client): array + private static function describeThrowable(Throwable $throwable): array { - if (!$client instanceof JsonSerializable) { - return $response; - } - - $clientData = $client->jsonSerialize(); - if ($clientData === null) { - return $response; - } - - $response['__client'] = $clientData; - return $response; + 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(RpcSuccess|RpcError $result, Client $client): JsonResponse + private function produceJsonResponse(RpcResult $result): 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: $result->statusCode, headers: self::headersFor($result)); } - $this->exceptionHandler->report($result->cause); - $content = $this->appendClientDirectives([ - 'success' => false, - 'code' => $result->type->value, - '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, + 'fullyQualifiedName' => $result->resolveInfo->fullyQualifiedName, + 'type' => $result->resolveInfo->operationType->name, + ]; } - if ($this->debug) { - $exception = $result->cause; - $content['__debug'] = Arrays::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, + // We append debug info for failed operations + if ($result instanceof RpcError) { + $jsonResponse['__debug'] = Dicts::filterNullValues([ + ...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, ]); - - $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: $result->statusCode, + headers: self::headersFor($result), ); } -} \ No newline at end of file + + /** + * 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 eb4a94c..77aa605 100644 --- a/src/Adapters/Laravel/LaravelServiceProvider.php +++ b/src/Adapters/Laravel/LaravelServiceProvider.php @@ -1,40 +1,46 @@ -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, + ): Server { + $config = $app->make('config'); + + $operations ??= EagerlyLoadedOperationRegistry::eagerlyDiscover( + $config->get('operations.discovery_path', []), + $app->make(TypeParser::class), + self::keyGeneratorFrom($app), + ); + + /** @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: $configuration, + ); + } + /** * Register any application services. */ + #[Override] public function register(): void { $this->app->bind(TypeParser::class, function () { return new TypeParser( - consumers: TypeParser::defaultConsumers( - collectionClasses: [Collection::class] - ), + consumers: TypeParser::defaultConsumers(), ); }); $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 ClientAwareExceptionPresenter(), - ], - new CatchAllPresenter(), + $isRepositoryCached = file_exists(base_path('bootstrap/cache/operations.php')); + + return self::serverFactory( $app, - new ServerConfiguration() - ->withMiddlewares(...config('operations.middleware', [])), + $isRepositoryCached ? require(base_path('bootstrap/cache/operations.php')) : null ); }); $this->app->singleton(Preloader::class, function (Application $app): Preloader { - /** @var Repository $config */ - $config = $app->make('config'); - return new Preloader( - $app->make(self::DEFAULT_SERVER), - 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"), - }, + server: $app->make(self::DEFAULT_SERVER), + keyGenerator: self::keyGeneratorFrom($app), ); }); // 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), ); }); @@ -135,7 +186,8 @@ public function boot(): void ]); $this->mergeConfigFrom( - __DIR__ . '/config/config.php', 'operations' + __DIR__ . '/config/config.php', + 'operations' ); if ($this->app->runningInConsole()) { @@ -151,4 +203,4 @@ public function boot(): void ); } } -} \ No newline at end of file +} diff --git a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php b/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php deleted file mode 100644 index 76fee99..0000000 --- a/src/Adapters/Laravel/Middleware/LocalMetadataMiddleware.php +++ /dev/null @@ -1,54 +0,0 @@ -appendMetadata([ - 'durationMs' => $durationMs, - 'client' => [ - 'class' => $client::class, - ], - 'info' => [ - 'namespace' => $resolveInfo->namespace, - 'name' => $resolveInfo->name, - 'fqn' => $resolveInfo->fullyQualifiedName, - 'operationType' => $resolveInfo->operationType->name, - ], - 'handler' => [ - 'className' => $resolveInfo->className, - 'methodName' => $resolveInfo->methodName, - ], - 'middleware' => $resolveInfo->middleware, - 'input' => $input, - 'context' => [ - 'class' => is_object($context) ? get_class($context) : gettype($context), - ], - ]); - } - -} \ No newline at end of file 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/Preloader.php b/src/Adapters/Laravel/Preloader.php deleted file mode 100644 index 1bcb56e..0000000 --- a/src/Adapters/Laravel/Preloader.php +++ /dev/null @@ -1,66 +0,0 @@ -} - */ - 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); - $result = $this->server->query($fqcn, $input, $context, new NullClient()); - - if (!$result instanceof RpcSuccess) { - throw new RuntimeException("Failed to preload: {$namespaceAsString}.{$name}"); - } - - return [ - 'response' => $result->data, - 'queryKey' => $input === null ? [$namespaceAsString, $name] : [$namespaceAsString, $name, $input], - ]; - } - - /** - * @param list $preloads - * @param 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), - $preloads - ); - } -} \ No newline at end of file diff --git a/src/Adapters/Laravel/Utils/ArtisanOptions.php b/src/Adapters/Laravel/Utils/ArtisanOptions.php index c313698..adcc39d 100644 --- a/src/Adapters/Laravel/Utils/ArtisanOptions.php +++ b/src/Adapters/Laravel/Utils/ArtisanOptions.php @@ -1,29 +1,78 @@ -|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 => [], + }; + + /** @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; + } + + /** + * 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 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, - ])); - }, []); + return $int !== null && $int > 0 ? $int : null; } -} \ No newline at end of file +} diff --git a/src/Adapters/Laravel/config/config.php b/src/Adapters/Laravel/config/config.php index ebf2aff..af82a2a 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, + + /** + * 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 */ - "context" => null, + 'client' => 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, + ], /** * Define the way to generate the key of the remote procedures. @@ -31,53 +57,93 @@ * - plain * - custom: MUST define className */ - "key" => [ - "mode" => "obfuscate", + 'key' => [ + /** + * Options: obfuscate, plain, custom + * + * For obfuscate: you can define a pepper(string) to add randomness + * For custom: MUST define className + */ + 'mode' => 'obfuscate', /** * Only relevant for mode 'obfuscate' */ - "pepper" => "none", + 'pepper' => 'none', /** * Only relevantly for mode custom * Class must implement: Le0daniel\PhpTsBindings\Contracts\OperationKeyGenerator + * + * @see OperationKeyGenerator */ - "className" => null, + 'className' => null, ], /** - * 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. + * + * 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. * * 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 MiddlewareContract */ - "middleware" => [], + 'middleware' => [], /** - * Map your exceptions to framework-specific exceptions. + * Map your exceptions onto the server's built-in error categories. Anything not listed here and + * 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. A #[Throws] + * declaration on the throwing scope wins over these lists. */ - "exceptions" => [ - "unauthenticated" => [ + 'exceptions' => [ + 'unauthenticated' => [ AuthenticationException::class, ], - "unauthorized" => [ + 'unauthorized' => [ TokenMismatchException::class, AuthorizationException::class, ], - "not_found" => [ + 'not_found' => [ ModelNotFoundException::class, 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' => [], ], -]; \ No newline at end of file + + /** + * 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/PHPStan/UtilitiesNodeResolver.php b/src/Adapters/PHPStan/UtilitiesNodeResolver.php similarity index 71% rename from src/PHPStan/UtilitiesNodeResolver.php rename to src/Adapters/PHPStan/UtilitiesNodeResolver.php index bd4abb4..76f721e 100644 --- a/src/PHPStan/UtilitiesNodeResolver.php +++ b/src/Adapters/PHPStan/UtilitiesNodeResolver.php @@ -1,25 +1,30 @@ -reflectionProvider = $reflectionProvider; } + #[Override] public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void { $this->typeNodeResolver = $typeNodeResolver; } + #[Override] public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type { - if (!$typeNode instanceof GenericTypeNode) { + // 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; } $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; @@ -65,12 +101,13 @@ 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, }; } + /** @param 'Omit'|'Pick' $typeName */ private function resolvePickAndOmitUtil(string $typeName, GenericTypeNode $typeNode, NameScope $nameScope): ?Type { $arguments = $typeNode->genericTypes; @@ -98,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 { @@ -110,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; } @@ -130,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 @@ -142,20 +174,20 @@ 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); $isPropertyInNewObject = match ($type) { 'Pick' => $keysType->isSuperTypeOf($keyType)->yes(), - 'Omit' => !$keysType->isSuperTypeOf($keyType)->yes(), + 'Omit' => ! $keysType->isSuperTypeOf($keyType)->yes(), }; - if (!$isPropertyInNewObject) { + if (! $isPropertyInNewObject) { continue; } @@ -163,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; } @@ -173,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 { @@ -185,13 +215,13 @@ 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(), + 'Omit' => ! $keysType->isSuperTypeOf($keyType)->yes(), }; - if (!$isPropertyInNewObject) { + if (! $isPropertyInNewObject) { continue; } @@ -203,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 new file mode 100644 index 0000000..d4227a8 --- /dev/null +++ b/src/CodeGen/CodeGenerators.php @@ -0,0 +1,127 @@ +}> + */ + 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]) { + // 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 (! $isEnabled) { + continue; + } + + $generators[] = match ($classString) { + EmitOperations::class => new EmitOperations($namingGenerator), + default => new $classString(), + }; + } + + return $generators; + } +} diff --git a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php index 42e9179..d45bd48 100644 --- a/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php +++ b/src/CodeGen/CodeGenerators/EmitOperationClientBindings.php @@ -1,51 +1,144 @@ - $values + * @param list $types + */ + public function importFromBindings(array $values = [], array $types = []): TypescriptImport + { + return new TypescriptImport( + Paths::libImport(self::BINDINGS_FILE), + values: $values, + types: $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 { return [ EmitTypes::class, + EmitTypeUtils::class, ]; } + #[Override] + public function setDependencies(array $dependencies): void + { + $this->types = Assertions::instanceOf( + EmitTypes::class, + $dependencies[EmitTypes::class] ?? null, + ); + $this->utils = Assertions::instanceOf( + EmitTypeUtils::class, + $dependencies[EmitTypeUtils::class] ?? null, + ); + } + /** - * @return array + * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata): array + #[Override] + public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { return [ - "OperationClient" => << new TypescriptFile(<<<'TypeScript' export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; +/** + * 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( - type: "command"|"query", - key: string, - input: unknown, + execute( + type: "command"|"query", + key: string, + input: unknown, options?: OperationOptions - ): Promise>>; + ): Promise<{status: number; jsonBody: unknown}>; } -TypeScript, - "DefaultClient" => <<>) => Promise | void; - +TypeScript), + self::DEFAULT_CLIENT_FILE => new TypescriptFile(<<<'TypeScript' export class DefaultClient implements OperationClient { - private hooks: Hook[] = []; - constructor( private readonly fetcher: typeof window.fetch, private readonly options: { @@ -73,28 +166,25 @@ public function emitFiles(array $operations, ServerMetadata $metadata): array 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('&'); } - 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>> { + /** + * 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('{fqn}', key)}`; + const fullPath = `${this.options.baseUrl ?? ''}/${route.replace('{key}', 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 = { @@ -107,80 +197,86 @@ public function emitFiles(array $operations, ServerMetadata $metadata): array } 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, 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: response.type ?? 'INTERNAL_ERROR' - } as WithClientDirectives>); - } - - 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}; } } -TypeScript, - "OperationException" => <<importFromOperationClient(types: ['OperationClient', 'OperationOptions']), + ]), + self::OPERATION_EXCEPTION_FILE => new TypescriptFile(<<<'TypeScript' +/** + * 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 { + public readonly cause: Failure; -export class OperationException extends Error { - public readonly cause: Failure; + /** + * 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. + */ + public isClientError(): this is OperationException & {cause: Failure & ClientError} { + return this.cause.code === 0; + } get code(): number { - const code = this.cause.code; - if (!code || typeof code !== 'number' || Number.isNaN(code)) { - return 500; - } - - return code; + return this.cause.code; } - constructor(cause: Failure) { - super(`Operation failed with code \${cause.code}`); + 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; } } -TypeScript, - "bindings" => <<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): DefaultClient { +export function createDefaultClient( + fetcher?: typeof window.fetch, + options?: {baseUrl?: string; timeoutMs?: number}, +): DefaultClient { return new DefaultClient(fetcher ?? fetch, { paths: {query: '{$metadata->queryUrl}', command: '{$metadata->commandUrl}'}, - baseUrl: '', - timeoutMs: 10000, + baseUrl: options?.baseUrl ?? '', + timeoutMs: options?.timeoutMs ?? 10000, }); } @@ -188,24 +284,74 @@ public function emitFiles(array $operations, ServerMetadata $metadata): array client = operationClient; } -export function throwOnFailure(result: Result): asserts result is Success { - if (!result.success) { - throw new OperationException(result); +/** + * 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); } + + return 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); - } +/** + * 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}; +} - if (client) { - return await client.execute(type, key, input, options); +/** + * 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, +TypeScript, [ + $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']), + ]), ]; } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitOperations.php b/src/CodeGen/CodeGenerators/EmitOperations.php index a29ad31..8c40628 100644 --- a/src/CodeGen/CodeGenerators/EmitOperations.php +++ b/src/CodeGen/CodeGenerators/EmitOperations.php @@ -1,4 +1,6 @@ -types = Assertions::instanceOf( + EmitTypes::class, + $dependencies[EmitTypes::class] ?? null, + ); + $this->bindings = Assertions::instanceOf( + EmitOperationClientBindings::class, + $dependencies[EmitOperationClientBindings::class] ?? null, + ); + } + + /** + * 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->operation->definition->name; + return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->definition->name; } - public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): TypescriptCodeBlock + /** + * 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 { - $definition = $operation->operation->definition; - $name = $this->generateName($operation); + /** @var array $seen */ + $seen = []; + foreach ($operations as $operation) { + $name = $this->operationName($operation); + $key = "{$operation->definition->namespace}/{$name}"; - $operationBaseTypeName = ucfirst($name); - $resultTypeName = $operationBaseTypeName . "Result"; - $resultInputTypeName = $operationBaseTypeName . "Input"; - $errorTypeName = $operationBaseTypeName . "Error"; + 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)); + } + + public function inputTypeName(TypedOperation $operation): string + { + return $this->baseTypeName($operation).'Input'; + } + + public function resultTypeName(TypedOperation $operation): string + { + return $this->baseTypeName($operation).'Result'; + } + + /** + * 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).'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. 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(), + ]), + ]; + } + + #[Override] + public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): TypescriptFile + { + $definition = $operation->definition; + $name = $this->operationName($operation); + $resultTypeName = $this->resultTypeName($operation); + $resultInputTypeName = $this->inputTypeName($operation); + $domainErrorTypeName = $this->domainErrorTypeName($operation); $imports = [ - new TypescriptImportStatement( - from: Paths::libImport("bindings"), - imports: ["executeOperation"] - ), - new TypescriptImportStatement( - from: Paths::libImport("OperationClient"), - imports: ["OperationOptions"] - ), - new TypescriptImportStatement( - from: Paths::libImport("types"), - imports: ["type Brand"], - ) + $this->bindings->importFromBindings(values: ['executeOperation']), + $this->bindings->importFromOperationClient(types: ['OperationOptions']), + ...$this->aliasImports($operation), ]; $docBlock = <<inputDefinition === 'null') { - return new TypescriptCodeBlock( + if (! $operation->hasInput) { + return new TypescriptFile( <<outputDefinition}; +export type {$resultTypeName} = {$operation->outputDef->type}; export type {$resultInputTypeName} = null; -export type {$errorTypeName} = {$operation->errorDefinition}; +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, options ) } -TypeScript, $imports, +TypeScript, + $imports, ); } - return new TypescriptCodeBlock( + return new TypescriptFile( <<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 {$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, 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 new file mode 100644 index 0000000..bf61747 --- /dev/null +++ b/src/CodeGen/CodeGenerators/EmitOperationsSpaClient.php @@ -0,0 +1,97 @@ + $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/EmitQueryKey.php b/src/CodeGen/CodeGenerators/EmitQueryKey.php index 80da4fb..fa02775 100644 --- a/src/CodeGen/CodeGenerators/EmitQueryKey.php +++ b/src/CodeGen/CodeGenerators/EmitQueryKey.php @@ -1,59 +1,91 @@ -operations = Assertions::instanceOf( + EmitOperations::class, + $dependencies[EmitOperations::class] ?? null, + ); + $this->utils = Assertions::instanceOf( + EmitTypeUtils::class, + $dependencies[EmitTypeUtils::class] ?? null, + ); } - private function generateName(TypedOperation $operation): string - { - return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->operation->definition->name; - } - - - public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptCodeBlock + #[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 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 TypescriptCodeBlock( - <<hasInput) { + return new TypescriptFile( + <<inputDefinition}) { +export function {$name}QueryKey(input: {$inputTypeName}) { return queryKey('{$definition->namespace}', '{$definition->name}', input); } +TypeScript + , + imports: [ + $this->utils->importFromUtils(values: ['queryKey']), + ], + ); + } + + return new TypescriptFile( + <<namespace}', '{$definition->name}'); +} TypeScript , - [ - new TypescriptImportStatement( - from: Paths::libImport("utils"), - imports: ['queryKey'], - ), - ] + imports: [ + $this->utils->importFromUtils(values: ['queryKey']), + ], ); } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php index 1fbdd69..f62823e 100644 --- a/src/CodeGen/CodeGenerators/EmitTanstackQuery.php +++ b/src/CodeGen/CodeGenerators/EmitTanstackQuery.php @@ -1,71 +1,82 @@ -operations = Assertions::instanceOf( + EmitOperations::class, + $dependencies[EmitOperations::class] ?? null, + ); + $this->utils = Assertions::instanceOf( + EmitTypeUtils::class, + $dependencies[EmitTypeUtils::class] ?? null, + ); } - private function generateName(TypedOperation $operation): string - { - return $this->nameGenerator ? ($this->nameGenerator)($operation) : $operation->operation->definition->name; - } - - public function generateOperationCode(TypedOperation $operation, ServerMetadata $metadata): ?TypescriptCodeBlock + #[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"; - $queryName = "use" . $operationBaseTypeName . "Query"; - $queryOptionsName = lcfirst($operationBaseTypeName) . "QueryOptions"; - $optionsTypeName = $operationBaseTypeName . "Options"; + // 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'; $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'], ), + $this->utils->importFromUtils(values: ['queryKey', 'throwOnFailure']), ]; - - - if ($operation->inputDefinition === 'null') { - return new TypescriptCodeBlock( + if (! $operation->hasInput) { + return new TypescriptFile( <<, 'queryKey' | 'queryFn'>; export function {$queryOptionsName}(options?: {$optionsTypeName}) { @@ -83,12 +94,13 @@ public function generateOperationCode(TypedOperation $operation, ServerMetadata export function {$queryName}(queryOptions?: Partial<{$optionsTypeName}>) { return useQuery({$queryOptionsName}(queryOptions)); } -TypeScript, $imports); +TypeScript, + $imports + ); } - return new TypescriptCodeBlock( + return new TypescriptFile( <<, 'queryKey' | 'queryFn'>; export function {$queryOptionsName}(input: {$resultInputTypeName}, options?: {$optionsTypeName}) { @@ -106,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 + ); } -} \ No newline at end of file +} diff --git a/src/CodeGen/CodeGenerators/EmitTypeMap.php b/src/CodeGen/CodeGenerators/EmitTypeMap.php index 247f594..4e1b755 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeMap.php +++ b/src/CodeGen/CodeGenerators/EmitTypeMap.php @@ -1,46 +1,83 @@ -> $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' => "Failure<{$operation->domainErrors}>", ]; + return $carry; }, []); - $mapAsTsTypeString = '{' . implode(';', Arrays::mapWithKeys($map, function (string $type, array $operations) { + $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. So is Failure, which every operation's error + // entry is written in terms of. return [ - 'types' => new TypeScriptFile(code: << new TypescriptFile( + <<emitTypes->importFromTypes(types: ['Brand', 'Failure', ...$registry->usedAliases()]), + ] + ), ]; } -} \ No newline at end of file + + #[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, + ); + } +} diff --git a/src/CodeGen/CodeGenerators/EmitTypeUtils.php b/src/CodeGen/CodeGenerators/EmitTypeUtils.php index 67eee77..be8f6c5 100644 --- a/src/CodeGen/CodeGenerators/EmitTypeUtils.php +++ b/src/CodeGen/CodeGenerators/EmitTypeUtils.php @@ -1,68 +1,172 @@ - $values + * @param list $types + */ + public function importFromUtils(array $values = [], array $types = []): TypescriptImport + { + return new TypescriptImport( + Paths::libImport(self::UTILS_FILE), + values: $values, + types: $types, + ); + } + + #[Override] public function dependsOnGenerator(): array { return [ EmitTypes::class, + EmitOperationClientBindings::class, ]; } + #[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, + ); + } + /** - * @return array + * @return array */ - public function emitFiles(array $operations, ServerMetadata $metadata): 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; - }, []); + } return [ - "utils" => << new TypescriptFile(<<generateLiteralUnion($queryNamespaces)}; export function queryKey(ns: QueryNamespaces, ...args: unknown[]): [string, ...unknown[]] { return [ns, ...args]; } -export function isSpaClientDirectives(result: WithClientDirectives): result is SPAClientDirectives { - if (!result.__client || typeof result.__client !== 'object') { +/** + * 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 = { + 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 + * 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; } - return "type" in result.__client && result.__client.type === "operations-spa"; + 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 as keyof typeof SERVER_ERROR_CODES] === code; } -TypeScript + +/** + * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather + * catch than branch. + * + * 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) { + 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']), + // 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 010c740..3d13518 100644 --- a/src/CodeGen/CodeGenerators/EmitTypes.php +++ b/src/CodeGen/CodeGenerators/EmitTypes.php @@ -1,168 +1,128 @@ - + * Declarations this file always contains. An alias claiming one of these names would generate + * 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. + * + * @var list */ - public function emitFiles(array $operations, ServerMetadata $metadata): array - { - $uniqueNamespaces = array_reduce($operations, function (array $carry, TypedOperation $operation) { - if (!in_array($operation->operation->definition->namespace, $carry, true)) { - return [ - ...$carry, - $operation->operation->definition->namespace, - ]; - } - return $carry; - }, []); - - $brands = array_reduce($operations, function (array $carry, TypedOperation $operation) { - $inputBrands = $this->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); - - return [ - "types" => <<generateNamespaceUnion($uniqueNamespaces)}; - -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[]][] - } -}; - -declare const __brand: unique symbol; -export type Brand = {readonly [__brand]: TBrand;}; - -/* All Branded types exported */ -{$brandedTypeString} - -TypeScript, - ]; - } + private const array RESERVED_ALIASES = [ + 'Brand', + 'Success', + 'Failure', + 'Result', + 'OperationNamespaces', + 'InvalidInputError', + 'AuthenticationError', + 'AuthorizationError', + 'NotFoundError', + 'RateLimitedError', + 'DomainError', + 'InternalError', + 'ClientError', + ]; /** - * @param list $namespaces - * @return string + * 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 */ - private function generateNamespaceUnion(array $namespaces): string + public function importFromTypes(array $values = [], array $types = []): TypescriptImport { - return implode("|", array_map(fn(string $namespace) => "'$namespace'", $namespaces)); + return new TypescriptImport( + Paths::libImport(self::TYPES_FILE), + values: $values, + types: $types, + ); } /** - * @return array + * @return array */ - private function collectBrandedTypes(NodeInterface $ast, DefinitionTarget $target): array + #[Override] + public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array { - /** @var BuiltInNode[] $brandedNodes */ - $brandedNodes = []; - - $stack = [ - $ast, - ]; - - while ($current = array_pop($stack)) { - if ($current instanceof ValidatableNode) { - $current->validate(); + foreach ($registry->usedAliases() as $alias) { + if (in_array($alias, self::RESERVED_ALIASES, true)) { + throw UnsupportedTypeException::reservedAlias($alias); } + } - if ($current instanceof LeafNode) { - if ($current instanceof BuiltInNode && $current->brand !== null) { - $brandedNodes[] = $current; - } - - continue; + /** @var list $uniqueNamespaces */ + $uniqueNamespaces = []; + foreach ($operations as $operation) { + $namespace = $operation->operation->definition->namespace; + if (! in_array($namespace, $uniqueNamespaces, true)) { + $uniqueNamespaces[] = $namespace; } - - 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) { - $typeDefinition = $target === DefinitionTarget::INPUT - ? $node->inputDefinition() - : $node->outputDefinition(); + // 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}", + )); - if (!isset($brandedTypes[$node->brand])) { - $brandedTypes[$node->brand] = $typeDefinition; - continue; - } + return [ + self::TYPES_FILE => new TypescriptFile(<<generateNamespaceUnion($uniqueNamespaces)}; - if ($typeDefinition !== $brandedTypes[$node->brand]) { - throw new RuntimeException("Branded type {$node->brand} has different definitions"); - } - } +/* + * 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 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|RateLimitedError|DomainError|InternalError|ClientError); +export type Result = Success | Failure; + +declare const __brand: unique symbol; +export type Brand = {readonly [__brand]: TBrand;}; - return $brandedTypes; +/* All branded and named types exported */ +{$aliasTypeString} +TypeScript), + ]; } /** - * @param array $brands - * @param array ...$otherTypes - * @return array + * @param list $namespaces */ - private function mergeBrandedTypes(array $brands, array ... $otherTypes): array + private function generateNamespaceUnion(array $namespaces): string { - 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 implode('|', array_map(fn (string $namespace) => "'$namespace'", $namespaces)); } } diff --git a/src/CodeGen/Contracts/DependsOn.php b/src/CodeGen/Contracts/DependsOn.php index 61ceabf..8b867e8 100644 --- a/src/CodeGen/Contracts/DependsOn.php +++ b/src/CodeGen/Contracts/DependsOn.php @@ -1,4 +1,6 @@ -> */ public function dependsOnGenerator(): array; -} \ No newline at end of file + + /** + * 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; +} diff --git a/src/CodeGen/Contracts/GeneratesLibFiles.php b/src/CodeGen/Contracts/GeneratesLibFiles.php index 6d99e5f..c7a88bb 100644 --- a/src/CodeGen/Contracts/GeneratesLibFiles.php +++ b/src/CodeGen/Contracts/GeneratesLibFiles.php @@ -1,10 +1,13 @@ - 'content' * ] * - * @param list $operations - * @return array + * @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): array; -} \ No newline at end of file + public function emitFiles(array $operations, ServerMetadata $metadata, AliasRegistry $registry): array; +} diff --git a/src/CodeGen/Contracts/GeneratesOperationCode.php b/src/CodeGen/Contracts/GeneratesOperationCode.php index cab5da1..5631504 100644 --- a/src/CodeGen/Contracts/GeneratesOperationCode.php +++ b/src/CodeGen/Contracts/GeneratesOperationCode.php @@ -1,17 +1,19 @@ -queryUrl, '{fqn}')) { - throw new InvalidArgumentException('Query URL must contain {fqn} placeholder'); + public ServerConfiguration $configuration, + ) { + if (! str_contains($this->queryUrl, '{key}')) { + throw new CodeGenException('Query URL must contain {key} placeholder'); } - if (!str_contains($this->commandUrl, '{fqn}')) { - throw new InvalidArgumentException('Command URL must contain {fqn} placeholder'); + if (! str_contains($this->commandUrl, '{key}')) { + throw new CodeGenException('Command URL must contain {key} placeholder'); } } - public function getFullyQualifiedUrl(Operation $operation): string + #[NoDiscard] + public function withConfiguration(ServerConfiguration $configuration): self { - return $operation->definition->type === OperationType::QUERY - ? str_replace('{fqn}', $operation->key, $this->queryUrl) - : str_replace('{fqn}', $operation->key, $this->commandUrl); + return new self($this->queryUrl, $this->commandUrl, $configuration); } -} \ No newline at end of file +} diff --git a/src/CodeGen/Data/TypedOperation.php b/src/CodeGen/Data/TypedOperation.php index dae3740..29e10d4 100644 --- a/src/CodeGen/Data/TypedOperation.php +++ b/src/CodeGen/Data/TypedOperation.php @@ -1,9 +1,12 @@ - $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'; + } + + /** + * 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 string $inputDefinition, - public readonly string $outputDefinition, - public readonly string $errorDefinition, + public readonly Typescript $inputDef, + public readonly Typescript $outputDef, + public readonly string $domainErrors, public readonly Operation $operation, - ) + ) { + } + + /** + * 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 + */ + public function usedAliases(): array { + $aliases = array_values(array_unique([ + ...$this->inputDef->registry->usedAliases(), + ...$this->outputDef->registry->usedAliases(), + ])); + sort($aliases); + + return $aliases; } -} \ No newline at end of file +} diff --git a/src/CodeGen/Exceptions/CodeGenException.php b/src/CodeGen/Exceptions/CodeGenException.php new file mode 100644 index 0000000..5d0db42 --- /dev/null +++ b/src/CodeGen/Exceptions/CodeGenException.php @@ -0,0 +1,19 @@ + $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/Helpers/TypeScriptFile.php b/src/CodeGen/Helpers/TypeScriptFile.php deleted file mode 100644 index 856a6ae..0000000 --- a/src/CodeGen/Helpers/TypeScriptFile.php +++ /dev/null @@ -1,75 +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 = implode(PHP_EOL, array_map(fn(TypescriptImportStatement $import): string => $import->toString(), $this->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 ee16688..0000000 --- a/src/CodeGen/Helpers/TypescriptImportStatement.php +++ /dev/null @@ -1,56 +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); - } - - public function toString(): string - { - $imports = $this->getImports(); - usort($imports, fn(string $a, string $b): int => strcmp($a, $b)); - - $importedValues = implode(', ', $imports); - return "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/TypescriptDefinitionGenerator.php b/src/CodeGen/TypescriptDefinitionGenerator.php deleted file mode 100644 index c1d2ef0..0000000 --- a/src/CodeGen/TypescriptDefinitionGenerator.php +++ /dev/null @@ -1,114 +0,0 @@ -emitBrandedTypes && $node instanceof BuiltInNode && $node->brand) { - $encodedBrand = json_encode($node->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 e7cc333..c3af88a 100644 --- a/src/CodeGen/TypescriptServerCodeGenerator.php +++ b/src/CodeGen/TypescriptServerCodeGenerator.php @@ -1,43 +1,65 @@ - $generators + * * @throws InvalidGeneratorDependencies */ public function __construct( - private array $generators, - private TypescriptDefinitionGenerator $definitionGenerator, - ) - { - $this->verifyGeneratorDependencies(); + private array $generators, + private TypescriptGenerator $typescriptGenerator = new TypescriptGenerator(), + ) { + $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) { @@ -45,52 +67,84 @@ private function verifyGeneratorDependencies(): void } foreach ($generator->dependsOnGenerator() as $className) { - if (!in_array($className, $generatorClassNames, true)) { - $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."; } } } - if (!empty($issues)) { + if (count($issues) > 0) { 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. + array_flip($generator->dependsOnGenerator()) + |> (static fn ($x) => array_intersect_key($instances, $x)) + |> $generator->setDependencies(...); + } } /** - * @param Server $server - * @param ServerMetadata $metadata * @param list $ignore - * @return array + * @return array */ 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. + * * @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; - }) - ); - - $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); - - return new TypedOperation( - $inputType, - $successOutputType, - $possibleErrorType, - $operation, - ); - }, $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), + ) |> 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(); + + // 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 ($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), + domainErrors: ErrorTypescript::domainTypesFor($operation->definition), + operation: $operation, + ); + }, $filteredDefinitions); // Deterministically sort for consistency between systems usort($definitions, function (TypedOperation $a, TypedOperation $b): int { @@ -100,45 +154,55 @@ 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), + ...$this->generateLibFiles($definitions, $metadata, $registry), ...$this->generateOperationDefinitions($definitions, $metadata), ]; } - private function generateAllErrorTypes(Server $server, Definition $operation): string - { - $possibleTypes = Arrays::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}}"; - }, [...$server->exceptionPresenters, $server->defaultPresenter])); - - return implode('|', $possibleTypes); - } - /** * @param list $definitions - * @param ServerMetadata $metadata - * @return array + * @param AliasRegistry $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, AliasRegistry $registry): array { return array_reduce( $this->generators, - function (array $carry, $codeGenerator) use ($definitions, $metadata): array { + /** + * @param array $carry + * @return array + */ + function (array $carry, $codeGenerator) use ($definitions, $metadata, $registry): array { if (!$codeGenerator instanceof GeneratesLibFiles) { return $carry; } - foreach ($codeGenerator->emitFiles($definitions, $metadata) 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 _."); + foreach ($codeGenerator->emitFiles($definitions, $metadata, $registry) as $fileName => $fileContent) { + 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 _."); } - $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. + // + // 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->withModulesResolvedBy(Paths::fromInsideLib(...))); } + return $carry; }, [] @@ -147,16 +211,31 @@ function (array $carry, $codeGenerator) use ($definitions, $metadata): array { /** * @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(); + + // 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. + $file = $operationFiles[$fileKey] ?? new TypescriptFile(); foreach ($this->generators as $codeGenerator) { if (!$codeGenerator instanceof GeneratesOperationCode) { @@ -164,13 +243,13 @@ 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; } -} \ No newline at end of file +} diff --git a/src/CodeGen/Utils/ErrorTypescript.php b/src/CodeGen/Utils/ErrorTypescript.php new file mode 100644 index 0000000..f07eebf --- /dev/null +++ b/src/CodeGen/Utils/ErrorTypescript.php @@ -0,0 +1,40 @@ + json_encode($name, JSON_THROW_ON_ERROR), + $names, + )); + } +} diff --git a/src/CodeGen/Utils/OutputDirectory.php b/src/CodeGen/Utils/OutputDirectory.php new file mode 100644 index 0000000..5402902 --- /dev/null +++ b/src/CodeGen/Utils/OutputDirectory.php @@ -0,0 +1,157 @@ + realpath(...) + |> Assertions::string(...) + |> unlink(...) + |> Assertions::true(...); + } + } + + /** + * @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)) { + 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.' + ); + } + } + + foreach ($files as $fileName => $file) { + $fullPath = "{$directory}/{$fileName}"; + $directoryPath = dirname($fullPath); + + if (!file_exists($directoryPath) && !is_dir($directoryPath)) { + mkdir($directoryPath, 0777, true); + } + + file_put_contents($fullPath, $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. + * + * 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 + { + $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 || !self::isGeneratedFile($realPath)) { + continue; + } + + $fileNames[] = substr($realPath, strlen($root) + 1); + } + + 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/CodeGen/Utils/Paths.php b/src/CodeGen/Utils/Paths.php index 049fd5a..83ad48e 100644 --- a/src/CodeGen/Utils/Paths.php +++ b/src/CodeGen/Utils/Paths.php @@ -1,11 +1,39 @@ -)`, 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">)`. + * + * 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. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class Brand +{ + /** + * @param string|Closure(string): string|null $name + */ + public function __construct( + public string|Closure|null $name = null, + ) { + } + + public function brandName(string $classString): string + { + $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}"); + } + + return $name; + } +} diff --git a/src/Contracts/Attributes/Castable.php b/src/Contracts/Attributes/Castable.php index 025067d..1fc9dd1 100644 --- a/src/Contracts/Attributes/Castable.php +++ b/src/Contracts/Attributes/Castable.php @@ -1,4 +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/ExposeAs.php b/src/Contracts/Attributes/ExposeAs.php new file mode 100644 index 0000000..5b9f6a1 --- /dev/null +++ b/src/Contracts/Attributes/ExposeAs.php @@ -0,0 +1,34 @@ +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/Middleware.php b/src/Contracts/Attributes/Middleware.php index 88893a8..1df36eb 100644 --- a/src/Contracts/Attributes/Middleware.php +++ b/src/Contracts/Attributes/Middleware.php @@ -1,24 +1,37 @@ - - it is exported into the operations cache as plain PHP code. + * + * ```php + * #[Command('users')] + * #[Middleware(AuthMiddleware::class)] + * #[Middleware(RateLimitMiddleware::class, config: ['limit' => 10])] + * public function create(array $input): array { } + * ``` + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final readonly class Middleware { /** - * @var array - */ - public array $middleware; - - /** - * @param class-string|array $middleware + * @param class-string> $middleware + * @param array $config */ public function __construct( - string|array $middleware, - ) - { - $this->middleware = is_array($middleware) ? $middleware : [$middleware]; + public string $middleware, + public array $config = [], + ) { } -} \ No newline at end of file +} diff --git a/src/Contracts/Attributes/Named.php b/src/Contracts/Attributes/Named.php new file mode 100644 index 0000000..483e12d --- /dev/null +++ b/src/Contracts/Attributes/Named.php @@ -0,0 +1,81 @@ +name === null => explode('\\', $classString) |> array_last(...), + $this->name instanceof Closure => ($this->name)($classString, $io), + default => $this->name, + }; + + if (! Syntax::isValidIdentifier($name)) { + throw InvalidStringLiteralException::notAValidTypescriptIdentifier($name, "#[Named] on {$classString}"); + } + + return $name; + } +} diff --git a/src/Contracts/Attributes/Optional.php b/src/Contracts/Attributes/Optional.php index 4bb705c..83a5fc9 100644 --- a/src/Contracts/Attributes/Optional.php +++ b/src/Contracts/Attributes/Optional.php @@ -1,15 +1,19 @@ -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 947655d..344a028 100644 --- a/src/Contracts/Attributes/Throws.php +++ b/src/Contracts/Attributes/Throws.php @@ -1,23 +1,72 @@ - $exceptionClass + * @param class-string $exceptionClass + * @param non-empty-string|null $name */ public function __construct( - public string $exceptionClass, - ) + 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; } -} \ No newline at end of file +} diff --git a/src/Contracts/Client.php b/src/Contracts/Client.php index 214c7f6..8c93cbe 100644 --- a/src/Contracts/Client.php +++ b/src/Contracts/Client.php @@ -1,26 +1,30 @@ - 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/Contracts/Constraint.php b/src/Contracts/Constraint.php deleted file mode 100644 index ccc0fa2..0000000 --- a/src/Contracts/Constraint.php +++ /dev/null @@ -1,10 +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 deleted file mode 100644 index be19881..0000000 --- a/src/Contracts/ExceptionPresenter.php +++ /dev/null @@ -1,40 +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/Contracts/ExportableToPhpCode.php b/src/Contracts/ExportableToPhpCode.php index 2945474..d5a7a2c 100644 --- a/src/Contracts/ExportableToPhpCode.php +++ b/src/Contracts/ExportableToPhpCode.php @@ -1,8 +1,13 @@ - */ public function all(): array; -} \ No newline at end of file +} 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 @@ - + */ + public array $metadata { + get; + } + + /** + * Overwrite all existing metadata. + * + * @param array $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/Contracts/SerializableClient.php b/src/Contracts/SerializableClient.php new file mode 100644 index 0000000..17546b3 --- /dev/null +++ b/src/Contracts/SerializableClient.php @@ -0,0 +1,22 @@ +|null + */ + public function serializeToArray(): ?array; +} diff --git a/src/Contracts/ServerAdapter.php b/src/Contracts/ServerAdapter.php new file mode 100644 index 0000000..7bda964 --- /dev/null +++ b/src/Contracts/ServerAdapter.php @@ -0,0 +1,21 @@ + $className + */ + public function createMiddleware(string $className): MiddlewareContract; + + /** + * @template TClass + * + * @param class-string $className + * @return TClass + */ + public function createController(string $className): mixed; +} diff --git a/src/Contracts/ValidatableNode.php b/src/Contracts/ValidatableNode.php deleted file mode 100644 index 1c38aa5..0000000 --- a/src/Contracts/ValidatableNode.php +++ /dev/null @@ -1,8 +0,0 @@ - + * 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> */ - private(set) array $issues = []; + public private(set) array $issues = []; public function enterPath(int|string $path): void { @@ -41,18 +46,38 @@ private function pathAsString(): string : Issues::ROOT_PATH; } + #[Override] 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. + * + * 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 { - 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())) { + if ($this->path === []) { + $this->issues = []; + + return; + } + + $current = $this->pathAsString(); + foreach (array_keys($this->issues) as $path) { + $path = (string) $path; + if ($path === $current || str_starts_with($path, "{$current}.")) { unset($this->issues[$path]); } } } -} \ No newline at end of file +} diff --git a/src/Executor/Data/Failure.php b/src/Executor/Data/Failure.php index 282ee55..a32a56b 100644 --- a/src/Executor/Data/Failure.php +++ b/src/Executor/Data/Failure.php @@ -1,15 +1,36 @@ -issues->serializeToCompleteString()}."; + } + + public function isSuccess(): false { - parent::__construct("Validation failed: {$this->issues->serializeToCompleteString()}.", 422); + return false; } -} \ No newline at end of file +} diff --git a/src/Executor/Data/Issue.php b/src/Executor/Data/Issue.php index e72d6ed..83d9b64 100644 --- a/src/Executor/Data/Issue.php +++ b/src/Executor/Data/Issue.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, @@ -29,18 +28,28 @@ public function __construct( } /** - * @param list $messages + * 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 */ 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 { @@ -52,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 { @@ -63,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 26e3337..bfd1068 100644 --- a/src/Executor/Data/IssueMessage.php +++ b/src/Executor/Data/IssueMessage.php @@ -1,14 +1,27 @@ - $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 = [], - ) - { + ) { } /** - * @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(is_array($issues) ? $issues : [$issues]), + fn (string|array $issues) => Issue::fromMessageArray( + is_array($issues) ? array_values($issues) : [$issues], + ), $issuesMap ) ); @@ -31,20 +36,23 @@ public static function fromMessages(array $issuesMap): self public function isEmpty(): bool { - return empty($this->issuesMap); + return count($this->issuesMap) === 0; } /** @return list */ public function at(?string $path): array { $path ??= self::ROOT_PATH; + return $this->issuesMap[$path] ?? []; } /** @return list */ public function allFlat(): array { - return array_merge(...array_values($this->issuesMap)); + return $this->issuesMap === [] + ? [] + : array_merge(...array_values($this->issuesMap)); } /** @@ -53,7 +61,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); } @@ -62,9 +70,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 ? [ @@ -83,9 +90,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 true; } -} \ No newline at end of file + /** + * 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(); + } +} diff --git a/src/Executor/Exceptions/SchemaException.php b/src/Executor/Exceptions/SchemaException.php new file mode 100644 index 0000000..a71b9a6 --- /dev/null +++ b/src/Executor/Exceptions/SchemaException.php @@ -0,0 +1,20 @@ + + */ + 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/Executor/Handlers/CustomClassHandler.php b/src/Executor/Handlers/CustomClassHandler.php index 2b78c0f..e935cfa 100644 --- a/src/Executor/Handlers/CustomClassHandler.php +++ b/src/Executor/Handlers/CustomClassHandler.php @@ -1,83 +1,91 @@ - */ -final class CustomClassHandler implements Handler +final readonly class CustomClassHandler implements Handler { - - - /** @param CustomCastingNode $node - * @return stdClass|array|Value - */ - public function serialize(NodeInterface $node, mixed $value, Context $context, Executor $executor): stdClass|array|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) { return Value::INVALID; } - if ($node->strategy === ObjectCastStrategy::COLLECTION && is_array($object)) { - return $object; - } - - if (!$object instanceof stdClass) { + if (! $object instanceof stdClass) { $objectClass = get_class($object); $context->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; } 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) { + $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; } - try { - if ($node->strategy === ObjectCastStrategy::COLLECTION) { - return new ($node->fullyQualifiedCastingClass)($arrayValue); - } + if (! is_array($arrayValue)) { + $context->addIssue(Issue::invalidType('array', $arrayValue)); + + return Value::INVALID; + } + try { if ($node->strategy === ObjectCastStrategy::CONSTRUCTOR) { 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, [ @@ -88,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 458285f..cb981c2 100644 --- a/src/Executor/Handlers/IntersectionHandler.php +++ b/src/Executor/Handlers/IntersectionHandler.php @@ -1,30 +1,35 @@ - */ -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 +41,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; @@ -53,17 +60,18 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu $mode ??= is_array($partialObject) ? 'array' : 'object'; if ( - ($mode === 'object' && !$partialObject instanceof stdClass) || - ($mode === 'array' && !is_array($partialObject)) + ($mode === 'object' && ! $partialObject instanceof stdClass) || + ($mode === 'array' && ! is_array($partialObject)) ) { $context->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; } @@ -73,7 +81,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 9bfffea..9812962 100644 --- a/src/Executor/Handlers/ListHandler.php +++ b/src/Executor/Handlers/ListHandler.php @@ -1,27 +1,34 @@ - */ -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 { - if (!is_iterable($value)) { + assert($node instanceof ListNode); + + if (! is_iterable($value)) { + $context->addIssue(Issue::invalidType('iterable', $value)); + return Value::INVALID; } @@ -34,6 +41,7 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E if ($result === Value::INVALID) { $context->leavePath(); + return Value::INVALID; } @@ -42,20 +50,25 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E $index++; $context->leavePath(); } + return $values; } /** - * @param ListNode $node * @return Value|array */ + #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): array|Value { - if (!is_array($value) || !array_is_list($value)) { + assert($node instanceof ListNode); + + if (! is_array($value) || ! array_is_list($value)) { + $context->addIssue(Issue::invalidType('list', $value)); + return Value::INVALID; } - if (empty($value)) { + if (count($value) === 0) { return []; } @@ -68,6 +81,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu if ($result === Value::INVALID) { $context->leavePath(); + return Value::INVALID; } @@ -78,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 c9fca6d..3c09daa 100644 --- a/src/Executor/Handlers/RecordHandler.php +++ b/src/Executor/Handlers/RecordHandler.php @@ -1,43 +1,51 @@ - */ -final class RecordHandler implements Handler +final readonly class RecordHandler implements Handler { - - /** @param RecordNode $node */ + /** + * 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 { - if (!is_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(); @@ -47,32 +55,36 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E } $values[$key] = $result; } + return (object) $values; } /** - * @param RecordNode $node - * @return array|Value::INVALID + * @return array|Value::INVALID */ + #[Override] public function parse(NodeInterface $node, mixed $value, Context $context, Executor $executor): array|Value { - if (!is_array($value)) { + /** @var RecordNode $node */ + + 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(); @@ -80,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; } -} \ No newline at end of file + + /** + * 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/Executor/Handlers/StructHandler.php b/src/Executor/Handlers/StructHandler.php index 9f9be35..67c70d6 100644 --- a/src/Executor/Handlers/StructHandler.php +++ b/src/Executor/Handlers/StructHandler.php @@ -1,30 +1,46 @@ - */ -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) { - if (!$propertyNode->propertyType->isOutput()) { + assert($propertyNode instanceof PropertyNode, self::REFERENCE_INVARIANT); + + if (! $propertyNode->propertyType->isOutput()) { continue; } @@ -33,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; } @@ -49,6 +67,7 @@ public function serialize(NodeInterface $node, mixed $value, Context $context, E ] )); $context->leavePath(); + return Value::INVALID; } @@ -70,10 +89,12 @@ 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 { - if (!is_array($value) && !$value instanceof stdClass) { + assert($node instanceof StructNode); + + if (! is_array($value) && ! $value instanceof stdClass) { $context->addIssue(new Issue( IssueMessage::INVALID_TYPE, [ @@ -81,12 +102,15 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu 'value' => $value, ] )); + return Value::INVALID; } $struct = []; foreach ($node->properties as $propertyNode) { - if (!$propertyNode->propertyType->isInput()) { + assert($propertyNode instanceof PropertyNode, self::REFERENCE_INVARIANT); + + if (! $propertyNode->propertyType->isInput()) { continue; } @@ -104,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(); @@ -119,6 +145,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu 'message' => "Missing property: {$propertyNode->name}", ] )); + return Value::INVALID; } } @@ -150,14 +177,15 @@ 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; } 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, }; } -} \ No newline at end of file +} diff --git a/src/Executor/Handlers/TupleHandler.php b/src/Executor/Handlers/TupleHandler.php index 2f75954..e57dd59 100644 --- a/src/Executor/Handlers/TupleHandler.php +++ b/src/Executor/Handlers/TupleHandler.php @@ -1,37 +1,60 @@ - */ -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 { - if (!is_array($value) && !$value instanceof ArrayAccess) { + assert($node instanceof TupleNode); + + if (! is_array($value) && ! $value instanceof ArrayAccess) { + $context->addIssue(Issue::invalidType('array', $value)); + return Value::INVALID; } $tupleValues = []; - foreach ($node->types as $index => $type) { + 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(); + return Value::INVALID; } $tupleValues[] = $result; @@ -42,31 +65,52 @@ 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 { - if (!is_array($value) || !array_is_list($value)) { + assert($node instanceof TupleNode); + + if (! is_array($value) || ! array_is_list($value)) { + $context->addIssue(Issue::invalidType('list', $value)); + return Value::INVALID; } - $expectedCount = count($node->types); + $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; } $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) { $context->leavePath(); + return Value::INVALID; } $tupleValues[] = $result; $context->leavePath(); } + return $tupleValues; } -} \ No newline at end of file + + /** + * @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); + } +} diff --git a/src/Executor/Handlers/UnionHandler.php b/src/Executor/Handlers/UnionHandler.php index 911b758..474e56e 100644 --- a/src/Executor/Handlers/UnionHandler.php +++ b/src/Executor/Handlers/UnionHandler.php @@ -1,43 +1,49 @@ -> */ -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 { + /** @var UnionNode $node */ + // Quick check for nullability. if ($value === null && $node->acceptsNull()) { return null; } // 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,34 +59,42 @@ 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(); + return $result; } } + + $context->addIssue(Issue::invalidType((string) $node, $value)); + 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; } - 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; } @@ -88,14 +102,25 @@ 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; } // 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(); + return $result; } } @@ -106,6 +131,7 @@ public function parse(NodeInterface $node, mixed $value, Context $context, Execu 'message' => 'No valid union type found.', ] )); + return Value::INVALID; } @@ -114,8 +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 */ 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 7ebde54..ccafb5d 100644 --- a/src/Executor/SchemaExecutor.php +++ b/src/Executor/SchemaExecutor.php @@ -1,10 +1,9 @@ -partialFailures, - runConstraints: true, coercePrimitives: $options->coercePrimitives, ); $result = $this->executeParse($node, $input, $context); @@ -71,7 +74,6 @@ public function serialize(NodeInterface $node, mixed $output, SerializationOptio { $context = new Context( partialFailures: $options->partialFailures, - runConstraints: $options->runConstraints, ); $result = $this->executeSerialize($node, $output, $context); @@ -86,18 +88,29 @@ 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. + // 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); } + // 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) { - 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, + 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), + // 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. @@ -111,23 +124,28 @@ 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) { $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; } + // Ordered by how often each case is hit; see executeSerialize(). return match (true) { - 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), - default => Value::INVALID, + 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), + // 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/ASTOptimizer.php b/src/Parser/ASTOptimizer.php deleted file mode 100644 index c6ad327..0000000 --- a/src/Parser/ASTOptimizer.php +++ /dev/null @@ -1,173 +0,0 @@ - */ - private array $dedupedNodes = []; - - public function __construct( - private readonly string $registryVariableName = 'registry', - ) - { - } - - /** - * @param array $nodes - */ - 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}"); - } - } - - /** - * @param array $nodes - */ - 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'); - } - - $this->dedupedNodes = []; - - $optimizedNodes = array_map( - fn(Closure|NodeInterface $node) => $this->dedupeNode($node instanceof Closure ? $node() : $node), - $nodes - ); - - $registryClass = PHPExport::absolute(CachedTypeRegistry::class); - - $dedupedAsString = Arrays::mapWithKeys( - $this->dedupedNodes, - fn(string $key, NodeInterface $node) => PHPExport::export($key) . " => static fn({$registryClass} \${$this->registryVariableName}) => {$node->exportPhpCode()}", - ); - - $optimizedNodesFactories = Arrays::mapWithKeys( - $optimizedNodes, - fn(string $key, NodeInterface $ast) => PHPExport::export($key) . " => static fn({$registryClass} \${$this->registryVariableName}) => {$ast->exportPhpCode()}" - ); - - $factories = implode(',', [ - ... $dedupedAsString, - ... $optimizedNodesFactories, - ]); - - return "new {$registryClass}([{$factories}])"; - } - - /** - * @template T of NodeInterface - * @param T $node - * @return T|ReferencedNode - */ - private function dedupeNode(NodeInterface $node): NodeInterface - { - if ($node instanceof NamedNode) { - return $this->dedupeNode($node->node); - } - - if ($node instanceof ReferencedNode) { - return $node; - } - - if ($node instanceof LeafNode) { - $identifier = '#leaf_' . sha1((string)$node); - $this->dedupedNodes[$identifier] ??= $node; - return new ReferencedNode($identifier, (string)$node, $this->registryVariableName); - } - - if ($node instanceof PropertyNode) { - $identifier = '#prop_' . sha1((string)$node); - $this->dedupedNodes[$identifier] ??= new PropertyNode( - $node->name, - $this->dedupeNode($node->node), - $node->isOptional, - $node->propertyType - ); - - return new ReferencedNode($identifier, (string)$node, $this->registryVariableName); - } - - // Deep optimization - if ($node instanceof StructNode) { - $deepOptimizedNode = 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); - } - - // ToDo: Further optimization for example on union nodes with only Primitive Types or - // more intelligent node determination for better runtime performance. - return match ($node::class) { - ConstraintNode::class => $this->flattenConstraintNode($node), - CustomCastingNode::class => new CustomCastingNode( - $this->dedupeNode($node->node), - $node->fullyQualifiedCastingClass, - $node->strategy, - ), - ListNode::class => new ListNode( - $this->dedupeNode($node->node), - ), - RecordNode::class => new RecordNode( - $this->dedupeNode($node->node), - ), - TupleNode::class => new TupleNode( - array_map($this->dedupeNode(...), $node->types), - ), - UnionNode::class => new UnionNode( - array_map($this->dedupeNode(...), $node->types), - $node->discriminator, - $node->discriminatorMap, - ), - IntersectionNode::class => new IntersectionNode( - array_map($this->dedupeNode(...), $node->types), - ), - default => throw new RuntimeException('Unknown node type: ' . $node::class), - }; - } - - private function flattenConstraintNode(ConstraintNode $node): ConstraintNode - { - /** @var list $constraints */ - $constraints = []; - while ($node instanceof ConstraintNode) { - array_push($constraints, ...$node->constraints); - $node = $node->node; - } - - return new ConstraintNode( - $this->dedupeNode($node), - $constraints, - ); - } -} \ No newline at end of file diff --git a/src/Parser/AstSorter.php b/src/Parser/AstSorter.php deleted file mode 100644 index faafe61..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)), - NamedNode::class => new NamedNode(self::sort($node->node), $node->name), - 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/Consumers/ArrayConsumer.php b/src/Parser/Consumers/ArrayConsumer.php deleted file mode 100644 index c42c044..0000000 --- a/src/Parser/Consumers/ArrayConsumer.php +++ /dev/null @@ -1,206 +0,0 @@ - => ListNode - * array => RecordNode - * array{int, int} => TupleNode - * array{0: int, 1: int} => TupleNode - */ -final readonly class ArrayConsumer implements TypeConsumer -{ - use InteractsWithGenerics; - - /** - * Add classes which are Collection classes. A collection class is a generic class - * which is iterable and supports 1 (list) or 2 (list|record) generics. A collection class constructor - * is expected to accept exactly one argument, a PHP array. - * - * Example for it is laravel collections: - * - Collection => Array<{id: string}> - * - Collection => Record - * @param array $collectionLikeClasses - */ - public function __construct( - public array $collectionLikeClasses = [], - ) - { - } - - public function canConsume(ParserState $state): bool - { - if (!$state->currentTokenIs(TokenType::IDENTIFIER)) { - 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); - } - - /** - * @throws InvalidSyntaxException - */ - public function consume(ParserState $state, TypeParser $parser): RecordNode|ListNode|TupleNode|CustomCastingNode - { - $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) || !in_array($type, ['array', 'list'], true)) { - $state->produceSyntaxError("Expected Array Type Identifier: array or list"); - } - - // 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)) { - 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"); - } - - $maxGenerics = $type === 'list' ? 1 : 2; - - // Consuming of the array type identifier - $state->advance(); - - // No generics - if (!$state->currentTokenIs(TokenType::LT)) { - return new ListNode(new BuiltInNode(BuiltInType::MIXED)); - } - - $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; - } - - $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]), - default => $state->produceSyntaxError("Array key type must be 'string' or 'int'. Got: {$keyType}"), - }; - - return $customType - ? new CustomCastingNode($node, $customType, ObjectCastStrategy::COLLECTION) - : $node; - } - - /** - * @throws InvalidSyntaxException - */ - private function consumeIntegerDeterminedTuple(ParserState $state, TypeParser $parser): TupleNode - { - if (!$state->currentTokenIs(TokenType::IDENTIFIER, 'array')) { - $state->produceSyntaxError("Expected array"); - } - $state->advance(); - - if (!$state->currentTokenIs(TokenType::LBRACE)) { - $state->produceSyntaxError("Expected {"); - } - $state->advance(); - - $types = []; - while ($state->canAdvance()) { - if ($state->currentTokenIs(TokenType::RBRACE)) { - break; - } - - if ($state->currentTokenIs(TokenType::COMMA) && $state->nextTokenIs(TokenType::RBRACE)) { - $state->advance(); - break; - } - - if ($state->currentTokenIs(TokenType::COMMA)) { - $state->advance(); - continue; - } - - 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"); - } - $state->advance(); - $types[] = $parser->consume($state, TokenType::COMMA, TokenType::RBRACE); - } - - $state->advance(); - return new TupleNode($types); - } - - /** - * @throws InvalidSyntaxException - */ - private function consumeTuple(ParserState $state, TypeParser $parser): TupleNode - { - if (!$state->currentTokenIs(TokenType::IDENTIFIER, 'array')) { - $state->produceSyntaxError("Expected array"); - } - $state->advance(); - - if (!$state->currentTokenIs(TokenType::LBRACE)) { - $state->produceSyntaxError("Expected {"); - } - $state->advance(); - - $types = []; - while ($state->canAdvance()) { - $types[] = $parser->consume($state, TokenType::COMMA, TokenType::RBRACE); - - if ($state->currentTokenIs(TokenType::RBRACE)) { - break; - } - - if ($state->currentTokenIs(TokenType::COMMA) && $state->nextTokenIs(TokenType::RBRACE)) { - $state->advance(); - break; - } - - if (!$state->currentTokenIs(TokenType::COMMA)) { - $state->produceSyntaxError("Expected comma for union: array{string, int}"); - } - $state->advance(); - } - - $state->advance(); - return new TupleNode($types); - } -} \ No newline at end of file diff --git a/src/Parser/Consumers/BuiltInLeafConsumer.php b/src/Parser/Consumers/BuiltInLeafConsumer.php deleted file mode 100644 index d57ced5..0000000 --- a/src/Parser/Consumers/BuiltInLeafConsumer.php +++ /dev/null @@ -1,98 +0,0 @@ -currentTokenIs(TokenType::IDENTIFIER)) { - return false; - } - - return in_array($state->current()->value, [ - 'string', - 'bool', - 'null', - 'float', - 'mixed', - 'truthy-string', - 'non-falsy-string', - 'non-empty-string', - 'scalar', - 'positive-int', - 'negative-int', - "non-negative-int", - 'non-positive-int', - 'numeric', - ]); - } - - /** - * @throws InvalidSyntaxException - */ - public function consume(ParserState $state, TypeParser $parser): NodeInterface - { - $token = $state->current(); - $state->advance(); - - return match ($token->value) { - 'string', - 'bool', - 'null', - 'float', - 'mixed' => new BuiltInNode(BuiltInType::from($token->value)), - 'truthy-string', - 'non-falsy-string' => new ConstraintNode( - new BuiltInNode(BuiltInType::STRING), - [new NonFalsyStringValidator()], - ), - 'non-empty-string' => new ConstraintNode( - new BuiltInNode(BuiltInType::STRING), - [new NonEmptyString()], - ), - 'scalar' => new UnionNode([ - new BuiltInNode(BuiltInType::INT), - new BuiltInNode(BuiltInType::FLOAT), - new BuiltInNode(BuiltInType::BOOL), - new BuiltInNode(BuiltInType::STRING), - ]), - 'positive-int' => new ConstraintNode( - new BuiltInNode(BuiltInType::INT), - [new LengthValidator(min: 1, including: true)] - ), - 'negative-int' => new ConstraintNode( - new BuiltInNode(BuiltInType::INT), - [new LengthValidator(max: -1, including: true)] - ), - "non-negative-int" => new ConstraintNode( - new BuiltInNode(BuiltInType::INT), - [new LengthValidator(min: 0, including: true)] - ), - 'non-positive-int' => new ConstraintNode( - new BuiltInNode(BuiltInType::INT), - [new LengthValidator(max: 0, including: true)] - ), - 'numeric' => new UnionNode([ - new BuiltInNode(BuiltInType::INT), - new BuiltInNode(BuiltInType::FLOAT), - ]), - default => $state->produceSyntaxError('Expected valid built-in type, got ' . $token->value), - }; - } -} \ No newline at end of file diff --git a/src/Parser/Consumers/ClassConstConsumer.php b/src/Parser/Consumers/ClassConstConsumer.php deleted file mode 100644 index 4bab5e8..0000000 --- a/src/Parser/Consumers/ClassConstConsumer.php +++ /dev/null @@ -1,45 +0,0 @@ -currentTokenIs(TokenType::CLASS_CONST); - } - - /** @throws InvalidSyntaxException */ - public function consume(ParserState $state, TypeParser $parser): LiteralNode - { - $token = $state->current(); - [$className, $constOrEnumCase] = explode('::', $token->value); - $fqcn = $state->context->toFullyQualifiedClassName($className); - - try { - $reflection = new ReflectionClass($fqcn); - $const = $reflection->getConstant($constOrEnumCase); - $isEnum = $const instanceof UnitEnum; - $state->advance(); - - return new LiteralNode( - $isEnum ? LiteralType::ENUM_CASE : LiteralType::identifyPrimitiveTypeValue($const), - $const - ); - } catch (Throwable $exception) { - $state->produceSyntaxError("Could not identify class const or enum", $exception); - } - } -} \ No newline at end of file diff --git a/src/Parser/Consumers/IntConsumer.php b/src/Parser/Consumers/IntConsumer.php deleted file mode 100644 index 6b3666a..0000000 --- a/src/Parser/Consumers/IntConsumer.php +++ /dev/null @@ -1,65 +0,0 @@ -currentTokenIs(TokenType::IDENTIFIER, 'int'); - } - - /** - * @throws InvalidSyntaxException - */ - public function consume(ParserState $state, TypeParser $parser): NodeInterface - { - $state->advance(); - - if (!$state->currentTokenIs(TokenType::LT)) { - return new BuiltInNode(BuiltInType::INT); - } - - $state->advance(); - $min = match (true) { - $state->currentTokenIs(TokenType::INT) => (int)$state->current()->value, - $state->currentTokenIs(TokenType::IDENTIFIER, 'min') => PHP_INT_MIN, - default => $state->produceSyntaxError('Expected int or min'), - }; - - $state->advance(); - if (!$state->currentTokenIs(TokenType::COMMA)) { - $state->produceSyntaxError("Expected comma"); - } - $state->advance(); - - $max = match (true) { - $state->currentTokenIs(TokenType::INT) => (int)$state->current()->value, - $state->currentTokenIs(TokenType::IDENTIFIER, 'max') => PHP_INT_MAX, - default => $state->produceSyntaxError('Expected int or max'), - }; - - $state->advance(); - if (!$state->current()->is(TokenType::GT)) { - $state->produceSyntaxError("Expected >"); - } - - $state->advance(); - - return new ConstraintNode( - new BuiltInNode(BuiltInType::INT), - [new LengthValidator(min: $min, max: $max, including: true)] - ); - } -} \ No newline at end of file diff --git a/src/Parser/Consumers/LiteralConsumer.php b/src/Parser/Consumers/LiteralConsumer.php deleted file mode 100644 index 38b84cc..0000000 --- a/src/Parser/Consumers/LiteralConsumer.php +++ /dev/null @@ -1,30 +0,0 @@ -current()->isAnyTypeOf(TokenType::BOOL, TokenType::STRING, TokenType::FLOAT, TokenType::INT); - } - - public function consume(ParserState $state, TypeParser $parser): NodeInterface - { - $token = $state->current(); - $state->advance(); - - return new LiteralNode( - LiteralType::identifyPrimitiveTypeValue($token->coercedValue()), - $token->coercedValue(), - ); - } -} \ No newline at end of file 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/Consumers/UtilsConsumer.php b/src/Parser/Consumers/UtilsConsumer.php deleted file mode 100644 index ebc0c00..0000000 --- a/src/Parser/Consumers/UtilsConsumer.php +++ /dev/null @@ -1,104 +0,0 @@ -current()->value, ['Pick', 'Omit', 'BrandedString', 'BrandedInt'], true); - } - - 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); - } - - $literalValue = $literalNode->value; - if (!is_string($literalValue)) { - $state->produceSyntaxError("Expected literal string value for branded type, got: " . gettype($literalValue)); - } - - return new BuiltInNode( - match ($type) { - 'BrandedString' => BuiltInType::STRING, - 'BrandedInt' => BuiltInType::INT, - }, - brand: $literalValue - ); - } - - [$nodeToPickFrom, $pick] = $this->consumeGenerics($state, $parser, 2, 2); - - if (!$nodeToPickFrom instanceof StructNode && !$nodeToPickFrom instanceof CustomCastingNode) { - $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 - ->filter(fn(PropertyNode $propertyNode): bool => $propertyNode->propertyType->isOutput()) - ->map(fn(PropertyNode $propertyType) => $propertyType->changePropertyType(PropertyType::BOTH)) - ->ofType(StructPhpType::OBJECT) - : $nodeToPickFrom; - - return $structNode->filter( - 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"), - } - ); - } - - - /** - * @param ParserState $state - * @param NodeInterface $node - * @return list - * @throws InvalidSyntaxException - */ - private function propertiesToPickOrOmit(ParserState $state, NodeInterface $node): array - { - if ($node instanceof LiteralNode && $node->type === LiteralType::STRING) { - return [(string)$node->value]; - } - - 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 { - if ($node instanceof LiteralNode && $node->type === LiteralType::STRING) { - return (string)$node->value; - } - - $type = $node::class; - $state->produceSyntaxError("Expected string literal for picking or omitting, got: {$type}"); - }, $node->types); - } -} \ No newline at end of file diff --git a/src/Parser/Contracts/Coercible.php b/src/Parser/Contracts/Coercible.php new file mode 100644 index 0000000..448da08 --- /dev/null +++ b/src/Parser/Contracts/Coercible.php @@ -0,0 +1,10 @@ +` 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; +} diff --git a/src/Contracts/LeafNode.php b/src/Parser/Contracts/LeafNode.php similarity index 65% rename from src/Contracts/LeafNode.php rename to src/Parser/Contracts/LeafNode.php index 0b8830d..0b92cd9 100644 --- a/src/Contracts/LeafNode.php +++ b/src/Parser/Contracts/LeafNode.php @@ -1,6 +1,8 @@ - + */ + public array $nodes { + get; + } +} diff --git a/src/Parser/Data/Exceptions/InvalidSyntaxException.php b/src/Parser/Data/Exceptions/InvalidSyntaxException.php new file mode 100644 index 0000000..869f05a --- /dev/null +++ b/src/Parser/Data/Exceptions/InvalidSyntaxException.php @@ -0,0 +1,9 @@ + $aliases + * @param array $aliases */ public function __construct( public array $aliases = [], ) { } + public function isEmpty(): bool + { + return count($this->aliases) === 0; + } + public function isGlobalAlias(string $value): bool { return array_key_exists($value, $this->aliases); @@ -23,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/Definition/ParserState.php b/src/Parser/Definition/ParserState.php deleted file mode 100644 index 7f0a18f..0000000 --- a/src/Parser/Definition/ParserState.php +++ /dev/null @@ -1,130 +0,0 @@ - - */ -final class ParserState implements Iterator -{ - private int $currentIndex = 0; - private int $count; - - /** - * @param string $input - * @param list $tokens - * @param ParsingContext $context - */ - public function __construct( - public readonly string $input, - private readonly array $tokens, - public readonly ParsingContext $context, - ) - { - $this->count = count($this->tokens); - } - - private function getTokenAtIndex(int $index): ?Token - { - return $this->tokens[$index] ?? null; - } - - public function current(): Token - { - return $this->getTokenAtIndex($this->currentIndex); - } - - public function peek(int $offset = 1): ?Token - { - return $this->getTokenAtIndex(($this->currentIndex + $offset)); - } - - public function at(int $index): ?Token - { - return $this->getTokenAtIndex($index); - } - - 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); - } - - public function nextTokenIs(TokenType $type): bool - { - return $this->peek()?->type === $type; - } - - public function canAdvance(int $amount = 1): bool - { - return ($this->currentIndex + $amount) < $this->count; - } - - public function advance(int $amount = 1): void - { - if (!$this->canAdvance($amount)) { - throw new RuntimeException('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(); - $length = $token->end->offset - $token->start->offset; - - return implode(PHP_EOL, [ - "Type: {$token->type->name} ({$token->__toString()})", - $this->input, - str_pad("", $token->start->offset, ' ') . ( - $length > 0 ? str_pad("", $length, '^') : '|' - ) - ]); - } - - public function produceSyntaxError(string $message, ?Throwable $throwable = null): never - { - throw new InvalidSyntaxException( - implode(PHP_EOL, array_filter([ - "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 2ca5dea..0000000 --- a/src/Parser/Definition/TokenType.php +++ /dev/null @@ -1,36 +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 = '?'; - 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/Exceptions/InvalidSyntaxException.php b/src/Parser/Exceptions/InvalidSyntaxException.php deleted file mode 100644 index 7607775..0000000 --- a/src/Parser/Exceptions/InvalidSyntaxException.php +++ /dev/null @@ -1,10 +0,0 @@ - id => [node, exported code] + */ + private array $dedupedNodes = []; + + private const string KEY_VARIABLE_NAME = 'key'; + + public function __construct( + private readonly string $registryVariableName = 'r', + private readonly int $idLength = 10, + ) { + if ($this->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.", + ); + } + } + + /** + * 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 ParserException( + "Identity hash collision on '{$identifier}'. Increase the idLength of the ASTOptimizer.", + ); + } + + $this->dedupedNodes[$identifier] = [$node, $exported]; + + return new ReferencedNode($identifier, $originalTypeString, $this->registryVariableName); + } + + /** + * @param array $nodes + */ + public function optimizeAndWriteToFile(string $fileName, array $nodes): void + { + PHPExport::writeFileAtomically($fileName, <<generateOptimizedCode($nodes)}; +PHP); + } + + /** + * @param array $nodes + */ + public function generateOptimizedCode(array $nodes): string + { + 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), + $nodes + ); + + $registryClass = PHPExport::absolute(CachedTypeRegistry::class); + $nodeInterface = PHPExport::absolute(NodeInterface::class); + $unknownKeyException = PHPExport::absolute(UnknownTypeKeyException::class); + + $internedArms = Arrays::mapWithKeys( + $this->dedupedNodes, + fn (string $key, array $entry) => PHPExport::export($key)." => {$entry[1]},", + ); + + $schemaArms = Arrays::mapWithKeys( + $optimizedNodes, + fn (string $key, NodeInterface $ast) => PHPExport::export($key)." => {$ast->exportPhpCode()}," + ); + + $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}) }; })"; + } + + 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; + } + + /** + * 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 + { + // 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); + } + + if ($node instanceof ReferencedNode) { + return $node; + } + + if ($node instanceof LeafNode) { + 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) { + return $this->intern('p', new PropertyNode( + $node->name, + $this->dedupeNode($node->node), + $node->isOptional, + $node->propertyType + ), (string) $node); + } + + // Deep optimization + if ($node instanceof StructNode) { + /** @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 + // 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( + // 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, + ), + ListNode::class => new ListNode( + $this->dedupeNode($node->node), + ), + RecordNode::class => new RecordNode( + $this->dedupeNode($node->keyNode), + $this->dedupeNode($node->node), + ), + TupleNode::class => new TupleNode( + array_map($this->dedupeNode(...), $node->nodes), + ), + UnionNode::class => new UnionNode( + array_map($this->dedupeNode(...), $node->nodes), + $node->discriminator, + $node->discriminatorMap, + ), + IntersectionNode::class => new IntersectionNode( + array_map($this->dedupeNode(...), $node->nodes), + ), + default => throw new ParserException('Unknown node type: '.$node::class), + }; + } + + private function flattenConstraintNode(ConstraintNode $node): ConstraintNode + { + /** @var list $constraints */ + $constraints = []; + while ($node instanceof ConstraintNode) { + array_push($constraints, ...$node->constraints); + $node = $node->node; + } + + return new ConstraintNode( + $this->dedupeNode($node), + $constraints, + ); + } +} diff --git a/src/Parser/AstValidator.php b/src/Parser/Helpers/AstValidator.php similarity index 54% rename from src/Parser/AstValidator.php rename to src/Parser/Helpers/AstValidator.php index 70f4134..1e51aa4 100644 --- a/src/Parser/AstValidator.php +++ b/src/Parser/Helpers/AstValidator.php @@ -1,23 +1,25 @@ - $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), + 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), }; } } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Constraints/IntRange.php b/src/Parser/Helpers/Constraints/IntRange.php new file mode 100644 index 0000000..685e516 --- /dev/null +++ b/src/Parser/Helpers/Constraints/IntRange.php @@ -0,0 +1,89 @@ +`, `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/Helpers/Constraints/ListLength.php b/src/Parser/Helpers/Constraints/ListLength.php new file mode 100644 index 0000000..0e2719a --- /dev/null +++ b/src/Parser/Helpers/Constraints/ListLength.php @@ -0,0 +1,89 @@ +` 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/Helpers/Constraints/LowercaseString.php b/src/Parser/Helpers/Constraints/LowercaseString.php new file mode 100644 index 0000000..6758e9b --- /dev/null +++ b/src/Parser/Helpers/Constraints/LowercaseString.php @@ -0,0 +1,57 @@ +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/Parser/Helpers/Constraints/NonEmptyString.php b/src/Parser/Helpers/Constraints/NonEmptyString.php new file mode 100644 index 0000000..fc5e403 --- /dev/null +++ b/src/Parser/Helpers/Constraints/NonEmptyString.php @@ -0,0 +1,56 @@ +isString($value, $context)) { + return false; + } + + // Not empty(): "0" is empty() but is a valid non-empty-string. Rejecting it here would be + // stricter than the type this constraint backs - that is what non-falsy-string is for. + if ($value === '') { + $context->addIssue(new Issue( + IssueMessage::NOT_EMPTY_STRING, + [ + 'message' => 'Expected non-empty string, got an empty string.', + ] + )); + + return false; + } + + return true; + } + + #[Override] + public function exportPhpCode(): string + { + return 'new '.PHPExport::absolute(self::class).'()'; + } + + #[Override] + public function __toString(): string + { + return 'NonEmptyString'; + } +} diff --git a/src/Parser/Helpers/Constraints/NonFalsyString.php b/src/Parser/Helpers/Constraints/NonFalsyString.php new file mode 100644 index 0000000..6491517 --- /dev/null +++ b/src/Parser/Helpers/Constraints/NonFalsyString.php @@ -0,0 +1,54 @@ +isString($value, $context)) { + return false; + } + + if (! $value) { + $context->addIssue(new Issue( + IssueMessage::FALSY_STRING, + [ + 'message' => "Expected non-falsy string, got: '{$value}'", + ] + )); + + return false; + } + + return true; + } + + #[Override] + public function exportPhpCode(): string + { + return 'new '.PHPExport::absolute(self::class).'()'; + } + + #[Override] + public function __toString(): string + { + return 'NonFalsyString'; + } +} diff --git a/src/Parser/Helpers/Constraints/NumericString.php b/src/Parser/Helpers/Constraints/NumericString.php new file mode 100644 index 0000000..77f9268 --- /dev/null +++ b/src/Parser/Helpers/Constraints/NumericString.php @@ -0,0 +1,55 @@ +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/Helpers/Constraints/UppercaseString.php b/src/Parser/Helpers/Constraints/UppercaseString.php new file mode 100644 index 0000000..d4db8d2 --- /dev/null +++ b/src/Parser/Helpers/Constraints/UppercaseString.php @@ -0,0 +1,53 @@ +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/Helpers/Constraints/ValidatesString.php b/src/Parser/Helpers/Constraints/ValidatesString.php new file mode 100644 index 0000000..37949f2 --- /dev/null +++ b/src/Parser/Helpers/Constraints/ValidatesString.php @@ -0,0 +1,36 @@ +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/Helpers/Consumers/AliasConsumer.php similarity index 75% rename from src/Parser/Consumers/AliasConsumer.php rename to src/Parser/Helpers/Consumers/AliasConsumer.php index 295fadf..fdf2cbd 100644 --- a/src/Parser/Consumers/AliasConsumer.php +++ b/src/Parser/Helpers/Consumers/AliasConsumer.php @@ -1,32 +1,36 @@ -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) @@ -36,23 +40,27 @@ public function canConsume(ParserState $state): bool /** * @throws InvalidSyntaxException|ReflectionException */ + #[Override] public function consume(ParserState $state, TypeParser $parser): NodeInterface { $token = $state->current(); 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, @@ -64,12 +72,13 @@ public function consume(ParserState $state, TypeParser $parser): NodeInterface $state->advance(); $importDefinition = $state->context->getImportedTypeInfo($token->value); + return $parser->parse( $importDefinition['typeName'], - ParsingContext::fromClassString($importDefinition['className']), + 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 new file mode 100644 index 0000000..58ba499 --- /dev/null +++ b/src/Parser/Helpers/Consumers/ArrayConsumer.php @@ -0,0 +1,248 @@ + => 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 +{ + use InteractsWithGenerics; + + public function __construct() + { + } + + #[Override] + public function canConsume(ParserState $state): bool + { + if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { + return false; + } + + return in_array($state->current()->value, ['list', 'non-empty-list', 'array', 'non-empty-array'], true); + } + + /** + * `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): NodeInterface + { + $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'); + } + + // 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)?->is(TokenType::COLON) === true) { + return $this->consumeIntegerDeterminedTuple($state, $parser); + } + + // 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; + + // Consuming of the array type identifier + $state->advance(); + + // 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, T[], array or array." + ); + } + + $generics = $this->consumeGenerics($state, $parser, min: 1, max: $maxGenerics); + + if ($type === 'list') { + return $this->applyEmptiness(new ListNode($generics[0]), $isNonEmpty); + } + + // 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(new RecordNode($keyNode, $valueNode), $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; + } + + /** + * @throws InvalidSyntaxException + */ + private function consumeIntegerDeterminedTuple(ParserState $state, TypeParser $parser): TupleNode + { + if (! $state->currentTokenIs(TokenType::IDENTIFIER, 'array')) { + $state->produceSyntaxError('Expected array'); + } + $state->advance(); + + if (! $state->currentTokenIs(TokenType::LBRACE)) { + $state->produceSyntaxError('Expected {'); + } + $state->advance(); + + $types = []; + while ($state->canAdvance()) { + if ($state->currentTokenIs(TokenType::RBRACE)) { + break; + } + + if ($state->currentTokenIs(TokenType::COMMA) && $state->nextTokenIs(TokenType::RBRACE)) { + $state->advance(); + break; + } + + 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)); + } + $state->advance(); + + if (! $state->currentTokenIs(TokenType::COLON)) { + $state->produceSyntaxError('Expected colon'); + } + $state->advance(); + $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.'); + } + + return new TupleNode($types); + } + + /** + * @throws InvalidSyntaxException + */ + private function consumeTuple(ParserState $state, TypeParser $parser): TupleNode + { + if (! $state->currentTokenIs(TokenType::IDENTIFIER, 'array')) { + $state->produceSyntaxError('Expected array'); + } + $state->advance(); + + if (! $state->currentTokenIs(TokenType::LBRACE)) { + $state->produceSyntaxError('Expected {'); + } + $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 = []; + // 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)) { + break; + } + + if ($state->currentTokenIs(TokenType::COMMA) && $state->nextTokenIs(TokenType::RBRACE)) { + $state->advance(); + break; + } + + if (! $state->currentTokenIs(TokenType::COMMA)) { + $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(); + + return new TupleNode($types); + } +} diff --git a/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php b/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php new file mode 100644 index 0000000..b76272d --- /dev/null +++ b/src/Parser/Helpers/Consumers/BuiltInLeafConsumer.php @@ -0,0 +1,144 @@ +` + * (IntConsumer) plus the four named shorthands below, nothing else. + */ +final readonly class BuiltInLeafConsumer implements TypeConsumer +{ + #[Override] + public function canConsume(ParserState $state): bool + { + if (! $state->currentTokenIs(TokenType::IDENTIFIER)) { + return false; + } + + return in_array($state->current()->value, [ + 'string', + 'bool', + 'null', + 'float', + 'mixed', + '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', + 'non-negative-int', + 'non-positive-int', + 'numeric', + ], true); + } + + /** + * @throws InvalidSyntaxException + */ + #[Override] + public function consume(ParserState $state, TypeParser $parser): NodeInterface + { + $token = $state->current(); + $state->advance(); + + return match ($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 StringNode(), + [new NonFalsyString()], + ), + 'non-empty-string' => new ConstraintNode( + 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(), + new BoolNode(), + new StringNode(), + ]), + 'positive-int' => new ConstraintNode( + new IntNode(), + [new IntRange(min: 1)] + ), + 'negative-int' => new ConstraintNode( + new IntNode(), + [new IntRange(max: -1)] + ), + 'non-negative-int' => new ConstraintNode( + new IntNode(), + [new IntRange(min: 0)] + ), + 'non-positive-int' => new ConstraintNode( + new IntNode(), + [new IntRange(max: 0)] + ), + 'numeric' => new UnionNode([ + new IntNode(), + new FloatNode(), + ]), + 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 new file mode 100644 index 0000000..f772946 --- /dev/null +++ b/src/Parser/Helpers/Consumers/ClassConstConsumer.php @@ -0,0 +1,68 @@ +currentTokenIs(TokenType::IDENTIFIER) + && $state->nextTokenIs(TokenType::DOUBLE_COLON) + && $state->peek(2)?->is(TokenType::IDENTIFIER) === true; + } + + /** @throws InvalidSyntaxException */ + #[Override] + public function consume(ParserState $state, TypeParser $parser): LiteralNode + { + $className = $state->current()->value; + $state->advance(2); + + $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); + 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(); + + return new 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/Helpers/Consumers/DateTimeConsumer.php b/src/Parser/Helpers/Consumers/DateTimeConsumer.php new file mode 100644 index 0000000..dd436d7 --- /dev/null +++ b/src/Parser/Helpers/Consumers/DateTimeConsumer.php @@ -0,0 +1,39 @@ +currentTokenIs(TokenType::IDENTIFIER)) { + return false; + } + + $token = $state->current(); + + return is_a($state->context->toFullyQualifiedClassName($token->value), DateTimeInterface::class, true); + } + + #[Override] + public function consume(ParserState $state, TypeParser $parser): NodeInterface + { + /** @var class-string $className */ + $className = $state->context->toFullyQualifiedClassName($state->current()->value); + $state->advance(); + + return new DateTimeNode($className); + } +} diff --git a/src/Parser/Helpers/Consumers/EnumConsumer.php b/src/Parser/Helpers/Consumers/EnumConsumer.php new file mode 100644 index 0000000..d56f21d --- /dev/null +++ b/src/Parser/Helpers/Consumers/EnumConsumer.php @@ -0,0 +1,42 @@ +currentTokenIs(TokenType::IDENTIFIER)) { + return false; + } + + 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); + $state->advance(); + + /** @var class-string $fullyQualifiedClassName */ + return MetadataAttributes::wrap( + new EnumNode($fullyQualifiedClassName), + new ReflectionClass($fullyQualifiedClassName), + ); + } +} diff --git a/src/Parser/Helpers/Consumers/IntConsumer.php b/src/Parser/Helpers/Consumers/IntConsumer.php new file mode 100644 index 0000000..b815d61 --- /dev/null +++ b/src/Parser/Helpers/Consumers/IntConsumer.php @@ -0,0 +1,74 @@ +currentTokenIs(TokenType::IDENTIFIER, 'int'); + } + + /** + * @throws InvalidSyntaxException + */ + #[Override] + public function consume(ParserState $state, TypeParser $parser): NodeInterface + { + $state->advance(); + + if (! $state->currentTokenIs(TokenType::LT)) { + 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') => null, + default => $state->produceSyntaxError('Expected int or min'), + }; + + $state->advance(); + if (! $state->currentTokenIs(TokenType::COMMA)) { + $state->produceSyntaxError('Expected comma'); + } + $state->advance(); + + $max = match (true) { + $state->currentTokenIs(TokenType::INT) => Lexemes::decodeInt($state->current()->value), + $state->currentTokenIs(TokenType::IDENTIFIER, 'max') => null, + default => $state->produceSyntaxError('Expected int or max'), + }; + + $state->advance(); + if (! $state->current()->is(TokenType::GT)) { + $state->produceSyntaxError('Expected >'); + } + + $state->advance(); + + return new ConstraintNode( + new IntNode(), + [new IntRange($min, $max)] + ); + } +} diff --git a/src/Parser/Consumers/InteractsWithGenerics.php b/src/Parser/Helpers/Consumers/InteractsWithGenerics.php similarity index 64% rename from src/Parser/Consumers/InteractsWithGenerics.php rename to src/Parser/Helpers/Consumers/InteractsWithGenerics.php index 3248e79..cb313be 100644 --- a/src/Parser/Consumers/InteractsWithGenerics.php +++ b/src/Parser/Helpers/Consumers/InteractsWithGenerics.php @@ -1,19 +1,21 @@ - + * * @throws InvalidSyntaxException - * @return NodeInterface[] */ 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 (empty($generics)) { - $state->produceSyntaxError("Expected at least one generic type, got none"); + if (count($generics) === 0) { + $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 new file mode 100644 index 0000000..3864e78 --- /dev/null +++ b/src/Parser/Helpers/Consumers/LiteralConsumer.php @@ -0,0 +1,54 @@ +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); + } + + #[Override] + 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($value), + $value, + ); + } +} diff --git a/src/Parser/Consumers/StructConsumer.php b/src/Parser/Helpers/Consumers/StructConsumer.php similarity index 51% rename from src/Parser/Consumers/StructConsumer.php rename to src/Parser/Helpers/Consumers/StructConsumer.php index 43bfd64..58a3db3 100644 --- a/src/Parser/Consumers/StructConsumer.php +++ b/src/Parser/Helpers/Consumers/StructConsumer.php @@ -1,59 +1,68 @@ -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) - && !$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; } /** * @throws InvalidSyntaxException */ + #[Override] 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(); $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); - if (!$state->current()->is(TokenType::COLON)) { - $state->produceSyntaxError("Expected colon"); + if (! $state->current()->is(TokenType::COLON)) { + $state->produceSyntaxError('Expected colon'); } $state->advance(); @@ -72,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 (empty($properties)) { - $state->produceSyntaxError("Expected properties"); + if (count($properties) === 0) { + $state->produceSyntaxError('Expected properties'); } // We move out of the object $state->advance(); + return new StructNode($structType, $properties); } @@ -89,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/Consumers/UserDefinedObjectConsumer.php b/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php similarity index 53% rename from src/Parser/Consumers/UserDefinedObjectConsumer.php rename to src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php index c2c9823..3fec5e5 100644 --- a/src/Parser/Consumers/UserDefinedObjectConsumer.php +++ b/src/Parser/Helpers/Consumers/UserDefinedObjectConsumer.php @@ -1,17 +1,18 @@ -currentTokenIs(TokenType::IDENTIFIER)) { @@ -46,14 +46,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 */ @@ -67,24 +64,22 @@ 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) { - return ObjectCastStrategy::NEVER; + return $instance->strategy ?? $this->findCastingStrategy($class); } - return $this->findCastingStrategy($class); + return ObjectCastStrategy::NEVER; } /** * @param ReflectionClass $class - * @return ObjectCastStrategy */ 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; } @@ -95,27 +90,31 @@ 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); $castingStrategy = $this->determineCastingStrategy($reflectionClass); - $context = ParsingContext::fromReflectionClass($reflectionClass, $this->consumeGenerics($state, $parser)); + $context = ParsingScope::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 { - if (empty($param->getAttributes(Optional::class))) { + if (count($param->getAttributes(Optional::class)) === 0) { return false; } @@ -129,34 +128,34 @@ 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; } /** @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(), + $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, @@ -164,25 +163,28 @@ 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) { - if ($property->isReadOnly() || $property->hasHooks()) { - throw new RuntimeException("Property {$property->name} is not writable"); + $isWritable = PropertiesReflector::isWritableFromPublicScope($property); + $isReadable = PropertiesReflector::isReadableFromPublicScope($property); + if (!$isWritable && !$isReadable) { + continue; } $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, + propertyType: match (true) { + $isWritable && $isReadable => PropertyType::BOTH, + $isWritable => PropertyType::INPUT, + $isReadable => PropertyType::OUTPUT, + }, ); } @@ -193,42 +195,29 @@ private function parseSetPropertiesStrategy(ReflectionClass $reflectionClass, Ty ); } - private function applyConstraints(ReflectionProperty|ReflectionParameter $reflection, NodeInterface $node): NodeInterface - { - $constraints = Arrays::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 = []; - 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( - $parameter, - $parser->parse( - TypeReflector::reflectParameter($parameter), - $context->descendIntoDeclaringClass($parameter) - ) + $parser->parse( + TypeReflector::reflectParameter($parameter), + $context->descendIntoDeclaringClass($parameter) ), isOptional: $this->allowsOptional($parameter), propertyType: PropertyType::INPUT, @@ -236,20 +225,24 @@ 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()); - $structProperties[$index] = $structProperties[$index]->changePropertyType(PropertyType::BOTH); + $index = array_find_key($structProperties, fn (PropertyNode $propertyNode) => $propertyNode->name === $property->getName()); + if ($index !== null) { + $structProperties[$index] = $structProperties[$index]->changePropertyType(PropertyType::BOTH); + } + continue; } $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, @@ -257,9 +250,9 @@ 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, ); } -} \ No newline at end of file +} diff --git a/src/Parser/Helpers/Consumers/UtilsConsumer.php b/src/Parser/Helpers/Consumers/UtilsConsumer.php new file mode 100644 index 0000000..13aef9d --- /dev/null +++ b/src/Parser/Helpers/Consumers/UtilsConsumer.php @@ -0,0 +1,161 @@ +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; + $state->advance(); + + 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); + } + + [$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); + $brand = $this->literalStringValue($state, $literalNode, 'branded type'); + + // 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(), + NamedType::same(ucfirst($brand)), + $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'); + } + + 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); + } else { + $structNode = $nodeToPickFrom; + } + + return $structNode->filter( + 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'), + } + ); + } + + /** + * @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; + } + + /** + * @return list + * + * @throws InvalidSyntaxException + */ + private function propertiesToPickOrOmit(ParserState $state, NodeInterface $node): array + { + if ($node instanceof LiteralNode && $node->type === LiteralType::STRING) { + return [$node->stringValue()]; + } + + 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 { + if ($node instanceof LiteralNode && $node->type === LiteralType::STRING) { + return $node->stringValue(); + } + + $type = $node::class; + $state->produceSyntaxError("Expected string literal for picking or omitting, got: {$type}"); + }, $node->nodes); + } +} diff --git a/src/Parser/Helpers/Consumers/ValueObjectConsumer.php b/src/Parser/Helpers/Consumers/ValueObjectConsumer.php new file mode 100644 index 0000000..d19a17a --- /dev/null +++ b/src/Parser/Helpers/Consumers/ValueObjectConsumer.php @@ -0,0 +1,87 @@ +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 + */ + #[Override] + 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." + ); + } + + 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( + "Value object {$fullyQualifiedClassName} must be instantiable. Abstract classes and interfaces are not supported." + ); + } + + $state->advance(); + + /** @var class-string $fullyQualifiedClassName */ + return MetadataAttributes::wrap( + new ValueObjectNode( + $fullyQualifiedClassName, + $isStringBacked ? BackingType::STRING : BackingType::INT, + ), + $reflectionClass, + // 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/Parser/Helpers/ParserState.php b/src/Parser/Helpers/ParserState.php new file mode 100644 index 0000000..3a0bd27 --- /dev/null +++ b/src/Parser/Helpers/ParserState.php @@ -0,0 +1,124 @@ + */ + private readonly array $tokens; + + /** + * @param non-empty-list $tokens The raw, lossless token stream. + */ + public function __construct( + 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) + ); + + // The Lexer always terminates the stream with EOF, which is never whitespace. + if ($significant === []) { + throw new ParserException('The token stream must contain at least one significant token.'); + } + + $this->tokens = $significant; + $this->count = count($significant); + } + + 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) ?? $this->tokens[$this->count - 1]; + } + + public function peek(int $offset = 1): ?Token + { + return $this->getTokenAtIndex($this->currentIndex + $offset); + } + + public function at(int $index): ?Token + { + return $this->getTokenAtIndex($index); + } + + public function currentTokenIs(TokenType $type, ?string $value = null): bool + { + return $this->current()->is($type, $value); + } + + public function nextTokenIs(TokenType $type): bool + { + return $this->peek()?->type === $type; + } + + public function canAdvance(int $amount = 1): bool + { + return ($this->currentIndex + $amount) < $this->count; + } + + public function advance(int $amount = 1): void + { + if (! $this->canAdvance($amount)) { + throw new ParserException('Cannot advance past end of token'); + } + $this->currentIndex += $amount; + } + + public function highlightCurrentToken(): string + { + $token = $this->current(); + $location = SourceLocation::fromOffset($this->input, $token->offset); + + return implode(PHP_EOL, [ + "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, [ + "Syntax Error: {$message}", + $this->highlightCurrentToken(), + ]), + previous: $throwable, + ); + } +} diff --git a/src/Parser/Data/ParsingContext.php b/src/Parser/Helpers/ParsingScope.php similarity index 51% rename from src/Parser/Data/ParsingContext.php rename to src/Parser/Helpers/ParsingScope.php index ddfaf61..095e575 100644 --- a/src/Parser/Data/ParsingContext.php +++ b/src/Parser/Helpers/ParsingScope.php @@ -1,37 +1,54 @@ - $usedNamespaceMap - * @param array $localTypes - * @param array $importedTypes - * @param array $generics + * 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 = [], - public array $localTypes = [], - public array $importedTypes = [], - public array $generics = [], + array $usedNamespaceMap = [], + public array $localTypes = [], + public array $importedTypes = [], + public array $generics = [], public ?string $declaredInClass = null, - ) - { + ) { + $this->usedNamespaceMap = array_change_key_case($usedNamespaceMap); } + /** + * Given an identifier, returns the fully qualified class name without leading backslash. + */ public function toFullyQualifiedClassName(string $className): string { return Utils\Namespaces::toFullyQualifiedClassName($className, $this->namespace, $this->usedNamespaceMap); @@ -53,12 +70,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"); + if (! $this->isLocalType($typeName)) { + throw new ParserException("Type definition for {$typeName} not found"); } return $this->localTypes[$typeName]; @@ -70,46 +87,86 @@ public function isImportedType(string $typeName): bool } /** - * @param string $typeName * @return ImportedType */ public function getImportedTypeInfo(string $typeName): array { - if (!$this->isImportedType($typeName)) { - throw new RuntimeException("Type definition for {$typeName} not found"); + if (! $this->isImportedType($typeName)) { + throw new ParserException("Type definition for {$typeName} not found"); } 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()) { + // 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); } /** - * @param list $generics + * 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 + * * @throws ReflectionException */ 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); } /** - * @param ReflectionClass $class - * @param list $generics - * @return self + * @param ReflectionClass $class + * @param list $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()); @@ -124,7 +181,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 @@ -145,21 +203,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 @@ -170,13 +226,14 @@ 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 = []; foreach ($declaredGenerics as $index => $genericName) { $assignedGenerics[$genericName] = $generics[$index]; } + return $assignedGenerics; } -} \ No newline at end of file +} 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/Helpers/Registry/CachedTypeRegistry.php b/src/Parser/Helpers/Registry/CachedTypeRegistry.php new file mode 100644 index 0000000..a00c029 --- /dev/null +++ b/src/Parser/Helpers/Registry/CachedTypeRegistry.php @@ -0,0 +1,53 @@ + + */ + private array $instantiatedNodes = []; + + /** + * @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( + Closure|array $factory, + ) { + if (! $factory instanceof Closure) { + throw UnknownTypeKeyException::forLegacyCacheShape(); + } + + $this->factory = $factory; + } + + #[Override] + public function get(string $key): NodeInterface + { + // An unknown key throws before the assignment, so misses are never memoized. + return $this->instantiatedNodes[$key] ??= ($this->factory)($key, $this); + } +} diff --git a/src/Parser/Lexer/Exceptions/UnexpectedCharacterException.php b/src/Parser/Lexer/Exceptions/UnexpectedCharacterException.php index 3999d43..95c5332 100644 --- a/src/Parser/Lexer/Exceptions/UnexpectedCharacterException.php +++ b/src/Parser/Lexer/Exceptions/UnexpectedCharacterException.php @@ -1,18 +1,19 @@ - + * * @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 RuntimeException("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 fc76ff5..df8d85c 100644 --- a/src/Parser/Lexer/Token.php +++ b/src/Parser/Lexer/Token.php @@ -1,7 +1,10 @@ -type, $types, true); } + #[Override] public function __toString(): string { return $this->value; diff --git a/src/Parser/Lexer/TokenType.php b/src/Parser/Lexer/TokenType.php index 015c62f..4e0ee93 100644 --- a/src/Parser/Lexer/TokenType.php +++ b/src/Parser/Lexer/TokenType.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] public function __toString(): string { - return $this->node->__toString(); + if (count($this->constraints) === 0) { + return $this->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): string => (string) $constraint, + $this->constraints, + )); + + return "{$this->node} & {$names}"; } + #[Override] public function exportPhpCode(): string { - if (empty($this->constraints)) { + if (count($this->constraints) === 0) { return $this->node->exportPhpCode(); } $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 a8dd944..994092f 100644 --- a/src/Parser/Nodes/CustomCastingNode.php +++ b/src/Parser/Nodes/CustomCastingNode.php @@ -1,35 +1,37 @@ -fullyQualifiedCastingClass}@{$this->strategy->name}({$this->node})"; } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); - $fullyQualifiedCastingClass = PHPExport::absolute($this->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 new file mode 100644 index 0000000..cf60e91 --- /dev/null +++ b/src/Parser/Nodes/Data/BackingType.php @@ -0,0 +1,14 @@ + LiteralType::FLOAT, 'integer' => LiteralType::INT, '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/Data/NamedType.php b/src/Parser/Nodes/Data/NamedType.php new file mode 100644 index 0000000..0ead9dd --- /dev/null +++ b/src/Parser/Nodes/Data/NamedType.php @@ -0,0 +1,46 @@ +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/Data/ObjectCastStrategy.php b/src/Parser/Nodes/Data/ObjectCastStrategy.php index 243027b..3eafe14 100644 --- a/src/Parser/Nodes/Data/ObjectCastStrategy.php +++ b/src/Parser/Nodes/Data/ObjectCastStrategy.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 3f9c5b3..c171959 100644 --- a/src/Parser/Nodes/IntersectionNode.php +++ b/src/Parser/Nodes/IntersectionNode.php @@ -1,47 +1,54 @@ - $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 new file mode 100644 index 0000000..0d5b4d5 --- /dev/null +++ b/src/Parser/Nodes/Leaf/BoolNode.php @@ -0,0 +1,51 @@ +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) { + '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 2d4f39c..0000000 --- a/src/Parser/Nodes/Leaf/BuiltInNode.php +++ /dev/null @@ -1,152 +0,0 @@ -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)" - ]); - } - - 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 inputDefinition(): string - { - return match ($this->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 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/DateTimeNode.php b/src/Parser/Nodes/Leaf/DateTimeNode.php index 89885c4..dd3b888 100644 --- a/src/Parser/Nodes/Leaf/DateTimeNode.php +++ b/src/Parser/Nodes/Leaf/DateTimeNode.php @@ -1,65 +1,88 @@ - $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] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); $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; + } + + // 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; } } @@ -67,27 +90,20 @@ 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) { + 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); - } - public function inputDefinition(): string - { - return "string"; - } - - public function outputDefinition(): string - { - return "string"; + 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 23b7abf..f50d47a 100644 --- a/src/Parser/Nodes/Leaf/EnumNode.php +++ b/src/Parser/Nodes/Leaf/EnumNode.php @@ -1,100 +1,95 @@ - */ + private array $cases; + /** - * @param class-string $enumClassName + * @param class-string $enumClassName */ public function __construct( - private string $enumClassName, - ) - { + public readonly string $enumClassName, + ) { } + #[Override] public function __toString(): string { return "enum<{$this->enumClassName}>"; } + #[Override] public function exportPhpCode(): string { $enumClass = PHPExport::absolute($this->enumClassName); $className = PHPExport::absolute(self::class); + return "new {$className}({$enumClass}::class)"; } + #[Override] 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; } - $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( 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_object($value) || ! is_a($value, $this->enumClassName)) { + $context->addIssue(Issue::invalidType($this->enumClassName, $value)); + return Value::INVALID; } 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/FloatNode.php b/src/Parser/Nodes/Leaf/FloatNode.php new file mode 100644 index 0000000..0db454b --- /dev/null +++ b/src/Parser/Nodes/Leaf/FloatNode.php @@ -0,0 +1,56 @@ +invalidType('float', $value, $context); + } + + #[Override] + public function serializeValue(mixed $value, ExecutionContext $context): mixed + { + // 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); + } + + #[Override] + 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..f2aa2c7 --- /dev/null +++ b/src/Parser/Nodes/Leaf/IntNode.php @@ -0,0 +1,48 @@ +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 + ? (int) $value + : $value; + } +} diff --git a/src/Parser/Nodes/Leaf/LiteralNode.php b/src/Parser/Nodes/Leaf/LiteralNode.php index 1779865..28d2572 100644 --- a/src/Parser/Nodes/Leaf/LiteralNode.php +++ b/src/Parser/Nodes/Leaf/LiteralNode.php @@ -1,57 +1,111 @@ -name off a string. + * + * @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), + 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, LiteralType::FLOAT => "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})"; } $value = var_export($this->value, true); + return "new {$className}({$type}, {$value})"; } + #[Override] public function parseValue(mixed $value, ExecutionContext $context): mixed { if ($this->type !== LiteralType::ENUM_CASE) { @@ -59,48 +113,48 @@ 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; } return $this->value; } - $name = $this->value->name; - return $value === $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->value->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; } - /** - * @throws JsonException - */ - public function inputDefinition(): string + private function notTheLiteral(mixed $expected, mixed $value, ExecutionContext $context): Value { - 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), - }; - } + $context->addIssue(new Issue( + IssueMessage::INVALID_TYPE, + [ + 'message' => 'Expected literal value: '.var_export($expected, true) + .', got: '.var_export($value, true), + ] + )); - /** - * @throws JsonException - */ - public function outputDefinition(): string - { - return $this->inputDefinition(); + return Value::INVALID; } + #[Override] public function coerce(mixed $value): mixed { return match ($this->type) { @@ -110,10 +164,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 new file mode 100644 index 0000000..56e7bbd --- /dev/null +++ b/src/Parser/Nodes/Leaf/MixedNode.php @@ -0,0 +1,38 @@ +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/RejectsInvalidType.php b/src/Parser/Nodes/Leaf/RejectsInvalidType.php new file mode 100644 index 0000000..bc9a5a5 --- /dev/null +++ b/src/Parser/Nodes/Leaf/RejectsInvalidType.php @@ -0,0 +1,19 @@ +addIssue(Issue::invalidType($expected, $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..909019c --- /dev/null +++ b/src/Parser/Nodes/Leaf/StringNode.php @@ -0,0 +1,66 @@ +invalidType('string', $value, $context); + } + + #[Override] + 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; + } + } + + #[Override] + 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; + } +} diff --git a/src/Parser/Nodes/Leaf/ValueObjectNode.php b/src/Parser/Nodes/Leaf/ValueObjectNode.php new file mode 100644 index 0000000..9e5b30e --- /dev/null +++ b/src/Parser/Nodes/Leaf/ValueObjectNode.php @@ -0,0 +1,218 @@ + $className + */ + public function __construct( + public string $className, + public BackingType $backingType, + ) { + } + + #[Override] + public function __toString(): string + { + return "valueObject<{$this->className},{$this->backingType->value}>"; + } + + #[Override] + public function exportPhpCode(): string + { + $className = PHPExport::absolute(self::class); + $valueObjectClass = PHPExport::absolute($this->className); + $backingType = PHPExport::exportEnumCase($this->backingType); + + return "new {$className}({$valueObjectClass}::class, {$backingType})"; + } + + #[Override] + public function parseValue(mixed $value, ExecutionContext $context): mixed + { + if ($this->backingType === BackingType::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) { + $this->addRejectionIssues($throwable, $value, $context); + + 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) { + $this->addRejectionIssues($throwable, $value, $context); + + return Value::INVALID; + } + } + + #[Override] + public function serializeValue(mixed $value, ExecutionContext $context): mixed + { + if ($this->backingType === BackingType::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; + } + } + + #[Override] + 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 filter_var($value, FILTER_VALIDATE_INT) !== false + ? (int) $value + : $value; + } + + 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, + ], + ); + } + + /** + * 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 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 addRejectionIssues(Throwable $throwable, mixed $value, ExecutionContext $context): void + { + 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 + { + return new Issue( + IssueMessage::INVALID_TYPE, + debugInfo: [ + '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 StringNode::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/Nodes/ListNode.php b/src/Parser/Nodes/ListNode.php index 0cf3a75..dafe8f6 100644 --- a/src/Parser/Nodes/ListNode.php +++ b/src/Parser/Nodes/ListNode.php @@ -1,26 +1,32 @@ -node}>"; } + #[Override] public function exportPhpCode(): string { $classname = PHPExport::absolute(self::class); + return "new {$classname}({$this->node->exportPhpCode()})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/MetadataNode.php b/src/Parser/Nodes/MetadataNode.php new file mode 100644 index 0000000..abf79e8 --- /dev/null +++ b/src/Parser/Nodes/MetadataNode.php @@ -0,0 +1,115 @@ +node; + } + + #[Override] + public function exportPhpCode(): string + { + return $this->node->exportPhpCode(); + } + + #[Override] + public function validate(): void + { + if ($this->name === null && $this->brand === null) { + throw new ParserException( + 'MetadataNode without a name or brand is meaningless; use the inner node directly.' + ); + } + + if ($this->node instanceof MetadataNode) { + throw new ParserException( + '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/Parser/Nodes/NamedNode.php b/src/Parser/Nodes/NamedNode.php deleted file mode 100644 index eac7171..0000000 --- a/src/Parser/Nodes/NamedNode.php +++ /dev/null @@ -1,28 +0,0 @@ -node; - } - - 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/Nodes/PropertyNode.php b/src/Parser/Nodes/PropertyNode.php index 522cdb0..d51f778 100644 --- a/src/Parser/Nodes/PropertyNode.php +++ b/src/Parser/Nodes/PropertyNode.php @@ -1,36 +1,41 @@ -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); + return "{$this->name}{$optional}: {$this->node}{$this->propertyType->asString()}"; } + #[Override] public function exportPhpCode(): string { $className = PHPExport::absolute(self::class); @@ -39,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 0a7118b..e159d3d 100644 --- a/src/Parser/Nodes/RecordNode.php +++ b/src/Parser/Nodes/RecordNode.php @@ -1,30 +1,63 @@ -` 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 { - /** - * @param NodeInterface $node - */ 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})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/ReferencedNode.php b/src/Parser/Nodes/ReferencedNode.php index 9494799..e128ae4 100644 --- a/src/Parser/Nodes/ReferencedNode.php +++ b/src/Parser/Nodes/ReferencedNode.php @@ -1,30 +1,35 @@ -registryVariableName}->get('{$this->referenceNode}')"; } + #[Override] public function __toString(): string { return $this->originalTypeString; } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/StructNode.php b/src/Parser/Nodes/StructNode.php index 3afd099..0b81061 100644 --- a/src/Parser/Nodes/StructNode.php +++ b/src/Parser/Nodes/StructNode.php @@ -1,88 +1,133 @@ - */ + public readonly array $properties; + + /** + * Properties are exposed as a node. + */ + public array $nodes { + get => $this->properties; + } + /** - * @param non-empty-list $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 list $properties */ public function __construct( - public StructPhpType $phpType, - public array $properties, - ) + public readonly StructPhpType $phpType, + array $properties, + ) { + $this->properties = self::canonicalise($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)) { + 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; } + #[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"); + 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"); } } /** - * @param Closure(PropertyNode): bool $closure - * @return self + * @param Closure(PropertyNode): bool $closure */ + #[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 + * @param Closure(PropertyNode): PropertyNode $closure */ + #[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); } - /** - * @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 */ $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 @@ -90,18 +135,22 @@ 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); $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 87febcc..41ebb0c 100644 --- a/src/Parser/Nodes/TupleNode.php +++ b/src/Parser/Nodes/TupleNode.php @@ -1,41 +1,46 @@ - $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}"; + + 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"); - } } -} \ No newline at end of file +} diff --git a/src/Parser/Nodes/UnionNode.php b/src/Parser/Nodes/UnionNode.php index ae06f83..d1b87b5 100644 --- a/src/Parser/Nodes/UnionNode.php +++ b/src/Parser/Nodes/UnionNode.php @@ -1,50 +1,57 @@ -acceptsNull ??= array_any($this->types, fn(NodeInterface $type) => $type instanceof BuiltInNode && $type->type === BuiltInType::NULL); + return $this->acceptsNull ??= array_any($this->nodes, fn (NodeInterface $type) => $type instanceof NullNode); } /** - * @param list $types - * @param string|null $discriminator - * @param list|null $discriminatorMap + * @param list $nodes + * @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, - ) - { - + public readonly ?array $discriminatorMap = null, + ) { } + #[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 { - return 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 + : "{$types} by '{$this->discriminator}'"; } public function isDiscriminated(): bool @@ -54,23 +61,26 @@ 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->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})"; } -} \ No newline at end of file +} diff --git a/src/Parser/Parsers/DateTimeParser.php b/src/Parser/Parsers/DateTimeParser.php deleted file mode 100644 index f18a573..0000000 --- a/src/Parser/Parsers/DateTimeParser.php +++ /dev/null @@ -1,36 +0,0 @@ -value, false) && is_a($token->value, DateTimeInterface::class, true); - } - - public function parse(string $fullyQualifiedClassName, Token $token): DateTimeNode - { - /** @var class-string $className */ - $className = is_a($fullyQualifiedClassName, DateTimeInterface::class, true) - ? $fullyQualifiedClassName - : $token->value; - - return new DateTimeNode($className); - } -} \ No newline at end of file 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/Registry/CachedTypeRegistry.php b/src/Parser/Registry/CachedTypeRegistry.php deleted file mode 100644 index ad4c408..0000000 --- a/src/Parser/Registry/CachedTypeRegistry.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ - private array $instantiatedNodes = []; - - /** - * @param array $registeredSchemas - */ - public function __construct( - private readonly array $registeredSchemas, - ) - { - } - - public function get(string $fullyQualifiedClassName): NodeInterface - { - return $this->instantiatedNodes[$fullyQualifiedClassName] ??= ($this->registeredSchemas[$fullyQualifiedClassName])($this); - } -} \ No newline at end of file diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 720ef3d..fe34c46 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -1,34 +1,37 @@ -|null $consumers */ public function __construct( - private TypeStringTokenizer $tokenizer = new TypeStringTokenizer(), ?array $consumers = null, - ) - { + ) { $this->consumers = $consumers ?? self::defaultConsumers(); } /** - * @param GlobalTypeAliases $globalTypeAliases - * @param list $collectionClasses - * @param list|null $parsers - * @param bool $allowAllObjectCasting - * @return TypeConsumer[] + * @return list */ public static function defaultConsumers( GlobalTypeAliases $globalTypeAliases = new GlobalTypeAliases(), - array $collectionClasses = [], - ?array $parsers = null, - bool $allowAllObjectCasting = false, - ): array - { + ): array { return [ new LiteralConsumer(), new ClassConstConsumer(), @@ -79,57 +71,58 @@ public static function defaultConsumers( new IntConsumer(), new BuiltInLeafConsumer(), new StructConsumer(), - new ArrayConsumer($collectionClasses), - new UserDefinedParsers($parsers ?? self::getDefaultParsers()), - new UserDefinedObjectConsumer($allowAllObjectCasting), + 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(), 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. * * @throws InvalidSyntaxException */ - public function parse(string $typeString, ParsingContext $context = new ParsingContext()): NodeInterface + public function parse(string $typeString, ParsingScope $context = new ParsingScope()): 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; } /** - * @param ParserState $state - * @return NodeInterface * @throws InvalidSyntaxException */ private function consumeType(ParserState $state): NodeInterface @@ -141,11 +134,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 @@ -158,7 +152,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'; } @@ -173,30 +167,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::AND)) { + 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; } @@ -204,12 +200,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; } @@ -218,12 +215,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); @@ -231,7 +228,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); @@ -245,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 { @@ -254,23 +251,36 @@ 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 + * @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); } @@ -280,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; } } @@ -297,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) { @@ -315,4 +333,4 @@ private function checkForDiscriminatedUnion(array $types): UnionNode return new UnionNode($types); } -} \ No newline at end of file +} 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/src/Parser/Utils/Lexemes.php b/src/Parser/Utils/Lexemes.php new file mode 100644 index 0000000..fd35621 --- /dev/null +++ b/src/Parser/Utils/Lexemes.php @@ -0,0 +1,160 @@ + '\\', + '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(self::toByte((int) hexdec(substr($sequence, 1)))); + } + + if ($sequence[0] === 'u') { + 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))); + }, + $string, + ); + + if ($resolved === null) { + 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(self::toByte($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/Reflection/AttributesReflector.php b/src/Reflection/AttributesReflector.php index 2a0cca3..f4ffb19 100644 --- a/src/Reflection/AttributesReflector.php +++ b/src/Reflection/AttributesReflector.php @@ -1,58 +1,55 @@ -> $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 { - $reflection = array_find($this->attributes, fn(ReflectionAttribute $attribute) => $attribute->name === $attributeClass); - if (!$reflection) { - throw new RuntimeException("Attribute {$attributeClass} not found"); - } - - /** @var T */ - return $reflection->newInstance(); + 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 list + * + * @param class-string $attributeClass + * @return T|null */ - public function getInstances(string $attributeClass): array + public function firstInstanceOrNull(string $attributeClass): ?object { - $reflections = array_filter( - $this->attributes, - fn(ReflectionAttribute $attribute) => $attribute->name === $attributeClass - ); - - return array_values( - array_map(fn(ReflectionAttribute $attribute) => $attribute->newInstance(), $reflections) - ); + $reflection = array_find($this->attributes, fn (ReflectionAttribute $attribute) => $attribute->name === $attributeClass); + + /** @var T|null */ + return $reflection?->newInstance(); } -} \ No newline at end of file +} diff --git a/src/Reflection/FileReflector.php b/src/Reflection/FileReflector.php index 2c6c1f9..5b18484 100644 --- a/src/Reflection/FileReflector.php +++ b/src/Reflection/FileReflector.php @@ -1,11 +1,12 @@ -|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; + private bool $namespaceParsed = false; /** @@ -25,15 +31,14 @@ final class FileReflector private ?ReflectionClass $declaredClass = null; /** - * @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( + if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { + throw new ParserException( "File does not exist or is not readable: {$this->filePath}" ); } @@ -56,27 +61,48 @@ public function getUsedNamespaces(): array return $this->usedNamespaces; } - $this->ensureTokensAreParsed(); + $tokens = $this->tokens(); $namespaces = []; - $numTokens = count($this->tokens); + $numTokens = count($tokens); + $depth = 0; for ($i = 0; $i < $numTokens; $i++) { - $token = $this->tokens[$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++; - if (!is_array($token) || $token[0] !== T_USE) { continue; } + if ($token === '}') { + $depth--; - // Skip `use function` and `use const` - $nextToken = $this->peekNextSignificantToken($i, $numTokens); - if ($nextToken && in_array($nextToken[0], [T_FUNCTION, T_CONST], true)) { continue; } - [$fullyQualifiedClassName, $alias, $i] = $this->parseUseStatement($i, $numTokens); + if (! is_array($token) || $token[0] !== T_USE) { + continue; + } + + // Imports are top level statements. Inside a body, `use` composes a trait. + if ($depth > 0) { + continue; + } - if ($fullyQualifiedClassName) { - if ($alias) { + // 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 === null || in_array($nextToken[0], [T_FUNCTION, T_CONST], true)) { + continue; + } + + [$imports, $i] = self::parseUseStatement($tokens, $i, $numTokens); + + foreach ($imports as [$fullyQualifiedClassName, $alias]) { + if ($alias !== null) { $namespaces[$fullyQualifiedClassName] = $alias; } else { $namespaces[] = $fullyQualifiedClassName; @@ -98,8 +124,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 +135,8 @@ 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 +145,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}" ); } @@ -134,118 +158,205 @@ 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)) { - throw new RuntimeException("Failed to load class {$fullyQualifiedClassName} from file {$this->filePath}"); + 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}"); } 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]; } } } + return null; } /** + * @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]; - if (!is_array($token)) { + $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); - if ($nextToken && $nextToken[0] === T_STRING) { - return $nextToken[1]; - } + if (! in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true)) { + 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; } /** - * @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]; - if (is_array($token) && $token[0] !== T_WHITESPACE) { - return $token; + $token = $tokens[$i]; + 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; } /** - * @param int $startIndex - * @param int $maxIndex - * @return array{string, string|null, int} + * 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{list, int} The imports and the index of the `;`. */ - private function parseUseStatement(int $startIndex, int $maxIndex): array + 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) { - $token = $this->tokens[$i]; + 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 = $this->peekNextSignificantToken($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; + } + + 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; } - $i++; + + 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]; } -} \ No newline at end of file +} diff --git a/src/Reflection/MetadataAttributes.php b/src/Reflection/MetadataAttributes.php new file mode 100644 index 0000000..53fabb3 --- /dev/null +++ b/src/Reflection/MetadataAttributes.php @@ -0,0 +1,190 @@ + $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, + ReflectionClass $reflectionClass, + bool $inheritFromParents = false, + ): NodeInterface { + $attributes = new AttributesReflector($reflectionClass->getAttributes()); + + // 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; + } + + $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( + inputName: $named->typeName($className, IO::INPUT), + outputName: $named->typeName($className, IO::OUTPUT), + ), + $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/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/src/Reflection/TypeReflector.php b/src/Reflection/TypeReflector.php index 977ce6c..dfae987 100644 --- a/src/Reflection/TypeReflector.php +++ b/src/Reflection/TypeReflector.php @@ -1,67 +1,119 @@ -getType()) { - throw new RuntimeException("No type defined."); + $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(); + if (! $property->isPromoted()) { + 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()) { - throw new RuntimeException("No type defined."); + $type = $parameter->getType(); + if (! $type) { + throw new ParserException('No type defined.'); } $declaringDocBlock = $parameter->getDeclaringFunction()->getDocComment(); - if (!$declaringDocBlock) { - return (string)$parameter->getType(); + if (! $declaringDocBlock) { + 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()) { - throw new RuntimeException("No return type defined."); + $type = $returnable->getReturnType(); + if (! $type) { + throw new ParserException('No return type defined.'); } $docBlock = $returnable->getDocComment(); - if (!$docBlock) { - return (string) $returnable->getReturnType(); + if (! $docBlock) { + 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; } -} \ 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..def191d --- /dev/null +++ b/src/Server/Adapters/NewInstanceAdapter.php @@ -0,0 +1,21 @@ +container->get($className); + } + + public function createController(string $className): object + { + return $this->container->get($className); + } +} diff --git a/src/Server/Client/InteractsWithToasts.php b/src/Server/Client/InteractsWithToasts.php new file mode 100644 index 0000000..faaec41 --- /dev/null +++ b/src/Server/Client/InteractsWithToasts.php @@ -0,0 +1,48 @@ +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 2b6df6d..233ac7d 100644 --- a/src/Server/Client/NullClient.php +++ b/src/Server/Client/NullClient.php @@ -1,30 +1,30 @@ -, message: string} */ -final class OperationSPAClient implements Client, JsonSerializable +final class OperationSPAClient implements SerializableClient { - /** @var Redirect|null */ + use InteractsWithToasts; + + /** @var Redirect|null */ private ?array $redirect = null; - /** @var list|null */ + /** @var list|null */ private ?array $toasts = null; - /** @var list>|null */ + /** @var list>|null */ private ?array $invalidations = null; - public function toast(string $type, string $message): void + #[Override] + 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 + #[Override] + public function redirect(string $url, bool $reload = false): void { $this->redirect = [ 'url' => $url, - 'type' => 'hard', + 'reload' => $reload, ]; } - public function invalidate(UnitEnum|string $namespace, ...$key): void + #[Override] + public function invalidate(UnitEnum|string $namespace, mixed ...$key): void { $this->invalidations ??= []; - $this->invalidations[] = [ - Strings::toString($namespace), - ... $key, - ]; + $this->invalidations[] = [Strings::toString($namespace), ...$key] |> array_values(...); } /** - * @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 + #[Override] + public function serializeToArray(): ?array { - $data = Arrays::filterNullValues([ - 'redirect' => $this->redirect, - 'toasts' => $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; } -} \ No newline at end of file +} diff --git a/src/Server/Data/Definition.php b/src/Server/Data/Definition.php index 7085eab..fade854 100644 --- a/src/Server/Data/Definition.php +++ b/src/Server/Data/Definition.php @@ -1,30 +1,28 @@ - $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 @@ -32,6 +30,15 @@ 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 { $className = PHPExport::absolute(self::class); @@ -45,4 +52,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..dec7b2e 100644 --- a/src/Server/Data/ErrorType.php +++ b/src/Server/Data/ErrorType.php @@ -1,4 +1,6 @@ -failure); - } - - /** - * @param array $issuesMap - * @return self - */ - public static function createFromMessages(array $issuesMap): self - { - return new self( - new Failure(Issues::fromMessages($issuesMap)), - ); + parent::__construct('Input validation failed', 422); } -} \ No newline at end of file +} diff --git a/src/Server/Data/Exceptions/InvalidMiddlewareException.php b/src/Server/Data/Exceptions/InvalidMiddlewareException.php new file mode 100644 index 0000000..aeae280 --- /dev/null +++ b/src/Server/Data/Exceptions/InvalidMiddlewareException.php @@ -0,0 +1,37 @@ +, entry '{$key}' is not."); + } +} diff --git a/src/Server/Data/Exceptions/InvalidOutputException.php b/src/Server/Data/Exceptions/InvalidOutputException.php index d958f39..1e253c8 100644 --- a/src/Server/Data/Exceptions/InvalidOutputException.php +++ b/src/Server/Data/Exceptions/InvalidOutputException.php @@ -1,12 +1,17 @@ - $this->failure->issues; @@ -14,6 +19,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..84cab41 100644 --- a/src/Server/Data/Exceptions/OperationNotFoundException.php +++ b/src/Server/Data/Exceptions/OperationNotFoundException.php @@ -1,10 +1,18 @@ -> $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/Data/Operation.php b/src/Server/Data/Operation.php index f0d9c28..a604676 100644 --- a/src/Server/Data/Operation.php +++ b/src/Server/Data/Operation.php @@ -1,34 +1,61 @@ -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)(); } -} \ No newline at end of file +} diff --git a/src/Server/Data/OperationType.php b/src/Server/Data/OperationType.php index da6e30e..af74088 100644 --- a/src/Server/Data/OperationType.php +++ b/src/Server/Data/OperationType.php @@ -1,4 +1,6 @@ -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 fullyQualifiedOperationKey(string $key): string + { + return "{$this->name}@{$key}"; + } } diff --git a/src/Server/Data/ResolveInfo.php b/src/Server/Data/ResolveInfo.php index f89c974..8b47b8e 100644 --- a/src/Server/Data/ResolveInfo.php +++ b/src/Server/Data/ResolveInfo.php @@ -1,7 +1,11 @@ - $className - * @param string $methodName - * @param list $middleware + * @param class-string $className + * @param list>> $middleware */ public function __construct( public readonly string $namespace, @@ -23,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 37c3ce3..71b6f9d 100644 --- a/src/Server/Data/RpcError.php +++ b/src/Server/Data/RpcError.php @@ -1,44 +1,95 @@ - $this->type->value; + } + /** - * @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; 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. */ public function __construct( - public ErrorType $type, - public Throwable $cause, - public mixed $details, - public ?ResolveInfo $resolveInfo, - public array $metadata = [], - ) + 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 - * @return self + * @param array $metadata + * * @api */ + #[Override] + #[NoDiscard] public function withMetadata(array $metadata): self { - return new self($this->type, $this->cause, $this->details, $this->resolveInfo, $metadata); + return clone ($this, [ + 'metadata' => $metadata, + ]); } /** - * @param array $metadata - * @return self + * @param array $metadata + * * @api */ + #[Override] + #[NoDiscard] public function appendMetadata(array $metadata): self { - return new self($this->type, $this->cause, $this->details, $this->resolveInfo, [ - ...$this->metadata, - ...$metadata, + return clone ($this, [ + 'metadata' => [...$this->metadata, ...$metadata], + ]); + } + + /** + * @return array + */ + #[Override] + 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, ]); } -} \ No newline at end of file +} diff --git a/src/Server/Data/RpcSuccess.php b/src/Server/Data/RpcSuccess.php index cf74c4c..c50a191 100644 --- a/src/Server/Data/RpcSuccess.php +++ b/src/Server/Data/RpcSuccess.php @@ -1,45 +1,79 @@ - $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 - * @return self + * + * @param array $metadata + * * @api */ + #[Override] + #[NoDiscard] 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 self + * + * @param array $metadata + * * @api */ + #[Override] + #[NoDiscard] 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 + */ + #[Override] + public function jsonSerialize(): array + { + $metadata = Dicts::filterNullValues([ + '__client' => $this->client instanceof SerializableClient ? $this->client->serializeToArray() : null, + '__metadata' => count($this->metadata) > 0 ? $this->metadata : null, ]); + + return [ + 'success' => true, + 'data' => $this->data, + ...$metadata, + ]; } -} \ No newline at end of file +} diff --git a/src/Server/Data/ServerConfiguration.php b/src/Server/Data/ServerConfiguration.php index 8796b2e..4fdf2cf 100644 --- a/src/Server/Data/ServerConfiguration.php +++ b/src/Server/Data/ServerConfiguration.php @@ -1,32 +1,93 @@ - $middleware + * 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 list>> $middleware + * @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, + public bool $coerceQueryInput = false, public array $middleware = [], - ) - { + public array $notFoundExceptions = [], + public array $unauthenticatedExceptions = [], + public array $unauthorizedExceptions = [], + public array $rateLimitedExceptions = [], + public ?Closure $resolveRetryIn = null, + ) { } /** - * @param class-string ...$middlewares - * @return self + * @param class-string> ...$middlewares */ + #[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( + coerceQueryInput: $this->coerceQueryInput, + middleware: [...$this->middleware, ...$middlewares] |> array_values(...), + notFoundExceptions: $this->notFoundExceptions, + unauthenticatedExceptions: $this->unauthenticatedExceptions, + unauthorizedExceptions: $this->unauthorizedExceptions, + rateLimitedExceptions: $this->rateLimitedExceptions, + resolveRetryIn: $this->resolveRetryIn, + ); + } + + /** + * Appends to the existing lists. An omitted category is left untouched. + * + * @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( - $this->coerceQueryInput, - [ - ...$this->middleware, - ...array_values($middlewares), - ], + coerceQueryInput: $this->coerceQueryInput, + middleware: $this->middleware, + notFoundExceptions: [...$this->notFoundExceptions, ...$notFound], + unauthenticatedExceptions: [...$this->unauthenticatedExceptions, ...$unauthenticated], + unauthorizedExceptions: [...$this->unauthorizedExceptions, ...$unauthorized], + rateLimitedExceptions: [...$this->rateLimitedExceptions, ...$rateLimited], + resolveRetryIn: $this->resolveRetryIn, ); } -} \ No newline at end of file + + /** + * @param Closure(Throwable): (int|null) $resolveRetryIn + */ + #[NoDiscard] + public function withRetryInResolver(Closure $resolveRetryIn): self + { + return clone($this, [ + "resolveRetryIn" => $resolveRetryIn, + ]); + } +} diff --git a/src/Server/Data/Toast.php b/src/Server/Data/Toast.php new file mode 100644 index 0000000..2f9f0a7 --- /dev/null +++ b/src/Server/Data/Toast.php @@ -0,0 +1,29 @@ +, 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..0f5b950 --- /dev/null +++ b/src/Server/Data/ToastType.php @@ -0,0 +1,14 @@ + $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, + ) { + } + + /** + * @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, + $this->matchesAny($className, $this->rateLimitedExceptions) => ErrorType::RATE_LIMITED, + 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) + ); + } +} 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/ThrowAttributeResolver.php b/src/Server/Errors/ThrowAttributeResolver.php new file mode 100644 index 0000000..1cb7372 --- /dev/null +++ b/src/Server/Errors/ThrowAttributeResolver.php @@ -0,0 +1,126 @@ + + * @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->middlewareClassNames()), + ]; + + $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, + ); + } +} diff --git a/src/Server/KeyGenerators/HashSha256KeyGenerator.php b/src/Server/KeyGenerators/HashSha256KeyGenerator.php index 4e59bad..bd7c540 100644 --- a/src/Server/KeyGenerators/HashSha256KeyGenerator.php +++ b/src/Server/KeyGenerators/HashSha256KeyGenerator.php @@ -1,29 +1,36 @@ -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); return "{$namespace}.{$fnName}"; } -} \ No newline at end of file +} diff --git a/src/Server/KeyGenerators/PlainlyExposedKeyGenerator.php b/src/Server/KeyGenerators/PlainlyExposedKeyGenerator.php index f59ef6d..e4c67f0 100644 --- a/src/Server/KeyGenerators/PlainlyExposedKeyGenerator.php +++ b/src/Server/KeyGenerators/PlainlyExposedKeyGenerator.php @@ -1,15 +1,17 @@ - $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; } - public function has(OperationType $type, string $fullyQualifiedKey): bool + #[Override] + public function has(OperationType $type, string $key): bool { return array_key_exists( - self::key($type, $fullyQualifiedKey), - $this->operations + $type->fullyQualifiedOperationKey($key), + $this->keys ); } - public function get(OperationType $type, string $fullyQualifiedKey): Operation + #[Override] + public function get(OperationType $type, string $key): Operation { - $key = self::key($type, $fullyQualifiedKey); - return $this->instances[$key] ??= $this->operations[$key](); - } + $key = $type->fullyQualifiedOperationKey($key); - private static function key(OperationType $type, string $fullyQualifiedKey): string - { - return "{$type->name}:{$fullyQualifiedKey}"; + 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; } - public static function toPhpCode(OperationRegistry $registry): string - { + public static function toPhpCode( + OperationRegistry $registry, + int $idLength, + ): string { $endpointClass = PHPExport::absolute(Operation::class); - $endpoints = []; + $arms = []; + $keys = []; $asts = []; foreach ($registry->all() as $endpoint) { $operation = $endpoint->definition; @@ -68,36 +103,53 @@ public static function toPhpCode(OperationRegistry $registry): string $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->fullyQualifiedOperationKey($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 is used to deduplicate all the ASTs, minimizing the nodes required. - // Additional optimizations are performed on structs and unions for faster execution. - $optimizer = new AstOptimizer(); + // The ast optimizer deduplicates all the ASTs, minimizing the nodes required at runtime. + $optimizer = new ASTOptimizer( + idLength: $idLength, + ); $operationRegistryClass = PHPExport::absolute(CachedOperationRegistry::class); + $notFoundException = PHPExport::absolute(OperationNotFoundException::class); - $endpointsCode = implode(',', $endpoints); + // Operation discovery order depends on the filesystem, so sorting by key is what makes the + // generated artifact byte identical across machines. + ksort($asts); + 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; } - 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. - file_put_contents($filePath, << $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/EagerlyLoadedOperationRegistry.php b/src/Server/Operations/EagerlyLoadedOperationRegistry.php new file mode 100644 index 0000000..3d3bf4f --- /dev/null +++ b/src/Server/Operations/EagerlyLoadedOperationRegistry.php @@ -0,0 +1,168 @@ + + */ + private array $instances = []; + + /** + * @param array $factories + */ + public function __construct( + private readonly array $factories, + ) { + } + + /** + * @param string|string[] $directories + */ + public static function eagerlyDiscover( + string|array $directories, + TypeParser $parser = new TypeParser(), + OperationKeyGenerator $keyGenerator = new HashSha256KeyGenerator('default', 8, 24), + OperationDiscovery $discovery = new OperationDiscovery(), + ): self { + $directories = is_array($directories) ? $directories : [$directories]; + foreach ($directories as $directory) { + self::discoverDirectory($directory, $discovery); + } + + return self::registryFromDiscovery($parser, $keyGenerator, $discovery); + } + + /** + * 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, + ): self { + $factories = []; + foreach ($discovery->operations as $definition) { + $key = $keyGenerator->generateKey($definition->namespace, $definition->name); + $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 + // 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) { + $classReflection = new ReflectionClass($definition->fullyQualifiedClassName); + $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); + + $input = fn () => $parser->parse(TypeReflector::reflectParameter($inputParameter), $parsingContext); + $output = fn () => $parser->parse(TypeReflector::reflectReturnType($method), $parsingContext); + + return new Operation($key, $definition, $input, $output); + }; + } + + return new self($factories); + } + + /** + * @param list $classes + * + * @throws ReflectionException + */ + public static function withClasses( + array $classes, + TypeParser $parser = new TypeParser(), + OperationKeyGenerator $keyGenerator = new HashSha256KeyGenerator('default', 8, 24), + 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 $key): bool + { + $key = $type->fullyQualifiedOperationKey($key); + + return array_key_exists($key, $this->factories); + } + + /** + * @throws ReflectionException + */ + #[Override] + public function get(OperationType $type, string $key): Operation + { + $key = $type->fullyQualifiedOperationKey($key); + + return $this->instances[$key] ??= $this->factories[$key](); + } + + /** + * @return array + */ + #[Override] + public function all(): array + { + foreach ($this->factories as $key => $factory) { + $this->instances[$key] ??= $factory(); + } + + return $this->instances; + } +} diff --git a/src/Server/Operations/EagerlyLoadedRegistry.php b/src/Server/Operations/EagerlyLoadedRegistry.php deleted file mode 100644 index 9e46123..0000000 --- a/src/Server/Operations/EagerlyLoadedRegistry.php +++ /dev/null @@ -1,129 +0,0 @@ - - */ - private array $instances = []; - - /** - * @param array $factories - */ - public function __construct( - private readonly array $factories, - ) - { - } - - /** - * @param string|string[] $directories - * @param TypeParser $parser - * @param OperationKeyGenerator $keyGenerator - * @return self - */ - public static function eagerlyDiscover( - string|array $directories, - TypeParser $parser = new TypeParser(), - OperationKeyGenerator $keyGenerator = new HashSha256KeyGenerator('default', 8, 24), - OperationDiscovery $discovery = new OperationDiscovery(), - ): self - { - $directories = is_array($directories) ? $directories : [$directories]; - $discoverer = new DiscoveryManager([$discovery]); - foreach ($directories as $directory) { - $discoverer->discover($directory); - } - - return self::readDiscoverer($parser, $keyGenerator, $discovery); - } - - private static function readDiscoverer( - TypeParser $parser, - OperationKeyGenerator $keyGenerator, - OperationDiscovery $discovery, - ): self - { - $factories = []; - foreach ($discovery->operations as $definition) { - $key = $keyGenerator->generateKey($definition->namespace, $definition->name); - $fullyQualifiedKey = self::key($definition->type, $key); - - // Lazily execute the parsing. - $factories[$fullyQualifiedKey] = static function () use ($definition, $parser, $key) { - $classReflection = new ReflectionClass($definition->fullyQualifiedClassName); - $inputParameter = $classReflection->getMethod($definition->methodName)->getParameters()[0]; - - $parsingContext = ParsingContext::fromReflectionClass($classReflection); - $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); - }; - } - - return new self($factories); - } - - /** - * @param list $classes - * @throws ReflectionException - */ - public static function withClasses( - array $classes, - TypeParser $parser = new TypeParser(), - OperationKeyGenerator $keyGenerator = new HashSha256KeyGenerator('default', 8, 24), - OperationDiscovery $discovery = new OperationDiscovery(), - ): self - { - foreach ($classes as $className) { - $discovery->discover(new ReflectionClass($className)); - } - return self::readDiscoverer($parser, $keyGenerator, $discovery); - } - - private static function key(OperationType $type, string $fullyQualifiedKey): string - { - return "{$type->name}@{$fullyQualifiedKey}"; - } - - public function has(OperationType $type, string $fullyQualifiedKey): bool - { - $key = self::key($type, $fullyQualifiedKey); - return array_key_exists($key, $this->factories); - } - - /** - * @throws ReflectionException - */ - public function get(OperationType $type, string $fullyQualifiedKey): Operation - { - $key = self::key($type, $fullyQualifiedKey); - return $this->instances[$key] ??= $this->factories[$key](); - } - - /** - * @return Operation[] - */ - 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 998a741..0d17da3 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) { } /** - * 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 + * @param ReflectionClass $class */ - 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,11 +49,11 @@ 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(); - if (empty($attributes)) { + if (count($attributes) === 0) { continue; } @@ -60,7 +62,7 @@ final 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; } @@ -68,7 +70,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; @@ -78,10 +80,64 @@ final public function discover(ReflectionClass $class): void } /** - * @param Query|Command $attribute - * @param ReflectionClass $class - * @param ReflectionMethod $method - * @return Definition + * 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 ReflectionClass $class */ private function toDefinition(Query|Command $attribute, ReflectionClass $class, ReflectionMethod $method): Definition { @@ -90,22 +146,29 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, Command::class => OperationType::COMMAND, }; - $parameters = $method->getParameters(); - if (count($parameters) < 1) { - throw new RuntimeException("Method {$method->name} must have at least one parameter."); - } + self::assertHandlerSignature($class, $method); - $attributes = [ - // Collect all middlewares, on the class and the method itself. - ... $class->getAttributes(Middleware::class), - ... $method->getAttributes(Middleware::class), + // 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), ]; - $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) { + $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( $type, @@ -116,4 +179,20 @@ private function toDefinition(Query|Command $attribute, ReflectionClass $class, $middlewares, ); } -} \ No newline at end of file + + /** + * 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/Pipeline/ContextualPipeline.php b/src/Server/Pipeline/ContextualPipeline.php index 25dfe7c..95f8470 100644 --- a/src/Server/Pipeline/ContextualPipeline.php +++ b/src/Server/Pipeline/ContextualPipeline.php @@ -1,86 +1,87 @@ - $pipes + * @param list> $middlewares + * @param Closure(Throwable, ExceptionScope|null): 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): RpcSuccess|RpcError { + try { + return ($this->destination)($input); + } catch (Throwable $throwable) { + return ($this->onError)($throwable, null); + } + }; - /** - * @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 + * @param MiddlewareContract $middleware + * @param Next $next + * @param TContext $context + * @return Next */ - private function reducer(array $context): 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; - } - }; - }; - } - - public function execute(mixed $value, mixed ... $context): mixed + private function ring(MiddlewareContract $middleware, Closure $next, mixed $context, ResolveInfo $info, Client $client): Closure { - $context = array_values($context); - $middle = function ($value) use ($context) { - if ($this->then) { - try { - return ($this->then)($value, ...$context); - } catch (Throwable $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->onError)($throwable, new ExceptionScope($middleware::class, 'handle')); } - - return $value; }; - - $pipeline = array_reduce( - array_reverse($this->pipes), $this->reducer($context), $middle, - ); - - return $pipeline($value); } -} \ No newline at end of file +} diff --git a/src/Server/Preloader.php b/src/Server/Preloader.php new file mode 100644 index 0000000..cfd24a4 --- /dev/null +++ b/src/Server/Preloader.php @@ -0,0 +1,94 @@ +} + */ + public function preload(string|UnitEnum $namespace, string $name, mixed $input, mixed $context): array + { + $namespaceAsString = Strings::toString($namespace); + $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}"); + } + + return [ + 'response' => $result->data, + '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]; + } + + /** + * 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 instanceof Closure ? $context() : $context + ), + $preloads + ); + } +} diff --git a/src/Server/Presenter/CatchAllPresenter.php b/src/Server/Presenter/CatchAllPresenter.php deleted file mode 100644 index cd34a6a..0000000 --- a/src/Server/Presenter/CatchAllPresenter.php +++ /dev/null @@ -1,37 +0,0 @@ - 'INTERNAL_SERVER_ERROR', - ]; - } - - public static function errorType(): ErrorType - { - return ErrorType::INTERNAL_ERROR; - } -} \ No newline at end of file diff --git a/src/Server/Presenter/ClientAwareExceptionPresenter.php b/src/Server/Presenter/ClientAwareExceptionPresenter.php deleted file mode 100644 index 4d4746c..0000000 --- a/src/Server/Presenter/ClientAwareExceptionPresenter.php +++ /dev/null @@ -1,82 +0,0 @@ -> - * @throws ReflectionException - */ - private function extractExposedExceptions(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 - 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); - } - - /** - * @throws ReflectionException - */ - public function matches(Throwable $throwable, Definition $definition): bool - { - return $throwable instanceof ClientAwareException && in_array($throwable::class, $this->extractExposedExceptions($definition), true); - } - - public function toTypeScriptDefinition(Definition $definition): ?string - { - $exceptionClasses = $this->extractExposedExceptions($definition); - if (empty($exceptionClasses)) { - return null; - } - - return implode('|', array_map(function (string $exceptionClass): string { - $type = json_encode($exceptionClass::type(), JSON_THROW_ON_ERROR); - return "{type: {$type}}"; - }, $exceptionClasses)); - } - - /** - * @return array{type: string} - */ - public function details(Throwable $throwable): array - { - /** @var ClientAwareException $throwable */ - return [ - 'type' => $throwable::type(), - ]; - } - - public static function errorType(): ErrorType - { - return ErrorType::DOMAIN_ERROR; - } -} \ No newline at end of file diff --git a/src/Server/Presenter/InvalidInputPresenter.php b/src/Server/Presenter/InvalidInputPresenter.php deleted file mode 100644 index e2c6432..0000000 --- a/src/Server/Presenter/InvalidInputPresenter.php +++ /dev/null @@ -1,41 +0,0 @@ -;}'; - } - - /* - * @return array{status: 422, type: "INVALID_INPUT", fields: array} - */ - public function details(Throwable $throwable): array - { - /** @var InvalidInputException $throwable */ - - return [ - 'type' => 'INVALID_INPUT', - 'fields' => $throwable->failure->issues->serializeToFieldsArray(), - ]; - } - - 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 bd1e57d..0000000 --- a/src/Server/Presenter/NotFoundPresenter.php +++ /dev/null @@ -1,45 +0,0 @@ -> $classNames - */ - public function __construct( - private readonly array $classNames - ) - { - } - - public function matches(Throwable $throwable, Definition $definition): bool - { - return in_array(get_class($throwable), $this->classNames, true); - } - - public function toTypeScriptDefinition(Definition $definition): string - { - return '{type: "NOT_FOUND";}'; - } - - /* - * @return array{status: 404, type: "NOT_FOUND"} - */ - public function details(Throwable $throwable): array - { - return [ - 'type' => 'NOT_FOUND', - ]; - } - - 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 6f2b8d2..0000000 --- a/src/Server/Presenter/UnauthenticatedPresenter.php +++ /dev/null @@ -1,45 +0,0 @@ -> $unauthenticatedClassNames - */ - public function __construct( - private readonly array $unauthenticatedClassNames - ) - { - } - - public function matches(Throwable $throwable, Definition $definition): bool - { - return in_array(get_class($throwable), $this->unauthenticatedClassNames, true); - } - - public function toTypeScriptDefinition(Definition $definition): string - { - return '{type: "UNAUTHENTICATED";}'; - } - - /* - * @return array{status: 401, type: "UNAUTHENTICATED"} - */ - public function details(Throwable $throwable): array - { - return [ - 'type' => 'UNAUTHENTICATED', - ]; - } - - 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 7ff95b8..0000000 --- a/src/Server/Presenter/UnauthorizedPresenter.php +++ /dev/null @@ -1,45 +0,0 @@ -> $unauthenticatedClassNames - */ - public function __construct( - private readonly array $unauthenticatedClassNames - ) - { - } - - public function matches(Throwable $throwable, Definition $definition): bool - { - return in_array(get_class($throwable), $this->unauthenticatedClassNames, true); - } - - public function toTypeScriptDefinition(Definition $definition): string - { - return '{type: "UNAUTHORIZED";}'; - } - - /** - * @return array{type: "UNAUTHORIZED"} - */ - public function details(Throwable $throwable): array - { - return [ - 'type' => 'UNAUTHORIZED', - ]; - } - - 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 a4da5a7..a5213e1 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -1,29 +1,37 @@ - $exceptionPresenters - * @param ExceptionPresenter $defaultPresenter - * @param ContainerInterface|null $container - * @param ServerConfiguration $configuration + * 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 ErrorClassifier $classifier; + public function __construct( - public OperationRegistry $registry, - public array $exceptionPresenters, - public ExceptionPresenter $defaultPresenter = new CatchAllPresenter(), - 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(); + $this->classifier = new ErrorClassifier( + authenticationExceptions: $configuration->unauthenticatedExceptions, + authorizationExceptions: $configuration->unauthorizedExceptions, + notFoundExceptions: $configuration->notFoundExceptions, + rateLimitedExceptions: $configuration->rateLimitedExceptions, + ); } - 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)) { - return new RpcError( - ErrorType::NOT_FOUND, - new OperationNotFoundException("Operation with name: {$name} was not found."), - ['type' => 'NOT_FOUND'], + if (!$this->registry->has(OperationType::QUERY, $key)) { + return $this->present( + 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)) { - return new RpcError( - ErrorType::NOT_FOUND, - new OperationNotFoundException("Operation with name: {$name} was not found."), - ['type' => 'NOT_FOUND'], + if (!$this->registry->has(OperationType::COMMAND, $key)) { + return $this->present( + 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); } - /** - * @throws ContainerExceptionInterface|NotFoundExceptionInterface - */ private function execute(Operation $operation, mixed $input, mixed $context, Client $client): RpcError|RpcSuccess { - $middlewareClassNames = [ - ... $this->configuration->middleware, - ... $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, $operation->definition->type, $operation->definition->fullyQualifiedClassName, $operation->definition->methodName, - $middlewareClassNames, + [...$this->configuration->middleware, ...$operation->definition->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 { - 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->produceError( - new InvalidInputException($inputValidationResult), - $operation->definition, - $resolveInfo, - ); - } + // 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. Nothing was + // executing yet, so there is no scope whose declarations could apply. + try { + // 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); + } - $serializedResult = $this->executor - ->serialize( - $operation->outputNode(), - $controllerClass->{$operation->definition->methodName}($inputValidationResult->value, $context, $client) - ); + return new ContextualPipeline( + middlewares: $middlewares, + 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 ($serializedResult instanceof Failure) { - return $this->produceError( - new InvalidOutputException($serializedResult), - $operation->definition, - $resolveInfo, - ); - } + if ($inputValidationResult instanceof Failure) { + return $this->present( + new InvalidInputException($inputValidationResult), + $resolveInfo, + ); + } - return new RpcSuccess($serializedResult->value, $client, $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 { + /** @phpstan-ignore-next-line method.dynamicName */ + $result = $controllerClass->{$operation->definition->methodName}($inputValidationResult->value, $context, $client); } catch (Throwable $throwable) { - return $this->produceError($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, + ); } - })->execute($input, $context, $resolveInfo, $client); + + return new RpcSuccess($serializedResult->value, $client, $resolveInfo); + }, + )->execute($input, $context, $resolveInfo, $client); } /** - * @param Throwable $exception - * @param Definition $definition - * @return RpcError + * 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 produceError(Throwable $exception, Definition $definition, ?ResolveInfo $info): RpcError + private function createMiddleware(MiddlewareDefinition $definition): MiddlewareContract { - foreach ($this->exceptionPresenters as $presenter) { - if ($presenter->matches($exception, $definition)) { - return new RpcError( - $presenter::errorType(), - $exception, - $presenter->details($exception), - $info - ); + $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 + * 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 { + 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: $this->detailsFor($presentConfig['type'], $throwable, $presentConfig['name'] ?? null), + resolveInfo: $info, + ); + } + } } + + $type = $this->classifier->classify($throwable); + + return new RpcError( + type: $type, + cause: $throwable, + details: $this->detailsFor($type, $throwable), + resolveInfo: $info, + ); + } catch (Throwable $throwable) { + return new RpcError( + type: ErrorType::INTERNAL_ERROR, + cause: $throwable, + details: null, + resolveInfo: $info, + ); } + } - return new RpcError( - $this->defaultPresenter::errorType(), - $exception, - $this->defaultPresenter->details($exception), - $info - ); + /** + * 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, + }; } -} \ No newline at end of file +} diff --git a/src/Typescript/Code/TypescriptFile.php b/src/Typescript/Code/TypescriptFile.php new file mode 100644 index 0000000..8a604c5 --- /dev/null +++ b/src/Typescript/Code/TypescriptFile.php @@ -0,0 +1,196 @@ + 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); + } + + #[NoDiscard] + 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. + */ + #[NoDiscard] + 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; + $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'; + + /** + * 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] + 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..a86f6f3 --- /dev/null +++ b/src/Typescript/Code/TypescriptImport.php @@ -0,0 +1,156 @@ + Sorted, unique. + */ + public array $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 + * + * @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 = [], + ) { + 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 CodeGenException When the two imports name different modules. + */ + #[NoDiscard] + public function merge(self $other): self + { + if ($this->from !== $other->from) { + throw new CodeGenException( + "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(...); + } + + /** + * 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 + { + 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 new file mode 100644 index 0000000..087c362 --- /dev/null +++ b/src/Typescript/Data/EmissionContext.php @@ -0,0 +1,22 @@ +)`. + * @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 AliasRegistry $registry + ) { + } + + public static function fromRawString(string $type): Typescript + { + return new Typescript($type, new AliasRegistry()); + } +} diff --git a/src/Typescript/Exceptions/InvalidStringLiteralException.php b/src/Typescript/Exceptions/InvalidStringLiteralException.php new file mode 100644 index 0000000..76228f4 --- /dev/null +++ b/src/Typescript/Exceptions/InvalidStringLiteralException.php @@ -0,0 +1,23 @@ + $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..1442e11 --- /dev/null +++ b/src/Typescript/Exceptions/UnsupportedTypeException.php @@ -0,0 +1,66 @@ +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." + ); + } + + /** + * 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}'." + ." 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(...))].' + ); + } + + public static function reservedAlias(string $alias): self + { + return new self( + "Type alias {$alias} collides with a declaration the generated types file always contains. Pick a different #[Named] or brand name." + ); + } +} diff --git a/src/Typescript/Helpers/AliasRegistry.php b/src/Typescript/Helpers/AliasRegistry.php new file mode 100644 index 0000000..83080a1 --- /dev/null +++ b/src/Typescript/Helpers/AliasRegistry.php @@ -0,0 +1,94 @@ + 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 AliasRegistry +{ + /** @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 === []; + } + + /** + * 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. + * + * @return array + */ + public function toArray(): array + { + $definitions = $this->definitions; + ksort($definitions); + + return $definitions; + } +} diff --git a/src/Typescript/TypescriptGenerator.php b/src/Typescript/TypescriptGenerator.php new file mode 100644 index 0000000..70000af --- /dev/null +++ b/src/Typescript/TypescriptGenerator.php @@ -0,0 +1,280 @@ +emit($node, $context); + + foreach ($localRegistry->toArray() as $alias => $definition) { + $sharedRegistry?->set($alias, $definition); + } + + return new Typescript($type, $localRegistry); + } + + private function emit(NodeInterface $node, EmissionContext $context): string + { + return match (true) { + $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 => match ($node->backingType) { + BackingType::STRING => 'string', + BackingType::INT => 'number', + }, + $node instanceof LiteralNode => self::literal($node), + $node instanceof EnumNode => self::enum($node), + $node instanceof DateTimeNode => 'string', + $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 => $this->record($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), + }; + } + + 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): string + { + $cases = array_map( + fn (UnitEnum $case): string => Syntax::stringLiteral($case->name), + $node->enumClassName::cases(), + ); + + if ($cases === []) { + throw UnsupportedTypeException::emptyEnum($node->enumClassName); + } + + return implode('|', $cases) |> Syntax::wrapInParentheses(...); + } + + /** + * 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 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 + { + $inner = $this->emit($node->node, $context); + + if ($node->brand !== null) { + $inner = Syntax::branded($inner, $node->brand) |> Syntax::wrapInParentheses(...); + } + + if ($node->name !== null) { + $alias = $node->name->nameFor($context->io); + $context->registry->set($alias, $inner); + + return $alias; + } + + return $inner; + } + + 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. + if ($context->io === IO::INPUT && $node->strategy === ObjectCastStrategy::NEVER) { + throw UnsupportedTypeException::uncastableInput($node); + } + + return $this->emit($node->node, $context); + } + + private function struct(StructNode $node, EmissionContext $context): 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, optional: $property->isOptional), + $this->emit($property->node, $context), + ]; + } + + if ($properties === []) { + return '{}'; + } + + return '{'.implode('', array_map( + fn (array $property): string => "{$property[0]}:{$property[1]};", + $properties, + )).'}'; + } + + /** + * @param UnionNode $node + */ + private function union(UnionNode $node, EmissionContext $context): string + { + $members = array_map( + fn ($member): string => $this->emit($member, $context), + $node->nodes, + ); + + // Distinct schema nodes can render to the same type: `int|float` is one `number`. + return implode('|', array_unique($members)) |> Syntax::wrapInParentheses(...); + } + + private function intersection(IntersectionNode $node, EmissionContext $context): string + { + $members = array_map( + fn ($member): string => $this->emit($member, $context), + $node->nodes, + ); + + 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( + fn (NodeInterface $member): string => $this->emit($member, $context), + $node->nodes, + ); + + return '['.implode(',', $members).']'; + } +} diff --git a/src/Typescript/Utils/Syntax.php b/src/Typescript/Utils/Syntax.php new file mode 100644 index 0000000..4c9793a --- /dev/null +++ b/src/Typescript/Utils/Syntax.php @@ -0,0 +1,81 @@ +`. + */ + public static function branded(string $baseType, string $brandName): string + { + return "{$baseType} & Brand<".self::stringLiteral($brandName).'>'; + } +} diff --git a/src/Utils/Arrays.php b/src/Utils/Arrays.php index 58f2559..d31cb11 100644 --- a/src/Utils/Arrays.php +++ b/src/Utils/Arrays.php @@ -1,18 +1,20 @@ - $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,21 +23,7 @@ public static function mapWithKeys(array $array, Closure $callback): array foreach ($array as $key => $value) { $mapped[$key] = $callback($key, $value); } - 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; + return $mapped; } -} \ No newline at end of file +} diff --git a/src/Utils/Assertions.php b/src/Utils/Assertions.php new file mode 100644 index 0000000..c476694 --- /dev/null +++ b/src/Utils/Assertions.php @@ -0,0 +1,55 @@ + $className + * + * @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; + } + + /** + * @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; + } + + /** + * @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; + } +} diff --git a/src/Utils/Dicts.php b/src/Utils/Dicts.php new file mode 100644 index 0000000..1e0126c --- /dev/null +++ b/src/Utils/Dicts.php @@ -0,0 +1,22 @@ + $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 8f7b5cd..6c467be 100644 --- a/src/Utils/Hashs.php +++ b/src/Utils/Hashs.php @@ -1,15 +1,16 @@ - base64_encode(...) + |> (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 new file mode 100644 index 0000000..eae0609 --- /dev/null +++ b/src/Utils/Lists.php @@ -0,0 +1,48 @@ + $list + * @return list + */ + #[NoDiscard] + 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 + */ + #[NoDiscard] + public static function unique(array $list): array + { + return array_unique($list) |> array_values(...); + } + + /** + * @param list $list + * @return list + */ + #[NoDiscard] + public static function sorted(array $list): array + { + usort($list, strcmp(...)); + + return $list; + } +} diff --git a/src/Utils/Namespaces.php b/src/Utils/Namespaces.php index e77a2cb..23fef56 100644 --- a/src/Utils/Namespaces.php +++ b/src/Utils/Namespaces.php @@ -1,8 +1,10 @@ - 'App\Models', - * 'User' => 'App\Models\User', - * 'UserContract' => 'App\Contracts\User', + * 'models' => 'App\Models', + * 'user' => 'App\Models\User', + * 'usercontract' => 'App\Contracts\User', * ] * ``` * - * @param array|array $namespaces - * @return array + * 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. + * + * @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); + $map[strtolower(Strings::classBaseName($alias))] = self::withoutLeadingSlash($alias); } else { - /** @var class-string $namespace */ - $map[$alias] = self::withoutLeadingSlash($namespace); + $map[strtolower($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 + * 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 { if (str_starts_with($className, '\\')) { - /** @var class-string $className */ 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 = strtolower($segments[0]); if (array_key_exists($lookupKey, $namespacesMap)) { - $classNameOrNameSpace = $namespacesMap[$lookupKey]; - return str_contains($className, '\\') - ? $classNameOrNameSpace . '\\' . $className - : $classNameOrNameSpace; - } - - // 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))) { - return $className; - } + $remaining = array_slice($segments, 1); - if ($namespace !== null && !str_starts_with($className, $namespace)) { - return $namespace . '\\' . $className; + return $remaining === [] + ? $namespacesMap[$lookupKey] + : $namespacesMap[$lookupKey].'\\'.implode('\\', $remaining); } - return $className; + return $namespace === null ? $className : $namespace.'\\'.$className; } -} \ No newline at end of file +} diff --git a/src/Utils/Nodes.php b/src/Utils/Nodes.php index ce2874b..5f0fd24 100644 --- a/src/Utils/Nodes.php +++ b/src/Utils/Nodes.php @@ -1,27 +1,29 @@ -node; } + return $node; } /** - * @param list $nodes - * @return bool + * @param list $nodes */ public static function areAllNodesOfSameStructType(array $nodes): bool { @@ -31,12 +33,13 @@ 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; } $declaredNode = self::getDeclaringNode($node); - if (!$declaredNode instanceof StructNode) { + if (! $declaredNode instanceof StructNode) { return false; } @@ -48,4 +51,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 6424089..dedd775 100644 --- a/src/Utils/PHPExport.php +++ b/src/Utils/PHPExport.php @@ -1,39 +1,74 @@ -name; $className = self::absolute($enum::class); + return "{$className}::{$name}"; } /** - * @param array $array - * @return string + * @param array $array */ public static function exportArray(array $array): string { - if (empty($array)) { + if (count($array) === 0) { return '[]'; } - if (!array_is_list($array)) { - throw new \InvalidArgumentException('Array must be a list'); + if (! array_is_list($array)) { + throw new ParserException('Array must be a list'); } $imploded = implode(',', array_map(self::export(...), $array)); + return "[{$imploded}]"; } @@ -49,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 c094cf9..167bb04 100644 --- a/src/Utils/PhpDoc.php +++ b/src/Utils/PhpDoc.php @@ -1,15 +1,20 @@ - '[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,12 +28,11 @@ private static function compileRegex(string $regex): string } /** - * @param false|string|null $docBlock * @return array */ public static function findImportedTypeDefinition(null|false|string $docBlock): array { - if (empty($docBlock)) { + if ($docBlock === false || $docBlock === null) { return []; } @@ -46,16 +50,16 @@ 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 { - if (empty($docBlock)) { + if ($docBlock === false || $docBlock === null) { return []; } @@ -67,17 +71,17 @@ 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 */ public static function findLocallyDefinedTypes(null|false|string $docBlock): array { - if (empty($docBlock)) { + if ($docBlock === false || $docBlock === null) { return []; } @@ -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 deleted file mode 100644 index 9a8fb1c..0000000 --- a/src/Utils/Reflections.php +++ /dev/null @@ -1,71 +0,0 @@ -getType()) { - throw new RuntimeException("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 RuntimeException("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 RuntimeException("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(); - } -} \ No newline at end of file diff --git a/src/Utils/Regexes.php b/src/Utils/Regexes.php index 8628a0d..375bfd6 100644 --- a/src/Utils/Regexes.php +++ b/src/Utils/Regexes.php @@ -1,41 +1,204 @@ - 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; + $lines = preg_split('/\R/', $withoutDelimiters); + + return array_map( + static fn (string $line): string => trim(preg_replace('/^\s*\*/', '', $line) ?? $line), + $lines === false ? [$withoutDelimiters] : $lines, + ); + } + + /** + * 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/src/Utils/Strings.php b/src/Utils/Strings.php index d50e6e5..1d6359e 100644 --- a/src/Utils/Strings.php +++ b/src/Utils/Strings.php @@ -1,25 +1,28 @@ - (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/src/Validators/Email.php b/src/Validators/Email.php deleted file mode 100644 index d559429..0000000 --- a/src/Validators/Email.php +++ /dev/null @@ -1,45 +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; - } - - public function exportPhpCode(): string - { - $className = PHPExport::absolute(self::class); - return "new {$className}()"; - } -} \ No newline at end of file diff --git a/src/Validators/LengthValidator.php b/src/Validators/LengthValidator.php deleted file mode 100644 index 34e8ecb..0000000 --- a/src/Validators/LengthValidator.php +++ /dev/null @@ -1,105 +0,0 @@ -min); - $max = PHPExport::export($this->max); - $including = PHPExport::export($this->including); - return "new {$className}({$min}, {$max}, {$including})"; - } - - 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/Validators/NonEmptyString.php b/src/Validators/NonEmptyString.php deleted file mode 100644 index a97c0c8..0000000 --- a/src/Validators/NonEmptyString.php +++ /dev/null @@ -1,44 +0,0 @@ -addIssue(new Issue( - IssueMessage::INVALID_TYPE, - [ - "message" => "Expected string, got: " . gettype($value), - ] - )); - return false; - } - - if (empty($value)) { - $context->addIssue(new Issue( - 'validation.not_empty_string', - [ - "message" => "Expected non-empty string, got: '{$value}'", - ] - )); - return false; - } - return true; - } - - public function exportPhpCode(): string - { - $className = PHPExport::absolute(self::class); - return "new {$className}()"; - } -} \ No newline at end of file diff --git a/src/Validators/NonFalsyStringValidator.php b/src/Validators/NonFalsyStringValidator.php deleted file mode 100644 index f058cd9..0000000 --- a/src/Validators/NonFalsyStringValidator.php +++ /dev/null @@ -1,47 +0,0 @@ -addIssue(new Issue( - IssueMessage::INVALID_TYPE, - [ - "message" => "Expected string, got: " . gettype($value), - "value" => $value, - ] - )); - return false; - } - - if (!$value) { - $context->addIssue(new Issue( - IssueMessage::FALSY_STRING, - [ - "message" => "Expected non-falsy string, got: '{$value}'", - ] - )); - return false; - } - - return true; - } - - public function exportPhpCode(): string - { - $className = PHPExport::absolute(self::class); - return "new {$className}()"; - } -} \ No newline at end of file diff --git a/tests/Adapters/Laravel/CodeGenCommandNamingTest.php b/tests/Adapters/Laravel/CodeGenCommandNamingTest.php new file mode 100644 index 0000000..02199a8 --- /dev/null +++ b/tests/Adapters/Laravel/CodeGenCommandNamingTest.php @@ -0,0 +1,65 @@ +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/Adapters/Laravel/LaravelHttpControllerTest.php b/tests/Adapters/Laravel/LaravelHttpControllerTest.php index 0945226..be3f8fb 100644 --- a/tests/Adapters/Laravel/LaravelHttpControllerTest.php +++ b/tests/Adapters/Laravel/LaravelHttpControllerTest.php @@ -2,25 +2,29 @@ namespace Tests\Adapters\Laravel; +use Closure; use Illuminate\Config\Repository; use Illuminate\Contracts\Debug\ExceptionHandler; 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\Executor\SchemaExecutor; 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; use Le0daniel\PhpTsBindings\Server\Data\OperationType; -use Le0daniel\PhpTsBindings\Server\Presenter\CatchAllPresenter; -use Le0daniel\PhpTsBindings\Server\Presenter\InvalidInputPresenter; +use Le0daniel\PhpTsBindings\Server\Data\ServerConfiguration; use Le0daniel\PhpTsBindings\Server\Server; use Mockery; -use Symfony\Component\HttpFoundation\InputBag; +use Throwable; +use TypeError; test('handle successful http query request', function () { // Arrange @@ -31,8 +35,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, @@ -46,11 +49,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() { } @@ -64,15 +67,9 @@ 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, - [], - new CatchAllPresenter(), - $app, - ); + $server = new Server($operationRegistry, new PsrContainerAdapter(container: $app)); $controller = new LaravelHttpController( $server, @@ -92,6 +89,72 @@ public function someMethod(array $input, null $context, Client $client): array ]); }); +test('an operations-spa request gets the client directives appended', function () { + // Arrange + $fcn = 'docs.method'; + $inputData = ['name' => 'some_value']; + + $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', $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->headers->set(OperationClientFactory::CLIENT_ID_HEADER, 'operations-spa'); + $app->shouldReceive('get')->with($operationDefinition->fullyQualifiedClassName)->andReturn($controllerInstance); + + $controller = new LaravelHttpController( + new Server($operationRegistry, new PsrContainerAdapter(container: $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'; @@ -102,8 +165,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, @@ -117,11 +179,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() { } @@ -136,16 +198,10 @@ 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); - $server = new Server( - $operationRegistry, - [new InvalidInputPresenter()], - new CatchAllPresenter(), - $app, - ); + $server = new Server($operationRegistry, new PsrContainerAdapter(container: $app)); $controller = new LaravelHttpController( $server, @@ -162,11 +218,355 @@ 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'] + '__root' => ['validation.missing_property'], ], ], 'code' => 422, + 'type' => 'INVALID_INPUT', + ]); +}); +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'); +}); + +/** + * 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('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'); +}); + +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(OperationClientFactory::CLIENT_ID_HEADER, 'operations-spa'); + + $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'); + } + }; + + // 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); + $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', + ]); +}); + +/** + * 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], ]); -}); \ No newline at end of file +}); + +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'; + + $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); +}); 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/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..02c06ca --- /dev/null +++ b/tests/Feature/ErrorHandling/ErrorScopeOperations.php @@ -0,0 +1,116 @@ + 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(), + '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} + */ + #[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/FullSchemaTest.php b/tests/Feature/FullSchemaTest.php index 7106c56..d7a2274 100644 --- a/tests/Feature/FullSchemaTest.php +++ b/tests/Feature/FullSchemaTest.php @@ -4,8 +4,8 @@ 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\Registry\CachedTypeRegistry; +use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer; +use Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry; use Le0daniel\PhpTsBindings\Parser\TypeParser; use Tests\Feature\Mocks\CreateObjectInput; use Tests\Feature\Mocks\CreateUserInput; @@ -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 { @@ -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); @@ -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() @@ -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', @@ -94,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() @@ -102,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) @@ -129,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) @@ -141,35 +147,33 @@ 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]); + ->and($validResult->value)->toEqual((object) ['id' => 123]); }); test('serialization with custom collection class', function () { @@ -186,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']]); @@ -218,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 @@ - + */ +final class GloballyThrowingMiddleware implements MiddlewareContract +{ + #[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') { + throw new GlobalMiddlewareException(); + } + + return $next($input); + } +} 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/Mocks/NotAMiddleware.php b/tests/Feature/Mocks/NotAMiddleware.php new file mode 100644 index 0000000..fd9da97 --- /dev/null +++ b/tests/Feature/Mocks/NotAMiddleware.php @@ -0,0 +1,13 @@ + $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/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/InvalidNameException.php b/tests/Feature/Operations/InvalidNameException.php index dc1440f..c39ef7d 100644 --- a/tests/Feature/Operations/InvalidNameException.php +++ b/tests/Feature/Operations/InvalidNameException.php @@ -1,14 +1,12 @@ - + */ +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/Operations/PoolingTestClass.php b/tests/Feature/Operations/PoolingTestClass.php new file mode 100644 index 0000000..0afc7ac --- /dev/null +++ b/tests/Feature/Operations/PoolingTestClass.php @@ -0,0 +1,88 @@ + $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/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/Operations/TestClass.php b/tests/Feature/Operations/TestClass.php index 00a5f76..4916988 100644 --- a/tests/Feature/Operations/TestClass.php +++ b/tests/Feature/Operations/TestClass.php @@ -1,18 +1,20 @@ - "Hello {$data['name']}", ]; } -} \ No newline at end of file + + /** + * 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()]; + } + + /** + * 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 + * 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]]; + } +} diff --git a/tests/Feature/ServerErrorHandlingTest.php b/tests/Feature/ServerErrorHandlingTest.php new file mode 100644 index 0000000..30141be --- /dev/null +++ b/tests/Feature/ServerErrorHandlingTest.php @@ -0,0 +1,171 @@ +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('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()); + + 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 d8a9998..61dc84c 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -1,22 +1,35 @@ -command($name, $input, null, new NullClient()); $cachedResponse = $cachedServer->command($name, $input, null, new NullClient()); @@ -31,12 +44,11 @@ function executeOperation(string $name, mixed $input): RpcSuccess|RpcError { expect($regularResponse->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) @@ -47,23 +59,259 @@ 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', ]); }); -test("Middleware emits typescript middleware", function () { +/** + * 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, 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' => '']); + + 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( - EagerlyLoadedRegistry::eagerlyDiscover( - __DIR__ . '/Operations', - keyGenerator: new PlainlyExposedKeyGenerator + EagerlyLoadedOperationRegistry::eagerlyDiscover( + __DIR__.'/Operations', + keyGenerator: new PlainlyExposedKeyGenerator() + ), + configuration: new ServerConfiguration()->withMiddlewares(NotAMiddleware::class), + ); + + $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. + // + // 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(TypeError::class) + ->and($result->cause->getMessage())->toContain(NotAMiddleware::class) + ->and($result->previous)->toBe([]); +}); + +test('Middleware emits typescript middleware', function () { + $server = new Server( + EagerlyLoadedOperationRegistry::eagerlyDiscover( + __DIR__.'/Operations', + keyGenerator: new PlainlyExposedKeyGenerator() ), - [ - new ClientAwareExceptionPresenter(), - ], ); $operation = $server->registry->get(OperationType::COMMAND, 'test.run'); - $errorPresenter = new ClientAwareExceptionPresenter(); - $definition = $errorPresenter->toTypeScriptDefinition($operation->definition); - expect($definition)->toEqual('{type: "invalid_name"}'); -}); \ No newline at end of file + $domainErrors = ErrorTypescript::domainTypesFor($operation->definition); + + expect($domainErrors)->toBe('"invalid_name"'); +}); +/** + * 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); +}); + +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); +}); + +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(), + ); + $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::INTERNAL_ERROR) + ->and($error->details)->toBeNull(); + + $domainErrors = ErrorTypescript::domainTypesFor( + $registry->get(OperationType::COMMAND, 'test.run')->definition, + ); + + expect($domainErrors)->not->toContain('"global_middleware_failed"'); +}); + +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); + + // 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(['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":['); +}); + +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/Integration/CastingGenericsAndAliasesTest.php b/tests/Integration/CastingGenericsAndAliasesTest.php new file mode 100644 index 0000000..7ba13bf --- /dev/null +++ b/tests/Integration/CastingGenericsAndAliasesTest.php @@ -0,0 +1,101 @@ +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"]}},"__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"},"__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"},"__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"},"__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"]}},"__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"]}},"__metadata":{"key":"wow"}}'); +}); + +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/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 @@ +appendMetadata(['key' => $this->value]); + } + + public function configure(array $config): self + { + $this->value = $config['value'] ?? 'default'; + return $this; + } +} 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 + { + $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 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'>}} + */ + #[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/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/CheckoutCommands.php b/tests/Integration/Fixtures/Operations/CheckoutCommands.php new file mode 100644 index 0000000..bba2b26 --- /dev/null +++ b/tests/Integration/Fixtures/Operations/CheckoutCommands.php @@ -0,0 +1,55 @@ + ['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 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(object $input): array + { + if (!$input instanceof stdClass) { + throw new InvalidArgumentException('Expected object'); + } + + return [ + 'flagged' => $input->level === 1 ? 'high' : 'low', + 'level' => $input->level, + ]; + } +} 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/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..3dcfee3 --- /dev/null +++ b/tests/Integration/Fixtures/Operations/OrderQueries.php @@ -0,0 +1,208 @@ +, + * 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' => Sku::fromStringValue('ABC-123')], + ['lineTotal' => new Money(1495, Currency::CHF), 'quantity' => 1, 'sku' => Sku::fromStringValue('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 + { + if (!$input['sku'] instanceof Sku) { + throw new InvalidArgumentException('Expected Sku'); + } + + 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/Operations/ShippingCommands.php b/tests/Integration/Fixtures/Operations/ShippingCommands.php new file mode 100644 index 0000000..c959d7e --- /dev/null +++ b/tests/Integration/Fixtures/Operations/ShippingCommands.php @@ -0,0 +1,194 @@ +} $input + * @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 [ + '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/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 @@ + 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 @@ +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 @@ +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/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 @@ +value; + } +} diff --git a/tests/Integration/Fixtures/Types/PaymentMethod.php b/tests/Integration/Fixtures/Types/PaymentMethod.php new file mode 100644 index 0000000..ff8809c --- /dev/null +++ b/tests/Integration/Fixtures/Types/PaymentMethod.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/PublicWarehouse.php b/tests/Integration/Fixtures/Types/PublicWarehouse.php new file mode 100644 index 0000000..efc06a0 --- /dev/null +++ b/tests/Integration/Fixtures/Types/PublicWarehouse.php @@ -0,0 +1,25 @@ + $value]); + } + + return new self($value); + } + + public function toIntValue(): int + { + return $this->value; + } +} diff --git a/tests/Integration/Fixtures/Types/ShippingClass.php b/tests/Integration/Fixtures/Types/ShippingClass.php new file mode 100644 index 0000000..281d63c --- /dev/null +++ b/tests/Integration/Fixtures/Types/ShippingClass.php @@ -0,0 +1,17 @@ + $value]); + } + + return new self($value); + } + + public function toStringValue(): string + { + return $this->value; + } +} diff --git a/tests/Integration/Fixtures/Types/StockLevel.php b/tests/Integration/Fixtures/Types/StockLevel.php new file mode 100644 index 0000000..eec0280 --- /dev/null +++ b/tests/Integration/Fixtures/Types/StockLevel.php @@ -0,0 +1,16 @@ + $value]); + } + + return new self($value); + } + + public function toIntValue(): int + { + return $this->value; + } +} diff --git a/tests/Integration/IntegrationHarness.php b/tests/Integration/IntegrationHarness.php new file mode 100644 index 0000000..5b69460 --- /dev/null +++ b/tests/Integration/IntegrationHarness.php @@ -0,0 +1,100 @@ +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; + } + + 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 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(), + ); + } + + private static function eagerRegistry(): EagerlyLoadedOperationRegistry + { + return self::$eagerRegistry ??= self::discoverEagerRegistry(); + } + + 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: self::CACHE_ID_LENGTH); + 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/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"]}}}'); +}); diff --git a/tests/Mocks/Errors/ErrorOperations.php b/tests/Mocks/Errors/ErrorOperations.php new file mode 100644 index 0000000..75e4214 --- /dev/null +++ b/tests/Mocks/Errors/ErrorOperations.php @@ -0,0 +1,34 @@ + + */ +final class RenamingMiddleware implements MiddlewareContract +{ + #[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/Mocks/Errors/ThrowingMiddleware.php b/tests/Mocks/Errors/ThrowingMiddleware.php new file mode 100644 index 0000000..c99deba --- /dev/null +++ b/tests/Mocks/Errors/ThrowingMiddleware.php @@ -0,0 +1,25 @@ + + */ +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..8a2a195 --- /dev/null +++ b/tests/Mocks/Errors/UndeclaredExposedException.php @@ -0,0 +1,16 @@ + array_last(...); + + return $io === IO::INPUT ? "{$base}Input" : $base; + } +} diff --git a/tests/Mocks/Named/ArticleResource.php b/tests/Mocks/Named/ArticleResource.php new file mode 100644 index 0000000..7126233 --- /dev/null +++ b/tests/Mocks/Named/ArticleResource.php @@ -0,0 +1,18 @@ +visible = strrev($secret); + } +} diff --git a/tests/Mocks/Named/BrandedPayload.php b/tests/Mocks/Named/BrandedPayload.php new file mode 100644 index 0000000..454ef41 --- /dev/null +++ b/tests/Mocks/Named/BrandedPayload.php @@ -0,0 +1,18 @@ +)` + * 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')] +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..e80dbf5 --- /dev/null +++ b/tests/Mocks/Named/Order.php @@ -0,0 +1,21 @@ +visible = strrev($secret); + } +} diff --git a/tests/Mocks/Named/PublicResource.php b/tests/Mocks/Named/PublicResource.php new file mode 100644 index 0000000..28010d9 --- /dev/null +++ b/tests/Mocks/Named/PublicResource.php @@ -0,0 +1,19 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/AmbiguousValueObject.php b/tests/Mocks/ValueObjects/AmbiguousValueObject.php new file mode 100644 index 0000000..2ac8e7e --- /dev/null +++ b/tests/Mocks/ValueObjects/AmbiguousValueObject.php @@ -0,0 +1,39 @@ +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..23ceb21 --- /dev/null +++ b/tests/Mocks/ValueObjects/CreateAccountInput.php @@ -0,0 +1,15 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/EmptyValidationValueObject.php b/tests/Mocks/ValueObjects/EmptyValidationValueObject.php new file mode 100644 index 0000000..612ac5d --- /dev/null +++ b/tests/Mocks/ValueObjects/EmptyValidationValueObject.php @@ -0,0 +1,29 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/ExplodingValueObject.php b/tests/Mocks/ValueObjects/ExplodingValueObject.php new file mode 100644 index 0000000..d203b9f --- /dev/null +++ b/tests/Mocks/ValueObjects/ExplodingValueObject.php @@ -0,0 +1,28 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/AccountId.php b/tests/Mocks/ValueObjects/Inherited/AccountId.php new file mode 100644 index 0000000..127de41 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/AccountId.php @@ -0,0 +1,29 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/AlsoBranded.php b/tests/Mocks/ValueObjects/Inherited/AlsoBranded.php new file mode 100644 index 0000000..6d30e20 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/AlsoBranded.php @@ -0,0 +1,16 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/BadClosureId.php b/tests/Mocks/ValueObjects/Inherited/BadClosureId.php new file mode 100644 index 0000000..9d9bc56 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/BadClosureId.php @@ -0,0 +1,29 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/BaseId.php b/tests/Mocks/ValueObjects/Inherited/BaseId.php new file mode 100644 index 0000000..95f5f1e --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/BaseId.php @@ -0,0 +1,32 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/BrandId.php b/tests/Mocks/ValueObjects/Inherited/BrandId.php new file mode 100644 index 0000000..cef9292 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/BrandId.php @@ -0,0 +1,22 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/ChildId.php b/tests/Mocks/ValueObjects/Inherited/ChildId.php new file mode 100644 index 0000000..bca3bb5 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/ChildId.php @@ -0,0 +1,9 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/DeepId.php b/tests/Mocks/ValueObjects/Inherited/DeepId.php new file mode 100644 index 0000000..4c3f6cd --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/DeepId.php @@ -0,0 +1,22 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/DeepIntId.php b/tests/Mocks/ValueObjects/Inherited/DeepIntId.php new file mode 100644 index 0000000..7d88f23 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/DeepIntId.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/GrandChildId.php b/tests/Mocks/ValueObjects/Inherited/GrandChildId.php new file mode 100644 index 0000000..52ab0c3 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/GrandChildId.php @@ -0,0 +1,12 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/LegacyId.php b/tests/Mocks/ValueObjects/Inherited/LegacyId.php new file mode 100644 index 0000000..9b6adc8 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/LegacyId.php @@ -0,0 +1,9 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/Naming.php b/tests/Mocks/ValueObjects/Inherited/Naming.php new file mode 100644 index 0000000..2c9c945 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/Naming.php @@ -0,0 +1,54 @@ + array_last(...); + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php b/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php new file mode 100644 index 0000000..158456c --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/ParentWinsBase.php @@ -0,0 +1,30 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php b/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php new file mode 100644 index 0000000..250d8d0 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/ParentWinsContract.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/PlainContract.php b/tests/Mocks/ValueObjects/Inherited/PlainContract.php new file mode 100644 index 0000000..c7e7d09 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/PlainContract.php @@ -0,0 +1,14 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/ReceiptId.php b/tests/Mocks/ValueObjects/Inherited/ReceiptId.php new file mode 100644 index 0000000..feaec3e --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/ReceiptId.php @@ -0,0 +1,22 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Inherited/SharedExplicitBrand.php b/tests/Mocks/ValueObjects/Inherited/SharedExplicitBrand.php new file mode 100644 index 0000000..e8991b6 --- /dev/null +++ b/tests/Mocks/ValueObjects/Inherited/SharedExplicitBrand.php @@ -0,0 +1,17 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/Slug.php b/tests/Mocks/ValueObjects/Slug.php new file mode 100644 index 0000000..d679d99 --- /dev/null +++ b/tests/Mocks/ValueObjects/Slug.php @@ -0,0 +1,40 @@ +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..8cc751b --- /dev/null +++ b/tests/Mocks/ValueObjects/StatusEnum.php @@ -0,0 +1,28 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/UserId.php b/tests/Mocks/ValueObjects/UserId.php new file mode 100644 index 0000000..30d33f4 --- /dev/null +++ b/tests/Mocks/ValueObjects/UserId.php @@ -0,0 +1,31 @@ +value; + } +} diff --git a/tests/Mocks/ValueObjects/ValidatedAge.php b/tests/Mocks/ValueObjects/ValidatedAge.php new file mode 100644 index 0000000..9317c42 --- /dev/null +++ b/tests/Mocks/ValueObjects/ValidatedAge.php @@ -0,0 +1,34 @@ + 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..5523d1b --- /dev/null +++ b/tests/Mocks/ValueObjects/ValidatedEmail.php @@ -0,0 +1,41 @@ + $value]); + } + + return new self($value); + } + + public function toStringValue(): string + { + return $this->value; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index fee29a8..081fc14 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -11,21 +11,24 @@ | */ -use Le0daniel\PhpTsBindings\CodeGen\Data\DefinitionTarget; -use Le0daniel\PhpTsBindings\CodeGen\TypescriptDefinitionGenerator; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Data\IO; use Le0daniel\PhpTsBindings\Executor\Data\Failure; use Le0daniel\PhpTsBindings\Executor\Data\Issue; use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; use Le0daniel\PhpTsBindings\Executor\Data\SerializationOptions; 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\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer; +use Le0daniel\PhpTsBindings\Parser\Helpers\AstValidator; +use Le0daniel\PhpTsBindings\Parser\Helpers\Registry\CachedTypeRegistry; use Le0daniel\PhpTsBindings\Parser\TypeParser; +use Le0daniel\PhpTsBindings\Typescript\Data\Typescript; +use Le0daniel\PhpTsBindings\Typescript\Helpers\AliasRegistry; +use Le0daniel\PhpTsBindings\Typescript\TypescriptGenerator; +use Tests\TestCase; -pest()->extend(Tests\TestCase::class)->in('Feature'); +pest()->extend(TestCase::class)->in('Feature', 'Integration'); /* |-------------------------------------------------------------------------- @@ -47,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', ])); }); @@ -57,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) ); }); }); @@ -78,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) @@ -104,48 +109,41 @@ | */ -function compareToOptimizedAst(NodeInterface $node) { - $sortedNode = AstSorter::sort($node); +function compareToOptimizedAst(NodeInterface $node) +{ $optimizer = new ASTOptimizer(); - $optimizedCode = $optimizer->generateOptimizedCode(['node' => $sortedNode]); + $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); expect( (string) $registry->get('node') - )->toEqual((string) $sortedNode); + )->toEqual((string) $node); } -function typescriptDefinition(NodeInterface $node, DefinitionTarget $target): string +/** + * Generates TypeScript for a node, asserting the optimized AST stays structurally identical. + * + * Codegen metadata (brands, named types) is deliberately eliminated by the ASTOptimizer: cached + * ASTs are runtime only, and TypeScript generation always runs on freshly parsed schemas. + * 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 { - $sortedNode = AstSorter::sort($node); - $optimizer = new ASTOptimizer(); - $optimizedCode = $optimizer->generateOptimizedCode(['node' => $sortedNode]); - - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ - $registry = eval("return {$optimizedCode};"); - - $tsGenerator = new TypescriptDefinitionGenerator(); - - foreach (DefinitionTarget::cases() as $case) { - $expected = $tsGenerator->toDefinition($sortedNode, $case); - $optimized = $tsGenerator->toDefinition($registry->get('node'), $case); - expect($expected)->toEqual($optimized); - } + compareToOptimizedAst($node); - return $tsGenerator->toDefinition($sortedNode, $target); + 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]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $optimizedAst = $registry->get('node'); @@ -160,25 +158,25 @@ 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; } 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]); - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ + /** @var CachedTypeRegistry $registry */ $registry = eval("return {$optimizedCode};"); $optimizedAst = $registry->get('node'); @@ -193,22 +191,39 @@ 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; } +/** + * 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(); $optimizedCode = $optimizer->generateOptimizedCode(['node' => $node]); - /** @var \Le0daniel\PhpTsBindings\Parser\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 new file mode 100644 index 0000000..3dde199 --- /dev/null +++ b/tests/Unit/Adapters/Laravel/ArtisanOptionsTest.php @@ -0,0 +1,49 @@ +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], +]); diff --git a/tests/Unit/CodeGen/CodeGeneratorsTest.php b/tests/Unit/CodeGen/CodeGeneratorsTest.php new file mode 100644 index 0000000..8bb9e37 --- /dev/null +++ b/tests/Unit/CodeGen/CodeGeneratorsTest.php @@ -0,0 +1,126 @@ + $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/{key}', '/command/{key}', new ServerConfiguration())); + + 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/EmitOperationClientBindingsTest.php b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php new file mode 100644 index 0000000..1be1191 --- /dev/null +++ b/tests/Unit/CodeGen/EmitOperationClientBindingsTest.php @@ -0,0 +1,292 @@ + + */ +function bindingFiles(): array +{ + $emitter = new EmitOperationClientBindings(); + $emitter->setDependencies([ + EmitTypes::class => new EmitTypes(), + EmitTypeUtils::class => new EmitTypeUtils(), + ]); + + return $emitter->emitFiles( + [], + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), + 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([ + // 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']], + ]], + '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. 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' => ['Failure', 'Result']], + './lib/utils' => ['values' => ['isValidEnvelop'], 'types' => []], + ]], +]); + +/** + * 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 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', '): Promise<{status: number; jsonBody: unknown}>;'], + 'the implementation' => ['DefaultClient', 'options?: OperationOptions): Promise<{status: number; jsonBody: unknown}>'], + 'the binding' => ['bindings', 'options?: OperationOptions): Promise>'], +]); + +/** + * 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('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('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 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('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 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()['bindings']->toString()) + ->toContain('if (isValidEnvelop(jsonBody)) {') + ->toContain('return await callHooks(jsonBody as Result, operation);') + ->not->toContain('response.ok'); +}); + +/** + * 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()['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 {'); +}); + +/** + * 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 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. + 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 + * 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) { + 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([]); +}); + +// 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' && $name !== 'utils' && ! 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/EmitOperationsSpaClientTest.php b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php new file mode 100644 index 0000000..70c4b23 --- /dev/null +++ b/tests/Unit/CodeGen/EmitOperationsSpaClientTest.php @@ -0,0 +1,91 @@ + + */ +function spaClientFiles(): array +{ + return new EmitOperationsSpaClient()->emitFiles( + [], + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), + 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/EmitQueryKeyTest.php b/tests/Unit/CodeGen/EmitQueryKeyTest.php new file mode 100644 index 0000000..6d49b72 --- /dev/null +++ b/tests/Unit/CodeGen/EmitQueryKeyTest.php @@ -0,0 +1,86 @@ +setDependencies([ + EmitOperations::class => new EmitOperations($nameGenerator), + EmitTypeUtils::class => new EmitTypeUtils(), + ]); + + $file = $emitter->generateOperationCode( + $typedOperation, + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), + ); + + return [$file->code, $file->toString()]; +} + +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('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;}'])), + 'never', + queryOperation(), + )); + + // 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('follows the naming rule of the EmitOperations it depends on', function () { + [$code] = queryKeyCodeFor( + new TypedOperation( + Typescript::fromRawString('{id:number;}'), + Typescript::fromRawString('string'), + 'never', + queryOperation(), + ), + 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 new file mode 100644 index 0000000..9c1960c --- /dev/null +++ b/tests/Unit/CodeGen/EmitTanstackQueryTest.php @@ -0,0 +1,117 @@ +setDependencies([ + EmitOperations::class => new EmitOperations($nameGenerator), + EmitTypeUtils::class => new EmitTypeUtils(), + EmitOperationClientBindings::class => new EmitOperationClientBindings(), + ]); + + return $emitter->generateOperationCode( + $typedOperation, + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), + ); +} + +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'), + 'never', + 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'), + 'never', + 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'), + 'never', + 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'), + 'never', + tanstackOperation(OperationType::COMMAND), + )))->toBeNull(); +}); diff --git a/tests/Unit/CodeGen/EmitTypeUtilsTest.php b/tests/Unit/CodeGen/EmitTypeUtilsTest.php new file mode 100644 index 0000000..bfd3db3 --- /dev/null +++ b/tests/Unit/CodeGen/EmitTypeUtilsTest.php @@ -0,0 +1,124 @@ +parse('array{id: string}'), + output: $parser->parse('array{id: string}'), + ); + + $generator = new TypescriptGenerator(); + $registry = new AliasRegistry(); + $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, $registry); + $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); + + // 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(), + EmitOperationClientBindings::class => new EmitOperationClientBindings(), + ]); + + $files = $emitter->emitFiles( + [new TypedOperation($input, $output, 'never', $operation)], + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), + $registry, + ); + + return $files['utils']->toString(); +} + +test('query namespaces are emitted as a literal union', function () { + expect(emitUtilsFor(namespace: 'orders'))->toContain("type QueryNamespaces = 'orders';"); +}); + +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);'); +}); + +/** + * 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()) + ->not->toContain('__client') + ->not->toContain('operations-spa') + ->not->toContain('ClientToast') + ->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 = {') + ->toContain("&& typeof code === 'number'") + ->toContain('&& SERVER_ERROR_CODES[type as keyof typeof SERVER_ERROR_CODES] === 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 + // is pinned in TypescriptServerCodeGeneratorTest. + // + // OperationException is constructed, so a type only import would leave `new OperationException(...)` + // referencing nothing at runtime. + expect(emitUtilsFor()) + ->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 new file mode 100644 index 0000000..e8ce945 --- /dev/null +++ b/tests/Unit/CodeGen/EmitTypesTest.php @@ -0,0 +1,248 @@ +parse($inputType), + output: $parser->parse($outputType), + ); + + $generator = new TypescriptGenerator(); + $registry = new AliasRegistry(); + $input = $generator->toTypescript($operation->inputNode(), IO::INPUT, $registry); + $output = $generator->toTypescript($operation->outputNode(), IO::OUTPUT, $registry); + + $files = new EmitTypes()->emitFiles( + [new TypedOperation($input, $output, 'never', $operation)], + new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration()), + $registry, + ); + + return $files['types']->toString(); +} + +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/{key}', '/command/{key}', new ServerConfiguration()), $registry)) + ->toThrow(UnsupportedTypeException::class, 'collides with a declaration'); +})->with([ + 'the Brand helper generic' => ['Brand'], + 'the Result envelope' => ['Result'], + '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 rate limited envelope' => ['RateLimitedError'], + 'the domain envelope' => ['DomainError'], + 'the internal envelope' => ['InternalError'], + 'the client envelope' => ['ClientError'], +]); + +/** + * 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 declaration the types file always contains is reserved', function () { + $types = new EmitTypes()->emitFiles( + [], + new ServerMetadata('/query/{key}', '/command/{key}', 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/{key}', '/command/{key}', 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 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}};'); +}); + +/** + * 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 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|AuthenticationError|AuthorizationError|NotFoundError|RateLimitedError|DomainError|InternalError|ClientError);') + ->not->toContain('{code: number}'); +}); + +/** + * 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('the auth branches are in Failure without any exceptions mapped onto them', function () { + $types = emitTypesFor( + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.'}', + ); + + preg_match('/^export type Failure.*$/m', $types, $matches); + + 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 () { + $types = emitTypesFor( + 'array{id: \\'.UserId::class.'}', + 'array{email: \\'.Email::class.'}', + ); + + // 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;') + ->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} & ('); +}); + +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.'}', + ); + + expect($types) + ->toContain('export type Brand') + ->not->toContain('export type CustomerId') + ->not->toContain('export type Email') + ->not->toContain('Slug'); +}); + +test('named types are exported once, nested aliases and inline brands included', function () { + $types = emitTypesFor( + 'array{status: \\'.OrderStatus::class.'}', + '\\'.Order::class, + ); + + expect($types) + ->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 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">)') + ->not->toContain('export type Email'); +}); diff --git a/tests/Unit/CodeGen/ErrorTypescriptTest.php b/tests/Unit/CodeGen/ErrorTypescriptTest.php new file mode 100644 index 0000000..d5f3446 --- /dev/null +++ b/tests/Unit/CodeGen/ErrorTypescriptTest.php @@ -0,0 +1,71 @@ + $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. + array_map(static fn (string $className): MiddlewareDefinition => new MiddlewareDefinition($className), $middleware), + ); +} + +test('the domain types list every exposed exception the operation declares', function () { + $domainTypes = ErrorTypescript::domainTypesFor( + typescriptDefinition('declaresThrows', [ThrowingMiddleware::class]), + ); + + expect($domainTypes)->toBe('"domain_failure"|"middleware_failure"'); +}); + +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('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( + typescriptDefinition('declaresRenamedThrows', [RenamingMiddleware::class]), + ); + + expect($domainTypes)->toBe('"renamed_failure"|"overridden_failure"|"renamed_middleware_failure"|"middleware_name"|"middleware_named_it"'); +}); + +/** + * `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(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(typescriptDefinition('declaresThrows')); + + expect($domainTypes)->toBe('"domain_failure"') + ->and($domainTypes)->not->toContain('UnexposedException'); +}); diff --git a/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php b/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php new file mode 100644 index 0000000..2015d06 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/AsymmetricNamedOperations.php @@ -0,0 +1,24 @@ + OrderStatus::OPEN]; + } +} diff --git a/tests/Unit/CodeGen/Mocks/PerDirectionNamedOperations.php b/tests/Unit/CodeGen/Mocks/PerDirectionNamedOperations.php new file mode 100644 index 0000000..d1ea974 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/PerDirectionNamedOperations.php @@ -0,0 +1,28 @@ + 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..f763015 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/CatalogOperations.php @@ -0,0 +1,65 @@ +} $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..659ef57 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/ShapeOperations.php @@ -0,0 +1,95 @@ +, + * byId: array, + * modes: array<'draft'|'live', int>, + * 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' => [], + 'byId' => [], + 'modes' => [], + '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..fb9e4c2 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/AccountFilter.php @@ -0,0 +1,21 @@ + 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..e5a1633 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/Availability.php @@ -0,0 +1,19 @@ +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..a1cf8fd --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/Money.php @@ -0,0 +1,23 @@ + */ + 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..aedb80a --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProductId.php @@ -0,0 +1,35 @@ +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..81f3d1d --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/TsOutput/Types/ProvisioningException.php @@ -0,0 +1,15 @@ +). + */ +#[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/Mocks/UnrepresentableOperations.php b/tests/Unit/CodeGen/Mocks/UnrepresentableOperations.php new file mode 100644 index 0000000..2ebb951 --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/UnrepresentableOperations.php @@ -0,0 +1,24 @@ + $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..729741b --- /dev/null +++ b/tests/Unit/CodeGen/Mocks/UserOperations.php @@ -0,0 +1,41 @@ + 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/OutputDirectoryTest.php b/tests/Unit/CodeGen/OutputDirectoryTest.php new file mode 100644 index 0000000..7a2bf72 --- /dev/null +++ b/tests/Unit/CodeGen/OutputDirectoryTest.php @@ -0,0 +1,101 @@ + 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/PathsTest.php b/tests/Unit/CodeGen/PathsTest.php new file mode 100644 index 0000000..0ec428c --- /dev/null +++ b/tests/Unit/CodeGen/PathsTest.php @@ -0,0 +1,26 @@ +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/TsOutputFixture.php b/tests/Unit/CodeGen/TsOutputFixture.php new file mode 100644 index 0000000..48179ba --- /dev/null +++ b/tests/Unit/CodeGen/TsOutputFixture.php @@ -0,0 +1,58 @@ + + */ + 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( + CodeGenerators::fromDefaults('name', with: ['type-map', 'tanstack-query', 'query-key']), + )->generate($server, new ServerMetadata('/query/{key}', '/command/{key}', new ServerConfiguration())); + } +} diff --git a/tests/Unit/CodeGen/TsOutputFixtureTest.php b/tests/Unit/CodeGen/TsOutputFixtureTest.php new file mode 100644 index 0000000..6811455 --- /dev/null +++ b/tests/Unit/CodeGen/TsOutputFixtureTest.php @@ -0,0 +1,46 @@ +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/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') + ->toContain('catalog.ts') + ->toContain('shapes.ts'); +}); diff --git a/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php new file mode 100644 index 0000000..00cc39b --- /dev/null +++ b/tests/Unit/CodeGen/TypescriptServerCodeGeneratorTest.php @@ -0,0 +1,387 @@ + $classes + * @param list $generators + * @return array + */ +function generateFor(array $classes, ?array $generators = null): array +{ + $server = new Server( + EagerlyLoadedOperationRegistry::withClasses($classes, keyGenerator: new PlainlyExposedKeyGenerator()), + ); + + return new TypescriptServerCodeGenerator( + $generators ?? [ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTypeUtils(), + new EmitOperations(), + ], + )->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 () { + $files = generateFor([UserOperations::class]); + + expect($files)->toHaveKey('lib/types.ts') + ->and($files['lib/types.ts']->toString()) + ->toContain('export type Brand') + ->not->toContain('export type CustomerId') + ->not->toContain('export type Email') + ->not->toContain('Slug'); +}); + +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:(number & Brand<"customerId">);};') + ->toContain('export type GetResult = {email:(string & Brand<"email">);slug:string;};') + ->toContain('export type CreateInput = {name:string;};') + ->toContain('export type CreateResult = {id:(number & Brand<"customerId">);};') + ->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('RateLimitedError') + ->not->toContain('DomainError<'); +}); + +/** + * 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 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|AuthenticationError|AuthorizationError|NotFoundError|RateLimitedError|DomainError|InternalError|ClientError);'); +}); + +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]); + + 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 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('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: + // 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".<<<'TypeScript' + import type {OperationOptions} from './lib/OperationClient'; + import {executeOperation} from './lib/bindings'; + import type {Brand, Customer, Order, OrderStatus} from './lib/types'; + import {queryKey, throwOnFailure} from './lib/utils'; + import type {UseQueryOptions} from '@tanstack/react-query'; + import {queryOptions, useQuery} from '@tanstack/react-query'; + + 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('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(TypescriptFile::MARKER."\n\n".<<<'TypeScript' + import {DefaultClient} from './DefaultClient'; + import type {OperationClient, OperationOptions} from './OperationClient'; + import type {Failure, Result} from './types'; + import {isValidEnvelop} from './utils'; + + TypeScript); + + expect($files['lib/utils.ts']->toString())->toStartWith(TypescriptFile::MARKER."\n\n".<<<'TypeScript' + import {OperationException} from './OperationException'; + import type {Result, Success} from './types'; + + TypeScript); + + expect($files['lib/types.ts']->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(), + 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()]); + + // Two files: typemap inlines the aliases EmitTypes declares, so it only resolves while + // it sits next to them. + 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 () { + expect(fn () => generateFor([NamedOperations::class], [ + new EmitTypes(), + new EmitOperationClientBindings(), + new EmitTanstackQuery(), + ]))->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 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/{key}', '/command/{key}', $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'); +}); + +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'); +}); + +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;'); +}); + +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/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/Contracts/Attributes/NamespaceAsStringTest.php b/tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php new file mode 100644 index 0000000..20f81a3 --- /dev/null +++ b/tests/Unit/Contracts/Attributes/NamespaceAsStringTest.php @@ -0,0 +1,39 @@ +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..3e85c02 --- /dev/null +++ b/tests/Unit/Contracts/ExceptionHierarchyTest.php @@ -0,0 +1,112 @@ +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, + 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([ + 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 ParsingScope()); + $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/Definition/TypescriptDefinitionTest.php b/tests/Unit/Definition/TypescriptDefinitionTest.php deleted file mode 100644 index 26fd213..0000000 --- a/tests/Unit/Definition/TypescriptDefinitionTest.php +++ /dev/null @@ -1,85 +0,0 @@ -parse($typeString); - - $optimizer = new ASTOptimizer(); - $optimizedCode = $optimizer->generateOptimizedCode(['node' => $ast]); - - /** @var \Le0daniel\PhpTsBindings\Parser\Registry\CachedTypeRegistry $registry */ - $registry = eval("return {$optimizedCode};"); - - $definitionWriter = new TypescriptDefinitionGenerator(); - - /** @var string|null $definition */ - $definition = null; - foreach ($modes as $mode) { - $realDef = $definitionWriter->toDefinition($ast, $mode); - $optimizedDef = $definitionWriter->toDefinition($registry->get('node'), $mode); - expect($realDef)->toEqual($optimizedDef); - $definition ??= $realDef; - expect($definition)->toEqual($realDef); - } - - return $definition; -} - -describe('Test to definition', function () { - - test('Simple union type', function () { - expect(toDefinition('array{name: string}|string')) - ->toBe("{name:string;}|string"); - }); - - test('Optional Fields', function () { - expect(toDefinition('array{name?: string}|string')) - ->toBe("{name?:string;}|string"); - }); - - test('Array type returns object', function () { - expect(toDefinition('array{name: string}')) - ->toBe("{name:string;}"); - }); - - test('Object type returns object', function () { - expect(toDefinition('object{name: string}')) - ->toBe("{name:string;}"); - }); - - test('Custom class type input', function () { - expect(toDefinition(UserSchema::class, DefinitionTarget::INPUT)) - ->toBe("{age:number;email:string;username:string;}"); - }); - - test('Custom class type output', function () { - expect(toDefinition(UserSchema::class, DefinitionTarget::OUTPUT)) - ->toBe("{age:number;username:string;}"); - }); - - test('scalar', function () { - expect(toDefinition('scalar')) - ->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;}"); - }); - - test('Complex union intersection', function () { - expect(toDefinition('((array{id: positive-int}|array{token: string})&array{reason: string})|' . UserSchema::class, DefinitionTarget::INPUT)) - ->toBe("(({id:number;}|{token:string;})&{reason:string;})|{age:number;email:string;username:string;}"); - }); -}); - diff --git a/tests/Unit/Executor/ContextPathTest.php b/tests/Unit/Executor/ContextPathTest.php new file mode 100644 index 0000000..154a5ea --- /dev/null +++ b/tests/Unit/Executor/ContextPathTest.php @@ -0,0 +1,98 @@ +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. + // 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', '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/Exceptions/ValidationExceptionTest.php b/tests/Unit/Executor/Exceptions/ValidationExceptionTest.php new file mode 100644 index 0000000..401b429 --- /dev/null +++ b/tests/Unit/Executor/Exceptions/ValidationExceptionTest.php @@ -0,0 +1,60 @@ +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/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/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 @@ - '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/ResultTest.php b/tests/Unit/Executor/ResultTest.php new file mode 100644 index 0000000..ff8d1fb --- /dev/null +++ b/tests/Unit/Executor/ResultTest.php @@ -0,0 +1,45 @@ +not->toBeInstanceOf(\Throwable::class); +}); + +test('isSuccess distinguishes the two arms without instanceof', function () { + expect(new Success('value')->isSuccess())->toBeTrue() + ->and(new Failure(new Issues())->isSuccess())->toBeFalse(); +}); + +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); +}); + +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/Executor/SchemaExecutorTest.php b/tests/Unit/Executor/SchemaExecutorTest.php index 685ba8f..52e37eb 100644 --- a/tests/Unit/Executor/SchemaExecutorTest.php +++ b/tests/Unit/Executor/SchemaExecutorTest.php @@ -2,19 +2,29 @@ namespace Tests\Unit\Executor; -use Closure; use DateTimeImmutable; -use JsonException; -use Le0daniel\PhpTsBindings\Contracts\NodeInterface; -use Le0daniel\PhpTsBindings\Executor\Data\Failure; +use InvalidArgumentException; +use Le0daniel\PhpTsBindings\Executor\Data\Issue; +use Le0daniel\PhpTsBindings\Executor\Data\ParsingOptions; 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 Le0daniel\PhpTsBindings\Executor\Exceptions\ValidationException; +use Le0daniel\PhpTsBindings\Parser\Nodes\Leaf\ValueObjectNode; +use LogicException; use Stringable; +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\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) { $result = executeParse($type, $value); @@ -23,6 +33,7 @@ if (is_object($expected)) { expect($result->value)->toBeInstanceOf(get_class($expected)); expect($result->value)->toEqual($expected); + return; } expect($result->value)->toBe($expected); @@ -32,7 +43,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'], @@ -43,55 +54,71 @@ ['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]], + ['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')], + [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], + [StatusEnum::class, 'active', StatusEnum::ACTIVE], ]); test('serialize success', function (string $type, mixed $value, mixed $expected) { @@ -102,6 +129,7 @@ if (is_object($expected)) { expect($result->value)->toBeInstanceOf(get_class($expected)); expect($result->value)->toEqual($expected); + return; } expect($result->value)->toBe($expected); @@ -127,69 +155,89 @@ 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]], + // 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]], + [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], + // 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'], + ], +]); test('serialization with partial failures', function () { /** @var Success $result */ @@ -199,7 +247,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(); @@ -214,8 +262,402 @@ public function __toString(): string expect($result)->toBeFailureAt('name'); expect($result->issues->serializeToFieldsArray())->toEqual([ 'name' => [ - 'validation.missing_property' + 'validation.missing_property', ], ]); }); +/** + * --------------------------------------------------------------------------- + * 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'); +}); + +/** + * 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') + ->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_value') + ->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_value') + ->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'), + ]); +}); + +/** + * --------------------------------------------------------------------------- + * 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 + * --------------------------------------------------------------------------- + */ +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']); + + expect($result)->toBeFailureAt('email', 'validation.invalid_value'); +}); + +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]); +}); + +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/Executor/StrictnessTest.php b/tests/Unit/Executor/StrictnessTest.php new file mode 100644 index 0000000..8295918 --- /dev/null +++ b/tests/Unit/Executor/StrictnessTest.php @@ -0,0 +1,116 @@ + $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' => [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' => [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(); +}); diff --git a/tests/Unit/Executor/UnionAndEnumDispatchTest.php b/tests/Unit/Executor/UnionAndEnumDispatchTest.php new file mode 100644 index 0000000..65569ed --- /dev/null +++ b/tests/Unit/Executor/UnionAndEnumDispatchTest.php @@ -0,0 +1,140 @@ +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(ResultEnum::class); + $executor = new SchemaExecutor(); + + 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(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..8b528b0 --- /dev/null +++ b/tests/Unit/Parser/ASTOptimizerTest.php @@ -0,0 +1,169 @@ + $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 UnionNode($discriminated->nodes); + + 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/Constraints/IntRangeTest.php b/tests/Unit/Parser/Constraints/IntRangeTest.php new file mode 100644 index 0000000..01b2916 --- /dev/null +++ b/tests/Unit/Parser/Constraints/IntRangeTest.php @@ -0,0 +1,82 @@ +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..c086aeb --- /dev/null +++ b/tests/Unit/Parser/Constraints/ListLengthTest.php @@ -0,0 +1,67 @@ +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..477841d --- /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', +]); + +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/Parser/Constraints/StringConstraintsTest.php b/tests/Unit/Parser/Constraints/StringConstraintsTest.php new file mode 100644 index 0000000..aaa1b18 --- /dev/null +++ b/tests/Unit/Parser/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/Parser/Data/ParsingContextTest.php b/tests/Unit/Parser/Data/ParsingContextTest.php index dd926a8..b55de03 100644 --- a/tests/Unit/Parser/Data/ParsingContextTest.php +++ b/tests/Unit/Parser/Data/ParsingContextTest.php @@ -2,15 +2,15 @@ namespace Tests\Unit\Parser\Data; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; use ReflectionClass; use Tests\Unit\Parser\Data\Stubs\ComplexPhpDoc; use Tests\Unit\Parser\Data\Stubs\MyUserClass; test('from class reflection', function () { // Reads all the context out of the file. - $context = ParsingContext::fromReflectionClass(new ReflectionClass(MyUserClass::class)); - $fromFileContext = ParsingContext::fromFilePath(__DIR__ . '/Stubs/MyUserClass.php'); + $context = ParsingScope::fromReflectionClass(new ReflectionClass(MyUserClass::class)); + $fromFileContext = ParsingScope::fromFilePath(__DIR__.'/Stubs/MyUserClass.php'); expect(serialize($context)) ->toBe(serialize($fromFileContext)) @@ -18,8 +18,8 @@ ->toBe('Tests\\Unit\\Parser\\Data\\Stubs') ->and($context->usedNamespaceMap) ->toBe([ - 'Optimizer' => 'Le0daniel\PhpTsBindings\Parser\ASTOptimizer', - 'TypeParser' => 'Le0daniel\PhpTsBindings\Parser\TypeParser', + 'optimizer' => 'Le0daniel\PhpTsBindings\Parser\Helpers\ASTOptimizer', + 'typeparser' => 'Le0daniel\PhpTsBindings\Parser\TypeParser', ]) ->and($context->localTypes) ->toBe([ @@ -38,8 +38,8 @@ ]); }); -test("Extensive PHP Doc type declaration", function () { - $fromFileContext = ParsingContext::fromClassString(ComplexPhpDoc::class); +test('Extensive PHP Doc type declaration', function () { + $fromFileContext = ParsingScope::fromClassString(ComplexPhpDoc::class); expect($fromFileContext->localTypes)->toBe([ 'ReadyToOrderInput' => 'array{ id: positive-int, status: OrderStatus::READY_TO_ORDER, fileId?: positive-int }', @@ -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 17551d4..b31d1b8 100644 --- a/tests/Unit/Parser/Data/Stubs/MyUserClass.php +++ b/tests/Unit/Parser/Data/Stubs/MyUserClass.php @@ -1,19 +1,21 @@ -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 @@ -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/Lexer/LexerTest.php b/tests/Unit/Parser/Lexer/LexerTest.php index 788a626..c0c886b 100644 --- a/tests/Unit/Parser/Lexer/LexerTest.php +++ b/tests/Unit/Parser/Lexer/LexerTest.php @@ -17,21 +17,23 @@ function lex(string $input): array /** * Compact "TYPE(lexeme)" rendering with WHITESPACE and EOF removed. + * * @return list */ 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 new file mode 100644 index 0000000..0ead1de --- /dev/null +++ b/tests/Unit/Parser/MetadataEliminationTest.php @@ -0,0 +1,149 @@ +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>", + '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>}}", + 'named class' => Customer::class, + 'branded value object' => Email::class, + '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) { + 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 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(), NamedType::same('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(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(ParserException::class, 'meaningless'); +}); + +test('getDeclaringNode looks through both wrappers', function () { + $constrained = new ConstraintNode( + new StringNode(), + [new NonEmptyString()], + ); + + 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/NamedTypeTest.php b/tests/Unit/Parser/NamedTypeTest.php new file mode 100644 index 0000000..a2793f4 --- /dev/null +++ b/tests/Unit/Parser/NamedTypeTest.php @@ -0,0 +1,297 @@ +parse(Customer::class); + + expect($node)->toBeInstanceOf(MetadataNode::class) + ->and($node->name?->inputName)->toBe('Customer') + ->and($node->name?->outputName)->toBe('Customer') + ->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?->inputName)->toBe('CustomThing') + ->and($node->name?->outputName)->toBe('CustomThing'); +}); + +test('a class without codegen attributes carries no metadata wrapper', function () { + $node = new TypeParser()->parse(CreateAccountInput::class); + + expect($node)->toBeInstanceOf(CustomCastingNode::class); +}); + +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?->inputName)->toBe('OrderStatus') + ->and($node->name?->outputName)->toBe('OrderStatus') + ->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?->inputName)->toBe('AccountId') + ->and($node->name?->outputName)->toBe('AccountId') + ->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'); + + $optimizedCode = new ASTOptimizer()->generateOptimizedCode(['node' => $node]); + + /** @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); +}); + +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'); +}); + +/** + * 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?->outputName)->toBe($expectedName) + ->and($node->name?->inputName)->toBe($expectedName) + ->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?->outputName)->toBe('BaseId') + ->and($child->brand)->toBe('childId') + ->and($child->name?->outputName)->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?->outputName)->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?->outputName)->toBe('PartiallyOverriddenId'); + + compareToOptimizedAst($node); + validateAst($node); +}); + +test('the parent class is consulted before the interfaces', function () { + // 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?->outputName)->toBe('ParentWinsIdFromParent'); +}); + +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?->outputName)->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'); +}); + +/** + * 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)); +}); diff --git a/tests/Unit/Parser/NodeDiagnosticStringTest.php b/tests/Unit/Parser/NodeDiagnosticStringTest.php new file mode 100644 index 0000000..521ed4c --- /dev/null +++ b/tests/Unit/Parser/NodeDiagnosticStringTest.php @@ -0,0 +1,55 @@ +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 UnionNode($discriminated->nodes); + + 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); +}); diff --git a/tests/Unit/Parser/OptimizeAndWriteToFileTest.php b/tests/Unit/Parser/OptimizeAndWriteToFileTest.php new file mode 100644 index 0000000..8918f85 --- /dev/null +++ b/tests/Unit/Parser/OptimizeAndWriteToFileTest.php @@ -0,0 +1,73 @@ +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/OptimizedCodeShapeTest.php b/tests/Unit/Parser/OptimizedCodeShapeTest.php new file mode 100644 index 0000000..30c4add --- /dev/null +++ b/tests/Unit/Parser/OptimizedCodeShapeTest.php @@ -0,0 +1,96 @@ + $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 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, '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, 'Regenerate the optimized schema cache'); +}); + +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/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/StructNodeOrderTest.php b/tests/Unit/Parser/StructNodeOrderTest.php new file mode 100644 index 0000000..6df4c3d --- /dev/null +++ b/tests/Unit/Parser/StructNodeOrderTest.php @@ -0,0 +1,107 @@ + 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 96b9d87..3a580d3 100644 --- a/tests/Unit/Parser/TypeParserTest.php +++ b/tests/Unit/Parser/TypeParserTest.php @@ -2,31 +2,44 @@ namespace Tests\Unit\Parser; -use Le0daniel\PhpTsBindings\CodeGen\Data\DefinitionTarget; -use Le0daniel\PhpTsBindings\CodeGen\TypescriptDefinitionGenerator; +use Le0daniel\PhpTsBindings\Data\IO; +use Le0daniel\PhpTsBindings\Parser\Data\Exceptions\InvalidSyntaxException; use Le0daniel\PhpTsBindings\Parser\Data\GlobalTypeAliases; -use Le0daniel\PhpTsBindings\Parser\Data\ParsingContext; +use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\IntRange; +use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\ListLength; +use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\LowercaseString; +use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\NonEmptyString; +use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\NumericString; +use Le0daniel\PhpTsBindings\Parser\Helpers\Constraints\UppercaseString; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; 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\PropertyType; 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\MetadataNode; use Le0daniel\PhpTsBindings\Parser\Nodes\RecordNode; use Le0daniel\PhpTsBindings\Parser\Nodes\StructNode; 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 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; @@ -36,18 +49,17 @@ use Tests\Unit\Parser\Data\Stubs\UncastableClass; use Tests\Unit\Parser\Data\UserMock; - test('test simple union', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); - expect($node = $parser->parse("string | int")) + expect($node = $parser->parse('string | int')) ->toBeInstanceOf(UnionNode::class); compareToOptimizedAst($node); }); test('test literal union', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ expect($node = $parser->parse("7|'18'|true")) @@ -59,7 +71,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) @@ -75,7 +87,7 @@ }); test('Complex inheritance', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse(FullAccount::class); @@ -86,31 +98,26 @@ $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[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 () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ - $node = $parser->parse("scalar"); + $node = $parser->parse('scalar'); expect($node)->toBeInstanceOf(UnionNode::class); - /** - * @var int $index - * @var BuiltInNode $type - */ - foreach ($node->types as $index => $type) { + foreach ($node->nodes 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), }; } @@ -118,140 +125,120 @@ }); test('test questionmark nullability support', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ - $node = $parser->parse("?float"); + $node = $parser->parse('?float'); 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->nodes[0])->toBeInstanceOf(NullNode::class); + expect($node->nodes[1])->toBeInstanceOf(FloatNode::class); compareToOptimizedAst($node); }); 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"); + 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"); + $node = $parser->parse('(?float)|string'); 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->nodes[0])->toBeInstanceOf(NullNode::class); + expect($node->nodes[1])->toBeInstanceOf(FloatNode::class); + expect($node->nodes[2])->toBeInstanceOf(StringNode::class); compareToOptimizedAst($node); }); test('float', function () { - $parser = new TypeParser(new TypeStringTokenizer()); - /** @var BuiltInNode $node */ - $node = $parser->parse("float"); + $parser = new TypeParser(); + $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(new TypeStringTokenizer()); - /** @var BuiltInNode $node */ - $node = $parser->parse("int"); + $parser = new TypeParser(); + $node = $parser->parse('int'); - expect($node)->toBeInstanceOf(BuiltInNode::class); - expect($node->type)->toEqual(BuiltInType::INT); + expect($node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); test('Generic Int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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) ->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); }); test('Generic Int Min', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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) - ->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(BuiltInNode::class) - ->and($node->node->type)->toEqual(BuiltInType::INT); + ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); test('Generic Int Max', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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) - ->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->constraints[0]->max)->toBeNull() + ->and($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); test('Generic Int Negative Values', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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) ->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); }); test('numeric', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ - $node = $parser->parse("numeric"); + $node = $parser->parse('numeric'); - /** - * @var int $index - * @var BuiltInNode $type - */ - foreach ($node->types as $index => $type) { + foreach ($node->nodes 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), }; } @@ -260,40 +247,38 @@ test('Global aliases', function () { $parser = new TypeParser( - new TypeStringTokenizer(), TypeParser::defaultConsumers(new GlobalTypeAliases([ - 'Email' => fn() => new ConstraintNode( - new BuiltInNode(BuiltInType::STRING), - [new Email()], + 'Slug' => fn () => new ConstraintNode( + new StringNode(), + [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); }); test('positive-int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); test('Local type resolution', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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); @@ -301,9 +286,9 @@ }); test('Local imported resolution', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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); @@ -311,163 +296,324 @@ }); test('non-negative-int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); test('non-positive-int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); test('negative-int', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->node)->toBeInstanceOf(IntNode::class); + + 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', RecordNode::class], + ['non-empty-array', RecordNode::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']); + +test('a bare array or list is rejected rather than degraded', function (string $type) { + // 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']); + test('object struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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); - 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); }); test('array struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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); - 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); }); test('simplified tuple struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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->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->nodes[0])->toBeInstanceOf(StringNode::class); + expect($node->nodes[1])->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); test('classic tuple struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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->types[0])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[0]->type)->toEqual(BuiltInType::STRING); + expect($node->nodes[0])->toBeInstanceOf(StringNode::class); + expect($node->nodes[1])->toBeInstanceOf(IntNode::class); + + 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->types[1])->toBeInstanceOf(BuiltInNode::class); - expect($node->types[1]->type)->toEqual(BuiltInType::INT); + expect($node)->toBeInstanceOf(StructNode::class) + ->and($node->getProperty('window')?->node)->toBeInstanceOf(TupleNode::class); compareToOptimizedAst($node); }); -test('List struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); - /** @var ListNode $node */ - $node = $parser->parse("array"); - expect($node)->toBeInstanceOf(ListNode::class); +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 + // 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(RecordNode::class); - expect($node->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::STRING); + expect($node->keyNode)->toBeInstanceOf(StringNode::class); + expect($node->node)->toBeInstanceOf(StringNode::class); compareToOptimizedAst($node); }); test('List by modifier', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var ListNode $node */ - $node = $parser->parse("string[]"); + $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); }); test('Grouped Modifier', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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); - 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->nodes[0])->toBeInstanceOf(StringNode::class); + expect($node->node->nodes[1])->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); test('Record struct', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var RecordNode $node */ - $node = $parser->parse("array"); + $node = $parser->parse('array'); expect($node)->toBeInstanceOf(RecordNode::class); - expect($node->node)->toBeInstanceOf(BuiltInNode::class); - expect($node->node->type)->toEqual(BuiltInType::INT); + expect($node->keyNode)->toBeInstanceOf(StringNode::class); + expect($node->node)->toBeInstanceOf(IntNode::class); compareToOptimizedAst($node); }); +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 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)->toBeInstanceOf(RecordNode::class); + + compareToOptimizedAst($node); +})->with([ + 'branded string key' => ["array, int>"], + 'branded int key' => ["array, string>"], +]); + 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); - 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), @@ -482,7 +628,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,31 +636,40 @@ }); test('Test date time with a namespace', function () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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 ParsingContext('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 () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); /** @var UnionNode $node */ $node = $parser->parse( - "ResultEnumBase::SUCCESS|ResultEnumBase::FAILURE|ResultEnum::OTHER", - new ParsingContext('SomeName\\Space', [ + 'ResultEnumBase::SUCCESS|ResultEnumBase::FAILURE|ResultEnum::OTHER', + new ParsingScope('SomeName\\Space', [ 'ResultEnumBase' => ResultEnum::class, 'ResultEnum' => ResultEnum::class, ]), ); 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), 2 => expect($type->value)->toBe('other'), - default => throw new \RuntimeException("Should not be reached"), + default => throw new \RuntimeException('Should not be reached'), }; } @@ -522,7 +677,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 +686,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 +695,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 +704,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 +713,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 +722,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,55 +731,51 @@ }); test('Generics parsing', function () { - $parser = new TypeParser(new TypeStringTokenizer()); - $node = $parser->parse(Paginated::class . ''); + $parser = new TypeParser(); + $node = $parser->parse(Paginated::class.''); expect($node)->toBeInstanceOf(CustomCastingNode::class); 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 () { - $parser = new TypeParser(new TypeStringTokenizer()); + $parser = new TypeParser(); $node = $parser->parse(ReadonlyOutputFields::class); expect($node)->toBeInstanceOf(CustomCastingNode::class); compareToOptimizedAst($node); validateAst($node); - $typescriptGenerator = new TypescriptDefinitionGenerator(); - $definition = $typescriptGenerator->toDefinition($node, DefinitionTarget::OUTPUT); - expect($definition)->toBe('{name:string;email:string;}'); + // Struct properties are canonically ordered at construction, so emission is alphabetical + // regardless of declaration order. + expect(new TypescriptGenerator()->toTypescript($node, IO::OUTPUT)->type) + ->toBe('{email:string;name:string;}'); }); 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); 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 () { - $parser = new TypeParser(new TypeStringTokenizer()); + $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.'); }); 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) @@ -640,12 +791,12 @@ ->and($node->hasProperty('id'))->toBeTrue() ->and($node->getProperty('id')->propertyType)->toEqual(PropertyType::BOTH) ->and($node->phpType)->toEqual(StructPhpType::OBJECT); - ; + compareToOptimizedAst($node); }); 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,10 +817,10 @@ }); 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'>"); + $node = $parser->parse('Pick<'.UserMock::class.", 'username'>"); expect($node)->toBeInstanceOf(StructNode::class) ->and($node->properties)->toHaveCount(1) ->and($node->hasProperty('username'))->toBeTrue() @@ -679,7 +830,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) @@ -690,12 +841,11 @@ }); 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); - $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'], @@ -703,75 +853,355 @@ '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">'], - 'Simple Pick with optional' => ['{name?:string|null;}', 'Pick'], + '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 () { - $parser = new TypeParser(new TypeStringTokenizer()); +test('parse interface properties', function () { + $parser = new TypeParser(); $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 () { - $parser = new TypeParser(new TypeStringTokenizer()); +test('parse abstract class properties', function () { + $parser = new TypeParser(); $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 () { - // Branded types are optimized away. They have no runtime Impact - $parser = new TypeParser(new TypeStringTokenizer()); +test('parse BrandedInt correctly', function () { + $parser = new TypeParser(); $node = $parser->parse("BrandedInt<'wow'>"); compareToOptimizedAst($node); - $tsGenerator = new TypescriptDefinitionGenerator(true); - $outputDef = $tsGenerator->toDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe('number & Brand<"wow">'); + 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">)']); + } +}); - $inputDef = $tsGenerator->toDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('number & Brand<"wow">'); +test('parse BrandedString correctly', function () { + $parser = new TypeParser(); + $node = $parser->parse("BrandedString<'wow'>"); + compareToOptimizedAst($node); - $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' => '(string & Brand<"wow">)']); + } +}); + +test('rejects a branded utility tag that is not a valid TypeScript identifier', function (string $type) { + expect(fn () => new TypeParser()->parse($type)) + ->toThrow( + InvalidStringLiteralException::class, + 'not a valid TypeScript identifier', + ); +})->with([ + 'BrandedString' => ["BrandedString<'not valid'>"], + 'BrandedInt' => ["BrandedInt<'not valid'>"], +]); - $inputDef = $tsGeneratorWithoutBrand->toDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('number'); +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(MetadataNode::class) + ->and($string->brand)->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') + ->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'); }); -test("parse BrandedString correctly", function () { - // Branded types are optimized away. They have no runtime Impact - $parser = new TypeParser(new TypeStringTokenizer()); - $node = $parser->parse("BrandedString<'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->nodes[0])->toBeInstanceOf(NullNode::class) + ->and($node->nodes[1])->toBeInstanceOf(BoolNode::class); + + compareToOptimizedAst($node); +}); + +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(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) { + $parser = new TypeParser(); + $node = $parser->parse($type); + + 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)'], + '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'], +]); + +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 + * + * 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(StringNode::class) + ->and($node->hasProperty('b'))->toBeTrue() + ->and($node->getProperty('b')?->node)->toBeInstanceOf(IntNode::class); + 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(typescriptFor($node, IO::OUTPUT)->type) + ->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\\\"\""); - $tsGenerator = new TypescriptDefinitionGenerator(true); + expect($node)->toBeInstanceOf(UnionNode::class) + ->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"'); +}); - $outputDef = $tsGenerator->toDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe('string & Brand<"wow">'); +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); +}); - $inputDef = $tsGenerator->toDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('string & Brand<"wow">'); +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(); + } - $tsGeneratorWithoutBrand = new TypescriptDefinitionGenerator(false); - $outputDef = $tsGeneratorWithoutBrand->toDefinition($node, DefinitionTarget::OUTPUT); - expect($outputDef)->toBe('string'); + expect($warnings)->toBe([]); +}); - $inputDef = $tsGeneratorWithoutBrand->toDefinition($node, DefinitionTarget::INPUT); - expect($inputDef)->toBe('string'); -}); \ No newline at end of file +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} */')) + ->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) + ->and(fn () => new TypeParser()->parse('array{a,')) + ->toThrow(InvalidSyntaxException::class) + ->and(fn () => new TypeParser()->parse('array{0: int,')) + ->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->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); +}); + +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/Parser/UserDefinedObjectConsumerTest.php b/tests/Unit/Parser/UserDefinedObjectConsumerTest.php new file mode 100644 index 0000000..5af79f3 --- /dev/null +++ b/tests/Unit/Parser/UserDefinedObjectConsumerTest.php @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..c3b1edf --- /dev/null +++ b/tests/Unit/Parser/ValueObjectConsumerTest.php @@ -0,0 +1,169 @@ +parse(Email::class); + + 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); +}); + +test('parses an int value object with an explicit brand name', function () { + $node = new TypeParser()->parse(UserId::class); + + 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); +}); + +test('the default brand name is lcfirst of the base class name', function (string $type, ?string $expected) { + $node = new TypeParser()->parse($type); + 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 codegen attributes stays a bare node', function () { + $node = new TypeParser()->parse(Slug::class); + + expect($node)->toBeInstanceOf(ValueObjectNode::class); + + 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 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 ParsingScope('Some\\Space', ['Mail' => 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) { + $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(BackingType::STRING); + + compareToOptimizedAst($node); +}); + +test('a value object is never treated as a castable object', function () { + $parser = new TypeParser(TypeParser::defaultConsumers()); + + expect($parser->parse(Slug::class))->toBeInstanceOf(ValueObjectNode::class); +}); + +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) { + $result = typescriptFor($node, $io); + expect($result->type)->toBe($expected) + ->and($result->registry->isEmpty())->toBeTrue(); + } +})->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); + + foreach ([IO::INPUT, IO::OUTPUT] as $io) { + 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/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 2432b89..6ed0b84 100644 --- a/tests/Unit/PhpStan/data/types.php +++ b/tests/Unit/PhpStan/data/types.php @@ -1,91 +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; -} \ No newline at end of file +} + +/** + * @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); +} diff --git a/tests/Unit/Reflection/FileReflectorTest.php b/tests/Unit/Reflection/FileReflectorTest.php new file mode 100644 index 0000000..87f246b --- /dev/null +++ b/tests/Unit/Reflection/FileReflectorTest.php @@ -0,0 +1,81 @@ +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'); +}); + +/** + * 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/ClassConstantBeforeDeclaration.php b/tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php new file mode 100644 index 0000000..82261df --- /dev/null +++ b/tests/Unit/Reflection/Fixtures/ClassConstantBeforeDeclaration.php @@ -0,0 +1,19 @@ + */ + 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 @@ + $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/Mocks/UserClassMock.php b/tests/Unit/Reflection/Mocks/UserClassMock.php index 4bd574a..253948f 100644 --- a/tests/Unit/Reflection/Mocks/UserClassMock.php +++ b/tests/Unit/Reflection/Mocks/UserClassMock.php @@ -1,4 +1,6 @@ -, + * } + */ + public function serialize(): array + { + throw new \Exception(); + } +} 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], +]); diff --git a/tests/Unit/Reflection/TypeReflectionTest.php b/tests/Unit/Reflection/TypeReflectionTest.php index a0729a6..b347a9e 100644 --- a/tests/Unit/Reflection/TypeReflectionTest.php +++ b/tests/Unit/Reflection/TypeReflectionTest.php @@ -2,8 +2,12 @@ namespace Tests\Unit\Reflection; +use Le0daniel\PhpTsBindings\Parser\Contracts\NodeInterface; +use Le0daniel\PhpTsBindings\Parser\Helpers\ParsingScope; +use Le0daniel\PhpTsBindings\Parser\TypeParser; use Le0daniel\PhpTsBindings\Reflection\TypeReflector; use ReflectionClass; +use Tests\Unit\Reflection\Mocks\NativeTypesMock; use Tests\Unit\Reflection\Mocks\UserClassMock; test('from reflection property', function () { @@ -14,7 +18,7 @@ ->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 () { @@ -35,4 +39,91 @@ ->toBe('non-empty-string') ->and(TypeReflector::reflectReturnType($classReflection->getMethod('toArray'))) ->toBe('array'); -}); \ No newline at end of file +}); + +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, }'); +}); + +/** + * 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/Server/Client/NullClientTest.php b/tests/Unit/Server/Client/NullClientTest.php new file mode 100644 index 0000000..f02cf04 --- /dev/null +++ b/tests/Unit/Server/Client/NullClientTest.php @@ -0,0 +1,31 @@ +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..52f31e8 --- /dev/null +++ b/tests/Unit/Server/Client/OperationSPAClientTest.php @@ -0,0 +1,133 @@ +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); +}); 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/Data/RpcErrorTest.php b/tests/Unit/Server/Data/RpcErrorTest.php new file mode 100644 index 0000000..8fe1e1b --- /dev/null +++ b/tests/Unit/Server/Data/RpcErrorTest.php @@ -0,0 +1,157 @@ +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` 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 three 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'), + ['name' => 'invalid_name'], + errorInfo(), + ); + + $rateLimited = new RpcError( + ErrorType::RATE_LIMITED, + new RuntimeException('slow down'), + ['retryIn' => 30], + 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' => ['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], + ]); +}); + +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'), ['name' => '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..4dde4f4 --- /dev/null +++ b/tests/Unit/Server/Data/RpcSuccessTest.php @@ -0,0 +1,104 @@ + 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([ + 'success' => true, + 'data' => ['id' => '123'], + '__client' => [ + 'redirect' => ['url' => '/users/123', 'reload' => false], + 'toasts' => [ + ['type' => 'success', 'message' => 'Saved'], + ], + 'type' => 'operations-spa', + ], + ]); +}); + +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([ + 'success' => true, + 'data' => 'ok', + '__metadata' => ['durationMs' => 12], + ]); +}); + +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/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 new file mode 100644 index 0000000..9d84b17 --- /dev/null +++ b/tests/Unit/Server/Errors/ErrorClassifierTest.php @@ -0,0 +1,150 @@ + $exception + */ +function classifyError(Throwable|string $exception): ErrorType +{ + return new ErrorClassifier( + authenticationExceptions: [UnauthenticatedException::class, RequiresLoginInterface::class], + authorizationExceptions: [UnauthorizedException::class], + notFoundExceptions: [RecordMissingException::class], + rateLimitedExceptions: [TooManyAttemptsException::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('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], []); + + 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. + array_map(static fn (string $className): MiddlewareDefinition => new MiddlewareDefinition($className), $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, ErrorType $type) { + expect(resolveThrows($method, allowDomainErrors: false))->toBe([ + 'data' => [$exceptionClass => ['type' => $type]], + 'issues' => [], + ]); +})->with([ + '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) { + 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); diff --git a/tests/Unit/Server/KeyGeneratorTest.php b/tests/Unit/Server/KeyGeneratorTest.php new file mode 100644 index 0000000..084f505 --- /dev/null +++ b/tests/Unit/Server/KeyGeneratorTest.php @@ -0,0 +1,58 @@ +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->fullyQualifiedOperationKey('users.get')) + ->not->toBe(OperationType::COMMAND->fullyQualifiedOperationKey('users.get')); +}); 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 new file mode 100644 index 0000000..8ed1ac2 --- /dev/null +++ b/tests/Unit/Server/OperationDiscoveryTest.php @@ -0,0 +1,221 @@ +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; + } +} + +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); +}); + +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]->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'); +}); 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; + } +} diff --git a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php index d98a96c..df3d7cd 100644 --- a/tests/Unit/Server/Pipeline/ContextualPipelineTest.php +++ b/tests/Unit/Server/Pipeline/ContextualPipelineTest.php @@ -1,94 +1,256 @@ "; - } - }, - 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, 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, ?ExceptionScope $scope): 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('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('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('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(); - }); + ); + + 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(), + ); - $result = $pipeline->execute('input', 'context'); - expect($result)->toBe('first>>'); + // 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; + ); + + expect($result)->toBeInstanceOf(RpcError::class) + ->and($result->metadata['trace'])->toBe(['exit outer']) + ->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'); }); - $result = $pipeline->execute([], 'context'); - expect($result)->toBe([ - "Enter first", - "Enter second", - "Middle", - "Exit second", - "Exit first", - ]); -}); \ No newline at end of file + 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; + + $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, ExceptionScope $scope) 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('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'); + }), + ], + fn (): RpcSuccess => succeed(), + function (): RpcError { + throw new RuntimeException('the presenter is broken too'); + }, + ))->toThrow(RuntimeException::class, 'the presenter is broken too'); +}); 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 diff --git a/tests/Unit/Typescript/AliasRegistryTest.php b/tests/Unit/Typescript/AliasRegistryTest.php new file mode 100644 index 0000000..012305e --- /dev/null +++ b/tests/Unit/Typescript/AliasRegistryTest.php @@ -0,0 +1,104 @@ +isEmpty())->toBeTrue() + ->and($registry->toArray())->toBe([]) + ->and($registry->has('Anything'))->toBeFalse(); +}); + +test('is seeded from the constructor', function () { + $registry = new AliasRegistry(['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 AliasRegistry(); + $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 AliasRegistry(); + $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 AliasRegistry(); + $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 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'])) + ->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 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 AliasRegistry()->get('Missing')) + ->toThrow(UnknownAliasException::class, 'Known aliases: none.'); +}); + +test('every stored alias counts as used, sorted', function () { + $registry = new AliasRegistry(); + $registry->set('Zulu', 'string'); + $registry->set('Alpha', 'number'); + + expect($registry->usedAliases())->toBe(['Alpha', 'Zulu']) + ->and(new AliasRegistry()->usedAliases())->toBe([]); +}); + +test('returns definitions sorted by alias', function () { + $registry = new AliasRegistry(); + $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 AliasRegistry(['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/Code/TypescriptFileTest.php b/tests/Unit/Typescript/Code/TypescriptFileTest.php new file mode 100644 index 0000000..fcd8729 --- /dev/null +++ b/tests/Unit/Typescript/Code/TypescriptFileTest.php @@ -0,0 +1,365 @@ +toString(); + + expect($rendered)->toStartWith($prefix); + + return $rendered === $prefix ? '' : substr($rendered, strlen($prefix) + 1); +} + +test('renders an empty file as an empty string', function () { + expect(renderedBody(new TypescriptFile()))->toBe(''); +}); + +test('renders code with no imports', function () { + 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(renderedBody(new TypescriptFile($code)))->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(renderedBody($file))->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(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 () { + $file = new TypescriptFile('', [ + new TypescriptImport('./lib/types', values: ['isBrand'], types: ['Brand']), + ]); + + expect(renderedBody($file))->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(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(renderedBody(new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Brand')]))) + ->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(renderedBody($file))->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(renderedBody($file))->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(renderedBody($file))->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(renderedBody($file))->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(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 () { + $file = new TypescriptFile('', [TypescriptImport::types('./lib/types', 'Order')]) + ->withImports(TypescriptImport::types('./lib/types', 'Brand')); + + expect($file->imports)->toHaveCount(1) + ->and(renderedBody($file))->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(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 () { + $file = new TypescriptFile('', [ + TypescriptImport::types('./lib/types', ['Status', 'Order']), + TypescriptImport::values('./lib/types', 'Status'), + ]); + + expect(renderedBody($file))->toBe( + "import type {Order} from './lib/types';\n" + ."import {Status} from './lib/types';\n" + ); +}); + +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(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" + ); +}); + +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'), + TypescriptImport::types('./lib/types', 'Brand'), + TypescriptImport::values('./lib/utils', 'queryKey'), + TypescriptImport::types('./lib/types', 'Order'), + ]; + + 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 () { + $file = new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')]) + ->append('const b = 2;'); + + expect($file->imports)->toHaveCount(1) + ->and(renderedBody($file))->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(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" + ); +}); + +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(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(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(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(renderedBody($file->append($code)))->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(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(renderedBody(new TypescriptFile($code)))->toBe(renderedBody(new TypescriptFile()->append($code))); +})->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( + <<<'TypeScript' + + export function get(input: Id) { + return queryKey('orders', 'get', input); + } + + TypeScript, + [TypescriptImport::types('./lib/types', 'OrderStatus')], + )); + + expect(renderedBody($file))->toBe(<<<'TypeScript' + import type {Brand, Order, OrderStatus} from './lib/types'; + import {queryKey} from './lib/utils'; + + export type Id = number; + + export function get(input: Id) { + return queryKey('orders', 'get', input); + } + + TypeScript); +}); + +test('casts to a string', function () { + $file = new TypescriptFile('const a = 1;', [TypescriptImport::types('./lib/types', 'Brand')]); + + expect($file)->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/Unit/Typescript/Code/TypescriptImportTest.php b/tests/Unit/Typescript/Code/TypescriptImportTest.php new file mode 100644 index 0000000..17747f1 --- /dev/null +++ b/tests/Unit/Typescript/Code/TypescriptImportTest.php @@ -0,0 +1,176 @@ +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(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(CodeGenException::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(CodeGenException::class, 'different modules'); +}); + +test('merging leaves both operands untouched', function () { + $one = TypescriptImport::types('./lib/types', 'Brand'); + $two = TypescriptImport::values('./lib/types', 'queryKey'); + + // 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([]) + ->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); +}); diff --git a/tests/Unit/Typescript/NamedTypesTest.php b/tests/Unit/Typescript/NamedTypesTest.php new file mode 100644 index 0000000..c80eb86 --- /dev/null +++ b/tests/Unit/Typescript/NamedTypesTest.php @@ -0,0 +1,225 @@ +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 carries its alias on input too, under one shared registry', function () { + $node = new TypeParser()->parse(Customer::class); + $shared = new AliasRegistry(); + + $result = new TypescriptGenerator()->toTypescript($node, IO::INPUT, $shared); + + 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 () { + $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('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('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 () { + $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 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")']); + } +}); + +/** + * 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(); + + $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('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(); + + expect($generator->toTypescript($node, IO::INPUT, $shared)->type)->toBe('AsymmetricNamed'); + + expect(fn () => $generator->toTypescript($node, IO::OUTPUT, $shared)) + ->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 () { + $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('two named nodes claiming one alias with different shapes are rejected', function () { + $inner = new MetadataNode(new StringNode(), NamedType::same('Cycle')); + $outer = new MetadataNode( + new StructNode(StructPhpType::ARRAY, [ + new PropertyNode('self', $inner, false, PropertyType::BOTH), + ]), + NamedType::same('Cycle'), + ); + + 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.'}', + ); + + 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]); + + /** @var 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 new file mode 100644 index 0000000..89cbf0d --- /dev/null +++ b/tests/Unit/Typescript/OptimizedAstTest.php @@ -0,0 +1,91 @@ +parse($typeString); + + $optimizer = new ASTOptimizer(); + $optimizedCode = $optimizer->generateOptimizedCode(['node' => $ast]); + + /** @var CachedTypeRegistry $registry */ + $registry = eval("return {$optimizedCode};"); + + $generator = new TypescriptGenerator(); + + /** @var string|null $definition */ + $definition = null; + 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); + } + + return $definition; +} + +describe('Test to definition', function () { + + test('Simple union type', function () { + expect(toDefinition('array{name: string}|string')) + ->toBe('({name:string;}|string)'); + }); + + test('Optional Fields', function () { + expect(toDefinition('array{name?: string}|string')) + ->toBe('({name?:string;}|string)'); + }); + + test('Array type returns object', function () { + expect(toDefinition('array{name: string}')) + ->toBe('{name:string;}'); + }); + + test('Object type returns object', function () { + expect(toDefinition('object{name: string}')) + ->toBe('{name:string;}'); + }); + + test('Custom class type input', function () { + expect(toDefinition(UserSchema::class, IO::INPUT)) + ->toBe('{age:number;email:string;username:string;}'); + }); + + test('Custom class type output', function () { + expect(toDefinition(UserSchema::class, IO::OUTPUT)) + ->toBe('{age:number;username:string;}'); + }); + + test('scalar', function () { + expect(toDefinition('scalar')) + ->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;})'); + }); + + 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;})'); + }); +}); diff --git a/tests/Unit/Typescript/Stubs/EmptyEnum.php b/tests/Unit/Typescript/Stubs/EmptyEnum.php new file mode 100644 index 0000000..75cf044 --- /dev/null +++ b/tests/Unit/Typescript/Stubs/EmptyEnum.php @@ -0,0 +1,12 @@ +parse($type) : $type; + + return new TypescriptGenerator()->toTypescript($node, $io, $sharedRegistry); +} + +/** + * 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'], + 'non empty list' => ['non-empty-list', 'Array'], + 'array shorthand' => ['string[]', '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]'], +]); + +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 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('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->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'], + '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)>', + ], +]); + +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, + array $expectedAliases, +) { + $result = typescriptOf($type); + + expect($result->type)->toBe($expectedType) + ->and($result->registry->toArray())->toBe($expectedAliases); +})->with([ + '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">)'], + ], +]); + +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('{a:Alpha;m:Mike;z:Zulu;}'); +}); + +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('reads a collected alias back out of the registry', function () { + $registry = typescriptOf("BrandedString<'email'>")->registry; + + expect($registry->isEmpty())->toBeFalse() + ->and($registry->has('Email'))->toBeTrue() + ->and($registry->get('Email'))->toBe('(string & Brand<"email">)') + ->and($registry->has('Nope'))->toBeFalse(); +}); + +test('registers into the passed registry but returns only what the emission needs', function () { + $shared = new AliasRegistry(['Existing' => '(string & Brand<"existing">)']); + + $result = typescriptOf("BrandedString<'email'>", IO::INPUT, $shared); + + expect($shared->toArray())->toBe([ + 'Email' => '(string & Brand<"email">)', + 'Existing' => '(string & Brand<"existing">)', + ]) + ->and($result->registry->toArray())->toBe(['Email' => '(string & Brand<"email">)']) + ->and($result->registry->usedAliases())->toBe(['Email']); +}); + +test('one shared registry accumulates aliases across emissions', function () { + $shared = new AliasRegistry(); + + $first = typescriptOf("BrandedString<'email'>", IO::INPUT, $shared); + $second = typescriptOf("BrandedInt<'customerId'>", IO::INPUT, $shared); + + 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">)']); +}); + +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)) + ->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 StringNode(), 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, '{email:string;id:number;}'], + 'interface' => [SomeFileInterface::class, '{id:number;url:string;}'], + 'readonly output fields' => [ReadonlyOutputFields::class, '{email:string;name:string;}'], +]); + +test('throws for nodes it cannot represent', function (NodeInterface $node) { + expect(fn () => typescriptOf($node))->toThrow(UnsupportedTypeException::class); +})->with([ + '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 ''; + } + }], +]); diff --git a/tests/Unit/Typescript/Utils/SyntaxTest.php b/tests/Unit/Typescript/Utils/SyntaxTest.php new file mode 100644 index 0000000..9d4e3d6 --- /dev/null +++ b/tests/Unit/Typescript/Utils/SyntaxTest.php @@ -0,0 +1,41 @@ +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"?'); +}); + +// 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/Mocks/ReflectionsUtilMock.php b/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php deleted file mode 100644 index 461a0d8..0000000 --- a/tests/Unit/Utils/Mocks/ReflectionsUtilMock.php +++ /dev/null @@ -1,27 +0,0 @@ -toBe('Bar'); +}); + +test('an unimported name is resolved against the current namespace', function () { + 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']); + + 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('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. + $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'); +}); + +/** + * 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('Foo\\App\\Models\\User') + ->and(Namespaces::toFullyQualifiedClassName('App\\Models\\UserProfile', 'Foo', $map)) + ->toBe('Foo\\App\\Models\\UserProfile'); +}); - 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('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 () { @@ -23,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/NodesTest.php b/tests/Unit/Utils/NodesTest.php index 0cb9c95..6b23a93 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 +}); diff --git a/tests/Unit/Utils/PHPExportTest.php b/tests/Unit/Utils/PHPExportTest.php new file mode 100644 index 0000000..b91d6d0 --- /dev/null +++ b/tests/Unit/Utils/PHPExportTest.php @@ -0,0 +1,64 @@ +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'); +}); 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 deleted file mode 100644 index d73d295..0000000 --- a/tests/Unit/Utils/ReflectionsTest.php +++ /dev/null @@ -1,32 +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}'); -}); \ 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..501686d --- /dev/null +++ b/tests/Unit/Utils/RegexesTest.php @@ -0,0 +1,212 @@ +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/Unit/Validators/EmailTest.php b/tests/Unit/Validators/EmailTest.php deleted file mode 100644 index 18c542f..0000000 --- a/tests/Unit/Validators/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/Validators/LengthValidatorTest.php b/tests/Unit/Validators/LengthValidatorTest.php deleted file mode 100644 index 9030388..0000000 --- a/tests/Unit/Validators/LengthValidatorTest.php +++ /dev/null @@ -1,118 +0,0 @@ -context = new Context(); -}); - -it('validates string length correctly', function () { - $validator = new LengthValidator(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 LengthValidator(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 LengthValidator(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 LengthValidator(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 LengthValidator(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 LengthValidator(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 LengthValidator(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 LengthValidator(min: 5, max: 10, including: false); - $expected = 'new \\' . LengthValidator::class . '(5, 10, false)'; - expect($validator->exportPhpCode())->toBe($expected); -}); - -it('adds correct validation issues to context', function () { - $validator = new LengthValidator(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/benchmark/run.php b/tests/benchmark/run.php new file mode 100644 index 0000000..c6eda1d --- /dev/null +++ b/tests/benchmark/run.php @@ -0,0 +1,186 @@ + $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(); + +// 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), +]; + +$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). 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; diff --git a/tests/ts-output/.gitignore b/tests/ts-output/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/tests/ts-output/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/tests/ts-output/generate.php b/tests/ts-output/generate.php new file mode 100644 index 0000000..e2d5d32 --- /dev/null +++ b/tests/ts-output/generate.php @@ -0,0 +1,26 @@ +( + '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 LockDomainErrors = "account_locked"|"quota_exceeded"; + +/** + * 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 UnlockDomainErrors = never; + +/** + * 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..b633c73 --- /dev/null +++ b/tests/ts-output/generated/catalog.ts @@ -0,0 +1,153 @@ +// generated by: php-ts-bindings + +import type {OperationOptions} from './lib/OperationClient'; +import {executeOperation} from './lib/bindings'; +import type {Availability, Brand, Draft, DraftInput, Money, Product, Sku} from './lib/types'; +import {queryKey, throwOnFailure} 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 PrepareDomainErrors = never; + +/** + * 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 ProductDomainErrors = never; + +/** + * 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 RestockDomainErrors = never; + +/** + * 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 SearchDomainErrors = never; + +/** + * 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..02fb442 --- /dev/null +++ b/tests/ts-output/generated/lib/DefaultClient.ts @@ -0,0 +1,79 @@ +// generated by: php-ts-bindings + +import type {OperationClient, OperationOptions} from './OperationClient'; + +export class DefaultClient implements OperationClient { + + 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('&'); + } + + /** + * 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)}`; + + // 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 ? AbortSignal.timeout(timeoutInMs) : 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 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 new file mode 100644 index 0000000..1f60227 --- /dev/null +++ b/tests/ts-output/generated/lib/OperationClient.ts @@ -0,0 +1,19 @@ +// generated by: php-ts-bindings + +export type OperationOptions = {signal?: AbortSignal; timeoutMs?: number; client?: OperationClient}; + +/** + * 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( + type: "command"|"query", + key: string, + input: unknown, + options?: OperationOptions + ): Promise<{status: number; jsonBody: unknown}>; +} diff --git a/tests/ts-output/generated/lib/OperationException.ts b/tests/ts-output/generated/lib/OperationException.ts new file mode 100644 index 0000000..d99e797 --- /dev/null +++ b/tests/ts-output/generated/lib/OperationException.ts @@ -0,0 +1,34 @@ +// generated by: php-ts-bindings + +import type {ClientError, Failure} from './types'; + +/** + * 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 { + public readonly cause: Failure; + + /** + * 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. + */ + public isClientError(): this is OperationException & {cause: Failure & ClientError} { + return this.cause.code === 0; + } + + get code(): number { + return this.cause.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..67668ed --- /dev/null +++ b/tests/ts-output/generated/lib/bindings.ts @@ -0,0 +1,100 @@ +// generated by: php-ts-bindings + +import {DefaultClient} from './DefaultClient'; +import type {OperationClient, OperationOptions} from './OperationClient'; +import type {Failure, Result} from './types'; +import {isValidEnvelop} from './utils'; + +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, + options?: {baseUrl?: string; timeoutMs?: number}, +): DefaultClient { + return new DefaultClient(fetcher ?? fetch, { + paths: {query: '/query/{key}', command: '/command/{key}'}, + baseUrl: options?.baseUrl ?? '', + timeoutMs: options?.timeoutMs ?? 10000, + }); +} + +export function setClient(operationClient: OperationClient|null): void { + client = operationClient; +} + +/** + * 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); + } + + 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); + } + + 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/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/type-map.ts b/tests/ts-output/generated/lib/type-map.ts new file mode 100644 index 0000000..2b57ea4 --- /dev/null +++ b/tests/ts-output/generated/lib/type-map.ts @@ -0,0 +1,8 @@ +// generated by: php-ts-bindings + +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: 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/lib/types.ts b/tests/ts-output/generated/lib/types.ts new file mode 100644 index 0000000..35560ef --- /dev/null +++ b/tests/ts-output/generated/lib/types.ts @@ -0,0 +1,33 @@ +// generated by: php-ts-bindings + +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 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|RateLimitedError|DomainError|InternalError|ClientError); +export type Result = Success | Failure; + +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..63f36a1 --- /dev/null +++ b/tests/ts-output/generated/lib/utils.ts @@ -0,0 +1,68 @@ +// generated by: php-ts-bindings + +import {OperationException} from './OperationException'; +import type {Result, Success} from './types'; + +type QueryNamespaces = 'accounts'|'catalog'|'shapes'; + +export function queryKey(ns: QueryNamespaces, ...args: unknown[]): [string, ...unknown[]] { + 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 = { + 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 + * 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 as keyof typeof SERVER_ERROR_CODES] === code; +} + +/** + * Narrows a Result to its success branch, throwing otherwise, for call sites that would rather + * catch than branch. + * + * 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) { + 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 new file mode 100644 index 0000000..f521f5d --- /dev/null +++ b/tests/ts-output/generated/shapes.ts @@ -0,0 +1,111 @@ +// generated by: php-ts-bindings + +import type {OperationOptions} from './lib/OperationClient'; +import {executeOperation} from './lib/bindings'; +import type {Availability, Brand, Money, Product, Sku} from './lib/types'; +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;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; + +/** + * 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 RoundtripDomainErrors = never; + +/** + * 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 SubmitDomainErrors = never; + +/** + * 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..159437e --- /dev/null +++ b/tests/ts-output/src/usage.ts @@ -0,0 +1,461 @@ +/** + * 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 type {ProductDomainErrors} from '../generated/catalog'; +import {prepare, product, productQueryKey, productQueryOptions, restock, search, useProductQuery} from '../generated/catalog'; +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'; +import {isValidEnvelop, throwOnFailure} 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 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. + 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 + // 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; + } +} + +/** + * 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. + */ +export async function readProductOrThrow(): Promise { + try { + const result = await product({id: productId}); + throwOnFailure(result); + return result.data; + } catch (error) { + // 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) { + const fields: Record = error.cause.details.fields; + console.error('invalid input', fields); + } + } + 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; + + // 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; +} + +/** + * 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}); + + if (!result.success && result.code === 400) { + // Two exposed exceptions become a union the client discriminates on. + const name: 'account_locked' | 'quota_exceeded' = result.details.name; + console.warn(name); + return null; + } + + if (!containsOperationSpaPayload(result)) { + return null; + } + + // No second round of guards: past the check every directive has its declared type. + for (const toast of result.__client.toasts ?? []) { + console.info(toast.type, toast.message); + } + + if (result.__client.redirect) { + window.location.href = result.__client.redirect.url; + } + + for (const [namespace, ...key] of result.__client.invalidations ?? []) { + console.debug('invalidate', namespace, key); + } + + return result.__client; +} + +/** + * 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}); +} + +/** + * 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}); + +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']; + +/** + * `__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'); + } +} + +/** + * A failed HTTP status is not blindly a server failure: a body that is not the server's envelope + * arrives as the client branch, carrying the raw response for whoever wants to look. + */ +export async function inspectRawResponse(): 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; +} 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"] +}