├── .gitattributes ├── .github └── workflows │ └── build-app-image.yml ├── .gitignore ├── .idea └── runConfigurations │ ├── down_local_dev.xml │ ├── openapi.xml │ └── up_local_dev.xml ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── builder ├── BaseScripts.php ├── PostCreateScript.php └── Scripts.php ├── composer.json ├── config ├── config-dev.env ├── config-dev.php ├── config-prod.php ├── config-staging.php ├── config-test.env └── config-test.php ├── db ├── base.sql └── migrations │ ├── down │ └── 00000-rollback-table-users.sql │ └── up │ └── 00001-create-table-users.sql ├── docker-compose-dev.yml ├── docker ├── Dockerfile ├── fpm │ └── php │ │ └── custom.ini └── nginx │ └── conf.d │ └── default.conf ├── docs ├── code_generator.md ├── functional_test.md ├── getting_started.md ├── getting_started_01_create_table.md ├── getting_started_02_add_new_field.md ├── getting_started_03_create_rest_method.md ├── login.md ├── migration.md ├── orm.md ├── psr11.md ├── psr11_di.md ├── rest.md └── windows.md ├── phpunit.xml.dist ├── post-install.jpg ├── public ├── app.php └── docs │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── index.html │ ├── openapi.js │ ├── openapi.json │ └── require.js ├── src ├── Model │ ├── Dummy.php │ ├── DummyHex.php │ └── User.php ├── OpenApiSpec.php ├── Psr11.php ├── Repository │ ├── BaseRepository.php │ ├── DummyHexRepository.php │ ├── DummyRepository.php │ └── UserDefinition.php ├── Rest │ ├── DummyHexRest.php │ ├── DummyRest.php │ ├── Login.php │ ├── Sample.php │ └── SampleProtected.php └── Util │ ├── FakeApiRequester.php │ ├── JwtContext.php │ └── OpenApiContext.php ├── templates ├── codegen │ ├── config.php.jinja │ ├── model.php.jinja │ ├── repository.php.jinja │ ├── rest.php.jinja │ └── test.php.jinja └── emails │ ├── email.html │ └── email_code.html └── tests └── Rest ├── BaseApiTestCase.php ├── Credentials.php ├── DummyHexTest.php ├── DummyTest.php ├── LoginTest.php ├── SampleProtectedTest.php └── SampleTest.php /.gitattributes: -------------------------------------------------------------------------------- 1 | # Declare files that will always have LF line endings on checkout. 2 | *.css text eol=lf 3 | *.html text eol=lf 4 | *.js text eol=lf 5 | *.sh text eol=lf 6 | *.json text eol=lf 7 | *.md text eol=lf 8 | *.gitattributes text eol=lf 9 | *.gitignore text eol=lf 10 | *.php text eol=lf 11 | *.env text eol=lf 12 | -------------------------------------------------------------------------------- /.github/workflows/build-app-image.yml: -------------------------------------------------------------------------------- 1 | name: Build App Image 2 | on: 3 | push: 4 | branches: [ master ] 5 | # Publish semver tags as releases. 6 | tags: [ 'v*.*.*' ] 7 | pull_request: 8 | branches: [ master ] 9 | 10 | workflow_dispatch: 11 | inputs: 12 | push: 13 | description: 'Push Image (true or false)' 14 | type: choice 15 | options: 16 | - "true" 17 | - "false" 18 | required: true 19 | run_tests: 20 | description: 'Run unit tests (true or false)' 21 | type: choice 22 | options: 23 | - "true" 24 | - "false" 25 | required: true 26 | default: 'true' 27 | 28 | env: 29 | # Use docker.io for Docker Hub if empty 30 | REGISTRY: ghcr.io 31 | # github.repository as / 32 | IMAGE_NAME: ${{ github.repository }} 33 | 34 | # Unit test 35 | DB_DATABASE: mydb 36 | DB_USERNAME: root 37 | DB_PASSWORD: mysqlp455w0rd 38 | 39 | permissions: 40 | contents: read 41 | packages: write 42 | 43 | jobs: 44 | build_backend_app: 45 | runs-on: ubuntu-latest 46 | 47 | services: 48 | mysql-container: 49 | image: mysql:8.0 50 | env: 51 | MYSQL_ALLOW_EMPTY_PASSWORD: false 52 | MYSQL_ROOT_PASSWORD: ${{ env.DB_PASSWORD }} 53 | MYSQL_DATABASE: ${{ env.DB_DATABASE }} 54 | ports: 55 | - 3306/tcp 56 | options: --name mysql --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 57 | 58 | steps: 59 | - name: Checkout repository 60 | uses: actions/checkout@v4 61 | 62 | - name: Set up QEMU 63 | uses: docker/setup-qemu-action@v3 64 | 65 | - name: Set up Docker Buildx 66 | uses: docker/setup-buildx-action@v3 67 | 68 | # Login against a Docker registry to pull the GHCR image 69 | # https://github.com/docker/login-action 70 | - name: Log into registry 71 | uses: docker/login-action@v3 72 | with: 73 | registry: ${{ env.REGISTRY }} 74 | username: ${{ github.actor }} 75 | password: ${{ secrets.GITHUB_TOKEN }} 76 | 77 | # Extract metadata (tags, labels) for Docker 78 | # https://github.com/docker/metadata-action 79 | - name: Extract Docker metadata 80 | id: meta 81 | uses: docker/metadata-action@v4 82 | with: 83 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 84 | 85 | # Build image locally to enable to run the test 86 | # https://github.com/docker/build-push-action 87 | - name: Build Docker image 88 | uses: docker/build-push-action@v5 89 | with: 90 | context: . 91 | file: docker/Dockerfile 92 | push: false 93 | platforms: linux/amd64 #,linux/arm64 94 | tags: ${{ steps.meta.outputs.tags }} 95 | labels: ${{ steps.meta.outputs.labels }} 96 | load: true 97 | 98 | - name: Inspect 99 | run: | 100 | for tag in $(echo "${{ steps.meta.outputs.tags }}"); do 101 | docker image inspect $tag 102 | done 103 | 104 | - name: Test with phpunit 105 | if: ${{ github.event.inputs.run_tests != 'false' }} 106 | run: | 107 | for tag in $(echo "${{ steps.meta.outputs.tags }}"); do 108 | docker run \ 109 | -e APP_ENV=test \ 110 | -p 80:80 \ 111 | --name tester \ 112 | --rm -d \ 113 | --network ${{ job.container.network }} \ 114 | $tag tail -f /dev/null 115 | docker cp tests/ tester:/srv 116 | docker exec tester composer run migrate -- reset --yes 117 | docker exec tester composer install --dev --no-interaction --no-progress 118 | docker exec tester composer run test 119 | docker stop tester 120 | break 121 | done 122 | 123 | # PUSH the image (if isn't PR) 124 | - name: Push Docker image 125 | if: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }} 126 | run: | 127 | for tag in $(echo "${{ steps.meta.outputs.tags }}"); do 128 | docker push $tag 129 | done 130 | 131 | - name: Job Summary 132 | run: | 133 | for tag in $(echo "${{ steps.meta.outputs.tags }}"); do 134 | echo Docker Image: $tag 135 | done 136 | echo Push Image: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }} 137 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | !.idea/runConfigurations 3 | /Dockerfile 4 | composer.lock 5 | vendor 6 | /src/*.db 7 | node_modules 8 | .mysql 9 | .phpunit.result.cache 10 | TODO 11 | 12 | thunder-tests 13 | 14 | .secrets 15 | .env 16 | ignore.txt 17 | -------------------------------------------------------------------------------- /.idea/runConfigurations/down_local_dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations/openapi.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations/up_local_dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Built-in web server", 9 | "type": "php", 10 | "request": "launch", 11 | "runtimeArgs": [ 12 | "-dxdebug.mode=debug", 13 | "-dxdebug.start_with_request=yes", 14 | "-S", 15 | "localhost:8080", 16 | "app.php" 17 | ], 18 | "program": "", 19 | "env": { 20 | "APP_ENV": "test", 21 | "XDEBUG_MODE": "debug,develop", 22 | "XDEBUG_CONFIG": "client_port=${port}" 23 | }, 24 | "cwd": "${workspaceRoot}/public", 25 | "port": 9003, 26 | "serverReadyAction": { 27 | "pattern": "Development Server \\(http://localhost:([0-9]+)\\) started", 28 | "uriFormat": "http://localhost:%s/sample/ping", 29 | "action": "openExternally" 30 | } 31 | }, 32 | { 33 | "name": "Debug current Script in Console", 34 | "type": "php", 35 | "request": "launch", 36 | "program": "${file}", 37 | "cwd": "${fileDirname}", 38 | "port": 9003, 39 | "runtimeArgs": [ 40 | "-dxdebug.start_with_request=yes" 41 | ], 42 | "env": { 43 | "XDEBUG_MODE": "debug,develop", 44 | "XDEBUG_CONFIG": "client_port=${port}" 45 | } 46 | }, 47 | { 48 | "name": "PHPUnit Debug", 49 | "type": "php", 50 | "request": "launch", 51 | "program": "${workspaceFolder}/vendor/bin/phpunit", 52 | "cwd": "${workspaceFolder}", 53 | "port": 9003, 54 | "runtimeArgs": [ 55 | "-dxdebug.start_with_request=yes" 56 | ], 57 | "env": { 58 | "APP_ENV": "test", 59 | "XDEBUG_MODE": "debug,develop", 60 | "XDEBUG_CONFIG": "client_port=${port}" 61 | } 62 | }, 63 | { 64 | "name": "PHPUnit Debug (Current file)", 65 | "type": "php", 66 | "request": "launch", 67 | "program": "${workspaceFolder}/vendor/bin/phpunit", 68 | "args": [ 69 | "${file}" 70 | ], 71 | "cwd": "${workspaceFolder}", 72 | "port": 9003, 73 | "runtimeArgs": [ 74 | "-dxdebug.start_with_request=yes" 75 | ], 76 | "env": { 77 | "APP_ENV": "test", 78 | "XDEBUG_MODE": "debug,develop", 79 | "XDEBUG_CONFIG": "client_port=${port}" 80 | } 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Joao Gilberto Magalhaes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reference Architecture project for RESTFul services in PHP 2 | 3 | [![Build Status](https://github.com/byjg/php-rest-template/actions/workflows/build-app-image.yml/badge.svg?branch=master)](https://github.com/byjg/php-rest-template/actions/workflows/build-app-image.yml) 4 | [![Opensource ByJG](https://img.shields.io/badge/opensource-byjg-success.svg)](http://opensource.byjg.com) 5 | [![GitHub source](https://img.shields.io/badge/Github-source-informational?logo=github)](https://github.com/byjg/php-rest-template/) 6 | [![GitHub license](https://img.shields.io/github/license/byjg/php-rest-template.svg)](https://opensource.byjg.com/opensource/licensing.html) 7 | [![GitHub release](https://img.shields.io/github/release/byjg/php-rest-template.svg)](https://github.com/byjg/php-rest-template/releases/) 8 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/byjg/php-rest-template/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/byjg/php-rest-template/?branch=master) 9 | 10 | This project is a boilerplate for create Rest Applications API Ready to Use with the best techniques to improve your productivity. 11 | 12 | ## What is a PHP Rest Template? 13 | 14 | ```mermaid 15 | mindmap 16 | (("Reference Architecture")) 17 | ("PSR Standards") 18 | ("WebRequests") 19 | ("Container & Dependency Injection") 20 | ("Cache") 21 | ("Authentication & Authorization") 22 | ("Decoupled Code") 23 | ("Database") 24 | ("ORM Integration") 25 | ("Migration") 26 | ("Routing") 27 | ("OpenAPI Integration") 28 | ("Rest Methods") 29 | ("Contract Testing") 30 | ("Documentation") 31 | ("Error Handling") 32 | ``` 33 | 34 | It is a PHP-based RESTful API template or boilerplate that aims to simplify the development process of RESTful web services in PHP. 35 | It provides a foundation or starting point for building APIs following REST architectural principles. 36 | 37 | Using this PHP Rest Reference Architecture you can focus on the business logic of your application and not in the infrastructure as for example: 38 | 39 | - Rapid Development: By offering a pre-defined structure and essential components, the template can expedite the process of building RESTful APIs in PHP. 40 | - Standardization: The template promotes consistency and adherence to RESTful design principles, making it easier for developers to understand and collaborate on the codebase. 41 | - Customizable: Developers can modify and extend the template to fit their specific requirements, allowing for flexibility in implementing additional features or business logic. 42 | 43 | Key features and components: 44 | 45 | - Uses [OpenAPI](https://swagger.io/specification/) specification for API documentation and endpoint creation. 46 | - Routing: Includes a routing system that helps map incoming HTTP requests to specific API endpoints or resources. 47 | - Middleware: It allows developers to add custom logic or perform operations before and after the request is processed. 48 | - Handling: The project offer utilities to handle and parse incoming requests, extract parameters, and handle request methods (GET, POST, PUT, DELETE, etc.). 49 | - Response Formatting: It provides mechanisms to format and structure API responses, including JSON serialization, error handling, and status codes. 50 | - Authentication and Authorization: The template include support for implementing authentication and authorization mechanisms to secure API endpoints using JWT. 51 | - Database Integration: It offers integration for connecting to databases, executing queries, and managing data persistence. 52 | - Error Handling: The project include error handling mechanisms to properly handle and format error responses. 53 | - Dependency Injection: It includes support for dependency injection and inversion of control (IoC) containers. 54 | - Testing: It includes support for testing the API endpoints and resources, including unit testing and functional testing. 55 | - PHP Standards: PSR-7 (Http Message Interface), PSR-11 (Container), PSR-16 and PSR-6 (Cache Interface) and others. 56 | 57 | This project is not a framework. It is a template that you can use to create your own project. You can use the template as a starting point for your own project and customize it to fit your specific requirements. 58 | 59 | ## Some Features Explained 60 | 61 | This project install the follow components (click on the link for more details): 62 | 63 | - [Rest Methods API integrated with OpenAPI](docs/rest.md) 64 | - [Functional Unit Tests of your Rest Method API](docs/functional_test.md) 65 | - [PSR-11 Container and different environments](docs/psr11.md) 66 | - [Dependency Injection](docs/psr11_di.md) 67 | - [Login Integration with JWT](docs/login.md) 68 | - [Database Migration](docs/migration.md) 69 | - [Database ORM](docs/orm.md) 70 | 71 | ## Getting Started 72 | 73 | Here some examples of how to use the template: 74 | 75 | - [Getting Started, Installing and create a new project](docs/getting_started.md) 76 | - [Add a new Table](docs/getting_started_01_create_table.md) 77 | - [Add a new Field](docs/getting_started_02_add_new_field.md) 78 | - [Add a new Rest Method](docs/getting_started_03_create_rest_method.md) 79 | 80 | ---- 81 | [Open source ByJG](http://opensource.byjg.com) 82 | -------------------------------------------------------------------------------- /builder/BaseScripts.php: -------------------------------------------------------------------------------- 1 | workdir = realpath(__DIR__ . '/..'); 23 | } 24 | 25 | public function getSystemOs(): string 26 | { 27 | if (!$this->systemOs) { 28 | $this->systemOs = php_uname('s'); 29 | if (preg_match('/[Dd]arwin/', $this->systemOs)) { 30 | $this->systemOs = 'Darwin'; 31 | } elseif (preg_match('/[Ww]in/', $this->systemOs)) { 32 | $this->systemOs = 'Windows'; 33 | } 34 | } 35 | 36 | return $this->systemOs; 37 | } 38 | 39 | public function fixDir($command) 40 | { 41 | if ($this->getSystemOs() === "Windows") { 42 | return str_replace('/', '\\', $command); 43 | } 44 | return $command; 45 | } 46 | 47 | /** 48 | * Execute the given command by displaying console output live to the user. 49 | * 50 | * @param array|string $cmd : command to be executed 51 | * @return array|null exit_status : exit status of the executed command 52 | * output : console output of the executed command 53 | * @throws ConfigException 54 | * @throws ConfigNotFoundException 55 | * @throws DependencyInjectionException 56 | * @throws InvalidArgumentException 57 | * @throws InvalidDateException 58 | * @throws KeyNotFoundException 59 | * @throws ReflectionException 60 | */ 61 | protected function liveExecuteCommand(array|string $cmd): ?array 62 | { 63 | // while (@ ob_end_flush()); // end all output buffers if any 64 | 65 | if (is_array($cmd)) { 66 | foreach ($cmd as $item) { 67 | $this->liveExecuteCommand($item); 68 | } 69 | return null; 70 | } 71 | 72 | $cmd = $this->replaceVariables($cmd); 73 | echo "\n>> $cmd\n"; 74 | 75 | $complement = " 2>&1 ; echo Exit status : $?"; 76 | if ($this->getSystemOs() === "Windows") { 77 | $complement = ' & echo Exit status : %errorlevel%'; 78 | } 79 | $proc = popen("$cmd $complement", 'r'); 80 | 81 | $completeOutput = ""; 82 | 83 | while (!feof($proc)) { 84 | $liveOutput = fread($proc, 4096); 85 | $completeOutput = $completeOutput . $liveOutput; 86 | echo "$liveOutput"; 87 | @ flush(); 88 | } 89 | 90 | pclose($proc); 91 | 92 | // get exit status 93 | preg_match('/[0-9]+$/', $completeOutput, $matches); 94 | 95 | $exitStatus = intval($matches[0]); 96 | // if ($exitStatus !== 0) { 97 | // exit($exitStatus); 98 | // } 99 | 100 | // return exit status and intended output 101 | return array ( 102 | 'exit_status' => $exitStatus, 103 | 'output' => str_replace("Exit status : " . $matches[0], '', $completeOutput) 104 | ); 105 | } 106 | 107 | /** 108 | * @param string|Closure $variableValue 109 | * @return array|Closure|string|string[] 110 | * @throws ConfigNotFoundException 111 | * @throws DependencyInjectionException 112 | * @throws InvalidArgumentException 113 | * @throws KeyNotFoundException 114 | * @throws ReflectionException 115 | * @throws ConfigException 116 | * @throws InvalidDateException 117 | */ 118 | protected function replaceVariables($variableValue) 119 | { 120 | $args = []; 121 | if (preg_match_all("/%[\\w\\d]+%/", $variableValue, $args)) { 122 | foreach ($args[0] as $arg) { 123 | $variableValue = str_replace( 124 | $arg, 125 | Psr11::get(substr($arg,1, -1)), 126 | $variableValue 127 | ); 128 | } 129 | } 130 | 131 | return $variableValue; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /builder/PostCreateScript.php: -------------------------------------------------------------------------------- 1 | getFilename()[0] === '.') { 24 | return false; 25 | } 26 | if ($current->isDir()) { 27 | // Only recurse into intended subdirectories. 28 | return $current->getFilename() !== 'fw'; 29 | } 30 | // else { 31 | // // Only consume files of interest. 32 | // return strpos($current->getFilename(), 'wanted_filename') === 0; 33 | // } 34 | return true; 35 | }); 36 | 37 | $composerName = strtolower($composerName); 38 | $composerParts = explode("/", $composerName); 39 | $phpVersionMSimple = str_replace(".", "", $phpVersion); 40 | 41 | // ------------------------------------------------ 42 | //Replace composer name: 43 | $contents = file_get_contents($workdir . '/composer.json'); 44 | file_put_contents( 45 | $workdir . '/composer.json', 46 | str_replace('byjg/rest-reference-architecture', $composerName, $contents) 47 | ); 48 | 49 | // ------------------------------------------------ 50 | // Replace Docker PHP Version 51 | $files = [ 'docker/Dockerfile' ]; 52 | foreach ($files as $file) { 53 | $contents = file_get_contents("$workdir/$file"); 54 | $contents = str_replace('ENV TZ=UTC', "ENV TZ=$timezone", $contents); 55 | $contents = str_replace('php:8.3-fpm', "php:$phpVersion-fpm", $contents); 56 | $contents = str_replace('php83', "php$phpVersionMSimple", $contents); 57 | file_put_contents( 58 | "$workdir/$file", 59 | $contents 60 | ); 61 | } 62 | 63 | // ------------------------------------------------ 64 | // Adjusting config files 65 | $files = [ 66 | 'config/config-dev.php', 67 | 'config/config-staging.php' , 68 | 'config/config-prod.php', 69 | 'config/config-test.php', 70 | 'docker-compose-dev.yml', 71 | ]; 72 | $uri = new Uri($mysqlConnection); 73 | foreach ($files as $file) { 74 | $contents = file_get_contents("$workdir/$file"); 75 | $contents = str_replace( 'jwt_super_secret_key', JwtWrapper::generateSecret(64), $contents); 76 | $contents = str_replace('mysql://root:mysqlp455w0rd@mysql-container/mydb', "$mysqlConnection", $contents); 77 | $contents = str_replace('mysql-container', $uri->getHost(), $contents); 78 | $contents = str_replace('mysqlp455w0rd', $uri->getPassword(), $contents); 79 | $contents = str_replace('resttest', $composerParts[1], $contents); 80 | file_put_contents( 81 | "$workdir/$file", 82 | $contents 83 | ); 84 | } 85 | 86 | // ------------------------------------------------ 87 | // Adjusting namespace 88 | $objects = new RecursiveIteratorIterator($filter); 89 | foreach ($objects as $name => $object) { 90 | $contents = file_get_contents($name); 91 | if (str_contains($contents, 'RestReferenceArchitecture')) { 92 | echo "$name\n"; 93 | 94 | // Replace inside Quotes 95 | $contents = preg_replace( 96 | "/(['\"])RestReferenceArchitecture(.*?['\"])/", 97 | '$1' . str_replace('\\', '\\\\\\\\', $namespace) . '$2', 98 | $contents 99 | ); 100 | 101 | // Replace reserved name 102 | $contents = str_replace('RestReferenceArchitecture', $namespace, $contents); 103 | 104 | // Replace reserved name 105 | $contents = str_replace( 106 | 'rest-reference-architecture', 107 | str_replace('/', '', $composerName), 108 | $contents 109 | ); 110 | 111 | // Save it 112 | file_put_contents( 113 | $name, 114 | $contents 115 | ); 116 | } 117 | } 118 | 119 | shell_exec("composer update"); 120 | shell_exec("git init"); 121 | shell_exec("git branch -m main"); 122 | shell_exec("git add ."); 123 | shell_exec("git commit -m 'Initial commit'"); 124 | } 125 | 126 | /** 127 | * @param Event $event 128 | * @return void 129 | * @throws Exception 130 | */ 131 | public static function run(Event $event) 132 | { 133 | $workdir = realpath(__DIR__ . '/..'); 134 | $stdIo = $event->getIO(); 135 | 136 | $currentPhpVersion = PHP_MAJOR_VERSION . "." .PHP_MINOR_VERSION; 137 | 138 | $validatePHPVersion = function ($arg) { 139 | $validPHPVersions = ['8.1', '8.2', '8.3']; 140 | if (in_array($arg, $validPHPVersions)) { 141 | return $arg; 142 | } 143 | throw new Exception('Only the PHP versions ' . implode(', ', $validPHPVersions) . ' are supported'); 144 | }; 145 | 146 | $validateNamespace = function ($arg) { 147 | if (empty($arg) || !preg_match('/^[A-Z][a-zA-Z0-9]*$/', $arg)) { 148 | throw new Exception('Namespace must be one word in CamelCase'); 149 | } 150 | return $arg; 151 | }; 152 | 153 | $validateComposer = function ($arg) { 154 | if (empty($arg) || !preg_match('/^[a-z0-9-]+\/[a-z0-9-]+$/', $arg)) { 155 | throw new Exception('Invalid Composer name'); 156 | } 157 | return $arg; 158 | }; 159 | 160 | $validateURI = function ($arg) { 161 | $uri = new Uri($arg); 162 | if (empty($uri->getScheme())) { 163 | throw new Exception('Invalid URI'); 164 | } 165 | Factory::getRegisteredDrivers($uri->getScheme()); 166 | return $arg; 167 | }; 168 | 169 | $validateTimeZone = function ($arg) { 170 | if (empty($arg) || !in_array($arg, timezone_identifiers_list())) { 171 | throw new Exception('Invalid Timezone'); 172 | } 173 | return $arg; 174 | }; 175 | 176 | $maxRetries = 5; 177 | 178 | $stdIo->write("========================================================"); 179 | $stdIo->write(" Setup Project"); 180 | $stdIo->write(" Answer the questions below"); 181 | $stdIo->write("========================================================"); 182 | $stdIo->write(""); 183 | $stdIo->write("Project Directory: " . $workdir); 184 | $phpVersion = $stdIo->askAndValidate("PHP Version [$currentPhpVersion]: ", $validatePHPVersion, $maxRetries, $currentPhpVersion); 185 | $namespace = $stdIo->askAndValidate('Project namespace [MyRest]: ', $validateNamespace, $maxRetries, 'MyRest'); 186 | $composerName = $stdIo->askAndValidate('Composer name [me/myrest]: ', $validateComposer, $maxRetries, 'me/myrest'); 187 | $mysqlConnection = $stdIo->askAndValidate('MySQL connection DEV [mysql://root:mysqlp455w0rd@mysql-container/mydb]: ', $validateURI, $maxRetries, 'mysql://root:mysqlp455w0rd@mysql-container/mydb'); 188 | $timezone = $stdIo->askAndValidate('Timezone [UTC]: ', $validateTimeZone, $maxRetries, 'UTC'); 189 | $stdIo->ask('Press to continue'); 190 | 191 | $script = new PostCreateScript(); 192 | $script->execute($workdir, $namespace, $composerName, $phpVersion, $mysqlConnection, $timezone); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "byjg/rest-reference-architecture", 3 | "description": "ByJG's Reference Architecture project for RESTFul services in PHP with docker and database integrated", 4 | "minimum-stability": "dev", 5 | "prefer-stable": true, 6 | "license": "MIT", 7 | "require": { 8 | "php": ">=8.1 <8.4", 9 | "ext-json": "*", 10 | "ext-openssl": "*", 11 | "ext-curl": "*", 12 | "byjg/config": "^5.0", 13 | "byjg/anydataset-db": "^5.0", 14 | "byjg/micro-orm": "^5.0", 15 | "byjg/authuser": "^5.0", 16 | "byjg/mailwrapper": "^5.0", 17 | "byjg/restserver": "^5.0", 18 | "zircote/swagger-php": "^4.6.1", 19 | "byjg/swagger-test": "^5.0", 20 | "byjg/migration": "^5.0", 21 | "byjg/php-daemonize": "^5.0", 22 | "byjg/shortid": "^5.0", 23 | "byjg/jinja-php": "^5.0" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "5.7.*|7.4.*|^9.5" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "RestReferenceArchitecture\\": "src/", 31 | "Builder\\": "builder/" 32 | } 33 | }, 34 | "scripts": { 35 | "test": "./vendor/bin/phpunit", 36 | "migrate": "Builder\\Scripts::migrate", 37 | "codegen": "Builder\\Scripts::codeGenerator", 38 | "openapi": "Builder\\Scripts::genOpenApiDocs", 39 | "compile": "git pull && composer run openapi && composer run test", 40 | "up-local-dev": "docker compose -f docker-compose-dev.yml up -d", 41 | "down-local-dev": "docker compose -f docker-compose-dev.yml down", 42 | "post-create-project-cmd": "Builder\\PostCreateScript::run" 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Test\\": "tests/" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /config/config-dev.env: -------------------------------------------------------------------------------- 1 | WEB_SERVER=localhost 2 | DASH_SERVER=localhost 3 | WEB_SCHEMA=http 4 | API_SERVER=localhost 5 | API_SCHEMA=http 6 | DBDRIVER_CONNECTION=mysql://root:mysqlp455w0rd@mysql-container/mydb 7 | EMAIL_CONNECTION=smtp://username:password@mail.example.com 8 | EMAIL_TRANSACTIONAL_FROM='No Reply ' 9 | CORS_SERVERS=.* 10 | -------------------------------------------------------------------------------- /config/config-dev.php: -------------------------------------------------------------------------------- 1 | DI::bind(NoCacheEngine::class) 39 | ->toSingleton(), 40 | 41 | OpenAPiRouteList::class => DI::bind(OpenAPiRouteList::class) 42 | ->withConstructorArgs([ 43 | __DIR__ . '/../public/docs/openapi.json' 44 | ]) 45 | ->withMethodCall("withDefaultProcessor", [JsonCleanOutputProcessor::class]) 46 | ->withMethodCall("withCache", [Param::get(BaseCacheEngine::class)]) 47 | ->toSingleton(), 48 | 49 | Schema::class => DI::bind(Schema::class) 50 | ->withFactoryMethod('getInstance', [file_get_contents(__DIR__ . '/../public/docs/openapi.json')]) 51 | ->toSingleton(), 52 | 53 | JwtKeyInterface::class => DI::bind(JwtHashHmacSecret::class) 54 | ->withConstructorArgs(['jwt_super_secret_key']) 55 | ->toSingleton(), 56 | 57 | JwtWrapper::class => DI::bind(JwtWrapper::class) 58 | ->withConstructorArgs([Param::get('API_SERVER'), Param::get(JwtKeyInterface::class)]) 59 | ->toSingleton(), 60 | 61 | MailWrapperInterface::class => function () { 62 | $apiKey = Psr11::get('EMAIL_CONNECTION'); 63 | MailerFactory::registerMailer(MailgunApiWrapper::class); 64 | MailerFactory::registerMailer(FakeSenderWrapper::class); 65 | 66 | return MailerFactory::create($apiKey); 67 | }, 68 | 69 | DbDriverInterface::class => DI::bind(Factory::class) 70 | ->withFactoryMethod("getDbRelationalInstance", [Param::get('DBDRIVER_CONNECTION')]) 71 | ->toSingleton(), 72 | 73 | DummyRepository::class => DI::bind(DummyRepository::class) 74 | ->withInjectedConstructor() 75 | ->toSingleton(), 76 | 77 | DummyHexRepository::class => DI::bind(DummyHexRepository::class) 78 | ->withInjectedConstructor() 79 | ->toSingleton(), 80 | 81 | PasswordDefinition::class => DI::bind(PasswordDefinition::class) 82 | ->withConstructorArgs([[ 83 | PasswordDefinition::MINIMUM_CHARS => 12, 84 | PasswordDefinition::REQUIRE_UPPERCASE => 1, // Number of uppercase characters 85 | PasswordDefinition::REQUIRE_LOWERCASE => 1, // Number of lowercase characters 86 | PasswordDefinition::REQUIRE_SYMBOLS => 1, // Number of symbols 87 | PasswordDefinition::REQUIRE_NUMBERS => 1, // Number of numbers 88 | PasswordDefinition::ALLOW_WHITESPACE => 0, // Allow whitespace 89 | PasswordDefinition::ALLOW_SEQUENTIAL => 0, // Allow sequential characters 90 | PasswordDefinition::ALLOW_REPEATED => 0 // Allow repeated characters 91 | ]]) 92 | ->toSingleton(), 93 | 94 | UserDefinition::class => DI::bind(UserDefinitionAlias::class) 95 | ->withConstructorArgs( 96 | [ 97 | 'users', // Table name 98 | User::class, // User class 99 | UserDefinition::LOGIN_IS_EMAIL, 100 | [ 101 | // Field name in the User class => Field name in the database 102 | 'userid' => 'userid', 103 | 'name' => 'name', 104 | 'email' => 'email', 105 | 'username' => 'username', 106 | 'password' => 'password', 107 | 'created' => 'created', 108 | 'admin' => 'admin' 109 | ] 110 | ] 111 | ) 112 | ->toSingleton(), 113 | 114 | UserPropertiesDefinition::class => DI::bind(UserPropertiesDefinition::class) 115 | ->toSingleton(), 116 | 117 | UsersDBDataset::class => DI::bind(UsersDBDataset::class) 118 | ->withInjectedConstructor() 119 | ->toSingleton(), 120 | 121 | 'CORS_SERVER_LIST' => function () { 122 | return preg_split('/,(?![^{}]*})/', Psr11::get('CORS_SERVERS')); 123 | }, 124 | 125 | JwtMiddleware::class => DI::bind(JwtMiddleware::class) 126 | ->withConstructorArgs([ 127 | Param::get(JwtWrapper::class) 128 | ]) 129 | ->toSingleton(), 130 | 131 | CorsMiddleware::class => DI::bind(CorsMiddleware::class) 132 | ->withNoConstructor() 133 | ->withMethodCall("withCorsOrigins", [Param::get("CORS_SERVER_LIST")]) // Required to enable CORS 134 | // ->withMethodCall("withAcceptCorsMethods", [[/* list of methods */]]) // Optional. Default all methods. Don't need to pass 'OPTIONS' 135 | // ->withMethodCall("withAcceptCorsHeaders", [[/* list of headers */]]) // Optional. Default all headers 136 | ->toSingleton(), 137 | 138 | LoggerInterface::class => DI::bind(NullLogger::class) 139 | ->toSingleton(), 140 | 141 | HttpRequestHandler::class => DI::bind(HttpRequestHandler::class) 142 | ->withConstructorArgs([ 143 | Param::get(LoggerInterface::class) 144 | ]) 145 | ->withMethodCall("withMiddleware", [Param::get(JwtMiddleware::class)]) 146 | ->withMethodCall("withMiddleware", [Param::get(CorsMiddleware::class)]) 147 | // ->withMethodCall("withDetailedErrorHandler", []) 148 | ->toSingleton(), 149 | 150 | // ---------------------------------------------------------------------------- 151 | 152 | 'MAIL_ENVELOPE' => function ($to, $subject, $template, $mapVariables = []) { 153 | $body = ""; 154 | 155 | $loader = new FileSystemLoader(__DIR__ . "/../templates/emails", ".html"); 156 | $template = $loader->getTemplate($template); 157 | $body = $template->render($mapVariables); 158 | 159 | $prefix = ""; 160 | if (Psr11::environment()->getCurrentEnvironment() != "prod") { 161 | $prefix = "[" . Psr11::environment()->getCurrentEnvironment() . "] "; 162 | } 163 | return new Envelope(Psr11::get('EMAIL_TRANSACTIONAL_FROM'), $to, $prefix . $subject, $body, true); 164 | }, 165 | 166 | ]; 167 | -------------------------------------------------------------------------------- /config/config-prod.php: -------------------------------------------------------------------------------- 1 | DI::bind(JwtHashHmacSecret::class) 9 | ->withConstructorArgs(['jwt_super_secret_key']) 10 | ->toSingleton(), 11 | ]; 12 | -------------------------------------------------------------------------------- /config/config-staging.php: -------------------------------------------------------------------------------- 1 | DI::bind(FileSystemCacheEngine::class)->toSingleton(), 12 | 13 | JwtKeyInterface::class => DI::bind(JwtHashHmacSecret::class) 14 | ->withConstructorArgs(['jwt_super_secret_key']) 15 | ->toSingleton(), 16 | 17 | ]; 18 | -------------------------------------------------------------------------------- /config/config-test.env: -------------------------------------------------------------------------------- 1 | DBDRIVER_CONNECTION=mysql://root:mysqlp455w0rd@mysql-container/mydb 2 | EMAIL_CONNECTION=fakesender://nothing 3 | -------------------------------------------------------------------------------- /config/config-test.php: -------------------------------------------------------------------------------- 1 | DI::bind(JwtHashHmacSecret::class) 9 | ->withConstructorArgs(['jwt_super_secret_key']) 10 | ->toSingleton(), 11 | ]; 12 | 13 | -------------------------------------------------------------------------------- /db/base.sql: -------------------------------------------------------------------------------- 1 | 2 | 3 | -- Migration 1 --> 2 can be removed 4 | -- Just for demo 5 | 6 | create table dummy ( 7 | id INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, 8 | field varchar(10) not null 9 | ); 10 | 11 | create index ix_field on dummy(field); 12 | 13 | insert into dummy (field) values ('fld value'); 14 | insert into dummy (field) values ('Test 1'); 15 | insert into dummy (field) values ('Test 2'); 16 | 17 | create table dummyhex ( 18 | id binary(16) DEFAULT (uuid_to_bin(uuid())) PRIMARY KEY NOT NULL, 19 | `uuid` varchar(36) GENERATED ALWAYS AS (insert(insert(insert(insert(hex(`id`),9,0,'-'),14,0,'-'),19,0,'-'),24,0,'-')) VIRTUAL, 20 | field varchar(10) not null 21 | ); 22 | 23 | insert into dummyhex (field) values ('fld value'); 24 | insert into dummyhex (id, field) values (X'11111111222233334444555555555555', 'Test 1'); 25 | insert into dummyhex (field) values ('Test 2'); 26 | 27 | -------------------------------------------------------------------------------- /db/migrations/down/00000-rollback-table-users.sql: -------------------------------------------------------------------------------- 1 | -- Migration 1 --> 0 can be removed 2 | -- Just for demo 3 | DROP TABLE dummy; 4 | DROP TABLE dummyhex; -------------------------------------------------------------------------------- /db/migrations/up/00001-create-table-users.sql: -------------------------------------------------------------------------------- 1 | -- This table are using by the component byjg/authuser 2 | 3 | create table users 4 | ( 5 | userid binary(16) DEFAULT (uuid_to_bin(uuid())) NOT NULL, 6 | `uuid` varchar(36) GENERATED ALWAYS AS (insert(insert(insert(insert(hex(`userid`),9,0,'-'),14,0,'-'),19,0,'-'),24,0,'-')) VIRTUAL, 7 | name varchar(50), 8 | email varchar(120), 9 | username varchar(20) not null, 10 | password char(40) not null, 11 | created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 12 | updated DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 13 | admin enum('yes','no'), 14 | 15 | constraint pk_users primary key (userid) 16 | ) ENGINE=InnoDB; 17 | 18 | 19 | -- Index 20 | ALTER TABLE `users` 21 | ADD INDEX `ix_users_email` (`email` ASC, `password` ASC), 22 | ADD INDEX `ix_users_username` (`username` ASC, `password` ASC); 23 | 24 | -- Default Password is "pwd" 25 | -- Please change it! 26 | insert into users (name, email, username, password, admin) VALUES 27 | ('Administrator', 'admin@example.com', 'admin', '9800aa1b77334ff0952b203062f0fbb0c480d3de', 'yes'); -- !P4ssw0rdstr! 28 | 29 | insert into users (userid, name, email, username, password, admin) VALUES 30 | (0x5f6e7fe7bd1b11ed8ca90242ac120002, 'Regular User', 'user@example.com', 'user', '9800aa1b77334ff0952b203062f0fbb0c480d3de', 'no') -- !P4ssw0rdstr! 31 | ; 32 | 33 | -- random binary(16) generator 34 | -- select hex(uuid_to_bin(uuid())); 35 | 36 | 37 | -- This table are using by the component byjg/authuser 38 | create table users_property 39 | ( 40 | id integer AUTO_INCREMENT not null, 41 | name varchar(20), 42 | value varchar(100), 43 | userid binary(16) NOT NULL, 44 | 45 | constraint pk_custom primary key (id), 46 | constraint fk_custom_user foreign key (userid) references users (userid) 47 | ) ENGINE=InnoDB; 48 | 49 | insert into users_property (name, value, userid) values 50 | ('picture', 'https://www.gravatar.com/avatar/9f4d313491a7df705b7071c228fc79cd', 0x5f6e7fe7bd1b11ed8ca90242ac120002); 51 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | services: 3 | rest: 4 | image: resttest:dev 5 | container_name: resttest 6 | build: 7 | context: . 8 | dockerfile: docker/Dockerfile 9 | ports: 10 | - "8080:80" 11 | volumes: 12 | - .:/srv/ 13 | environment: 14 | - APP_ENV=dev 15 | - PHP_FPM_SERVER=0.0.0.0:9000 16 | - VERBOSE=true 17 | networks: 18 | - net 19 | 20 | mysql-container: 21 | image: mysql:8.0 22 | container_name: mysql-container 23 | environment: 24 | MYSQL_ROOT_PASSWORD: mysqlp455w0rd 25 | TZ: UTC 26 | volumes: 27 | - mysql-volume:/var/lib/mysql 28 | ports: 29 | - "3306:3306" 30 | networks: 31 | - net 32 | 33 | volumes: 34 | mysql-volume: 35 | 36 | networks: 37 | net: 38 | 39 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM byjg/php:8.3-fpm-nginx-2025.03 2 | 3 | # Refer to the documentation to setup the environment variables 4 | # https://github.com/byjg/docker-php/blob/master/docs/environment.md 5 | ENV NGINX_ROOT="/srv/public" 6 | ENV PHP_CONTROLLER="/app.php" 7 | # --------------------------------------------- 8 | 9 | WORKDIR /srv 10 | 11 | # Setup Docker/Fpm 12 | 13 | COPY ./docker/fpm/php /etc/php83/conf.d 14 | COPY ./docker/nginx/conf.d /etc/nginx/http.d/ 15 | 16 | # Setup DateFile 17 | 18 | RUN apk add --no-cache --update tzdata 19 | ENV TZ=UTC 20 | 21 | # Copy project files 22 | 23 | COPY builder /srv/builder 24 | COPY config /srv/config 25 | COPY src /srv/src 26 | COPY public /srv/public 27 | COPY templates /srv/templates 28 | 29 | # This is necessary for the migration script 30 | 31 | COPY composer.* /srv 32 | COPY phpunit.xml.dist /srv 33 | COPY db /srv/db 34 | RUN composer install --no-dev --no-interaction --no-progress --no-scripts --optimize-autoloader && \ 35 | composer dump-autoload --optimize --classmap-authoritative -q 36 | -------------------------------------------------------------------------------- /docker/fpm/php/custom.ini: -------------------------------------------------------------------------------- 1 | expose_php = off 2 | 3 | date.timezone = UTC 4 | max_execution_time = 300 5 | 6 | ; Maximum allowed size for uploaded files. 7 | upload_max_filesize = 40M 8 | 9 | ; Must be greater than or equal to upload_max_filesize 10 | post_max_size = 40M 11 | memory_limit = 192M 12 | -------------------------------------------------------------------------------- /docker/nginx/conf.d/default.conf: -------------------------------------------------------------------------------- 1 | ################################ 2 | # FROM: 3 | # https://github.com/byjg/docker-php/blob/master/assets/fpm-nginx/conf/nginx.vh.default.conf 4 | ################################ 5 | server { 6 | listen 80; 7 | 8 | root @root@; 9 | index index.php index.html index.htm; 10 | 11 | # The lines below will are handled by entrypoint.sh. 12 | # Do not delete it. 13 | #listen 443 ssl; 14 | #ssl_certificate @cert@; 15 | #ssl_certificate_key @certkey@; 16 | 17 | client_header_timeout 240; 18 | client_body_timeout 240; 19 | fastcgi_read_timeout 240; 20 | client_max_body_size 40M; 21 | client_body_buffer_size 40M; 22 | 23 | server_name_in_redirect off; 24 | port_in_redirect off; 25 | 26 | location / { 27 | # The line below will be created by the entrypoint.sh. 28 | # Do not delete it. 29 | #try_files $uri $uri/ @controller@?$query_string; 30 | } 31 | 32 | #error_page 404 /404.html; 33 | 34 | # redirect server error pages to the static page /50x.html 35 | # 36 | error_page 500 502 503 504 /50x.html; 37 | 38 | # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 39 | # 40 | location ~ \.php$ { 41 | fastcgi_pass 127.0.0.1:9000; 42 | 43 | # Setup 44 | include fastcgi_params; 45 | fastcgi_index index.php; 46 | fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name; 47 | fastcgi_split_path_info ^(.+\.php)(/.+)$; 48 | 49 | # Specific 50 | fastcgi_buffer_size 128k; 51 | fastcgi_buffers 4 256k; 52 | fastcgi_busy_buffers_size 256k; 53 | 54 | fastcgi_param X-Real-IP $remote_addr; 55 | fastcgi_param X-Forwarded-For $proxy_add_x_forwarded_for; 56 | } 57 | 58 | location ~* \.(css|js)$ { 59 | expires off; 60 | sendfile off; 61 | break; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docs/code_generator.md: -------------------------------------------------------------------------------- 1 | # Code Generator 2 | 3 | The code generator can create PHP classes based on the database tables. It can create the following classes: 4 | 5 | * Model 6 | * Repository 7 | * Basic CRUD Rest API (GET, POST, PUT) 8 | * Functional Test for the CRUD API 9 | 10 | ## How to use 11 | 12 | The code generator is a command line tool. You can run it using the command: 13 | 14 | ```bash 15 | APP_ENV=dev composer run codegen -- --table [--save] [--debug] 16 | ``` 17 | 18 | The command above will connect to your database and create the classes based on the table `
`. 19 | 20 | You can specify which classes you want to create using the parameter ``. The possible values are: 21 | 22 | * `model` - Create the model class 23 | * `repo` - Create the repository class 24 | * `config` - Create the configuration class 25 | * `rest` - Create the REST API 26 | * `test` - Create the functional test for the REST API 27 | * `all` - Create all classes 28 | 29 | If the parameter `--save` is specified, the classes will be saved to the file in your project folder. It will overwrite existing content you might have. Be cautious. If you don't specify the parameter, the classes will be printed to the console. The `config` class will be printed to the console regardless of the `--save` parameter. 30 | 31 | If you specify the parameter `--debug`, the code generator will print the array with the table structure. 32 | 33 | ## Customizing the code generator 34 | 35 | You can change the existing templates or create your own templates. The templates are located in the folder `templates/codegen`. It uses the [Jinja template engine for PHP](https://github.com/byjg/jinja_php). 36 | -------------------------------------------------------------------------------- /docs/functional_test.md: -------------------------------------------------------------------------------- 1 | # RestAPI Functional Test 2 | 3 | ## Running the tests 4 | 5 | The project has some tests implemented. You can run the tests from the VSCode interface or from the command line. 6 | 7 | ```php 8 | # Create an empty database for test 9 | APP_TEST=test composer run migrate reset yes 10 | 11 | # Set optional values 12 | # export TEST_ADMIN_USER=admin@example.com 13 | # export TEST_ADMIN_PASSWORD='!P4ssw0rdstr!' 14 | # export TEST_REGULAR_USER=user@example.com 15 | # export TEST_REGULAR_PASSWORD='!P4ssw0rdstr!' 16 | 17 | # Run the tests 18 | APP_TEST=test composer run test 19 | ``` 20 | 21 | ## Creating your tests 22 | 23 | We can test the RestAPI as follows: 24 | 25 | ```php 26 | namespace Test\Functional\Rest; 27 | 28 | 29 | use ByJG\ApiTools\Base\Schema; 30 | use RestReferenceArchitecture\Util\FakeApiRequester; 31 | 32 | /** 33 | * Create a TestCase inherited from SwaggerTestCase 34 | */ 35 | class SampleTest extends BaseApiTestCase 36 | { 37 | protected $filePath = __DIR__ . '/../../../public/docs/openapi.json'; 38 | 39 | protected function setUp(): void 40 | { 41 | $schema = Schema::getInstance(file_get_contents($this->filePath)); 42 | $this->setSchema($schema); 43 | 44 | parent::setUp(); 45 | } 46 | 47 | public function testPing() 48 | { 49 | $request = new FakeApiRequester(); 50 | $request 51 | ->withPsr7Request($this->getPsr7Request()) 52 | ->withMethod('GET') 53 | ->withPath("/sample/ping") 54 | ; 55 | $this->assertRequest($request); 56 | } 57 | } 58 | ``` 59 | 60 | The `BaseApiTestCase` is a class that extends the `ByJG\ApiTools\Base\SwaggerTestCase` and provides some helper methods to test the RestAPI. 61 | 62 | The `testPing` method will test the `/sample/ping` endpoint. The `assertRequest` method will test the endpoint and will throw an exception if the endpoint does not match the OpenAPI specification for the status code `200`. 63 | 64 | There is no necessary have a webserver running to test the RestAPI. The `BaseApiTestCase` will create the request and will pass to the `FakeApiRequester` object. The `FakeApiRequester` will call the endpoint as a PHP method and will try to match the result with the OpenAPI specification. 65 | 66 | However, as it is a functional test you need to have the database and other resources accessed by the endpoint running. 67 | 68 | ## Send body data to the test 69 | 70 | We can send body data to the test as follows: 71 | 72 | ```php 73 | public function testPing() 74 | { 75 | $request = new FakeApiRequester(); 76 | $request 77 | ->withPsr7Request($this->getPsr7Request()) 78 | ->withMethod('POST') 79 | ->withPath("/sample/ping") 80 | ->withBody([ 81 | 'name' => 'John Doe' 82 | ]) 83 | ; 84 | $this->assertRequest($request); 85 | } 86 | ``` 87 | 88 | ## Send query parameters to the test 89 | 90 | ```php 91 | public function testPing() 92 | { 93 | $request = new FakeApiRequester(); 94 | $request 95 | ->withPsr7Request($this->getPsr7Request()) 96 | ->withMethod('GET') 97 | ->withPath("/sample/ping") 98 | ->withQuery([ 99 | 'name' => 'John Doe' 100 | ]) 101 | ; 102 | $this->assertRequest($request); 103 | } 104 | ``` 105 | 106 | ## Expect a specific status code 107 | 108 | ```php 109 | public function testPing() 110 | { 111 | $request = new FakeApiRequester(); 112 | $request 113 | ->withPsr7Request($this->getPsr7Request()) 114 | ->withMethod('GET') 115 | ->withPath("/sample/ping") 116 | ->assertResponseCode(404) 117 | ; 118 | $this->assertRequest($request); 119 | } 120 | ``` 121 | 122 | ## Expect a specific response body 123 | 124 | ```php 125 | public function testPing() 126 | { 127 | $request = new FakeApiRequester(); 128 | $request 129 | ->withPsr7Request($this->getPsr7Request()) 130 | ->withMethod('GET') 131 | ->withPath("/sample/ping") 132 | ->assertResponseBody([ 133 | 'message' => 'pong' 134 | ]) 135 | ; 136 | $this->assertRequest($request); 137 | } 138 | ``` 139 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Requirements 4 | 5 | - **Docker Engine**: For containerizing your application 6 | - **PHP 8.3+**: For local development 7 | - **IDE**: Any development environment of your choice 8 | 9 | > **Windows Users**: If you don't have PHP installed in WSL2, please follow the [Windows](windows.md) guide. 10 | 11 | ### Required PHP Extensions 12 | 13 | - ctype 14 | - curl 15 | - dom 16 | - filter 17 | - hash 18 | - json 19 | - libxml 20 | - mbstring 21 | - openssl 22 | - pcre 23 | - pdo 24 | - pdo_mysql 25 | - phar 26 | - simplexml 27 | - tokenizer 28 | - xml 29 | - xmlwriter 30 | 31 | ## Installation 32 | 33 | Choose one of the following installation methods: 34 | 35 | ```shell script 36 | # Standard installation 37 | mkdir ~/tutorial 38 | composer create-project byjg/rest-reference-architecture ~/tutorial ^5.0 39 | 40 | # OR Latest development version 41 | mkdir ~/tutorial 42 | composer -sdev create-project byjg/rest-reference-architecture ~/tutorial master 43 | ``` 44 | 45 | ### Setup Configuration 46 | 47 | The installation will prompt you for configuration details: 48 | 49 | ```text 50 | > Builder\PostCreateScript::run 51 | ======================================================== 52 | Setup Project 53 | Answer the questions below 54 | ======================================================== 55 | 56 | Project Directory: ~/tutorial 57 | PHP Version [8.3]: 8.3 58 | Project namespace [MyRest]: Tutorial 59 | Composer name [me/myrest]: 60 | MySQL connection DEV [mysql://root:mysqlp455w0rd@mysql-container/mydb]: 61 | Timezone [UTC]: 62 | ``` 63 | 64 | **Tip**: To access the MySQL container locally, add this to your `/etc/hosts` file: 65 | ``` 66 | 127.0.0.1 mysql-container 67 | ``` 68 | 69 | ## Running the Project 70 | 71 | ```shell 72 | cd ~/tutorial 73 | docker compose -f docker-compose-dev.yml up -d 74 | ``` 75 | 76 | ## Database Setup 77 | 78 | ```shell 79 | # Create a fresh database (warning: destroys existing data) 80 | APP_ENV=dev composer run migrate -- reset --yes 81 | 82 | # Alternative if local PHP isn't configured: 83 | # export CONTAINER_NAME=myrest # second part of your composer name 84 | # docker exec -it $CONTAINER_NAME composer run migrate -- reset --yes 85 | ``` 86 | 87 | Expected output: 88 | ```text 89 | > Builder\Scripts::migrate 90 | > Command: reset 91 | Doing reset, 0 92 | Doing migrate, 1 93 | ``` 94 | 95 | ## Verify Installation 96 | 97 | ```shell script 98 | curl http://localhost:8080/sample/ping 99 | ``` 100 | 101 | Expected response: 102 | ```json 103 | {"result":"pong"} 104 | ``` 105 | 106 | ## Run Tests 107 | 108 | ```shell script 109 | APP_ENV=dev composer run test 110 | # OR: docker exec -it $CONTAINER_NAME composer run test 111 | ``` 112 | 113 | ## Documentation 114 | 115 | Access the Swagger documentation: 116 | ```shell script 117 | open http://localhost:8080/docs 118 | ``` 119 | 120 | ## Next Steps 121 | 122 | Continue with [creating a new table and CRUD operations](getting_started_01_create_table.md). 123 | -------------------------------------------------------------------------------- /docs/getting_started_01_create_table.md: -------------------------------------------------------------------------------- 1 | # Getting Started - Creating a Table 2 | 3 | After [creating the project](getting_started.md), you're ready to create your own tables. 4 | 5 | ## Create the Table 6 | 7 | Create a new migration file in the `migrations` folder using the format `0000X-message.sql`, where `X` represents a sequential number that determines execution order. 8 | 9 | 1. Create an "up" migration file `db/migrations/up/00002-create-table-example.sql`: 10 | 11 | ```sql 12 | create table example_crud 13 | ( 14 | id int auto_increment not null primary key, 15 | name varchar(50) not null, 16 | birthdate datetime null, 17 | code int null 18 | ); 19 | ``` 20 | 21 | 2. Create a corresponding "down" migration file `db/migrations/down/00001-rollback-table-example.sql` for rollbacks: 22 | 23 | ```sql 24 | drop table example_crud; 25 | ``` 26 | 27 | ## Run the Migration 28 | 29 | Apply your migrations with: 30 | 31 | ```shell 32 | APP_ENV=dev composer run migrate -- update 33 | ``` 34 | 35 | Expected output: 36 | ```text 37 | > Builder\Scripts::migrate 38 | > Command: update 39 | Doing migrate, 2 40 | ``` 41 | 42 | To rollback changes: 43 | 44 | ```shell 45 | APP_ENV=dev composer run migrate -- update --up-to=1 46 | ``` 47 | 48 | The result should be: 49 | 50 | ```text 51 | > Builder\Scripts::migrate 52 | > Command: update 53 | Doing migrate, 1 54 | ``` 55 | 56 | Remember to run the migrate update again to apply the changes. 57 | 58 | 59 | ## Generate CRUD Components with the Code Generator 60 | 61 | Generate all necessary files for your new table: 62 | 63 | ```shell 64 | # Ensure DB is updated first 65 | APP_ENV=dev composer run migrate -- update 66 | 67 | # Generate files (options: rest, model, test, repo, config, or all) 68 | APP_ENV=dev composer run codegen -- --table example_crud --save all 69 | ``` 70 | 71 | This creates: 72 | - `./src/Rest/ExampleCrudRest.php` 73 | - `./src/Model/ExampleCrud.php` 74 | - `./src/Repository/ExampleCrudRepository.php` 75 | - `./tests/Functional/Rest/ExampleCrudTest.php` 76 | 77 | You have a manual step to generate the configuration by running the command below and adding it to `config/config-dev.php` 78 | 79 | ```shell 80 | APP_ENV=dev composer run codegen -- --table example_crud config 81 | ``` 82 | 83 | ## Run the Tests 84 | 85 | The automatically generated test is located at `tests/Functional/Rest/ExampleCrudTest.php`. 86 | 87 | Run it: 88 | 89 | ```shell 90 | composer run test 91 | ``` 92 | 93 | Initial tests **_will fail_** because we need to: 94 | 95 | 1. Generate OpenAPI documentation to create the endpoints: 96 | 97 | ```shell 98 | composer run openapi 99 | ``` 100 | 101 | 2. Fix the test data by updating `tests/Rest/ExampleCrudTest.php`: 102 | 103 | 104 | Locate: 105 | 106 | ```php 107 | protected function getSampleData($array = false) 108 | { 109 | $sample = [ 110 | 111 | 'name' => 'name', 112 | 'birthdate' => 'birthdate', 113 | 'code' => 1, 114 | ]; 115 | ... 116 | ``` 117 | 118 | And Change: 119 | ```php 120 | 'birthdate' => 'birthdate', 121 | ``` 122 | 123 | To: 124 | 125 | ```php 126 | 'birthdate' => '2023-01-01 00:00:00', 127 | ``` 128 | 129 | 3. Run the tests again: 130 | ```shell 131 | composer run test 132 | ``` 133 | 134 | Your tests should now pass successfully! 135 | 136 | ## Next Steps 137 | 138 | Continue with [Adding a New Field](getting_started_02_add_new_field.md) to enhance your implementation. 139 | -------------------------------------------------------------------------------- /docs/getting_started_02_add_new_field.md: -------------------------------------------------------------------------------- 1 | # Getting Started - Adding a new field to the Table 2 | 3 | Now we have the table `example_crud` created in the [previous tutorial](getting_started_01_create_table.md), 4 | let's modify it to add a new field `status`. 5 | 6 | ## Changing the table 7 | 8 | We need to add the proper field in the `up` script and remove it in the `down` script. 9 | 10 | `db/migrations/up/00003-add-field-status.sql`: 11 | 12 | ```sql 13 | alter table example_crud 14 | add status varchar(10) null; 15 | ``` 16 | 17 | `db/migrations/down/00002-rollback-field-status.sql`: 18 | 19 | ```sql 20 | alter table example_crud 21 | drop column status; 22 | ``` 23 | 24 | ## Run the migration 25 | 26 | ```shell 27 | APP_ENV=dev composer run migrate -- update 28 | ``` 29 | 30 | 31 | ## Adding the field status to the `Model` 32 | 33 | Open the file: `src/Model/ExampleCrud.php` and add the field `status`: 34 | 35 | ```php 36 | ... 37 | /** 38 | * @var string|null 39 | */ 40 | #[OA\Property(type: "string", format: "string", nullable: true)] 41 | protected ?string $status = null; 42 | 43 | /** 44 | * @return string|null 45 | */ 46 | public function getStatus(): ?string 47 | { 48 | return $this->status; 49 | } 50 | 51 | /** 52 | * @param string|null $status 53 | * @return ExampleCrud 54 | */ 55 | public function setStatus(?string $status): ExampleCrud 56 | { 57 | $this->status = $status; 58 | return $this; 59 | } 60 | ... 61 | ``` 62 | 63 | ## Adding the field status to the `Repository` 64 | 65 | As we are just adding a new field, and we already updated the Model to support this new field 66 | we don't need to change the `Repository` class. 67 | 68 | ## Adding the field status to the `Rest` 69 | 70 | We just need to allow the rest receive the new field. If we don't do it the API will throw an error. 71 | 72 | Open the file: `src/Rest/ExampleCrudRest.php` and add the attribute `status` to method `postExampleCrud()`: 73 | 74 | ```php 75 | #[OA\RequestBody( 76 | description: "The object DummyHex to be created", 77 | required: true, 78 | content: new OA\JsonContent( 79 | required: [ "name" ], 80 | properties: [ 81 | 82 | new OA\Property(property: "name", type: "string", format: "string"), 83 | new OA\Property(property: "birthdate", type: "string", format: "date-time", nullable: true), 84 | new OA\Property(property: "code", type: "integer", format: "int32", nullable: true), 85 | new OA\Property(property: "status", type: "string", format: "string", nullable: true) # <-- Add this line 86 | ] 87 | ) 88 | )] 89 | public function postExampleCrud(HttpResponse $response, HttpRequest $request) 90 | ``` 91 | 92 | ## Adding the field status to the `Test` 93 | 94 | We only need to change our method `getSample()` to return the status. 95 | Open the file: `tests/Rest/ExampleCrudTest.php` 96 | 97 | ```php 98 | protected function getSampleData($array = false) 99 | { 100 | $sample = [ 101 | 'name' => 'name', 102 | 'birthdate' => '2023-01-01 00:00:00', 103 | 'code' => 1, 104 | 'status' => 'status', # <-- Add this line 105 | ]; 106 | ... 107 | ``` 108 | 109 | ## Update the OpenAPI 110 | 111 | ```shell 112 | composer run openapi 113 | ``` 114 | 115 | ## Run the tests 116 | 117 | If everything is ok, the tests should pass: 118 | 119 | ```shell 120 | composer run test 121 | ``` 122 | 123 | ## Continue the tutorial 124 | 125 | [Next: Creating a rest method](getting_started_03_create_rest_method.md) -------------------------------------------------------------------------------- /docs/getting_started_03_create_rest_method.md: -------------------------------------------------------------------------------- 1 | I'll help you fix and improve this text. Let me analyze the Markdown document first. 2 | 3 | # Getting Started - Creating a REST Method 4 | 5 | In this tutorial, we'll create a new REST method to update the status of the `example_crud` table. 6 | 7 | We'll cover the following topics: 8 | 9 | - OpenAPI Attributes 10 | - Protecting the endpoint 11 | - Validating input 12 | - Saving to the database 13 | - Returning results 14 | - Unit testing 15 | 16 | ## OpenAPI Attributes 17 | 18 | First, we'll add OpenAPI attributes to our REST method using 19 | the [zircote/swagger-php](https://zircote.github.io/swagger-php/guide/) library. 20 | 21 | While the OpenAPI specification offers numerous attributes, we must define at least these three essential sets: 22 | 23 | ### 1. Method Attribute 24 | 25 | This defines the HTTP method: 26 | 27 | - `OA\Get` - For retrieving data 28 | - `OA\Post` - For creating data 29 | - `OA\Put` - For updating data 30 | - `OA\Delete` - For deleting/canceling data 31 | 32 | Example: 33 | 34 | ```php 35 | #[OA\Put( 36 | path: "/example/crud/status", 37 | security: [ 38 | ["jwt-token" => []] 39 | ], 40 | tags: ["Example"], 41 | description: "Update the status of the ExampleCrud" 42 | )] 43 | ``` 44 | 45 | The `security` attribute defines the security schema. Without it, the endpoint remains public. 46 | 47 | ### 2. Request Attribute 48 | 49 | This defines the input to the method using `OA\RequestBody` or `OA\Parameter`. 50 | 51 | Example: 52 | 53 | ```php 54 | #[OA\RequestBody( 55 | description: "The status to be updated", 56 | required: true, 57 | content: new OA\JsonContent( 58 | required: ["status"], 59 | properties: [ 60 | new OA\Property(property: "id", type: "integer", format: "int32"), 61 | new OA\Property(property: "status", type: "string") 62 | ] 63 | ) 64 | )] 65 | ``` 66 | 67 | ### 3. Response Attribute 68 | 69 | This defines the expected output using `OA\Response`. 70 | 71 | ```php 72 | #[OA\Response( 73 | response: 200, 74 | description: "The operation result", 75 | content: new OA\JsonContent( 76 | required: ["result"], 77 | properties: [ 78 | new OA\Property(property: "result", type: "string") 79 | ] 80 | ) 81 | )] 82 | ``` 83 | 84 | Place these attributes at the beginning of your method. Following our pattern, we'll add this method at the end of the `ExampleCrudRest` class: 85 | 86 | ```php 87 | #[OA\Put()] // complete with the attributes above 88 | #[OA\RequestBody()] // complete with the attributes above 89 | #[OA\Response()] // complete with the attributes above 90 | public function putExampleCrudStatus(HttpResponse $response, HttpRequest $request) 91 | { 92 | // Code to be added 93 | } 94 | ``` 95 | 96 | ## Protecting the Endpoint 97 | 98 | If you've set the `security` property in your attributes, you need to validate the token: 99 | 100 | ```php 101 | public function putExampleCrudStatus(HttpResponse $response, HttpRequest $request) 102 | { 103 | // Secure the endpoint 104 | // Use one of the following methods: 105 | 106 | // a. Require a user with admin role 107 | JwtContext::requireRole($request, "admin"); 108 | 109 | // b. OR require any authenticated user 110 | JwtContext::requireAuthenticated($request); 111 | 112 | // c. OR do nothing to make the endpoint public 113 | } 114 | ``` 115 | 116 | Both methods return the token content. If the token is invalid or missing, an `Error401Exception` will be thrown. If the user lacks the required role, an `Error403Exception` will be thrown. 117 | 118 | The default token content structure is: 119 | 120 | ```php 121 | $data = [ 122 | "userid" => "123", 123 | "name" => "John Doe", 124 | "role" => "admin" // or "user" 125 | ] 126 | ``` 127 | 128 | ## Validating Input 129 | 130 | Next, validate that the incoming request matches the OpenAPI specifications: 131 | 132 | ```php 133 | public function putExampleCrudStatus(HttpResponse $response, HttpRequest $request) 134 | { 135 | ... 136 | // The line below will validate again the OpenAPI attributes 137 | // If the request doesn't match, an exception `Error400Exception` will be thrown 138 | // If the request matches, the payload will be returned 139 | $payload = OpenApiContext::validateRequest($request); 140 | } 141 | ``` 142 | 143 | ## Updating Status in the Repository 144 | 145 | After validating the payload, we can update the record status using the repository pattern: 146 | 147 | ```php 148 | /** 149 | * Update the status of an Example CRUD record 150 | * 151 | * @param HttpResponse $response 152 | * @param HttpRequest $request 153 | * @return void 154 | */ 155 | public function putExampleCrudStatus(HttpResponse $response, HttpRequest $request) 156 | { 157 | // Previous code for payload validation... 158 | 159 | // Update the record status 160 | $exampleCrudRepo = Psr11::get(ExampleCrudRepository::class); 161 | $model = $exampleCrudRepo->get($payload["id"]); 162 | 163 | if (!$model) { 164 | throw new NotFoundException("Record not found"); 165 | } 166 | 167 | $model->setStatus($payload["status"]); 168 | $exampleCrudRepo->save($model); 169 | 170 | // Return response... 171 | } 172 | ``` 173 | 174 | ## Returning the Response 175 | 176 | After updating the record, we need to return a standardized response as specified in our OpenAPI schema: 177 | 178 | ```php 179 | public function putExampleCrudStatus(HttpResponse $response, HttpRequest $request) 180 | { 181 | // Previous code for update logic... 182 | 183 | // Return standardized response 184 | $response->write([ 185 | "result" => "ok" 186 | ]); 187 | } 188 | ``` 189 | 190 | ## Unit Testing 191 | 192 | To ensure our endpoint works correctly and continues to function as expected, we'll create a functional test. This test simulates calling the endpoint and validates both the response format and the business logic. 193 | 194 | Create or update the test file `tests/Functional/Rest/ExampleCrudTest.php`: 195 | 196 | ```php 197 | /** 198 | * @covers \YourNamespace\Controller\ExampleCrudController 199 | */ 200 | public function testUpdateStatus() 201 | { 202 | // Authenticate to get a valid token (if required) 203 | $authResult = json_decode( 204 | $this->assertRequest(Credentials::requestLogin(Credentials::getAdminUser())) 205 | ->getBody() 206 | ->getContents(), 207 | true 208 | ); 209 | 210 | // Prepare test data 211 | $recordId = 1; 212 | $newStatus = 'new status'; 213 | 214 | // Create mock API request 215 | $request = new FakeApiRequester(); 216 | $request 217 | ->withPsr7Request($this->getPsr7Request()) 218 | ->withMethod('PUT') 219 | ->withPath("/example/crud/status") 220 | ->withRequestBody(json_encode([ 221 | 'id' => $recordId, 222 | 'status' => $newStatus 223 | ])) 224 | ->withRequestHeader([ 225 | "Authorization" => "Bearer " . $authResult['token'], 226 | "Content-Type" => "application/json" 227 | ]) 228 | ->assertResponseCode(200); 229 | 230 | // Execute the request and get response 231 | $response = $this->assertRequest($request); 232 | $responseData = json_decode($response->getBody()->getContents(), true); 233 | 234 | // There is no necessary to Assert expected response format and data 235 | // because the assertRequest will do it for you. 236 | // $this->assertIsArray($responseData); 237 | // $this->assertArrayHasKey('result', $responseData); 238 | // $this->assertEquals('ok', $responseData['result']); 239 | 240 | // Verify the database was updated correctly 241 | $repository = Psr11::get(ExampleCrudRepository::class); 242 | $updatedRecord = $repository->get($recordId); 243 | $this->assertEquals($newStatus, $updatedRecord->getStatus()); 244 | } 245 | ``` 246 | 247 | This test performs the following validations: 248 | 1. Ensures the endpoint returns a 200 status code 249 | 2. Verifies the response has the expected JSON structure 250 | 3. Confirms the database record was actually updated with the new status -------------------------------------------------------------------------------- /docs/login.md: -------------------------------------------------------------------------------- 1 | # Login with JWT 2 | 3 | This project comes with a management user, login and JWT out of the box. 4 | 5 | For most of the use cases you don't need to change the application. Just the dependency injection configuration. 6 | 7 | ## Configure the Login 8 | 9 | Here the main guidelines: 10 | 11 | The database table created by the project is `users` with the following structure: 12 | 13 | ```sql 14 | CREATE TABLE `users` ( 15 | userid binary(16) DEFAULT (uuid_to_bin(uuid())) NOT NULL, 16 | `uuid` varchar(36) GENERATED ALWAYS AS (insert(insert(insert(insert(hex(`userid`),9,0,'-'),14,0,'-'),19,0,'-'),24,0,'-')) VIRTUAL, 17 | name varchar(50), 18 | email varchar(120), 19 | username varchar(20) not null, 20 | password char(40) not null, 21 | created datetime, 22 | admin enum('yes','no'), 23 | PRIMARY KEY (userid) 24 | ); 25 | ``` 26 | 27 | and the RestReferenceArchitecture/Model/User.php has the mapping for this table. 28 | 29 | If you have the same fields but named differently, you can change the mapping in the `config/config_dev.php` file: 30 | 31 | ```php 32 | UserDefinition::class => DI::bind(UserDefinition::class) 33 | ->withConstructorArgs( 34 | [ 35 | 'users', 36 | User::class, 37 | UserDefinition::LOGIN_IS_EMAIL, 38 | [ 39 | // Field name in the User class => Field name in the database 40 | 'userid' => 'userid', 41 | 'name' => 'name', 42 | 'email' => 'email', 43 | 'username' => 'username', 44 | 'password' => 'password', 45 | 'created' => 'created', 46 | 'admin' => 'admin' 47 | ] 48 | ] 49 | ) 50 | ); 51 | ``` 52 | 53 | You can modify completely this structure by referring the documentation of the project [byjg/authuser](https://github.com/byjg/authuser). 54 | 55 | ## Configure Password Rule Enforcement 56 | 57 | You can configure how the password will be saved by changing here: 58 | 59 | ```php 60 | PasswordDefinition::class => DI::bind(PasswordDefinition::class) 61 | ->withConstructorArgs([[ 62 | PasswordDefinition::MINIMUM_CHARS => 12, 63 | PasswordDefinition::REQUIRE_UPPERCASE => 1, // Number of uppercase characters 64 | PasswordDefinition::REQUIRE_LOWERCASE => 1, // Number of lowercase characters 65 | PasswordDefinition::REQUIRE_SYMBOLS => 1, // Number of symbols 66 | PasswordDefinition::REQUIRE_NUMBERS => 1, // Number of numbers 67 | PasswordDefinition::ALLOW_WHITESPACE => 0, // Allow whitespace 68 | PasswordDefinition::ALLOW_SEQUENTIAL => 0, // Allow sequential characters 69 | PasswordDefinition::ALLOW_REPEATED => 0 // Allow repeated characters 70 | ]]) 71 | ->toSingleton(), 72 | ``` 73 | 74 | ## Configure the JWT 75 | 76 | There is an endpoint to generate the JWT token. The endpoint is `/login` and the method is `POST`. 77 | 78 | You can test it using the endpoint `/sampleprotected/ping` with the method `GET` passing the header `Authorization: Bearer `. 79 | 80 | Also, there is an endpoint to refresh the token. The endpoint is `/refresh` and the method is `POST`. 81 | 82 | To configure the key you can change here: 83 | 84 | ```php 85 | JwtKeyInterface::class => DI::bind(\ByJG\JwtWrapper\JwtHashHmacSecret::class) 86 | ->withConstructorArgs(['supersecretkeyyoushouldnotcommittogithub']) 87 | ->toSingleton(), 88 | ``` 89 | 90 | More information on the [byjg/jwt-wrapper](https://github.com/byjg/jwt-wrapper) 91 | -------------------------------------------------------------------------------- /docs/migration.md: -------------------------------------------------------------------------------- 1 | # Database Migration 2 | 3 | ## Create a new Database 4 | 5 | You can create a fresh new database using the command: 6 | 7 | ```bash 8 | APP_ENV=dev composer migrate -- reset --yes 9 | ``` 10 | 11 | ```tip 12 | Use this command carefully. It will drop all tables and create a new database. 13 | ``` 14 | 15 | ## Update the Database 16 | 17 | It is possible to update the database using the command: 18 | 19 | ```bash 20 | APP_ENV=dev composer migrate -- update --up-to=x 21 | ``` 22 | 23 | This command will update the database using the migrations files inside the `migrations` folder. It will apply only the migrations that are not applied yet up to the migration number `x`. If you want to apply all migrations just remove the `--up-to=x` parameter. 24 | 25 | ## Create a new Migration version 26 | 27 | Just create a new file inside the folder `db/migrations/up` with the name `DDDDD.sql`. The DDDDD is the number of the migration. The number must be unique and incremental. 28 | 29 | If you want to be able to revert the migration, you must create a file inside the folder `db/migrations/down` with the name `DDDDD.sql`. The DDDDD is the number of the migration to go back. The number must be unique and incremental. 30 | 31 | You can get more information about the migration process in the [byjg/migration](https://github.com/byjg/migration) 32 | -------------------------------------------------------------------------------- /docs/orm.md: -------------------------------------------------------------------------------- 1 | # Database ORM 2 | 3 | To query the database you can use the ORM. The ORM uses the [byjg/micro-orm](https://github.com/byjg/micro-orm) 4 | 5 | You can start by creating a class inheriting from `BaseRepository` and defining the table name and the primary key. 6 | 7 | ```php 8 | public function __construct(DbDriverInterface $dbDriver) 9 | { 10 | $mapper = new Mapper( 11 | Your_Model_Class::class, 12 | 'table_name', 13 | 'primary_key_field' 14 | ); 15 | 16 | $this->repository = new Repository($dbDriver, $mapper); 17 | } 18 | ``` 19 | 20 | Then you can use the `Repository` class to query the database. 21 | 22 | ```php 23 | // Get a single row from DB based on your PK and return a model 24 | $repository->get($id) 25 | 26 | // Get all records from DB and create them as a list of models 27 | $repository->getAll() 28 | 29 | // Delete a row 30 | $repository->delete($model) 31 | 32 | // Insert a new row or update an existing row in the database 33 | $repository->save($model) 34 | ``` 35 | 36 | You also can create custom queries: 37 | 38 | ```php 39 | public function getByName($value) 40 | { 41 | $query = Query::getInstance() 42 | ->table('table_name') 43 | ->where('table_name.name = :name', ['name' => $value]); 44 | 45 | $result = $this->repository->getByQuery($query); 46 | if (is_null($result)) { 47 | return null; 48 | } 49 | 50 | return $result; 51 | } 52 | ``` 53 | -------------------------------------------------------------------------------- /docs/psr11.md: -------------------------------------------------------------------------------- 1 | # Psr11 Container 2 | 3 | The project uses the [PSR11](https://www.php-fig.org/psr/psr-11/) container to manage the dependencies. 4 | 5 | The configuration per environment is defined in the `config/config-.php` file or `config/config-.env` file or a configuration for all environment in the `config/.env` file. 6 | 7 | We are required to set the environment variable `APP_ENV` to the environment name. 8 | 9 | Examples: 10 | 11 | **config/config-dev.env** 12 | 13 | ```env 14 | WEB_SERVER=localhost 15 | DASH_SERVER=localhost 16 | WEB_SCHEMA=http 17 | API_SERVER=localhost 18 | API_SCHEMA=http 19 | DBDRIVER_CONNECTION=mysql://root:mysqlp455w0rd@mysql-container/restserver_dev 20 | ``` 21 | 22 | **config/config-dev.env** 23 | 24 | ```php 25 | 'localhost', 28 | 'DASH_SERVER' => 'localhost', 29 | 'WEB_SCHEMA' => 'http', 30 | 'API_SERVER' => 'localhost', 31 | 'API_SCHEMA' => 'http', 32 | 'DBDRIVER_CONNECTION' => 'mysql://root:mysqlp455w0rd@mysql-container/restserver_dev' 33 | ]; 34 | ``` 35 | 36 | The configuration is loaded by the [byjg/config](https://github.com/byjg/config) library. 37 | 38 | ## Get the configuration 39 | 40 | You just need to: 41 | 42 | ```php 43 | Psr11::get('WEB_SERVER'); 44 | ``` 45 | 46 | ## Defining the available environments 47 | 48 | The available environments are defined in the class `RestReferenceArchitecture\Psr11` in the method `environment()`. 49 | 50 | The project has 4 environments: 51 | 52 | ```text 53 | - dev 54 | - |- test 55 | - |- staging 56 | - |- prod 57 | ``` 58 | 59 | It means that the environment `dev` is the parent of `test` and `staging` and `staging` is the parent of `prod`. A configuration of the bottom environment will override the configuration of the parent environment. 60 | 61 | You can change the environments in the `RestReferenceArchitecture\Psr11` class as your needs. 62 | 63 | ```php 64 | public static function environment() 65 | { 66 | $dev = new Environment('dev'); 67 | $test = new Environment('test', [$dev]); 68 | $staging = new Environment('staging', [$dev], new FileSystemCacheEngine()); 69 | $prod = new Environment('prod', [$staging, $dev], new FileSystemCacheEngine()); 70 | 71 | if (is_null(self::$definition)) { 72 | self::$definition = (new Definition()) 73 | ->addEnvironment($dev) 74 | ->addEnvironment($test) 75 | ->addEnvironment($staging) 76 | ->addEnvironment($prod) 77 | ; 78 | } 79 | 80 | return self::$definition; 81 | } 82 | ``` 83 | -------------------------------------------------------------------------------- /docs/psr11_di.md: -------------------------------------------------------------------------------- 1 | # Dependency Injection 2 | 3 | Before continue, refer to the [Psr11](psr11.md) documentation and the [Dependency Injection](https://github.com/byjg/config#dependency-injection) documentation. 4 | 5 | The main advantage of the Dependency Injection is to decouple the code from the implementation. For example, if you want to cache objects for `prod` environment, but don't for `dev` environment, you can do it easily by creating different implementations of the same interface. 6 | 7 | e.g. `config-dev.php`: 8 | 9 | ```php 10 | return [ 11 | BaseCacheEngine::class => DI::bind(NoCacheEngine::class) 12 | ->toSingleton(), 13 | ] 14 | ``` 15 | 16 | and `config-prod.php`: 17 | 18 | ```php 19 | return [ 20 | BaseCacheEngine::class => DI::bind(FileSystemCacheEngine::class) 21 | ->toSingleton(), 22 | ] 23 | ``` 24 | 25 | To use in your code, you just need to set the environment variable `APP_ENV` to the environment name (`dev` or `prod`) and call: 26 | 27 | ```php 28 | Psr11::get(BaseCacheEngine::class); 29 | ``` 30 | 31 | The application will return the correct implementation based on the environment. 32 | -------------------------------------------------------------------------------- /docs/rest.md: -------------------------------------------------------------------------------- 1 | # Rest Methods API integrated with OpenAPI 2 | 3 | There is two ways to create a Rest Method API: 4 | 5 | - using an existing OpenAPI specification in JSON format 6 | - documenting your application and generating the OpenAPI specification from your code 7 | 8 | ## Using existing OpenAPI specification 9 | 10 | If you already have an OpenAPI specification in JSON format, you can use it to create your Rest Method API. 11 | Just put a file named `openapi.json` in the folder `public/docs`. 12 | 13 | There are one requirement in your specification. You need for each method to define a `operarionId` property as follows: 14 | 15 | ```json 16 | "paths": { 17 | "/login": { 18 | "post": { 19 | "operationId": "POST::/login::RestReferenceArchitecture\\Rest\\Login::mymethod", 20 | } 21 | } 22 | ``` 23 | 24 | The `operationId` is composed by the following parts: 25 | 26 | - HTTP Method (not used, any string will work) 27 | - Path (not used, any string will work) 28 | - Namespace of the class (required) 29 | - Method of the class (required) 30 | 31 | With definition above every request to `POST /login` will be handled by the method `mymethod` of the class `RestReferenceArchitecture\Rest\Login`. 32 | 33 | The only requirement is that the method must receive two parameters: 34 | 35 | ```php 36 | namespace RestReferenceArchitecture\Rest; 37 | 38 | use ByJG\RestServer\HttpRequest; 39 | use ByJG\RestServer\HttpResponse; 40 | 41 | class Login 42 | public function mymethod(HttpRequest $request, HttpResponse $response) 43 | { 44 | // ... 45 | } 46 | } 47 | ``` 48 | 49 | We use the package byjg/restserver to handle the requests. Please refer to the documentation of this package at [https://github.com/byjg/restserver/tree/bump#2-processing-the-request-and-response](https://github.com/byjg/restserver/tree/bump#2-processing-the-request-and-response) 50 | 51 | ## Documenting your application with PHPDOC and generating the OpenAPI specification 52 | 53 | If you don't have an OpenAPI specification, you can document your application with PHPDOC and generate the OpenAPI specification from your code. 54 | 55 | ```php 56 | namespace RestReferenceArchitecture\Rest; 57 | 58 | use ByJG\RestServer\HttpRequest; 59 | use ByJG\RestServer\HttpResponse; 60 | use OpenApi\Attributes as OA; 61 | 62 | class Login 63 | 64 | /** 65 | * Do login 66 | */ 67 | #[OA\Post( 68 | path: "/login", 69 | tags: ["Login"], 70 | )] 71 | #[OA\RequestBody( 72 | description: "The Login Data", 73 | required: true, 74 | content: new OA\JsonContent( 75 | required: [ "username", "password" ], 76 | properties: [ 77 | new OA\Property(property: "username", description: "The Username", type: "string", format: "string"), 78 | new OA\Property(property: "password", description: "The Password", type: "string", format: "string") 79 | ] 80 | ) 81 | )] 82 | #[OA\Response( 83 | response: 200, 84 | description: "The object to be created", 85 | content: new OA\JsonContent( 86 | required: [ "token" ], 87 | properties: [ 88 | new OA\Property(property: "token", type: "string"), 89 | new OA\Property(property: "data", properties: [ 90 | new OA\Property(property: "userid", type: "string"), 91 | new OA\Property(property: "name", type: "string"), 92 | new OA\Property(property: "role", type: "string"), 93 | ]) 94 | ] 95 | ) 96 | )] 97 | public function mymethod(HttpRequest $request, HttpResponse $response) 98 | { 99 | // ... 100 | } 101 | } 102 | ``` 103 | 104 | After documenting you code, you can generate the OpenAPI specification with the following command: 105 | 106 | ```bash 107 | APP_ENV=dev composer run openapi 108 | ``` 109 | 110 | The OpenAPI specification will be generated in the folder `public/docs`. 111 | 112 | We use the package zircote/swagger-php to generate the OpenAPI specification. 113 | Please refer to the documentation of this package at [https://zircote.github.io/swagger-php/]() to learn more about the PHPDOC annotations. 114 | 115 | We use the package byjg/restserver to handle the requests. Please refer to the documentation of this package at [https://github.com/byjg/restserver/tree/bump#2-processing-the-request-and-response](https://github.com/byjg/restserver/tree/bump#2-processing-the-request-and-response) 116 | -------------------------------------------------------------------------------- /docs/windows.md: -------------------------------------------------------------------------------- 1 | # Running on Windows Without PHP 2 | 3 | This project is primarily designed for Linux environments, but can be easily run on Windows using Docker. 4 | 5 | ## Prerequisites 6 | 7 | - Docker Desktop installed and running on your Windows machine 8 | - No need for a local PHP installation or WSL2 configuration 9 | 10 | ## Quick Start 11 | 12 | 1. Open Command Prompt or PowerShell 13 | 2. Navigate to your desired project location: 14 | 15 | ```textmate 16 | cd C:\Users\MyUser\Projects 17 | ``` 18 | 19 | 3. Launch a containerized PHP environment with the following command: 20 | 21 | ```textmate 22 | docker run -it --rm -v %cd%:/root/tutorial -w /root/tutorial byjg/php:8.3-cli bash 23 | ``` 24 | 25 | 4. Once inside the container shell, you can run all PHP commands normally as if you had PHP installed locally. 26 | The docker commands you'll run outside. 27 | 28 | **Note:** 29 | > Inside the container shell, the folder `~/tutorial` or else `/root/tutorial` 30 | is mapped to your current directory on Windows. 31 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 19 | 20 | 21 | 22 | ./src 23 | 24 | 25 | 26 | 27 | 28 | ./tests/Rest 29 | ./tests/ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /post-install.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byjg/php-rest-reference-architecture/391ca2ceb5b57c1fe57ab7cbe6f97b5f602f7c79/post-install.jpg -------------------------------------------------------------------------------- /public/app.php: -------------------------------------------------------------------------------- 1 | handle(Psr11::get(OpenApiRouteList::class)); 15 | } 16 | } 17 | 18 | App::run(); 19 | -------------------------------------------------------------------------------- /public/docs/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byjg/php-rest-reference-architecture/391ca2ceb5b57c1fe57ab7cbe6f97b5f602f7c79/public/docs/favicon-16x16.png -------------------------------------------------------------------------------- /public/docs/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byjg/php-rest-reference-architecture/391ca2ceb5b57c1fe57ab7cbe6f97b5f602f7c79/public/docs/favicon-32x32.png -------------------------------------------------------------------------------- /public/docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OpenAPI Viewer 6 | 7 | 8 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/docs/openapi.js: -------------------------------------------------------------------------------- 1 | function readTextFile(file, callback) { 2 | let rawFile = new XMLHttpRequest(); 3 | rawFile.overrideMimeType("application/json"); 4 | rawFile.open("GET", file, true); 5 | rawFile.onreadystatechange = function() { 6 | if (rawFile.readyState === 4 && rawFile.status == "200") { 7 | callback(rawFile.responseText); 8 | } 9 | } 10 | rawFile.send(null); 11 | } 12 | 13 | readTextFile("./openapi.json", function(text){ 14 | window.ui = SwaggerUIBundle({ 15 | spec: JSON.parse(text), 16 | dom_id: '#swagger-ui', 17 | deepLinking: true, 18 | docExpansion: "none", 19 | tryItOutEnabled: true, 20 | syntaxHighlight: true, 21 | presets: [ 22 | SwaggerUIBundle.presets.apis, 23 | SwaggerUIStandalonePreset 24 | ], 25 | plugins: [ 26 | SwaggerUIBundle.plugins.DownloadUrl 27 | ], 28 | layout: "StandaloneLayout" 29 | }) 30 | }); 31 | -------------------------------------------------------------------------------- /src/Model/Dummy.php: -------------------------------------------------------------------------------- 1 | id; 40 | } 41 | 42 | /** 43 | * @param int|null $id 44 | * @return $this 45 | */ 46 | public function setId(int|null $id): static 47 | { 48 | $this->id = $id; 49 | return $this; 50 | } 51 | 52 | /** 53 | * @return string|null 54 | */ 55 | public function getField(): string|null 56 | { 57 | return $this->field; 58 | } 59 | 60 | /** 61 | * @param string|null $field 62 | * @return $this 63 | */ 64 | public function setField(string|null $field): static 65 | { 66 | $this->field = $field; 67 | return $this; 68 | } 69 | 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/Model/DummyHex.php: -------------------------------------------------------------------------------- 1 | id; 49 | } 50 | 51 | /** 52 | * @param string|HexUuidLiteral|null $id 53 | * @return $this 54 | */ 55 | public function setId(string|HexUuidLiteral|null $id): static 56 | { 57 | $this->id = $id; 58 | return $this; 59 | } 60 | 61 | /** 62 | * @return string|null 63 | */ 64 | public function getUuid(): string|null 65 | { 66 | return $this->uuid; 67 | } 68 | 69 | /** 70 | * @param string|null $uuid 71 | * @return $this 72 | */ 73 | public function setUuid(string|null $uuid): static 74 | { 75 | $this->uuid = $uuid; 76 | return $this; 77 | } 78 | 79 | /** 80 | * @return string|null 81 | */ 82 | public function getField(): string|null 83 | { 84 | return $this->field; 85 | } 86 | 87 | /** 88 | * @param string|null $field 89 | * @return $this 90 | */ 91 | public function setField(string|null $field): static 92 | { 93 | $this->field = $field; 94 | return $this; 95 | } 96 | 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/Model/User.php: -------------------------------------------------------------------------------- 1 | withPasswordDefinition(Psr11::get(PasswordDefinition::class)); 104 | } 105 | 106 | 107 | /** 108 | * @return string|HexUuidLiteral|int|null 109 | */ 110 | public function getUserid(): string|HexUuidLiteral|int|null 111 | { 112 | return $this->userid; 113 | } 114 | 115 | /** 116 | * @param string|HexUuidLiteral|int|null $userid 117 | */ 118 | public function setUserid(string|HexUuidLiteral|int|null $userid): void 119 | { 120 | $this->userid = $userid; 121 | } 122 | 123 | /** 124 | * @return string|null 125 | */ 126 | public function getName(): ?string 127 | { 128 | return $this->name; 129 | } 130 | 131 | /** 132 | * @param string|null $name 133 | */ 134 | public function setName(?string $name): void 135 | { 136 | $this->name = $name; 137 | } 138 | 139 | /** 140 | * @return string|null 141 | */ 142 | public function getEmail(): ?string 143 | { 144 | return $this->email; 145 | } 146 | 147 | /** 148 | * @param string|null $email 149 | */ 150 | public function setEmail(?string $email): void 151 | { 152 | $this->email = $email; 153 | } 154 | 155 | /** 156 | * @return string|null 157 | */ 158 | public function getUsername(): ?string 159 | { 160 | return $this->username; 161 | } 162 | 163 | /** 164 | * @param string|null $username 165 | */ 166 | public function setUsername(?string $username): void 167 | { 168 | $this->username = $username; 169 | } 170 | 171 | /** 172 | * @return string|null 173 | */ 174 | public function getPassword(): ?string 175 | { 176 | return $this->password; 177 | } 178 | 179 | /** 180 | * @param string|null $password 181 | */ 182 | public function setPassword(?string $password): void 183 | { 184 | if (!empty($this->passwordDefinition) && !empty($password) && strlen($password) != 40) { 185 | $result = $this->passwordDefinition->matchPassword($password); 186 | if ($result != PasswordDefinition::SUCCESS) { 187 | throw new InvalidArgumentException("Password does not match the password definition [{$result}]"); 188 | } 189 | } 190 | $this->password = $password; 191 | } 192 | 193 | /** 194 | * @return string|null 195 | */ 196 | public function getCreated(): ?string 197 | { 198 | return $this->created; 199 | } 200 | 201 | /** 202 | * @param string|null $created 203 | */ 204 | public function setCreated(?string $created): void 205 | { 206 | $this->created = $created; 207 | } 208 | 209 | /** 210 | * @return string|null 211 | */ 212 | public function getAdmin(): ?string 213 | { 214 | return $this->admin; 215 | } 216 | 217 | /** 218 | * @param string|null $admin 219 | */ 220 | public function setAdmin(?string $admin): void 221 | { 222 | $this->admin = $admin; 223 | } 224 | 225 | public function set(string $name, string|null $value): void 226 | { 227 | $property = $this->get($name, true); 228 | if (empty($property)) { 229 | $property = new UserPropertiesModel($name, $value ?? ""); 230 | $this->addProperty($property); 231 | } else { 232 | $property->setValue($value); 233 | } 234 | } 235 | 236 | /** 237 | * @return ?string 238 | */ 239 | public function getUuid(): ?string 240 | { 241 | return $this->uuid; 242 | } 243 | 244 | /** 245 | * @param mixed $uuid 246 | */ 247 | public function setUuid(?string $uuid) 248 | { 249 | $this->uuid = $uuid; 250 | } 251 | 252 | public function getUpdated(): ?string 253 | { 254 | return $this->updated; 255 | } 256 | 257 | public function setUpdated(?string $updated) 258 | { 259 | $this->updated = $updated; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/OpenApiSpec.php: -------------------------------------------------------------------------------- 1 | build($env); 39 | } 40 | 41 | return self::$container; 42 | } 43 | 44 | /** 45 | * @param string $id 46 | * @param mixed ...$parameters 47 | * @return mixed 48 | * @throws ConfigException 49 | * @throws ConfigNotFoundException 50 | * @throws DependencyInjectionException 51 | * @throws InvalidArgumentException 52 | * @throws KeyNotFoundException 53 | * @throws ContainerExceptionInterface 54 | * @throws NotFoundExceptionInterface 55 | * @throws ReflectionException 56 | */ 57 | public static function get(string $id, mixed ...$parameters): mixed 58 | { 59 | return Psr11::container()->get($id, ...$parameters); 60 | } 61 | 62 | /** 63 | * @return Definition|null 64 | * @throws ConfigException 65 | */ 66 | public static function environment(): ?Definition 67 | { 68 | $dev = new Environment('dev'); 69 | $test = new Environment('test', [$dev]); 70 | $staging = new Environment('staging', [$dev], new FileSystemCacheEngine()); 71 | $prod = new Environment('prod', [$staging, $dev], new FileSystemCacheEngine()); 72 | 73 | if (is_null(self::$definition)) { 74 | self::$definition = (new Definition()) 75 | ->addEnvironment($dev) 76 | ->addEnvironment($test) 77 | ->addEnvironment($staging) 78 | ->addEnvironment($prod) 79 | ->withOSEnvironment( 80 | [ 81 | 'TAG_VERSION', 82 | 'TAG_COMMIT', 83 | ] 84 | ); 85 | } 86 | 87 | return self::$definition; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Repository/BaseRepository.php: -------------------------------------------------------------------------------- 1 | repository->get(HexUuidLiteral::create($itemId)); 40 | } 41 | 42 | public function getRepository(): Repository 43 | { 44 | return $this->repository; 45 | } 46 | 47 | public function getMapper() 48 | { 49 | return $this->repository->getMapper(); 50 | } 51 | 52 | public function getDbDriver() 53 | { 54 | return $this->repository->getDbDriver(); 55 | } 56 | 57 | public function getByQuery($query) 58 | { 59 | $query->table($this->repository->getMapper()->getTable()); 60 | return $this->repository->getByQuery($query); 61 | } 62 | 63 | /** 64 | * @param int|null $page 65 | * @param int $size 66 | * @param string|array|null $orderBy 67 | * @param string|array|null $filter 68 | * @return array 69 | * @throws \ByJG\MicroOrm\Exception\InvalidArgumentException 70 | * @throws InvalidArgumentException 71 | */ 72 | public function list($page = 0, $size = 20, $orderBy = null, $filter = null) 73 | { 74 | $query = $this->listQuery(page: $page, size: $size, orderBy: $orderBy, filter: $filter); 75 | 76 | return $this->repository 77 | ->getByQuery($query); 78 | } 79 | 80 | /** 81 | * @throws \ByJG\MicroOrm\Exception\InvalidArgumentException 82 | * @throws InvalidArgumentException 83 | */ 84 | public function listGeneric($tableName, $fields = [], $page = 0, $size = 20, $orderBy = null, $filter = null) 85 | { 86 | $query = $this->listQuery($tableName, $fields, $page, $size, $orderBy, $filter); 87 | 88 | $object = $query->build($this->repository->getDbDriver()); 89 | 90 | $iterator = $this->repository->getDbDriver()->getIterator($object->getSql(), $object->getParameters()); 91 | return $iterator->toArray(); 92 | } 93 | 94 | public function listQuery($tableName = null, $fields = [], $page = 0, $size = 20, $orderBy = null, $filter = null): Query 95 | { 96 | if (empty($page)) { 97 | $page = 0; 98 | } 99 | 100 | if (empty($size)) { 101 | $size = 20; 102 | } 103 | 104 | $query = Query::getInstance() 105 | ->table($tableName ?? $this->repository->getMapper()->getTable()) 106 | ->limit($page * $size, $size); 107 | 108 | if (!empty($fields)) { 109 | $query->fields($fields); 110 | } 111 | 112 | if (!empty($orderBy)) { 113 | if (!is_array($orderBy)) { 114 | $orderBy = [$orderBy]; 115 | } 116 | $query->orderBy($orderBy); 117 | } 118 | 119 | foreach ((array) $filter as $item) { 120 | $query->where($item[0], $item[1]); 121 | } 122 | 123 | return $query; 124 | } 125 | 126 | public function model() 127 | { 128 | $class = $this->repository->getMapper()->getEntity(); 129 | 130 | return new $class(); 131 | } 132 | 133 | public static function getClosureNewUUID(): \Closure 134 | { 135 | return function () { 136 | return new Literal("X'" . Psr11::get(DbDriverInterface::class)->getScalar("SELECT hex(uuid_to_bin(uuid()))") . "'"); 137 | }; 138 | } 139 | 140 | /** 141 | * @return mixed 142 | * @throws ConfigException 143 | * @throws ConfigNotFoundException 144 | * @throws DependencyInjectionException 145 | * @throws InvalidDateException 146 | * @throws KeyNotFoundException 147 | * @throws \Psr\SimpleCache\InvalidArgumentException 148 | * @throws ReflectionException 149 | */ 150 | public static function getUuid() 151 | { 152 | return Psr11::get(DbDriverInterface::class)->getScalar("SELECT insert(insert(insert(insert(hex(uuid_to_bin(uuid())),9,0,'-'),14,0,'-'),19,0,'-'),24,0,'-')"); 153 | } 154 | 155 | /** 156 | * @param Mapper|null $mapper 157 | * @param string $binPropertyName 158 | * @param string $uuidStrPropertyName 159 | * @return FieldMapping 160 | */ 161 | protected function setClosureFixBinaryUUID(?Mapper $mapper, $binPropertyName = 'id', $uuidStrPropertyName = 'uuid') 162 | { 163 | $fieldMapping = FieldMapping::create($binPropertyName) 164 | ->withUpdateFunction( 165 | function ($value, $instance) { 166 | if (empty($value)) { 167 | return null; 168 | } 169 | if (!($value instanceof Literal)) { 170 | $value = new HexUuidLiteral($value); 171 | } 172 | return $value; 173 | } 174 | ) 175 | ->withSelectFunction( 176 | function ($value, $instance) use ($binPropertyName, $uuidStrPropertyName) { 177 | if (!empty($uuidStrPropertyName)) { 178 | $fieldValue = $instance->{'get' . $uuidStrPropertyName}(); 179 | } else { 180 | $itemValue = $instance->{'get' . $binPropertyName}(); 181 | $fieldValue = HexUuidLiteral::getFormattedUuid($itemValue, false, $itemValue); 182 | } 183 | if (is_null($fieldValue)) { 184 | return null; 185 | } 186 | return $fieldValue; 187 | } 188 | ); 189 | 190 | if (!empty($mapper)) { 191 | $mapper->addFieldMapping($fieldMapping); 192 | } 193 | 194 | return $fieldMapping; 195 | } 196 | 197 | /** 198 | * @param $model 199 | * @return mixed 200 | * @throws InvalidArgumentException 201 | * @throws OrmBeforeInvalidException 202 | * @throws OrmInvalidFieldsException 203 | * @throws \ByJG\MicroOrm\Exception\InvalidArgumentException 204 | */ 205 | public function save($model, ?UpdateConstraint $updateConstraint = null) 206 | { 207 | $model = $this->repository->save($model, $updateConstraint); 208 | 209 | $primaryKey = $this->repository->getMapper()->getPrimaryKey()[0]; 210 | 211 | if ($model->{"get$primaryKey"}() instanceof Literal) { 212 | $model->{"set$primaryKey"}(HexUuidLiteral::create($model->{"get$primaryKey"}())); 213 | } 214 | 215 | return $model; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/Repository/DummyHexRepository.php: -------------------------------------------------------------------------------- 1 | repository = new Repository($dbDriver, DummyHex::class); 23 | } 24 | 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/Repository/DummyRepository.php: -------------------------------------------------------------------------------- 1 | repository = new Repository($dbDriver, Dummy::class); 24 | } 25 | 26 | 27 | /** 28 | * @param mixed $field 29 | * @return null|Dummy[] 30 | */ 31 | public function getByField($field) 32 | { 33 | $query = Query::getInstance() 34 | ->table('dummy') 35 | ->where('dummy.field = :value', ['value' => $field]); 36 | $result = $this->repository->getByQuery($query); 37 | return $result; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/Repository/UserDefinition.php: -------------------------------------------------------------------------------- 1 | markPropertyAsReadOnly("uuid"); 18 | $this->markPropertyAsReadOnly("created"); 19 | $this->markPropertyAsReadOnly("updated"); 20 | $this->defineGenerateKeyClosure(function () { 21 | return new Literal("X'" . Psr11::get(DbDriverInterface::class)->getScalar("SELECT hex(uuid_to_bin(uuid()))") . "'"); 22 | } 23 | ); 24 | 25 | $this->defineClosureForSelect( 26 | "userid", 27 | function ($value, $instance) { 28 | if (!method_exists($instance, 'getUuid')) { 29 | return $value; 30 | } 31 | if (!empty($instance->getUuid())) { 32 | return $instance->getUuid(); 33 | } 34 | return $value; 35 | } 36 | ); 37 | 38 | $this->defineClosureForUpdate( 39 | 'userid', 40 | function ($value, $instance) { 41 | if (empty($value)) { 42 | return null; 43 | } 44 | if (!($value instanceof Literal)) { 45 | $value = new HexUuidLiteral($value); 46 | } 47 | return $value; 48 | } 49 | ); 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /src/Rest/DummyHexRest.php: -------------------------------------------------------------------------------- 1 | []] 52 | ], 53 | tags: ["Dummyhex"], 54 | )] 55 | #[OA\Parameter( 56 | name: "id", 57 | in: "path", 58 | required: true, 59 | schema: new OA\Schema( 60 | type: "string", 61 | format: "string" 62 | ) 63 | )] 64 | #[OA\Response( 65 | response: 200, 66 | description: "The object DummyHex", 67 | content: new OA\JsonContent(ref: "#/components/schemas/DummyHex") 68 | )] 69 | public function getDummyHex(HttpResponse $response, HttpRequest $request): void 70 | { 71 | JwtContext::requireAuthenticated($request); 72 | 73 | $dummyHexRepo = Psr11::get(DummyHexRepository::class); 74 | $id = $request->param('id'); 75 | 76 | $result = $dummyHexRepo->get($id); 77 | if (empty($result)) { 78 | throw new Error404Exception('Id not found'); 79 | } 80 | $response->write( 81 | $result 82 | ); 83 | } 84 | 85 | /** 86 | * List DummyHex 87 | * 88 | * @param mixed $response 89 | * @param mixed $request 90 | * @return void 91 | * @throws ConfigException 92 | * @throws ConfigNotFoundException 93 | * @throws DependencyInjectionException 94 | * @throws Error401Exception 95 | * @throws InvalidArgumentException 96 | * @throws InvalidDateException 97 | * @throws KeyNotFoundException 98 | * @throws \ByJG\Serializer\Exception\InvalidArgumentException 99 | * @throws \Psr\SimpleCache\InvalidArgumentException 100 | * @throws ReflectionException 101 | */ 102 | #[OA\Get( 103 | path: "/dummyhex", 104 | security: [ 105 | ["jwt-token" => []] 106 | ], 107 | tags: ["Dummyhex"] 108 | )] 109 | #[OA\Parameter( 110 | name: "page", 111 | description: "Page number", 112 | in: "query", 113 | required: false, 114 | schema: new OA\Schema( 115 | type: "integer", 116 | ) 117 | )] 118 | #[OA\Parameter( 119 | name: "size", 120 | description: "Page size", 121 | in: "query", 122 | required: false, 123 | schema: new OA\Schema( 124 | type: "integer", 125 | ) 126 | )] 127 | #[OA\Parameter( 128 | name: "orderBy", 129 | description: "Order by", 130 | in: "query", 131 | required: false, 132 | schema: new OA\Schema( 133 | type: "string", 134 | ) 135 | )] 136 | #[OA\Parameter( 137 | name: "filter", 138 | description: "Filter", 139 | in: "query", 140 | required: false, 141 | schema: new OA\Schema( 142 | type: "string", 143 | ) 144 | )] 145 | #[OA\Response( 146 | response: 200, 147 | description: "The object DummyHex", 148 | content: new OA\JsonContent(type: "array", items: new OA\Items(ref: "#/components/schemas/DummyHex")) 149 | )] 150 | #[OA\Response( 151 | response: 401, 152 | description: "Not Authorized", 153 | content: new OA\JsonContent(ref: "#/components/schemas/error") 154 | )] 155 | public function listDummyHex(HttpResponse $response, HttpRequest $request): void 156 | { 157 | JwtContext::requireAuthenticated($request); 158 | 159 | $repo = Psr11::get(DummyHexRepository::class); 160 | 161 | $page = $request->get('page'); 162 | $size = $request->get('size'); 163 | // $orderBy = $request->get('orderBy'); 164 | // $filter = $request->get('filter'); 165 | 166 | $result = $repo->list($page, $size); 167 | $response->write( 168 | $result 169 | ); 170 | } 171 | 172 | 173 | /** 174 | * Create a new DummyHex 175 | * 176 | * @param HttpResponse $response 177 | * @param HttpRequest $request 178 | * @return void 179 | * @throws ConfigException 180 | * @throws ConfigNotFoundException 181 | * @throws DependencyInjectionException 182 | * @throws Error400Exception 183 | * @throws Error401Exception 184 | * @throws Error403Exception 185 | * @throws InvalidArgumentException 186 | * @throws InvalidDateException 187 | * @throws KeyNotFoundException 188 | * @throws OrmBeforeInvalidException 189 | * @throws OrmInvalidFieldsException 190 | * @throws \ByJG\Serializer\Exception\InvalidArgumentException 191 | * @throws \Psr\SimpleCache\InvalidArgumentException 192 | * @throws ReflectionException 193 | */ 194 | #[OA\Post( 195 | path: "/dummyhex", 196 | security: [ 197 | ["jwt-token" => []] 198 | ], 199 | tags: ["Dummyhex"] 200 | )] 201 | #[OA\RequestBody( 202 | description: "The object DummyHex to be created", 203 | required: true, 204 | content: new OA\JsonContent( 205 | required: [ "field" ], 206 | properties: [ 207 | 208 | new OA\Property(property: "field", type: "string", format: "string") 209 | ] 210 | ) 211 | )] 212 | #[OA\Response( 213 | response: 200, 214 | description: "The object rto be created", 215 | content: new OA\JsonContent( 216 | required: [ "id" ], 217 | properties: [ 218 | 219 | new OA\Property(property: "id", type: "string", format: "string") 220 | ] 221 | ) 222 | )] 223 | #[OA\Response( 224 | response: 401, 225 | description: "Not Authorized", 226 | content: new OA\JsonContent(ref: "#/components/schemas/error") 227 | )] 228 | public function postDummyHex(HttpResponse $response, HttpRequest $request): void 229 | { 230 | JwtContext::requireRole($request, User::ROLE_ADMIN); 231 | 232 | $payload = OpenApiContext::validateRequest($request); 233 | 234 | $model = new DummyHex(); 235 | ObjectCopy::copy($payload, $model); 236 | 237 | $dummyHexRepo = Psr11::get(DummyHexRepository::class); 238 | $dummyHexRepo->save($model); 239 | 240 | $response->write([ "id" => $model->getId()]); 241 | } 242 | 243 | 244 | /** 245 | * Update an existing DummyHex 246 | * 247 | * @param HttpResponse $response 248 | * @param HttpRequest $request 249 | * @return void 250 | * @throws Error401Exception 251 | * @throws Error404Exception 252 | * @throws ConfigException 253 | * @throws ConfigNotFoundException 254 | * @throws DependencyInjectionException 255 | * @throws InvalidDateException 256 | * @throws KeyNotFoundException 257 | * @throws InvalidArgumentException 258 | * @throws OrmBeforeInvalidException 259 | * @throws OrmInvalidFieldsException 260 | * @throws Error400Exception 261 | * @throws Error403Exception 262 | * @throws \ByJG\Serializer\Exception\InvalidArgumentException 263 | * @throws \Psr\SimpleCache\InvalidArgumentException 264 | * @throws ReflectionException 265 | */ 266 | #[OA\Put( 267 | path: "/dummyhex", 268 | security: [ 269 | ["jwt-token" => []] 270 | ], 271 | tags: ["Dummyhex"] 272 | )] 273 | #[OA\RequestBody( 274 | description: "The object DummyHex to be updated", 275 | required: true, 276 | content: new OA\JsonContent(ref: "#/components/schemas/DummyHex") 277 | )] 278 | #[OA\Response( 279 | response: 200, 280 | description: "Nothing to return" 281 | )] 282 | #[OA\Response( 283 | response: 401, 284 | description: "Not Authorized", 285 | content: new OA\JsonContent(ref: "#/components/schemas/error") 286 | )] 287 | public function putDummyHex(HttpResponse $response, HttpRequest $request): void 288 | { 289 | JwtContext::requireRole($request, User::ROLE_ADMIN); 290 | 291 | $payload = OpenApiContext::validateRequest($request); 292 | 293 | $dummyHexRepo = Psr11::get(DummyHexRepository::class); 294 | $model = $dummyHexRepo->get($payload['id']); 295 | if (empty($model)) { 296 | throw new Error404Exception('Id not found'); 297 | } 298 | ObjectCopy::copy($payload, $model); 299 | 300 | $dummyHexRepo->save($model); 301 | } 302 | 303 | } 304 | -------------------------------------------------------------------------------- /src/Rest/DummyRest.php: -------------------------------------------------------------------------------- 1 | []] 52 | ], 53 | tags: ["Dummy"], 54 | )] 55 | #[OA\Parameter( 56 | name: "id", 57 | in: "path", 58 | required: true, 59 | schema: new OA\Schema( 60 | type: "integer", 61 | format: "int32" 62 | ) 63 | )] 64 | #[OA\Response( 65 | response: 200, 66 | description: "The object Dummy", 67 | content: new OA\JsonContent(ref: "#/components/schemas/Dummy") 68 | )] 69 | public function getDummy(HttpResponse $response, HttpRequest $request): void 70 | { 71 | JwtContext::requireAuthenticated($request); 72 | 73 | $dummyRepo = Psr11::get(DummyRepository::class); 74 | $id = $request->param('id'); 75 | 76 | $result = $dummyRepo->get($id); 77 | if (empty($result)) { 78 | throw new Error404Exception('Id not found'); 79 | } 80 | $response->write( 81 | $result 82 | ); 83 | } 84 | 85 | /** 86 | * List Dummy 87 | * 88 | * @param mixed $response 89 | * @param mixed $request 90 | * @return void 91 | * @throws ConfigException 92 | * @throws ConfigNotFoundException 93 | * @throws DependencyInjectionException 94 | * @throws Error401Exception 95 | * @throws InvalidArgumentException 96 | * @throws InvalidDateException 97 | * @throws KeyNotFoundException 98 | * @throws \ByJG\Serializer\Exception\InvalidArgumentException 99 | * @throws \Psr\SimpleCache\InvalidArgumentException 100 | * @throws ReflectionException 101 | */ 102 | #[OA\Get( 103 | path: "/dummy", 104 | security: [ 105 | ["jwt-token" => []] 106 | ], 107 | tags: ["Dummy"] 108 | )] 109 | #[OA\Parameter( 110 | name: "page", 111 | description: "Page number", 112 | in: "query", 113 | required: false, 114 | schema: new OA\Schema( 115 | type: "integer", 116 | ) 117 | )] 118 | #[OA\Parameter( 119 | name: "size", 120 | description: "Page size", 121 | in: "query", 122 | required: false, 123 | schema: new OA\Schema( 124 | type: "integer", 125 | ) 126 | )] 127 | #[OA\Parameter( 128 | name: "orderBy", 129 | description: "Order by", 130 | in: "query", 131 | required: false, 132 | schema: new OA\Schema( 133 | type: "string", 134 | ) 135 | )] 136 | #[OA\Parameter( 137 | name: "filter", 138 | description: "Filter", 139 | in: "query", 140 | required: false, 141 | schema: new OA\Schema( 142 | type: "string", 143 | ) 144 | )] 145 | #[OA\Response( 146 | response: 200, 147 | description: "The object Dummy", 148 | content: new OA\JsonContent(type: "array", items: new OA\Items(ref: "#/components/schemas/Dummy")) 149 | )] 150 | #[OA\Response( 151 | response: 401, 152 | description: "Not Authorized", 153 | content: new OA\JsonContent(ref: "#/components/schemas/error") 154 | )] 155 | public function listDummy(HttpResponse $response, HttpRequest $request): void 156 | { 157 | JwtContext::requireAuthenticated($request); 158 | 159 | $repo = Psr11::get(DummyRepository::class); 160 | 161 | $page = $request->get('page'); 162 | $size = $request->get('size'); 163 | // $orderBy = $request->get('orderBy'); 164 | // $filter = $request->get('filter'); 165 | 166 | $result = $repo->list($page, $size); 167 | $response->write( 168 | $result 169 | ); 170 | } 171 | 172 | 173 | /** 174 | * Create a new Dummy 175 | * 176 | * @param HttpResponse $response 177 | * @param HttpRequest $request 178 | * @return void 179 | * @throws ConfigException 180 | * @throws ConfigNotFoundException 181 | * @throws DependencyInjectionException 182 | * @throws Error400Exception 183 | * @throws Error401Exception 184 | * @throws Error403Exception 185 | * @throws InvalidArgumentException 186 | * @throws InvalidDateException 187 | * @throws KeyNotFoundException 188 | * @throws OrmBeforeInvalidException 189 | * @throws OrmInvalidFieldsException 190 | * @throws \ByJG\Serializer\Exception\InvalidArgumentException 191 | * @throws \Psr\SimpleCache\InvalidArgumentException 192 | * @throws ReflectionException 193 | */ 194 | #[OA\Post( 195 | path: "/dummy", 196 | security: [ 197 | ["jwt-token" => []] 198 | ], 199 | tags: ["Dummy"] 200 | )] 201 | #[OA\RequestBody( 202 | description: "The object Dummy to be created", 203 | required: true, 204 | content: new OA\JsonContent( 205 | required: [ "field" ], 206 | properties: [ 207 | 208 | new OA\Property(property: "field", type: "string", format: "string") 209 | ] 210 | ) 211 | )] 212 | #[OA\Response( 213 | response: 200, 214 | description: "The object rto be created", 215 | content: new OA\JsonContent( 216 | required: [ "id" ], 217 | properties: [ 218 | 219 | new OA\Property(property: "id", type: "integer", format: "int32") 220 | ] 221 | ) 222 | )] 223 | #[OA\Response( 224 | response: 401, 225 | description: "Not Authorized", 226 | content: new OA\JsonContent(ref: "#/components/schemas/error") 227 | )] 228 | public function postDummy(HttpResponse $response, HttpRequest $request): void 229 | { 230 | JwtContext::requireRole($request, User::ROLE_ADMIN); 231 | 232 | $payload = OpenApiContext::validateRequest($request); 233 | 234 | $model = new Dummy(); 235 | ObjectCopy::copy($payload, $model); 236 | 237 | $dummyRepo = Psr11::get(DummyRepository::class); 238 | $dummyRepo->save($model); 239 | 240 | $response->write([ "id" => $model->getId()]); 241 | } 242 | 243 | 244 | /** 245 | * Update an existing Dummy 246 | * 247 | * @param HttpResponse $response 248 | * @param HttpRequest $request 249 | * @return void 250 | * @throws Error401Exception 251 | * @throws Error404Exception 252 | * @throws ConfigException 253 | * @throws ConfigNotFoundException 254 | * @throws DependencyInjectionException 255 | * @throws InvalidDateException 256 | * @throws KeyNotFoundException 257 | * @throws InvalidArgumentException 258 | * @throws OrmBeforeInvalidException 259 | * @throws OrmInvalidFieldsException 260 | * @throws Error400Exception 261 | * @throws Error403Exception 262 | * @throws \ByJG\Serializer\Exception\InvalidArgumentException 263 | * @throws \Psr\SimpleCache\InvalidArgumentException 264 | * @throws ReflectionException 265 | */ 266 | #[OA\Put( 267 | path: "/dummy", 268 | security: [ 269 | ["jwt-token" => []] 270 | ], 271 | tags: ["Dummy"] 272 | )] 273 | #[OA\RequestBody( 274 | description: "The object Dummy to be updated", 275 | required: true, 276 | content: new OA\JsonContent(ref: "#/components/schemas/Dummy") 277 | )] 278 | #[OA\Response( 279 | response: 200, 280 | description: "Nothing to return" 281 | )] 282 | #[OA\Response( 283 | response: 401, 284 | description: "Not Authorized", 285 | content: new OA\JsonContent(ref: "#/components/schemas/error") 286 | )] 287 | public function putDummy(HttpResponse $response, HttpRequest $request): void 288 | { 289 | JwtContext::requireRole($request, User::ROLE_ADMIN); 290 | 291 | $payload = OpenApiContext::validateRequest($request); 292 | 293 | $dummyRepo = Psr11::get(DummyRepository::class); 294 | $model = $dummyRepo->get($payload['id']); 295 | if (empty($model)) { 296 | throw new Error404Exception('Id not found'); 297 | } 298 | ObjectCopy::copy($payload, $model); 299 | 300 | $dummyRepo->save($model); 301 | } 302 | 303 | } 304 | -------------------------------------------------------------------------------- /src/Rest/Login.php: -------------------------------------------------------------------------------- 1 | isValidUser($json["username"], $json["password"]); 62 | $metadata = JwtContext::createUserMetadata($user); 63 | 64 | $response->getResponseBag()->setSerializationRule(SerializationRuleEnum::SingleObject); 65 | $response->write(['token' => JwtContext::createToken($metadata)]); 66 | $response->write(['data' => $metadata]); 67 | } 68 | 69 | /** 70 | * Refresh Token 71 | * 72 | */ 73 | #[OA\Post( 74 | path: "/refreshtoken", 75 | security: [ 76 | ["jwt-token" => []] 77 | ], 78 | tags: ["Login"] 79 | )] 80 | #[OA\Response( 81 | response: 200, 82 | description: "The object rto be created", 83 | content: new OA\JsonContent( 84 | required: [ "token" ], 85 | properties: [ 86 | new OA\Property(property: "token", type: "string"), 87 | new OA\Property(property: "data", properties: [ 88 | new OA\Property(property: "role", type: "string"), 89 | new OA\Property(property: "userid", type: "string"), 90 | new OA\Property(property: "name", type: "string") 91 | ], type: "object") 92 | ] 93 | ) 94 | )] 95 | #[OA\Response( 96 | response: 401, 97 | description: "Não autorizado", 98 | content: new OA\JsonContent(ref: "#/components/schemas/error") 99 | )] 100 | public function refreshToken(HttpResponse $response, HttpRequest $request) 101 | { 102 | JwtContext::requireAuthenticated($request); 103 | 104 | $diff = ($request->param("jwt.exp") - time()) / 60; 105 | 106 | if ($diff > 5) { 107 | throw new Error401Exception("You only can refresh the token 5 minutes before expire"); 108 | } 109 | 110 | $users = Psr11::get(UsersDBDataset::class); 111 | $user = $users->getById(new HexUuidLiteral(JwtContext::getUserId())); 112 | 113 | $metadata = JwtContext::createUserMetadata($user); 114 | 115 | $response->getResponseBag()->setSerializationRule(SerializationRuleEnum::SingleObject); 116 | $response->write(['token' => JwtContext::createToken($metadata)]); 117 | $response->write(['data' => $metadata]); 118 | 119 | } 120 | 121 | /** 122 | * Initialize the Password Request 123 | * 124 | */ 125 | #[OA\Post( 126 | path: "/login/resetrequest", 127 | tags: ["Login"] 128 | )] 129 | #[OA\RequestBody( 130 | description: "The email to have the password reset", 131 | content: new OA\JsonContent( 132 | required: [ "email" ], 133 | properties: [ 134 | new OA\Property(property: "email", type: "string"), 135 | ] 136 | ) 137 | )] 138 | #[OA\Response( 139 | response: 200, 140 | description: "The token for reset", 141 | content: new OA\JsonContent( 142 | required: [ "token" ], 143 | properties: [ 144 | new OA\Property(property: "token", type: "string"), 145 | ] 146 | ) 147 | )] 148 | public function postResetRequest(HttpResponse $response, HttpRequest $request) 149 | { 150 | $json = OpenApiContext::validateRequest($request); 151 | 152 | $users = Psr11::get(UsersDBDataset::class); 153 | $user = $users->getByEmail($json["email"]); 154 | 155 | $token = BaseRepository::getUuid(); 156 | $code = rand(10000, 99999); 157 | 158 | if (!is_null($user)) { 159 | $user->set(User::PROP_RESETTOKEN, $token); 160 | $user->set(User::PROP_RESETTOKENEXPIRE, date('Y-m-d H:i:s', strtotime('+10 minutes'))); 161 | $user->set(User::PROP_RESETCODE, $code); 162 | $user->set(User::PROP_RESETALLOWED, null); 163 | $users->save($user); 164 | 165 | // Send email using MailWrapper 166 | $mailWrapper = Psr11::get(MailWrapperInterface::class); 167 | $envelope = Psr11::get('MAIL_ENVELOPE', [$json["email"], "RestReferenceArchitecture - Password Reset", "email_code.html", [ 168 | "code" => trim(chunk_split($code, 1, ' ')), 169 | "expire" => 10 170 | ]]); 171 | 172 | $mailWrapper->send($envelope); 173 | } 174 | 175 | $response->write(['token' => $token]); 176 | } 177 | 178 | protected function validateResetToken($response, $request): array 179 | { 180 | $json = OpenApiContext::validateRequest($request); 181 | 182 | $users = Psr11::get(UsersDBDataset::class); 183 | $user = $users->getByEmail($json["email"]); 184 | 185 | if (is_null($user)) { 186 | throw new Error422Exception("Invalid data"); 187 | } 188 | 189 | if ($user->get("resettoken") != $json["token"]) { 190 | throw new Error422Exception("Invalid data"); 191 | } 192 | 193 | if (strtotime($user->get("resettokenexpire")) < time()) { 194 | throw new Error422Exception("Invalid data"); 195 | } 196 | 197 | return [$users, $user, $json]; 198 | } 199 | 200 | /** 201 | * Initialize the Password Request 202 | * 203 | */ 204 | #[OA\Post( 205 | path: "/login/confirmcode", 206 | tags: ["Login"] 207 | )] 208 | #[OA\RequestBody( 209 | description: "The email and code to confirm the password reset", 210 | content: new OA\JsonContent( 211 | required: [ "email", "token", "code" ], 212 | properties: [ 213 | new OA\Property(property: "email", type: "string"), 214 | new OA\Property(property: "token", type: "string"), 215 | new OA\Property(property: "code", type: "string"), 216 | ] 217 | ) 218 | )] 219 | #[OA\Response( 220 | response: 200, 221 | description: "The token for reset", 222 | content: new OA\JsonContent( 223 | required: [ "token" ], 224 | properties: [ 225 | new OA\Property(property: "token", type: "string"), 226 | ] 227 | ) 228 | )] 229 | #[OA\Response( 230 | response: 422, 231 | description: "Invalid data", 232 | content: new OA\JsonContent(ref: "#/components/schemas/error") 233 | )] 234 | public function postConfirmCode(HttpResponse $response, HttpRequest $request) 235 | { 236 | list($users, $user, $json) = $this->validateResetToken($response, $request); 237 | 238 | if ($user->get("resetcode") != $json["code"]) { 239 | throw new Error422Exception("Invalid data"); 240 | } 241 | 242 | $user->set("resetallowed", "yes"); 243 | $users->save($user); 244 | 245 | $response->write(['token' => $json["token"]]); 246 | } 247 | 248 | /** 249 | * Initialize the Password Request 250 | * 251 | */ 252 | #[OA\Post( 253 | path: "/login/resetpassword", 254 | tags: ["Login"] 255 | )] 256 | #[OA\RequestBody( 257 | description: "The email and the new password", 258 | content: new OA\JsonContent( 259 | required: [ "email", "token", "password" ], 260 | properties: [ 261 | new OA\Property(property: "email", type: "string"), 262 | new OA\Property(property: "token", type: "string"), 263 | new OA\Property(property: "password", type: "string"), 264 | ] 265 | ) 266 | )] 267 | #[OA\Response( 268 | response: 200, 269 | description: "The token for reset", 270 | content: new OA\JsonContent( 271 | required: [ "token" ], 272 | properties: [ 273 | new OA\Property(property: "token", type: "string"), 274 | ] 275 | ) 276 | )] 277 | #[OA\Response( 278 | response: 422, 279 | description: "Invalid data", 280 | content: new OA\JsonContent(ref: "#/components/schemas/error") 281 | )] 282 | public function postResetPassword(HttpResponse $response, HttpRequest $request) 283 | { 284 | list($users, $user, $json) = $this->validateResetToken($response, $request); 285 | 286 | if ($user->get("resetallowed") != "yes") { 287 | throw new Error422Exception("Invalid data"); 288 | } 289 | 290 | $user->setPassword($json["password"]); 291 | $user->set("resettoken", null); 292 | $user->set("resettokenexpire", null); 293 | $user->set("resetcode", null); 294 | $user->set("resetallowed", null); 295 | $users->save($user); 296 | 297 | $response->write(['token' => $json["token"]]); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/Rest/Sample.php: -------------------------------------------------------------------------------- 1 | write([ 34 | 'result' => 'pong' 35 | ]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Rest/SampleProtected.php: -------------------------------------------------------------------------------- 1 | []] 27 | ], 28 | tags: ["zz_sampleprotected"], 29 | )] 30 | #[OA\Response( 31 | response: 200, 32 | description: "The object", 33 | content: new OA\JsonContent( 34 | required: [ "result" ], 35 | properties: [ 36 | new OA\Property(property: "result", type: "string", format: "string") 37 | ] 38 | ) 39 | )] 40 | #[OA\Response( 41 | response: 401, 42 | description: "Não autorizado", 43 | content: new OA\JsonContent(ref: "#/components/schemas/error") 44 | )] 45 | public function getPing(HttpResponse $response, HttpRequest $request) 46 | { 47 | JwtContext::requireAuthenticated($request); 48 | 49 | $response->write([ 50 | 'result' => 'pong' 51 | ]); 52 | } 53 | 54 | /** 55 | * Sample Ping Only Admin 56 | * 57 | * @param HttpResponse $response 58 | * @param HttpRequest $request 59 | * @throws Error401Exception 60 | * @throws InvalidArgumentException 61 | * @throws Error403Exception 62 | */ 63 | #[OA\Get( 64 | path: "/sampleprotected/pingadm", 65 | security: [ 66 | ["jwt-token" => []] 67 | ], 68 | tags: ["zz_sampleprotected"], 69 | )] 70 | #[OA\Response( 71 | response: 200, 72 | description: "The object", 73 | content: new OA\JsonContent( 74 | required: [ "result" ], 75 | properties: [ 76 | new OA\Property(property: "result", type: "string", format: "string") 77 | ] 78 | ) 79 | )] 80 | #[OA\Response( 81 | response: 401, 82 | description: "Não autorizado", 83 | content: new OA\JsonContent(ref: "#/components/schemas/error") 84 | )] 85 | public function getPingAdm(HttpResponse $response, HttpRequest $request) 86 | { 87 | JwtContext::requireRole($request, 'admin'); 88 | 89 | $response->write([ 90 | 'result' => 'pongadm' 91 | ]); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Util/FakeApiRequester.php: -------------------------------------------------------------------------------- 1 | withMiddleware(Psr11::get(JwtMiddleware::class)); 56 | $mock->withRequestObject($request); 57 | $mock->handle(Psr11::get(OpenApiRouteList::class), false, false); 58 | 59 | $httpClient = new MockClient($mock->getPsr7Response()); 60 | return $httpClient->sendRequest($request); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Util/JwtContext.php: -------------------------------------------------------------------------------- 1 | ($user->getAdmin() === User::VALUE_YES ? User::ROLE_ADMIN : User::ROLE_USER), 38 | 'userid' => HexUuidLiteral::getFormattedUuid($user->getUserid()), 39 | 'name' => $user->getName(), 40 | ]; 41 | } 42 | 43 | /** 44 | * @param array $properties 45 | * @return mixed 46 | * @throws ConfigException 47 | * @throws ConfigNotFoundException 48 | * @throws DependencyInjectionException 49 | * @throws InvalidArgumentException 50 | * @throws InvalidDateException 51 | * @throws KeyNotFoundException 52 | * @throws ReflectionException 53 | */ 54 | public static function createToken(array $properties = []) 55 | { 56 | $jwt = Psr11::get(JwtWrapper::class); 57 | $jwtData = $jwt->createJwtData($properties, 60 * 60 * 24 * 7); // 7 Dias 58 | return $jwt->generateToken($jwtData); 59 | } 60 | 61 | /** 62 | * @param HttpRequest $request 63 | * @return void 64 | * @throws Error401Exception 65 | */ 66 | public static function requireAuthenticated(HttpRequest $request): void 67 | { 68 | self::$request = $request; 69 | if ($request->param(JwtMiddleware::JWT_PARAM_PARSE_STATUS) !== JwtMiddleware::JWT_SUCCESS) { 70 | throw new Error401Exception($request->param(JwtMiddleware::JWT_PARAM_PARSE_MESSAGE)); 71 | } 72 | } 73 | 74 | public static function parseJwt(HttpRequest $request): void 75 | { 76 | self::$request = $request; 77 | } 78 | 79 | /** 80 | * @param HttpRequest $request 81 | * @param string $role 82 | * @return void 83 | * @throws Error401Exception 84 | * @throws Error403Exception 85 | * @throws InvalidArgumentException 86 | */ 87 | public static function requireRole(HttpRequest $request, string $role): void 88 | { 89 | self::requireAuthenticated($request); 90 | if (JwtContext::getRole() !== $role) { 91 | throw new Error403Exception('Insufficient privileges'); 92 | } 93 | } 94 | 95 | protected static function getRequestParam(string $value): ?string 96 | { 97 | if (isset(self::$request)) { 98 | $data = (array)self::$request->param("jwt.data"); 99 | if (isset($data[$value])) { 100 | return $data[$value]; 101 | } 102 | } 103 | return null; 104 | } 105 | 106 | public static function getUserId(): ?string 107 | { 108 | return self::getRequestParam("userid"); 109 | } 110 | 111 | public static function getRole(): ?string 112 | { 113 | return self::getRequestParam("role"); 114 | } 115 | 116 | public static function getName(): ?string 117 | { 118 | return self::getRequestParam("name"); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /src/Util/OpenApiContext.php: -------------------------------------------------------------------------------- 1 | getRequestPath(); 36 | $method = $request->server('REQUEST_METHOD'); 37 | 38 | // Validate the request body (payload) 39 | if (str_contains($request->getHeader('Content-Type') ?? "", 'multipart/')) { 40 | $requestBody = $request->post(); 41 | $files = $request->uploadedFiles()->getKeys(); 42 | $requestBody = array_merge($requestBody, array_combine($files, $files)); 43 | } else { 44 | $requestBody = json_decode($request->payload(), true); 45 | } 46 | 47 | try { 48 | // Validate the request path and query against the OpenAPI schema 49 | $schema->getPathDefinition($path, $method); 50 | // Returns a SwaggerRequestBody instance 51 | $bodyRequestDef = $schema->getRequestParameters($path, $method); 52 | $bodyRequestDef->match($requestBody); 53 | 54 | } catch (Exception $ex) { 55 | throw new Error400Exception(explode("\n", $ex->getMessage())[0]); 56 | } 57 | 58 | $requestBody = empty($requestBody) ? [] : $requestBody; 59 | 60 | if ($allowNull) { 61 | return $requestBody; 62 | } 63 | 64 | return Serialize::from($requestBody)->withDoNotParseNullValues()->toArray(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /templates/codegen/config.php.jinja: -------------------------------------------------------------------------------- 1 | DI::bind({{ className }}Repository::class) 7 | ->withInjectedConstructor() 8 | ->toSingleton(), 9 | 10 | ]; 11 | -------------------------------------------------------------------------------- /templates/codegen/model.php.jinja: -------------------------------------------------------------------------------- 1 | 0 %}, "{{ nonNullableFields | join('", "')}}"{% endif %}], type: "object", xml: new OA\Xml(name: "{{ className }}"))] 17 | #[Table{% if autoIncrement == "no" %}MySqlUuidPK{% endif %}Attribute("{{ tableName }}")] 18 | class {{ className }} 19 | { 20 | {% for field in fields %} 21 | /** 22 | * @var {{ field.php_type }}|null 23 | */ 24 | #[OA\Property(type: "{{ field.openapi_type }}", format: "{{ field.openapi_format }}"{% if field.null == "YES" %}, nullable: true{% endif %})] 25 | #[Field{% if 'binary' in field.type %}Uuid{% endif %}Attribute({% if field.key == "PRI" %}primaryKey: true, {% endif %}fieldName: "{{ field.field }}"{% if 'VIRTUAL' in field.extra %}, syncWithDb: false{% endif %})] 26 | protected {{ field.php_type }}{% if 'binary' in field.type %}|HexUuidLiteral{% endif %}|null ${{ field.property }} = null; 27 | {% endfor %} 28 | 29 | {% for field in fields %} 30 | /** 31 | * @return {{ field.php_type }}{% if 'binary' in field.type %}|HexUuidLiteral{% endif %}|null 32 | */ 33 | public function get{{ field.property | capitalize }}(): {{ field.php_type }}{% if 'binary' in field.type %}|HexUuidLiteral{% endif %}|null 34 | { 35 | return $this->{{ field.property }}; 36 | } 37 | 38 | /** 39 | * @param {{ field.php_type }}{% if 'binary' in field.type %}|HexUuidLiteral{% endif %}|null ${{ field.property }} 40 | * @return $this 41 | */ 42 | public function set{{ field.property | capitalize }}({{ field.php_type }}{% if 'binary' in field.type %}|HexUuidLiteral{% endif %}|null ${{ field.property }}): static 43 | { 44 | $this->{{ field.property }} = ${{ field.property }}; 45 | return $this; 46 | } 47 | {% endfor %} 48 | 49 | } 50 | -------------------------------------------------------------------------------- /templates/codegen/repository.php.jinja: -------------------------------------------------------------------------------- 1 | repository = new Repository($dbDriver, {{ className }}::class); 25 | } 26 | {% for index in indexes -%} 27 | {% if index.key_name != 'PRIMARY' -%} 28 | /** 29 | * @param mixed ${{ index.camelColumnName }} 30 | * @return null|{{ className }}[] 31 | */ 32 | public function getBy{{ index.camelColumnName | capitalize }}(${{ index.camelColumnName }}) 33 | { 34 | $query = Query::getInstance() 35 | ->table('{{ tableName }}') 36 | ->where('{{ tableName }}.{{ index.column_name }} = :value', ['value' => ${{ index.camelColumnName }}]); 37 | $result = $this->repository->getByQuery($query); 38 | return $result; 39 | } 40 | {% endif %} 41 | {% endfor %} 42 | 43 | } 44 | -------------------------------------------------------------------------------- /templates/codegen/rest.php.jinja: -------------------------------------------------------------------------------- 1 | []] 52 | ], 53 | tags: ["{{ restTag }}"], 54 | )] 55 | #[OA\Parameter( 56 | name: "id", 57 | in: "path", 58 | required: true, 59 | schema: new OA\Schema( 60 | type: "{{ fields.0.openapi_type }}", 61 | format: "{{ fields.0.openapi_format }}" 62 | ) 63 | )] 64 | #[OA\Response( 65 | response: 200, 66 | description: "The object {{ className }}", 67 | content: new OA\JsonContent(ref: "#/components/schemas/{{ className }}") 68 | )] 69 | public function get{{ className }}(HttpResponse $response, HttpRequest $request): void 70 | { 71 | JwtContext::requireAuthenticated($request); 72 | 73 | ${{ varTableName }}Repo = Psr11::get({{ className }}Repository::class); 74 | $id = $request->param('id'); 75 | 76 | $result = ${{ varTableName }}Repo->get($id); 77 | if (empty($result)) { 78 | throw new Error404Exception('Id not found'); 79 | } 80 | $response->write( 81 | $result 82 | ); 83 | } 84 | 85 | /** 86 | * List {{ className }} 87 | * 88 | * @param mixed $response 89 | * @param mixed $request 90 | * @return void 91 | * @throws ConfigException 92 | * @throws ConfigNotFoundException 93 | * @throws DependencyInjectionException 94 | * @throws Error401Exception 95 | * @throws InvalidArgumentException 96 | * @throws InvalidDateException 97 | * @throws KeyNotFoundException 98 | * @throws \ByJG\Serializer\Exception\InvalidArgumentException 99 | * @throws \Psr\SimpleCache\InvalidArgumentException 100 | * @throws ReflectionException 101 | */ 102 | #[OA\Get( 103 | path: "/{{ restPath }}", 104 | security: [ 105 | ["jwt-token" => []] 106 | ], 107 | tags: ["{{ restTag }}"] 108 | )] 109 | #[OA\Parameter( 110 | name: "page", 111 | description: "Page number", 112 | in: "query", 113 | required: false, 114 | schema: new OA\Schema( 115 | type: "integer", 116 | ) 117 | )] 118 | #[OA\Parameter( 119 | name: "size", 120 | description: "Page size", 121 | in: "query", 122 | required: false, 123 | schema: new OA\Schema( 124 | type: "integer", 125 | ) 126 | )] 127 | #[OA\Parameter( 128 | name: "orderBy", 129 | description: "Order by", 130 | in: "query", 131 | required: false, 132 | schema: new OA\Schema( 133 | type: "string", 134 | ) 135 | )] 136 | #[OA\Parameter( 137 | name: "filter", 138 | description: "Filter", 139 | in: "query", 140 | required: false, 141 | schema: new OA\Schema( 142 | type: "string", 143 | ) 144 | )] 145 | #[OA\Response( 146 | response: 200, 147 | description: "The object {{ className }}", 148 | content: new OA\JsonContent(type: "array", items: new OA\Items(ref: "#/components/schemas/{{ className }}")) 149 | )] 150 | #[OA\Response( 151 | response: 401, 152 | description: "Not Authorized", 153 | content: new OA\JsonContent(ref: "#/components/schemas/error") 154 | )] 155 | public function list{{ className }}(HttpResponse $response, HttpRequest $request): void 156 | { 157 | JwtContext::requireAuthenticated($request); 158 | 159 | $repo = Psr11::get({{ className }}Repository::class); 160 | 161 | $page = $request->get('page'); 162 | $size = $request->get('size'); 163 | // $orderBy = $request->get('orderBy'); 164 | // $filter = $request->get('filter'); 165 | 166 | $result = $repo->list($page, $size); 167 | $response->write( 168 | $result 169 | ); 170 | } 171 | 172 | 173 | /** 174 | * Create a new {{ className }} 175 | * 176 | * @param HttpResponse $response 177 | * @param HttpRequest $request 178 | * @return void 179 | * @throws ConfigException 180 | * @throws ConfigNotFoundException 181 | * @throws DependencyInjectionException 182 | * @throws Error400Exception 183 | * @throws Error401Exception 184 | * @throws Error403Exception 185 | * @throws InvalidArgumentException 186 | * @throws InvalidDateException 187 | * @throws KeyNotFoundException 188 | * @throws OrmBeforeInvalidException 189 | * @throws OrmInvalidFieldsException 190 | * @throws \ByJG\Serializer\Exception\InvalidArgumentException 191 | * @throws \Psr\SimpleCache\InvalidArgumentException 192 | * @throws ReflectionException 193 | */ 194 | #[OA\Post( 195 | path: "/{{ restPath }}", 196 | security: [ 197 | ["jwt-token" => []] 198 | ], 199 | tags: ["{{ restTag }}"] 200 | )] 201 | #[OA\RequestBody( 202 | description: "The object {{ className }} to be created", 203 | required: true, 204 | content: new OA\JsonContent( 205 | {% if nonNullableFields | count > 0 %}required: [ "{{ nonNullableFields | join('", "')}}" ],{% endif %} 206 | properties: [ 207 | {% for field in fields -%}{% if field.key != "PRI" && field.extra != 'VIRTUAL GENERATED' -%} 208 | new OA\Property(property: "{{ field.property }}", type: "{{ field.openapi_type }}", format: "{{ field.openapi_format }}"{% if field.null == "YES" %}, nullable: true{% endif %}){% if loop.last == false %}, {% endif %} 209 | {% endif %}{% endfor %} 210 | ] 211 | ) 212 | )] 213 | #[OA\Response( 214 | response: 200, 215 | description: "The object rto be created", 216 | content: new OA\JsonContent( 217 | required: [ "{{ primaryKeys | join('", "') }}" ], 218 | properties: [ 219 | {% for field in fields -%} 220 | {% if field.key == 'PRI' -%} new OA\Property(property: "{{ field.property }}", type: "{{ field.openapi_type }}", format: "{{ field.openapi_format }}"){% endif %} 221 | {% endfor %} 222 | ] 223 | ) 224 | )] 225 | #[OA\Response( 226 | response: 401, 227 | description: "Not Authorized", 228 | content: new OA\JsonContent(ref: "#/components/schemas/error") 229 | )] 230 | public function post{{ className }}(HttpResponse $response, HttpRequest $request): void 231 | { 232 | JwtContext::requireRole($request, User::ROLE_ADMIN); 233 | 234 | $payload = OpenApiContext::validateRequest($request); 235 | 236 | $model = new {{ className }}(); 237 | ObjectCopy::copy($payload, $model); 238 | 239 | ${{ varTableName }}Repo = Psr11::get({{ className }}Repository::class); 240 | ${{ varTableName }}Repo->save($model); 241 | 242 | $response->write([ "id" => $model->getId()]); 243 | } 244 | 245 | 246 | /** 247 | * Update an existing {{ className }} 248 | * 249 | * @param HttpResponse $response 250 | * @param HttpRequest $request 251 | * @return void 252 | * @throws Error401Exception 253 | * @throws Error404Exception 254 | * @throws ConfigException 255 | * @throws ConfigNotFoundException 256 | * @throws DependencyInjectionException 257 | * @throws InvalidDateException 258 | * @throws KeyNotFoundException 259 | * @throws InvalidArgumentException 260 | * @throws OrmBeforeInvalidException 261 | * @throws OrmInvalidFieldsException 262 | * @throws Error400Exception 263 | * @throws Error403Exception 264 | * @throws \ByJG\Serializer\Exception\InvalidArgumentException 265 | * @throws \Psr\SimpleCache\InvalidArgumentException 266 | * @throws ReflectionException 267 | */ 268 | #[OA\Put( 269 | path: "/{{ restPath }}", 270 | security: [ 271 | ["jwt-token" => []] 272 | ], 273 | tags: ["{{ restTag }}"] 274 | )] 275 | #[OA\RequestBody( 276 | description: "The object {{ className }} to be updated", 277 | required: true, 278 | content: new OA\JsonContent(ref: "#/components/schemas/{{ className }}") 279 | )] 280 | #[OA\Response( 281 | response: 200, 282 | description: "Nothing to return" 283 | )] 284 | #[OA\Response( 285 | response: 401, 286 | description: "Not Authorized", 287 | content: new OA\JsonContent(ref: "#/components/schemas/error") 288 | )] 289 | public function put{{ className }}(HttpResponse $response, HttpRequest $request): void 290 | { 291 | JwtContext::requireRole($request, User::ROLE_ADMIN); 292 | 293 | $payload = OpenApiContext::validateRequest($request); 294 | 295 | ${{ varTableName }}Repo = Psr11::get({{ className }}Repository::class); 296 | $model = ${{ varTableName }}Repo->get($payload['{{ fields.0.field }}']); 297 | if (empty($model)) { 298 | throw new Error404Exception('Id not found'); 299 | } 300 | ObjectCopy::copy($payload, $model); 301 | 302 | ${{ varTableName }}Repo->save($model); 303 | } 304 | 305 | } 306 | -------------------------------------------------------------------------------- /templates/codegen/test.php.jinja: -------------------------------------------------------------------------------- 1 | {% if field.php_type == 'int' %}1{% endif %}{% if field.php_type == 'string' %}'{{ field.property }}'{% endif %}{% if field.php_type == 'float' %}1.1{% endif %}{% if field.php_type == 'bool' %}true{% endif %},{% endif %} 27 | {% endfor %} 28 | ]; 29 | 30 | if ($array) { 31 | return $sample; 32 | } 33 | 34 | ObjectCopy::copy($sample, $model = new {{ className }}()); 35 | return $model; 36 | } 37 | 38 | 39 | 40 | public function testGetUnauthorized() 41 | { 42 | $this->expectException(Error401Exception::class); 43 | $this->expectExceptionMessage('Absent authorization token'); 44 | 45 | $request = new FakeApiRequester(); 46 | $request 47 | ->withPsr7Request($this->getPsr7Request()) 48 | ->withMethod('GET') 49 | ->withPath("/{{ restPath }}/{% if fields.0.type == 'int' %}1"{% else %}" . BaseRepository::getUuid(){% endif %}) 50 | ->assertResponseCode(401) 51 | ; 52 | $this->assertRequest($request); 53 | } 54 | 55 | public function testListUnauthorized() 56 | { 57 | $this->expectException(Error401Exception::class); 58 | $this->expectExceptionMessage('Absent authorization token'); 59 | 60 | $request = new FakeApiRequester(); 61 | $request 62 | ->withPsr7Request($this->getPsr7Request()) 63 | ->withMethod('GET') 64 | ->withPath("/{{ restPath }}/{% if fields.0.type == 'int' %}1"{% else %}" . BaseRepository::getUuid(){% endif %}) 65 | ->assertResponseCode(401) 66 | ; 67 | $this->assertRequest($request); 68 | } 69 | 70 | public function testPostUnauthorized() 71 | { 72 | $this->expectException(Error401Exception::class); 73 | $this->expectExceptionMessage('Absent authorization token'); 74 | 75 | $request = new FakeApiRequester(); 76 | $request 77 | ->withPsr7Request($this->getPsr7Request()) 78 | ->withMethod('POST') 79 | ->withPath("/{{ restPath }}") 80 | ->withRequestBody(json_encode($this->getSampleData(true))) 81 | ->assertResponseCode(401) 82 | ; 83 | $this->assertRequest($request); 84 | } 85 | 86 | public function testPutUnauthorized() 87 | { 88 | $this->expectException(Error401Exception::class); 89 | $this->expectExceptionMessage('Absent authorization token'); 90 | 91 | $request = new FakeApiRequester(); 92 | $request 93 | ->withPsr7Request($this->getPsr7Request()) 94 | ->withMethod('PUT') 95 | ->withPath("/{{ restPath }}") 96 | ->withRequestBody(json_encode($this->getSampleData(true) + ['{{ fields.0.field }}' => {% if fields.0.type == 'int' %}1{% else %}BaseRepository::getUuid(){% endif %}])) 97 | ->assertResponseCode(401) 98 | ; 99 | $this->assertRequest($request); 100 | } 101 | 102 | public function testPostInsufficientPrivileges() 103 | { 104 | $this->expectException(Error403Exception::class); 105 | $this->expectExceptionMessage('Insufficient privileges'); 106 | 107 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 108 | 109 | $request = new FakeApiRequester(); 110 | $request 111 | ->withPsr7Request($this->getPsr7Request()) 112 | ->withMethod('POST') 113 | ->withPath("/{{ restPath }}") 114 | ->withRequestBody(json_encode($this->getSampleData(true))) 115 | ->assertResponseCode(403) 116 | ->withRequestHeader([ 117 | "Authorization" => "Bearer " . $result['token'] 118 | ]) 119 | ; 120 | $this->assertRequest($request); 121 | } 122 | 123 | public function testPutInsufficientPrivileges() 124 | { 125 | $this->expectException(Error403Exception::class); 126 | $this->expectExceptionMessage('Insufficient privileges'); 127 | 128 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 129 | 130 | $request = new FakeApiRequester(); 131 | $request 132 | ->withPsr7Request($this->getPsr7Request()) 133 | ->withMethod('PUT') 134 | ->withPath("/{{ restPath }}") 135 | ->withRequestBody(json_encode($this->getSampleData(true) + ['{{ fields.0.field }}' => {% if fields.0.type == 'int' %}1{% else %}BaseRepository::getUuid(){% endif %}])) 136 | ->assertResponseCode(403) 137 | ->withRequestHeader([ 138 | "Authorization" => "Bearer " . $result['token'] 139 | ]) 140 | ; 141 | $this->assertRequest($request); 142 | } 143 | 144 | public function testFullCrud() 145 | { 146 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getAdminUser()))->getBody()->getContents(), true); 147 | 148 | $request = new FakeApiRequester(); 149 | $request 150 | ->withPsr7Request($this->getPsr7Request()) 151 | ->withMethod('POST') 152 | ->withPath("/{{ restPath }}") 153 | ->withRequestBody(json_encode($this->getSampleData(true))) 154 | ->assertResponseCode(200) 155 | ->withRequestHeader([ 156 | "Authorization" => "Bearer " . $result['token'] 157 | ]) 158 | ; 159 | $body = $this->assertRequest($request); 160 | $bodyAr = json_decode($body->getBody()->getContents(), true); 161 | 162 | $request = new FakeApiRequester(); 163 | $request 164 | ->withPsr7Request($this->getPsr7Request()) 165 | ->withMethod('GET') 166 | ->withPath("/{{ restPath }}/" . $bodyAr['{{ fields.0.field }}']) 167 | ->assertResponseCode(200) 168 | ->withRequestHeader([ 169 | "Authorization" => "Bearer " . $result['token'] 170 | ]) 171 | ; 172 | $body = $this->assertRequest($request); 173 | 174 | $request = new FakeApiRequester(); 175 | $request 176 | ->withPsr7Request($this->getPsr7Request()) 177 | ->withMethod('PUT') 178 | ->withPath("/{{ restPath }}") 179 | ->withRequestBody($body->getBody()->getContents()) 180 | ->assertResponseCode(200) 181 | ->withRequestHeader([ 182 | "Authorization" => "Bearer " . $result['token'] 183 | ]) 184 | ; 185 | $this->assertRequest($request); 186 | } 187 | 188 | public function testList() 189 | { 190 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 191 | 192 | $request = new FakeApiRequester(); 193 | $request 194 | ->withPsr7Request($this->getPsr7Request()) 195 | ->withMethod('GET') 196 | ->withPath("/{{ restPath }}") 197 | ->assertResponseCode(200) 198 | ->withRequestHeader([ 199 | "Authorization" => "Bearer " . $result['token'] 200 | ]) 201 | ; 202 | $this->assertRequest($request); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /templates/emails/email.html: -------------------------------------------------------------------------------- 1 |

