diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..a8172fe --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1 @@ +json_path: coveralls-upload.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9e4c2ee --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: composer + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 10 + versioning-strategy: increase diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2934da2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,49 @@ +name: Tests + +on: [push, pull_request] + +jobs: + tests: + name: Tests PHP ${{ matrix.php }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + php: [7.4, 8.0, 8.1] + experimental: [false] + include: + - php: 8.1 + analysis: true + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + + - name: Install dependencies with Composer + uses: ramsey/composer-install@v2 + + - name: Coding standards + if: matrix.analysis + run: vendor/bin/phpcs + + - name: Static analysis + if: matrix.analysis + run: vendor/bin/phpstan + + - name: Tests + run: vendor/bin/phpunit --coverage-clover clover.xml + + - name: Upload coverage results to Coveralls + if: matrix.analysis + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + composer require php-coveralls/php-coveralls -n -W + vendor/bin/php-coveralls --coverage_clover=clover.xml -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ebad0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea/ +.vscode/ +/coverage/ +/vendor/ +/logs/* +!/logs/README.md +.phpunit.result.cache +.DS_Store \ No newline at end of file diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..c7552bf --- /dev/null +++ b/.htaccess @@ -0,0 +1,19 @@ +Options All -Indexes + + +order allow,deny +deny from all + + + + # 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] + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c7878cb --- /dev/null +++ b/CONTRIBUTING.md @@ -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-12 standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-12-extended-coding-style-guide.md). diff --git a/README.md b/README.md new file mode 100644 index 0000000..f769efc --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# MTEX Mock API + +Simple REST API providing sample JSON data for testing and prototyping. + +## Installation + +```bash +composer install +``` + +## Local Development + +```bash +php -S localhost:8000 -t public +``` + +Visit `http://localhost:8000` to see the API documentation. + +## Endpoints + +### Documentation + +- `GET /` - API documentation + +### Users + +- `GET /users` - List all users +- `GET /users/{id}` - Get user by ID + +### Posts + +- `GET /posts` - List all posts +- `GET /posts/{id}` - Get post by ID + +### Products + +- `GET /products` - List all products +- `GET /products/{id}` - Get product by ID + +## Example Requests + +```bash +curl https://mock.mtex.dev/users +curl https://mock.mtex.dev/users/1 +curl https://mock.mtex.dev/posts +curl https://mock.mtex.dev/products/2 +``` + +## Features + +- [x] RESTful API design +- [x] JSON responses +- [x] CORS enabled +- [x] Error handling +- [x] Self-documenting +- [x] No database required + +## Tech Stack + +- PHP 8.1+ +- Slim Framework 4 +- PSR-7 HTTP Messages + +## License + +MIT \ No newline at end of file diff --git a/app/dependencies.php b/app/dependencies.php new file mode 100644 index 0000000..fd3f066 --- /dev/null +++ b/app/dependencies.php @@ -0,0 +1,30 @@ +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; + }, + ]); +}; diff --git a/app/middleware.php b/app/middleware.php new file mode 100644 index 0000000..a3f6cf9 --- /dev/null +++ b/app/middleware.php @@ -0,0 +1,10 @@ +add(SessionMiddleware::class); +}; diff --git a/app/repositories.php b/app/repositories.php new file mode 100644 index 0000000..15f20f3 --- /dev/null +++ b/app/repositories.php @@ -0,0 +1,14 @@ +addDefinitions([ + UserRepository::class => \DI\autowire(InMemoryUserRepository::class), + ]); +}; diff --git a/app/routes.php b/app/routes.php new file mode 100644 index 0000000..17e4b82 --- /dev/null +++ b/app/routes.php @@ -0,0 +1,27 @@ +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); + }); +}; diff --git a/app/settings.php b/app/settings.php new file mode 100644 index 0000000..79392bd --- /dev/null +++ b/app/settings.php @@ -0,0 +1,27 @@ +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, + ], + ]); + } + ]); +}; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d32875e --- /dev/null +++ b/composer.json @@ -0,0 +1,15 @@ +{ + "name": "mtex/mock-api", + "description": "Simple REST API providing sample JSON data", + "type": "project", + "require": { + "php": "^8.1", + "slim/slim": "^4.12", + "slim/psr7": "^1.6" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..b62b333 --- /dev/null +++ b/composer.lock @@ -0,0 +1,690 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "e6111b07c655e23e9706a7e73e61a3b4", + "packages": [ + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "slim/psr7", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Slim-Psr7.git", + "reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Slim-Psr7/zipball/76e7e3b1cdfd583e9035c4c966c08e01e45ce959", + "reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959", + "shasum": "" + }, + "require": { + "fig/http-message-util": "^1.1.5", + "php": "^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.0 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "^1.0", + "psr/http-message-implementation": "^1.0 || ^2.0" + }, + "require-dev": { + "adriansuter/php-autoload-override": "^1.5|| ^2.0", + "ext-json": "*", + "http-interop/http-factory-tests": "^1.0 || ^2.0", + "php-http/psr7-integration-tests": "^1.5", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.6 || ^10", + "squizlabs/php_codesniffer": "^3.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\Psr7\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "https://joshlockhart.com" + }, + { + "name": "Andrew Smith", + "email": "a.smith@silentworks.co.uk", + "homepage": "https://silentworks.co.uk" + }, + { + "name": "Rob Allen", + "email": "rob@akrabat.com", + "homepage": "https://akrabat.com" + }, + { + "name": "Pierre Berube", + "email": "pierre@lgse.com", + "homepage": "https://www.lgse.com" + } + ], + "description": "Strict PSR-7 implementation", + "homepage": "https://www.slimframework.com", + "keywords": [ + "http", + "psr-7", + "psr7" + ], + "support": { + "issues": "https://github.com/slimphp/Slim-Psr7/issues", + "source": "https://github.com/slimphp/Slim-Psr7/tree/1.8.0" + }, + "time": "2025-11-02T17:51:19+00:00" + }, + { + "name": "slim/slim", + "version": "4.15.1", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Slim.git", + "reference": "887893516557506f254d950425ce7f5387a26970" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Slim/zipball/887893516557506f254d950425ce7f5387a26970", + "reference": "887893516557506f254d950425ce7f5387a26970", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/fast-route": "^1.3", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.0 || ^2.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "adriansuter/php-autoload-override": "^1.4 || ^2", + "ext-simplexml": "*", + "guzzlehttp/psr7": "^2.6", + "httpsoft/http-message": "^1.1", + "httpsoft/http-server-request": "^1.1", + "laminas/laminas-diactoros": "^2.17 || ^3", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "phpspec/prophecy": "^1.19", + "phpspec/prophecy-phpunit": "^2.1", + "phpstan/phpstan": "^1 || ^2", + "phpunit/phpunit": "^9.6 || ^10 || ^11 || ^12", + "slim/http": "^1.3", + "slim/psr7": "^1.6", + "squizlabs/php_codesniffer": "^3.10", + "vimeo/psalm": "^5 || ^6" + }, + "suggest": { + "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware", + "ext-xml": "Needed to support XML format in BodyParsingMiddleware", + "php-di/php-di": "PHP-DI is the recommended container library to be used with Slim", + "slim/psr7": "Slim PSR-7 implementation. See https://www.slimframework.com/docs/v4/start/installation.html for more information." + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\": "Slim" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "https://joshlockhart.com" + }, + { + "name": "Andrew Smith", + "email": "a.smith@silentworks.co.uk", + "homepage": "https://silentworks.co.uk" + }, + { + "name": "Rob Allen", + "email": "rob@akrabat.com", + "homepage": "https://akrabat.com" + }, + { + "name": "Pierre Berube", + "email": "pierre@lgse.com", + "homepage": "https://www.lgse.com" + }, + { + "name": "Gabriel Manricks", + "email": "gmanricks@me.com", + "homepage": "http://gabrielmanricks.com" + } + ], + "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs", + "homepage": "https://www.slimframework.com", + "keywords": [ + "api", + "framework", + "micro", + "router" + ], + "support": { + "docs": "https://www.slimframework.com/docs/v4/", + "forum": "https://discourse.slimframework.com/", + "irc": "irc://irc.freenode.net:6667/slimphp", + "issues": "https://github.com/slimphp/Slim/issues", + "rss": "https://www.slimframework.com/blog/feed.rss", + "slack": "https://slimphp.slack.com/", + "source": "https://github.com/slimphp/Slim", + "wiki": "https://github.com/slimphp/Slim/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/slimphp", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/slim/slim", + "type": "tidelift" + } + ], + "time": "2025-11-21T12:23:44+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.1" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..28e6f2b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.7' + +volumes: + logs: + driver: local + +services: + slim: + image: php:7-alpine + working_dir: /var/www + command: php -S 0.0.0.0:8080 -t public + environment: + docker: "true" + ports: + - "8080:8080" + volumes: + - .:/var/www + - logs:/var/www/logs diff --git a/logs/README.md b/logs/README.md new file mode 100644 index 0000000..d4a602e --- /dev/null +++ b/logs/README.md @@ -0,0 +1 @@ +Your Slim Framework application's log files will be written to this directory. diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..e5c3d04 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,17 @@ + + + Slim coding standard + + + + + + + + + + + + src + tests + \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..2e09595 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,4 @@ +parameters: + level: 4 + paths: + - src diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..f830400 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,27 @@ + + + + ./tests/ + + + + + ./src/ + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..11b28de --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] \ No newline at end of file diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..2a97b97 --- /dev/null +++ b/public/index.php @@ -0,0 +1,50 @@ +addErrorMiddleware(true, true, true); + +// CORS Middleware +$app->add(function (Request $request, $handler) { + $response = $handler->handle($request); + return $response + ->withHeader('Access-Control-Allow-Origin', '*') + ->withHeader('Access-Control-Allow-Headers', 'Content-Type') + ->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); +}); + +// API Documentation +$app->get('/', function (Request $request, Response $response) { + $docs = [ + 'service' => 'MTEX Mock API', + 'version' => '1.0.0', + 'description' => 'Simple REST API providing sample JSON data', + 'endpoints' => [ + 'GET /' => 'API documentation', + 'GET /users' => 'List all users', + 'GET /users/{id}' => 'Get user by ID', + 'GET /posts' => 'List all posts', + 'GET /posts/{id}' => 'Get post by ID', + 'GET /products' => 'List all products', + 'GET /products/{id}' => 'Get product by ID', + ], + 'examples' => [ + 'https://mock.mtex.dev/users', + 'https://mock.mtex.dev/users/1', + 'https://mock.mtex.dev/posts', + 'https://mock.mtex.dev/products', + ], + ]; + + $response->getBody()->write(json_encode($docs, JSON_PRETTY_PRINT)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +require __DIR__ . '/../src/routes.php'; + +$app->run(); \ No newline at end of file diff --git a/src/Application/Actions/Action.php b/src/Application/Actions/Action.php new file mode 100644 index 0000000..1021c20 --- /dev/null +++ b/src/Application/Actions/Action.php @@ -0,0 +1,92 @@ +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()); + } +} diff --git a/src/Application/Actions/ActionError.php b/src/Application/Actions/ActionError.php new file mode 100644 index 0000000..3a81cc5 --- /dev/null +++ b/src/Application/Actions/ActionError.php @@ -0,0 +1,61 @@ +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 + { + return [ + 'type' => $this->type, + 'description' => $this->description, + ]; + } +} diff --git a/src/Application/Actions/ActionPayload.php b/src/Application/Actions/ActionPayload.php new file mode 100644 index 0000000..cbf5110 --- /dev/null +++ b/src/Application/Actions/ActionPayload.php @@ -0,0 +1,63 @@ +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; + } +} diff --git a/src/Application/Actions/User/ListUsersAction.php b/src/Application/Actions/User/ListUsersAction.php new file mode 100644 index 0000000..7ca74d7 --- /dev/null +++ b/src/Application/Actions/User/ListUsersAction.php @@ -0,0 +1,22 @@ +userRepository->findAll(); + + $this->logger->info("Users list was viewed."); + + return $this->respondWithData($users); + } +} diff --git a/src/Application/Actions/User/UserAction.php b/src/Application/Actions/User/UserAction.php new file mode 100644 index 0000000..4ea2d8c --- /dev/null +++ b/src/Application/Actions/User/UserAction.php @@ -0,0 +1,20 @@ +userRepository = $userRepository; + } +} diff --git a/src/Application/Actions/User/ViewUserAction.php b/src/Application/Actions/User/ViewUserAction.php new file mode 100644 index 0000000..711cf0a --- /dev/null +++ b/src/Application/Actions/User/ViewUserAction.php @@ -0,0 +1,23 @@ +resolveArg('id'); + $user = $this->userRepository->findUserOfId($userId); + + $this->logger->info("User of id `${userId}` was viewed."); + + return $this->respondWithData($user); + } +} diff --git a/src/Application/Handlers/HttpErrorHandler.php b/src/Application/Handlers/HttpErrorHandler.php new file mode 100644 index 0000000..64e02b1 --- /dev/null +++ b/src/Application/Handlers/HttpErrorHandler.php @@ -0,0 +1,69 @@ +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'); + } +} diff --git a/src/Application/Handlers/ShutdownHandler.php b/src/Application/Handlers/ShutdownHandler.php new file mode 100644 index 0000000..dc54b27 --- /dev/null +++ b/src/Application/Handlers/ShutdownHandler.php @@ -0,0 +1,74 @@ +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); + } + } +} diff --git a/src/Application/Middleware/SessionMiddleware.php b/src/Application/Middleware/SessionMiddleware.php new file mode 100644 index 0000000..d7dc923 --- /dev/null +++ b/src/Application/Middleware/SessionMiddleware.php @@ -0,0 +1,26 @@ +withAttribute('session', $_SESSION); + } + + return $handler->handle($request); + } +} diff --git a/src/Application/ResponseEmitter/ResponseEmitter.php b/src/Application/ResponseEmitter/ResponseEmitter.php new file mode 100644 index 0000000..8a27ae1 --- /dev/null +++ b/src/Application/ResponseEmitter/ResponseEmitter.php @@ -0,0 +1,38 @@ +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); + } +} diff --git a/src/Application/Settings/Settings.php b/src/Application/Settings/Settings.php new file mode 100644 index 0000000..6da55e4 --- /dev/null +++ b/src/Application/Settings/Settings.php @@ -0,0 +1,23 @@ +settings = $settings; + } + + /** + * @return mixed + */ + public function get(string $key = '') + { + return (empty($key)) ? $this->settings : $this->settings[$key]; + } +} diff --git a/src/Application/Settings/SettingsInterface.php b/src/Application/Settings/SettingsInterface.php new file mode 100644 index 0000000..557d98b --- /dev/null +++ b/src/Application/Settings/SettingsInterface.php @@ -0,0 +1,14 @@ +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, + ]; + } +} diff --git a/src/Domain/User/UserNotFoundException.php b/src/Domain/User/UserNotFoundException.php new file mode 100644 index 0000000..ae3d11e --- /dev/null +++ b/src/Domain/User/UserNotFoundException.php @@ -0,0 +1,12 @@ +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]; + } +} diff --git a/src/data/posts.php b/src/data/posts.php new file mode 100644 index 0000000..446f605 --- /dev/null +++ b/src/data/posts.php @@ -0,0 +1,28 @@ + 1, + 'userId' => 1, + 'title' => 'Getting Started with Slim PHP', + 'body' => 'Slim is a PHP micro framework that helps you write simple web applications and APIs.', + 'tags' => ['php', 'slim', 'tutorial'], + 'published' => true, + ], + [ + 'id' => 2, + 'userId' => 2, + 'title' => 'REST API Best Practices', + 'body' => 'Learn how to design clean and efficient REST APIs.', + 'tags' => ['api', 'rest', 'best-practices'], + 'published' => true, + ], + [ + 'id' => 3, + 'userId' => 1, + 'title' => 'Mock Data for Testing', + 'body' => 'Why using mock data is essential for rapid development.', + 'tags' => ['testing', 'mock', 'development'], + 'published' => false, + ], +]; \ No newline at end of file diff --git a/src/data/products.php b/src/data/products.php new file mode 100644 index 0000000..c7cbf79 --- /dev/null +++ b/src/data/products.php @@ -0,0 +1,36 @@ + 1, + 'name' => 'Wireless Headphones', + 'price' => 79.99, + 'category' => 'Electronics', + 'inStock' => true, + 'rating' => 4.5, + ], + [ + 'id' => 2, + 'name' => 'Coffee Maker', + 'price' => 49.99, + 'category' => 'Home & Kitchen', + 'inStock' => true, + 'rating' => 4.2, + ], + [ + 'id' => 3, + 'name' => 'Running Shoes', + 'price' => 89.99, + 'category' => 'Sports', + 'inStock' => false, + 'rating' => 4.8, + ], + [ + 'id' => 4, + 'name' => 'Desk Lamp', + 'price' => 29.99, + 'category' => 'Home & Office', + 'inStock' => true, + 'rating' => 4.0, + ], +]; \ No newline at end of file diff --git a/src/data/users.php b/src/data/users.php new file mode 100644 index 0000000..322f991 --- /dev/null +++ b/src/data/users.php @@ -0,0 +1,32 @@ + 1, + 'name' => 'Alice Johnson', + 'email' => 'alice@example.mtex.dev', + 'role' => 'admin', + 'active' => true, + ], + [ + 'id' => 2, + 'name' => 'Bob Smith', + 'email' => 'bob@example.mtex.dev', + 'role' => 'user', + 'active' => true, + ], + [ + 'id' => 3, + 'name' => 'Charlie Brown', + 'email' => 'charlie@example.mtex.dev', + 'role' => 'user', + 'active' => false, + ], + [ + 'id' => 4, + 'name' => 'Diana Prince', + 'email' => 'diana@example.mtex.dev', + 'role' => 'moderator', + 'active' => true, + ], +]; \ No newline at end of file diff --git a/src/routes.php b/src/routes.php new file mode 100644 index 0000000..33176d0 --- /dev/null +++ b/src/routes.php @@ -0,0 +1,86 @@ +get('/users', function (Request $request, Response $response) { + $users = require __DIR__ . '/data/users.php'; + $response->getBody()->write(json_encode($users, JSON_PRETTY_PRINT)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +$app->get('/users/{id}', function (Request $request, Response $response, $args) { + $users = require __DIR__ . '/data/users.php'; + $id = (int) $args['id']; + + $user = array_values(array_filter($users, fn($u) => $u['id'] === $id)); + + if (empty($user)) { + $error = ['error' => 'User not found']; + $response->getBody()->write(json_encode($error)); + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus(404); + } + + $response->getBody()->write(json_encode($user[0], JSON_PRETTY_PRINT)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +// Posts Routes +$app->get('/posts', function (Request $request, Response $response) { + $posts = require __DIR__ . '/data/posts.php'; + $response->getBody()->write(json_encode($posts, JSON_PRETTY_PRINT)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +$app->get('/posts/{id}', function (Request $request, Response $response, $args) { + $posts = require __DIR__ . '/data/posts.php'; + $id = (int) $args['id']; + + $post = array_values(array_filter($posts, fn($p) => $p['id'] === $id)); + + if (empty($post)) { + $error = ['error' => 'Post not found']; + $response->getBody()->write(json_encode($error)); + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus(404); + } + + $response->getBody()->write(json_encode($post[0], JSON_PRETTY_PRINT)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +// Products Routes +$app->get('/products', function (Request $request, Response $response) { + $products = require __DIR__ . '/data/products.php'; + $response->getBody()->write(json_encode($products, JSON_PRETTY_PRINT)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +$app->get( + '/products/{id}', + function (Request $request, Response $response, $args) { + $products = require __DIR__ . '/data/products.php'; + $id = (int) $args['id']; + + $product = array_values( + array_filter($products, fn($p) => $p['id'] === $id) + ); + + if (empty($product)) { + $error = ['error' => 'Product not found']; + $response->getBody()->write(json_encode($error)); + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus(404); + } + + $response->getBody()->write( + json_encode($product[0], JSON_PRETTY_PRINT) + ); + return $response->withHeader('Content-Type', 'application/json'); + } +); \ No newline at end of file diff --git a/tests/Application/Actions/ActionTest.php b/tests/Application/Actions/ActionTest.php new file mode 100644 index 0000000..9881c68 --- /dev/null +++ b/tests/Application/Actions/ActionTest.php @@ -0,0 +1,79 @@ +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()); + } +} diff --git a/tests/Application/Actions/User/ListUserActionTest.php b/tests/Application/Actions/User/ListUserActionTest.php new file mode 100644 index 0000000..fcf3222 --- /dev/null +++ b/tests/Application/Actions/User/ListUserActionTest.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/tests/Application/Actions/User/ViewUserActionTest.php b/tests/Application/Actions/User/ViewUserActionTest.php new file mode 100644 index 0000000..785b6c6 --- /dev/null +++ b/tests/Application/Actions/User/ViewUserActionTest.php @@ -0,0 +1,80 @@ +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); + } +} diff --git a/tests/Domain/User/UserTest.php b/tests/Domain/User/UserTest.php new file mode 100644 index 0000000..36f4925 --- /dev/null +++ b/tests/Domain/User/UserTest.php @@ -0,0 +1,60 @@ +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)); + } +} diff --git a/tests/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php b/tests/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php new file mode 100644 index 0000000..6b51b0c --- /dev/null +++ b/tests/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php @@ -0,0 +1,53 @@ + $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); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..a55f09d --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,90 @@ +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); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..d21c14d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,3 @@ +