first version

This commit is contained in:
Bastian Allgeier
2019-01-13 23:17:34 +01:00
commit 01277f79f2
595 changed files with 82913 additions and 0 deletions

428
kirby/src/Api/Api.php Executable file
View File

@@ -0,0 +1,428 @@
<?php
namespace Kirby\Api;
use Closure;
use Exception;
use Throwable;
use Kirby\Exception\NotFoundException;
use Kirby\Http\Router;
use Kirby\Http\Response;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Properties;
/**
* The API class is a generic container
* for API routes, models and collections and is used
* to run our REST API. You can find our API setup
* in kirby/config/api.php
*/
class Api
{
use Properties;
protected $authentication;
protected $debug = false;
protected $collections = [];
protected $data = [];
protected $models = [];
protected $routes = [];
protected $requestData = [];
protected $requestMethod;
public function __call($method, $args)
{
return $this->data($method, ...$args);
}
public function __construct(array $props)
{
$this->setProperties($props);
}
public function authenticate()
{
if ($auth = $this->authentication()) {
return $auth->call($this);
}
return true;
}
public function authentication()
{
return $this->authentication;
}
public function call(string $path = null, string $method = 'GET', array $requestData = [])
{
$path = rtrim($path, '/');
$this->setRequestMethod($method);
$this->setRequestData($requestData);
$router = new Router($this->routes());
$result = $router->find($path, $method);
$auth = $result->attributes()['auth'] ?? true;
if ($auth !== false) {
$this->authenticate();
}
$output = $result->action()->call($this, ...$result->arguments());
if (is_object($output) === true) {
return $this->resolve($output)->toResponse();
}
return $output;
}
public function collection(string $name, $collection = null)
{
if (isset($this->collections[$name]) === false) {
throw new NotFoundException(sprintf('The collection "%s" does not exist', $name));
}
return new Collection($this, $collection, $this->collections[$name]);
}
public function collections(): array
{
return $this->collections;
}
public function data($key = null, ...$args)
{
if ($key === null) {
return $this->data;
}
if ($this->hasData($key) === false) {
throw new NotFoundException(sprintf('Api data for "%s" does not exist', $key));
}
// lazy-load data wrapped in Closures
if (is_a($this->data[$key], 'Closure') === true) {
return $this->data[$key]->call($this, ...$args);
}
return $this->data[$key];
}
public function hasData($key): bool
{
return isset($this->data[$key]) === true;
}
public function model(string $name, $object = null)
{
if (isset($this->models[$name]) === false) {
throw new NotFoundException(sprintf('The model "%s" does not exist', $name));
}
return new Model($this, $object, $this->models[$name]);
}
public function models(): array
{
return $this->models;
}
public function requestData($type = null, $key = null, $default = null)
{
if ($type === null) {
return $this->requestData;
}
if ($key === null) {
return $this->requestData[$type] ?? [];
}
$data = array_change_key_case($this->requestData($type));
$key = strtolower($key);
return $data[$key] ?? $default;
}
public function requestBody(string $key = null, $default = null)
{
return $this->requestData('body', $key, $default);
}
public function requestFiles(string $key = null, $default = null)
{
return $this->requestData('files', $key, $default);
}
public function requestHeaders(string $key = null, $default = null)
{
return $this->requestData('headers', $key, $default);
}
public function requestMethod(): string
{
return $this->requestMethod;
}
public function requestQuery(string $key = null, $default = null)
{
return $this->requestData('query', $key, $default);
}
public function resolve($object)
{
if (is_a($object, 'Kirby\Api\Model') === true || is_a($object, 'Kirby\Api\Collection') === true) {
return $object;
}
$className = strtolower(get_class($object));
$lastDash = strrpos($className, '\\');
if ($lastDash !== false) {
$className = substr($className, $lastDash + 1);
}
if (isset($this->models[$className]) === true) {
return $this->model($className, $object);
}
if (isset($this->collections[$className]) === true) {
return $this->collection($className, $object);
}
// now models deeply by checking for the actual type
foreach ($this->models as $modelClass => $model) {
if (is_a($object, $model['type']) === true) {
return $this->model($modelClass, $object);
}
}
throw new NotFoundException(sprintf('The object "%s" cannot be resolved', $className));
}
public function routes(): array
{
return $this->routes;
}
protected function setAuthentication(Closure $authentication = null)
{
$this->authentication = $authentication;
return $this;
}
protected function setCollections(array $collections = null)
{
if ($collections !== null) {
$this->collections = array_change_key_case($collections);
}
return $this;
}
protected function setData(array $data = null)
{
$this->data = $data ?? [];
return $this;
}
protected function setDebug(bool $debug = false)
{
$this->debug = $debug;
return $this;
}
protected function setModels(array $models = null)
{
if ($models !== null) {
$this->models = array_change_key_case($models);
}
return $this;
}
protected function setRequestData(array $requestData = null)
{
$defaults = [
'query' => [],
'body' => [],
'files' => []
];
$this->requestData = array_merge($defaults, (array)$requestData);
return $this;
}
protected function setRequestMethod(string $requestMethod = null)
{
$this->requestMethod = $requestMethod;
return $this;
}
protected function setRoutes(array $routes = null)
{
$this->routes = $routes ?? [];
return $this;
}
public function render(string $path, $method = 'GET', array $requestData = [])
{
try {
$result = $this->call($path, $method, $requestData);
} catch (Throwable $e) {
if (is_a($e, 'Kirby\Exception\Exception') === true) {
$result = ['status' => 'error'] + $e->toArray();
} else {
$result = [
'status' => 'error',
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => ltrim($e->getFile(), $_SERVER['DOCUMENT_ROOT'] ?? null),
'line' => $e->getLine(),
'code' => empty($e->getCode()) === false ? $e->getCode() : 500
];
}
}
if ($result === null) {
$result = [
'status' => 'error',
'message' => 'not found',
'code' => 404,
];
}
if ($result === true) {
$result = [
'status' => 'ok',
];
}
if ($result === false) {
$result = [
'status' => 'error',
'message' => 'bad request',
'code' => 400,
];
}
if (is_array($result) === false) {
return $result;
}
// pretty print json data
$pretty = (bool)($requestData['query']['pretty'] ?? false) === true;
// remove critical info from the result set if
// debug mode is switched off
if ($this->debug !== true) {
unset(
$result['file'],
$result['exception'],
$result['line']
);
}
if (($result['status'] ?? 'ok') === 'error') {
$code = $result['code'] ?? 400;
// sanitize the error code
if ($code < 400 || $code > 599) {
$code = 500;
}
return Response::json($result, $code, $pretty);
}
return Response::json($result, 200, $pretty);
}
public function upload(Closure $callback, $single = false): array
{
$trials = 0;
$uploads = [];
$errors = [];
$files = $this->requestFiles();
if (empty($files) === true) {
throw new Exception('No uploaded files');
}
foreach ($files as $upload) {
if (isset($upload['tmp_name']) === false && is_array($upload)) {
continue;
}
$trials++;
try {
if ($upload['error'] !== 0) {
throw new Exception('Upload error');
}
// get the extension of the uploaded file
$extension = F::extension($upload['name']);
// try to detect the correct mime and add the extension
// accordingly. This will avoid .tmp filenames
if (empty($extension) === true || in_array($extension, ['tmp', 'temp'])) {
$mime = F::mime($upload['tmp_name']);
$extension = F::mimeToExtension($mime);
$filename = F::name($upload['name']) . '.' .$extension;
} else {
$filename = basename($upload['name']);
}
$source = dirname($upload['tmp_name']) . '/' . uniqid() . '.' . $filename;
// move the file to a location including the extension,
// for better mime detection
if (move_uploaded_file($upload['tmp_name'], $source) === false) {
throw new Exception('The uploaded file could not be moved');
}
$data = $callback($source, $filename);
if (is_object($data) === true) {
$data = $this->resolve($data)->toArray();
}
$uploads[$upload['name']] = $data;
} catch (Exception $e) {
$errors[$upload['name']] = $e->getMessage();
}
if ($single === true) {
break;
}
}
// return a single upload response
if ($trials === 1) {
if (empty($errors) === false) {
return [
'status' => 'error',
'message' => current($errors)
];
}
return [
'status' => 'ok',
'data' => current($uploads)
];
}
if (empty($errors) === false) {
return [
'status' => 'error',
'errors' => $errors
];
}
return [
'status' => 'ok',
'data' => $uploads
];
}
}

124
kirby/src/Api/Collection.php Executable file
View File

@@ -0,0 +1,124 @@
<?php
namespace Kirby\Api;
use Closure;
use Exception;
use Kirby\Toolkit\Str;
/**
* The Collection class is a wrapper
* around our Kirby Collections and handles
* stuff like pagination and proper JSON output
* for collections in REST calls.
*/
class Collection
{
protected $api;
protected $data;
protected $model;
protected $select;
protected $view;
public function __construct(Api $api, $data = null, array $schema)
{
$this->api = $api;
$this->data = $data;
$this->model = $schema['model'];
$this->view = $schema['view'] ?? null;
if ($data === null) {
if (is_a($schema['default'] ?? null, 'Closure') === false) {
throw new Exception('Missing collection data');
}
$this->data = $schema['default']->call($this->api);
}
if (isset($schema['type']) === true && is_a($this->data, $schema['type']) === false) {
throw new Exception('Invalid collection type');
}
}
public function select($keys = null)
{
if ($keys === false) {
return $this;
}
if (is_string($keys)) {
$keys = Str::split($keys);
}
if ($keys !== null && is_array($keys) === false) {
throw new Exception('Invalid select keys');
}
$this->select = $keys;
return $this;
}
public function toArray(): array
{
$result = [];
foreach ($this->data as $item) {
$model = $this->api->model($this->model, $item);
if ($this->view !== null) {
$model = $model->view($this->view);
}
if ($this->select !== null) {
$model = $model->select($this->select);
}
$result[] = $model->toArray();
}
return $result;
}
public function toResponse(): array
{
if ($query = $this->api->requestQuery('query')) {
$this->data = $this->data->query($query);
}
if (!$this->data->pagination()) {
$this->data = $this->data->paginate([
'page' => $this->api->requestQuery('page', 1),
'limit' => $this->api->requestQuery('limit', 100)
]);
}
$pagination = $this->data->pagination();
if ($select = $this->api->requestQuery('select')) {
$this->select($select);
}
if ($view = $this->api->requestQuery('view')) {
$this->view($view);
}
return [
'code' => 200,
'data' => $this->toArray(),
'pagination' => [
'page' => $pagination->page(),
'total' => $pagination->total(),
'offset' => $pagination->offset(),
'limit' => $pagination->limit(),
],
'status' => 'ok',
'type' => 'collection'
];
}
public function view(string $view)
{
$this->view = $view;
return $this;
}
}

