├── tests ├── .gitignore ├── Support │ ├── _generated │ │ └── .gitignore │ ├── ApiTester.php │ ├── UnitTester.php │ ├── ConsoleTester.php │ └── FunctionalTester.php ├── Functional.suite.yml ├── Unit.suite.yml ├── Console.suite.yml ├── bootstrap.php ├── Api.suite.yml ├── Console │ ├── YiiCest.php │ └── HelloCommandCest.php ├── Unit │ ├── EnvironmentTest.php │ └── Api │ │ └── Shared │ │ ├── Presenter │ │ └── OffsetPaginatorPresenterTest.php │ │ ├── ResponseFactoryTest.php │ │ └── ExceptionResponderFactoryTest.php ├── Api │ ├── NotFoundCest.php │ └── IndexCest.php └── Functional │ └── HomePageCest.php ├── docker ├── dev │ ├── .gitignore │ ├── override.env.example │ ├── .env │ └── compose.yml ├── prod │ ├── .gitignore │ ├── .env │ └── compose.yml ├── test │ ├── .gitignore │ ├── .env │ └── compose.yml ├── compose.yml ├── .env └── Dockerfile ├── runtime └── .gitignore ├── public ├── robots.txt ├── favicon.ico └── index.php ├── config ├── .gitignore ├── environments │ ├── prod │ │ └── params.php │ ├── test │ │ └── params.php │ └── dev │ │ └── params.php ├── common │ ├── application.php │ ├── params.php │ ├── routes.php │ ├── di │ │ ├── application.php │ │ ├── router.php │ │ ├── hydrator.php │ │ ├── logger.php │ │ └── error-handler.php │ └── aliases.php ├── console │ ├── commands.php │ └── params.php ├── web │ ├── params.php │ └── di │ │ ├── error-handler.php │ │ ├── psr17.php │ │ └── application.php └── configuration.php ├── yii.bat ├── src ├── autoload.php ├── Shared │ └── ApplicationParams.php ├── Api │ ├── Shared │ │ ├── Presenter │ │ │ ├── PresenterInterface.php │ │ │ ├── AsIsPresenter.php │ │ │ ├── ValidationResultPresenter.php │ │ │ ├── CollectionPresenter.php │ │ │ ├── SuccessPresenter.php │ │ │ ├── OffsetPaginatorPresenter.php │ │ │ └── FailPresenter.php │ │ ├── NotFoundMiddleware.php │ │ ├── ExceptionResponderFactory.php │ │ └── ResponseFactory.php │ └── IndexAction.php ├── Console │ └── HelloCommand.php └── Environment.php ├── .dockerignore ├── infection.json.dist ├── .editorconfig ├── .gitignore ├── yii ├── codeception.yml ├── rector.php ├── .php-cs-fixer.php ├── psalm.xml ├── composer-dependency-analyser.php ├── composer.json └── Makefile /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /_output 2 | -------------------------------------------------------------------------------- /docker/dev/.gitignore: -------------------------------------------------------------------------------- 1 | /override.env 2 | -------------------------------------------------------------------------------- /docker/prod/.gitignore: -------------------------------------------------------------------------------- 1 | /override.env 2 | -------------------------------------------------------------------------------- /docker/test/.gitignore: -------------------------------------------------------------------------------- 1 | /override.env 2 | -------------------------------------------------------------------------------- /runtime/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /config/.gitignore: -------------------------------------------------------------------------------- 1 | *-local.php 2 | .merge-plan.php 3 | -------------------------------------------------------------------------------- /tests/Support/_generated/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /tests/Functional.suite.yml: -------------------------------------------------------------------------------- 1 | actor: FunctionalTester 2 | -------------------------------------------------------------------------------- /docker/prod/.env: -------------------------------------------------------------------------------- 1 | APP_ENV=prod 2 | APP_DEBUG=false 3 | SERVER_NAME=:80 4 | -------------------------------------------------------------------------------- /docker/dev/override.env.example: -------------------------------------------------------------------------------- 1 | APP_HOST_PATH=/projects/yiisoft/app-api 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiisoft/app-api/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /tests/Unit.suite.yml: -------------------------------------------------------------------------------- 1 | actor: UnitTester 2 | modules: 3 | enabled: 4 | - Asserts 5 | -------------------------------------------------------------------------------- /tests/Console.suite.yml: -------------------------------------------------------------------------------- 1 | actor: ConsoleTester 2 | modules: 3 | enabled: 4 | - Cli 5 | -------------------------------------------------------------------------------- /config/environments/prod/params.php: -------------------------------------------------------------------------------- 1 | 'My Project', 7 | 'version' => '1.0', 8 | ]; 9 | -------------------------------------------------------------------------------- /yii.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | @setlocal 3 | set YII_PATH=%~dp0 4 | if "%PHP_COMMAND%" == "" set PHP_COMMAND=php 5 | "%PHP_COMMAND%" "%YII_PATH%yii" %* 6 | @endlocal 7 | -------------------------------------------------------------------------------- /docker/test/.env: -------------------------------------------------------------------------------- 1 | APP_ENV=test 2 | APP_DEBUG=false 3 | APP_C3=true 4 | SERVER_NAME=:80 5 | XDEBUG_MODE=coverage 6 | COMPOSER_CACHE_DIR=/app/runtime/cache/composer 7 | -------------------------------------------------------------------------------- /config/environments/dev/params.php: -------------------------------------------------------------------------------- 1 | 'phpstorm://open?url=file://{file}&line={line}', 7 | ]; 8 | -------------------------------------------------------------------------------- /config/console/commands.php: -------------------------------------------------------------------------------- 1 | Console\HelloCommand::class, 9 | ]; 10 | -------------------------------------------------------------------------------- /docker/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: &appconfig 3 | extra_hosts: 4 | - "host.docker.internal:host-gateway" 5 | 6 | volumes: 7 | caddy_data: 8 | caddy_config: 9 | -------------------------------------------------------------------------------- /src/autoload.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'commands' => require __DIR__ . '/commands.php', 8 | ], 9 | ]; 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # except: 5 | 6 | !/config 7 | !/public 8 | !/src 9 | !/vendor 10 | !autoload.php 11 | !configuration.php 12 | !yii 13 | !composer.json 14 | !composer.lock 15 | !.env* 16 | -------------------------------------------------------------------------------- /docker/.env: -------------------------------------------------------------------------------- 1 | STACK_NAME=app-api 2 | 3 | # 4 | # Development 5 | # 6 | 7 | DEV_PORT=80 8 | 9 | # 10 | # Production 11 | # 12 | 13 | PROD_HOST=app-api.example.com 14 | PROD_SSH="ssh://docker-web" 15 | 16 | IMAGE=app-api 17 | IMAGE_TAG=latest 18 | -------------------------------------------------------------------------------- /config/web/params.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'requestInputParametersResolver' => [ 8 | 'throwInputValidationException' => true, 9 | ], 10 | ], 11 | ]; 12 | -------------------------------------------------------------------------------- /config/common/params.php: -------------------------------------------------------------------------------- 1 | require __DIR__ . '/application.php', 7 | 8 | 'yiisoft/aliases' => [ 9 | 'aliases' => require __DIR__ . '/aliases.php', 10 | ], 11 | ]; 12 | -------------------------------------------------------------------------------- /config/common/routes.php: -------------------------------------------------------------------------------- 1 | action(Api\IndexAction::class)->name('app/index'), 14 | ]; 15 | -------------------------------------------------------------------------------- /tests/Api.suite.yml: -------------------------------------------------------------------------------- 1 | actor: ApiTester 2 | extensions: 3 | enabled: 4 | - Codeception\Extension\RunProcess: 5 | 0: composer serve 6 | sleep: 3 7 | modules: 8 | enabled: 9 | - REST: 10 | url: http://127.0.0.1:8080 11 | depends: PhpBrowser 12 | -------------------------------------------------------------------------------- /src/Shared/ApplicationParams.php: -------------------------------------------------------------------------------- 1 | JsonRenderer::class, 14 | ]; 15 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "php:\/\/stderr", 9 | "stryker": { 10 | "report": "master" 11 | } 12 | }, 13 | "mutators": { 14 | "@default": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Console/YiiCest.php: -------------------------------------------------------------------------------- 1 | runApp(); 14 | $I->canSeeResultCodeIs(0); 15 | $I->seeInShellOutput('Yii Console'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /config/common/di/application.php: -------------------------------------------------------------------------------- 1 | [ 11 | '__construct()' => [ 12 | 'name' => $params['application']['name'], 13 | 'version' => $params['application']['version'], 14 | ], 15 | ], 16 | ]; 17 | -------------------------------------------------------------------------------- /src/Api/Shared/Presenter/PresenterInterface.php: -------------------------------------------------------------------------------- 1 | runApp('hello'); 14 | $I->canSeeResultCodeIs(0); 15 | $I->seeInShellOutput('Hello!'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | 13 | [*.php] 14 | ij_php_space_before_short_closure_left_parenthesis = false 15 | ij_php_space_after_type_cast = true 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | 20 | [*.yml] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /tests/Unit/EnvironmentTest.php: -------------------------------------------------------------------------------- 1 | assertSame('test', Environment::appEnv()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # phpstorm project files 2 | .idea 3 | 4 | # netbeans project files 5 | nbproject 6 | 7 | # zend studio for eclipse project files 8 | .buildpath 9 | .project 10 | .settings 11 | 12 | # sublime text project / workspace files 13 | *.sublime-project 14 | *.sublime-workspace 15 | 16 | # windows thumbnail cache 17 | Thumbs.db 18 | 19 | # Mac DS_Store Files 20 | .DS_Store 21 | 22 | # composer vendor dir 23 | /vendor 24 | 25 | # Codeception C3 26 | c3.php 27 | -------------------------------------------------------------------------------- /src/Api/Shared/Presenter/AsIsPresenter.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final readonly class AsIsPresenter implements PresenterInterface 13 | { 14 | public function present(mixed $value, DataResponse $response): DataResponse 15 | { 16 | return $response->withData($value); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docker/test/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | dockerfile: docker/Dockerfile 5 | context: .. 6 | target: dev 7 | args: 8 | USER_ID: ${UID} 9 | GROUP_ID: ${GID} 10 | env_file: 11 | - path: ./test/.env 12 | - path: ./test/override.env 13 | required: false 14 | volumes: 15 | - ../:/app 16 | - ../runtime:/app/runtime 17 | - caddy_data:/data 18 | - caddy_config:/config 19 | tty: true 20 | -------------------------------------------------------------------------------- /yii: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 19 | -------------------------------------------------------------------------------- /config/common/di/router.php: -------------------------------------------------------------------------------- 1 | 14 | static fn(RouteCollectorInterface $collector) => new RouteCollection( 15 | $collector->addRoute(...$config->get('routes')), 16 | ), 17 | ]; 18 | -------------------------------------------------------------------------------- /codeception.yml: -------------------------------------------------------------------------------- 1 | namespace: App\Tests 2 | support_namespace: Support 3 | bootstrap: bootstrap.php 4 | 5 | settings: 6 | shuffle: true 7 | colors: true 8 | 9 | paths: 10 | tests: tests 11 | output: tests/_output 12 | data: tests/Support/Data 13 | support: tests/Support 14 | 15 | coverage: 16 | enabled: true 17 | show_uncovered: true 18 | show_only_summary: true 19 | include: 20 | - src/* 21 | - public/index.php 22 | - yii 23 | 24 | extensions: 25 | enabled: 26 | - Codeception\Extension\RunFailed 27 | -------------------------------------------------------------------------------- /config/common/di/hydrator.php: -------------------------------------------------------------------------------- 1 | ContainerAttributeResolverFactory::class, 12 | ObjectFactoryInterface::class => ContainerObjectFactory::class, 13 | ]; 14 | -------------------------------------------------------------------------------- /docker/dev/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | dockerfile: docker/Dockerfile 5 | context: .. 6 | target: dev 7 | args: 8 | USER_ID: ${UID} 9 | GROUP_ID: ${GID} 10 | env_file: 11 | - path: ./dev/.env 12 | - path: ./dev/override.env 13 | required: false 14 | restart: unless-stopped 15 | ports: 16 | - "${DEV_PORT:-80}:80" 17 | volumes: 18 | - ../:/app 19 | - ../runtime:/app/runtime 20 | - caddy_data:/data 21 | - caddy_config:/config 22 | tty: true 23 | 24 | -------------------------------------------------------------------------------- /config/common/aliases.php: -------------------------------------------------------------------------------- 1 | dirname(__DIR__, 2), 7 | '@assets' => '@public/assets', 8 | '@assetsUrl' => '@baseUrl/assets', 9 | '@baseUrl' => '/', 10 | '@data' => '@root/data', 11 | '@messages' => '@resources/messages', 12 | '@public' => '@root/public', 13 | '@resources' => '@root/resources', 14 | '@runtime' => '@root/runtime', 15 | '@src' => '@root/src', 16 | '@tests' => '@root/tests', 17 | '@views' => '@root/views', 18 | '@vendor' => '@root/vendor', 19 | ]; 20 | -------------------------------------------------------------------------------- /src/Api/Shared/Presenter/ValidationResultPresenter.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final readonly class ValidationResultPresenter implements PresenterInterface 14 | { 15 | public function present(mixed $value, DataResponse $response): DataResponse 16 | { 17 | return $response->withData( 18 | $value->getErrorMessagesIndexedByPath(), 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config/common/di/logger.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'class' => Logger::class, 16 | '__construct()' => [ 17 | 'targets' => ReferencesArray::from([ 18 | FileTarget::class, 19 | StreamTarget::class, 20 | ]), 21 | ], 22 | ], 23 | ]; 24 | -------------------------------------------------------------------------------- /src/Api/IndexAction.php: -------------------------------------------------------------------------------- 1 | success([ 18 | 'name' => $applicationParams->name, 19 | 'version' => $applicationParams->version, 20 | ]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Api/NotFoundCest.php: -------------------------------------------------------------------------------- 1 | sendGET('/not_found_page'); 15 | $I->seeResponseCodeIs(HttpCode::NOT_FOUND); 16 | $I->seeResponseIsJson(); 17 | $I->seeResponseContainsJson( 18 | [ 19 | 'status' => 'failed', 20 | 'error_message' => 'Not found.', 21 | ], 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 12 | __DIR__ . '/src', 13 | __DIR__ . '/tests', 14 | ]) 15 | ->withPhpSets(php82: true) 16 | ->withRules([ 17 | InlineConstructorDefaultToPropertyRector::class, 18 | ]) 19 | ->withSkip([ 20 | ClosureToArrowFunctionRector::class, 21 | ReadOnlyPropertyRector::class, 22 | ]); 23 | -------------------------------------------------------------------------------- /src/Api/Shared/NotFoundMiddleware.php: -------------------------------------------------------------------------------- 1 | responseFactory->notFound(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Console/HelloCommand.php: -------------------------------------------------------------------------------- 1 | writeln('Hello!'); 22 | return ExitCode::OK; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Api/IndexCest.php: -------------------------------------------------------------------------------- 1 | sendGET('/'); 15 | $I->seeResponseCodeIs(HttpCode::OK); 16 | $I->seeResponseIsJson(); 17 | $I->seeResponseContainsJson( 18 | [ 19 | 'status' => 'success', 20 | 'data' => [ 21 | 'name' => 'My Project', 22 | 'version' => '1.0', 23 | ], 24 | ], 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Support/ApiTester.php: -------------------------------------------------------------------------------- 1 | in([ 14 | $root . '/config', 15 | $root . '/src', 16 | $root . '/tests', 17 | ]) 18 | ->append([ 19 | $root . '/public/index.php', 20 | ]); 21 | 22 | return (new Config()) 23 | ->setCacheFile(__DIR__ . '/runtime/cache/.php-cs-fixer.cache') 24 | ->setParallelConfig(ParallelConfigFactory::detect()) 25 | ->setRules([ 26 | '@PER-CS2.0' => true, 27 | 'no_unused_imports' => true, 28 | ]) 29 | ->setFinder($finder); 30 | -------------------------------------------------------------------------------- /tests/Support/UnitTester.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final readonly class CollectionPresenter implements PresenterInterface 13 | { 14 | public function __construct( 15 | private PresenterInterface $itemPresenter = new AsIsPresenter(), 16 | ) {} 17 | 18 | public function present(mixed $value, DataResponse $response): DataResponse 19 | { 20 | $result = []; 21 | foreach ($value as $item) { 22 | $response = $this->itemPresenter->present($item, $response); 23 | $result[] = $response->getData(); 24 | } 25 | return $response->withData($result); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Api/Shared/Presenter/SuccessPresenter.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final readonly class SuccessPresenter implements PresenterInterface 14 | { 15 | public function __construct( 16 | private PresenterInterface $presenter = new AsIsPresenter(), 17 | ) {} 18 | 19 | public function present(mixed $value, DataResponse $response): DataResponse 20 | { 21 | $response = $this->presenter->present($value, $response); 22 | return $response 23 | ->withData([ 24 | 'status' => 'success', 25 | 'data' => $response->getData(), 26 | ]) 27 | ->withStatus(Status::OK); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Functional/HomePageCest.php: -------------------------------------------------------------------------------- 1 | sendRequest( 18 | new ServerRequest(uri: '/'), 19 | ); 20 | 21 | $output = $response->getBody()->getContents(); 22 | assertJson($output); 23 | 24 | assertSame( 25 | [ 26 | 'status' => 'success', 27 | 'data' => ['name' => 'My Project', 'version' => '1.0'], 28 | ], 29 | json_decode($output, true), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /composer-dependency-analyser.php: -------------------------------------------------------------------------------- 1 | disableComposerAutoloadPathScan() 11 | ->setFileExtensions(['php']) 12 | ->addPathToScan($root . '/src', isDev: false) 13 | ->addPathToScan($root . '/config', isDev: false) 14 | ->addPathToScan($root . '/public/index.php', isDev: false) 15 | ->addPathToScan($root . '/yii', isDev: false) 16 | ->addPathToScan($root . '/tests', isDev: true) 17 | ->ignoreErrorsOnPackages( 18 | ['yiisoft/di', 'yiisoft/data'], 19 | [ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV], 20 | ) 21 | ->ignoreErrorsOnPackages( 22 | ['psr/container', 'yiisoft/config', 'yiisoft/aliases', 'yiisoft/request-provider', 'yiisoft/router-fastroute'], 23 | [ErrorType::UNUSED_DEPENDENCY], 24 | ); 25 | -------------------------------------------------------------------------------- /docker/prod/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: ${IMAGE}:${IMAGE_TAG} 4 | networks: 5 | - caddy_public 6 | volumes: 7 | - runtime:/app/runtime 8 | - caddy_data:/data 9 | - caddy_config:/config 10 | env_file: 11 | - path: ./dev/.env 12 | - path: ./dev/override.env 13 | required: false 14 | deploy: 15 | replicas: 2 16 | update_config: 17 | delay: 10s 18 | parallelism: 1 19 | order: start-first 20 | failure_action: rollback 21 | monitor: 10s 22 | rollback_config: 23 | parallelism: 0 24 | order: stop-first 25 | restart_policy: 26 | condition: on-failure 27 | delay: 5s 28 | max_attempts: 3 29 | window: 120s 30 | labels: 31 | caddy: ${PROD_HOST:-app-api.example.com} 32 | caddy.reverse_proxy: "{{upstreams 80}}" 33 | 34 | volumes: 35 | runtime: 36 | 37 | networks: 38 | caddy_public: 39 | external: true 40 | -------------------------------------------------------------------------------- /tests/Support/ConsoleTester.php: -------------------------------------------------------------------------------- 1 | runShellCommand( 33 | dirname(__DIR__, 2) . '/yii' . ($parameters === null ? '' : (' ' . $parameters)), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/web/di/psr17.php: -------------------------------------------------------------------------------- 1 | RequestFactory::class, 20 | ServerRequestFactoryInterface::class => ServerRequestFactory::class, 21 | ResponseFactoryInterface::class => ResponseFactory::class, 22 | StreamFactoryInterface::class => StreamFactory::class, 23 | UriFactoryInterface::class => UriFactory::class, 24 | UploadedFileFactoryInterface::class => UploadedFileFactory::class, 25 | ]; 26 | -------------------------------------------------------------------------------- /src/Api/Shared/Presenter/OffsetPaginatorPresenter.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final readonly class OffsetPaginatorPresenter implements PresenterInterface 14 | { 15 | private CollectionPresenter $collectionPresenter; 16 | 17 | public function __construct( 18 | PresenterInterface $itemPresenter = new AsIsPresenter(), 19 | ) { 20 | $this->collectionPresenter = new CollectionPresenter($itemPresenter); 21 | } 22 | 23 | public function present(mixed $value, DataResponse $response): DataResponse 24 | { 25 | $collectionResponse = $this->collectionPresenter->present($value->read(), $response); 26 | return $collectionResponse->withData([ 27 | 'items' => $collectionResponse->getData(), 28 | 'pageSize' => $value->getPageSize(), 29 | 'currentPage' => $value->getCurrentPage(), 30 | 'totalPages' => $value->getTotalPages(), 31 | ]); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /config/common/di/error-handler.php: -------------------------------------------------------------------------------- 1 | [ 14 | '__construct()' => [ 15 | 'traceLink' => static function (string $file, int|null $line) use ($params): string|null { 16 | if (!isset($params['traceLink'])) { 17 | return null; 18 | } 19 | 20 | try { 21 | $hostPath = Environment::appHostPath(); 22 | if ($hostPath !== null) { 23 | /** @var string $file */ 24 | $file = preg_replace('~^(/app/)~', rtrim($hostPath, '\\/') . '/', $file); 25 | } 26 | return str_replace( 27 | ['{file}', '{line}'], 28 | [$file, (string) $line], 29 | $params['traceLink'], 30 | ); 31 | } catch (Throwable) { 32 | return null; 33 | } 34 | }, 35 | ], 36 | ], 37 | ]; 38 | -------------------------------------------------------------------------------- /src/Api/Shared/Presenter/FailPresenter.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final readonly class FailPresenter implements PresenterInterface 14 | { 15 | public function __construct( 16 | private string $message = 'Unknown error.', 17 | private int|null $code = null, 18 | private int $httpCode = Status::BAD_REQUEST, 19 | private PresenterInterface $presenter = new AsIsPresenter(), 20 | ) {} 21 | 22 | public function present(mixed $value, DataResponse $response): DataResponse 23 | { 24 | $response = $this->presenter->present($value, $response); 25 | $result = [ 26 | 'status' => 'failed', 27 | 'error_message' => $this->message, 28 | ]; 29 | if ($this->code !== null) { 30 | $result['error_code'] = $this->code; 31 | } 32 | if ($value !== null) { 33 | $result['error_data'] = $response->getData(); 34 | } 35 | return $response->withData($result)->withStatus($this->httpCode); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Support/FunctionalTester.php: -------------------------------------------------------------------------------- 1 | runAndGetResponse($request); 47 | 48 | $body = $response->getBody(); 49 | if ($body->isSeekable()) { 50 | $body->rewind(); 51 | } 52 | 53 | return $response; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM composer/composer:2-bin AS composer 2 | 3 | FROM dunglas/frankenphp:1-php8.2-bookworm AS base 4 | 5 | RUN apt update && apt -y install \ 6 | unzip 7 | 8 | RUN install-php-extensions \ 9 | opcache \ 10 | mbstring \ 11 | intl \ 12 | dom \ 13 | ctype \ 14 | curl \ 15 | phar \ 16 | openssl \ 17 | xml \ 18 | xmlwriter \ 19 | simplexml \ 20 | pdo 21 | 22 | # 23 | # Development 24 | # 25 | 26 | FROM base AS dev 27 | ARG USER_ID=10001 28 | ARG GROUP_ID=10001 29 | ARG USER_NAME=appuser 30 | ARG GROUP_NAME=appuser 31 | 32 | RUN install-php-extensions \ 33 | xdebug 34 | 35 | COPY --from=composer /composer /usr/bin/composer 36 | 37 | # Based on https://frankenphp.dev/docs/docker/#running-as-a-non-root-user 38 | RUN \ 39 | groupadd --gid ${GROUP_ID} ${GROUP_NAME}; \ 40 | useradd --gid ${GROUP_ID} --uid ${USER_ID} ${GROUP_NAME}; \ 41 | # Add additional capability to bind to port 80 and 443 42 | setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp; \ 43 | # Give write access to /data/caddy and /config/caddy 44 | chown -R ${USER_NAME}:${GROUP_NAME} /data/caddy && chown -R ${USER_NAME}:${GROUP_NAME} /config/caddy 45 | USER ${USER_NAME} 46 | 47 | # 48 | # Production 49 | # 50 | 51 | FROM base AS prod-builder 52 | COPY --from=composer /composer /usr/bin/composer 53 | COPY .. /app 54 | RUN --mount=type=cache,target=/tmp/cache \ 55 | composer install --no-dev --no-progress --no-interaction --classmap-authoritative && \ 56 | rm composer.lock composer.json 57 | 58 | FROM base AS prod 59 | ENV APP_ENV=prod 60 | COPY --from=prod-builder --chown=www-data:www-data /app /app 61 | USER www-data 62 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | setLevels([ 50 | LogLevel::EMERGENCY, 51 | LogLevel::ERROR, 52 | LogLevel::WARNING, 53 | ]), 54 | ], 55 | ), 56 | new JsonRenderer(), 57 | ), 58 | ); 59 | $runner->run(); 60 | -------------------------------------------------------------------------------- /src/Api/Shared/ExceptionResponderFactory.php: -------------------------------------------------------------------------------- 1 | $this->inputValidationException(...), 28 | Throwable::class => $this->throwable(...), 29 | ], 30 | $this->psrResponseFactory, 31 | $this->injector, 32 | ); 33 | } 34 | 35 | private function inputValidationException(InputValidationException $exception): ResponseInterface 36 | { 37 | return $this->apiResponseFactory->failValidation($exception->getResult()); 38 | } 39 | 40 | private function throwable(Throwable $exception): ResponseInterface 41 | { 42 | if (UserException::isUserException($exception)) { 43 | $code = $exception->getCode(); 44 | return $this->apiResponseFactory->fail( 45 | $exception->getMessage(), 46 | code: is_int($code) ? $code : null, 47 | ); 48 | } 49 | throw $exception; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config/configuration.php: -------------------------------------------------------------------------------- 1 | [ 10 | 'params' => 'common/params.php', 11 | 'params-web' => [ 12 | '$params', 13 | 'web/params.php', 14 | ], 15 | 'params-console' => [ 16 | '$params', 17 | 'console/params.php', 18 | ], 19 | 'di' => 'common/di/*.php', 20 | 'di-web' => [ 21 | '$di', 22 | 'web/di/*.php', 23 | ], 24 | 'di-console' => '$di', 25 | 'di-delegates' => [], 26 | 'di-delegates-console' => '$di-delegates', 27 | 'di-delegates-web' => '$di-delegates', 28 | 'di-providers' => [], 29 | 'di-providers-console' => '$di-providers', 30 | 'di-providers-web' => '$di-providers', 31 | 'events' => [], 32 | 'events-console' => '$events', 33 | 'events-web' => '$events', 34 | 'bootstrap' => [], 35 | 'bootstrap-console' => '$bootstrap', 36 | 'bootstrap-web' => '$bootstrap', 37 | 'routes' => 'common/routes.php', 38 | ], 39 | 'config-plugin-environments' => [ 40 | Environment::DEV => [ 41 | 'params' => [ 42 | 'environments/dev/params.php', 43 | ], 44 | ], 45 | Environment::TEST => [ 46 | 'params' => [ 47 | 'environments/test/params.php', 48 | ], 49 | ], 50 | Environment::PROD => [ 51 | 'params' => [ 52 | 'environments/prod/params.php', 53 | ], 54 | ], 55 | ], 56 | 'config-plugin-options' => [ 57 | 'source-directory' => 'config', 58 | ], 59 | ]; 60 | -------------------------------------------------------------------------------- /src/Api/Shared/ResponseFactory.php: -------------------------------------------------------------------------------- 1 | present($data, $this->dataResponseFactory->createResponse()); 29 | } 30 | 31 | public function fail( 32 | string $message, 33 | array|object|null $data = null, 34 | int|null $code = null, 35 | int $httpCode = Status::BAD_REQUEST, 36 | PresenterInterface $presenter = new AsIsPresenter(), 37 | ): ResponseInterface { 38 | return (new FailPresenter($message, $code, $httpCode, $presenter)) 39 | ->present($data, $this->dataResponseFactory->createResponse()); 40 | } 41 | 42 | public function notFound(string $message = 'Not found.'): ResponseInterface 43 | { 44 | return $this->fail($message, httpCode: Status::NOT_FOUND); 45 | } 46 | 47 | public function failValidation(Result $result): ResponseInterface 48 | { 49 | return $this->fail( 50 | 'Validation failed.', 51 | $result, 52 | httpCode: Status::UNPROCESSABLE_ENTITY, 53 | presenter: new ValidationResultPresenter(), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /config/web/di/application.php: -------------------------------------------------------------------------------- 1 | [ 27 | '__construct()' => [ 28 | 'dispatcher' => DynamicReference::to([ 29 | 'class' => MiddlewareDispatcher::class, 30 | 'withMiddlewares()' => [ 31 | [ 32 | FormatDataResponseAsJson::class, 33 | static fn() => new ContentNegotiator([ 34 | 'application/xml' => new XmlDataResponseFormatter(), 35 | 'application/json' => new JsonDataResponseFormatter(), 36 | ]), 37 | ErrorCatcher::class, 38 | static fn(ExceptionResponderFactory $factory) => $factory->create(), 39 | RequestBodyParser::class, 40 | Router::class, 41 | NotFoundMiddleware::class, 42 | ], 43 | ], 44 | ]), 45 | ], 46 | ], 47 | 48 | ParametersResolverInterface::class => [ 49 | 'class' => CompositeParametersResolver::class, 50 | '__construct()' => [ 51 | Reference::to(HydratorAttributeParametersResolver::class), 52 | Reference::to(RequestInputParametersResolver::class), 53 | ], 54 | ], 55 | ]; 56 | -------------------------------------------------------------------------------- /tests/Unit/Api/Shared/Presenter/OffsetPaginatorPresenterTest.php: -------------------------------------------------------------------------------- 1 | 1, 'name' => 'Item 1'], 24 | ['id' => 2, 'name' => 'Item 2'], 25 | ['id' => 3, 'name' => 'Item 3'], 26 | ['id' => 4, 'name' => 'Item 4'], 27 | ['id' => 5, 'name' => 'Item 5'], 28 | ]), 29 | )) 30 | ->withPageSize(2) 31 | ->withCurrentPage(2); 32 | $presenter = new OffsetPaginatorPresenter(); 33 | 34 | $result = $presenter->present($paginator, $this->createDataResponse()); 35 | 36 | $this->assertSame( 37 | [ 38 | 'items' => [ 39 | ['id' => 3, 'name' => 'Item 3'], 40 | ['id' => 4, 'name' => 'Item 4'], 41 | ], 42 | 'pageSize' => 2, 43 | 'currentPage' => 2, 44 | 'totalPages' => 3, 45 | ], 46 | $result->getData(), 47 | ); 48 | } 49 | 50 | public function testItemPresenter(): void 51 | { 52 | $paginator = new OffsetPaginator( 53 | new IterableDataReader([ 54 | ['id' => 1, 'name' => 'Item 1'], 55 | ['id' => 2, 'name' => 'Item 2'], 56 | ]), 57 | ); 58 | $presenter = new OffsetPaginatorPresenter( 59 | new class implements PresenterInterface { 60 | public function present(mixed $value, DataResponse $response): DataResponse 61 | { 62 | return $response->withData($value['name']); 63 | } 64 | }, 65 | ); 66 | 67 | $result = $presenter->present($paginator, $this->createDataResponse()); 68 | 69 | $this->assertSame( 70 | [ 71 | 'items' => ['Item 1', 'Item 2'], 72 | 'pageSize' => 10, 73 | 'currentPage' => 1, 74 | 'totalPages' => 1, 75 | ], 76 | $result->getData(), 77 | ); 78 | } 79 | 80 | private function createDataResponse(): DataResponse 81 | { 82 | return new DataResponse( 83 | '', 84 | Status::OK, 85 | '', 86 | new ResponseFactory(), 87 | new StreamFactory(), 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/Unit/Api/Shared/ResponseFactoryTest.php: -------------------------------------------------------------------------------- 1 | createResponseFactory() 21 | ->success(['name' => 'test']); 22 | 23 | $this->assertInstanceOf(DataResponse::class, $response); 24 | $this->assertSame( 25 | [ 26 | 'status' => 'success', 27 | 'data' => ['name' => 'test'], 28 | ], 29 | $response->getData(), 30 | ); 31 | } 32 | 33 | public function testFail(): void 34 | { 35 | $response = $this 36 | ->createResponseFactory() 37 | ->fail('error text'); 38 | 39 | $this->assertInstanceOf(DataResponse::class, $response); 40 | $this->assertSame( 41 | [ 42 | 'status' => 'failed', 43 | 'error_message' => 'error text', 44 | ], 45 | $response->getData(), 46 | ); 47 | } 48 | 49 | public function testNotFound(): void 50 | { 51 | $response = $this 52 | ->createResponseFactory() 53 | ->notFound(); 54 | 55 | $this->assertInstanceOf(DataResponse::class, $response); 56 | $this->assertSame( 57 | [ 58 | 'status' => 'failed', 59 | 'error_message' => 'Not found.', 60 | ], 61 | $response->getData(), 62 | ); 63 | } 64 | 65 | public function testFailValidation(): void 66 | { 67 | $result = (new Result()) 68 | ->addError('error1', valuePath: ['name']) 69 | ->addError('error2', valuePath: ['name']) 70 | ->addError('error3', valuePath: ['age']); 71 | $response = $this 72 | ->createResponseFactory() 73 | ->failValidation($result); 74 | 75 | $this->assertInstanceOf(DataResponse::class, $response); 76 | $this->assertSame( 77 | [ 78 | 'status' => 'failed', 79 | 'error_message' => 'Validation failed.', 80 | 'error_data' => [ 81 | 'name' => ['error1', 'error2'], 82 | 'age' => ['error3'], 83 | ], 84 | ], 85 | $response->getData(), 86 | ); 87 | } 88 | 89 | private function createResponseFactory(): ResponseFactory 90 | { 91 | return new ResponseFactory( 92 | new DataResponseFactory( 93 | new PsrResponseFactory(), 94 | new StreamFactory(), 95 | ), 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/Unit/Api/Shared/ExceptionResponderFactoryTest.php: -------------------------------------------------------------------------------- 1 | addError('error1', valuePath: ['name']), 36 | ); 37 | } 38 | }; 39 | 40 | $response = $this->createExceptionResponder()->process($request, $handler); 41 | 42 | $this->assertInstanceOf(DataResponse::class, $response); 43 | $this->assertSame( 44 | [ 45 | 'status' => 'failed', 46 | 'error_message' => 'Validation failed.', 47 | 'error_data' => [ 48 | 'name' => ['error1'], 49 | ], 50 | ], 51 | $response->getData(), 52 | ); 53 | } 54 | 55 | public function testUserException(): void 56 | { 57 | $request = new ServerRequest(); 58 | $handler = new class implements RequestHandlerInterface { 59 | public function handle(ServerRequestInterface $request): ResponseInterface 60 | { 61 | throw new UserException('Hello, Exception!'); 62 | } 63 | }; 64 | 65 | $response = $this->createExceptionResponder()->process($request, $handler); 66 | 67 | $this->assertInstanceOf(DataResponse::class, $response); 68 | $this->assertSame( 69 | [ 70 | 'status' => 'failed', 71 | 'error_message' => 'Hello, Exception!', 72 | 'error_code' => 0, 73 | ], 74 | $response->getData(), 75 | ); 76 | } 77 | 78 | public function testOtherThrowable(): void 79 | { 80 | $request = new ServerRequest(); 81 | $handler = new class implements RequestHandlerInterface { 82 | public function handle(ServerRequestInterface $request): ResponseInterface 83 | { 84 | throw new LogicException('Hello, Exception!'); 85 | } 86 | }; 87 | 88 | $this->expectException(LogicException::class); 89 | $this->expectExceptionMessage('Hello, Exception!'); 90 | $this->createExceptionResponder()->process($request, $handler); 91 | } 92 | 93 | private function createExceptionResponder(): ExceptionResponder 94 | { 95 | return (new ExceptionResponderFactory( 96 | new PsrResponseFactory(), 97 | new ResponseFactory( 98 | new DataResponseFactory( 99 | new PsrResponseFactory(), 100 | new StreamFactory(), 101 | ), 102 | ), 103 | new Injector(new Container()), 104 | ))->create(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Environment.php: -------------------------------------------------------------------------------- 1 |