Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"require": {
"php": "^8.1.0",
"devizzent/cebe-php-openapi": "^1.1.2",
"atto/codegen-tools": "^0.1",
"atto/codegen-tools": "^0.1.1",
"psr/http-message": "^1.0 || ^2.0",
"psr/log": "^2.0 || ^3.0",
"symfony/console": "^6.2 || ^7.0",
Expand Down
49 changes: 49 additions & 0 deletions docs/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ interface Filter
}
```

If you need a custom Filter, implement this in your own class.

The `filter` method SHOULD NOT throw on failure.
It SHOULD provide an invalid result with an error message.
This ensures the processor can fail gracefully and provide all errors in one go.

It MAY throw exceptions in other methods (such as invalid arguments to `__construct`).

## Methods

### Filter
Expand Down Expand Up @@ -44,6 +52,47 @@ This method can also be called implicitly by typecasting your filter as string.

Create objects from external data.

### CallMethod

This filter exists to call bespoke methods required by a class, when [FromArray](#fromarray) and [WithNamedArguments](#withnamedarguments) are not sufficiently flexible.

If you are calling methods on an external class, as a way of reusing logic; create a reusable [Filter](#interface) instead.

```php
new CallMethod($className, $methodName)
```

| Parameter | Type |
|-------------|--------|
| $className | string |
| $methodName | string |

**Example**

Your class may have a constructor with variadic number of arguments.
Currently, no filter exists to handle this.

```php
$classWithMethod = new class () {
public function __construct(string ...$tags) {
// do some stuff...
}

/** @param list<string> $list */
public static function fromList(array $list): self
{
return new self(...$list);
}
};

$callMethod = new CallMethod($class::class, 'fromList');

$result = $callMethod->filter(['foo', 'bar', 'baz'])

