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, * rest:list, * 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} */ 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, * > * ) * } */ 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, * ?execution:function,record,any,any>, * ?title:(null|string), * ?description:(null|string), * ?input_type:function, * ?output_type:function, * > * ) * } * @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), ] ) ]; } ?>