Initial project structure with Slim skeleton

This commit is contained in:
Gregory Ballantine 2022-07-09 12:25:26 -04:00
commit 14735bd9a5
43 changed files with 4878 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.idea/
.vscode/
/coverage/
/vendor/
/logs/*
!/logs/README.md
.phpunit.result.cache

19
.htaccess Normal file
View File

@ -0,0 +1,19 @@
Options All -Indexes
<Files .htaccess>
order allow,deny
deny from all
</Files>
<IfModule mod_rewrite.c>
# Redirect to the public folder
RewriteEngine On
# RewriteBase /
RewriteRule ^$ public/ [L]
RewriteRule (.*) public/$1 [L]
# Redirect to HTTPS
# RewriteEngine On
# RewriteCond %{HTTPS} off
# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</IfModule>

14
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,14 @@
# How to Contribute
## Pull Requests
1. Fork the Slim Skeleton repository
2. Create a new branch for each feature or improvement
3. Send a pull request from each feature branch to the **4.x** branch
It is very important to separate new features or improvements into separate feature branches, and to send a
pull request for each branch. This allows us to review and pull in new features or improvements individually.
## Style Guide
All pull requests must adhere to the [PSR-2 standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md).

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# Slim Framework 4 Skeleton Application
[![Coverage Status](https://coveralls.io/repos/github/slimphp/Slim-Skeleton/badge.svg?branch=master)](https://coveralls.io/github/slimphp/Slim-Skeleton?branch=master)
Use this skeleton application to quickly setup and start working on a new Slim Framework 4 application. This application uses the latest Slim 4 with Slim PSR-7 implementation and PHP-DI container implementation. It also uses the Monolog logger.
This skeleton application was built for Composer. This makes setting up a new Slim Framework application quick and easy.
## Install the Application
Run this command from the directory in which you want to install your new Slim Framework application. You will require PHP 7.3 or newer.
```bash
composer create-project slim/slim-skeleton [my-app-name]
```
Replace `[my-app-name]` with the desired directory name for your new application. You'll want to:
* Point your virtual host document root to your new application's `public/` directory.
* Ensure `logs/` is web writable.
To run the application in development, you can run these commands
```bash
cd [my-app-name]
composer start
```
Or you can use `docker-compose` to run the app with `docker`, so you can run these commands:
```bash
cd [my-app-name]
docker-compose up -d
```
After that, open `http://localhost:8080` in your browser.
Run this command in the application directory to run the test suite
```bash
composer test
```
That's it! Now go build something cool.

29
app/dependencies.php Normal file
View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Application\Settings\SettingsInterface;
use DI\ContainerBuilder;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Processor\UidProcessor;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
return function (ContainerBuilder $containerBuilder) {
$containerBuilder->addDefinitions([
LoggerInterface::class => function (ContainerInterface $c) {
$settings = $c->get(SettingsInterface::class);
$loggerSettings = $settings->get('logger');
$logger = new Logger($loggerSettings['name']);
$processor = new UidProcessor();
$logger->pushProcessor($processor);
$handler = new StreamHandler($loggerSettings['path'], $loggerSettings['level']);
$logger->pushHandler($handler);
return $logger;
},
]);
};

9
app/middleware.php Normal file
View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
use App\Application\Middleware\SessionMiddleware;
use Slim\App;
return function (App $app) {
$app->add(SessionMiddleware::class);
};

13
app/repositories.php Normal file
View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use App\Domain\User\UserRepository;
use App\Infrastructure\Persistence\User\InMemoryUserRepository;
use DI\ContainerBuilder;
return function (ContainerBuilder $containerBuilder) {
// Here we map our UserRepository interface to its in memory implementation
$containerBuilder->addDefinitions([
UserRepository::class => \DI\autowire(InMemoryUserRepository::class),
]);
};

26
app/routes.php Normal file
View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Application\Actions\User\ListUsersAction;
use App\Application\Actions\User\ViewUserAction;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\App;
use Slim\Interfaces\RouteCollectorProxyInterface as Group;
return function (App $app) {
$app->options('/{routes:.*}', function (Request $request, Response $response) {
// CORS Pre-Flight OPTIONS Request Handler
return $response;
});
$app->get('/', function (Request $request, Response $response) {
$response->getBody()->write('Hello world!');
return $response;
});
$app->group('/users', function (Group $group) {
$group->get('', ListUsersAction::class);
$group->get('/{id}', ViewUserAction::class);
});
};

26
app/settings.php Normal file
View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Application\Settings\Settings;
use App\Application\Settings\SettingsInterface;
use DI\ContainerBuilder;
use Monolog\Logger;
return function (ContainerBuilder $containerBuilder) {
// Global Settings Object
$containerBuilder->addDefinitions([
SettingsInterface::class => function () {
return new Settings([
'displayErrorDetails' => true, // Should be set to false in production
'logError' => false,
'logErrorDetails' => false,
'logger' => [
'name' => 'slim-app',
'path' => isset($_ENV['docker']) ? 'php://stdout' : __DIR__ . '/../logs/app.log',
'level' => Logger::DEBUG,
],
]);
}
]);
};

58
composer.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "slim/slim-skeleton",
"description": "A Slim Framework skeleton application for rapid development",
"keywords": [
"microframework",
"rest",
"router",
"psr7"
],
"homepage": "http://github.com/slimphp/Slim-Skeleton",
"license": "MIT",
"authors": [
{
"name": "Josh Lockhart",
"email": "info@joshlockhart.com",
"homepage": "http://www.joshlockhart.com/"
},
{
"name": "Pierre Berube",
"email": "pierre@lgse.com",
"homepage": "http://www.lgse.com/"
}
],
"require": {
"php": "^7.4 || ^8.0",
"ext-json": "*",
"monolog/monolog": "^2.3",
"php-di/php-di": "^6.3",
"slim/psr7": "^1.5",
"slim/slim": "^4.9"
},
"require-dev": {
"jangregor/phpstan-prophecy": "^1.0.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/extension-installer": "^1.1.0",
"phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^9.5.11",
"squizlabs/php_codesniffer": "^3.6"
},
"config": {
"process-timeout": 0,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"start": "php -S localhost:8080 -t public",
"test": "phpunit"
}
}

3401
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

1
logs/README.md Normal file
View File

@ -0,0 +1 @@
Your Slim Framework application's log files will be written to this directory.

17
phpcs.xml Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0"?>
<ruleset name="Slim coding standard">
<description>Slim coding standard</description>
<!-- display progress -->
<arg value="p"/>
<!-- use colors in output -->
<arg name="colors"/>
<!-- inherit rules from: -->
<rule ref="PSR2"/>
<rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
<!-- Paths to check -->
<file>src</file>
<file>tests</file>
</ruleset>

4
phpstan.neon.dist Normal file
View File

@ -0,0 +1,4 @@
parameters:
level: 4
paths:
- src

27
phpunit.xml Normal file
View File

@ -0,0 +1,27 @@
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/7.1/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutChangesToGlobalState="true"
beStrictAboutOutputDuringTests="true"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
bootstrap="tests/bootstrap.php"
>
<testsuites>
<testsuite name="Test Suite">
<directory>./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./src/</directory>
</whitelist>
</filter>
</phpunit>

34
public/.htaccess Normal file
View File

@ -0,0 +1,34 @@
Options All -Indexes
<Files .htaccess>
order allow,deny
deny from all
</Files>
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect to HTTPS
# RewriteEngine On
# RewriteCond %{HTTPS} off
# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Some hosts may require you to use the `RewriteBase` directive.
# Determine the RewriteBase automatically and set it as environment variable.
# If you are using Apache aliases to do mass virtual hosting or installed the
# project in a subdirectory, the base path will be prepended to allow proper
# resolution of the index.php file and to redirect to the correct URI. It will
# work in environments without path prefix as well, providing a safe, one-size
# fits all solution. But as you do not need it in this case, you can comment
# the following 2 lines to eliminate the overhead.
RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$
RewriteRule ^(.*) - [E=BASE:%1]
# If the above doesn't work you might need to set the `RewriteBase` directive manually, it should be the
# absolute physical path to the directory that contains this htaccess file.
# RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
</IfModule>

81
public/index.php Normal file
View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Application\Handlers\HttpErrorHandler;
use App\Application\Handlers\ShutdownHandler;
use App\Application\ResponseEmitter\ResponseEmitter;
use App\Application\Settings\SettingsInterface;
use DI\ContainerBuilder;
use Slim\Factory\AppFactory;
use Slim\Factory\ServerRequestCreatorFactory;
require __DIR__ . '/../vendor/autoload.php';
// Instantiate PHP-DI ContainerBuilder
$containerBuilder = new ContainerBuilder();
if (false) { // Should be set to true in production
$containerBuilder->enableCompilation(__DIR__ . '/../var/cache');
}
// Set up settings
$settings = require __DIR__ . '/../app/settings.php';
$settings($containerBuilder);
// Set up dependencies
$dependencies = require __DIR__ . '/../app/dependencies.php';
$dependencies($containerBuilder);
// Set up repositories
$repositories = require __DIR__ . '/../app/repositories.php';
$repositories($containerBuilder);
// Build PHP-DI Container instance
$container = $containerBuilder->build();
// Instantiate the app
AppFactory::setContainer($container);
$app = AppFactory::create();
$callableResolver = $app->getCallableResolver();
// Register middleware
$middleware = require __DIR__ . '/../app/middleware.php';
$middleware($app);
// Register routes
$routes = require __DIR__ . '/../app/routes.php';
$routes($app);
/** @var SettingsInterface $settings */
$settings = $container->get(SettingsInterface::class);
$displayErrorDetails = $settings->get('displayErrorDetails');
$logError = $settings->get('logError');
$logErrorDetails = $settings->get('logErrorDetails');
// Create Request object from globals
$serverRequestCreator = ServerRequestCreatorFactory::create();
$request = $serverRequestCreator->createServerRequestFromGlobals();
// Create Error Handler
$responseFactory = $app->getResponseFactory();
$errorHandler = new HttpErrorHandler($callableResolver, $responseFactory);
// Create Shutdown Handler
$shutdownHandler = new ShutdownHandler($request, $errorHandler, $displayErrorDetails);
register_shutdown_function($shutdownHandler);
// Add Routing Middleware
$app->addRoutingMiddleware();
// Add Body Parsing Middleware
$app->addBodyParsingMiddleware();
// Add Error Middleware
$errorMiddleware = $app->addErrorMiddleware($displayErrorDetails, $logError, $logErrorDetails);
$errorMiddleware->setDefaultErrorHandler($errorHandler);
// Run App & Emit Response
$response = $app->handle($request);
$responseEmitter = new ResponseEmitter();
$responseEmitter->emit($response);

