From bd7985f92c7b28cb47f00d181fbb222a0277675e Mon Sep 17 00:00:00 2001 From: Chris Arter Date: Tue, 2 Dec 2025 11:07:30 -0500 Subject: [PATCH] context and catch --- README.md | 76 ++++++++++++- docs/API.md | 48 +++++++++ phpstan.neon | 5 +- src/ZenPipe.php | 59 ++++++++-- src/helpers.php | 4 +- tests/Unit/PipelineTest.php | 207 ++++++++++++++++++++++++++++++++++++ 6 files changed, 388 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f8eab9e..986118d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ zenpipe(100) 4. [Usage](#usage) - [Pipeline Operations](#pipeline-operations) - [Class Methods as Operations](#class-methods-as-operations) + - [Context Passing](#context-passing) + - [Exception Handling](#exception-handling) - [More Examples](#more-examples) 5. [API Reference](#api-reference) 6. [Contributing](#contributing) @@ -58,10 +60,11 @@ composer require dynamik-dev/zenpipe-php ## Usage ### Pipeline Operations -Pipeline operations are functions that take an input and return a processed value. Each operation can receive up to three parameters: +Pipeline operations are functions that take an input and return a processed value. Each operation can receive up to four parameters: - `$input`: The value being processed - `$next`: A callback to pass the value to the next operation - `$return`: (Optional) A callback to exit the pipeline early with a value +- `$context`: (Optional) A shared context object passed to all operations #### Basic Operation Example Let's build an input sanitization pipeline: @@ -157,6 +160,77 @@ $pipeline = zenpipe() ]); ``` +### Context Passing + +You can pass a shared context object to all operations using `withContext()`. This is useful for sharing state, configuration, or dependencies across the pipeline without threading them through the value. + +```php +// Use any object as context - your own DTO, stdClass, or array +class RequestContext +{ + public function __construct( + public string $userId, + public array $permissions, + public array $logs = [] + ) {} +} + +$context = new RequestContext( + userId: 'user-123', + permissions: ['read', 'write'] +); + +$result = zenpipe(['action' => 'update', 'data' => [...]]) + ->withContext($context) + ->pipe(function ($request, $next, $return, RequestContext $ctx) { + if (!in_array('write', $ctx->permissions)) { + return $return(['error' => 'Unauthorized']); + } + $ctx->logs[] = "Permission check passed for {$ctx->userId}"; + return $next($request); + }) + ->pipe(function ($request, $next, $return, RequestContext $ctx) { + $ctx->logs[] = "Processing {$request['action']}"; + return $next([...$request, 'processed_by' => $ctx->userId]); + }) + ->process(); + +// Context is mutable - logs are accumulated across operations +// $context->logs = ['Permission check passed for user-123', 'Processing update'] +``` + +Type hint your context parameter in the operation signature for IDE support: + +```php +/** @var ZenPipe */ +$pipeline = zenpipe() + ->withContext(new RequestContext(...)) + ->pipe(fn($value, $next, $return, RequestContext $ctx) => ...); +``` + +### Exception Handling + +Use `catch()` to handle exceptions gracefully without breaking the pipeline: + +```php +$result = zenpipe($userData) + ->pipe(fn($data, $next) => $next(validateInput($data))) + ->pipe(fn($data, $next) => $next(processPayment($data))) // might throw + ->pipe(fn($data, $next) => $next(sendConfirmation($data))) + ->catch(fn(Throwable $e, $originalValue) => [ + 'error' => $e->getMessage(), + 'input' => $originalValue, + ]) + ->process(); +``` + +The catch handler receives: +- `$e`: The thrown exception (`Throwable`) +- `$value`: The original input value passed to `process()` +- `$context`: The context set via `withContext()` (null if not set) + +If no catch handler is set, exceptions propagate normally. + ### More Examples #### RAG Processes diff --git a/docs/API.md b/docs/API.md index b8af952..68fda83 100644 --- a/docs/API.md +++ b/docs/API.md @@ -35,6 +35,47 @@ Static factory method to create a new pipeline instance. - `$initialValue` (mixed|null): The initial value to be processed through the pipeline. - **Returns:** A new `ZenPipe` instance. +### withContext() + +```php +public function withContext(mixed $context): self +``` + +Sets a context object that will be passed to all operations as the fourth parameter. + +- **Parameters:** + - `$context` (mixed): Any value to be passed as context (object, array, DTO, etc.) +- **Returns:** The `ZenPipe` instance for method chaining. + +**Example:** +```php +$pipeline = zenpipe($value) + ->withContext(new MyContext()) + ->pipe(fn($v, $next, $return, MyContext $ctx) => $next($v)); +``` + +### catch() + +```php +public function catch(callable $handler): self +``` + +Sets an exception handler for the pipeline. + +- **Parameters:** + - `$handler` (callable): A function that receives `(Throwable $e, mixed $originalValue, mixed $context)` and returns a fallback value. +- **Returns:** The `ZenPipe` instance for method chaining. + +**Example:** +```php +$pipeline = zenpipe($value) + ->withContext($myContext) + ->pipe(fn($v, $next) => $next(riskyOperation($v))) + ->catch(fn($e, $value, $ctx) => ['error' => $e->getMessage()]); +``` + +If an exception occurs and no catch handler is set, the exception propagates normally. + ### pipe() ```php @@ -51,6 +92,13 @@ Adds an operation to the pipeline. - **Returns:** The `ZenPipe` instance for method chaining. - **Throws:** `\InvalidArgumentException` if the specified class does not exist. +**Operation Parameters:** +Operations receive up to four parameters: +1. `$value` - The current value being processed +2. `$next` - Callback to pass value to next operation +3. `$return` - Callback to exit pipeline early with a value +4. `$context` - The context set via `withContext()` (null if not set) + ### process() ```php diff --git a/phpstan.neon b/phpstan.neon index 60bfa75..3dbcdb8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,7 +6,10 @@ parameters: - vendor ignoreErrors: - '#PHPDoc tag @var#' - - + - message: '#Template type T of function zenpipe\(\) is not referenced in a parameter.#' path: src/helpers.php + - + message: '#Template type TContext of function zenpipe\(\) is not referenced in a parameter.#' + path: src/helpers.php reportUnmatchedIgnoredErrors: false diff --git a/src/ZenPipe.php b/src/ZenPipe.php index d053819..4f9057d 100644 --- a/src/ZenPipe.php +++ b/src/ZenPipe.php @@ -4,19 +4,54 @@ /** * @template T + * @template TContext */ class ZenPipe { /** @var array */ protected array $operations = []; + /** @var TContext|null */ + protected mixed $context = null; + + /** @var callable|null */ + protected $exceptionHandler = null; + public function __construct(protected mixed $initialValue = null) { } + /** + * Set an exception handler for the pipeline. + * + * @param callable(\Throwable, mixed, TContext|null): mixed $handler + * @return self + */ + public function catch(callable $handler): self + { + $this->exceptionHandler = $handler; + + return $this; + } + + /** + * Set the context to be passed to each operation. + * + * @template TNewContext + * + * @param TNewContext $context + * @return self + */ + public function withContext(mixed $context): self + { + $this->context = $context; + + return $this; + } + /** * @param mixed|null $initialValue - * @return self + * @return self */ public static function make(mixed $initialValue = null): self { @@ -25,7 +60,7 @@ public static function make(mixed $initialValue = null): self /** * @param callable|array{class-string, string} $operation - * @return self + * @return self */ public function pipe($operation): self { @@ -78,6 +113,7 @@ public function __invoke($initialValue) * @param T|null $initialValue * @return T * @throws \InvalidArgumentException + * @throws \Throwable */ public function process($initialValue = null) { @@ -93,7 +129,14 @@ public function process($initialValue = null) $this->passThroughOperation() ); - return $pipeline($value); + try { + return $pipeline($value); + } catch (\Throwable $e) { + if ($this->exceptionHandler !== null) { + return ($this->exceptionHandler)($e, $value, $this->context); + } + throw $e; + } } /** @@ -101,9 +144,9 @@ public function process($initialValue = null) * It wraps the next operation in a closure that can handle both * static method calls and regular callables. * - * The operation can now accept a third parameter for early return: - * - For callables: function($value, $next, $return) - * - For class methods: method($value, $next, $return) + * Operations can accept up to four parameters: + * - For callables: function($value, $next, $return, $context) + * - For class methods: method($value, $next, $return, $context) * * @return callable */ @@ -120,10 +163,10 @@ public function carry(): callable $method = $operation[1]; $instance = new $class(); - return $instance->$method($value, $next, $return); + return $instance->$method($value, $next, $return, $this->context); } - return $operation($value, $next, $return); + return $operation($value, $next, $return, $this->context); }; }; } diff --git a/src/helpers.php b/src/helpers.php index f129b7d..3f94777 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -7,7 +7,9 @@ * Create a new ZenPipe instance. * * @template T - * @return ZenPipe + * @template TContext + * + * @return ZenPipe */ function zenpipe(mixed $initialValue = null): ZenPipe { diff --git a/tests/Unit/PipelineTest.php b/tests/Unit/PipelineTest.php index 3fefb74..cb79b9f 100644 --- a/tests/Unit/PipelineTest.php +++ b/tests/Unit/PipelineTest.php @@ -151,3 +151,210 @@ public function handle($value, $next, $return) expect($pipeline(5))->toBe('found six'); // Early return when value becomes 6 expect($pipeline(3))->toBe(5); // Normal flow: ((3 + 1) * 2) - 3 }); + +test('pipeline supports context passing', function () { + $context = new stdClass(); + $context->multiplier = 3; + + $result = zenpipe(10) + ->withContext($context) + ->pipe(fn ($value, $next, $return, $ctx) => $next($value * $ctx->multiplier)) + ->pipe(fn ($value, $next, $return, $ctx) => $next($value + $ctx->multiplier)) + ->process(); + + expect($result)->toBe(33); // (10 * 3) + 3 +}); + +test('pipeline context works with array context', function () { + $context = ['prefix' => 'Hello', 'suffix' => '!']; + + $result = zenpipe('World') + ->withContext($context) + ->pipe(fn ($value, $next, $return, $ctx) => $next($ctx['prefix'] . ' ' . $value)) + ->pipe(fn ($value, $next, $return, $ctx) => $next($value . $ctx['suffix'])) + ->process(); + + expect($result)->toBe('Hello World!'); +}); + +test('pipeline context works with class methods', function () { + $testClass = new class () { + public function handle($value, $next, $return, $context) + { + return $next($value * $context->factor); + } + }; + + $context = new stdClass(); + $context->factor = 5; + + $result = zenpipe(4) + ->withContext($context) + ->pipe([$testClass, 'handle']) + ->pipe(fn ($value, $next) => $next($value + 1)) + ->process(); + + expect($result)->toBe(21); // (4 * 5) + 1 +}); + +test('pipeline context can be modified during execution', function () { + $context = new stdClass(); + $context->steps = []; + + $result = zenpipe(1) + ->withContext($context) + ->pipe(function ($value, $next, $return, $ctx) { + $ctx->steps[] = 'step1'; + return $next($value + 1); + }) + ->pipe(function ($value, $next, $return, $ctx) { + $ctx->steps[] = 'step2'; + return $next($value + 1); + }) + ->process(); + + expect($result)->toBe(3); + expect($context->steps)->toBe(['step1', 'step2']); +}); + +test('pipeline context is null when not set', function () { + $capturedContext = 'not null'; + + zenpipe(5) + ->pipe(function ($value, $next, $return, $context) use (&$capturedContext) { + $capturedContext = $context; + return $next($value); + }) + ->process(); + + expect($capturedContext)->toBeNull(); +}); + +test('pipeline context works with custom DTO class', function () { + $dto = new class ('test-user', ['admin', 'editor']) { + public function __construct( + public string $userId, + public array $roles + ) { + } + + public function hasRole(string $role): bool + { + return in_array($role, $this->roles); + } + }; + + $result = zenpipe(['action' => 'edit']) + ->withContext($dto) + ->pipe(function ($value, $next, $return, $ctx) { + if (!$ctx->hasRole('editor')) { + return $return(['error' => 'Unauthorized']); + } + return $next($value); + }) + ->pipe(function ($value, $next, $return, $ctx) { + $value['user'] = $ctx->userId; + return $next($value); + }) + ->process(); + + expect($result)->toBe(['action' => 'edit', 'user' => 'test-user']); +}); + +test('pipeline catch handles exceptions with fallback value', function () { + $result = zenpipe(5) + ->pipe(fn ($value, $next) => $next($value * 2)) + ->pipe(function ($value, $next) { + throw new RuntimeException('Something went wrong'); + }) + ->pipe(fn ($value, $next) => $next($value + 1)) + ->catch(fn ($e, $value) => 'fallback') + ->process(); + + expect($result)->toBe('fallback'); +}); + +test('pipeline catch receives exception and original value', function () { + $capturedException = null; + $capturedValue = null; + + $result = zenpipe(42) + ->pipe(function ($value, $next) { + throw new InvalidArgumentException('Test error'); + }) + ->catch(function ($e, $value) use (&$capturedException, &$capturedValue) { + $capturedException = $e; + $capturedValue = $value; + + return 'handled'; + }) + ->process(); + + expect($result)->toBe('handled'); + expect($capturedException)->toBeInstanceOf(InvalidArgumentException::class); + expect($capturedException->getMessage())->toBe('Test error'); + expect($capturedValue)->toBe(42); +}); + +test('pipeline rethrows exception when no catch handler', function () { + $pipeline = zenpipe(5) + ->pipe(function ($value, $next) { + throw new RuntimeException('Unhandled error'); + }); + + expect(fn () => $pipeline->process())->toThrow(RuntimeException::class, 'Unhandled error'); +}); + +test('pipeline catch works with class method operations', function () { + $testClass = new class () { + public function handle($value, $next) + { + throw new RuntimeException('Class method error'); + } + }; + + $result = zenpipe(10) + ->pipe([$testClass, 'handle']) + ->catch(fn ($e, $value) => $value * 2) + ->process(); + + expect($result)->toBe(20); +}); + +test('pipeline catch can return computed value based on exception', function () { + $result = zenpipe(['items' => [1, 2, 3]]) + ->pipe(function ($value, $next) { + throw new RuntimeException('Processing failed'); + }) + ->catch(fn ($e, $value) => [ + 'error' => $e->getMessage(), + 'items_count' => count($value['items']), + ]) + ->process(); + + expect($result)->toBe([ + 'error' => 'Processing failed', + 'items_count' => 3, + ]); +}); + +test('pipeline catch handler receives context', function () { + $context = new stdClass(); + $context->fallbackMessage = 'Operation failed gracefully'; + + $result = zenpipe(10) + ->withContext($context) + ->pipe(function ($value, $next) { + throw new RuntimeException('Error occurred'); + }) + ->catch(fn ($e, $value, $ctx) => [ + 'value' => $value, + 'message' => $ctx->fallbackMessage, + ]) + ->process(); + + expect($result)->toBe([ + 'value' => 10, + 'message' => 'Operation failed gracefully', + ]); +});