rosavox/lib/alveolata/rest/functions.php
2025-05-23 07:33:29 +00:00

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),
]
)
];
}
?>