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"