diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..a21195d --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,52 @@ +engines: + duplication: + enabled: true + config: + languages: + - php + fixme: + enabled: true + phpmd: + enabled: true + exclude_fingerprints: + - 42b4b225a9c6cf44b27182f397e2b7ad # REMOVE this one when replacing with something useful + checks: + CyclomaticComplexity: + enabled: false + Design/TooManyPublicMethods: + enabled: false + Design/TooManyMethods: + enabled: false + Design/NpathComplexity: + enabled: false + Design/WeightedMethodCount: + enabled: false + Design/LongClass: + enabled: false + Controversial/CamelCaseMethodName: + enabled: false + Controversial/CamelCaseParameterName: + enabled: false + Controversial/CamelCasePropertyName: + enabled: false + Controversial/CamelCaseVariableName: + enabled: false + Controversial/CamelCaseClassName: + enabled: false + Controversial/Superglobals: # we use SESSION global + enabled: false + Naming/ShortVariable: + enabled: false + CleanCode/ElseExpression: + enabled: false + Design/LongMethod: # sometimes we have longer methods than 100 rows + enabled: false + radon: + enabled: true +ratings: + paths: + - src/** +exclude_paths: +- docs/** +- tests/** +- vendor/** diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..937faad --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,5 @@ +# See https://github.com/release-drafter/release-drafter#configuration +template: | + ## What’s Changed + + $CHANGES diff --git a/.github/workflows/bundler.yml b/.github/workflows/bundler.yml new file mode 100644 index 0000000..107fac9 --- /dev/null +++ b/.github/workflows/bundler.yml @@ -0,0 +1,43 @@ +name: Bundler + +on: create + +jobs: + autocommit: + name: Update to stable dependencies + if: startsWith(github.ref, 'refs/heads/release/') + runs-on: ubuntu-latest + container: + image: atk4/image:latest # https://github.com/atk4/image + steps: + - uses: actions/checkout@master + - run: echo ${{ github.ref }} + - name: Update to stable dependencies + run: | + # replaces X keys with X-release keys + 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 + v=$(echo ${{ github.ref }} | cut -d / -f 4) + echo "::set-env name=version::$v" + + - uses: teaminkling/autocommit@master + with: + commit-message: Setting release dependencies + - uses: ad-m/github-push-action@master + with: + branch: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: pull-request + uses: romaninsh/pull-request@master + with: + source_branch: "release/${{ env.version }}" + destination_branch: "master" # If blank, default: master + pr_title: "Releasing ${{ env.version }} into master" + pr_body: | + - [ ] Review changes (must include stable dependencies) + - [ ] Merge this PR into master (will delete ${{ github.ref }}) + - [ ] Go to Releases and create TAG from master + Do not merge master into develop + pr_reviewer: "romaninsh" + pr_assignee: "romaninsh" + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..68fcd9a --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,16 @@ +name: Release Drafter + +on: + push: + # branches to consider in the event; optional, defaults to all + branches: + - develop + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + # Drafts your next Release notes as Pull Requests are merged into "master" + - uses: toolmantim/release-drafter@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..cf671a3 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,50 @@ +name: Unit Testing + +on: + pull_request: + branches: '*' + push: + branches: + - master + - develop + +jobs: + unit-test: + name: Unit Testing + runs-on: ubuntu-latest + container: + image: atk4/image:${{ matrix.php }} # https://github.com/atk4/image + strategy: + matrix: + php: ['7.2', '7.3', 'latest'] + steps: + - uses: actions/checkout@v1 + # need this to trick composer + - run: php --version + - run: "git branch develop; git checkout develop" + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + + - uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-composer- + + - run: composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: Run Tests + run: | + mkdir -p build/logs + + - name: SQLite Testing + run: vendor/bin/phpunit --configuration phpunit.xml --coverage-text + + - uses: codecov/codecov-action@v1 + if: matrix.php == 'latest' + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: build/logs/clover.xml diff --git a/.travis.yml b/.travis.yml index 92330a3..100c353 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,8 @@ language: php php: - - '5.6' - - '7.0' - - '7.1' + - '7.2' + - '7.3' cache: directories: @@ -13,34 +12,31 @@ cache: # - mysql before_script: - - composer install + - composer install # - mysql -e 'create database dsql_test;' + after_script: - - echo $TRAVIS_PHP_VERSION - - if [[ ${TRAVIS_PHP_VERSION:0:3} == "7.0" ]]; then echo "Sending codecov report"; bash <(curl -s https://codecov.io/bash); fi + - echo $TRAVIS_BRANCH + - if [[ ${TRAVIS_PHP_VERSION:0:3} == "7.2" ]]; then echo "Sending coverage report"; vendor/bin/test-reporter --coverage-report build/logs/clover.xml; fi + - if [[ ${TRAVIS_PHP_VERSION:0:3} == "7.2" ]]; then echo "Sending codecov report"; TRAVIS_CMD="" bash <(curl -s https://codecov.io/bash) -f build/logs/clover.xml; fi script: - - if [[ ${TRAVIS_PHP_VERSION:0:3} != "7.0" ]]; then NC="--no-coverage"; fi - - ./vendor/phpunit/phpunit/phpunit $NC + - if [[ ${TRAVIS_PHP_VERSION:0:3} != "7.2" ]]; then NC="--no-coverage"; fi + - ./vendor/phpunit/phpunit/phpunit $NC notifications: + webhooks: urls: - https://webhooks.gitter.im/e/b33a2db0c636f34bafa9 - on_success: change # options: [always|never|change] default: always - on_failure: always # options: [always|never|change] default: always - on_start: never # options: [always|never|change] default: always - - slack: - rooms: - - agiletoolkit:bjrKuPBf1h4cYiNxPBQ1kF6c#dsql - on_success: change - - urls: - https://webhooks.gitter.im/e/c4000ab24556b09cb3e7 - on_success: change # options: [always|never|change] default: always on_failure: always # options: [always|never|change] default: always on_start: never # options: [always|never|change] default: always - email: false + slack: + rooms: + - agiletoolkit:bjrKuPBf1h4cYiNxPBQ1kF6c#dsql + on_success: change + + email: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c090366 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +## Pre-releases + +### 0.2 More flexible REST lookups + + - add support for looking up by fields other than ID: "api/country/code:GB" + +### 0.1 Initial release + + - added post(), get(), etc + - added rest() and integration with Model Data + - first working prototype diff --git a/README.md b/README.md index 8895bc3..6c118c6 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,72 @@ # Agile API Framework [![Build Status](https://travis-ci.org/atk4/api.png?branch=develop)](https://travis-ci.org/atk4/api) -[![Code Climate](https://codeclimate.com/github/atk4/api/badges/gpa.svg)](https://codeclimate.com/github/atk4/api) [![StyleCI](https://styleci.io/repos/107142772/shield)](https://styleci.io/repos/107142772) [![codecov](https://codecov.io/gh/atk4/api/branch/develop/graph/badge.svg)](https://codecov.io/gh/atk4/api) -[![Issue Count](https://codeclimate.com/github/atk4/api/badges/issue_count.svg)](https://codeclimate.com/github/atk4/api) +[![Code Climate](https://codeclimate.com/github/atk4/api/badges/gpa.svg)](https://codeclimate.com/github/atk4/api) +[![Issue Count](https://codeclimate.com/github/atk4/api/badges/issue_count.svg)](https://codeclimate.com/github/atk4/api/issues) [![License](https://poser.pugx.org/atk4/api/license)](https://packagist.org/packages/atk4/api) [![GitHub release](https://img.shields.io/github/release/atk4/api.svg?maxAge=2592000)](https://packagist.org/packages/atk4/api) +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). + +## 1. Simple To Use + +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` : + +``` php +include 'vendor/autoload.php'; + +$api = new \atk4\api\Api(); + +// Simple handling of GET request through a callback. +$api->get('/ping', function() { + return 'Pong'; +}); + +// Methods can accept arguments, and everything is type-safe. +$api->get('/hello/:name', function ($name) { + return "Hello, $name"; +}); +``` + +## 2. Agile Data Integration + +[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`: + +``` php +$api->rest('/countries', new Country($db)); +``` + +This creates a standard standard-compliant RESTful interface for interfacing the client model: + +- `GET /countries` responds with list of all Country records from $db. +- `POST /countries` adds a new Country reading data from Form data or JSON in POST body. +- `GET /countries/123` loads client with specified ID. +- `PATCH /countries/123` with some Form data or JSON will update existing Country. +- `DELETE /countries/123` will delete a record. + +Through Agile UI you may add conditions, limits and more. Also second argument can be a call-back: + +``` php +$api->rest('/countries', function() use($db) { + $c = new Country($db)); + $c->addCondition('is_eu', true); + $c->setLimit(20); + return $c; +}); +``` + +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. + + + +# Work in progress + +Agile UI is still a work in progress. This readme will be further updated to reflect a current features. + -End-to-end implementation for your REST API. 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). ## Planned Features diff --git a/composer.json b/composer.json index a189809..98a5605 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,48 @@ { - "name": "atk4/api", - "type": "library", - "description": "Agile API - Extensible API server in PHP for Agile Data", - "keywords": ["framework", "api", "rest", "restapi", "atk", "agile data", "data", "json", "toolkit", "agile"], - "homepage": "https://github.com/atk4/api", - "license": "MIT", - "minimum-stability": "dev", - "prefer-stable": true, - "authors": [ - { - "name": "Romans Malinovskis", - "email": "romans@agiletoolkit.org", - "homepage": "https://nearly.guru/" - } - ], - "require": { - "php": ">=5.6.0", - "atk4/data": "dev-develop", - "zendframework/zend-diactoros": "^1.6" - }, - "require-dev": { - "phpunit/phpunit": "<6", - "atk4/schema": "dev-develop", - "codeclimate/php-test-reporter": "*" - }, - "autoload": { - "psr-4": {"atk4\\api\\":"src/"} - }, - "autoload-dev": { - "psr-4": { - "atk4\\api\\tests\\":"tests/" - } + "name": "atk4/api", + "type": "library", + "description": "Agile API - Extensible API server in PHP for Agile Data", + "keywords": [ + "framework", + "api", + "rest", + "restapi", + "atk", + "agile data", + "data", + "json", + "toolkit", + "agile" + ], + "homepage": "https://github.com/atk4/api", + "license": "MIT", + "minimum-stability": "dev", + "prefer-stable": true, + "authors": [ + { + "name": "Romans Malinovskis", + "email": "romans@agiletoolkit.org", + "homepage": "https://nearly.guru/" } + ], + "require": { + "php": ">=7.2.0", + "atk4/data": "^2.0", + "zendframework/zend-diactoros": "^1.6" + }, + "require-dev": { + "phpunit/phpunit": "<6", + "atk4/schema": "^2.0", + "codeclimate/php-test-reporter": "*" + }, + "autoload": { + "psr-4": { + "atk4\\api\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "atk4\\api\\tests\\": "tests/" + } + } } diff --git a/examples/test.php b/examples/test.php index 9daefc8..06093dd 100644 --- a/examples/test.php +++ b/examples/test.php @@ -50,7 +50,7 @@ public function validate($intent = null) } } session_start(); -$db = new \atk4\data\Persistence_SQL('mysql:dbname=atk4;host=localhost', 'root', 'root'); +$db = new \atk4\data\Persistence_SQL('mysql:dbname=atk4;host=localhost', 'root', ''); $api->get('/ping/', function () { return 'Hello, World'; diff --git a/phpunit.xml b/phpunit.xml index a2dd6f8..2c4fc1d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,7 +16,6 @@ tests - tests/smbo/lib diff --git a/src/Api.php b/src/Api.php index ae351e6..e917eb5 100644 --- a/src/Api.php +++ b/src/Api.php @@ -39,7 +39,8 @@ public function __construct($request = null) $script = $_SERVER['SCRIPT_NAME']; $path = $_SERVER['REQUEST_URI']; - $this->path = str_replace($script, '', $path); + $regex = '|^'.preg_quote(dirname($script)).'(/'.preg_quote(basename($script)).')?|i'; + $this->path = preg_replace($regex, '', $path, 1); } $ct = $this->request->getHeader('Content-Type'); @@ -55,48 +56,61 @@ public function __construct($request = null) } /** - * Do pattern matching. + * Do pattern matching and save extracted variables. * - * @param string $pattern - * @param callable $callable + * @param string $pattern * - * @return mixed + * @return bool */ - public function match($pattern, $callable = null) + protected $_vars; + + public function match($pattern) { $path = explode('/', rtrim($this->path, '/')); $pattern = explode('/', rtrim($pattern, '/')); - $vars = []; + $this->_vars = []; while ($path || $pattern) { $p = array_shift($path); $r = array_shift($pattern); + // if path ends and there is nothing in pattern (used //) then continue if ($p === null && $r === '') { continue; } - // must make sure both match + // if both match, then continue if ($p === $r) { continue; } - // pattern 'r' accepts anything + // pattern '*' accepts anything if ($r == '*' && strlen($p)) { continue; } + // if pattern ends, but there is still something in path, then don't match if ($r === null || $r === '') { return false; } + // parameters always start with ':', save in $vars and continue if ($r[0] == ':' && strlen($p)) { - $vars[] = $p; + // if value contains : then treat it as fieldname:value pair + // if value contains : and there is no fieldname (:ABC for example), + // then it will use model->title_field as fieldname + // otherwise it will be treated as id value + if (strpos($p, ':') !== false) { + $parts = explode(':', $p, 2); + $this->_vars[] = [urldecode($parts[0]), urldecode($parts[1])]; + } else { + $this->_vars[] = urldecode($p); + } continue; } - // good until the end + // pattern '**' = good until the end if ($r == '**') { break; } @@ -104,11 +118,45 @@ public function match($pattern, $callable = null) return false; } - // if no callable function set - just say that it matches - if ($callable === null) { - return true; + return true; + } + + /** + * Call callable and emit response. + * + * @param callable $callable + * @param array $vars + */ + public function exec($callable, $vars = []) + { + // try to call callable function + $ret = $this->call($callable, $vars); + + // if callable function returns agile data model, then export it + // this is important for REST API implementation + if ($ret instanceof \atk4\data\Model) { + $ret = $this->exportModel($ret); + } + + // no response, just step out + if ($ret === null) { + return; } + // emit successful response + $this->successResponse($ret); + } + + /** + * Call callable and return response. + * + * @param callable $callable + * @param array $vars + * + * @return mixed + */ + protected function call($callable, $vars = []) + { // try to call callable function try { $ret = call_user_func_array($callable, $vars); @@ -116,32 +164,130 @@ public function match($pattern, $callable = null) $this->caughtException($e); } - // if callable function returns agile data model, then export it - // this is important for REST API implementation - if ($ret instanceof \atk4\data\Model) { - $ret = $ret->export(); + return $ret; + } + + /** + * Exports data model. + * + * Extend this method to implement your own field restrictions. + * + * @param \atk4\data\Model $m + * + * @return array + */ + protected function exportModel(\atk4\data\Model $m) + { + return $m->export($this->getAllowedFields($m, 'read')); + } + + /** + * Load model by value. + * + * Value could be: + * - string : will be treated as ID value + * - array[fieldname,value]: + * - if fieldname is empty, then use model->title_field + * - if fieldname is not empty, then use it + * + * @param \atk\data\Model $m + * @param string|array $value + * + * @return \atk4\data\Model + */ + protected function loadModelByValue(\atk4\data\Model $m, $value) + { + // value is not ID + if (is_array($value)) { + $field = empty($value[0]) ? $m->title_field : $value[0]; + + return $m->loadBy($field, $value[1]); } - // create response object - if ($ret !== null) { - if (!$this->response) { - $this->response = - new \Zend\Diactoros\Response\JsonResponse( - $ret, - 200, - [], - JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT - ); - } + // value is ID + return $m->load($value); + } - if ($this->emitter) { - // cannot do anything about it - $this->emitter->emit($this->response); - } + /** + * Returns list of model field names which allow particular action - read or modify. + * Also takes model->only_fields into account if that's defined. + * + * It uses custom model property apiFields[$action] which should contain array of + * allowed field names or null to allow all model fields. + * + * @param \atk4\data\Model $m + * @param string $action read|modify + * + * @return null|array of field names + */ + protected function getAllowedFields(\atk4\data\Model $m, $action = 'read') + { + $fields = null; + + // take model only_fields into account + if ($m->only_fields) { + $fields = $m->only_fields; + } - // no emitter (is that possible at all ?) - return $ret; + // limit by apiFields + if (isset($m->apiFields[$action])) { + $allowed = $m->apiFields[$action]; + $fields = $fields ? array_intersect($fields, $allowed) : $allowed; } + + return $fields; + } + + /** + * Filters data array by only allowed fields. + * + * Extend this method to implement your own field restrictions. + * + * @param \atk4\data\Model $m + * @param array $data + * + * @return array + */ + /* not used and maybe will not be needed too + protected function filterData(\atk4\data\Model $m, array $data) + { + $allowed = $this->getAllowedFields($m, 'modify'); + + if ($allowed) { + $data = array_intersect_key($data, array_flip($allowed)); + } + + return $data; + } + */ + + /** + * Emit successful response. + * + * @param mixed $response + */ + protected function successResponse($response) + { + // create response object + if (!$this->response) { + $this->response = + new \Zend\Diactoros\Response\JsonResponse( + $response, + 200, + [], + JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT + ); + } + + // if there is emitter, then emit response and exit + // for testing purposes there can be situations when emitter is disabled. then do nothing. + if ($this->emitter) { + $this->emitter->emit($this->response); + exit; + } + + // @todo Should we also stop script execution if no emitter is defined or just ignore that? + //exit; } /** @@ -154,8 +300,8 @@ public function match($pattern, $callable = null) */ public function get($pattern, $callable = null) { - if ($this->request->getMethod() === 'GET') { - return $this->match($pattern, $callable); + if ($this->request->getMethod() === 'GET' && $this->match($pattern)) { + return $this->exec($callable, $this->_vars); } } @@ -169,8 +315,8 @@ public function get($pattern, $callable = null) */ public function post($pattern, $callable = null) { - if ($this->request->getMethod() === 'POST') { - return $this->match($pattern, $callable); + if ($this->request->getMethod() === 'POST' && $this->match($pattern)) { + return $this->exec($callable, $this->_vars); } } @@ -184,8 +330,23 @@ public function post($pattern, $callable = null) */ public function patch($pattern, $callable = null) { - if ($this->request->getMethod() === 'PATCH') { - return $this->match($pattern, $callable); + if ($this->request->getMethod() === 'PATCH' && $this->match($pattern)) { + return $this->exec($callable, $this->_vars); + } + } + + /** + * Do PUT pattern matching. + * + * @param string $pattern + * @param callable $callable + * + * @return mixed + */ + public function put($pattern, $callable = null) + { + if ($this->request->getMethod() === 'PUT' && $this->match($pattern)) { + return $this->exec($callable, $this->_vars); } } @@ -199,42 +360,120 @@ public function patch($pattern, $callable = null) */ public function delete($pattern, $callable = null) { - if ($this->request->getMethod() === 'DELETE') { - return $this->match($pattern, $callable); + if ($this->request->getMethod() === 'DELETE' && $this->match($pattern)) { + return $this->exec($callable, $this->_vars); } } /** * Implement REST pattern matching. * - * @param string $pattern - * @param \atk4\data\Model $model + * @param string $pattern + * @param \atk4\data\Model|callable $model + * @param array $methods Allowed methods (read|modify|delete). By default all are allowed * * @return mixed */ - public function rest($pattern, $model = null) + public function rest($pattern, $model = null, $methods = null) { - $this->get($pattern, function () use ($model) { - return $model; - }); - - $this->get($pattern.'/:id', function ($id) use ($model) { - return $model->load($id)->get(); - }); - - $this->patch($pattern.'/:id', function ($id) use ($model) { - return $model->load($id)->set($this->requestData)->save()->get(); - }); - $this->post($pattern.'/:id', function ($id) use ($model) { - return $model->load($id)->set($this->requestData)->save()->get(); - }); - $this->delete($pattern.'/:id', function ($id) use ($model) { - return !$model->load($id)->delete()->loaded(); - }); - - $this->post($pattern, function () use ($model) { - return $model->set($this->requestData)->save()->get(); - }); + if (!$methods) { + $methods = ['read', 'modify', 'delete']; + } + $methods = array_map('strtolower', $methods); + + // GET all records + if (in_array('read', $methods)) { + $f = function () use ($model) { + $args = func_get_args(); + + if (is_callable($model)) { + $model = $this->call($model, $args); + } + + return $model; + }; + $this->get($pattern, $f); + } + + // GET :id - one record + if (in_array('read', $methods)) { + $f = function () use ($model) { + $args = func_get_args(); + $id = array_pop($args); // pop last element of args array, it's :id + + if (is_callable($model)) { + $model = $this->call($model, $args); + } + + // limit fields + $model->onlyFields($this->getAllowedFields($model, 'read')); + + // load model and get field values + return $this->loadModelByValue($model, $id)->get(); + }; + $this->get($pattern.'/:id', $f); + } + + // POST :id - update one record + // PATCH :id - update one record (same as POST :id) + // PUT :id - update one record (same as POST :id) + if (in_array('modify', $methods)) { + $f = function () use ($model) { + $args = func_get_args(); + $id = array_pop($args); // pop last element of args array, it's :id + + if (is_callable($model)) { + $model = $this->call($model, $args); + } + + // limit fields + $model->onlyFields($this->getAllowedFields($model, 'modify')); + $this->loadModelByValue($model, $id)->save($this->requestData); + $model->onlyFields($this->getAllowedFields($model, 'read')); + + return $model->get(); + }; + $this->patch($pattern.'/:id', $f); + $this->post($pattern.'/:id', $f); + $this->put($pattern.'/:id', $f); + } + + // POST - insert new record + if (in_array('modify', $methods)) { + $f = function () use ($model) { + $args = func_get_args(); + + if (is_callable($model)) { + $model = $this->call($model, $args); + } + + // limit fields + $model->onlyFields($this->getAllowedFields($model, 'modify')); + $model->unload()->save($this->requestData); + $model->onlyFields($this->getAllowedFields($model, 'read')); + + return $model->get(); + }; + $this->post($pattern, $f); + } + + // DELETE :id - delete one record + if (in_array('delete', $methods)) { + $f = function () use ($model) { + $args = func_get_args(); + $id = array_pop($args); // pop last element of args array, it's :id + + if (is_callable($model)) { + $model = $this->call($model, $args); + } + + // limit fields (not necessary, but will limit field list for performance) + $model->onlyFields($this->getAllowedFields($model, 'read')); + + return !$model->delete($id)->loaded(); + }; + $this->delete($pattern.'/:id', $f); + } } /** @@ -245,14 +484,17 @@ public function rest($pattern, $model = null) public function caughtException(\Exception $e) { $params = []; - foreach ($e->getParams() as $key => $val) { - $params[$key] = $e->toString($val); + if ($e instanceof \atk4\core\Exception) { + foreach ($e->getParams() as $key => $val) { + $params[$key] = $e->toString($val); + } } $this->response = new \Zend\Diactoros\Response\JsonResponse( [ 'error'=> [ + 'code' => $e->getCode(), 'message'=> $e->getMessage(), 'args' => $params, ], diff --git a/tests/ApiTester.php b/tests/ApiTester.php index df7e1ee..506fde5 100644 --- a/tests/ApiTester.php +++ b/tests/ApiTester.php @@ -32,6 +32,12 @@ public function assertRequest($response, $method, $uri = '/', $data = null) * passed into an Api class. Execute callback $apiBuild afterwards allowing * you to define your custom handlers. Match response from the API against * the $response and if it is different - create assertion error. + * + * @param string $response + * @param callable $apiBuild + * @param string $uri + * @param string $method + * @param array $data */ public function assertApi($response, $apiBuild, $uri = '/ping', $method = 'GET', $data = null) { @@ -60,7 +66,12 @@ public function assertApi($response, $apiBuild, $uri = '/ping', $method = 'GET', } /** - * Simmulate a request and validate a response. + * Simulate a request and validate a response. + * + * @param string $response + * @param callable $handler + * @param string $method + * @param array $data */ public function assertReq($response, $handler, $method = 'GET', $data = null) { diff --git a/tests/ApiTesterTest.php b/tests/ApiTesterTest.php index 21e9520..27c2ad2 100644 --- a/tests/ApiTesterTest.php +++ b/tests/ApiTesterTest.php @@ -26,5 +26,8 @@ function ($api) { $this->assertReq('pong', function () { return 'pong'; }, 'POST'); + $this->assertReq('pong', function () { + return 'pong'; + }, 'PATCH'); } } diff --git a/tools/release.sh b/tools/release.sh index 6937ef4..ccb4c96 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -2,7 +2,7 @@ set -e -product='data' +product='api' check=$(git symbolic-ref HEAD | cut -d / -f3) @@ -50,10 +50,10 @@ done open "https://github.com/atk4/$product/compare/$prev_version...develop" # Update dependency versions -sed -i "" -e '/atk4\/schema/s/dev-develop/\*/' composer.json # workaround composers inability to change both requries simultaniously -composer require atk4/core atk4/dsql +sed -i "" -e '/atk4\/data/s/dev-develop/\*/' composer.json # workaround composers inability to change both requries simultaniously -composer update +composer update --no-dev +composer require atk4/data ./vendor/phpunit/phpunit/phpunit --no-coverage echo "Press enter to publish the release"