From c22da9c090fdd50380cb13ffd87b5498476ae28e Mon Sep 17 00:00:00 2001 From: samuelgfeller Date: Sat, 30 Mar 2024 16:41:48 +0100 Subject: [PATCH] Initial commit --- .cs.php | 58 +++ .gitattributes | 5 + .github/FUNDING.yml | 1 + .github/workflows/build.yml | 82 ++++ .gitignore | 7 + .htaccess | 6 + LICENCE.txt | 15 + README.md | 97 +++++ bin/console.php | 29 ++ composer.json | 64 +++ config/bootstrap.php | 14 + config/container.php | 132 +++++++ config/defaults.php | 96 +++++ config/env/env.dev.php | 18 + config/env/env.example.php | 19 + config/env/env.github.php | 14 + config/env/env.phinx.php | 29 ++ config/env/env.prod.php | 25 ++ config/env/env.test.php | 13 + config/functions.php | 3 + config/middleware.php | 23 ++ config/routes.php | 13 + config/settings.php | 27 ++ logs/empty | 0 phpstan.neon | 7 + phpunit.xml | 32 ++ public/.htaccess | 4 + public/frontend/favicon.ico | Bin 0 -> 103248 bytes public/frontend/home.html | 22 ++ public/frontend/script.js | 41 ++ public/frontend/style.css | 55 +++ public/index.php | 4 + ...12_db_change_1187610968660592e_09f_632.php | 64 +++ resources/schema/schema.php | 374 ++++++++++++++++++ resources/schema/schema.sql | 19 + resources/seeds/UserSeeder.php | 52 +++ .../Action/User/UserCreateAction.php | 28 ++ .../Action/User/UserFetchListAction.php | 27 ++ .../ErrorHandler/DefaultApiErrorHandler.php | 133 +++++++ src/Application/Middleware/CorsMiddleware.php | 49 +++ .../NonFatalErrorHandlerMiddleware.php | 75 ++++ src/Application/Responder/JsonEncoder.php | 28 ++ src/Domain/Exception/ValidationException.php | 70 ++++ src/Domain/User/Data/UserData.php | 45 +++ .../User/Repository/UserCreatorRepository.php | 26 ++ .../User/Repository/UserFinderRepository.php | 40 ++ src/Domain/User/Service/UserCreator.php | 28 ++ src/Domain/User/Service/UserFinder.php | 24 ++ src/Domain/User/Service/UserValidator.php | 48 +++ .../Console/SqlSchemaGenerator.php | 80 ++++ src/Infrastructure/Factory/QueryFactory.php | 120 ++++++ src/Infrastructure/Utility/Hydrator.php | 28 ++ src/Infrastructure/Utility/Settings.php | 25 ++ tests/Fixture/UserFixture.php | 37 ++ .../Integration/User/UserCreateActionTest.php | 39 ++ .../User/UserFetchListActionTest.php | 45 +++ tests/Provider/empty | 0 tests/Trait/AppTestTrait.php | 71 ++++ tests/Unit/empty | 0 59 files changed, 2530 insertions(+) create mode 100644 .cs.php create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 LICENCE.txt create mode 100644 README.md create mode 100644 bin/console.php create mode 100644 composer.json create mode 100644 config/bootstrap.php create mode 100644 config/container.php create mode 100644 config/defaults.php create mode 100644 config/env/env.dev.php create mode 100644 config/env/env.example.php create mode 100644 config/env/env.github.php create mode 100644 config/env/env.phinx.php create mode 100644 config/env/env.prod.php create mode 100644 config/env/env.test.php create mode 100644 config/functions.php create mode 100644 config/middleware.php create mode 100644 config/routes.php create mode 100644 config/settings.php create mode 100644 logs/empty create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/frontend/favicon.ico create mode 100644 public/frontend/home.html create mode 100644 public/frontend/script.js create mode 100644 public/frontend/style.css create mode 100644 public/index.php create mode 100644 resources/migrations/20240328155512_db_change_1187610968660592e_09f_632.php create mode 100644 resources/schema/schema.php create mode 100644 resources/schema/schema.sql create mode 100644 resources/seeds/UserSeeder.php create mode 100644 src/Application/Action/User/UserCreateAction.php create mode 100644 src/Application/Action/User/UserFetchListAction.php create mode 100644 src/Application/ErrorHandler/DefaultApiErrorHandler.php create mode 100644 src/Application/Middleware/CorsMiddleware.php create mode 100644 src/Application/Middleware/NonFatalErrorHandlerMiddleware.php create mode 100644 src/Application/Responder/JsonEncoder.php create mode 100644 src/Domain/Exception/ValidationException.php create mode 100644 src/Domain/User/Data/UserData.php create mode 100644 src/Domain/User/Repository/UserCreatorRepository.php create mode 100644 src/Domain/User/Repository/UserFinderRepository.php create mode 100644 src/Domain/User/Service/UserCreator.php create mode 100644 src/Domain/User/Service/UserFinder.php create mode 100644 src/Domain/User/Service/UserValidator.php create mode 100644 src/Infrastructure/Console/SqlSchemaGenerator.php create mode 100644 src/Infrastructure/Factory/QueryFactory.php create mode 100644 src/Infrastructure/Utility/Hydrator.php create mode 100644 src/Infrastructure/Utility/Settings.php create mode 100644 tests/Fixture/UserFixture.php create mode 100644 tests/Integration/User/UserCreateActionTest.php create mode 100644 tests/Integration/User/UserFetchListActionTest.php create mode 100644 tests/Provider/empty create mode 100644 tests/Trait/AppTestTrait.php create mode 100644 tests/Unit/empty diff --git a/.cs.php b/.cs.php new file mode 100644 index 0000000..77cc33e --- /dev/null +++ b/.cs.php @@ -0,0 +1,58 @@ +setUsingCache(false) + ->setRiskyAllowed(true) + ->setRules( + [ + '@PSR1' => true, + '@PSR2' => true, + '@Symfony' => true, + '@PSR12' => true, + 'strict_param' => true, + 'psr_autoloading' => true, + // custom rules + 'align_multiline_comment' => ['comment_type' => 'phpdocs_only'], // psr-5 + 'phpdoc_to_comment' => false, + 'no_superfluous_phpdoc_tags' => false, + 'array_indentation' => true, + 'array_syntax' => ['syntax' => 'short'], + 'cast_spaces' => ['space' => 'none'], + 'concat_space' => ['spacing' => 'one'], + 'compact_nullable_type_declaration' => true, + 'nullable_type_declaration' => true, + 'nullable_type_declaration_for_default_null_value' => true, + 'declare_equal_normalize' => ['space' => 'single'], + 'declare_strict_types' => false, + 'increment_style' => ['style' => 'post'], + 'list_syntax' => ['syntax' => 'short'], + 'echo_tag_syntax' => ['format' => 'long'], + 'phpdoc_add_missing_param_annotation' => ['only_untyped' => false], + 'phpdoc_align' => false, + 'phpdoc_no_empty_return' => false, + 'phpdoc_order' => true, // psr-5 + 'phpdoc_no_useless_inheritdoc' => false, + 'protected_to_private' => false, + 'yoda_style' => false, + 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'const', 'function'] + ], + 'single_line_throw' => false, + 'fully_qualified_strict_types' => true, + 'global_namespace_import' => false, + ] + ) + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') + ->in(__DIR__ . '/config') + ->in(__DIR__ . '/public') + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true) + ); diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3d168cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.eot binary +*.ttf binary +*.woff binary +*.woff2 binary +*.otf binary \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e1b6ca8 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: samuelgfeller \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3f73972 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,82 @@ +name: 🧪 Build test +on: + push: + branches: + - master + - develop + pull_request: + types: [ opened, synchronize, reopened ] + +env: + APP_ENV: github + +jobs: + run: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ ubuntu-latest ] + php-versions: [ '8.2' ] + test-database: [ 'slim_api_starter_test' ] + name: PHP ${{ matrix.php-versions }} Test + + services: + mysql: + image: mysql:8.0.23 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_ALLOW_EMPTY_PASSWORD: true + MYSQL_DATABASE: test + ports: + - 33306:3306 + + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, pdo, pdo_mysql, intl, zip + coverage: xdebug + + - name: Check PHP version + run: php -v + + - name: Check Composer version + run: composer -V + + - name: Check PHP extensions + run: php -m + + - name: Check MySQL version + run: mysql -V + + - name: Start MySQL + run: sudo systemctl start mysql + + - name: Check MySQL variables + run: mysql -uroot -proot -e "SHOW VARIABLES LIKE 'version%';" + + - name: Set MySQL timezone to swiss time + run: mysql -uroot -proot -e "SET GLOBAL time_zone = '+01:00';" + + - name: Create database + run: mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS ${{ matrix.test-database }} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;' + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Install dependencies + run: composer update --no-ansi --no-interaction --no-progress + + - name: Execute database migrations + run: composer migrate-prod + + - name: Show test db tables + run: mysql -uroot -proot -D ${{ matrix.test-database }} -e "SHOW TABLES;" + + - name: Run test suite + run: composer test:coverage + env: + PHP_CS_FIXER_IGNORE_ENV: 1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66bdea0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor/ +/.idea/ +/config/env/env.php +/logs/*.log +/var/cache/* +/composer.lock +/.gtm/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..1676417 --- /dev/null +++ b/.htaccess @@ -0,0 +1,6 @@ +# Turn on the rewrite engine +RewriteEngine on +# If the URL path is empty, rewrite to the 'public/' directory +RewriteRule ^$ public/ [L] +# For any requested URL path, rewrite to the 'public/' directory followed by the requested path +RewriteRule (.*) public/$1 [L] diff --git a/LICENCE.txt b/LICENCE.txt new file mode 100644 index 0000000..34f7752 --- /dev/null +++ b/LICENCE.txt @@ -0,0 +1,15 @@ +Copyright (c) 2024 Samuel Gfeller + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f172e40 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +
+ +

Slim API starter

+ +[![Latest Version on Packagist](https://img.shields.io/github/release/samuelgfeller/slim-api-starter.svg)](https://packagist.org/packages/slim-api-starter) + +[Slim 4](https://www.slimframework.com/) API starter template with a few examples and some essential [features](#features) to +build a secure and scalable API following 2024 best practices and +[SOLID](https://en.wikipedia.org/wiki/SOLID) principles. + +An extensive [documentation](https://github.com/samuelgfeller/slim-example-project/wiki) explains +the project structure, components, design choices and features. + +
+ +## Features + +* [Dependency Injection](https://github.com/samuelgfeller/slim-example-project/wiki/Dependency-Injection) +* [Database migrations](https://github.com/samuelgfeller/slim-example-project/wiki/Database-Migrations) +* [Validation](https://github.com/samuelgfeller/slim-example-project/wiki/Validation) +* [Error handling](https://github.com/samuelgfeller/slim-example-project/wiki/Error-Handling) +* [Logging](https://github.com/samuelgfeller/slim-example-project/wiki/Logging) +* [Integration testing](https://github.com/samuelgfeller/slim-example-project/wiki/Writing-Tests) +* [Query Builder](https://github.com/samuelgfeller/slim-example-project/wiki/Repository-and-Query-Builder) +* [GitHub Actions](https://github.com/samuelgfeller/slim-example-project/wiki/GitHub-Actions) +* [Coding standards fixer](https://github.com/samuelgfeller/slim-example-project/wiki/Coding-Standards-Fixer) +* [PHPStan static code analysis](https://github.com/samuelgfeller/slim-example-project/wiki/PHPStan-Static-Code-Analysis) + + +## Requirements +* PHP 8.2+ +* [Composer](https://github.com/samuelgfeller/slim-example-project/wiki/Composer) +* MariaDB or MySQL database + +## Installation +#### 1. Create project +Navigate to the directory you want to create the project in and run the following +command, replacing [project-name] with the desired name for your project. +```bash +composer create-project samuelgfeller/slim-api-starter [project-name] +``` +This will create a new directory with the specified name and install all +necessary dependencies. + +#### 2. Set up the database +Open the project and rename the file `config/env/env.example.php` to `env.php` +and add the local database credentials. + +Then, create the database on the server and update the `config/env/env.dev.php` +file with the name of the database, like this: +```php +$settings['db']['database'] = 'my_dev_database_name'; +``` +After that, create a separate database for testing and update the `config/env/env.test.php` +file with its name. The name must contain the word "test". There is a safety measure to +prevent accidentally truncating the development database while testing: +```php +$settings['db']['database'] = 'my_dev_database_name_test'; +``` + +#### 3. Run migrations +Open the terminal in the project's root directory and run the following command to create the +demo table `user`: +```bash +composer migrate +``` + +### 4. Insert demo data +You can install four demo users into the database to test the API response by +running the following command: + +```bash +composer seed +``` + +#### 5. Update GitHub workflows + +To run the project's tests automatically when pushing, update the +`.github/workflows/develop.yml` file. +Replace the matrix value "test-database" `slim_api_starter_test` with the name of +your test database as you specified in `config/env/env.test.php`. +If you are not using Scrutinizer, remove the "Scrutinizer Scan" step from the workflow. + +### Done! +That's it! Your project should now be fully set up and ready to use. +If you are using XAMPP and installed the project in the `htdocs` folder, you can access it via +http://localhost/project-name. +Or you can serve it locally by running `php -S localhost:8080 -t public/` in the project's root +directory. + +## Support +If you value this project and want to support it, +visit the [Support❤️](https://github.com/samuelgfeller/slim-example-project/wiki/Support❤️) page. (thank you!) + +## License +This project is licensed under the MIT Licence — see the +[LICENCE](https://github.com/samuelgfeller/slim-example-project/blob/master/LICENCE.txt) file for details. diff --git a/bin/console.php b/bin/console.php new file mode 100644 index 0000000..364a49b --- /dev/null +++ b/bin/console.php @@ -0,0 +1,29 @@ +getContainer(); + +// The $argv variable is an array that contains the command-line arguments passed to the script. +// The first element of the $argv array is always the name of the script itself ('bin/console.php'). +// array_shift($argv) removes this first element that is not relevant here. +array_shift($argv); + +// The now first parameter after the script name that was removed is the class name. +// The second element in the $argv array is the function name. +[$containerKey, $functionName] = $argv; + +// Retrieve the instance corresponding to the $containerKey form the container. +$objectInstance = $container->get($containerKey); + +// The call_user_func_array function is used to call the specified function on the retrieved instance. +// In this case, it's calling the function specified by $functionName on the object instance. +// The second parameter is an empty array, which means no parameters are passed to the function. +call_user_func_array([$objectInstance, $functionName], []); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e8c2f25 --- /dev/null +++ b/composer.json @@ -0,0 +1,64 @@ +{ + "name": "samuelgfeller/slim-api-starter", + "description": "Example project with the slim micro-framework", + "type": "project", + "license": "MIT", + "require": { + "slim/slim": "^4", + "monolog/monolog": "^3", + "php-di/php-di": "^7.0", + "cakephp/database": "^5", + "selective/basepath": "^2.0", + "nyholm/psr7": "^1.5", + "nyholm/psr7-server": "^1.1", + "cakephp/validation": "^5", + "fig/http-message-util": "^1.1", + "php": "^8.2", + "ext-pdo": "*", + "ext-json": "*" + }, + "require-dev": { + "roave/security-advisories": "dev-latest", + "phpunit/phpunit": "^11", + "phpstan/phpstan": "^1", + "jetbrains/phpstorm-attributes": "^1.0", + "friendsofphp/php-cs-fixer": "^3", + "odan/phinx-migrations-generator": "^6", + "samuelgfeller/test-traits": "^5" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + }, + "files": [ + "config/functions.php" + ] + }, + "autoload-dev": { + "psr-4": { + "App\\Test\\": "tests/" + } + }, + "scripts": { + "stan": "phpstan analyse -c phpstan.neon --no-progress --ansi", + "test": "php ./vendor/bin/phpunit --configuration phpunit.xml --do-not-cache-result --colors=always", + "test:coverage": "vendor/bin/phpunit --coverage-clover=coverage.xml", + "cs:check": "php-cs-fixer fix --dry-run --format=txt --verbose --diff --config=.cs.php --ansi", + "cs:fix": "php-cs-fixer fix --config=.cs.php --ansi --verbose", + "migration:generate": [ + "phinx-migrations generate --overwrite -c config/env/env.phinx.php --ansi", + "@schema:generate" + ], + "migrate-prod": "vendor/bin/phinx migrate -c config/env/env.phinx.php --ansi -vvv", + "migrate": [ + "@migrate-prod", + "@schema:generate" + ], + "schema:generate": [ + "php bin/console.php SqlSchemaGenerator generateSqlSchema", + "@add-migrations-to-git" + ], + "add-migrations-to-git": "git add resources/migrations/* && git add resources/schema/*", + "seed": "php vendor/bin/phinx seed:run -c config/env/env.phinx.php" + } +} diff --git a/config/bootstrap.php b/config/bootstrap.php new file mode 100644 index 0000000..f9cd7c6 --- /dev/null +++ b/config/bootstrap.php @@ -0,0 +1,14 @@ +addDefinitions(__DIR__ . '/container.php')->build(); + +// Create app instance +return $container->get(App::class); diff --git a/config/container.php b/config/container.php new file mode 100644 index 0000000..8e67097 --- /dev/null +++ b/config/container.php @@ -0,0 +1,132 @@ + function () { + return require __DIR__ . '/settings.php'; + }, + App::class => function (ContainerInterface $container) { + $app = AppFactory::createFromContainer($container); + // Register routes + (require __DIR__ . '/routes.php')($app); + + // Register middleware + (require __DIR__ . '/middleware.php')($app); + + return $app; + }, + LoggerInterface::class => function (ContainerInterface $container) { + $loggerSettings = $container->get('settings')['logger']; + + $logger = new Logger('app'); + + // When testing, 'test' value is true which means the monolog test handler should be used + if (isset($loggerSettings['test']) && $loggerSettings['test'] === true) { + return $logger->pushHandler(new Monolog\Handler\TestHandler()); + } + + // Instantiate logger with rotating file handler + $filename = sprintf('%s/app.log', $loggerSettings['path']); + $level = $loggerSettings['level']; + // With the RotatingFileHandler, a new log file is created every day + $rotatingFileHandler = new RotatingFileHandler($filename, 0, $level, true, 0777); + // The last "true" here tells monolog to remove empty []'s + $rotatingFileHandler->setFormatter(new LineFormatter(null, 'Y-m-d H:i:s', false, true)); + + return $logger->pushHandler($rotatingFileHandler); + }, + + // HTTP factories + // For Responder and error middleware + ResponseFactoryInterface::class => function (ContainerInterface $container) { + return $container->get(Psr17Factory::class); + }, + ServerRequestFactoryInterface::class => function (ContainerInterface $container) { + return $container->get(Psr17Factory::class); + }, + + // For Responder + RouteParserInterface::class => function (ContainerInterface $container) { + return $container->get(App::class)->getRouteCollector()->getRouteParser(); + }, + + // Error middlewares + NonFatalErrorHandlerMiddleware::class => function (ContainerInterface $container) { + $config = $container->get('settings')['error']; + $logger = $container->get(LoggerInterface::class); + + return new NonFatalErrorHandlerMiddleware( + (bool)$config['display_error_details'], + (bool)$config['log_errors'], + $logger, + ); + }, + // Set error handler to custom DefaultErrorHandler + ErrorMiddleware::class => function (ContainerInterface $container) { + $config = $container->get('settings')['error']; + $app = $container->get(App::class); + + $logger = $container->get(LoggerInterface::class); + + $errorMiddleware = new ErrorMiddleware( + $app->getCallableResolver(), + $app->getResponseFactory(), + (bool)$config['display_error_details'], + (bool)$config['log_errors'], + (bool)$config['log_error_details'], + $logger + ); + + $errorMiddleware->setDefaultErrorHandler( + $container->get(\App\Application\ErrorHandler\DefaultApiErrorHandler::class) + ); + + return $errorMiddleware; + }, + + // Database + Connection::class => function (ContainerInterface $container) { + $settings = $container->get('settings')['db']; + + return new Connection($settings); + }, + PDO::class => function (ContainerInterface $container) { + $driver = $container->get(Connection::class)->getDriver(); + $class = new ReflectionClass($driver); + $method = $class->getMethod('getPdo'); + // Make function getPdo() public + $method->setAccessible(true); + + return $method->invoke($driver); + }, + // Used by command line to generate `schema.sql` for integration testing + 'SqlSchemaGenerator' => function (ContainerInterface $container) { + return new \App\Infrastructure\Console\SqlSchemaGenerator( + $container->get(PDO::class), + $container->get('settings')['root_dir'] + ); + }, + Settings::class => function (ContainerInterface $container) { + return new Settings($container->get('settings')); + }, + + BasePathMiddleware::class => function (ContainerInterface $container) { + return new BasePathMiddleware($container->get(App::class)); + }, +]; diff --git a/config/defaults.php b/config/defaults.php new file mode 100644 index 0000000..75d9224 --- /dev/null +++ b/config/defaults.php @@ -0,0 +1,96 @@ + null, +]; + +// Error handler +$settings['error'] = [ + // Should be set to false in production. When set to true, it will throw an ErrorException for notices and warnings. + 'display_error_details' => false, + 'log_errors' => true, + 'log_error_details' => true, +]; + +// Set false for production env +$settings['dev'] = false; + +// Project root dir (1 parent) +$settings['root_dir'] = dirname(__DIR__, 1); + +$settings['public'] = [ + 'app_name' => 'Slim Api Starter', +]; + +// Secret values are overwritten in env.php +$settings['db'] = [ + 'host' => '127.0.0.1', + 'database' => 'slim_api_starter', + 'username' => 'root', + 'password' => '', + 'driver' => Cake\Database\Driver\Mysql::class, + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + // Enable identifier quoting + 'quoteIdentifiers' => true, + // Disable query logging + 'log' => false, + // Turn off persistent connections + 'persistent' => false, + // PDO options + 'flags' => [ + // Turn off persistent connections + PDO::ATTR_PERSISTENT => false, + // Enable exceptions + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + // Emulate prepared statements + PDO::ATTR_EMULATE_PREPARES => true, + // Set default fetch mode to array + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +// Phinx database migrations settings +$settings['phinx'] = [ + 'paths' => [ + 'migrations' => $settings['root_dir'] . '/resources/migrations', + 'seeds' => $settings['root_dir'] . '/resources/seeds', + ], + 'schema_file' => $settings['root_dir'] . '/resources/schema/schema.php', + 'default_migration_prefix' => 'db_change_', + 'generate_migration_name' => true, + 'environments' => [ + // Table that keeps track of the migrations + 'default_migration_table' => 'phinx_migration_log', + 'default_environment' => 'local', + 'local' => [ + /* Environment specifics such as db credentials from the secret config are added in env.phinx.php */ + ], + ], +]; + +$settings['logger'] = [ + // Log file location + 'path' => $settings['root_dir'] . '/logs', + // Default log level + 'level' => Monolog\Level::Debug, +]; + +return $settings; diff --git a/config/env/env.dev.php b/config/env/env.dev.php new file mode 100644 index 0000000..32f6a9b --- /dev/null +++ b/config/env/env.dev.php @@ -0,0 +1,18 @@ + 'val', 'nextKey' => 'nextVal',]; + */ + +// $_ENV['APP_ENV'] should be set to "prod" in the secret env.php file of the prod server. +// APP_ENV should NOT be set to "dev" in dev env because that would override the phpunit.xml APP_ENV setting. + +// Database +$settings['db']['host'] = 'localhost'; +$settings['db']['username'] = 'root'; +$settings['db']['password'] = ''; diff --git a/config/env/env.github.php b/config/env/env.github.php new file mode 100644 index 0000000..06266fc --- /dev/null +++ b/config/env/env.github.php @@ -0,0 +1,14 @@ +getContainer(); +$pdo = $container->get(PDO::class); +$config = $container->get('settings'); +$database = $config['db']['database']; + +$phinxConfig = $config['phinx']; + +$phinxConfig['environments']['local'] = [ + // Set database name + 'name' => $database, + 'connection' => $pdo, +]; + +return $phinxConfig; diff --git a/config/env/env.prod.php b/config/env/env.prod.php new file mode 100644 index 0000000..4e3a0ba --- /dev/null +++ b/config/env/env.prod.php @@ -0,0 +1,25 @@ + 'val', 'nextKey' => 'nextVal',]; + * good $settings['db]['key'] = 'val'; $settings['db]['nextKey'] = 'nextVal'; + * It's mandatory to set every key by its own and not remap the entire array + */ + +// error_reporting taken from server php.ini +// display_errors value defined in server + +// Error handler. More controlled than ini +$settings['error']['display_error_details'] = false; + +$settings['logger']['level'] = Monolog\Level::Info; + +// $settings['db']['database'] = ''; + +// $settings['api']['allowed_origin'] = 'https://prod-frontend-domain.com'; +$settings['api']['allowed_origin'] = 'https://slim-api-starter-frontend.samuel-gfeller.ch'; diff --git a/config/env/env.test.php b/config/env/env.test.php new file mode 100644 index 0000000..9c5b1e4 --- /dev/null +++ b/config/env/env.test.php @@ -0,0 +1,13 @@ +addBodyParsingMiddleware(); + + $app->addRoutingMiddleware(); + + // Has to be after Routing + $app->add(Selective\BasePath\BasePathMiddleware::class); + + // Handle and log notices and warnings (throws ErrorException if displayErrorDetails is true) + $app->add(\App\Application\Middleware\NonFatalErrorHandlerMiddleware::class); + // Set error handler to custom DefaultErrorHandler (defined in container.php) + $app->add(Slim\Middleware\ErrorMiddleware::class); + + // Cross-Origin Resource Sharing (CORS) middleware + $app->add(\App\Application\Middleware\CorsMiddleware::class); +}; diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 0000000..175b30e --- /dev/null +++ b/config/routes.php @@ -0,0 +1,13 @@ +redirect('/', 'frontend/home.html', 301)->setName('home-page'); + + // Fetch user list + $app->get('/users', \App\Application\Action\User\UserFetchListAction::class)->setName('user-list'); + // Create user + $app->post('/users', \App\Application\Action\User\UserCreateAction::class)->setName('user-create'); +}; diff --git a/config/settings.php b/config/settings.php new file mode 100644 index 0000000..0ebc59e --- /dev/null +++ b/config/settings.php @@ -0,0 +1,27 @@ + + + + + + tests/Integration + + + tests/Unit + + + + + + + + + + src + + + bin + build + docs + public + tmp + vendor + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..cdb8cdd --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine on +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^ index.php [QSA,L] \ No newline at end of file diff --git a/public/frontend/favicon.ico b/public/frontend/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..68507fb5bb26518854af440145410d3d6ca59fd7 GIT binary patch literal 103248 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 literal 0 HcmV?d00001 diff --git a/public/frontend/home.html b/public/frontend/home.html new file mode 100644 index 0000000..680b3d3 --- /dev/null +++ b/public/frontend/home.html @@ -0,0 +1,22 @@ + + + + + + + Example Frontend for Slim API Starter + + +

Frontend for Slim API Starter

+

This frontend is an example of a separate application that will communicate with the API.

+

The link to the actual frontend must be added to the $settings['api']['allowed_origin'] + in the config files: config/env/env.dev.php and config/env/env.prod.php. +

+

You can test the API by clicking on the button below. It should request the list of users + that were inserted for demonstration purposes.

+ + + + + + \ No newline at end of file diff --git a/public/frontend/script.js b/public/frontend/script.js new file mode 100644 index 0000000..8fa0937 --- /dev/null +++ b/public/frontend/script.js @@ -0,0 +1,41 @@ +// Get the second segment of the url for the case that the application is not running in the root directory +// (e.g. localhost/my-app) -> basePath = 'my-app' +const secondUrlSegment = window.location.pathname.split('/')[1]; +// If the project is stored in the root directory, the second segment is the 'frontend' folder which means +// that the base path is empty. +const basePath = secondUrlSegment !== 'frontend' ? `/${secondUrlSegment}` : ''; +// Replace with the correct API url +const url = basePath + '/users'; +document.getElementById('request-users-btn').addEventListener('click', () => { + // Make Ajax request to fetch users + fetch(url, {method: 'GET', headers: {"Content-type": "application/json"}}) + .then(async response => { + const outputContainer = document.getElementById('request-output'); + // Output container is hidden by default, remove inline display: none property + outputContainer.style.display = null; + // Add success class to output container by default + outputContainer.className = 'success'; + + // Add red line border output if response is not between 200 and 299 + if (!response.ok) { + outputContainer.className = 'error'; + console.log(response.status) + if (response.status === 404) { + outputContainer.innerHTML = `Invalid API url: ${url}`; + return; + } + } + + // Parse the JSON response + const jsonResponse = await response.json(); + // Pretty print the JSON response + const prettyJson = JSON.stringify(jsonResponse, null, 2); + // Display the JSON response in the output container + outputContainer.innerHTML = prettyJson + // Replace newlines with
and remove \r, \" and \\ + .replace(/\\n/g, '
') + .replace(/\\r/g, '') + .replace(/\\"/g, '\"') + .replace(/\\\\/g, '\\'); + }); +}); \ No newline at end of file diff --git a/public/frontend/style.css b/public/frontend/style.css new file mode 100644 index 0000000..2ed7cf0 --- /dev/null +++ b/public/frontend/style.css @@ -0,0 +1,55 @@ +body { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +* { + font-family: Verdana, Helvetica, Arial, sans-serif; + /* Include padding and border in box width calculation */ + box-sizing: border-box; +} + +code { + font-family: Consolas, "courier new", Courier, monospace; + color: #8d031b; + background-color: #f1f1f1; + padding: 2px; +} + +code.file-name { + color: #02155e; + background-color: #f1f1f1; + padding: 2px; +} + +pre { + font-family: Consolas, "courier new", Courier, monospace; +} + +#request-users-btn { + margin-top: 30px; + cursor: pointer; +} + +#request-output { + margin-top: 40px; + border-radius: 20px; + padding: 30px; + background: #f1f1f1; + max-width: 100%; + width: 700px; + text-align: left; + overflow: auto; + /*overflow-wrap: break-word;*/ + /*word-break: break-all;*/ + white-space: pre-wrap; +} + +#request-output.success { + border: 4px solid darkgreen; +} +#request-output.error { + border: 4px solid darkred; +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..8f46405 --- /dev/null +++ b/public/index.php @@ -0,0 +1,4 @@ +run(); diff --git a/resources/migrations/20240328155512_db_change_1187610968660592e_09f_632.php b/resources/migrations/20240328155512_db_change_1187610968660592e_09f_632.php new file mode 100644 index 0000000..8f7f1d5 --- /dev/null +++ b/resources/migrations/20240328155512_db_change_1187610968660592e_09f_632.php @@ -0,0 +1,64 @@ +table('user', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_general_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => MysqlAdapter::INT_REGULAR, + 'identity' => true, + ]) + ->addColumn('first_name', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 100, + 'collation' => 'utf8mb4_general_ci', + 'encoding' => 'utf8mb4', + 'after' => 'id', + ]) + ->addColumn('last_name', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 100, + 'collation' => 'utf8mb4_general_ci', + 'encoding' => 'utf8mb4', + 'after' => 'first_name', + ]) + ->addColumn('email', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 254, + 'collation' => 'utf8mb4_general_ci', + 'encoding' => 'utf8mb4', + 'after' => 'last_name', + ]) + ->addColumn('updated_at', 'datetime', [ + 'null' => true, + 'default' => null, + 'after' => 'email', + ]) + ->addColumn('created_at', 'datetime', [ + 'null' => true, + 'default' => 'CURRENT_TIMESTAMP', + 'after' => 'updated_at', + ]) + ->addColumn('deleted_at', 'datetime', [ + 'null' => true, + 'default' => null, + 'after' => 'created_at', + ]) + ->create(); + } +} diff --git a/resources/schema/schema.php b/resources/schema/schema.php new file mode 100644 index 0000000..8573b6a --- /dev/null +++ b/resources/schema/schema.php @@ -0,0 +1,374 @@ + + array ( + 'default_character_set_name' => 'utf8mb4', + 'default_collation_name' => 'utf8mb4_general_ci', + ), + 'tables' => + array ( + 'user' => + array ( + 'table' => + array ( + 'table_name' => 'user', + 'engine' => 'InnoDB', + 'table_comment' => '', + 'table_collation' => 'utf8mb4_general_ci', + 'character_set_name' => 'utf8mb4', + 'row_format' => 'Dynamic', + ), + 'columns' => + array ( + 'id' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'user', + 'COLUMN_NAME' => 'id', + 'ORDINAL_POSITION' => 1, + 'COLUMN_DEFAULT' => NULL, + 'IS_NULLABLE' => 'NO', + 'DATA_TYPE' => 'int', + 'CHARACTER_MAXIMUM_LENGTH' => NULL, + 'CHARACTER_OCTET_LENGTH' => NULL, + 'NUMERIC_PRECISION' => 10, + 'NUMERIC_SCALE' => 0, + 'DATETIME_PRECISION' => NULL, + 'CHARACTER_SET_NAME' => NULL, + 'COLLATION_NAME' => NULL, + 'COLUMN_TYPE' => 'int(11)', + 'COLUMN_KEY' => 'PRI', + 'EXTRA' => 'auto_increment', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'first_name' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'user', + 'COLUMN_NAME' => 'first_name', + 'ORDINAL_POSITION' => 2, + 'COLUMN_DEFAULT' => 'NULL', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 100, + 'CHARACTER_OCTET_LENGTH' => 400, + 'NUMERIC_PRECISION' => NULL, + 'NUMERIC_SCALE' => NULL, + 'DATETIME_PRECISION' => NULL, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_general_ci', + 'COLUMN_TYPE' => 'varchar(100)', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'last_name' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'user', + 'COLUMN_NAME' => 'last_name', + 'ORDINAL_POSITION' => 3, + 'COLUMN_DEFAULT' => 'NULL', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 100, + 'CHARACTER_OCTET_LENGTH' => 400, + 'NUMERIC_PRECISION' => NULL, + 'NUMERIC_SCALE' => NULL, + 'DATETIME_PRECISION' => NULL, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_general_ci', + 'COLUMN_TYPE' => 'varchar(100)', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'email' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'user', + 'COLUMN_NAME' => 'email', + 'ORDINAL_POSITION' => 4, + 'COLUMN_DEFAULT' => 'NULL', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 254, + 'CHARACTER_OCTET_LENGTH' => 1016, + 'NUMERIC_PRECISION' => NULL, + 'NUMERIC_SCALE' => NULL, + 'DATETIME_PRECISION' => NULL, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_general_ci', + 'COLUMN_TYPE' => 'varchar(254)', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'updated_at' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'user', + 'COLUMN_NAME' => 'updated_at', + 'ORDINAL_POSITION' => 5, + 'COLUMN_DEFAULT' => 'NULL', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'datetime', + 'CHARACTER_MAXIMUM_LENGTH' => NULL, + 'CHARACTER_OCTET_LENGTH' => NULL, + 'NUMERIC_PRECISION' => NULL, + 'NUMERIC_SCALE' => NULL, + 'DATETIME_PRECISION' => 0, + 'CHARACTER_SET_NAME' => NULL, + 'COLLATION_NAME' => NULL, + 'COLUMN_TYPE' => 'datetime', + 'COLUMN_KEY' => '', + 'EXTRA' => 'on update current_timestamp()', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'created_at' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'user', + 'COLUMN_NAME' => 'created_at', + 'ORDINAL_POSITION' => 6, + 'COLUMN_DEFAULT' => 'current_timestamp()', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'datetime', + 'CHARACTER_MAXIMUM_LENGTH' => NULL, + 'CHARACTER_OCTET_LENGTH' => NULL, + 'NUMERIC_PRECISION' => NULL, + 'NUMERIC_SCALE' => NULL, + 'DATETIME_PRECISION' => 0, + 'CHARACTER_SET_NAME' => NULL, + 'COLLATION_NAME' => NULL, + 'COLUMN_TYPE' => 'datetime', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'deleted_at' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'user', + 'COLUMN_NAME' => 'deleted_at', + 'ORDINAL_POSITION' => 7, + 'COLUMN_DEFAULT' => 'NULL', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'datetime', + 'CHARACTER_MAXIMUM_LENGTH' => NULL, + 'CHARACTER_OCTET_LENGTH' => NULL, + 'NUMERIC_PRECISION' => NULL, + 'NUMERIC_SCALE' => NULL, + 'DATETIME_PRECISION' => 0, + 'CHARACTER_SET_NAME' => NULL, + 'COLLATION_NAME' => NULL, + 'COLUMN_TYPE' => 'datetime', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + ), + 'indexes' => + array ( + 'PRIMARY' => + array ( + 1 => + array ( + 'Table' => 'user', + 'Non_unique' => 0, + 'Key_name' => 'PRIMARY', + 'Seq_in_index' => 1, + 'Column_name' => 'id', + 'Collation' => 'A', + 'Sub_part' => NULL, + 'Packed' => NULL, + 'Null' => '', + 'Index_type' => 'BTREE', + 'Comment' => '', + 'Index_comment' => '', + ), + ), + ), + 'foreign_keys' => NULL, + ), + 'phinx_migration_log' => + array ( + 'table' => + array ( + 'table_name' => 'phinx_migration_log', + 'engine' => 'InnoDB', + 'table_comment' => '', + 'table_collation' => 'utf8mb4_unicode_ci', + 'character_set_name' => 'utf8mb4', + 'row_format' => 'Dynamic', + ), + 'columns' => + array ( + 'version' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'phinx_migration_log', + 'COLUMN_NAME' => 'version', + 'ORDINAL_POSITION' => 1, + 'COLUMN_DEFAULT' => NULL, + 'IS_NULLABLE' => 'NO', + 'DATA_TYPE' => 'bigint', + 'CHARACTER_MAXIMUM_LENGTH' => NULL, + 'CHARACTER_OCTET_LENGTH' => NULL, + 'NUMERIC_PRECISION' => 19, + 'NUMERIC_SCALE' => 0, + 'DATETIME_PRECISION' => NULL, + 'CHARACTER_SET_NAME' => NULL, + 'COLLATION_NAME' => NULL, + 'COLUMN_TYPE' => 'bigint(20)', + 'COLUMN_KEY' => 'PRI', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'migration_name' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'phinx_migration_log', + 'COLUMN_NAME' => 'migration_name', + 'ORDINAL_POSITION' => 2, + 'COLUMN_DEFAULT' => 'NULL', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 100, + 'CHARACTER_OCTET_LENGTH' => 400, + 'NUMERIC_PRECISION' => NULL, + 'NUMERIC_SCALE' => NULL, + 'DATETIME_PRECISION' => NULL, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_unicode_ci', + 'COLUMN_TYPE' => 'varchar(100)', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'start_time' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'phinx_migration_log', + 'COLUMN_NAME' => 'start_time', + 'ORDINAL_POSITION' => 3, + 'COLUMN_DEFAULT' => 'NULL', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'timestamp', + 'CHARACTER_MAXIMUM_LENGTH' => NULL, + 'CHARACTER_OCTET_LENGTH' => NULL, + 'NUMERIC_PRECISION' => NULL, + 'NUMERIC_SCALE' => NULL, + 'DATETIME_PRECISION' => 0, + 'CHARACTER_SET_NAME' => NULL, + 'COLLATION_NAME' => NULL, + 'COLUMN_TYPE' => 'timestamp', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'end_time' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'phinx_migration_log', + 'COLUMN_NAME' => 'end_time', + 'ORDINAL_POSITION' => 4, + 'COLUMN_DEFAULT' => 'NULL', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'timestamp', + 'CHARACTER_MAXIMUM_LENGTH' => NULL, + 'CHARACTER_OCTET_LENGTH' => NULL, + 'NUMERIC_PRECISION' => NULL, + 'NUMERIC_SCALE' => NULL, + 'DATETIME_PRECISION' => 0, + 'CHARACTER_SET_NAME' => NULL, + 'COLLATION_NAME' => NULL, + 'COLUMN_TYPE' => 'timestamp', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + 'breakpoint' => + array ( + 'TABLE_CATALOG' => 'def', + 'TABLE_NAME' => 'phinx_migration_log', + 'COLUMN_NAME' => 'breakpoint', + 'ORDINAL_POSITION' => 5, + 'COLUMN_DEFAULT' => '0', + 'IS_NULLABLE' => 'NO', + 'DATA_TYPE' => 'tinyint', + 'CHARACTER_MAXIMUM_LENGTH' => NULL, + 'CHARACTER_OCTET_LENGTH' => NULL, + 'NUMERIC_PRECISION' => 3, + 'NUMERIC_SCALE' => 0, + 'DATETIME_PRECISION' => NULL, + 'CHARACTER_SET_NAME' => NULL, + 'COLLATION_NAME' => NULL, + 'COLUMN_TYPE' => 'tinyint(1)', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => '', + 'IS_GENERATED' => 'NEVER', + 'GENERATION_EXPRESSION' => NULL, + ), + ), + 'indexes' => + array ( + 'PRIMARY' => + array ( + 1 => + array ( + 'Table' => 'phinx_migration_log', + 'Non_unique' => 0, + 'Key_name' => 'PRIMARY', + 'Seq_in_index' => 1, + 'Column_name' => 'version', + 'Collation' => 'A', + 'Sub_part' => NULL, + 'Packed' => NULL, + 'Null' => '', + 'Index_type' => 'BTREE', + 'Comment' => '', + 'Index_comment' => '', + ), + ), + ), + 'foreign_keys' => NULL, + ), + ), +); \ No newline at end of file diff --git a/resources/schema/schema.sql b/resources/schema/schema.sql new file mode 100644 index 0000000..ab1561c --- /dev/null +++ b/resources/schema/schema.sql @@ -0,0 +1,19 @@ +CREATE TABLE `phinx_migration_log` ( + `version` bigint(20) NOT NULL, + `migration_name` varchar(100) DEFAULT NULL, + `start_time` timestamp NULL DEFAULT NULL, + `end_time` timestamp NULL DEFAULT NULL, + `breakpoint` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `user` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `first_name` varchar(100) DEFAULT NULL, + `last_name` varchar(100) DEFAULT NULL, + `email` varchar(254) DEFAULT NULL, + `updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(), + `created_at` datetime DEFAULT current_timestamp(), + `deleted_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; \ No newline at end of file diff --git a/resources/seeds/UserSeeder.php b/resources/seeds/UserSeeder.php new file mode 100644 index 0000000..9418538 --- /dev/null +++ b/resources/seeds/UserSeeder.php @@ -0,0 +1,52 @@ + 1, + 'first_name' => 'Steve', + 'last_name' => 'Norton', + 'email' => 'steve.norton@email.com', + ], + [ + 'id' => 2, + 'first_name' => 'Alan', + 'last_name' => 'Brown', + 'email' => 'alan.brown@email.com', + ], + [ + 'id' => 3, + 'first_name' => 'Sylvia', + 'last_name' => 'Hager', + 'email' => 'sylvia.hager@email.com', + ], + [ + 'id' => 4, + 'first_name' => 'Kristin', + 'last_name' => 'Burns', + 'email' => 'kristin.burns@email.com', + ], + ]; + + $table = $this->table('user'); + $table->insert($userRows)->saveData(); + } +} diff --git a/src/Application/Action/User/UserCreateAction.php b/src/Application/Action/User/UserCreateAction.php new file mode 100644 index 0000000..d0029f8 --- /dev/null +++ b/src/Application/Action/User/UserCreateAction.php @@ -0,0 +1,28 @@ +getParsedBody(); + + $this->userCreator->createUser($userValues); + + return $response->withStatus(StatusCodeInterface::STATUS_CREATED); + } +} diff --git a/src/Application/Action/User/UserFetchListAction.php b/src/Application/Action/User/UserFetchListAction.php new file mode 100644 index 0000000..32fc719 --- /dev/null +++ b/src/Application/Action/User/UserFetchListAction.php @@ -0,0 +1,27 @@ +userFinder->findAllUsers(); + + return $this->jsonEncoder->encodeAndAddToResponse($response, $users); + } +} diff --git a/src/Application/ErrorHandler/DefaultApiErrorHandler.php b/src/Application/ErrorHandler/DefaultApiErrorHandler.php new file mode 100644 index 0000000..6c6aaee --- /dev/null +++ b/src/Application/ErrorHandler/DefaultApiErrorHandler.php @@ -0,0 +1,133 @@ +logger->error( + sprintf( + 'Error: [%s] %s File %s:%s , Method: %s, Path: %s', + $exception->getCode(), + $exception->getMessage(), + $exception->getFile(), + $exception->getLine(), + $request->getMethod(), + $request->getUri()->getPath() + ) + ); + } + + // Error output if script is called via cli (e.g. testing) + if (PHP_SAPI === 'cli') { + // If the column is not found and the request is coming from the command line, it probably means + // that the database schema.sql was not updated after a change. + if ($exception instanceof \PDOException && str_contains($exception->getMessage(), 'Column not found')) { + echo "Column not existing. Try running `composer schema:generate` in the console and run tests again. \n"; + } + + // Restore previous error handler when the exception has been thrown to satisfy PHPUnit v11 + // It is restored in the post-processing of the NonFatalErrorHandlerMiddleware, but the code doesn't + // reach it when there's an exception (especially needed for tests expecting an exception). + // Related PR: https://github.com/sebastianbergmann/phpunit/pull/5619 + restore_error_handler(); + + // The exception is thrown to have the standard behaviour (important for testing). + throw $exception; + } + + // Create response + $response = $this->responseFactory->createResponse(); + + // Detect status code + $statusCode = $this->getHttpStatusCode($exception); + $response = $response->withStatus($statusCode); + // Reason phrase is the text that describes the status code e.g. 404 => Not found + $reasonPhrase = $response->getReasonPhrase(); + + // If it's a HttpException it's safe to show the error message to the user + $exceptionMessage = $exception instanceof HttpException ? $exception->getMessage() : null; + + // Error details are never returned to the client. The log file is the only place where the details are stored. + $jsonErrorResponse = [ + 'status' => $statusCode, + 'message' => $exceptionMessage ?? $reasonPhrase, + ]; + + // If displayErrorDetails is true, add the error message to the response body + if ($displayErrorDetails === true) { + $jsonErrorResponse['error'] = $exception->getMessage(); + } + + $response = $response->withHeader('Content-Type', 'application/json'); + $response->getBody()->write( + json_encode($jsonErrorResponse, JSON_PARTIAL_OUTPUT_ON_ERROR) ?: 'An error occured.' + ); + + return $response; + } + + /** + * Determine http status code. + * + * @param Throwable $exception The exception + * + * @return int The http code + */ + private function getHttpStatusCode(Throwable $exception): int + { + // Default status code + $statusCode = StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR; // 500 + + // HttpExceptions have a status code + if ($exception instanceof HttpException) { + $statusCode = (int)$exception->getCode(); + } + + $file = basename($exception->getFile()); + if ($file === 'CallableResolver.php') { + $statusCode = StatusCodeInterface::STATUS_NOT_FOUND; // 404 + } + + return $statusCode; + } +} diff --git a/src/Application/Middleware/CorsMiddleware.php b/src/Application/Middleware/CorsMiddleware.php new file mode 100644 index 0000000..8eb63a0 --- /dev/null +++ b/src/Application/Middleware/CorsMiddleware.php @@ -0,0 +1,49 @@ +allowedOrigin = $settings->get('api')['allowed_origin'] ?? null; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // Handle all "OPTIONS" pre-flight requests with an empty response + // https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request + if ($request->getMethod() === 'OPTIONS') { + // Skips the rest of the middleware stack and returns the response + $response = $this->responseFactory->createResponse(); + } else { + // Continue with the middleware stack + $response = $handler->handle($request); + } + // Add response headers in post-processing before the response is sent + // https://github.com/samuelgfeller/slim-example-project/wiki/Middleware#order-of-execution + $response = $response + ->withHeader('Access-Control-Allow-Credentials', 'true') + ->withHeader('Access-Control-Allow-Origin', $this->allowedOrigin ?? '*') + ->withHeader('Access-Control-Allow-Headers', '*') + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->withHeader('Pragma', 'no-cache'); + + // Handle warnings and notices, so they won't affect the CORS headers + if (ob_get_contents()) { + ob_clean(); + } + + return $response; + } +} diff --git a/src/Application/Middleware/NonFatalErrorHandlerMiddleware.php b/src/Application/Middleware/NonFatalErrorHandlerMiddleware.php new file mode 100644 index 0000000..23e2b99 --- /dev/null +++ b/src/Application/Middleware/NonFatalErrorHandlerMiddleware.php @@ -0,0 +1,75 @@ +displayErrorDetails = $displayErrorDetails; + $this->logErrors = $logErrors; + } + + /** + * Invoke middleware. + * + * @param ServerRequestInterface $request The request + * @param RequestHandlerInterface $handler The handler + * + * @throws ErrorException + * + * @return ResponseInterface The response + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // Only make notices / wantings to ErrorException's if error details should be displayed + // SLE-57 Making warnings and notices to exceptions for development + // set_error_handler only handles non-fatal errors. The function callback is not called by fatal errors. + set_error_handler( + function ($severity, $message, $file, $line) { + // Don't throw exception if error reporting is turned off. + // '&' checks if a particular error level is included in the result of error_reporting(). + if (error_reporting() & $severity) { + // Log non fatal errors if logging is enabled + if ($this->logErrors) { + // If error is warning + if ($severity === E_WARNING | E_CORE_WARNING | E_COMPILE_WARNING | E_USER_WARNING) { + $this->logger->warning("Warning [$severity] $message on line $line in file $file"); + } // If error is non-fatal and is not a warning + else { + $this->logger->notice("Notice [$severity] $message on line $line in file $file"); + } + } + if ($this->displayErrorDetails === true) { + // Throw ErrorException to stop script execution and have access to more error details + // Logging for fatal errors happens in DefaultErrorHandler.php + throw new ErrorException($message, 0, $severity, $file, $line); + } + } + + return true; + } + ); + + $response = $handler->handle($request); + + // Restore previous error handler in post-processing to satisfy PHPUnit 11 that checks for any + // leftover error handlers https://github.com/sebastianbergmann/phpunit/pull/5619 + restore_error_handler(); + + return $response; + } +} diff --git a/src/Application/Responder/JsonEncoder.php b/src/Application/Responder/JsonEncoder.php new file mode 100644 index 0000000..def976a --- /dev/null +++ b/src/Application/Responder/JsonEncoder.php @@ -0,0 +1,28 @@ +getBody()->write((string)json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR)); + $response = $response->withStatus($status); + + return $response->withHeader('Content-Type', 'application/json'); + } +} diff --git a/src/Domain/Exception/ValidationException.php b/src/Domain/Exception/ValidationException.php new file mode 100644 index 0000000..deaa2d3 --- /dev/null +++ b/src/Domain/Exception/ValidationException.php @@ -0,0 +1,70 @@ +validationErrors = $this->transformCakephpValidationErrorsToOutputFormat($validationErrors); + } + + /** + * Transform the validation error output from the library to array that is used by the frontend. + * The changes are tiny, but the main purpose is to add an abstraction layer in case the validation + * library changes its error output format in the future so that only this function has to be + * changed instead of the frontend. + * + * In cakephp/validation 5 the error array is output in the following format: + * $cakeValidationErrors = [ + * 'field_name' => [ + * 'validation_rule_name' => 'Validation error message for that field', + * 'other_validation_rule_name' => 'Another validation error message for that field', + * ], + * 'email' => [ + * '_required' => 'This field is required', + * ], + * 'first_name' => [ + * 'minLength' => 'Minimum length is 3', + * ], + * ] + * + * This function transforms this into the format that is used by the frontend + * (which is roughly the same except we don't need the infringed rule name as key): + * $outputValidationErrors = [ + * 'field_name' => [ + * 0 => 'Validation error message for that field', + * 1 => 'Another validation error message for that field', + * ], + * 'email' => [ + * 0 => 'This field is required', + * ], + * 'first_name' => [ + * 0 => 'Minimum length is 3', + * ], + * ] + * + * @param array $validationErrors The cakephp validation errors + * + * @return array the transformed result in the format documented above + */ + private function transformCakephpValidationErrorsToOutputFormat(array $validationErrors): array + { + $validationErrorsForOutput = []; + foreach ($validationErrors as $fieldName => $fieldErrors) { + // There may be cases with multiple error messages for a single field. + foreach ($fieldErrors as $infringedRuleName => $infringedRuleMessage) { + // Output is basically the same except without the rule name as a key. + $validationErrorsForOutput[$fieldName][] = $infringedRuleMessage; + } + } + + return $validationErrorsForOutput; + } +} diff --git a/src/Domain/User/Data/UserData.php b/src/Domain/User/Data/UserData.php new file mode 100644 index 0000000..dc4f97d --- /dev/null +++ b/src/Domain/User/Data/UserData.php @@ -0,0 +1,45 @@ +id = $userData['id'] ?? null; + $this->firstName = $userData['first_name'] ?? null; + $this->lastName = $userData['last_name'] ?? null; + $this->email = $userData['email'] ?? null; + try { + $this->updatedAt = $userData['updated_at'] ?? null ? new \DateTimeImmutable($userData['updated_at']) : null; + } catch (\Exception $e) { + $this->updatedAt = null; + } + try { + $this->createdAt = $userData['created_at'] ?? null ? new \DateTimeImmutable($userData['created_at']) : null; + } catch (\Exception $e) { + $this->createdAt = null; + } + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'email' => $this->email, + 'updatedAt' => $this->updatedAt?->format('Y-m-d H:i:s'), + 'createdAt' => $this->createdAt?->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/src/Domain/User/Repository/UserCreatorRepository.php b/src/Domain/User/Repository/UserCreatorRepository.php new file mode 100644 index 0000000..78dec0b --- /dev/null +++ b/src/Domain/User/Repository/UserCreatorRepository.php @@ -0,0 +1,26 @@ +queryFactory->insertQueryWithData($userValues)->into('user')->execute()->lastInsertId(); + } +} \ No newline at end of file diff --git a/src/Domain/User/Repository/UserFinderRepository.php b/src/Domain/User/Repository/UserFinderRepository.php new file mode 100644 index 0000000..05dd3e0 --- /dev/null +++ b/src/Domain/User/Repository/UserFinderRepository.php @@ -0,0 +1,40 @@ +queryFactory->selectQuery()->select([ + 'id', + 'first_name', + 'last_name', + 'email', + 'updated_at', + 'created_at', + ])->from('user')->where( + ['deleted_at IS' => null] + ); + + $userRows = $query->execute()->fetchAll('assoc') ?: []; + + // Convert to list of objects + return $this->hydrator->hydrate($userRows, UserData::class); + } +} diff --git a/src/Domain/User/Service/UserCreator.php b/src/Domain/User/Service/UserCreator.php new file mode 100644 index 0000000..140ae96 --- /dev/null +++ b/src/Domain/User/Service/UserCreator.php @@ -0,0 +1,28 @@ +userValidator->validateUserValues($userValues); + + return $this->userCreatorRepository->insertUser($userValues); + } +} diff --git a/src/Domain/User/Service/UserFinder.php b/src/Domain/User/Service/UserFinder.php new file mode 100644 index 0000000..40d7e37 --- /dev/null +++ b/src/Domain/User/Service/UserFinder.php @@ -0,0 +1,24 @@ +userFinderRepository->findAllUsers(); + } +} diff --git a/src/Domain/User/Service/UserValidator.php b/src/Domain/User/Service/UserValidator.php new file mode 100644 index 0000000..726864d --- /dev/null +++ b/src/Domain/User/Service/UserValidator.php @@ -0,0 +1,48 @@ +requirePresence('first_name', $isCreateMode, 'Field is required') + ->allowEmptyString('first_name',) // Not required field + ->minLength('first_name', 2, 'Minimum length is 2') + ->maxLength('first_name', 100, 'Maximum length is 100') + ->requirePresence('last_name', $isCreateMode, 'Field is required') + ->allowEmptyString('last_name',) // Not required field + ->minLength('last_name', 2, 'Minimum length is 2') + ->maxLength('last_name', 100, 'Maximum length is 100') + ->requirePresence('email', $isCreateMode, 'Field is required') + ->allowEmptyString('first_name',) // Not required field + ->email('email', false, 'Invalid email') + ; + + // Validate and throw exception if there are errors + $errors = $validator->validate($userValues); + if ($errors) { + throw new ValidationException($errors); + } + } +} diff --git a/src/Infrastructure/Console/SqlSchemaGenerator.php b/src/Infrastructure/Console/SqlSchemaGenerator.php new file mode 100644 index 0000000..eb89ee3 --- /dev/null +++ b/src/Infrastructure/Console/SqlSchemaGenerator.php @@ -0,0 +1,80 @@ +query('select database()')->fetchColumn()); + + // Execute SQL query to get all table names from the current database + $statement = $this->query('SELECT table_name FROM information_schema.tables WHERE table_schema = database()'); + + $sql = []; + // Loop through each table in the database + while ($row = $statement->fetch(PDO::FETCH_ASSOC)) { + // Changes the case of the keys in the fetched row to lower case + $row = array_change_key_case($row); + // Execute SQL query to get the 'CREATE TABLE' statement for the current table + // SHOW CREATE TABLE is specific to MySQL + $statement2 = $this->query(sprintf('SHOW CREATE TABLE `%s`;', (string)$row['table_name'])); + // Fetch the 'CREATE TABLE' statement and remove the 'AUTO_INCREMENT' part + $createTableSql = $statement2->fetch()['Create Table']; + $sql[] = preg_replace('/AUTO_INCREMENT=\d+/', '', $createTableSql) . ';'; + } + + // Join all the 'CREATE TABLE' statements into a single string, separated by two newlines + $sql = implode("\n\n", $sql); + + // Write the generated SQL statements to a file + $filename = $this->rootPath . '/resources/schema/schema.sql'; + file_put_contents($filename, $sql); + + echo sprintf("\nGenerated file: %s", realpath($filename)); + + // Return 0 to indicate that the method has completed successfully + return 0; + } + + /** + * Create query statement. + * + * @param string $sql The sql + * + * @throws UnexpectedValueException + * + * @return PDOStatement The statement + */ + private function query(string $sql): PDOStatement + { + $statement = $this->pdo->query($sql); + + if (!$statement) { + throw new UnexpectedValueException( + 'Query failed: ' . $sql . ' Error: ' . $this->pdo->errorInfo()[2] + ); + } + + return $statement; + } +} diff --git a/src/Infrastructure/Factory/QueryFactory.php b/src/Infrastructure/Factory/QueryFactory.php new file mode 100644 index 0000000..5fedca1 --- /dev/null +++ b/src/Infrastructure/Factory/QueryFactory.php @@ -0,0 +1,120 @@ +queryFactory->selectQuery()->select(['*'])->from('user')->where( + * ['deleted_at IS' => null, 'name LIKE' => '%John%']); + * return $query->execute()->fetchAll('assoc'); + * + * @return SelectQuery + */ + public function selectQuery(): SelectQuery + { + return $this->connection->selectQuery(); + } + + /** + * Returns an update query instance. + * + * UPDATE example: + * $query = $this->queryFactory->updateQuery()->update('user')->set($data)->where(['id' => 1]); + * return $query->execute()->rowCount() > 0; + * + * @return UpdateQuery + */ + public function updateQuery(): UpdateQuery + { + return $this->connection->updateQuery(); + } + + /** + * Returns an insert query instance. + * + * @return InsertQuery the insert query object + */ + public function insertQuery(): InsertQuery + { + return $this->connection->insertQuery(); + } + + /** + * Data is an assoc array of a row to insert where the key is the column name. + * + * Example usage: + * return (int)$this->queryFactory->insertQueryWithData($data)->into('user')->execute()->lastInsertId();. + * + * @param array $data ['col_name' => 'Value', 'other_col' => 'Other value'] + * + * @return InsertQuery + */ + public function insertQueryWithData(array $data): InsertQuery + { + return $this->connection->insertQuery()->insert(array_keys($data))->values($data); + } + + /** + * Soft deletes entry from given table name. + * + * Example usage: + * $query = $this->queryFactory->softDeleteQuery('user')->where(['id' => $id]); + * return $query->execute()->rowCount() > 0;. + * + * @param string $fromTable + * + * @return UpdateQuery + */ + public function softDeleteQuery(string $fromTable): UpdateQuery + { + return $this->connection->updateQuery()->update($fromTable)->set(['deleted_at' => date('Y-m-d H:i:s')]); + } + + /** + * Returns a delete query instance for hard deletion. + * + * @return Query\DeleteQuery the delete query object + */ + public function hardDeleteQuery(): Query\DeleteQuery + { + // Return the delete query object created by the connection. + return $this->connection->deleteQuery(); + } + + /** + * Data is an assoc array of rows to insert where the key is the column name + * Example usage: + * return (int)$this->queryFactory->newMultipleInsert($data)->into('user')->execute()->lastInsertId();. + * + * @param array $arrayOfData [['col_name' => 'Value', 'other_col' => 'Other value'], ['col_name' => 'value']] + * + * @return InsertQuery + */ + public function insertQueryMultipleRows(array $arrayOfData): InsertQuery + { + $query = $this->connection->insertQuery()->insert(array_keys($arrayOfData[array_key_first($arrayOfData)])); + // According to the docs, chaining ->values is the way to go https://book.cakephp.org/4/en/orm/query-builder.html#inserting-data + foreach ($arrayOfData as $data) { + $query->values($data); + } + + return $query; + } +} diff --git a/src/Infrastructure/Utility/Hydrator.php b/src/Infrastructure/Utility/Hydrator.php new file mode 100644 index 0000000..016ac9a --- /dev/null +++ b/src/Infrastructure/Utility/Hydrator.php @@ -0,0 +1,28 @@ + $class The FQN + * + * @return T[] The list of object + */ + public function hydrate(array $rows, string $class): array + { + $result = []; + + foreach ($rows as $row) { + // Some classes like UserData have a restriction + $result[] = new $class($row, true); + } + + return $result; + } +} diff --git a/src/Infrastructure/Utility/Settings.php b/src/Infrastructure/Utility/Settings.php new file mode 100644 index 0000000..b2fe1e6 --- /dev/null +++ b/src/Infrastructure/Utility/Settings.php @@ -0,0 +1,25 @@ +settings = $settings; + } + + /** + * Get settings by key. + * + * @param string $key + * + * @return mixed + */ + public function get(string $key): mixed + { + return $this->settings[$key] ?? null; + } +} diff --git a/tests/Fixture/UserFixture.php b/tests/Fixture/UserFixture.php new file mode 100644 index 0000000..5447466 --- /dev/null +++ b/tests/Fixture/UserFixture.php @@ -0,0 +1,37 @@ + 1, + 'first_name' => 'Example', + 'last_name' => 'User', + 'email' => 'user@example.com', + 'updated_at' => '2021-01-01 00:00:01', + 'created_at' => '2021-01-01 00:00:01', + 'deleted_at' => null, + ], + ]; + + public function getTable(): string + { + return $this->table; + } + + public function getRecords(): array + { + return $this->records; + } +} diff --git a/tests/Integration/User/UserCreateActionTest.php b/tests/Integration/User/UserCreateActionTest.php new file mode 100644 index 0000000..47c9e0b --- /dev/null +++ b/tests/Integration/User/UserCreateActionTest.php @@ -0,0 +1,39 @@ + 1, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@doe.com', + ]; + + // Make request + $request = $this->createJsonRequest('POST', $this->urlFor('user-create'), $testUserData); + $response = $this->app->handle($request); + + // Assert status code + self::assertSame(StatusCodeInterface::STATUS_CREATED, $response->getStatusCode()); + + // Assert that the user was created in the database + $this->assertTableRow($testUserData, 'user', $testUserData['id']); + } +} \ No newline at end of file diff --git a/tests/Integration/User/UserFetchListActionTest.php b/tests/Integration/User/UserFetchListActionTest.php new file mode 100644 index 0000000..dd9e696 --- /dev/null +++ b/tests/Integration/User/UserFetchListActionTest.php @@ -0,0 +1,45 @@ + 1, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@doe.com', + ]; + + // Insert test data + $this->insertFixture(new UserFixture(), $testUserRow); + + // Make request + $request = $this->createJsonRequest('GET', $this->urlFor('user-list')); + $response = $this->app->handle($request); + + // Assert status code + self::assertSame(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); + + $jsonData = $this->getJsonData($response); + + // Assert that response data contains the user row inserted into the database above + self::assertArrayIsEqualToArrayOnlyConsideringListOfKeys($testUserRow, $jsonData[0], array_keys($testUserRow)); + } +} diff --git a/tests/Provider/empty b/tests/Provider/empty new file mode 100644 index 0000000..e69de29 diff --git a/tests/Trait/AppTestTrait.php b/tests/Trait/AppTestTrait.php new file mode 100644 index 0000000..c3a95a6 --- /dev/null +++ b/tests/Trait/AppTestTrait.php @@ -0,0 +1,71 @@ +app = require __DIR__ . '/../../config/bootstrap.php'; + + // Set $this->container to container instance + $this->setUpContainer($this->app->getContainer()); + + // If setUp() is called in a testClass that uses DatabaseTestTrait, the method setUpDatabase() exists + if (method_exists($this, 'setUpDatabase')) { + // Check that database name from config contains the word "test" + // This is a double security check to prevent unwanted use of dev db for testing + if (!str_contains($this->container->get('settings')['db']['database'], 'test')) { + throw new UnexpectedValueException('Test database name MUST contain the word "test"'); + } + + // Create tables + $this->setUpDatabase($this->container->get('settings')['root_dir'] . '/resources/schema/schema.sql'); + } + } + + /** + * Function called after each test + * Close database connection to prevent errors: + * - PDOException: Packets out of order. Expected 0 received 1. Packet size=23 + * - PDOException: SQLSTATE[HY000] [2006] MySQL server has gone away + * - Cake\Database\Exception\MissingConnectionException: + * Connection to Mysql could not be established: SQLSTATE[08004] [1040] Too many connections. + * + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \Psr\Container\ContainerExceptionInterface + * + * @return void + */ + protected function tearDown(): void + { + // Disconnect from database to avoid "too many connections" errors + if (method_exists($this, 'setUpDatabase')) { + $connection = $this->container->get(Connection::class); + $connection->rollback(); + $connection->getDriver()->disconnect(); + if ($this->container instanceof Container) { + $this->container->set(Connection::class, null); + $this->container->set(\PDO::class, null); + } + } + } +} diff --git a/tests/Unit/empty b/tests/Unit/empty new file mode 100644 index 0000000..e69de29