Email

2 | 3 | Sample variable {{ var }} 4 | -------------------------------------------------------------------------------- /templates/emails/email_code.html: -------------------------------------------------------------------------------- 1 | Reset code: {{ code }} 2 | -------------------------------------------------------------------------------- /tests/Rest/BaseApiTestCase.php: -------------------------------------------------------------------------------- 1 | setSchema(Schema::getInstance(file_get_contents($this->filePath))); 24 | } 25 | 26 | protected function tearDown(): void 27 | { 28 | $this->setSchema(null); 29 | } 30 | 31 | public function getPsr7Request(): Request 32 | { 33 | $uri = Uri::getInstanceFromString() 34 | ->withScheme(Psr11::get("API_SCHEMA")) 35 | ->withHost(Psr11::get("API_SERVER")); 36 | 37 | return Request::getInstance($uri); 38 | } 39 | 40 | public function resetDb() 41 | { 42 | if (!self::$databaseReset) { 43 | if (Psr11::environment()->getCurrentEnvironment() != "test") { 44 | throw new Exception("This test can only be executed in test environment"); 45 | } 46 | Migration::registerDatabase(MySqlDatabase::class); 47 | $migration = new Migration(new Uri(Psr11::get('DBDRIVER_CONNECTION')), __DIR__ . "/../../../db"); 48 | $migration->prepareEnvironment(); 49 | $migration->reset(); 50 | self::$databaseReset = true; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Rest/Credentials.php: -------------------------------------------------------------------------------- 1 | (getenv('TEST_ADMIN_USER') ? getenv('TEST_ADMIN_USER') : 'admin@example.com'), 16 | 'password' => (getenv('TEST_ADMIN_PASSWORD') ? getenv('TEST_ADMIN_PASSWORD') : '!P4ssw0rdstr!'), 17 | ]; 18 | } 19 | 20 | public static function getRegularUser(): array 21 | { 22 | return [ 23 | 'username' => (getenv('TEST_REGULAR_USER') ? getenv('TEST_REGULAR_USER') : 'user@example.com'), 24 | 'password' => (getenv('TEST_REGULAR_PASSWORD') ? getenv('TEST_REGULAR_PASSWORD') : '!P4ssw0rdstr!'), 25 | ]; 26 | } 27 | 28 | public static function requestLogin($cred): FakeApiRequester 29 | { 30 | $uri = Uri::getInstanceFromString() 31 | ->withScheme(Psr11::get("API_SCHEMA")) 32 | ->withHost(Psr11::get("API_SERVER")); 33 | 34 | $psr7Request = Request::getInstance($uri); 35 | 36 | $request = new FakeApiRequester(); 37 | $request 38 | ->withPsr7Request($psr7Request) 39 | ->withMethod('POST') 40 | ->withPath("/login") 41 | ->assertResponseCode(200) 42 | ->withRequestBody($cred) 43 | ; 44 | return $request; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Rest/DummyHexTest.php: -------------------------------------------------------------------------------- 1 | 'field', 27 | ]; 28 | 29 | if ($array) { 30 | return $sample; 31 | } 32 | 33 | ObjectCopy::copy($sample, $model = new DummyHex()); 34 | return $model; 35 | } 36 | 37 | 38 | 39 | public function testGetUnauthorized() 40 | { 41 | $this->expectException(Error401Exception::class); 42 | $this->expectExceptionMessage('Absent authorization token'); 43 | 44 | $request = new FakeApiRequester(); 45 | $request 46 | ->withPsr7Request($this->getPsr7Request()) 47 | ->withMethod('GET') 48 | ->withPath("/dummyhex/" . BaseRepository::getUuid()) 49 | ->assertResponseCode(401) 50 | ; 51 | $this->assertRequest($request); 52 | } 53 | 54 | public function testListUnauthorized() 55 | { 56 | $this->expectException(Error401Exception::class); 57 | $this->expectExceptionMessage('Absent authorization token'); 58 | 59 | $request = new FakeApiRequester(); 60 | $request 61 | ->withPsr7Request($this->getPsr7Request()) 62 | ->withMethod('GET') 63 | ->withPath("/dummyhex/" . BaseRepository::getUuid()) 64 | ->assertResponseCode(401) 65 | ; 66 | $this->assertRequest($request); 67 | } 68 | 69 | public function testPostUnauthorized() 70 | { 71 | $this->expectException(Error401Exception::class); 72 | $this->expectExceptionMessage('Absent authorization token'); 73 | 74 | $request = new FakeApiRequester(); 75 | $request 76 | ->withPsr7Request($this->getPsr7Request()) 77 | ->withMethod('POST') 78 | ->withPath("/dummyhex") 79 | ->withRequestBody(json_encode($this->getSampleData(true))) 80 | ->assertResponseCode(401) 81 | ; 82 | $this->assertRequest($request); 83 | } 84 | 85 | public function testPutUnauthorized() 86 | { 87 | $this->expectException(Error401Exception::class); 88 | $this->expectExceptionMessage('Absent authorization token'); 89 | 90 | $request = new FakeApiRequester(); 91 | $request 92 | ->withPsr7Request($this->getPsr7Request()) 93 | ->withMethod('PUT') 94 | ->withPath("/dummyhex") 95 | ->withRequestBody(json_encode($this->getSampleData(true) + ['id' => BaseRepository::getUuid()])) 96 | ->assertResponseCode(401) 97 | ; 98 | $this->assertRequest($request); 99 | } 100 | 101 | public function testPostInsufficientPrivileges() 102 | { 103 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 104 | 105 | $this->expectException(Error403Exception::class); 106 | $this->expectExceptionMessage('Insufficient privileges'); 107 | 108 | $request = new FakeApiRequester(); 109 | $request 110 | ->withPsr7Request($this->getPsr7Request()) 111 | ->withMethod('POST') 112 | ->withPath("/dummyhex") 113 | ->withRequestBody(json_encode($this->getSampleData(true))) 114 | ->assertResponseCode(403) 115 | ->withRequestHeader([ 116 | "Authorization" => "Bearer " . $result['token'] 117 | ]) 118 | ; 119 | $this->assertRequest($request); 120 | } 121 | 122 | public function testPutInsufficientPrivileges() 123 | { 124 | $this->expectException(Error403Exception::class); 125 | $this->expectExceptionMessage('Insufficient privileges'); 126 | 127 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 128 | 129 | $request = new FakeApiRequester(); 130 | $request 131 | ->withPsr7Request($this->getPsr7Request()) 132 | ->withMethod('PUT') 133 | ->withPath("/dummyhex") 134 | ->withRequestBody(json_encode($this->getSampleData(true) + ['id' => BaseRepository::getUuid()])) 135 | ->assertResponseCode(403) 136 | ->withRequestHeader([ 137 | "Authorization" => "Bearer " . $result['token'] 138 | ]) 139 | ; 140 | $this->assertRequest($request); 141 | } 142 | 143 | public function testFullCrud() 144 | { 145 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getAdminUser()))->getBody()->getContents(), true); 146 | 147 | $request = new FakeApiRequester(); 148 | $request 149 | ->withPsr7Request($this->getPsr7Request()) 150 | ->withMethod('POST') 151 | ->withPath("/dummyhex") 152 | ->withRequestBody(json_encode($this->getSampleData(true))) 153 | ->assertResponseCode(200) 154 | ->withRequestHeader([ 155 | "Authorization" => "Bearer " . $result['token'] 156 | ]) 157 | ; 158 | $body = $this->assertRequest($request); 159 | $bodyAr = json_decode($body->getBody()->getContents(), true); 160 | 161 | $request = new FakeApiRequester(); 162 | $request 163 | ->withPsr7Request($this->getPsr7Request()) 164 | ->withMethod('GET') 165 | ->withPath("/dummyhex/" . $bodyAr['id']) 166 | ->assertResponseCode(200) 167 | ->withRequestHeader([ 168 | "Authorization" => "Bearer " . $result['token'] 169 | ]) 170 | ; 171 | $body = $this->assertRequest($request); 172 | 173 | $request = new FakeApiRequester(); 174 | $request 175 | ->withPsr7Request($this->getPsr7Request()) 176 | ->withMethod('PUT') 177 | ->withPath("/dummyhex") 178 | ->withRequestBody($body->getBody()->getContents()) 179 | ->assertResponseCode(200) 180 | ->withRequestHeader([ 181 | "Authorization" => "Bearer " . $result['token'] 182 | ]) 183 | ; 184 | $this->assertRequest($request); 185 | } 186 | 187 | public function testList() 188 | { 189 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 190 | 191 | $request = new FakeApiRequester(); 192 | $request 193 | ->withPsr7Request($this->getPsr7Request()) 194 | ->withMethod('GET') 195 | ->withPath("/dummyhex") 196 | ->assertResponseCode(200) 197 | ->withRequestHeader([ 198 | "Authorization" => "Bearer " . $result['token'] 199 | ]) 200 | ; 201 | $this->assertRequest($request); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /tests/Rest/DummyTest.php: -------------------------------------------------------------------------------- 1 | 'field', 26 | ]; 27 | 28 | if ($array) { 29 | return $sample; 30 | } 31 | 32 | ObjectCopy::copy($sample, $model = new Dummy()); 33 | return $model; 34 | } 35 | 36 | 37 | 38 | public function testGetUnauthorized() 39 | { 40 | $this->expectException(Error401Exception::class); 41 | $this->expectExceptionMessage('Absent authorization token'); 42 | 43 | $request = new FakeApiRequester(); 44 | $request 45 | ->withPsr7Request($this->getPsr7Request()) 46 | ->withMethod('GET') 47 | ->withPath("/dummy/1") 48 | ->assertResponseCode(401) 49 | ; 50 | $this->assertRequest($request); 51 | } 52 | 53 | public function testListUnauthorized() 54 | { 55 | $this->expectException(Error401Exception::class); 56 | $this->expectExceptionMessage('Absent authorization token'); 57 | 58 | $request = new FakeApiRequester(); 59 | $request 60 | ->withPsr7Request($this->getPsr7Request()) 61 | ->withMethod('GET') 62 | ->withPath("/dummy/1") 63 | ->assertResponseCode(401) 64 | ; 65 | $this->assertRequest($request); 66 | } 67 | 68 | public function testPostUnauthorized() 69 | { 70 | $this->expectException(Error401Exception::class); 71 | $this->expectExceptionMessage('Absent authorization token'); 72 | 73 | $request = new FakeApiRequester(); 74 | $request 75 | ->withPsr7Request($this->getPsr7Request()) 76 | ->withMethod('POST') 77 | ->withPath("/dummy") 78 | ->withRequestBody(json_encode($this->getSampleData(true))) 79 | ->assertResponseCode(401) 80 | ; 81 | $this->assertRequest($request); 82 | } 83 | 84 | public function testPutUnauthorized() 85 | { 86 | $this->expectException(Error401Exception::class); 87 | $this->expectExceptionMessage('Absent authorization token'); 88 | 89 | $request = new FakeApiRequester(); 90 | $request 91 | ->withPsr7Request($this->getPsr7Request()) 92 | ->withMethod('PUT') 93 | ->withPath("/dummy") 94 | ->withRequestBody(json_encode($this->getSampleData(true) + ['id' => 1])) 95 | ->assertResponseCode(401) 96 | ; 97 | $this->assertRequest($request); 98 | } 99 | 100 | public function testPostInsufficientPrivileges() 101 | { 102 | $this->expectException(Error403Exception::class); 103 | $this->expectExceptionMessage('Insufficient privileges'); 104 | 105 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 106 | 107 | $request = new FakeApiRequester(); 108 | $request 109 | ->withPsr7Request($this->getPsr7Request()) 110 | ->withMethod('POST') 111 | ->withPath("/dummy") 112 | ->withRequestBody(json_encode($this->getSampleData(true))) 113 | ->assertResponseCode(403) 114 | ->withRequestHeader([ 115 | "Authorization" => "Bearer " . $result['token'] 116 | ]) 117 | ; 118 | $this->assertRequest($request); 119 | } 120 | 121 | public function testPutInsufficientPrivileges() 122 | { 123 | $this->expectException(Error403Exception::class); 124 | $this->expectExceptionMessage('Insufficient privileges'); 125 | 126 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 127 | 128 | $request = new FakeApiRequester(); 129 | $request 130 | ->withPsr7Request($this->getPsr7Request()) 131 | ->withMethod('PUT') 132 | ->withPath("/dummy") 133 | ->withRequestBody(json_encode($this->getSampleData(true) + ['id' => 1])) 134 | ->assertResponseCode(403) 135 | ->withRequestHeader([ 136 | "Authorization" => "Bearer " . $result['token'] 137 | ]) 138 | ; 139 | $this->assertRequest($request); 140 | } 141 | 142 | public function testFullCrud() 143 | { 144 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getAdminUser()))->getBody()->getContents(), true); 145 | 146 | $request = new FakeApiRequester(); 147 | $request 148 | ->withPsr7Request($this->getPsr7Request()) 149 | ->withMethod('POST') 150 | ->withPath("/dummy") 151 | ->withRequestBody(json_encode($this->getSampleData(true))) 152 | ->assertResponseCode(200) 153 | ->withRequestHeader([ 154 | "Authorization" => "Bearer " . $result['token'] 155 | ]) 156 | ; 157 | $body = $this->assertRequest($request); 158 | $bodyAr = json_decode($body->getBody()->getContents(), true); 159 | 160 | $request = new FakeApiRequester(); 161 | $request 162 | ->withPsr7Request($this->getPsr7Request()) 163 | ->withMethod('GET') 164 | ->withPath("/dummy/" . $bodyAr['id']) 165 | ->assertResponseCode(200) 166 | ->withRequestHeader([ 167 | "Authorization" => "Bearer " . $result['token'] 168 | ]) 169 | ; 170 | $body = $this->assertRequest($request); 171 | 172 | $request = new FakeApiRequester(); 173 | $request 174 | ->withPsr7Request($this->getPsr7Request()) 175 | ->withMethod('PUT') 176 | ->withPath("/dummy") 177 | ->withRequestBody($body->getBody()->getContents()) 178 | ->assertResponseCode(200) 179 | ->withRequestHeader([ 180 | "Authorization" => "Bearer " . $result['token'] 181 | ]) 182 | ; 183 | $this->assertRequest($request); 184 | } 185 | 186 | public function testList() 187 | { 188 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 189 | 190 | $request = new FakeApiRequester(); 191 | $request 192 | ->withPsr7Request($this->getPsr7Request()) 193 | ->withMethod('GET') 194 | ->withPath("/dummy") 195 | ->assertResponseCode(200) 196 | ->withRequestHeader([ 197 | "Authorization" => "Bearer " . $result['token'] 198 | ]) 199 | ; 200 | $this->assertRequest($request); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /tests/Rest/LoginTest.php: -------------------------------------------------------------------------------- 1 | assertRequest(Credentials::requestLogin(Credentials::getAdminUser())); 21 | } 22 | 23 | public function testLoginOk2() 24 | { 25 | $this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser())); 26 | } 27 | 28 | public function testLoginFail() 29 | { 30 | $this->expectException(Error401Exception::class); 31 | $this->expectExceptionMessage('Username or password is invalid'); 32 | 33 | $this->assertRequest(Credentials::requestLogin([ 34 | 'username' => 'invalid', 35 | 'password' => 'invalid' 36 | ])); 37 | } 38 | 39 | public function testResetRequestOk() 40 | { 41 | $email = Credentials::getRegularUser()["username"]; 42 | 43 | // Clear the reset token 44 | $userRepo = Psr11::get(UsersDBDataset::class); 45 | $user = $userRepo->getByEmail($email); 46 | $user->set(User::PROP_RESETTOKEN, null); 47 | $user->set(User::PROP_RESETTOKENEXPIRE, null); 48 | $user->set(User::PROP_RESETCODE, null); 49 | $user->set(User::PROP_RESETALLOWED, null); 50 | $userRepo->save($user); 51 | 52 | // Check if the reset token was cleared 53 | $user = $userRepo->getByEmail($email); 54 | $this->assertNotNull($user); 55 | $this->assertEmpty($user->get(User::PROP_RESETTOKEN)); 56 | $this->assertEmpty($user->get(User::PROP_RESETTOKENEXPIRE)); 57 | $this->assertEmpty($user->get(User::PROP_RESETCODE)); 58 | $this->assertEmpty($user->get(User::PROP_RESETALLOWED)); 59 | 60 | // Execute the request 61 | $request = new FakeApiRequester(); 62 | $request 63 | ->withPsr7Request($this->getPsr7Request()) 64 | ->withMethod('POST') 65 | ->withPath("/login/resetrequest") 66 | ->withRequestBody(json_encode(["email" => $email])) 67 | ->assertResponseCode(200) 68 | ; 69 | $this->assertRequest($request); 70 | 71 | // Check if the reset token was created 72 | $userRepo = Psr11::get(UsersDBDataset::class); 73 | $user = $userRepo->getByEmail($email); 74 | $this->assertNotNull($user); 75 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKEN)); 76 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKENEXPIRE)); 77 | $this->assertNotEmpty($user->get(User::PROP_RESETCODE)); 78 | $this->assertEmpty($user->get(User::PROP_RESETALLOWED)); 79 | } 80 | 81 | public function testConfirmCodeFail() 82 | { 83 | $email = Credentials::getRegularUser()["username"]; 84 | 85 | // Clear the reset token 86 | $userRepo = Psr11::get(UsersDBDataset::class); 87 | $user = $userRepo->getByEmail($email); 88 | $this->assertNotNull($user); 89 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKEN)); 90 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKENEXPIRE)); 91 | $this->assertNotEmpty($user->get(User::PROP_RESETCODE)); 92 | $this->assertEmpty($user->get(User::PROP_RESETALLOWED)); 93 | 94 | $this->expectException(Error422Exception::class); 95 | 96 | // Execute the request, expecting an error 97 | $request = new FakeApiRequester(); 98 | $request 99 | ->withPsr7Request($this->getPsr7Request()) 100 | ->withMethod('POST') 101 | ->withPath("/login/confirmcode") 102 | ->withRequestBody(json_encode((["email" => $email, "code" => "123456", "token" => $user->get(User::PROP_RESETTOKEN)]))) 103 | ->assertResponseCode(422) 104 | ; 105 | $this->assertRequest($request); 106 | } 107 | 108 | public function testConfirmCodeOk() 109 | { 110 | $email = Credentials::getRegularUser()["username"]; 111 | 112 | // Clear the reset token 113 | $userRepo = Psr11::get(UsersDBDataset::class); 114 | $user = $userRepo->getByEmail($email); 115 | $this->assertNotNull($user); 116 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKEN)); 117 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKENEXPIRE)); 118 | $this->assertNotEmpty($user->get(User::PROP_RESETCODE)); 119 | $this->assertEmpty($user->get(User::PROP_RESETALLOWED)); 120 | 121 | // Execute the request, now with the correct code 122 | $request = new FakeApiRequester(); 123 | $request 124 | ->withPsr7Request($this->getPsr7Request()) 125 | ->withMethod('POST') 126 | ->withPath("/login/confirmcode") 127 | ->withRequestBody(json_encode((["email" => $email, "code" => $user->get(User::PROP_RESETCODE), "token" => $user->get(User::PROP_RESETTOKEN)]))) 128 | ->assertResponseCode(200) 129 | ; 130 | $this->assertRequest($request); 131 | 132 | // Check if the reset token was created 133 | $user = $userRepo->getByEmail($email); 134 | $this->assertNotNull($user); 135 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKEN)); 136 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKENEXPIRE)); 137 | $this->assertNotEmpty($user->get(User::PROP_RESETCODE)); 138 | $this->assertEquals(User::VALUE_YES, $user->get(User::PROP_RESETALLOWED)); 139 | } 140 | 141 | public function testPasswordResetOk() 142 | { 143 | $email = Credentials::getRegularUser()["username"]; 144 | $password = Credentials::getRegularUser()["password"]; 145 | 146 | // Clear the reset token 147 | $userRepo = Psr11::get(UsersDBDataset::class); 148 | $user = $userRepo->getByEmail($email); 149 | $this->assertNotNull($user); 150 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKEN)); 151 | $this->assertNotEmpty($user->get(User::PROP_RESETTOKENEXPIRE)); 152 | $this->assertNotEmpty($user->get(User::PROP_RESETCODE)); 153 | $this->assertEquals(User::VALUE_YES, $user->get(User::PROP_RESETALLOWED)); 154 | 155 | // Execute the request, now with the correct code 156 | $request = new FakeApiRequester(); 157 | $request 158 | ->withPsr7Request($this->getPsr7Request()) 159 | ->withMethod('POST') 160 | ->withPath("/login/resetpassword") 161 | ->withRequestBody(json_encode([ 162 | "email" => $email, 163 | "token" => $user->get(User::PROP_RESETTOKEN), 164 | "password" => "new$password" 165 | ])) 166 | ->assertResponseCode(200) 167 | ; 168 | $this->assertRequest($request); 169 | 170 | // Check if the reset token was created 171 | $user = $userRepo->getByEmail($email); 172 | $this->assertNotNull($user); 173 | $this->assertEquals("83bfd34a3ebc0973609f5f2ec0080080286e3879", $user->getPassword()); 174 | $this->assertEmpty($user->get(User::PROP_RESETTOKEN)); 175 | $this->assertEmpty($user->get(User::PROP_RESETTOKENEXPIRE)); 176 | $this->assertEmpty($user->get(User::PROP_RESETCODE)); 177 | $this->assertEmpty($user->get(User::PROP_RESETALLOWED)); 178 | 179 | // Restore old password 180 | $user->setPassword($password); 181 | $userRepo->save($user); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /tests/Rest/SampleProtectedTest.php: -------------------------------------------------------------------------------- 1 | expectException(Error401Exception::class); 18 | $this->expectExceptionMessage('Absent authorization token'); 19 | 20 | $request = new FakeApiRequester(); 21 | $request 22 | ->withPsr7Request($this->getPsr7Request()) 23 | ->withMethod('GET') 24 | ->withPath("/sampleprotected/ping") 25 | ->assertResponseCode(401) 26 | ; 27 | $this->assertRequest($request); 28 | } 29 | 30 | public function testGetAuthorized() 31 | { 32 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getAdminUser()))->getBody()->getContents(), true); 33 | 34 | $request = new FakeApiRequester(); 35 | $request 36 | ->withPsr7Request($this->getPsr7Request()) 37 | ->withMethod('GET') 38 | ->withPath("/sampleprotected/ping") 39 | ->assertResponseCode(200) 40 | ->withRequestHeader([ 41 | "Authorization" => "Bearer " . $result['token'] 42 | ]) 43 | ; 44 | $this->assertRequest($request); 45 | } 46 | 47 | public function testGetAuthorizedRole1() 48 | { 49 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getAdminUser()))->getBody()->getContents(), true); 50 | 51 | $request = new FakeApiRequester(); 52 | $request 53 | ->withPsr7Request($this->getPsr7Request()) 54 | ->withMethod('GET') 55 | ->withPath("/sampleprotected/pingadm") 56 | ->assertResponseCode(200) 57 | ->withRequestHeader([ 58 | "Authorization" => "Bearer " . $result['token'] 59 | ]) 60 | ; 61 | $this->assertRequest($request); 62 | } 63 | 64 | public function testGetAuthorizedRole2() 65 | { 66 | $this->expectException(Error403Exception::class); 67 | $this->expectExceptionMessage('Insufficient privileges'); 68 | 69 | $result = json_decode($this->assertRequest(Credentials::requestLogin(Credentials::getRegularUser()))->getBody()->getContents(), true); 70 | 71 | $request = new FakeApiRequester(); 72 | $request 73 | ->withPsr7Request($this->getPsr7Request()) 74 | ->withMethod('GET') 75 | ->withPath("/sampleprotected/pingadm") 76 | ->assertResponseCode(401) 77 | ->withRequestHeader([ 78 | "Authorization" => "Bearer " . $result['token'] 79 | ]) 80 | ; 81 | $this->assertRequest($request); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Rest/SampleTest.php: -------------------------------------------------------------------------------- 1 | withPsr7Request($this->getPsr7Request()) 22 | ->withMethod('GET') 23 | ->withPath("/sample/ping") 24 | ; 25 | $this->assertRequest($request); 26 | } 27 | } 28 | --------------------------------------------------------------------------------