View File

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Application\Actions;
use App\Domain\DomainException\DomainRecordNotFoundException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpNotFoundException;
abstract class Action
{
protected LoggerInterface $logger;
protected Request $request;
protected Response $response;
protected array $args;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
/**
* @throws HttpNotFoundException
* @throws HttpBadRequestException
*/
public function __invoke(Request $request, Response $response, array $args): Response
{
$this->request = $request;
$this->response = $response;
$this->args = $args;
try {
return $this->action();
} catch (DomainRecordNotFoundException $e) {
throw new HttpNotFoundException($this->request, $e->getMessage());
}
}
/**
* @throws DomainRecordNotFoundException
* @throws HttpBadRequestException
*/
abstract protected function action(): Response;
/**
* @return array|object
*/
protected function getFormData()
{
return $this->request->getParsedBody();
}
/**
* @return mixed
* @throws HttpBadRequestException
*/
protected function resolveArg(string $name)
{
if (!isset($this->args[$name])) {
throw new HttpBadRequestException($this->request, "Could not resolve argument `{$name}`.");
}
return $this->args[$name];
}
/**
* @param array|object|null $data
*/
protected function respondWithData($data = null, int $statusCode = 200): Response
{
$payload = new ActionPayload($statusCode, $data);
return $this->respond($payload);
}
protected function respond(ActionPayload $payload): Response
{
$json = json_encode($payload, JSON_PRETTY_PRINT);
$this->response->getBody()->write($json);
return $this->response
->withHeader('Content-Type', 'application/json')
->withStatus($payload->getStatusCode());
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Application\Actions;
use JsonSerializable;
class ActionError implements JsonSerializable
{
public const BAD_REQUEST = 'BAD_REQUEST';
public const INSUFFICIENT_PRIVILEGES = 'INSUFFICIENT_PRIVILEGES';
public const NOT_ALLOWED = 'NOT_ALLOWED';
public const NOT_IMPLEMENTED = 'NOT_IMPLEMENTED';
public const RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND';
public const SERVER_ERROR = 'SERVER_ERROR';
public const UNAUTHENTICATED = 'UNAUTHENTICATED';
public const VALIDATION_ERROR = 'VALIDATION_ERROR';
public const VERIFICATION_ERROR = 'VERIFICATION_ERROR';
private string $type;
private string $description;
public function __construct(string $type, ?string $description)
{
$this->type = $type;
$this->description = $description;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(?string $description = null): self
{
$this->description = $description;
return $this;
}
#[\ReturnTypeWillChange]
public function jsonSerialize(): array
{
$payload = [
'type' => $this->type,
'description' => $this->description,
];
return $payload;
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Application\Actions;
use JsonSerializable;
class ActionPayload implements JsonSerializable
{
private int $statusCode;
/**
* @var array|object|null
*/
private $data;
private ?ActionError $error;
public function __construct(
int $statusCode = 200,
$data = null,
?ActionError $error = null
) {
$this->statusCode = $statusCode;
$this->data = $data;
$this->error = $error;
}
public function getStatusCode(): int
{
return $this->statusCode;
}
/**
* @return array|null|object
*/
public function getData()
{
return $this->data;
}
public function getError(): ?ActionError
{
return $this->error;
}
#[\ReturnTypeWillChange]
public function jsonSerialize(): array
{
$payload = [
'statusCode' => $this->statusCode,
];
if ($this->data !== null) {
$payload['data'] = $this->data;
} elseif ($this->error !== null) {
$payload['error'] = $this->error;
}
return $payload;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Application\Actions\User;
use Psr\Http\Message\ResponseInterface as Response;
class ListUsersAction extends UserAction
{
/**
* {@inheritdoc}
*/
protected function action(): Response
{
$users = $this->userRepository->findAll();
$this->logger->info("Users list was viewed.");
return $this->respondWithData($users);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Application\Actions\User;
use App\Application\Actions\Action;
use App\Domain\User\UserRepository;
use Psr\Log\LoggerInterface;
abstract class UserAction extends Action
{
protected UserRepository $userRepository;
public function __construct(LoggerInterface $logger, UserRepository $userRepository)
{
parent::__construct($logger);
$this->userRepository = $userRepository;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Application\Actions\User;
use Psr\Http\Message\ResponseInterface as Response;
class ViewUserAction extends UserAction
{
/**
* {@inheritdoc}
*/
protected function action(): Response
{
$userId = (int) $this->resolveArg('id');
$user = $this->userRepository->findUserOfId($userId);
$this->logger->info("User of id `${userId}` was viewed.");
return $this->respondWithData($user);
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Application\Handlers;
use App\Application\Actions\ActionError;
use App\Application\Actions\ActionPayload;
use Exception;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpException;
use Slim\Exception\HttpForbiddenException;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Exception\HttpNotImplementedException;
use Slim\Exception\HttpUnauthorizedException;
use Slim\Handlers\ErrorHandler as SlimErrorHandler;
use Throwable;
class HttpErrorHandler extends SlimErrorHandler
{
/**
* @inheritdoc
*/
protected function respond(): Response
{
$exception = $this->exception;
$statusCode = 500;
$error = new ActionError(
ActionError::SERVER_ERROR,
'An internal error has occurred while processing your request.'
);
if ($exception instanceof HttpException) {
$statusCode = $exception->getCode();
$error->setDescription($exception->getMessage());
if ($exception instanceof HttpNotFoundException) {
$error->setType(ActionError::RESOURCE_NOT_FOUND);
} elseif ($exception instanceof HttpMethodNotAllowedException) {
$error->setType(ActionError::NOT_ALLOWED);
} elseif ($exception instanceof HttpUnauthorizedException) {
$error->setType(ActionError::UNAUTHENTICATED);
} elseif ($exception instanceof HttpForbiddenException) {
$error->setType(ActionError::INSUFFICIENT_PRIVILEGES);
} elseif ($exception instanceof HttpBadRequestException) {
$error->setType(ActionError::BAD_REQUEST);
} elseif ($exception instanceof HttpNotImplementedException) {
$error->setType(ActionError::NOT_IMPLEMENTED);
}
}
if (!($exception instanceof HttpException)
&& $exception instanceof Throwable
&& $this->displayErrorDetails
) {
$error->setDescription($exception->getMessage());
}
$payload = new ActionPayload($statusCode, null, $error);
$encodedPayload = json_encode($payload, JSON_PRETTY_PRINT);
$response = $this->responseFactory->createResponse($statusCode);
$response->getBody()->write($encodedPayload);
return $response->withHeader('Content-Type', 'application/json');
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Application\Handlers;
use App\Application\ResponseEmitter\ResponseEmitter;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Exception\HttpInternalServerErrorException;
class ShutdownHandler
{
private Request $request;
private HttpErrorHandler $errorHandler;
private bool $displayErrorDetails;
public function __construct(
Request $request,
HttpErrorHandler $errorHandler,
bool $displayErrorDetails
) {
$this->request = $request;
$this->errorHandler = $errorHandler;
$this->displayErrorDetails = $displayErrorDetails;
}
public function __invoke()
{
$error = error_get_last();
if ($error) {
$errorFile = $error['file'];
$errorLine = $error['line'];
$errorMessage = $error['message'];
$errorType = $error['type'];
$message = 'An error while processing your request. Please try again later.';
if ($this->displayErrorDetails) {
switch ($errorType) {
case E_USER_ERROR:
$message = "FATAL ERROR: {$errorMessage}. ";
$message .= " on line {$errorLine} in file {$errorFile}.";
break;
case E_USER_WARNING:
$message = "WARNING: {$errorMessage}";
break;
case E_USER_NOTICE:
$message = "NOTICE: {$errorMessage}";
break;
default:
$message = "ERROR: {$errorMessage}";
$message .= " on line {$errorLine} in file {$errorFile}.";
break;
}
}
$exception = new HttpInternalServerErrorException($this->request, $message);
$response = $this->errorHandler->__invoke(
$this->request,
$exception,
$this->displayErrorDetails,
false,
false,
);
$responseEmitter = new ResponseEmitter();
$responseEmitter->emit($response);
}
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Application\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface as Middleware;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
class SessionMiddleware implements Middleware
{
/**
* {@inheritdoc}
*/
public function process(Request $request, RequestHandler $handler): Response
{
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
session_start();
$request = $request->withAttribute('session', $_SESSION);
}
return $handler->handle($request);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Application\ResponseEmitter;
use Psr\Http\Message\ResponseInterface;
use Slim\ResponseEmitter as SlimResponseEmitter;
class ResponseEmitter extends SlimResponseEmitter
{
/**
* {@inheritdoc}
*/
public function emit(ResponseInterface $response): void
{
// This variable should be set to the allowed host from which your API can be accessed with
$origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '';
$response = $response
->withHeader('Access-Control-Allow-Credentials', 'true')
->withHeader('Access-Control-Allow-Origin', $origin)
->withHeader(
'Access-Control-Allow-Headers',
'X-Requested-With, Content-Type, Accept, Origin, Authorization',
)
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->withAddedHeader('Cache-Control', 'post-check=0, pre-check=0')
->withHeader('Pragma', 'no-cache');
if (ob_get_contents()) {
ob_clean();
}
parent::emit($response);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Application\Settings;
class Settings implements SettingsInterface
{
private array $settings;
public function __construct(array $settings)
{
$this->settings = $settings;
}
/**
* @return mixed
*/
public function get(string $key = '')
{
return (empty($key)) ? $this->settings : $this->settings[$key];
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Application\Settings;
interface SettingsInterface
{
/**
* @param string $key
* @return mixed
*/
public function get(string $key = '');
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Domain\DomainException;
use Exception;
abstract class DomainException extends Exception
{
}

View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace App\Domain\DomainException;
class DomainRecordNotFoundException extends DomainException
{
}

56
src/Domain/User/User.php Normal file
View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
use JsonSerializable;
class User implements JsonSerializable
{
private ?int $id;
private string $username;
private string $firstName;
private string $lastName;
public function __construct(?int $id, string $username, string $firstName, string $lastName)
{
$this->id = $id;
$this->username = strtolower($username);
$this->firstName = ucfirst($firstName);
$this->lastName = ucfirst($lastName);
}
public function getId(): ?int
{
return $this->id;
}
public function getUsername(): string
{
return $this->username;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function getLastName(): string
{
return $this->lastName;
}
#[\ReturnTypeWillChange]
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'username' => $this->username,
'firstName' => $this->firstName,
'lastName' => $this->lastName,
];
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
use App\Domain\DomainException\DomainRecordNotFoundException;
class UserNotFoundException extends DomainRecordNotFoundException
{
public $message = 'The user you requested does not exist.';
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
interface UserRepository
{
/**
* @return User[]
*/
public function findAll(): array;
/**
* @param int $id
* @return User
* @throws UserNotFoundException
*/
public function findUserOfId(int $id): User;
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\User;
use App\Domain\User\User;
use App\Domain\User\UserNotFoundException;
use App\Domain\User\UserRepository;
class InMemoryUserRepository implements UserRepository
{
/**
* @var User[]
*/
private array $users;
/**
* @param User[]|null $users
*/
public function __construct(array $users = null)
{
$this->users = $users ?? [
1 => new User(1, 'bill.gates', 'Bill', 'Gates'),
2 => new User(2, 'steve.jobs', 'Steve', 'Jobs'),
3 => new User(3, 'mark.zuckerberg', 'Mark', 'Zuckerberg'),
4 => new User(4, 'evan.spiegel', 'Evan', 'Spiegel'),
5 => new User(5, 'jack.dorsey', 'Jack', 'Dorsey'),
];
}
/**
* {@inheritdoc}
*/
public function findAll(): array
{
return array_values($this->users);
}
/**
* {@inheritdoc}
*/
public function findUserOfId(int $id): User
{
if (!isset($this->users[$id])) {
throw new UserNotFoundException();
}
return $this->users[$id];
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Application\Actions;
use App\Application\Actions\Action;
use App\Application\Actions\ActionPayload;
use DateTimeImmutable;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Log\LoggerInterface;
use Tests\TestCase;
class ActionTest extends TestCase
{
public function testActionSetsHttpCodeInRespond()
{
$app = $this->getAppInstance();
$container = $app->getContainer();
$logger = $container->get(LoggerInterface::class);
$testAction = new class($logger) extends Action {
public function __construct(
LoggerInterface $loggerInterface
) {
parent::__construct($loggerInterface);
}
public function action(): Response
{
return $this->respond(
new ActionPayload(
202,
[
'willBeDoneAt' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM)
]
)
);
}
};
$app->get('/test-action-response-code', $testAction);
$request = $this->createRequest('GET', '/test-action-response-code');
$response = $app->handle($request);
$this->assertEquals(202, $response->getStatusCode());
}
public function testActionSetsHttpCodeRespondData()
{
$app = $this->getAppInstance();
$container = $app->getContainer();
$logger = $container->get(LoggerInterface::class);
$testAction = new class($logger) extends Action {
public function __construct(
LoggerInterface $loggerInterface
) {
parent::__construct($loggerInterface);
}
public function action(): Response
{
return $this->respondWithData(
[
'willBeDoneAt' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM)
],
202
);
}
};
$app->get('/test-action-response-code', $testAction);
$request = $this->createRequest('GET', '/test-action-response-code');
$response = $app->handle($request);
$this->assertEquals(202, $response->getStatusCode());
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Application\Actions\User;
use App\Application\Actions\ActionPayload;
use App\Domain\User\UserRepository;
use App\Domain\User\User;
use DI\Container;
use Tests\TestCase;
class ListUserActionTest extends TestCase
{
public function testAction()
{
$app = $this->getAppInstance();
/** @var Container $container */
$container = $app->getContainer();
$user = new User(1, 'bill.gates', 'Bill', 'Gates');
$userRepositoryProphecy = $this->prophesize(UserRepository::class);
$userRepositoryProphecy
->findAll()
->willReturn([$user])
->shouldBeCalledOnce();
$container->set(UserRepository::class, $userRepositoryProphecy->reveal());
$request = $this->createRequest('GET', '/users');
$response = $app->handle($request);
$payload = (string) $response->getBody();
$expectedPayload = new ActionPayload(200, [$user]);
$serializedPayload = json_encode($expectedPayload, JSON_PRETTY_PRINT);
$this->assertEquals($serializedPayload, $payload);
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Tests\Application\Actions\User;
use App\Application\Actions\ActionError;
use App\Application\Actions\ActionPayload;
use App\Application\Handlers\HttpErrorHandler;
use App\Domain\User\User;
use App\Domain\User\UserNotFoundException;
use App\Domain\User\UserRepository;
use DI\Container;
use Slim\Middleware\ErrorMiddleware;
use Tests\TestCase;
class ViewUserActionTest extends TestCase
{
public function testAction()
{
$app = $this->getAppInstance();
/** @var Container $container */
$container = $app->getContainer();
$user = new User(1, 'bill.gates', 'Bill', 'Gates');
$userRepositoryProphecy = $this->prophesize(UserRepository::class);
$userRepositoryProphecy
->findUserOfId(1)
->willReturn($user)
->shouldBeCalledOnce();
$container->set(UserRepository::class, $userRepositoryProphecy->reveal());
$request = $this->createRequest('GET', '/users/1');
$response = $app->handle($request);
$payload = (string) $response->getBody();
$expectedPayload = new ActionPayload(200, $user);
$serializedPayload = json_encode($expectedPayload, JSON_PRETTY_PRINT);
$this->assertEquals($serializedPayload, $payload);
}
public function testActionThrowsUserNotFoundException()
{
$app = $this->getAppInstance();
$callableResolver = $app->getCallableResolver();
$responseFactory = $app->getResponseFactory();
$errorHandler = new HttpErrorHandler($callableResolver, $responseFactory);
$errorMiddleware = new ErrorMiddleware($callableResolver, $responseFactory, true, false, false);
$errorMiddleware->setDefaultErrorHandler($errorHandler);
$app->add($errorMiddleware);
/** @var Container $container */
$container = $app->getContainer();
$userRepositoryProphecy = $this->prophesize(UserRepository::class);
$userRepositoryProphecy
->findUserOfId(1)
->willThrow(new UserNotFoundException())
->shouldBeCalledOnce();
$container->set(UserRepository::class, $userRepositoryProphecy->reveal());
$request = $this->createRequest('GET', '/users/1');
$response = $app->handle($request);
$payload = (string) $response->getBody();
$expectedError = new ActionError(ActionError::RESOURCE_NOT_FOUND, 'The user you requested does not exist.');
$expectedPayload = new ActionPayload(404, null, $expectedError);
$serializedPayload = json_encode($expectedPayload, JSON_PRETTY_PRINT);
$this->assertEquals($serializedPayload, $payload);
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Domain\User;
use App\Domain\User\User;
use Tests\TestCase;
class UserTest extends TestCase
{
public function userProvider()
{
return [
[1, 'bill.gates', 'Bill', 'Gates'],
[2, 'steve.jobs', 'Steve', 'Jobs'],
[3, 'mark.zuckerberg', 'Mark', 'Zuckerberg'],
[4, 'evan.spiegel', 'Evan', 'Spiegel'],
[5, 'jack.dorsey', 'Jack', 'Dorsey'],
];
}
/**
* @dataProvider userProvider
* @param int $id
* @param string $username
* @param string $firstName
* @param string $lastName
*/
public function testGetters(int $id, string $username, string $firstName, string $lastName)
{
$user = new User($id, $username, $firstName, $lastName);
$this->assertEquals($id, $user->getId());
$this->assertEquals($username, $user->getUsername());
$this->assertEquals($firstName, $user->getFirstName());
$this->assertEquals($lastName, $user->getLastName());
}
/**
* @dataProvider userProvider
* @param int $id
* @param string $username
* @param string $firstName
* @param string $lastName
*/
public function testJsonSerialize(int $id, string $username, string $firstName, string $lastName)
{
$user = new User($id, $username, $firstName, $lastName);
$expectedPayload = json_encode([
'id' => $id,
'username' => $username,
'firstName' => $firstName,
'lastName' => $lastName,
]);
$this->assertEquals($expectedPayload, json_encode($user));
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Tests\Infrastructure\Persistence\User;
use App\Domain\User\User;
use App\Domain\User\UserNotFoundException;
use App\Infrastructure\Persistence\User\InMemoryUserRepository;
use Tests\TestCase;
class InMemoryUserRepositoryTest extends TestCase
{
public function testFindAll()
{
$user = new User(1, 'bill.gates', 'Bill', 'Gates');
$userRepository = new InMemoryUserRepository([1 => $user]);
$this->assertEquals([$user], $userRepository->findAll());
}
public function testFindAllUsersByDefault()
{
$users = [
1 => new User(1, 'bill.gates', 'Bill', 'Gates'),
2 => new User(2, 'steve.jobs', 'Steve', 'Jobs'),
3 => new User(3, 'mark.zuckerberg', 'Mark', 'Zuckerberg'),
4 => new User(4, 'evan.spiegel', 'Evan', 'Spiegel'),
5 => new User(5, 'jack.dorsey', 'Jack', 'Dorsey'),
];
$userRepository = new InMemoryUserRepository();
$this->assertEquals(array_values($users), $userRepository->findAll());
}
public function testFindUserOfId()
{
$user = new User(1, 'bill.gates', 'Bill', 'Gates');
$userRepository = new InMemoryUserRepository([1 => $user]);
$this->assertEquals($user, $userRepository->findUserOfId(1));
}
public function testFindUserOfIdThrowsNotFoundException()
{
$userRepository = new InMemoryUserRepository([]);
$this->expectException(UserNotFoundException::class);
$userRepository->findUserOfId(1);
}
}

89
tests/TestCase.php Normal file
View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Tests;
use DI\ContainerBuilder;
use Exception;
use PHPUnit\Framework\TestCase as PHPUnit_TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Psr7\Factory\StreamFactory;
use Slim\Psr7\Headers;
use Slim\Psr7\Request as SlimRequest;
use Slim\Psr7\Uri;
class TestCase extends PHPUnit_TestCase
{
use ProphecyTrait;
/**
* @return App
* @throws Exception
*/
protected function getAppInstance(): App
{
// Instantiate PHP-DI ContainerBuilder
$containerBuilder = new ContainerBuilder();
// Container intentionally not compiled for tests.
// Set up settings
$settings = require __DIR__ . '/../app/settings.php';
$settings($containerBuilder);
// Set up dependencies
$dependencies = require __DIR__ . '/../app/dependencies.php';
$dependencies($containerBuilder);
// Set up repositories
$repositories = require __DIR__ . '/../app/repositories.php';
$repositories($containerBuilder);
// Build PHP-DI Container instance
$container = $containerBuilder->build();
// Instantiate the app
AppFactory::setContainer($container);
$app = AppFactory::create();
// Register middleware
$middleware = require __DIR__ . '/../app/middleware.php';
$middleware($app);
// Register routes
$routes = require __DIR__ . '/../app/routes.php';
$routes($app);
return $app;
}
/**
* @param string $method
* @param string $path
* @param array $headers
* @param array $cookies
* @param array $serverParams
* @return Request
*/
protected function createRequest(
string $method,
string $path,
array $headers = ['HTTP_ACCEPT' => 'application/json'],
array $cookies = [],
array $serverParams = []
): Request {
$uri = new Uri('', '', 80, $path);
$handle = fopen('php://temp', 'w+');
$stream = (new StreamFactory())->createStreamFromResource($handle);
$h = new Headers();
foreach ($headers as $name => $value) {
$h->addHeader($name, $value);
}
return new SlimRequest($method, $uri, $h, $cookies, $serverParams, $stream);
}
}

2
tests/bootstrap.php Normal file
View File

@ -0,0 +1,2 @@
<?php
require __DIR__ . '/../vendor/autoload.php';

2
var/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore