├── .github ├── release-drafter.yml └── workflows │ ├── bundler.yml │ ├── release-drafter.yml │ └── unit-tests.yml ├── .gitignore ├── .php_cs.dist ├── CHANGELOG.md ├── README.md ├── composer.json ├── examples ├── Country.php └── test.php ├── phpunit.xml.dist ├── src ├── Api.php └── Exception.php ├── tests ├── ApiTester.php ├── ApiTesterRestTest.php ├── ApiTesterTest.php ├── Model │ └── Country.php └── PatternMatcherTest.php └── tools └── release.sh /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # See https://github.com/release-drafter/release-drafter#configuration 2 | categories: 3 | - title: 'Enhancements' 4 | labels: 5 | - enhancement 6 | template: | 7 | ## What’s Changed 8 | $CHANGES 9 | -------------------------------------------------------------------------------- /.github/workflows/bundler.yml: -------------------------------------------------------------------------------- 1 | name: Bundler 2 | 3 | on: create 4 | 5 | jobs: 6 | autocommit: 7 | name: Update to stable dependencies 8 | if: startsWith(github.ref, 'refs/heads/release/') 9 | runs-on: ubuntu-latest 10 | container: 11 | image: atk4/image:latest # https://github.com/atk4/image 12 | steps: 13 | - uses: actions/checkout@master 14 | - run: echo ${{ github.ref }} 15 | - name: Update to stable dependencies 16 | run: | 17 | # replaces X keys with X-release keys 18 | jq '. as $in | reduce (keys_unsorted[] | select(endswith("-release")|not)) as $k ({}; . + {($k) : (($k + "-release") as $kr | $in | if has($kr) then .[$kr] else .[$k] end) } )' < composer.json > tmp && mv tmp composer.json 19 | composer config version --unset 20 | v=$(echo ${{ github.ref }} | cut -d / -f 4) 21 | echo "::set-env name=version::$v" 22 | 23 | - uses: teaminkling/autocommit@master 24 | with: 25 | commit-message: Setting release dependencies 26 | - uses: ad-m/github-push-action@master 27 | with: 28 | branch: ${{ github.ref }} 29 | github_token: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: pull-request 32 | uses: romaninsh/pull-request@master 33 | with: 34 | source_branch: "release/${{ env.version }}" 35 | destination_branch: "master" # If blank, default: master 36 | pr_title: "Releasing ${{ env.version }} into master" 37 | pr_body: | 38 | - [ ] Review changes (must include stable dependencies) 39 | - [ ] Merge this PR into master (will delete ${{ github.ref }}) 40 | - [ ] Go to Releases and create TAG from master 41 | Do not merge master into develop 42 | pr_reviewer: "romaninsh" 43 | pr_assignee: "romaninsh" 44 | github_token: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - develop 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | - uses: toolmantim/release-drafter@v5.6.1 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Testing 2 | 3 | on: 4 | pull_request: 5 | push: 6 | schedule: 7 | - cron: '0 * * * *' 8 | 9 | jobs: 10 | unit-test: 11 | name: Unit 12 | runs-on: ubuntu-latest 13 | container: 14 | image: atk4/image:${{ matrix.php }} # https://github.com/atk4/image 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | php: ['7.3', 'latest'] 19 | type: ['Phpunit'] 20 | include: 21 | - php: 'latest' 22 | type: 'CodingStyle' 23 | env: 24 | LOG_COVERAGE: "${{ fromJSON('{true: \"1\", false: \"\"}')[matrix.php == 'latest' && matrix.type == 'Phpunit' && (github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master')))] }}" 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | 29 | - name: Configure PHP 30 | run: | 31 | if [ -z "$LOG_COVERAGE" ]; then rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; fi 32 | php --version 33 | 34 | - name: Setup cache 1/2 35 | id: composer-cache 36 | run: | 37 | echo "::set-output name=dir::$(composer config cache-files-dir)" 38 | 39 | - name: Setup cache 2/2 40 | uses: actions/cache@v1 41 | with: 42 | path: ${{ steps.composer-cache.outputs.dir }} 43 | key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} 44 | restore-keys: | 45 | ${{ runner.os }}-composer- 46 | 47 | - name: Install PHP dependencies 48 | run: | 49 | if [ "${{ matrix.type }}" != "Phpunit" ]; then composer remove --no-interaction --no-update phpunit/phpunit --dev ; fi 50 | if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev ; fi 51 | composer install --no-suggest --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader 52 | 53 | - name: Init 54 | run: | 55 | mkdir -p build/logs 56 | 57 | - name: "Run tests: Phpunit (only for Phpunit)" 58 | if: matrix.type == 'Phpunit' 59 | run: "vendor/bin/phpunit \"$(if [ -n \"$LOG_COVERAGE\" ]; then echo '--coverage-text'; else echo '--no-coverage'; fi)\" -v" 60 | 61 | - name: Lint / check syntax (only for CodingStyle) 62 | if: matrix.type == 'CodingStyle' 63 | run: find . \( -type d \( -path './vendor/*' \) \) -prune -o ! -type d -name '*.php' -print0 | xargs -0 -n1 php -l 64 | 65 | - name: Check Coding Style (only for CodingStyle) 66 | if: matrix.type == 'CodingStyle' 67 | run: vendor/bin/php-cs-fixer fix --dry-run --using-cache=no --diff --diff-format=udiff --verbose --show-progress=dots 68 | 69 | - name: Upload coverage logs (only for "latest" Phpunit) 70 | if: env.LOG_COVERAGE 71 | uses: codecov/codecov-action@v1 72 | with: 73 | token: ${{ secrets.CODECOV_TOKEN }} 74 | file: build/logs/clover.xml 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/build 2 | /build 3 | /vendor 4 | /composer.lock 5 | .idea 6 | nbproject 7 | .DS_Store 8 | 9 | local 10 | *.local 11 | *.local.* 12 | *.cache 13 | *.cache.* 14 | 15 | /phpunit.xml 16 | /phpunit-*.xml 17 | 18 | *.bak 19 | *.db 20 | 21 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | in([__DIR__]) 5 | ->exclude([ 6 | 'cache', 7 | 'build', 8 | 'vendor', 9 | ]); 10 | 11 | return PhpCsFixer\Config::create() 12 | ->setRiskyAllowed(true) 13 | ->setRules([ 14 | '@PhpCsFixer' => true, 15 | '@PhpCsFixer:risky' =>true, 16 | '@PHP71Migration:risky' => true, 17 | '@PHP73Migration' => true, 18 | 19 | // required by PSR-12 20 | 'concat_space' => [ 21 | 'spacing' => 'one', 22 | ], 23 | 24 | // disable some too strict rules 25 | 'phpdoc_types' => [ 26 | // keep enabled, but without "alias" group to not fix 27 | // "Callback" to "callback" in phpdoc 28 | 'groups' => ['simple', 'meta'] 29 | ], 30 | 'phpdoc_types_order' => [ 31 | 'null_adjustment' => 'always_last', 32 | 'sort_algorithm' => 'none', 33 | ], 34 | 'single_line_throw' => false, 35 | 'yoda_style' => [ 36 | 'equal' => false, 37 | 'identical' => false, 38 | ], 39 | 'native_function_invocation' => false, 40 | 'non_printable_character' => [ 41 | 'use_escape_sequences_in_strings' => true, 42 | ], 43 | 'void_return' => false, 44 | 'blank_line_before_statement' => [ 45 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'exit'], 46 | ], 47 | 'combine_consecutive_issets' => false, 48 | 'combine_consecutive_unsets' => false, 49 | 'multiline_whitespace_before_semicolons' => false, 50 | 'no_superfluous_elseif' => false, 51 | 'ordered_class_elements' => false, 52 | 'php_unit_internal_class' => false, 53 | 'php_unit_test_case_static_method_calls' => [ 54 | 'call_type' => 'this', 55 | ], 56 | 'php_unit_test_class_requires_covers' => false, 57 | 'phpdoc_add_missing_param_annotation' => false, 58 | 'return_assignment' => false, 59 | 'comment_to_phpdoc' => false, 60 | 'list_syntax' => ['syntax' => 'short'], 61 | 'general_phpdoc_annotation_remove' => [ 62 | 'annotations' => ['author', 'copyright', 'throws'], 63 | ], 64 | 'nullable_type_declaration_for_default_null_value' => [ 65 | 'use_nullable_type_declaration' => false, 66 | ], 67 | ]) 68 | ->setFinder($finder) 69 | ->setCacheFile(__DIR__ . '/.php_cs.cache'); 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Pre-releases 2 | 3 | ### 0.2 More flexible REST lookups 4 | 5 | - add support for looking up by fields other than ID: "api/country/code:GB" 6 | 7 | ### 0.1 Initial release 8 | 9 | - added post(), get(), etc 10 | - added rest() and integration with Model Data 11 | - first working prototype 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Agile API Framework 2 | 3 | [![Build Status](https://travis-ci.org/atk4/api.png?branch=develop)](https://travis-ci.org/atk4/api) 4 | [![StyleCI](https://styleci.io/repos/107142772/shield)](https://styleci.io/repos/107142772) 5 | [![codecov](https://codecov.io/gh/atk4/api/branch/develop/graph/badge.svg)](https://codecov.io/gh/atk4/api) 6 | [![Code Climate](https://codeclimate.com/github/atk4/api/badges/gpa.svg)](https://codeclimate.com/github/atk4/api) 7 | [![Issue Count](https://codeclimate.com/github/atk4/api/badges/issue_count.svg)](https://codeclimate.com/github/atk4/api/issues) 8 | 9 | [![License](https://poser.pugx.org/atk4/api/license)](https://packagist.org/packages/atk4/api) 10 | [![GitHub release](https://img.shields.io/github/release/atk4/api.svg?maxAge=2592000)](https://packagist.org/packages/atk4/api) 11 | 12 | End-to-end implementation for your RESTful API and RPC. Provides a very simple means for you to define API end-points for the application that already uses [Agile Data](https://github.com/atk4/data). 13 | 14 | ## 1. Simple To Use 15 | 16 | Agile API strives to be very simple and work out of the box. Below is a minimal code to get your basic API going, put that into `v1.php` file then invoke `composer require Atk4/Api` : 17 | 18 | ``` php 19 | include 'vendor/autoload.php'; 20 | 21 | $api = new \Atk4\Api\Api(); 22 | 23 | // Simple handling of GET request through a callback. 24 | $api->get('/ping', function() { 25 | return 'Pong'; 26 | }); 27 | 28 | // Methods can accept arguments, and everything is type-safe. 29 | $api->get('/hello/:name', function ($name) { 30 | return "Hello, $name"; 31 | }); 32 | ``` 33 | 34 | ## 2. Agile Data Integration 35 | 36 | [Agile Data](https://github.com/atk4/data) is a data persistence framework. In simple terms, you can use Agile Data to create your business models (entities) and interact with the database. Agile API is designed to be a perfect integration if you are have already defined classes and persistence in Agile Data. Next code assumes you have `Model Country` and `Persistence $db`: 37 | 38 | ``` php 39 | $api->rest('/countries', new Country($db)); 40 | ``` 41 | 42 | This creates a standard standard-compliant RESTful interface for interfacing the client model: 43 | 44 | - `GET /countries` responds with list of all Country records from $db. 45 | - `POST /countries` adds a new Country reading data from Form data or JSON in POST body. 46 | - `GET /countries/123` loads client with specified ID. 47 | - `PATCH /countries/123` with some Form data or JSON will update existing Country. 48 | - `DELETE /countries/123` will delete a record. 49 | 50 | Through Agile UI you may add conditions, limits and more. Also second argument can be a call-back: 51 | 52 | ``` php 53 | $api->rest('/countries', function() use($db) { 54 | $c = new Country($db)); 55 | $c->addCondition('is_eu', true); 56 | $c->setLimit(20); 57 | return $c; 58 | }); 59 | ``` 60 | 61 | Field types, data conversions, validation and hooks can all be defined through Agile Data, making the API layer very transparent and simple. If you attempt to load non-existant record, API will respond with 404. Other errors will be properly mapped to the API codes or fallback to 500. 62 | 63 | 64 | 65 | # Work in progress 66 | 67 | Agile UI is still a work in progress. This readme will be further updated to reflect a current features. 68 | 69 | 70 | 71 | ## Planned Features 72 | 73 | Agile API is in development but the following features are planned: 74 | 75 | - [x] Simple to use. 76 | 77 | 78 | - [x] Model routing. Provide end-points by associating them with models. 79 | - [ ] Global authentication. Provide authentication strategy for entire framework. 80 | - [ ] Support for rate limits. Per-account, per-IP counters which can be stored in MemCache or Redis. 81 | - [ ] Deep logging, integrated with data persistence. Not only stores the request, but what data was affected inside persistence. 82 | - [ ] Support for API UNDO. Neutralize effect of API call had on your backend. 83 | 84 | ### Simple to use 85 | 86 | To set up your API, simply create new RestAPI class instance and define routes. You can enable versioning by creating "v1" folder and placing `index.php` in that folder. Some things work and we do not want to re-invent them! 87 | 88 | ``` php 89 | require 'vendor/autoload.php'; 90 | $app = new \Atk4\Api\Api(); 91 | 92 | $db = \Atk4\Data\Persistence::connect($DSN); 93 | 94 | // Lets set our index page 95 | $app->get('/', function() { 96 | return 'This worked!'; 97 | }); 98 | 99 | // Getting access to POST data 100 | $app->post('/stats/:id', function($id, $data) { 101 | return ['Received POST', 'id'=>$id, 'post_data'=>$data] 102 | }); 103 | ``` 104 | 105 | Calling methods such as `get()`, `post()` with a function call-back will register them and if URL matches a pattern, all the matching callbacks will be executed, that is, until some of them will present a return value. 106 | 107 | Execution will occur as soon as the match is confirmed (to help with error display). 108 | 109 | Technically this allows multiple call-backs to be matched: 110 | 111 | ``` php 112 | $app->get('/:method', function($method) { 113 | // do something 114 | }); 115 | 116 | $app->get('/ping', function() { 117 | return 'pong'; 118 | }); 119 | ``` 120 | 121 | Note, that some popular PHP API frameworks (like Slim) use {name} for matching parameters, however rest of IT industry prefers using ":name" instead. We will use industry pattern matching, but will try to also support {$foo}, although it does look too similar to Agile UI template tags. 122 | 123 | I think that the methods can be cleverly made to match the rules too: 124 | 125 | ``` php 126 | function get($route, $action) { 127 | if ($_SERVER['REQUEST_METHOD'] == 'GET') { 128 | return $this->match($route, $action); 129 | } 130 | } 131 | ``` 132 | 133 | A useful note about `match` is that it can be used without action and will return `true`/`false`. 134 | 135 | ``` php 136 | if ($app->match('/misc/**')) { 137 | // .. execute logic for requests starting with /misc/... 138 | } else { 139 | // .. other logic 140 | } 141 | ``` 142 | 143 | ### Model Routing 144 | 145 | Method `rest()` implements a standard Restful API end-point dedicated to a model. There are two ways to use it: 146 | 147 | ``` php 148 | $app->rest('/clients', new Client($db)); 149 | ``` 150 | 151 | This would simple enable all the necessary operations for accessing the model, in particular: 152 | 153 | - GET /clients - listing all clients 154 | - GET /clients/:id - get specific client data 155 | - POST /clients - create new client 156 | - PUT /clients/:id - same as patch 157 | - PATCH /clients/:id - load, update specified fields only, save 158 | - DELETE /clients/:id - delete specific client record 159 | 160 | You can also specify a different field if you don't want to use primary key: 161 | 162 | ``` php 163 | $app->rest('/country/:iso_name'); 164 | ``` 165 | 166 | Agile Data offers powerful ways of traversing references, and the above approach can also utilize: 167 | 168 | ``` php 169 | $app->rest('/clients/:id/orders/::Orders:id', new Client($db)); 170 | ``` 171 | 172 | This would create new route for URLs such as `/clients/123/orders/395`. The model for the client with id 123 would be loaded first, then ref('Orders') would be executed. The rest of the logic is similar to before. 173 | 174 | This gives us option to perform deep traversal too: 175 | 176 | ``` php 177 | $app->rest('/clients/:id/order_payments/::Orders::Payments:id', new Client($db)); 178 | ``` 179 | 180 | This would load the Client, perform ref('Orders')->ref('Payments'). Finally, the "id" is optional: 181 | 182 | ```php 183 | $app->rest('/client/:/order_payments/::Orders::Payments', new Client($db)); 184 | ``` 185 | 186 | Sometimes you would want to have even more control, so you can use: 187 | 188 | ``` php 189 | $app->rest('/client/:id/invoices-due', function($id) use($db) { 190 | $client = new Client($db); 191 | $client->load($id); 192 | return $client->ref('Invoices')->addCondition('status', 'due'); 193 | }); 194 | ``` 195 | 196 | Method `rest()` builds on top of methods `put()`, `get()`, `post()` and others. Third argument to method `rest()` can specify array with options. 197 | 198 | ## Auth 199 | 200 | Our API supports various authentication methods. Some of them are built-in and 3rd party extensions can also be used. 201 | 202 | Lets look at the very basic user/password authentication. 203 | 204 | ``` php 205 | // Enable user/password authentication. Field values are optional 206 | $app->userAuth('/**', new User($db)); 207 | ``` 208 | 209 | You can place the authentication method strategically, and it will protect all the further routes but not the ones above it. Also you can use a custom route if you wish to only protect some portion of your API. 210 | 211 | The method AUTH will look for HTTP_AUTH headers and will respond with 405 code if user record cannot be loaded with a corresponding user/password combination. 212 | 213 | After user authentication is performed, `$app->user` will exist: 214 | 215 | ``` php 216 | $app->authUser('/**', new User($db)); 217 | $app->rest('/notifications', $app->user->ref('Notifications')); 218 | ``` 219 | 220 | ### Rate Limit 221 | 222 | Rate Limit support will limit number of requests which user (or IP) can make. It's easy to set it up: 223 | 224 | ``` php 225 | $app->authUser('/**', new User($db)); 226 | 227 | $limit = new \Atk4\Api\Limit($db); 228 | $limit->addCondition('user_id', $app->user->id); 229 | 230 | $app->get('/limits', function() use ($limit){ 231 | return $limit; 232 | }); 233 | 234 | $app->rateLimit('/**', $limit, 10); // 10 requests per minute 235 | 236 | $app->rest('/notifications', $app->user->ref('Notifications')); 237 | ``` 238 | 239 | It's preferable to use rate limits with persistence such as Redis or Memcache: 240 | 241 | ``` php 242 | $cache = \Atk4\Data\Persistence\MemCache($conn); 243 | $limit = new \Atk4\Api\Limit($cache); 244 | ``` 245 | 246 | ### Deep logging 247 | 248 | Agile Data already supports audit log, but with Agile API you can compliment that even further: 249 | 250 | ``` php 251 | $audit_id = $app->auditLog( 252 | '/**', 253 | new \Atk4\Audit\Controller( 254 | new \Atk4\Audit\Model\AuditLog($db) 255 | ) 256 | ); 257 | ``` 258 | 259 | This would create a log entry per invocation and use it for all the subsequent changes inside data persistence. 260 | 261 | Note that the `$audit_id` produced by the above function can also be used for UNDO action: 262 | 263 | ``` php 264 | $app->auditLog->load($audit_id)->undo(); 265 | ``` 266 | 267 | which would also reverse all the changes done on the persistence layer. 268 | 269 | ### Error Logging 270 | 271 | Similarly to Agile UI, the application for API will catch exceptions raised. 272 | 273 | ``` php 274 | $app->? 275 | ``` 276 | 277 | ### System support and global scoping 278 | 279 | Agile Data supports global scoping, so you can add additional hook that would affect creation of all the models and add some further conditioning. That's useful based off the Auth response: 280 | 281 | ``` php 282 | $user_id = $app->authUser('/**', new User($db)); 283 | 284 | $db->addHook('afterAdd', function($o, $e) use ($user_id) { 285 | if ($e->hasElement('user_id')) { 286 | $e->addCondition('user_id', $user_id); 287 | } 288 | }) 289 | 290 | ``` 291 | 292 | ### Mapping to file-system 293 | 294 | ``` php 295 | $app->map('/:resource/**', function(resource) use($app) { 296 | 297 | // convert user-credit to UserCredit 298 | $class = preg_replace('/[^a-zA-Z]/', '', ucwords($resoprce)); 299 | 300 | $object = $app->factory($class, null, 'Interface'); // Interface\UserCredit.php 301 | 302 | return [$object, $app->method]; 303 | // convert path to file 304 | // load file 305 | // create class instance 306 | // call method of that class 307 | 308 | // TODO: think of some logical example here!! 309 | }); 310 | ``` 311 | 312 | 313 | 314 | ### Optional Arguments 315 | 316 | Agile API supports various get arguments. 317 | 318 | - `?sort=name,-age` specify columns to sort by. 319 | - `?q=search`, will attempt to perform full-text search by phrase. (if supported by persistence) 320 | - `?condition[name]=value`, conditioning, but can also use `?name=value` 321 | - `?limit=20`, return only 20 results at a time. 322 | - `?skip=20`, skip first 20 results. 323 | - `?only=name,surname` specify onlyFields 324 | - `?ad={transformation}`, apply Agile Data transformation 325 | 326 | Handling of those arguments happens inside function `args()`. It's passed in a Model, so it will look at the GET arguments and perform the necessary changes. 327 | 328 | ``` php 329 | function args(\Atk4\Data\Model $m) { 330 | if ($_GET['sort']) { 331 | $m->sortBy($_GET['sort']); 332 | } 333 | 334 | if ($_GET['condition']) { 335 | foreach($_GET['condition'] as $key=>$val) { 336 | $m->addCondition($key, $val); 337 | } 338 | } 339 | 340 | if ($_GET['limit'] || $_GET['skip']) { 341 | $m->setLimit($_GET['limit']?:null, $_GET['skip']?:null); 342 | } 343 | 344 | // etc. etc... 345 | } 346 | ``` 347 | 348 | ### Other points 349 | 350 | Agile API is JSON only. You might be able to add XML output, but why. 351 | 352 | Agile API does not use envelope. Response data will be "[]" for empty result. If there is a problem with response, you'll get it through status code, in which case output will change. 353 | 354 | Agile API does not support HATEOAS. Technically you should be able to add support for it, but it would require a more complex mapping or extra code. We prefer to keep things simple. 355 | 356 | Agile API will pretty-print JSON by default, so make sure "gzip" is enabled. 357 | 358 | Agile API will accept either raw JSON or Form encoded input, but examples will always use JSON 359 | 360 | Agile API does not use "pagination" instead "limit" and "skip" values. You can introduce pages if you wish. 361 | 362 | Deep-loading resources is something that you can add. For instance if you load "Invoice" it may contain "lines" array containing list of hashes. Documentation will be provided on how to make this possible. There will also get argument to instruct if deep-loading is needed. 363 | 364 | Errors and exceptions will contain "error", "message" and "args" keys. Optional key "raised_by" may contain another object with same keys if said error was raised by another error. Another possibility is "description" field. 365 | 366 | (see http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api) 367 | 368 | https://www.reddit.com/r/PHP/comments/32tbxs/looking_for_php_rest_api_framework/ 369 | 370 | testing / behat: http://restler3.luracast.com/examples/_001_helloworld/readme.html 371 | 372 | ### URL patterns 373 | 374 | Here are some examples 375 | 376 | - `/user/:id` matches /user/123 , /user/123/ , /user/abc/ but won't match /user/123/x 377 | - `/user/:` same as above 378 | - `/user/:/:` matches /user/123/321 but won't match /user/123 379 | - `/user/*/:` matches /user/blah/123 but will ignore blah 380 | - `/user/**/:` incorrect, as `**` must be last. 381 | - `/user/:/**` matches /user/123/blah and /user/123/foo/blah and /user/123 382 | - `/user/:id/:action?` optional parameter. If unspecified will be null 383 | 384 | ### Route Groups 385 | 386 | It's possible to divert route group to a different App. 387 | 388 | ``` php 389 | $app = new \Atk4\Ui\App\Api(); 390 | 391 | $app->group('/user/**', function($app2) { 392 | $app2->get('/test', function() { 393 | return 'yes'; 394 | }); 395 | }); 396 | ``` 397 | 398 | You can also divert 399 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atk4/api", 3 | "type": "library", 4 | "description": "Agile API - Extensible API server in PHP for Agile Data", 5 | "keywords": [ 6 | "framework", 7 | "api", 8 | "rest", 9 | "restapi", 10 | "atk", 11 | "agile data", 12 | "data", 13 | "json", 14 | "toolkit", 15 | "agile" 16 | ], 17 | "homepage": "https://github.com/atk4/api", 18 | "license": "MIT", 19 | "authors": [ 20 | { 21 | "name": "Romans Malinovskis", 22 | "email": "romans@agiletoolkit.org", 23 | "homepage": "https://nearly.guru/" 24 | } 25 | ], 26 | "minimum-stability": "dev", 27 | "prefer-stable": true, 28 | "config": { 29 | "sort-packages": true 30 | }, 31 | "require": { 32 | "php": ">=7.3.0", 33 | "atk4/data": "dev-develop", 34 | "laminas/laminas-diactoros": "^2.0", 35 | "laminas/laminas-httphandlerrunner": "^1.1" 36 | }, 37 | "require-release": { 38 | "php": ">=7.3.0", 39 | "atk4/data": "~2.3.0", 40 | "laminas/laminas-diactoros": "^2.0" 41 | }, 42 | "require-dev": { 43 | "friendsofphp/php-cs-fixer": "^2.16", 44 | "phpunit/phpcov": "*", 45 | "phpunit/phpunit": ">=9.3", 46 | "codeclimate/php-test-reporter": "*" 47 | }, 48 | "require-dev-release": { 49 | "friendsofphp/php-cs-fixer": "^2.16", 50 | "phpunit/phpcov": "*", 51 | "phpunit/phpunit": ">=9.3", 52 | "codeclimate/php-test-reporter": "*" 53 | }, 54 | "autoload": { 55 | "psr-4": { 56 | "Atk4\\Api\\": "src/" 57 | } 58 | }, 59 | "autoload-dev": { 60 | "psr-4": { 61 | "Atk4\\Api\\Tests\\": "tests/" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/Country.php: -------------------------------------------------------------------------------- 1 | addField('name', ['actual' => 'nicename', 'required' => true, 'type' => 'string']); 18 | $this->addField('sys_name', ['actual' => 'name', 'system' => true]); 19 | 20 | $this->addField('iso', ['caption' => 'ISO', 'required' => true, 'type' => 'string']); 21 | $this->addField('iso3', ['caption' => 'ISO3', 'required' => true, 'type' => 'string']); 22 | $this->addField('numcode', ['caption' => 'ISO Numeric Code', 'type' => 'integer', 'required' => true]); 23 | $this->addField('phonecode', ['caption' => 'Phone Prefix', 'type' => 'integer']); 24 | 25 | $this->onHook( 26 | Model::HOOK_BEFORE_SAVE, 27 | function ($m) { 28 | if (!$m['sys_name']) { 29 | $m['sys_name'] = strtoupper($m['name']); 30 | } 31 | } 32 | ); 33 | 34 | $this->onHook( 35 | Model::HOOK_VALIDATE, 36 | function (Model $m) { 37 | $errors = []; 38 | 39 | if (strlen($m['iso']) !== 2) { 40 | $errors['iso'] = 'Must be exactly 2 characters'; 41 | } 42 | 43 | if (strlen($m['iso3']) !== 3) { 44 | $errors['iso3'] = 'Must be exactly 3 characters'; 45 | } 46 | 47 | // look if name is unique 48 | $c = (clone $m)->unload()->tryLoadBy('name', $m['name']); 49 | if ($c->loaded() && $c->id !== $m->id) { 50 | $errors['name'] = 'Country name must be unique'; 51 | } 52 | 53 | return $errors; 54 | } 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /examples/test.php: -------------------------------------------------------------------------------- 1 | get('/ping/', function () { 17 | return 'Hello, World'; 18 | }); 19 | $api->get('/ping/:hello', function ($hello) { 20 | return "Hello, {$hello}"; 21 | }); 22 | 23 | $api->rest('/client', new Country($db)); 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | tests 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Api.php: -------------------------------------------------------------------------------- 1 | getBody()->rewind(); // reset pointer of request. 55 | } 56 | $this->request = $request ?: ServerRequestFactory::fromGlobals(); 57 | $this->path = $this->request->getUri()->getPath(); 58 | 59 | if (isset($_SERVER['SCRIPT_NAME'], $_SERVER['REQUEST_URI'])) { 60 | // both script name and request uri are supplied, possibly 61 | // we would want to extract path relative from script location 62 | 63 | $script = $_SERVER['SCRIPT_NAME']; 64 | $path = $_SERVER['REQUEST_URI']; 65 | 66 | $regex = '|^' . preg_quote(dirname($script)) . '(/' . preg_quote(basename($script)) . ')?|i'; 67 | $this->path = preg_replace($regex, '', $path, 1); 68 | } 69 | 70 | if ($this->request->getHeader('Content-Type')[0] ?? null === 'application/json') { 71 | $this->request_data = json_decode($this->request->getBody()->getContents(), true); 72 | } else { 73 | $this->request_data = $this->request->getParsedBody(); 74 | } 75 | 76 | // This is how we will send responses 77 | $this->emitter = new SapiEmitter(); 78 | } 79 | 80 | public function match($pattern) 81 | { 82 | $path = explode('/', rtrim($this->path, '/')); 83 | $pattern = explode('/', rtrim($pattern, '/')); 84 | 85 | $this->_vars = []; 86 | 87 | while ($path || $pattern) { 88 | $p = array_shift($path); 89 | $r = array_shift($pattern); 90 | 91 | // if path ends and there is nothing in pattern (used //) then continue 92 | if ($p === null && $r === '') { 93 | continue; 94 | } 95 | 96 | // if both match, then continue 97 | if ($p === $r) { 98 | continue; 99 | } 100 | 101 | // pattern '*' accepts anything 102 | if ($r === '*' && is_string($p) && strlen($p) > 0) { 103 | continue; 104 | } 105 | 106 | // if pattern ends, but there is still something in path, then don't match 107 | if ($r === null || $r === '') { 108 | return false; 109 | } 110 | 111 | // parameters always start with ':', save in $vars and continue 112 | if ($r[0] === ':' && is_string($p) && strlen($p) > 0) { 113 | // if value contains : then treat it as fieldname:value pair 114 | // if value contains : and there is no fieldname (:ABC for example), 115 | // then it will use model->title_field as fieldname 116 | // otherwise it will be treated as id value 117 | if (strpos($p, ':') !== false) { 118 | $parts = explode(':', $p, 2); 119 | $this->_vars[] = [urldecode($parts[0]), urldecode($parts[1])]; 120 | } else { 121 | $this->_vars[] = urldecode($p); 122 | } 123 | 124 | continue; 125 | } 126 | 127 | // pattern '**' = good until the end 128 | if ($r === '**') { 129 | break; 130 | } 131 | 132 | return false; 133 | } 134 | 135 | return true; 136 | } 137 | 138 | /** 139 | * Call callable and emit response. 140 | * 141 | * @param callable $callable 142 | * @param array $vars 143 | */ 144 | public function exec($callable, $vars = []) 145 | { 146 | // try to call callable function 147 | $ret = $this->call($callable, $vars); 148 | 149 | // if callable function returns agile data model, then export it 150 | // this is important for REST API implementation 151 | if ($ret instanceof Model) { 152 | $ret = $this->exportModel($ret); 153 | } 154 | 155 | // no response, just step out 156 | if ($ret === null) { 157 | return; 158 | } 159 | 160 | // emit successful response 161 | $this->successResponse($ret); 162 | } 163 | 164 | /** 165 | * Call callable and return response. 166 | * 167 | * @param callable $callable 168 | * @param array $vars 169 | * 170 | * @return mixed 171 | */ 172 | protected function call($callable, $vars = []) 173 | { 174 | // try to call callable function 175 | try { 176 | $ret = call_user_func_array($callable, $vars); 177 | } catch (\Exception $e) { 178 | $this->caughtException($e); 179 | } 180 | 181 | return $ret; 182 | } 183 | 184 | /** 185 | * Exports data model. 186 | * 187 | * Extend this method to implement your own field restrictions. 188 | * 189 | * @return array 190 | */ 191 | protected function exportModel(Model $m) 192 | { 193 | return $m->export($this->getAllowedFields($m, 'read')); 194 | } 195 | 196 | /** 197 | * Load model by value. 198 | * 199 | * Value could be: 200 | * - array[fieldname,value]: 201 | * - if fieldname is empty, then use model->title_field 202 | * - if fieldname is not empty, then use it 203 | * - string|integer : will be treated as ID value 204 | * 205 | * @param mixed $value 206 | * 207 | * @return Model 208 | */ 209 | protected function loadModelByValue(Model $m, $value) 210 | { 211 | // value is not ID 212 | if (is_array($value)) { 213 | $field = $value[0] ?? $m->title_field; 214 | 215 | return $m->loadBy($field, $value[1]); 216 | } 217 | 218 | // value is ID 219 | return $m->load($value); 220 | } 221 | 222 | /** 223 | * Returns list of model field names which allow particular action - read or modify. 224 | * Also takes model->only_fields into account if that's defined. 225 | * 226 | * It uses custom model property apiFields[$action] which should contain array of 227 | * allowed field names or null to allow all model fields. 228 | * 229 | * @param string $action read|modify 230 | * 231 | * @return array|null of field names 232 | */ 233 | protected function getAllowedFields(Model $m, $action = 'read') 234 | { 235 | // take model only_fields into account 236 | $fields = is_array($m->only_fields) ? $m->only_fields : []; 237 | 238 | // limit by apiFields 239 | if (isset($m->apiFields, $m->apiFields[$action])) { 240 | $allowed = $m->apiFields[$action]; 241 | $fields = $fields ? array_intersect($fields, $allowed) : $allowed; 242 | } 243 | 244 | return $fields; 245 | } 246 | 247 | /** 248 | * Filters data array by only allowed fields. 249 | * 250 | * Extend this method to implement your own field restrictions. 251 | * 252 | * @param Model $m 253 | * @param array $data 254 | * 255 | * @return array 256 | */ 257 | /* not used and maybe will not be needed too 258 | protected function filterData(\Atk4\Data\Model $m, array $data) 259 | { 260 | $allowed = $this->getAllowedFields($m, 'modify'); 261 | 262 | if ($allowed) { 263 | $data = array_intersect_key($data, array_flip($allowed)); 264 | } 265 | 266 | return $data; 267 | } 268 | */ 269 | 270 | /** 271 | * Emit successful response. 272 | * 273 | * @param mixed $response 274 | */ 275 | protected function successResponse($response) 276 | { 277 | // create response object 278 | if (!$this->response) { 279 | $this->response = new JsonResponse( 280 | $response, 281 | $this->response_code, 282 | $this->response_headers, 283 | $this->response_options 284 | ); 285 | } 286 | 287 | // if there is emitter, then emit response and exit 288 | // for testing purposes there can be situations when emitter is disabled. then do nothing. 289 | if ($this->emitter) { 290 | $this->emitter->emit($this->response); 291 | 292 | exit; 293 | } 294 | 295 | // @todo Should we also stop script execution if no emitter is defined or just ignore that? 296 | //exit; 297 | } 298 | 299 | /** 300 | * Do GET pattern matching. 301 | * 302 | * @param string $pattern 303 | * @param callable $callable 304 | */ 305 | public function get($pattern, $callable = null) 306 | { 307 | if ($this->request->getMethod() === 'GET' && $this->match($pattern)) { 308 | $this->exec($callable, $this->_vars); 309 | } 310 | } 311 | 312 | /** 313 | * Do POST pattern matching. 314 | * 315 | * @param string $pattern 316 | * @param callable $callable 317 | */ 318 | public function post($pattern, $callable = null) 319 | { 320 | if ($this->request->getMethod() === 'POST' && $this->match($pattern)) { 321 | $this->exec($callable, $this->_vars); 322 | } 323 | } 324 | 325 | /** 326 | * Do PATCH pattern matching. 327 | * 328 | * @param string $pattern 329 | * @param callable $callable 330 | */ 331 | public function patch($pattern, $callable = null) 332 | { 333 | if ($this->request->getMethod() === 'PATCH' && $this->match($pattern)) { 334 | $this->exec($callable, $this->_vars); 335 | } 336 | } 337 | 338 | /** 339 | * Do PUT pattern matching. 340 | * 341 | * @param string $pattern 342 | * @param callable $callable 343 | */ 344 | public function put($pattern, $callable = null) 345 | { 346 | if ($this->request->getMethod() === 'PUT' && $this->match($pattern)) { 347 | $this->exec($callable, $this->_vars); 348 | } 349 | } 350 | 351 | /** 352 | * Do DELETE pattern matching. 353 | * 354 | * @param string $pattern 355 | * @param callable $callable 356 | */ 357 | public function delete($pattern, $callable = null) 358 | { 359 | if ($this->request->getMethod() === 'DELETE' && $this->match($pattern)) { 360 | $this->exec($callable, $this->_vars); 361 | } 362 | } 363 | 364 | /** 365 | * Implement REST pattern matching. 366 | * 367 | * @param string $pattern 368 | * @param Model|callable $model 369 | * @param array $methods Allowed methods (read|modify|delete). By default all are allowed 370 | */ 371 | public function rest($pattern, $model = null, $methods = ['read', 'modify', 'delete']) 372 | { 373 | $methods = array_map('strtolower', $methods); 374 | 375 | // GET all records 376 | if (in_array('read', $methods, true)) { 377 | $f = function (...$params) use ($model) { 378 | if (is_callable($model)) { 379 | $model = $this->call($model, $params); 380 | } 381 | 382 | return $model; 383 | }; 384 | $this->get($pattern, $f); 385 | } 386 | 387 | // GET :id - one record 388 | if (in_array('read', $methods, true)) { 389 | $f = function (...$params) use ($model) { 390 | $id = array_pop($params); // pop last element of args array, it's :id 391 | 392 | if (is_callable($model)) { 393 | $model = $this->call($model, $params); 394 | } 395 | 396 | // limit fields 397 | $model->onlyFields($this->getAllowedFields($model, 'read')); 398 | 399 | // load model and get field values 400 | return $this->loadModelByValue($model, $id)->get(); 401 | }; 402 | $this->get($pattern . '/:id', $f); 403 | } 404 | 405 | // POST :id - update one record 406 | // PATCH :id - update one record (same as POST :id) 407 | // PUT :id - update one record (same as POST :id) 408 | if (in_array('modify', $methods, true)) { 409 | $f = function (...$params) use ($model) { 410 | $id = array_pop($params); // pop last element of args array, it's :id 411 | 412 | if (is_callable($model)) { 413 | $model = $this->call($model, $params); 414 | } 415 | 416 | // limit fields 417 | $model->onlyFields($this->getAllowedFields($model, 'modify')); 418 | $this->loadModelByValue($model, $id)->save($this->request_data); 419 | $model->onlyFields($this->getAllowedFields($model, 'read')); 420 | 421 | return $model->get(); 422 | }; 423 | $this->patch($pattern . '/:id', $f); 424 | $this->post($pattern . '/:id', $f); 425 | $this->put($pattern . '/:id', $f); 426 | } 427 | 428 | // POST - insert new record 429 | if (in_array('modify', $methods, true)) { 430 | $f = function (...$params) use ($model) { 431 | if (is_callable($model)) { 432 | $model = $this->call($model, $params); 433 | } 434 | 435 | // limit fields 436 | $model->onlyFields($this->getAllowedFields($model, 'modify')); 437 | $model->unload()->save($this->request_data); 438 | $model->onlyFields($this->getAllowedFields($model, 'read')); 439 | 440 | $this->response_code = 201; // http code for created 441 | 442 | return $model->get(); 443 | }; 444 | $this->post($pattern, $f); 445 | } 446 | 447 | // DELETE :id - delete one record 448 | if (in_array('delete', $methods, true)) { 449 | $f = function (...$params) use ($model) { 450 | $id = array_pop($params); // pop last element of args array, it's :id 451 | 452 | if (is_callable($model)) { 453 | $model = $this->call($model, $params); 454 | } 455 | 456 | // limit fields (not necessary, but will limit field list for performance) 457 | $model->onlyFields($this->getAllowedFields($model, 'read')); 458 | 459 | return !$model->delete($id)->loaded(); 460 | }; 461 | $this->delete($pattern . '/:id', $f); 462 | } 463 | } 464 | 465 | /** 466 | * Our own exception handling. 467 | */ 468 | public function caughtException(\Exception $e) 469 | { 470 | $params = []; 471 | if ($e instanceof \Atk4\Core\Exception) { 472 | foreach ($e->getParams() as $key => $val) { 473 | $params[$key] = $e->toString($val); 474 | } 475 | } 476 | 477 | $this->response = new JsonResponse( 478 | [ 479 | 'error' => [ 480 | 'code' => $e->getCode(), 481 | 'message' => $e->getMessage(), 482 | 'args' => $params, 483 | ], 484 | ], 485 | (int) $e->getCode() > 0 ? $e->getCode() : 500, 486 | $this->response_headers, 487 | $this->response_options 488 | ); 489 | 490 | //var_dump($this->response, $e->getMessage()); 491 | (new SapiEmitter())->emit($this->response); 492 | 493 | exit; 494 | } 495 | } 496 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | api = new \Atk4\Api\Api(); 17 | } 18 | 19 | public function assertRequest($response, $method, $uri = '/', $data = null) 20 | { 21 | $request = new Request( 22 | 'http://localhost' . $uri, 23 | $method, 24 | 'php://memory', 25 | [ 26 | 'Content-Type' => 'application/json', 27 | ] 28 | ); 29 | 30 | if ($data !== null) { 31 | $request->getBody()->write(json_encode($data)); 32 | } 33 | } 34 | 35 | /** 36 | * Simulates a request to $uri using $method and with $data body as it's 37 | * passed into an Api class. Execute callback $apiBuild afterwards allowing 38 | * you to define your custom handlers. Match response from the API against 39 | * the $response and if it is different - create assertion error. 40 | * 41 | * @param string $response 42 | * @param callable $apiBuild 43 | * @param string $uri 44 | * @param string $method 45 | * @param array $data 46 | */ 47 | public function assertApi($response, $apiBuild, $uri = '/ping', $method = 'GET', $data = null) 48 | { 49 | // create fake request 50 | $request = new Request( 51 | 'http://localhost' . $uri, 52 | $method, 53 | 'php://memory', 54 | [ 55 | 'Content-Type' => 'application/json', 56 | ] 57 | ); 58 | 59 | if ($data !== null) { 60 | $request->getBody()->write(json_encode($data)); 61 | } 62 | 63 | $api = new \Atk4\Api\Api($request); 64 | $api->emitter = false; // don't emmit response 65 | 66 | $apiBuild($api); 67 | 68 | $ret = json_decode($api->response->getBody()->getContents(), true); 69 | $this->assertSame($response, $ret); 70 | } 71 | 72 | /** 73 | * Simulate a request and validate a response. 74 | * 75 | * @param string $response 76 | * @param callable $handler 77 | * @param string $method 78 | * @param array $data 79 | */ 80 | public function assertReq($response, $handler, $method = 'GET', $data = null) 81 | { 82 | $uri = '/request'; 83 | 84 | $m = strtolower($method); 85 | $this->assertApi($response, function ($api) use ($handler, $uri, $m) { 86 | $api->{$m}($uri, $handler); 87 | }, $uri, $method); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/ApiTesterRestTest.php: -------------------------------------------------------------------------------- 1 | api = new Api($request); 23 | $this->api->emitter = false; 24 | $this->api->rest('/country', clone $this->model); 25 | 26 | return json_decode($this->api->response->getBody()->getContents(), true); 27 | } 28 | 29 | public function setupModel() 30 | { 31 | $this->model = new Country($this->db); 32 | $this->getMigrator($this->model)->create(); 33 | } 34 | 35 | public function testAll() 36 | { 37 | $this->setupModel(); 38 | 39 | // Create new record 40 | $data = [ 41 | 'name' => 'test', 42 | 'sys_name' => 'test', 43 | 'iso' => 'IT', 44 | 'iso3' => 'ITA', 45 | 'numcode' => 666, 46 | 'phonecode' => 39, 47 | ]; 48 | 49 | $request = new Request( 50 | 'http://localhost/country', 51 | 'POST', 52 | 'php://memory', 53 | [ 54 | 'Content-Type' => 'application/json', 55 | ] 56 | ); 57 | $request->getBody()->write(json_encode($data)); 58 | 59 | $response = $this->processRequest($request); 60 | $this->assertSame(201, $this->api->response_code); 61 | $this->assertSame([ 62 | 'id' => 1, 63 | 'name' => 'test', 64 | 'sys_name' => 'test', 65 | 'iso' => 'IT', 66 | 'iso3' => 'ITA', 67 | 'numcode' => 666, 68 | 'phonecode' => 39, 69 | ], $response); 70 | 71 | // Request one record by id 72 | $request = new Request( 73 | 'http://localhost/country/1', 74 | 'GET', 75 | 'php://memory', 76 | [ 77 | 'Content-Type' => 'application/json', 78 | ] 79 | ); 80 | 81 | $response = $this->processRequest($request); 82 | $this->assertSame([ 83 | 'id' => 1, 84 | 'name' => 'test', 85 | 'sys_name' => 'test', 86 | 'iso' => 'IT', 87 | 'iso3' => 'ITA', 88 | 'numcode' => 666, 89 | 'phonecode' => 39, 90 | ], $response); 91 | 92 | // Request one record by value of some other field 93 | $request = new Request( 94 | 'http://localhost/country/name:test', 95 | 'GET', 96 | 'php://memory', 97 | [ 98 | 'Content-Type' => 'application/json', 99 | ] 100 | ); 101 | 102 | $response = $this->processRequest($request); 103 | $this->assertSame([ 104 | 'id' => 1, 105 | 'name' => 'test', 106 | 'sys_name' => 'test', 107 | 'iso' => 'IT', 108 | 'iso3' => 'ITA', 109 | 'numcode' => 666, 110 | 'phonecode' => 39, 111 | ], $response); 112 | 113 | // Request all records 114 | $request = new Request( 115 | 'http://localhost/country', 116 | 'GET', 117 | 'php://memory', 118 | [ 119 | 'Content-Type' => 'application/json', 120 | ] 121 | ); 122 | 123 | $response = $this->processRequest($request); 124 | $this->assertSame([ 125 | 0 => [ 126 | 'id' => 1, 127 | 'nicename' => 'test', 128 | 'name' => 'test', 129 | 'iso' => 'IT', 130 | 'iso3' => 'ITA', 131 | 'numcode' => 666, 132 | 'phonecode' => 39, 133 | ], 134 | ], $response); 135 | 136 | // Modify record data 137 | $request = new Request( 138 | 'http://localhost/country/1', 139 | 'GET', 140 | 'php://memory', 141 | [ 142 | 'Content-Type' => 'application/json', 143 | ] 144 | ); 145 | 146 | $data = $this->processRequest($request); 147 | $data['name'] = 'test modified'; 148 | 149 | $request = $request->withMethod('POST'); 150 | $request->getBody()->write(json_encode($data)); 151 | 152 | $response = $this->processRequest($request); 153 | $this->assertSame([ 154 | 'id' => 1, 155 | 'name' => 'test modified', 156 | 'sys_name' => 'test', 157 | 'iso' => 'IT', 158 | 'iso3' => 'ITA', 159 | 'numcode' => 666, 160 | 'phonecode' => 39, 161 | ], $response); 162 | 163 | // Delete record 164 | $request = new Request( 165 | 'http://localhost/country/1', 166 | 'DELETE', 167 | 'php://memory', 168 | [ 169 | 'Content-Type' => 'application/json', 170 | ] 171 | ); 172 | 173 | $this->processRequest($request); 174 | 175 | // check via getAll 176 | $request = new Request( 177 | 'http://localhost/country', 178 | 'GET', 179 | 'php://memory', 180 | [ 181 | 'Content-Type' => 'application/json', 182 | ] 183 | ); 184 | 185 | $response = $this->processRequest($request); 186 | $this->assertSame([], $response); 187 | 188 | // Limit available model fields by using apiFields property 189 | $this->model->apiFields = [ 190 | 'read' => [ 191 | 'name', 192 | 'iso', 193 | 'numcode', 194 | ], 195 | ]; 196 | 197 | $data = [ 198 | 'name' => 'test', 199 | 'sys_name' => 'test', 200 | 'iso' => 'IT', 201 | 'iso3' => 'ITA', 202 | 'numcode' => 666, 203 | 'phonecode' => 39, 204 | ]; 205 | 206 | $request = new Request( 207 | 'http://localhost/country', 208 | 'POST', 209 | 'php://memory', 210 | [ 211 | 'Content-Type' => 'application/json', 212 | ] 213 | ); 214 | $request->getBody()->write(json_encode($data)); 215 | 216 | $response = $this->processRequest($request); 217 | $this->assertSame(201, $this->api->response_code); 218 | $this->assertSame([ 219 | 'name' => 'test', 220 | 'iso' => 'IT', 221 | 'numcode' => 666, 222 | ], $response); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /tests/ApiTesterTest.php: -------------------------------------------------------------------------------- 1 | assertApi( 12 | 'pong', 13 | function ($api) { 14 | $api->get('/ping', function () { 15 | return 'pong'; 16 | }); 17 | $api->get('/ping', function () { 18 | return 'bad-pong'; 19 | }); 20 | }, 21 | '/ping', 22 | 'GET' 23 | ); 24 | 25 | $this->assertReq('pong', function () { 26 | return 'pong'; 27 | }); 28 | $this->assertReq('pong', function () { 29 | return 'pong'; 30 | }, 'POST'); 31 | 32 | $this->assertReq('pong', function () { 33 | return 'pong'; 34 | }, 'PATCH'); 35 | 36 | $this->assertReq('pong', function () { 37 | return 'pong'; 38 | }, 'PUT'); 39 | 40 | $this->assertReq('pong', function () { 41 | return 'pong'; 42 | }, 'DELETE'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Model/Country.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'id', 16 | 'name', 17 | 'sys_name', 18 | 'iso', 19 | 'iso3', 20 | 'numcode', 21 | 'phonecode', 22 | ] 23 | ]; 24 | */ 25 | 26 | protected function init(): void 27 | { 28 | parent::init(); 29 | $this->addField('name', ['actual' => 'nicename', 'required' => true, 'type' => 'string']); 30 | $this->addField('sys_name', ['actual' => 'name', 'system' => true]); 31 | 32 | $this->addField('iso', ['caption' => 'ISO', 'required' => true, 'type' => 'string']); 33 | $this->addField('iso3', ['caption' => 'ISO3', 'required' => true, 'type' => 'string']); 34 | $this->addField('numcode', ['caption' => 'ISO Numeric Code', 'type' => 'integer', 'required' => true]); 35 | $this->addField('phonecode', ['caption' => 'Phone Prefix', 'type' => 'integer']); 36 | 37 | $this->onHook('beforeSave', function ($m) { 38 | if (!$m['sys_name']) { 39 | $m['sys_name'] = strtoupper($m['name']); 40 | } 41 | }); 42 | 43 | $this->onHook('validate', function ($m) { 44 | $errors = []; 45 | 46 | if (strlen($m['iso']) !== 2) { 47 | $errors['iso'] = 'Must be exactly 2 characters'; 48 | } 49 | 50 | if (strlen($m['iso3']) !== 3) { 51 | $errors['iso3'] = 'Must be exactly 3 characters'; 52 | } 53 | 54 | // look if name is unique 55 | $c = clone $m; 56 | $c->unload(); 57 | $c->tryLoadBy('name', $m['name']); 58 | if ($c->loaded() && $c->id !== $m->id) { 59 | $errors['name'] = 'Country name must be unique'; 60 | } 61 | 62 | return $errors; 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/PatternMatcherTest.php: -------------------------------------------------------------------------------- 1 | api = new \Atk4\Api\Api(); 16 | } 17 | 18 | public function assertMatch($pattern, $request) 19 | { 20 | $this->api->path = $request; 21 | $this->assertTrue($this->api->match($pattern)); 22 | } 23 | 24 | public function assertNoMatch($pattern, $request) 25 | { 26 | $this->api->path = $request; 27 | $this->assertFalse($this->api->match($pattern)); 28 | } 29 | 30 | public function testBasic() 31 | { 32 | $this->assertMatch('/', '/'); 33 | $this->assertMatch('/hello', '/hello'); 34 | $this->assertMatch('/hello', '/hello/'); 35 | $this->assertMatch('/hello/', '/hello'); 36 | 37 | $this->assertNoMatch('/hello', '/world'); 38 | $this->assertNoMatch('/hello//world', '/hello/world'); 39 | $this->assertNoMatch('/hello/world', '/hello//world'); 40 | } 41 | 42 | public function testAsterisk() 43 | { 44 | $this->assertNoMatch('/*', '/'); 45 | $this->assertMatch('/*', '/hello'); 46 | $this->assertNoMatch('/*', '/hello/world'); 47 | 48 | $this->assertNoMatch('/test/*', '/test'); 49 | $this->assertNoMatch('/test/*', '/test/'); 50 | $this->assertMatch('/test/*', '/test/something'); 51 | $this->assertNoMatch('/test/*', '/test/something/else'); 52 | 53 | $this->assertMatch('/test/*/abc', '/test/bah/abc'); 54 | $this->assertNoMatch('/test/*/abc', '/test/bah/cba'); 55 | $this->assertNoMatch('/test/*/abc', '/test/abc'); 56 | $this->assertNoMatch('/test/*/abc', '/test//abc'); 57 | $this->assertMatch('/test/*/abc', '/test/*/abc'); 58 | } 59 | 60 | public function testParam() 61 | { 62 | $this->assertNoMatch('/:', '/'); 63 | $this->assertMatch('/:', '/hello'); 64 | $this->assertNoMatch('/:', '/hello/world'); 65 | 66 | $this->assertNoMatch('/test/:', '/test'); 67 | $this->assertNoMatch('/test/:', '/test/'); 68 | $this->assertMatch('/test/:', '/test/something'); 69 | $this->assertNoMatch('/test/:', '/test/something/else'); 70 | 71 | $this->assertMatch('/test/:/abc', '/test/bah/abc'); 72 | $this->assertNoMatch('/test/:/abc', '/test/bah/cba'); 73 | $this->assertNoMatch('/test/:/abc', '/test/abc'); 74 | $this->assertNoMatch('/test/:/abc', '/test//abc'); 75 | $this->assertMatch('/test/:/abc', '/test/*/abc'); 76 | } 77 | 78 | public function testDoubleAsterisk() 79 | { 80 | $this->assertMatch('/**', '/'); 81 | $this->assertMatch('/**', '/hello'); 82 | $this->assertMatch('/**', '/hello/world'); 83 | 84 | $this->assertMatch('/test/**', '/test'); 85 | $this->assertMatch('/test/**', '/test/'); 86 | $this->assertMatch('/test/**', '/test/something'); 87 | $this->assertMatch('/test/**', '/test/something/else'); 88 | 89 | $this->assertNoMatch('/test/**', '/else'); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tools/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | product='api' 6 | 7 | 8 | check=$(git symbolic-ref HEAD | cut -d / -f3) 9 | if [ $check != "develop" ]; then 10 | echo "Must be on develop branch" 11 | exit -1 12 | fi 13 | 14 | # So that we can see un-committed stuff 15 | git status 16 | 17 | # Display list of recently released versions 18 | git fetch --tags 19 | git log --tags --simplify-by-decoration --pretty="format:%d - %cr" | head -n5 20 | 21 | echo "Which version we are releasing: " 22 | read version 23 | 24 | function finish { 25 | git checkout develop 26 | git branch -D release/$version 27 | git checkout composer.json 28 | } 29 | trap finish EXIT 30 | 31 | # Create temporary branch (local only) 32 | git branch release/$version 33 | git checkout release/$version 34 | 35 | # Find out previous version 36 | prev_version=$(git log --tags --simplify-by-decoration --pretty="format:%d" | grep -Eo '[0-9\.]+' | head -1) 37 | 38 | echo "Releasing $prev_version -> $version" 39 | 40 | vimr CHANGELOG.md 41 | 42 | # Compute diffs 43 | git log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr)%Creset' --abbrev-commit --date=relative $prev_version... 44 | 45 | git log --pretty=full $prev_version... | grep '#[0-9]*' | sed 's/.*#\([0-9]*\).*/\1/' | sort | uniq | while read i; do 46 | echo "-[ $i ]-------------------------------------------------------------------------------" 47 | ghi --color show $i | head -50 48 | done 49 | 50 | open "https://github.com/atk4/$product/compare/$prev_version...develop" 51 | 52 | # Update dependency versions 53 | sed -i "" -e '/atk4\/data/s/dev-develop/\*/' composer.json # workaround composers inability to change both requries simultaniously 54 | 55 | composer update --no-dev 56 | composer require atk4/data 57 | ./vendor/phpunit/phpunit/phpunit --no-coverage 58 | 59 | echo "Press enter to publish the release" 60 | read junk 61 | 62 | git commit -m "Added release notes for $version" CHANGELOG.md || echo "but its ok" 63 | merge_tag=$(git rev-parse HEAD) 64 | 65 | git commit -m "Set up stable dependencies for $version" composer.json 66 | 67 | git tag $version 68 | git push origin release/$version 69 | git push --tags 70 | 71 | git checkout develop 72 | git merge $merge_tag --no-edit 73 | git push 74 | 75 | echo '=[ SUCCESS ]================================================' 76 | echo "Released atk4/$product Version $version" 77 | echo '============================================================' 78 | echo 79 | 80 | open https://github.com/atk4/$product/releases/tag/$version 81 | 82 | # do we care about master branch? nah 83 | --------------------------------------------------------------------------------