715 lines
18 KiB
PHP
715 lines
18 KiB
PHP
<?php
|
|
|
|
namespace alveolata\rest;
|
|
|
|
require_once(DIR_ALVEOLATA . '/call/functions.php');
|
|
require_once(DIR_ALVEOLATA . '/map/functions.php');
|
|
require_once(DIR_ALVEOLATA . '/list/functions.php');
|
|
require_once(DIR_ALVEOLATA . '/string/functions.php');
|
|
require_once(DIR_ALVEOLATA . '/log/functions.php');
|
|
require_once(DIR_ALVEOLATA . '/type/interface.php');
|
|
require_once(DIR_ALVEOLATA . '/type/implementation-any.php');
|
|
require_once(DIR_ALVEOLATA . '/type/implementation-null.php');
|
|
require_once(DIR_ALVEOLATA . '/type/implementation-boolean.php');
|
|
require_once(DIR_ALVEOLATA . '/type/implementation-integer.php');
|
|
require_once(DIR_ALVEOLATA . '/type/implementation-string.php');
|
|
require_once(DIR_ALVEOLATA . '/type/implementation-list.php');
|
|
require_once(DIR_ALVEOLATA . '/type/implementation-record.php');
|
|
require_once(DIR_ALVEOLATA . '/type/implementation-union.php');
|
|
require_once(DIR_ALVEOLATA . '/api/functions.php');
|
|
require_once(DIR_ALVEOLATA . '/http/types.php');
|
|
require_once(DIR_ALVEOLATA . '/http/functions.php');
|
|
require_once(DIR_ALVEOLATA . '/rest/types.php');
|
|
|
|
|
|
|
|
/**
|
|
*/
|
|
function wildcard_step_decode(
|
|
string $step
|
|
) : ?string
|
|
{
|
|
$matches = null; \preg_match('/^\{(.*)\}$/', $step, $matches);
|
|
if (\is_null($matches) || (\count($matches) < 2)) {
|
|
return null;
|
|
}
|
|
else {
|
|
return $matches[1];
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
*/
|
|
function wildcard_step_encode(
|
|
string $name
|
|
) : ?string
|
|
{
|
|
return \sprintf('{%s}', $name);
|
|
}
|
|
|
|
|
|
/**
|
|
*/
|
|
function route_node_iterate(
|
|
struct_route_node $node,
|
|
\Closure $procedure,
|
|
array $steps
|
|
) : void
|
|
{
|
|
$procedure($node, $steps);
|
|
if (! \is_null($node->sub_branch)) {
|
|
foreach ($node->sub_branch as $key => $sub) {
|
|
route_node_iterate($sub, $procedure, \alveolata\list_\concat($steps, [$key]));
|
|
}
|
|
}
|
|
if (! \is_null($node->sub_wildcard)) {
|
|
route_node_iterate(
|
|
$node->sub_wildcard['node'],
|
|
$procedure,
|
|
\alveolata\list_\concat($steps, [wildcard_step_encode($node->sub_wildcard['name'])])
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
*/
|
|
function route_node_flatten(
|
|
struct_route_node $node
|
|
) : array
|
|
{
|
|
$list = [];
|
|
route_node_iterate(
|
|
$node,
|
|
function ($node_, $steps) use (&$list) {
|
|
\array_push($list, ['steps' => $steps, 'node' => $node_]);
|
|
},
|
|
[]
|
|
);
|
|
return $list;
|
|
}
|
|
|
|
|
|
/**
|
|
* @param string $http_method {&\alveolata\http\enum_method}
|
|
*/
|
|
function route_node_spawn(
|
|
array $steps,
|
|
string $http_method,
|
|
string $action_name
|
|
) : ?struct_route_node
|
|
{
|
|
if (\count($steps) <= 0) {
|
|
$node = (new struct_route_node([$http_method => $action_name], null, null));
|
|
}
|
|
else {
|
|
$steps_head = $steps[0];
|
|
$steps_tail = \array_slice($steps, 1);
|
|
$sub = route_node_spawn($steps_tail, $http_method, $action_name);
|
|
$wildcard_name = wildcard_step_decode($steps_head);
|
|
if (\is_null($wildcard_name)) {
|
|
// branch
|
|
$node = (new struct_route_node([], [$steps_head => $sub], null));
|
|
}
|
|
else {
|
|
// wildcard
|
|
$node = (new struct_route_node([], null, [$wildcard_name => $sub]));
|
|
}
|
|
}
|
|
return $node;
|
|
}
|
|
|
|
|
|
/**
|
|
* @return record<
|
|
* steps:list<string>,
|
|
* rest:list<string>,
|
|
* node:struct_route_node,
|
|
* parameters:list<(null|string)>,
|
|
* >
|
|
*/
|
|
function route_node_path_read(
|
|
struct_route_node $node,
|
|
array $steps,
|
|
?array $options = null
|
|
) : array
|
|
{
|
|
$options = \array_merge(
|
|
[
|
|
],
|
|
($options ?? [])
|
|
);
|
|
|
|
if (\count($steps) <= 0) {
|
|
return [
|
|
'steps' => [],
|
|
'rest' => [],
|
|
'node' => $node,
|
|
'parameters' => [],
|
|
];
|
|
}
|
|
else {
|
|
$path_head = $steps[0];
|
|
$path_tail = \array_slice($steps, 1);
|
|
if (\is_null($node->sub_branch) && \is_null($node->sub_wildcard)) {
|
|
throw (new \Exception('uncertain route node kind'));
|
|
}
|
|
else {
|
|
if (! \is_null($node->sub_branch)) {
|
|
// branch
|
|
if (! \array_key_exists($path_head, $node->sub_branch)) {
|
|
return [
|
|
'steps' => [$path_head],
|
|
'rest' => $path_tail,
|
|
'node' => $node,
|
|
'parameters' => [],
|
|
];
|
|
}
|
|
else {
|
|
$result = route_node_path_read($node->sub_branch[$path_head], $path_tail);
|
|
return [
|
|
'steps' => \alveolata\list_\concat([$path_head], $result['steps']),
|
|
'rest' => $result['rest'],
|
|
'node' => $result['node'],
|
|
'parameters' => $result['parameters'],
|
|
];
|
|
}
|
|
}
|
|
else if (! \is_null($node->sub_wildcard)) {
|
|
// wildcard
|
|
$result = route_node_path_read($node->sub_wildcard['node'], $path_tail);
|
|
if (! \array_key_exists($node->sub_wildcard['name'], $result['parameters'])) {
|
|
// do nothing
|
|
}
|
|
else {
|
|
\alveolata\log\warning(
|
|
'[alveolata:rest] overwriting path parameter',
|
|
[
|
|
'key' => $node->sub_wildcard['name'],
|
|
'value_old' => $result['parameters'][$node->sub_wildcard['name']],
|
|
'value_new' => $path_head,
|
|
]
|
|
);
|
|
}
|
|
return [
|
|
'steps' => \alveolata\list_\concat([$path_head], $result['steps']),
|
|
'rest' => $result['rest'],
|
|
'node' => $result['node'],
|
|
'parameters' => \alveolata\list_\concat([$node->sub_wildcard['name'] => $path_head], $result['parameters']),
|
|
];
|
|
}
|
|
else {
|
|
throw (new \Exception('inconsistent route node kind'));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @param array $steps {list<string>}
|
|
*/
|
|
function route_node_path_write(
|
|
struct_route_node $node,
|
|
array $steps,
|
|
string $http_method,
|
|
string $action_name,
|
|
?array $options = null
|
|
) : void
|
|
{
|
|
$options = \array_merge(
|
|
[
|
|
'create' => false,
|
|
],
|
|
($options ?? [])
|
|
);
|
|
|
|
if (\count($steps) <= 0) {
|
|
if (! \array_key_exists($http_method, $node->operations)) {
|
|
// do nothing
|
|
}
|
|
else {
|
|
\alveolata\log\warning(
|
|
'[alveolata:rest] overwriting action',
|
|
[
|
|
'http_method' => $http_method,
|
|
'steps' => $step,
|
|
]
|
|
);
|
|
}
|
|
$node->operations[$http_method] = $action_name;
|
|
}
|
|
else {
|
|
$steps_head = $steps[0];
|
|
$steps_tail = \array_slice($steps, 1);
|
|
|
|
if (\is_null($node->sub_branch) && \is_null($node->sub_wildcard)) {
|
|
if (! $options['create']) {
|
|
throw (new \Exception('may not create missing route'));
|
|
}
|
|
else {
|
|
$wildcard_name = wildcard_step_decode($steps_head);
|
|
if (\is_null($wildcard_name)) {
|
|
// branch
|
|
$node->sub_branch[$steps_head] = route_node_spawn($steps_tail, $http_method, $action_name);
|
|
}
|
|
else {
|
|
// wildcard
|
|
$node->sub_wildcard = [
|
|
'name' => $wildcard_name,
|
|
'node' => route_node_spawn($steps_tail, $http_method, $action_name),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
if (! \is_null($node->sub_branch)) {
|
|
// branch
|
|
if (! \array_key_exists($steps_head, $node->sub_branch)) {
|
|
if (! $options['create']) {
|
|
throw (new \Exception('no such route: ' . \alveolata\string\join(\alveolata\list_\concat([''], $steps), '/')));
|
|
}
|
|
else {
|
|
$node->sub_branch[$steps_head] = route_node_spawn($steps_tail, $http_method, $action_name);
|
|
}
|
|
}
|
|
else {
|
|
route_node_path_write($node->sub_branch[$steps_head], $steps_tail, $http_method, $action_name, $options);
|
|
}
|
|
}
|
|
else if (! \is_null($node->sub_wildcard)) {
|
|
// wildcard
|
|
$wildcard_name = wildcard_step_decode($steps_head);
|
|
if (\is_null($wildcard_name)) {
|
|
throw (new \Exception('expected brace enclosed name step on wildcard node'));
|
|
}
|
|
else {
|
|
route_node_path_write($node->sub_wildcard['node'], $steps_tail, $http_method, $action_name, $options);
|
|
}
|
|
}
|
|
else {
|
|
throw (new \Exception('inconsistent route node kind'));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @param ?array {
|
|
* (
|
|
* null
|
|
* |
|
|
* record<
|
|
* ?name:string,
|
|
* ?versioning_method:string,
|
|
* ?versioning_header_name:(null|string),
|
|
* ?versioning_query_key:(null|string),
|
|
* ?response_body_encode:function<any,string>,
|
|
* >
|
|
* )
|
|
* }
|
|
*/
|
|
function make(
|
|
?array $options = null
|
|
) : struct_subject
|
|
{
|
|
$options = \array_merge(
|
|
[
|
|
'name' => 'REST-API',
|
|
'versioning_method' => 'path',
|
|
'versioning_header_name' => 'X-Api-Version',
|
|
'versioning_query_key' => 'version',
|
|
'request_body_decode' => (fn($data) => \json_decode($data, true)),
|
|
'request_body_mimetype' => 'application/json',
|
|
'response_body_encode' => (fn($data) => \json_encode($data)),
|
|
'response_body_mimetype' => 'application/json',
|
|
],
|
|
($options ?? [])
|
|
);
|
|
return (
|
|
new struct_subject(
|
|
\alveolata\api\make(
|
|
$options['name']
|
|
),
|
|
new struct_route_node(
|
|
[],
|
|
null,
|
|
null
|
|
),
|
|
$options['versioning_method'],
|
|
$options['versioning_header_name'],
|
|
$options['versioning_query_key'],
|
|
$options['request_body_decode'],
|
|
$options['request_body_mimetype'],
|
|
$options['response_body_encode'],
|
|
$options['response_body_mimetype']
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* @param ?array $options {
|
|
* (
|
|
* null
|
|
* |
|
|
* record<
|
|
* ?active:function<string,boolean>,
|
|
* ?execution:function<string,record<string,string>,record<string,string>,any,any>,
|
|
* ?title:(null|string),
|
|
* ?description:(null|string),
|
|
* ?input_type:function<string,any>,
|
|
* ?output_type:function<string,any>,
|
|
* >
|
|
* )
|
|
* }
|
|
* @todo use "custom" for documenting the different responses?
|
|
*/
|
|
function register(
|
|
struct_subject $subject,
|
|
string $http_method,
|
|
string $path,
|
|
?array $options = null
|
|
) : void
|
|
{
|
|
$options = \array_merge(
|
|
[
|
|
'active' => function ($version) {
|
|
return true;
|
|
},
|
|
'execution' => function ($version, $path_parameters, $headers, $input) {
|
|
// throw (new \Exception(\sprintf('not implemented: "%s %s"', $http_method, $path)));
|
|
return [
|
|
'statuscode' => 501,
|
|
'data' => 'not implemented',
|
|
];
|
|
},
|
|
'title' => null,
|
|
'description' => null,
|
|
'input_type' => function ($version) {
|
|
return ['kind' => 'any'];
|
|
},
|
|
'output_type' => function ($version) {
|
|
return ['kind' => 'any'];
|
|
},
|
|
],
|
|
($options ?? [])
|
|
);
|
|
|
|
$steps = \array_slice(\alveolata\string\split($path, '/'), 1);
|
|
$steps_enriched = (
|
|
($subject->versioning_method === 'path')
|
|
? \array_merge(['{version}'], $steps)
|
|
: $steps
|
|
);
|
|
$action_name = \alveolata\string\join(
|
|
\alveolata\list_\concat(
|
|
$steps,
|
|
[\strtolower($http_method)]
|
|
),
|
|
'_'
|
|
);
|
|
route_node_path_write(
|
|
$subject->route_tree,
|
|
$steps_enriched,
|
|
$http_method,
|
|
$action_name,
|
|
[
|
|
'create' => true,
|
|
]
|
|
);
|
|
\alveolata\api\register(
|
|
$subject->api_subject,
|
|
$action_name,
|
|
[
|
|
'active' => $options['active'],
|
|
'execution' => function ($version, $environment, $input) use ($options) {
|
|
return $options['execution'](
|
|
$version,
|
|
$environment['path_parameters'],
|
|
$environment['headers'],
|
|
$input
|
|
);
|
|
},
|
|
'title' => $options['title'],
|
|
'description' => $options['description'],
|
|
'input_type' => $options['input_type'],
|
|
'output_type' => $options['output_type'],
|
|
]
|
|
);
|
|
\alveolata\log\info(
|
|
'[alveolata:rest] route added',
|
|
[
|
|
'route_tree' => $subject->route_tree
|
|
]
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
*/
|
|
function call(
|
|
struct_subject $subject,
|
|
\alveolata\http\struct_request $http_request
|
|
) : \alveolata\http\struct_response
|
|
{
|
|
// parse target
|
|
$target_parts = \parse_url($http_request->target);
|
|
$path = $target_parts['path'];
|
|
|
|
// parse query
|
|
$query_parameters = null; \parse_str($target_parts['query'] ?? '', $query_parameters);
|
|
|
|
$steps = \array_slice(\alveolata\string\split($path, '/'), 1);
|
|
if (\count($steps) <= 0) {
|
|
throw (new \Exception('empty path'));
|
|
}
|
|
else {
|
|
// resolve
|
|
$stuff = route_node_path_read($subject->route_tree, $steps, []);
|
|
if (\count($stuff['rest']) > 0) {
|
|
throw (new \Exception(\sprintf('path not found: %s', $path)));
|
|
}
|
|
else {
|
|
if (! \array_key_exists($http_request->method, $stuff['node']->operations)) {
|
|
throw (new \Exception(\sprintf('no route "%s %s"', $http_request->method, $path)));
|
|
}
|
|
else {
|
|
$action_name = $stuff['node']->operations[$http_request->method];
|
|
|
|
// get version
|
|
switch ($subject->versioning_method) {
|
|
case 'path': {
|
|
$version = $stuff['parameters']['version'];
|
|
// unset($stuff['parameters']['version']);
|
|
break;
|
|
}
|
|
case 'header': {
|
|
$version = $http_request->headers[$subject->versioning_header_name];
|
|
// unset($http_request->headers[$subject->versioning_header_name]);
|
|
break;
|
|
}
|
|
case 'query': {
|
|
$version = $query_parameters[$subject->versioning_query_key];
|
|
// unset($query_parameters[$subject->versioning_query_key]);
|
|
break;
|
|
}
|
|
default: {
|
|
throw (new \Exception('unhandled versioning method: ' . $subject->versioning_method));
|
|
break;
|
|
}
|
|
}
|
|
|
|
// call
|
|
$output = \alveolata\api\call(
|
|
$subject->api_subject,
|
|
$action_name,
|
|
[
|
|
'version' => $version,
|
|
'environment' => [
|
|
'headers' => $http_request->headers,
|
|
'path_parameters' => $stuff['parameters'],
|
|
'query_parameters' => $query_parameters,
|
|
],
|
|
'input' => ($subject->request_body_decode)($http_request->body),
|
|
]
|
|
);
|
|
|
|
// encode
|
|
return (
|
|
new \alveolata\http\struct_response(
|
|
$output['status_code'],
|
|
[],
|
|
($subject->response_body_encode)($output['data'])
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @return mixed
|
|
* @see https://swagger.io/specification/#openapi-object
|
|
*/
|
|
function to_oas(
|
|
struct_subject $subject,
|
|
?array $options = null
|
|
) : array
|
|
{
|
|
$options = \array_merge(
|
|
[
|
|
'version' => null,
|
|
'servers' => [],
|
|
],
|
|
($options ?? [])
|
|
);
|
|
$version = ($options['version'] ?? '-');
|
|
|
|
return [
|
|
'openapi' => '3.0.2',
|
|
'info' => [
|
|
'title' => $subject->api_subject->name,
|
|
'version' => $version,
|
|
],
|
|
'servers' => $options['servers'],
|
|
'paths' => \alveolata\call\convey(
|
|
$subject->route_tree,
|
|
[
|
|
fn($x) => route_node_flatten($x),
|
|
fn($x) => \alveolata\list_\map(
|
|
$x,
|
|
function ($entry) use ($subject, $options) {
|
|
$steps_ = (
|
|
($subject->versioning_method === 'path')
|
|
? \array_merge([$options['version'] ?? '-'], \array_slice($entry['steps'], 1))
|
|
: $entry['steps']
|
|
);
|
|
$path = (
|
|
(\count($steps_) <= 0)
|
|
? '/'
|
|
: \alveolata\string\join(
|
|
\alveolata\list_\concat([''], $steps_),
|
|
'/'
|
|
)
|
|
);
|
|
$key = (
|
|
(\count($steps_) <= 0)
|
|
? '/'
|
|
: \alveolata\string\join(
|
|
\alveolata\list_\concat([''], $steps_),
|
|
'/'
|
|
)
|
|
);
|
|
return [
|
|
'key' => $key,
|
|
'value' => \alveolata\call\convey(
|
|
$entry['node']->operations,
|
|
[
|
|
fn($x) => \alveolata\map\to_pairs($x),
|
|
fn($x) => \alveolata\list_\map(
|
|
$x,
|
|
fn($pair) => [
|
|
'key' => \alveolata\http\method_to_oas($pair['key']),
|
|
'value' => $pair['value'],
|
|
]
|
|
),
|
|
fn($x) => \alveolata\map\from_pairs($x),
|
|
fn($x) => \alveolata\map\map(
|
|
$x,
|
|
fn($action_name, $http_method) => [
|
|
'operationId' => $action_name,
|
|
'summary' => (
|
|
$subject->api_subject->actions[$action_name]->title
|
|
??
|
|
\alveolata\call\convey(
|
|
\alveolata\list_\concat(
|
|
[\strtolower($http_method)],
|
|
(
|
|
($subject->versioning_method === 'path')
|
|
? \array_slice($steps_, 1)
|
|
: $steps_
|
|
)
|
|
),
|
|
[
|
|
fn($x) => \alveolata\list_\map(
|
|
$x,
|
|
fn($step) => (
|
|
\is_null(wildcard_step_decode($step))
|
|
? $step
|
|
: '(specific)'
|
|
)
|
|
),
|
|
fn($x) => \alveolata\string\join($x, ' '),
|
|
]
|
|
)
|
|
),
|
|
'description' => $subject->api_subject->actions[$action_name]->description,
|
|
'parameters' => \array_merge(
|
|
[],
|
|
(
|
|
($subject->versioning_method === 'query')
|
|
? [
|
|
[
|
|
'name' => $subject->versioning_query_key,
|
|
'in' => 'query',
|
|
'required' => true,
|
|
'schema' => [
|
|
'type' => 'string',
|
|
'enum' => [$options['version'] ?? '-'],
|
|
],
|
|
]
|
|
]
|
|
: []
|
|
),
|
|
(
|
|
($subject->versioning_method === 'header')
|
|
? [
|
|
[
|
|
'name' => $subject->versioning_header_name,
|
|
'in' => 'header',
|
|
'required' => true,
|
|
'schema' => [
|
|
'type' => 'string',
|
|
'enum' => [$options['version'] ?? '-'],
|
|
],
|
|
]
|
|
]
|
|
: []
|
|
),
|
|
\alveolata\call\convey(
|
|
$steps_,
|
|
[
|
|
fn($x) => \alveolata\list_\map($x, fn($y) => wildcard_step_decode($y)),
|
|
fn($x) => \alveolata\list_\filter($x, fn($y) => (! \is_null($y))),
|
|
fn($x) => \alveolata\list_\map(
|
|
$x,
|
|
fn($y) => [
|
|
'name' => $y,
|
|
'in' => 'path',
|
|
'required' => true,
|
|
'schema' => [
|
|
'type' => 'string',
|
|
],
|
|
]
|
|
),
|
|
]
|
|
),
|
|
),
|
|
'requestBody' => [
|
|
'content' => [
|
|
$subject->request_body_mimetype => [
|
|
'schema' => ($subject->api_subject->actions[$action_name]->input_type)($options['version'])->to_oas(),
|
|
]
|
|
]
|
|
],
|
|
'responses' => [
|
|
'default' => [
|
|
'content' => [
|
|
$subject->response_body_mimetype => [
|
|
'schema' => ($subject->api_subject->actions[$action_name]->output_type)($options['version'])->to_oas(),
|
|
]
|
|
]
|
|
]
|
|
],
|
|
]
|
|
),
|
|
]
|
|
),
|
|
];
|
|
}
|
|
),
|
|
fn($x) => \alveolata\list_\filter(
|
|
$x,
|
|
fn($y) => (\count($y['value']) > 0)
|
|
),
|
|
fn($x) => \alveolata\map\from_pairs($x),
|
|
]
|
|
)
|
|
];
|
|
}
|
|
|
|
?>
|