echo $result->value;
echo $result->isValid() ? 'Result was valid' : 'Result was invalid';
```

### FromArray

construct new data object from an array. $className must correspond to a class with a method named 'fromArray'
Expand Down
8 changes: 7 additions & 1 deletion src/Exception/InvalidFilterArguments.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@

class InvalidFilterArguments extends \RuntimeException
{
public const EMPTY_STRING_DELIMITER = 0;
public const METHOD_NOT_CALLABLE = 0;
public const EMPTY_STRING_DELIMITER = 1;

public static function methodNotCallable(string $class, string $method): self
{
return new self("$class::$method must be callable", self::METHOD_NOT_CALLABLE);
}

public static function emptyStringDelimiter(): self
{
Expand Down
72 changes: 72 additions & 0 deletions src/Filter/CreateObject/CallMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Membrane\Filter\CreateObject;

use Membrane\Exception\InvalidFilterArguments;
use Membrane\Filter;
use Membrane\Result\Message;
use Membrane\Result\MessageSet;
use Membrane\Result\Result;

final class CallMethod implements Filter
{
/** @var callable&array{0: class-string, 1: string} */
private readonly array $callable;

/**
* @param class-string $class
*/
public function __construct(string $class, string $method)
{
$callable = [$class, $method];

if (!is_callable($callable)) {
throw InvalidFilterArguments::methodNotCallable($class, $method);
}

$this->callable = $callable;
}

public function __toString(): string
{
return sprintf(
'Call %s with array value as arguments',
implode('::', $this->callable),
);
}

public function __toPHP(): string
{
return sprintf(
'new %s(\'%s\', \'%s\')',
self::class,
$this->callable[0],
$this->callable[1],
);
}

public function filter(mixed $value): Result
{

if (!is_array($value)) {
$message = new Message(
'CallMethod requires arrays of arguments, %s given',
[gettype($value)],
);
return Result::invalid($value, new MessageSet(null, $message));
}

try {
$result = call_user_func($this->callable, ...$value);
} catch (\Throwable $e) {
return Result::invalid(
$value,
new MessageSet(null, new Message($e->getMessage(), [])),
);
}

return Result::noResult($result);
}
}
196 changes: 196 additions & 0 deletions tests/Filter/CreateObject/CallMethodTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php

declare(strict_types=1);

namespace Filter\CreateObject;

use Membrane\Exception\InvalidFilterArguments;
use Membrane\Filter;
use Membrane\Result\Message;
use Membrane\Result\MessageSet;
use Membrane\Result\Result;
use Membrane\Tests\MembraneTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;

#[UsesClass(Result::class)]
#[UsesClass(MessageSet::class)]
#[UsesClass(Message::class)]
#[CoversClass(Filter\CreateObject\CallMethod::class)]
class CallMethodTest extends MembraneTestCase
{
#[Test]
public function itExpectsClassToExist(): void
{
$class = 'n/a';
$method = 'n/a';

self::expectExceptionObject(
InvalidFilterArguments::methodNotCallable($class, $method),
);

new Filter\CreateObject\CallMethod($class, $method);
}

#[Test]
public function itExpectsMethodToExist(): void
{
$class = new class () {};
$method = 'n/a';

self::expectExceptionObject(
InvalidFilterArguments::methodNotCallable($class::class, $method),
);

new Filter\CreateObject\CallMethod($class::class, $method);
}

#[Test]
public function itExpectsMethodToBePublic(): void
{
$class = new class() {private static function foo() {}};
$method = 'foo';

self::expectExceptionObject(
InvalidFilterArguments::methodNotCallable($class::class, $method),
);

new Filter\CreateObject\CallMethod($class::class, $method);
}

#[Test]
public function itExpectsMethodToBeStatic(): void
{
$class = new class() {public function foo() {}};
$method = 'foo';

self::expectExceptionObject(
InvalidFilterArguments::methodNotCallable($class::class, $method),
);

new Filter\CreateObject\CallMethod($class::class, $method);
}

#[Test]
public function itIsStringable(): void
{
$class = new class() {public static function foo() {}};
$method = 'foo';

$sut = new Filter\CreateObject\CallMethod($class::class, $method);

self::assertSame(
sprintf('Call %s::%s with array value as arguments', $class::class, $method),
$sut->__toString(),
);
}

#[Test]
public function itIsPhpStringable(): void
{
$class = new class() {public static function foo() {}};
$method = 'foo';

$sut = new Filter\CreateObject\CallMethod($class::class, $method);

self::assertSame(
sprintf(
'new %s(\'%s\', \'%s\')',
$sut::class,
$class::class,
$method,
),
$sut->__toPHP(),
);
}

#[Test]
#[DataProvider('provideValuesToFilter')]
public function itFiltersValue(
Result $expected,
string $class,
string $method,
mixed $value,
): void {
$sut = new Filter\CreateObject\CallMethod($class, $method);

self::assertResultEquals($expected, $sut->filter($value));
}

/**
* @return \Generator<array{
* 0: Result,
* 1: class-string,
* 2: string,
* 3: mixed,
* }>
*/
public static function provideValuesToFilter(): \Generator
{
yield 'it expects an array' => (function () {
$class = new class () {public static function foo() {}};
return [
Result::invalid(
'Howdy, planet!',
new MessageSet(
null,
new Message('CallMethod requires arrays of arguments, %s given', ['string']),
)
),
$class::class,
'foo',
'Howdy, planet!',
];
})();

yield 'noop' => (function () {
$class = new class () {public static function foo() {}};
return [
Result::noResult(null),
$class::class,
'foo',
[],
];
})();

yield 'returns 1' => (function () {
$class = new class () {public static function foo() {return 1;}};
return [
Result::noResult(1),
$class::class,
'foo',
['Hello, world!'],
];
})();

yield 'capitalizes string' => (function () {
$class = new class () {
public static function shout(string $greeting) {
return strtoupper($greeting);
}
};
return [
Result::noResult('HOWDY, PLANET!'),
$class::class,
'shout',
['Howdy, planet!'],
];
})();

yield 'sum numbers' => (function () {
$class = new class () {
public static function sum(...$numbers) {
return array_sum($numbers);
}
};
return [
Result::noResult(6),
$class::class,
'sum',
[1, 2, 3],
];
})();
}
}