Implemented Validation for controllers

This commit is contained in:
Igor Scheller 2019-07-09 21:43:18 +02:00
parent 508695efb2
commit 7414f9b23d
16 changed files with 927 additions and 2 deletions

View File

@ -25,6 +25,7 @@ return [
\Engelsystem\Middleware\RouteDispatcherServiceProvider::class,
\Engelsystem\Middleware\RequestHandlerServiceProvider::class,
\Engelsystem\Middleware\SessionHandlerServiceProvider::class,
\Engelsystem\Http\Validation\ValidationServiceProvider::class,
// Additional services
\Engelsystem\Mail\MailerServiceProvider::class,

View File

@ -2,8 +2,12 @@
namespace Engelsystem\Controllers;
use Engelsystem\Http\Validation\ValidatesRequest;
abstract class BaseController
{
use ValidatesRequest;
/** @var string[]|string[][] A list of Permissions required to access the controller or certain pages */
protected $permissions = [];

View File

@ -0,0 +1,37 @@
<?php
namespace Engelsystem\Http\Exceptions;
use Engelsystem\Http\Validation\Validator;
use RuntimeException;
use Throwable;
class ValidationException extends RuntimeException
{
/** @var Validator */
protected $validator;
/**
* @param Validator $validator
* @param string $message
* @param int $code
* @param Throwable|null $previous
*/
public function __construct(
Validator $validator,
string $message = '',
int $code = 0,
Throwable $previous = null
) {
$this->validator = $validator;
parent::__construct($message, $code, $previous);
}
/**
* @return Validator
*/
public function getValidator(): Validator
{
return $this->validator;
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace Engelsystem\Http\Validation;
use InvalidArgumentException;
class Validates
{
/**
* @param mixed $value
* @return bool
*/
public function accepted($value): bool
{
return in_array($value, ['true', '1', 'y', 'yes', 'on', 1, true], true);
}
/**
* @param string $value
* @param array $parameters ['min', 'max']
* @return bool
*/
public function between($value, $parameters): bool
{
$this->validateParameterCount(2, $parameters, __FUNCTION__);
$size = $this->getSize($value);
return $size >= $parameters[0] && $size <= $parameters[1];
}
/**
* @param mixed $value
* @return bool
*/
public function bool($value): bool
{
return in_array($value, ['1', 1, true, '0', 0, false], true);
}
/**
* @param mixed $value
* @param array $parameters ['1,2,3,56,7']
* @return bool
*/
public function in($value, $parameters): bool
{
$this->validateParameterCount(1, $parameters, __FUNCTION__);
return in_array($value, explode(',', $parameters[0]));
}
/**
* @param mixed $value
* @return bool
*/
public function int($value): bool
{
return filter_var($value, FILTER_VALIDATE_INT) !== false;
}
/**
* @param string $value
* @param array $parameters ['max']
* @return bool
*/
public function max($value, $parameters): bool
{
$this->validateParameterCount(1, $parameters, __FUNCTION__);
$size = $this->getSize($value);
return $size <= $parameters[0];
}
/**
* @param string $value
* @param array $parameters ['min']
* @return bool
*/
public function min($value, $parameters)
{
$this->validateParameterCount(1, $parameters, __FUNCTION__);
$size = $this->getSize($value);
return $size >= $parameters[0];
}
/**
* @param mixed $value
* @param array $parameters ['1,2,3,56,7']
* @return bool
*/
public function notIn($value, $parameters): bool
{
$this->validateParameterCount(1, $parameters, __FUNCTION__);
return !$this->in($value, $parameters);
}
/**
* @param mixed $value
* @return bool
*/
public function numeric($value): bool
{
return is_numeric($value);
}
/**
* @param mixed $value
* @return bool
*/
public function required($value): bool
{
if (
is_null($value)
|| (is_string($value) && trim($value) === '')
) {
return false;
}
return true;
}
/**
* @param mixed $value
* @return int|float
*/
protected function getSize($value)
{
if (is_numeric($value)) {
return $value;
}
return mb_strlen($value);
}
/**
* @param int $count
* @param array $parameters
* @param string $rule
*
* @throws InvalidArgumentException
*/
protected function validateParameterCount(int $count, array $parameters, string $rule)
{
if (count($parameters) < $count) {
throw new InvalidArgumentException(sprintf(
'The rule "%s" requires at least %d parameters',
$rule,
$count
));
}
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Engelsystem\Http\Validation;
use Engelsystem\Http\Exceptions\ValidationException;
use Engelsystem\Http\Request;
trait ValidatesRequest
{
/** @var Validator */
protected $validator;
/**
* @param Request $request
* @param array $rules
* @return array
*/
protected function validate(Request $request, array $rules)
{
if (!$this->validator->validate(
(array)$request->getParsedBody(),
$rules
)) {
throw new ValidationException($this->validator);
}
return $this->validator->getData();
}
/**
* @param Validator $validator
*/
public function setValidator(Validator $validator)
{
$this->validator = $validator;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Engelsystem\Http\Validation;
use Engelsystem\Application;
use Engelsystem\Container\ServiceProvider;
use Engelsystem\Controllers\BaseController;
class ValidationServiceProvider extends ServiceProvider
{
public function register()
{
$validates = $this->app->make(Validates::class);
$this->app->instance(Validates::class, $validates);
$validator = $this->app->make(Validator::class);
$this->app->instance(Validator::class, $validator);
$this->app->instance('validator', $validator);
$this->app->afterResolving(function ($object, Application $app) {
if (!$object instanceof BaseController) {
return;
}
$object->setValidator($app->get(Validator::class));
});
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace Engelsystem\Http\Validation;
use Illuminate\Support\Str;
use InvalidArgumentException;
class Validator
{
/** @var Validates */
protected $validate;
/** @var string[] */
protected $errors = [];
/** @var array */
protected $data = [];
/**
* @param Validates $validate
*/
public function __construct(Validates $validate)
{
$this->validate = $validate;
}
/**
* @param array $data
* @param array $rules
* @return bool
*/
public function validate($data, $rules)
{
$this->errors = [];
$this->data = [];
foreach ($rules as $key => $values) {
foreach (explode('|', $values) as $parameters) {
$parameters = explode(':', $parameters);
$rule = array_shift($parameters);
$rule = Str::camel($rule);
if (!method_exists($this->validate, $rule)) {
throw new InvalidArgumentException('Unknown validation rule: ' . $rule);
}
$value = isset($data[$key]) ? $data[$key] : null;
if (!$this->validate->{$rule}($value, $parameters, $data)) {
$this->errors[$key][] = implode('.', ['validation', $key, $rule]);
continue;
}
$this->data[$key] = $value;
}
}
return empty($this->errors);
}
/**
* @return array
*/
public function getData(): array
{
return $this->data;
}
/**
* @return string[]
*/
public function getErrors(): array
{
return $this->errors;
}
}

View File

@ -3,6 +3,8 @@
namespace Engelsystem\Middleware;
use Engelsystem\Http\Exceptions\HttpException;
use Engelsystem\Http\Exceptions\ValidationException;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@ -43,6 +45,21 @@ class ErrorHandler implements MiddlewareInterface
$response = $handler->handle($request);
} catch (HttpException $e) {
$response = $this->createResponse($e->getMessage(), $e->getStatusCode(), $e->getHeaders());
} catch (ValidationException $e) {
$response = $this->createResponse('', 302, ['Location' => $this->getPreviousUrl($request)]);
if ($request instanceof Request) {
$session = $request->getSession();
$session->set(
'errors',
array_merge_recursive(
$session->get('errors', []),
['validation' => $e->getValidator()->getErrors()]
)
);
$session->set('form-data', $request->request->all());
}
}
$statusCode = $response->getStatusCode();
@ -106,4 +123,17 @@ class ErrorHandler implements MiddlewareInterface
{
return response($content, $status, $headers);
}
/**
* @param ServerRequestInterface $request
* @return string
*/
protected function getPreviousUrl(ServerRequestInterface $request)
{
if ($header = $request->getHeader('referer')) {
return array_pop($header);
}
return '/';
}
}

View File

@ -21,5 +21,7 @@ class BaseControllerTest extends TestCase
'dolor',
],
], $controller->getPermissions());
$this->assertTrue(method_exists($controller, 'setValidator'));
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Engelsystem\Test\Unit\Http\Exceptions;
use Engelsystem\Http\Exceptions\ValidationException;
use Engelsystem\Http\Validation\Validator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ValidationExceptionTest extends TestCase
{
/**
* @covers \Engelsystem\Http\Exceptions\ValidationException::__construct
* @covers \Engelsystem\Http\Exceptions\ValidationException::getValidator
*/
public function testConstruct()
{
/** @var Validator|MockObject $validator */
$validator = $this->createMock(Validator::class);
$exception = new ValidationException($validator);
$this->assertEquals($validator, $exception->getValidator());
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Engelsystem\Test\Unit\Http\Validation\Stub;
use Engelsystem\Controllers\BaseController;
use Engelsystem\Http\Request;
class ValidatesRequestImplementation extends BaseController
{
/**
* @param Request $request
* @param array $rules
* @return array
*/
public function validateData(Request $request, array $rules)
{
return $this->validate($request, $rules);
}
/**
* @return bool
*/
public function hasValidator()
{
return !is_null($this->validator);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Engelsystem\Test\Unit\Http\Validation;
use Engelsystem\Http\Exceptions\ValidationException;
use Engelsystem\Http\Request;
use Engelsystem\Http\Validation\Validator;
use Engelsystem\Test\Unit\Http\Validation\Stub\ValidatesRequestImplementation;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ValidatesRequestTest extends TestCase
{
/**
* @covers \Engelsystem\Http\Validation\ValidatesRequest::validate
* @covers \Engelsystem\Http\Validation\ValidatesRequest::setValidator
*/
public function testValidate()
{
/** @var Validator|MockObject $validator */
$validator = $this->createMock(Validator::class);
$validator->expects($this->exactly(2))
->method('validate')
->withConsecutive(
[['foo' => 'bar'], ['foo' => 'required']],
[[], ['foo' => 'required']]
)
->willReturnOnConsecutiveCalls(
true,
false
);
$validator->expects($this->once())
->method('getData')
->willReturn(['foo' => 'bar']);
$implementation = new ValidatesRequestImplementation();
$implementation->setValidator($validator);
$return = $implementation->validateData(new Request([], ['foo' => 'bar']), ['foo' => 'required']);
$this->assertEquals(['foo' => 'bar'], $return);
$this->expectException(ValidationException::class);
$implementation->validateData(new Request([], []), ['foo' => 'required']);
}
}

View File

@ -0,0 +1,308 @@
<?php
namespace Engelsystem\Test\Unit\Http\Validation;
use Engelsystem\Http\Validation\Validates;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class ValidatesTest extends TestCase
{
/**
* @return array
*/
public function provideAccepted()
{
return [
['true'],
['1'],
['y'],
['yes'],
['on'],
['1test', false],
['false', false],
['no', false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::accepted
* @param mixed $value
* @param bool $result
* @dataProvider provideAccepted
*/
public function testAccepted($value, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->accepted($value) === $result);
}
/**
* @return array
*/
public function provideBetween()
{
return [
['42', [10, 100]],
[42.5, [42, 43]],
[42, [42, 1000]],
[1337, [0, 99], false],
[-17, [32, 45], false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::between
* @param mixed $value
* @param array $parameters
* @param bool $result
* @dataProvider provideBetween
*/
public function testBetween($value, array $parameters, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->between($value, $parameters) === $result);
}
/**
* @return array
*/
public function provideBool()
{
return [
['1'],
[1],
[true],
['0'],
[0],
[false],
['true', false],
['false', false],
['yes', false],
['no', false],
['bool', false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::bool
* @param mixed $value
* @param bool $result
* @dataProvider provideBool
*/
public function testBool($value, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->bool($value) === $result);
}
/**
* @return array
*/
public function provideIn()
{
return [
['lorem', ['lorem,ipsum,dolor']],
[99, ['66,77,88,99,111']],
[4, ['1,3,5,7'], false],
['toggle', ['on,off'], false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::in
* @param mixed $value
* @param array $parameters
* @param bool $result
* @dataProvider provideIn
*/
public function testIn($value, array $parameters, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->in($value, $parameters) === $result);
}
/**
* @return array
*/
public function provideInt()
{
return [
['1337'],
[42],
['0'],
[false, false],
['12asd1', false],
['one', false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::int
* @param mixed $value
* @param bool $result
* @dataProvider provideInt
*/
public function testInt($value, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->int($value) === $result);
}
/**
* @return array
*/
public function provideMax()
{
return [
['99', [100]],
[-42, [1024]],
[99, [99]],
[100, [10], false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::max
* @param mixed $value
* @param array $parameters
* @param bool $result
* @dataProvider provideMax
*/
public function testMax($value, array $parameters, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->max($value, $parameters) === $result);
}
/**
* @return array
*/
public function provideMin()
{
return [
[32, [0]],
[7, [7]],
['99', [10]],
[3, [42], false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::min
* @param mixed $value
* @param array $parameters
* @param bool $result
* @dataProvider provideMin
*/
public function testMin($value, array $parameters, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->min($value, $parameters) === $result);
}
/**
* @return array
*/
public function provideNotIn()
{
return [
[77, ['50,60,70']],
['test', ['coding,deployment']],
['PHP', ['Java,PHP,bash'], false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::notIn
* @param mixed $value
* @param array $parameters
* @param bool $result
* @dataProvider provideNotIn
*/
public function testNotIn($value, array $parameters, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->notIn($value, $parameters) === $result);
}
/**
* @return array
*/
public function provideNumeric()
{
return [
[77],
['42'],
['1337e0'],
['123f00', false],
[null, false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::numeric
* @param mixed $value
* @param bool $result
* @dataProvider provideNumeric
*/
public function testNumeric($value, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->numeric($value) === $result);
}
/**
* @return array
*/
public function provideRequired()
{
return [
['Lorem ipsum'],
['1234'],
[1234],
['0'],
[0],
['', false],
[' ', false],
[null, false],
];
}
/**
* @covers \Engelsystem\Http\Validation\Validates::required
* @param mixed $value
* @param bool $result
* @dataProvider provideRequired
*/
public function testRequired($value, bool $result = true)
{
$val = new Validates;
$this->assertTrue($val->required($value) === $result);
}
/**
* @covers \Engelsystem\Http\Validation\Validates::getSize
*/
public function testGetSize()
{
$val = new Validates;
$this->assertTrue($val->max(42, [999]));
$this->assertTrue($val->max('99', [100]));
$this->assertFalse($val->max('101', [100]));
$this->assertTrue($val->max('lorem', [5]));
$this->assertFalse($val->max('Lorem Ipsum', [5]));
}
/**
* @covers \Engelsystem\Http\Validation\Validates::validateParameterCount
*/
public function testValidateParameterCount()
{
$val = new Validates;
$this->assertTrue($val->between(42, [1, 100]));
$this->expectException(InvalidArgumentException::class);
$val->between(42, [1]);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Engelsystem\Test\Unit\Http\Validation;
use Engelsystem\Application;
use Engelsystem\Http\Validation\ValidationServiceProvider;
use Engelsystem\Http\Validation\Validator;
use Engelsystem\Test\Unit\Http\Validation\Stub\ValidatesRequestImplementation;
use Engelsystem\Test\Unit\ServiceProviderTest;
use stdClass;
class ValidationServiceProviderTest extends ServiceProviderTest
{
/**
* @covers \Engelsystem\Http\Validation\ValidationServiceProvider::register
*/
public function testRegister()
{
$app = new Application();
$serviceProvider = new ValidationServiceProvider($app);
$serviceProvider->register();
$this->assertTrue($app->has(Validator::class));
$this->assertTrue($app->has('validator'));
/** @var ValidatesRequestImplementation $validatesRequest */
$validatesRequest = $app->make(ValidatesRequestImplementation::class);
$this->assertTrue($validatesRequest->hasValidator());
// Test afterResolving early return
$app->make(stdClass::class);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Engelsystem\Test\Unit\Http\Validation;
use Engelsystem\Http\Validation\Validates;
use Engelsystem\Http\Validation\Validator;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class ValidatorTest extends TestCase
{
/**
* @covers \Engelsystem\Http\Validation\Validator::__construct
* @covers \Engelsystem\Http\Validation\Validator::validate
* @covers \Engelsystem\Http\Validation\Validator::getData
* @covers \Engelsystem\Http\Validation\Validator::getErrors
*/
public function testValidate()
{
$val = new Validator(new Validates);
$this->assertTrue($val->validate(
['foo' => 'bar', 'lorem' => 'on'],
['foo' => 'required|not_in:lorem,ipsum,dolor', 'lorem' => 'accepted']
));
$this->assertEquals(['foo' => 'bar', 'lorem' => 'on'], $val->getData());
$this->assertFalse($val->validate(
[],
['lorem' => 'required|min:3']
));
$this->assertEquals(
['lorem' => ['validation.lorem.required', 'validation.lorem.min']],
$val->getErrors()
);
}
/**
* @covers \Engelsystem\Http\Validation\Validator::validate
*/
public function testValidateNotImplemented()
{
$val = new Validator(new Validates);
$this->expectException(InvalidArgumentException::class);
$val->validate(
['lorem' => 'bar'],
['foo' => 'never_implemented']
);
}
}

View File

@ -2,14 +2,23 @@
namespace Engelsystem\Test\Unit\Middleware;
use Engelsystem\Application;
use Engelsystem\Http\Exceptions\HttpException;
use Engelsystem\Http\Exceptions\ValidationException;
use Engelsystem\Http\Psr7ServiceProvider;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Http\ResponseServiceProvider;
use Engelsystem\Http\Validation\Validator;
use Engelsystem\Middleware\ErrorHandler;
use Engelsystem\Test\Unit\Middleware\Stub\ReturnResponseMiddlewareHandler;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Twig_LoaderInterface as TwigLoader;
class ErrorHandlerTest extends TestCase
@ -104,7 +113,7 @@ class ErrorHandlerTest extends TestCase
/**
* @covers \Engelsystem\Middleware\ErrorHandler::process
*/
public function testProcessException()
public function testProcessHttpException()
{
/** @var ServerRequestInterface|MockObject $request */
$request = $this->createMock(ServerRequestInterface::class);
@ -144,6 +153,63 @@ class ErrorHandlerTest extends TestCase
$this->assertEquals($psrResponse, $return);
}
/**
* @covers \Engelsystem\Middleware\ErrorHandler::process
* @covers \Engelsystem\Middleware\ErrorHandler::getPreviousUrl
*/
public function testProcessValidationException()
{
/** @var TwigLoader|MockObject $twigLoader */
$twigLoader = $this->createMock(TwigLoader::class);
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
$validator = $this->createMock(Validator::class);
$handler->expects($this->exactly(2))
->method('handle')
->willReturnCallback(function () use ($validator) {
throw new ValidationException($validator);
});
$validator->expects($this->exactly(2))
->method('getErrors')
->willReturn(['foo' => ['validation.foo.numeric']]);
$session = new Session(new MockArraySessionStorage());
$session->set('errors', ['validation' => ['foo' => ['validation.foo.required']]]);
$request = Request::create('/foo/bar', 'POST', ['foo' => 'bar']);
$request->setSession($session);
/** @var Application $app */
$app = app();
(new ResponseServiceProvider($app))->register();
(new Psr7ServiceProvider($app))->register();
$errorHandler = new ErrorHandler($twigLoader);
$return = $errorHandler->process($request, $handler);
$this->assertEquals(302, $return->getStatusCode());
$this->assertEquals('/', $return->getHeaderLine('location'));
$this->assertEquals([
'errors' => [
'validation' => [
'foo' => [
'validation.foo.required',
'validation.foo.numeric',
],
],
],
'form-data' => [
'foo' => 'bar',
],
], $session->all());
$request = $request->withAddedHeader('referer', '/foo/batz');
$return = $errorHandler->process($request, $handler);
$this->assertEquals('/foo/batz', $return->getHeaderLine('location'));
}
/**
* @covers \Engelsystem\Middleware\ErrorHandler::process
*/
@ -153,7 +219,7 @@ class ErrorHandlerTest extends TestCase
$request = $this->createMock(ServerRequestInterface::class);
/** @var TwigLoader|MockObject $twigLoader */
$twigLoader = $this->createMock(TwigLoader::class);
$response = new Response('<!DOCTYPE html><html><body><h1>Hi!</h1></body></html>', 500);
$response = new Response('<!DOCTYPE html><html lang="en"><body><h1>Hi!</h1></body></html>', 500);
$returnResponseHandler = new ReturnResponseMiddlewareHandler($response);
/** @var ErrorHandler|MockObject $errorHandler */