From e4cdba842cd711ecac23672a3994ec8705db3304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Dufraisse?= Date: Sat, 25 Feb 2023 18:11:26 +0100 Subject: [PATCH] feat(api): create --- SeacmsApi.php | 174 +++++++++++++++++++++++++++- composer.json | 3 + src/BadMethodException.php | 13 +++ src/JsonResponse.php | 206 +++++++++++++++++++++++++++++++++ src/NotFoundRouteException.php | 13 +++ 5 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 src/BadMethodException.php create mode 100644 src/JsonResponse.php create mode 100644 src/NotFoundRouteException.php diff --git a/SeacmsApi.php b/SeacmsApi.php index 2e53d7b..547e440 100644 --- a/SeacmsApi.php +++ b/SeacmsApi.php @@ -2,6 +2,10 @@ // SPDX-License-Identifier: EUPL-1.2 // Authors: see README.md +use SeaCMS\Api\BadMethodException; +use SeaCMS\Api\JsonResponse; +use SeaCMS\Api\NotFoundRouteException; + /** * An api plugin for Pico 3. */ @@ -13,6 +17,69 @@ class SeacmsApi extends AbstractPicoPlugin */ const API_VERSION = 3; + /** + * routes trigerred OnPageRendered + * @var array + */ + protected $routesOnPageRendered ; + + /** + * return api routes + * @return array + */ + public function registerOnPageRenderedApiRoutes():array + { + return [ + 'POST test' => 'api', + 'GET test/(.*)' => 'apiWithText', + ]; + } + + /** + * method for api + * @return JsonResponse + */ + public function api(): JsonResponse + { + return new JsonResponse(200,['test'=>'OK']); + } + + /** + * method for api + * @param string $text + * @return JsonResponse + */ + public function apiWithText(string $text): JsonResponse + { + return new JsonResponse(200,['text'=>$text]); + } + + /** + * Triggered after Pico has loaded all available plugins + * + * This event is triggered nevertheless the plugin is enabled or not. + * It is NOT guaranteed that plugin dependencies are fulfilled! + * + * + * @param object[] $plugins loaded plugin instances + */ + public function onPluginsLoaded(array $plugins) + { + $this->routesOnPageRendered = []; + foreach($plugins as $plugin){ + if (method_exists($plugin,'registerOnPageRenderedApiRoutes')){ + $routes = $plugin->registerOnPageRenderedApiRoutes(); + if (is_array($routes)){ + foreach($routes as $route => $methodName){ + if (is_string($methodName) && method_exists($plugin,$methodName)){ + $this->routesOnPageRendered[$route] = [$plugin,$methodName]; + } + } + } + } + } + } + /** * If the call is a save query, save the edited regions and output the JSON response. * @@ -23,6 +90,111 @@ class SeacmsApi extends AbstractPicoPlugin */ public function onPageRendered(&$output) { - // TODO + if (isset($_GET['api'])){ + $route = $this->getPico()->getUrlParameter( + 'api', + FILTER_UNSAFE_RAW, + [ + 'default' => '' + ], + [ + FILTER_FLAG_STRIP_LOW, + FILTER_FLAG_STRIP_HIGH + ] + ); + $route = trim($route); + if (empty($route)){ + $output = (new JsonResponse(404,['code'=>404,'reason'=>'Empty api route']))->send(); + } elseif (!preg_match('/^[A-Za-z0-9_\-.\/]+$/',$route)) { + $output = (new JsonResponse(404,['code'=>404,'reason'=>"Route '$route' use forbidden characters !"]))->send(); + } else { + ob_start(); + $response = null; + try { + $data = $this->searchCorrespondingRoute($route); + $response = call_user_func_array([$data['plugin'],$data['methodName']],$data['params']); + if (!($response instanceof JsonResponse)){ + $response = null; + throw new Exception("Return of '{$data['methodName']}' should be instanceof of 'JsonResponse'"); + } + } catch (BadMethodException $th) { + $code = 405; + $content = ['reason'=>$th->getMessage()]; + } catch (NotFoundRouteException $th) { + $code = 404; + $content = ['reason'=>"Route '$route' not found !"]; + } catch (Throwable $th) { + $code = 500; + $content = ['reason'=>$th->__toString()]; + } + $rawOutput = ob_get_contents(); + ob_end_clean(); + if (empty($response)){ + if (!empty($rawOutput)){ + $content['rawOutput'] = $rawOutput; + } + $content = array_merge(['code'=>$code],$content); + $response = (new JsonResponse($code,$content)); + } elseif (!empty($rawOutput)) { + $response->mergeInContent(compact(['rawOutput'])); + } + $output = $response->send(); + } + } + } + + /** + * search corresponding route + * @param string $route + * @return array ['plugin'=>$plugin,'methodName'=>string,'params'=>array] + * @throws BadMethodException + * @throws NotFoundRouteException + */ + protected function searchCorrespondingRoute(string $route): array + { + if (empty($_SERVER['REQUEST_METHOD'])){ + throw new BadMethodException('Method not defined'); + } + $method = $_SERVER['REQUEST_METHOD']; + if (!in_array($method,['GET','POST'],true)){ + throw new BadMethodException('Not allowed method'); + } + $splittedRoute = explode('/',$route); + + $nb = count($splittedRoute); + $badMethod = false; + for ($i=0; $i < 2**$nb; $i++) { + $params = []; + $splittedRouteFiltered = []; + foreach ($splittedRoute as $idx => $value) { + $currentPower = $nb - $idx - 1 ; + if ((2**$currentPower & $i) > 0){ + $splittedRouteFiltered[] = '(.*)'; + $params[] = $value; + } else { + $splittedRouteFiltered[] = $value; + } + } + $searchingRoute = implode('/',$splittedRouteFiltered); + $data = []; + if (array_key_exists("$method $searchingRoute",$this->routesOnPageRendered)){ + $data = $this->routesOnPageRendered["$method $searchingRoute"]; + } elseif (array_key_exists("$searchingRoute",$this->routesOnPageRendered)){ + $data = $this->routesOnPageRendered["$searchingRoute"]; + } + if (!empty($data)){ + return [ + 'plugin' => $data[0], + 'methodName' => $data[1], + 'params' => $params + ]; + } elseif (!$badMethod && array_key_exists((($method == 'GET') ? 'POST' : 'GET' )." $searchingRoute",$this->routesOnPageRendered)){ + $badMethod = true; + } + } + if ($badMethod){ + throw new BadMethodException('Not allowed method'); + } + throw new NotFoundRouteException(''); } } diff --git a/composer.json b/composer.json index 7cf4bf2..7e83149 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,9 @@ "php": "^8.0" }, "autoload": { + "psr-4": { + "SeaCMS\\Api\\": "src" + }, "classmap": [ "SeacmsApi.php" ] }, "config": { diff --git a/src/BadMethodException.php b/src/BadMethodException.php new file mode 100644 index 0000000..fed23df --- /dev/null +++ b/src/BadMethodException.php @@ -0,0 +1,13 @@ + 'OK', + 301 => 'Moved Permanently', + 302 => 'Found', + 304 => 'Not Modified', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 408 => 'Request Timeout', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 503 => 'Service Unavailable', + ]; + + /** + * HTTP CODE + * @var int + */ + protected $code; + /** + * content as array + * @var array + */ + protected $content; + /** + * headers + * @var array + */ + protected $headers; + + + public function __construct(int $code, array $content, array $headers = []){ + $this->code = array_key_exists($code, self::HTTP_CODES) ? $code : 501; // default + $this->content = $content; + $this->headers = array_merge([ + 'Content-Type' => 'application/json', + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Credentials' => 'true', + 'Access-Control-Allow-Headers' => 'X-Requested-With, Location, Slug, Accept, Content-Type', + 'Access-Control-Expose-Headers' => 'Location, Slug, Accept, Content-Type', + 'Access-Control-Allow-Methods' => 'POST, GET, OPTIONS, DELETE, PUT, PATCH', + 'Access-Control-Max-Age' => '86400' + ], $headers); + $this->preparedOutput = ''; + } + + /** + * merge in content + * @param array $newContent + * @return $this + */ + public function mergeInContent(array $newContent){ + $this->content = array_merge( + $this->content, + $newContent + ); + } + /** + * prepend in content + * @param array $newContent + * @return $this + */ + protected function prependInContent(array $newContent){ + $this->content = array_merge( + $newContent, + $this->content + ); + } + + /** + * set code + * @param int + */ + public function setCode(int $code) + { + $this->code = $code; + } + + /* === Getters === */ + /** + * return code + * @return int + */ + public function getCode(): int + { + return $this->code; + } + /** + * return content + * @return array + */ + public function getContent(): array + { + return $this->content; + } + + /* === === */ + + /** + * Sends HTTP headers. + * + * @return $this + */ + public function sendHeaders(): JsonResponse + { + // headers have already been sent by the developer + if (!headers_sent()) { + + // headers + foreach ($this->headers as $name => $value) { + header($name.': '.$value); + } + + // status + $statusText = self::HTTP_CODES[$this->code]; + header("HTTP/1.0 {$this->code} $statusText"); + } + + return $this; + + } + + /** + * send headers and return output + * @return string + */ + public function send(): string + { + return $this->preparedOutput()->prepareStatusText()->sendHeaders()->returnContent(); + } + + /** + * check if output is JSONSerializable + * @return JsonResponse $this + */ + protected function preparedOutput(): JsonResponse + { + try { + json_encode($this->content); + } catch (Throwable $th) { + $this->code = 500; + $this->content = ['code' => 500, 'reason' =>"Not possible to JSONSerialize content"]; + } + return $this; + } + + /** + * prepare statusText + * @return JsonResponse $this + */ + protected function prepareStatusText(): JsonResponse + { + if (!array_key_exists($this->code,self::HTTP_CODES)){ + $previousCode = intval($this->code); + $this->code = 501; + $this->prependInContent(['code' => 501, 'reason' =>"Wanted code ($previousCode) is not implemented !"]); + } + return $this; + } + + /** + * return content as String + * @return string + */ + protected function returnContent(): string + { + return json_encode($this->getContent()); + } + + /** + * export class as array + * @return array + */ + public function jsonSerialize(): mixed + { + return [ + 'code' => $this->getCode(), + 'content' => $this->getContent(), + 'headers' => $this->headers, + ]; + } +} diff --git a/src/NotFoundRouteException.php b/src/NotFoundRouteException.php new file mode 100644 index 0000000..48b50d9 --- /dev/null +++ b/src/NotFoundRouteException.php @@ -0,0 +1,13 @@ +