188
kirby/src/Api/Model.php Executable file
View File

@@ -0,0 +1,188 @@
<?php
namespace Kirby\Api;
use Closure;
use Exception;
use Kirby\Toolkit\Str;
/**
* The API Model class can be wrapped around any
* kind of object. Each model defines a set of properties that
* are availabel in REST calls. Those properties are defined as
* simple Closures which are resolved on demand. This is inspired
* by GraphQLs architecture and makes it possible to load
* only the model data that is needed for the current API call.
*
*/
class Model
{
protected $api;
protected $data;
protected $fields;
protected $select;
protected $views;
public function __construct(Api $api, $data = null, array $schema)
{
$this->api = $api;
$this->data = $data;
$this->fields = $schema['fields'] ?? [];
$this->select = $schema['select'] ?? null;
$this->views = $schema['views'] ?? [];
if ($this->select === null && array_key_exists('default', $this->views)) {
$this->view('default');
}
if ($data === null) {
if (is_a($schema['default'] ?? null, 'Closure') === false) {
throw new Exception('Missing model data');
}
$this->data = $schema['default']->call($this->api);
}
if (isset($schema['type']) === true && is_a($this->data, $schema['type']) === false) {
throw new Exception(sprintf('Invalid model type "%s" expected: "%s"', get_class($this->data), $schema['type']));
}
}
public function select($keys = null)
{
if ($keys === false) {
return $this;
}
if (is_string($keys)) {
$keys = Str::split($keys);
}
if ($keys !== null && is_array($keys) === false) {
throw new Exception('Invalid select keys');
}
$this->select = $keys;
return $this;
}
public function selection(): array
{
$select = $this->select;
if ($select === null) {
$select = array_keys($this->fields);
}
$selection = [];
foreach ($select as $key => $value) {
if (is_int($key) === true) {
$selection[$value] = [
'view' => null,
'select' => null
];
continue;
}
if (is_string($value) === true) {
if ($value === 'any') {
throw new Exception('Invalid sub view: "any"');
}
$selection[$key] = [
'view' => $value,
'select' => null
];
continue;
}
if (is_array($value) === true) {
$selection[$key] = [
'view' => null,
'select' => $value
];
}
}
return $selection;
}
public function toArray(): array
{
$select = $this->selection();
$result = [];
foreach ($this->fields as $key => $resolver) {
if (array_key_exists($key, $select) === false || is_a($resolver, 'Closure') === false) {
continue;
}
$value = $resolver->call($this->api, $this->data);
if (is_object($value)) {
$value = $this->api->resolve($value);
}
if (is_a($value, 'Kirby\Api\Collection') === true || is_a($value, 'Kirby\Api\Model') === true) {
$selection = $select[$key];
if ($subview = $selection['view']) {
$value->view($subview);
}
if ($subselect = $selection['select']) {
$value->select($subselect);
}
$value = $value->toArray();
}
$result[$key] = $value;
}
ksort($result);
return $result;
}
public function toResponse(): array
{
$model = $this;
if ($select = $this->api->requestQuery('select')) {
$model = $model->select($select);
}
if ($view = $this->api->requestQuery('view')) {
$model = $model->view($view);
}
return [
'code' => 200,
'data' => $model->toArray(),
'status' => 'ok',
'type' => 'model'
];
}
public function view(string $name)
{
if ($name === 'any') {
return $this->select(null);
}
if (isset($this->views[$name]) === false) {
$name = 'default';
// try to fall back to the default view at least
if (isset($this->views[$name]) === false) {
throw new Exception(sprintf('The view "%s" does not exist', $name));
}
}
return $this->select($this->views[$name]);
}
}