aboutsummaryrefslogtreecommitdiff
path: root/lib/trails
diff options
context:
space:
mode:
Diffstat (limited to 'lib/trails')
-rw-r--r--lib/trails/Controller.php411
-rw-r--r--lib/trails/Dispatcher.php262
-rw-r--r--lib/trails/Exception.php43
-rw-r--r--lib/trails/Exceptions/DoubleRenderError.php13
-rw-r--r--lib/trails/Exceptions/MissingFile.php10
-rw-r--r--lib/trails/Exceptions/RoutingError.php10
-rw-r--r--lib/trails/Exceptions/SessionRequiredException.php11
-rw-r--r--lib/trails/Exceptions/UnknownAction.php10
-rw-r--r--lib/trails/Exceptions/UnknownController.php10
-rw-r--r--lib/trails/Flash.php184
-rw-r--r--lib/trails/Inflector.php44
-rw-r--r--lib/trails/Response.php185
12 files changed, 1193 insertions, 0 deletions
diff --git a/lib/trails/Controller.php b/lib/trails/Controller.php
new file mode 100644
index 0000000..1918380
--- /dev/null
+++ b/lib/trails/Controller.php
@@ -0,0 +1,411 @@
+<?php
+namespace Trails;
+
+use Flexi\Factory;
+use Flexi\Template;
+use Flexi\TemplateNotFoundException;
+use Trails\Exceptions\DoubleRenderError;
+use Trails\Exceptions\UnknownAction;
+
+/**
+ * A Controller is responsible for matching the unconsumed part of an URI
+ * to an action using the left over words as arguments for that action. The
+ * action is then mapped to method of the controller instance which is called
+ * with the just mentioned arguments. That method can send the #render_action,
+ * #render_template, #render_text, #render_nothing or #redirect method.
+ * Otherwise the #render_action is called with the current action as argument.
+ * If the action method sets instance variables during performing, they will be
+ * be used as attributes for the flexi-template opened by #render_action or
+ * #render_template. A controller's response's body is populated with the output
+ * of the #render_* methods. The action methods can add additional headers or
+ * change the status of that response.
+ *
+ * @package trails
+ *
+ * @author mlunzena
+ * @copyright (c) Authors
+ * @version $Id: trails.php 7001 2008-04-04 11:20:27Z mlunzena $
+ */
+class Controller
+{
+ protected Dispatcher $dispatcher;
+ protected Response $response;
+ protected bool $performed = false;
+ protected Template|string|null $layout = null;
+ private string $format = 'html';
+
+ /**
+ * @param Dispatcher $dispatcher the dispatcher who creates this instance
+ */
+ public function __construct(Dispatcher $dispatcher)
+ {
+ $this->dispatcher = $dispatcher;
+ $this->erase_response();
+ }
+
+ /**
+ * Resets the response of the controller
+ *
+ * @return void
+ */
+ public function erase_response()
+ {
+ $this->performed = false;
+ $this->response = new Response();
+ }
+
+ /**
+ * Return this controller's response
+ *
+ * @return Response the controller's response
+ */
+ public function get_response()
+ {
+ return $this->response;
+ }
+
+ /**
+ * This method extracts an action string and further arguments from it's
+ * parameter. The action string is mapped to a method being called afterwards
+ * using the said arguments. That method is called and a response object is
+ * generated, populated and sent back to the dispatcher.
+ *
+ * @param string $unconsumed
+ *
+ * @return Response
+ * @throws UnknownAction
+ */
+ public function perform($unconsumed)
+ {
+ [$action, $args, $format] = $this->extract_action_and_args($unconsumed);
+
+ $this->format = $format ?? 'html';
+
+ $before_filter_result = $this->before_filter($action, $args);
+
+ # send action to controller
+ # TODO (mlunzena) shouldn't the after filter be triggered too?
+ if (!($before_filter_result === false || $this->performed)) {
+
+ $callable = $this->map_action($action);
+
+ if (is_callable($callable)) {
+ $callable(...$args);
+ } else {
+ $this->does_not_understand($action, $args);
+ }
+
+ if (!$this->performed) {
+ $this->render_action($action);
+ }
+
+ $this->after_filter($action, $args);
+ }
+
+ return $this->response;
+ }
+
+ /**
+ * Extracts action and args from a string.
+ *
+ * @param string $string the processed string
+ * @return array an array with two elements - a string containing the
+ * action and an array of strings representing the args
+ */
+ public function extract_action_and_args($string)
+ {
+ if ('' === $string) {
+ return $this->default_action_and_args();
+ }
+
+ // find optional file extension
+ $format = null;
+ if (preg_match('/^(.*[^\/.])\.(\w+)$/', $string, $matches)) {
+ [, $string, $format] = $matches;
+ }
+
+ // TODO this should possibly remove empty tokens
+ $args = explode('/', $string);
+ $action = array_shift($args);
+ return [$action, $args, $format];
+ }
+
+ /**
+ * Return the default action and arguments
+ *
+ * @return array containing the action, an array of args and the format
+ */
+ public function default_action_and_args()
+ {
+ return ['index', [], null];
+ }
+
+ /**
+ * Maps the action to an actual method name.
+ *
+ * @param string $action
+ * @return array the mapped method name
+ */
+ public function map_action($action)
+ {
+ return [&$this, $action . '_action'];
+ }
+
+ /**
+ * Callback function being called before an action is executed. If this
+ * function does not return FALSE, the action will be called, otherwise
+ * an error will be generated and processing will be aborted. If this function
+ * already #rendered or #redirected, further processing of the action is
+ * withheld.
+ *
+ * @param string $action Name of the action to perform.
+ * @param array $args An array of arguments to the action.
+ * @return bool|void
+ */
+ public function before_filter(&$action, &$args)
+ {
+ }
+
+ /**
+ * Callback function being called after an action is executed.
+ *
+ * @param string $action Name of the action to perform.
+ * @param array $args An array of arguments to the action.
+ * @return void
+ */
+ public function after_filter($action, $args)
+ {
+ }
+
+ /**
+ * @param string $action
+ * @param array $args
+ * @return void
+ * @throws UnknownAction
+ */
+ public function does_not_understand($action, $args)
+ {
+ throw new Exceptions\UnknownAction("No action responded to '$action'.");
+ }
+
+ /**
+ * @param string $to
+ *
+ * @return void
+ * @throws DoubleRenderError
+ */
+ public function redirect($to)
+ {
+ if ($this->performed) {
+ throw new Exceptions\DoubleRenderError();
+ }
+
+ $this->performed = true;
+
+ # get uri; keep absolute URIs
+ $url = preg_match('#^(/|\w+://)#', $to)
+ ? $to
+ : $this->url_for($to);
+
+ $this->response->add_header('Location', $url)->set_status(302);
+ }
+
+ /**
+ * Renders the given text as the body of the response.
+ *
+ * @param string $text the text to be rendered
+ * @return void
+ * @throws DoubleRenderError
+ */
+ public function render_text($text = ' ')
+ {
+ if ($this->performed) {
+ throw new Exceptions\DoubleRenderError();
+ }
+
+ $this->performed = true;
+
+ $this->response->set_body($text);
+ }
+
+ /**
+ * Renders the empty string as the response's body.
+ *
+ * @return void
+ * @throws DoubleRenderError
+ */
+ public function render_nothing()
+ {
+ $this->render_text('');
+ }
+
+ /**
+ * Renders the template of the given action as the response's body.
+ *
+ * @param string $action the action
+ * @return void
+ */
+ public function render_action($action)
+ {
+ $this->render_template(
+ $this->get_default_template($action),
+ $this->layout
+ );
+ }
+
+ public function get_default_template($action)
+ {
+ $controller_name = Inflector::underscore(
+ substr(static::class, 0, -10)
+ );
+ return $controller_name . '/' . $action;
+ }
+
+ /**
+ * Renders a template using an optional layout template.
+ *
+ * @param Template|string $template_name a flexi template
+ * @param Template|string|null $layout a flexi template which is used as layout
+ *
+ * @return void
+ * @throws DoubleRenderError
+ * @throws TemplateNotFoundException
+ */
+ public function render_template($template_name, $layout = null)
+ {
+ $factory = $this->get_template_factory();
+ $template = $factory->open($template_name);
+
+ $template->set_attributes($this->get_assigned_variables());
+
+ if (isset($layout)) {
+ $template->set_layout($layout);
+ }
+
+ $this->render_text($template->render());
+ }
+
+ /**
+ * Create and return a template factory for this controller.
+ *
+ * @return Factory
+ */
+ public function get_template_factory()
+ {
+ return new Factory($this->dispatcher->trails_root . '/views/');
+ }
+
+ /**
+ * This method returns all the set instance variables to be used as attributes
+ * for a template. This controller is returned too as value for
+ * key 'controller'.
+ *
+ * @return array an associative array of variables for the template
+ */
+ public function get_assigned_variables()
+ {
+ $assigns = [];
+ $protected = get_class_vars(static::class);
+
+ foreach (get_object_vars($this) as $var => $value) {
+ if (!array_key_exists($var, $protected)) {
+ $assigns[$var] =& $this->$var;
+ }
+ }
+
+ $assigns['controller'] = $this;
+
+ return $assigns;
+ }
+
+ /**
+ * Sets the layout to be used by this controller per default.
+ *
+ * @param Template|string|null $layout a flexi template to be used as layout
+ * @return void
+ */
+ public function set_layout($layout)
+ {
+ $this->layout = $layout;
+ }
+
+ /**
+ * Returns a URL to a specified route to your Trails application.
+ *
+ * Example:
+ * Your Trails application is located at 'http://example.com/dispatch.php'.
+ * So your dispatcher's trails_uri is set to 'http://example.com/dispatch.php'
+ * If you want the URL to your 'wiki' controller with action 'show' and
+ * parameter 'page' you should send:
+ *
+ * $url = $controller->url_for('wiki/show', 'page');
+ *
+ * $url should then contain 'http://example.com/dispatch.php/wiki/show/page'.
+ *
+ * The first parameter is a string containing the controller and optionally an
+ * action:
+ *
+ * - "{controller}/{action}"
+ * - "path/to/controller/action"
+ * - "controller"
+ *
+ * This "controller/action" string is not url encoded. You may provide
+ * additional parameter which will be urlencoded and concatenated with
+ * slashes:
+ *
+ * $controller->url_for('wiki/show', 'page');
+ * -> 'wiki/show/page'
+ *
+ * $controller->url_for('wiki/show', 'page', 'one and a half');
+ * -> 'wiki/show/page/one+and+a+half'
+ *
+ * @param string $to a string containing a controller and optionally an action
+ * @return string a URL to this route
+ */
+ public function url_for($to/*, ...*/)
+ {
+ # urlencode all but the first argument
+ $args = func_get_args();
+ $args = array_map('urlencode', $args);
+ $args[0] = $to;
+
+ return $this->dispatcher->trails_uri . '/' . implode('/', $args);
+ }
+
+ /**
+ * @param int $status
+ * @return void
+ */
+ public function set_status($status, $reason_phrase = null)
+ {
+ $this->response->set_status($status, $reason_phrase);
+ }
+
+ /**
+ * Sets the content type of the controller's response.
+ *
+ * @param string $type the content type
+ * @return void
+ */
+ public function set_content_type($type)
+ {
+ $this->response->add_header('Content-Type', $type);
+ }
+
+ /**
+ * Exception handler called when the performance of an action raises an
+ * exception.
+ *
+ * @param \Throwable $exception the thrown exception
+ * @return Response a response object
+ */
+ public function rescue($exception)
+ {
+ return $this->dispatcher->trails_error($exception);
+ }
+
+ public function respond_to($ext)
+ {
+ return $this->format === $ext;
+ }
+}
diff --git a/lib/trails/Dispatcher.php b/lib/trails/Dispatcher.php
new file mode 100644
index 0000000..efa90c7
--- /dev/null
+++ b/lib/trails/Dispatcher.php
@@ -0,0 +1,262 @@
+<?php
+namespace Trails;
+
+use Trails\Exceptions\MissingFile;
+use Trails\Exceptions\RoutingError;
+use Trails\Exceptions\UnknownController;
+
+/**
+ * The Dispatcher is used to map an incoming HTTP request to a Controller
+ * producing a response which is then rendered. To initialize an instance of
+ * class Dispatcher you have to give three configuration settings:
+ *
+ * trails_root - the absolute file path to a directory containing the
+ * applications controllers, views etc.
+ * trails_uri - the URI to which routes to mapped Controller/Actions
+ * are appended
+ * default_controller - the route to a controller, that is used if no
+ * controller is given, that is the route is equal to '/'
+ *
+ * After instantiation of a dispatcher you have to call method #dispatch with
+ * the request uri to be mapped to a controller/action pair.
+ *
+ * @package trails
+ *
+ * @author mlunzena
+ * @copyright (c) Authors
+ * @version $Id: trails.php 7001 2008-04-04 11:20:27Z mlunzena $
+ */
+class Dispatcher
+{
+ # TODO (mlunzena) Konfiguration muss anders geschehen
+
+ /**
+ * This is the absolute file path to the trails application directory.
+ */
+ public string $trails_root;
+
+ /**
+ * This is the URI to which routes to controller/actions are appended.
+ */
+ public string $trails_uri;
+
+ /**
+ * This variable contains the route to the default controller.
+ */
+ public string $default_controller;
+
+ /**
+ * @param string $trails_root absolute file path to a directory containing the
+ * applications controllers, views etc.
+ * @param string $trails_uri the URI to which routes to mapped Controller/Actions
+ * are appended
+ * @param string $default_controller the route to a controller, that is used if no
+ * controller is given, that is the route is equal to '/'
+ */
+ public function __construct(
+ string $trails_root,
+ string $trails_uri,
+ string $default_controller
+ ) {
+ $this->trails_root = $trails_root;
+ $this->trails_uri = $trails_uri;
+ $this->default_controller = $default_controller;
+ }
+
+ /**
+ * Maps a string to a response which is then rendered.
+ *
+ * @param string $uri The requested URI.
+ */
+ public function dispatch($uri)
+ {
+ # E_USER_ERROR|E_USER_WARNING|E_USER_NOTICE|E_RECOVERABLE_ERROR = 5888
+ $old_handler = set_error_handler([$this, 'error_handler'], 5888);
+
+ ob_start();
+ $level = ob_get_level();
+
+ $this->map_uri_to_response($this->clean_request_uri((string) $uri))->output();
+
+ while (ob_get_level() >= $level) {
+ ob_end_flush();
+ }
+
+ if (isset($old_handler)) {
+ set_error_handler($old_handler);
+ }
+ }
+
+ /**
+ * Maps an URI to a response by figuring out first what controller to
+ * instantiate, then delegating the unconsumed part of the URI to the
+ * controller who returns an appropriate response object or throws an
+ * Exception.
+ *
+ * @param string $uri the URI string
+ * @return Response a response object
+ */
+ public function map_uri_to_response($uri)
+ {
+ try {
+ [$controller_path, $unconsumed] = '' === $uri ? $this->default_route() : $this->parse($uri);
+
+ $controller = $this->load_controller($controller_path);
+
+ $response = $controller->perform($unconsumed);
+ } catch (Exception $e) {
+ $response = isset($controller) ? $controller->rescue($e) : $this->trails_error($e);
+ }
+
+ return $response;
+ }
+
+ /**
+ * @return array an array containing the default controller and an
+ * empty unconsumed route
+ * @throws MissingFile
+ */
+ public function default_route()
+ {
+ if (!$this->file_exists($this->default_controller . '.php')) {
+ throw new Exceptions\MissingFile(
+ "Default controller '{$this->default_controller}' not found'"
+ );
+ }
+ return [$this->default_controller, ''];
+ }
+
+ public function trails_error($exception)
+ {
+ ob_clean();
+
+ # show details for local requests
+ $detailed = @$_SERVER['REMOTE_ADDR'] === '127.0.0.1';
+
+ $body = sprintf('<html><head><title>Trails Error</title></head>' .
+ '<body><h1>%s</h1><pre>%s</pre></body></html>',
+ htmlentities($exception->__toString()),
+ $detailed
+ ? htmlentities($exception->getTraceAsString())
+ : '');
+
+ if ($exception instanceof Exception) {
+ $response = new Response(
+ $body,
+ $exception->getHeaders(),
+ $exception->getCode(),
+ $exception->getMessage()
+ );
+ } else {
+ $response = new Response(
+ $body,
+ [],
+ 500,
+ $exception->getMessage()
+ );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Clean up URI string by removing the query part and leading slashes.
+ *
+ * @param string $uri an URI string
+ * @return string the cleaned string
+ */
+ public function clean_request_uri($uri)
+ {
+ $pos = strpos($uri, '?');
+ if ($pos !== false) {
+ $uri = substr($uri, 0, $pos);
+ }
+ return ltrim($uri, '/');
+ }
+
+ /**
+ * @param string $unconsumed
+ * @param string $controller
+ * @return array
+ * @throws RoutingError
+ */
+ public function parse($unconsumed, $controller = null)
+ {
+ [$head, $tail] = $this->split_on_first_slash($unconsumed);
+
+ if (!preg_match('/^\w+$/', $head)) {
+ throw new RoutingError("No route matches '$head'");
+ }
+
+ $controller = (isset($controller) ? $controller . '/' : '') . $head;
+
+ if ($this->file_exists($controller . '.php')) {
+ return [$controller, $tail];
+ }
+
+ if ($this->file_exists($controller)) {
+ return $this->parse($tail, $controller);
+ }
+
+ throw new RoutingError("No route matches '$head'");
+ }
+
+ /**
+ * @param string $str
+ * @return array
+ */
+ public function split_on_first_slash($str)
+ {
+ preg_match(":([^/]*)(/+)?(.*):", $str, $matches);
+ return [$matches[1], $matches[3]];
+ }
+
+ /**
+ * @param string $path
+ * @return bool
+ */
+ public function file_exists($path)
+ {
+ return file_exists("{$this->trails_root}/controllers/$path");
+ }
+
+ /**
+ * Loads the controller file for a given controller path and return an
+ * instance of that controller. If an error occures, an exception will be
+ * thrown.
+ *
+ * @param string $controller the relative controller path
+ * @return Controller an instance of that controller
+ * @throws UnknownController
+ */
+ public function load_controller($controller)
+ {
+ require_once "{$this->trails_root}/controllers/{$controller}.php";
+ $class = Inflector::camelize($controller) . 'Controller';
+ if (!class_exists($class)) {
+ throw new UnknownController("Controller missing: '$class'");
+ }
+ return new $class($this);
+ }
+
+ /**
+ * This method transforms E_USER_* and E_RECOVERABLE_ERROR to
+ * Exceptions.
+ *
+ * @param integer $errno the level of the error raised
+ * @param string $string the error message
+ * @param string $file the filename that the error was raised in
+ * @param integer $line the line number the error was raised at
+ *
+ * @return bool
+ * @throws Exception
+ *
+ */
+ public function error_handler($errno, $string, $file, $line)
+ {
+ if (!(5888 & $errno)) {
+ return false;
+ }
+ throw new Exception(500, $string);
+ }
+}
diff --git a/lib/trails/Exception.php b/lib/trails/Exception.php
new file mode 100644
index 0000000..fd92b35
--- /dev/null
+++ b/lib/trails/Exception.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Trails;
+
+/**
+ * @author mlunzena
+ * @copyright (c) Authors
+ * @version $Id: trails.php 7001 2008-04-04 11:20:27Z mlunzena $
+ */
+class Exception extends \Exception
+{
+ protected array $headers;
+
+ /**
+ * @param int $status the status code to be set in the response
+ * @param string|null $reason a human readable presentation of the status code
+ * @param array $headers a hash of additional headers to be set in the response
+ */
+ public function __construct(int $status = 500, string $reason = null, array $headers = [])
+ {
+ parent::__construct(
+ $reason ?? Response::get_reason($status),
+ $status
+ );
+
+ $this->setHeaders($headers);
+ }
+
+ public function setHeaders(array $headers): void
+ {
+ $this->headers = $headers;
+ }
+
+ public function getHeaders(): array
+ {
+ return $this->headers;
+ }
+
+ public function __toString(): string
+ {
+ return "{$this->code} {$this->message}";
+ }
+}
diff --git a/lib/trails/Exceptions/DoubleRenderError.php b/lib/trails/Exceptions/DoubleRenderError.php
new file mode 100644
index 0000000..c2dfe9e
--- /dev/null
+++ b/lib/trails/Exceptions/DoubleRenderError.php
@@ -0,0 +1,13 @@
+<?php
+namespace Trails\Exceptions;
+
+class DoubleRenderError extends \Trails\Exception
+{
+ public function __construct()
+ {
+ $message = 'Render and/or redirect were called multiple times in this ';
+ $message .= 'action. Please note that you may only call render OR ';
+ $message .= 'redirect, and at most once per action.';
+ parent::__construct(500, $message);
+ }
+}
diff --git a/lib/trails/Exceptions/MissingFile.php b/lib/trails/Exceptions/MissingFile.php
new file mode 100644
index 0000000..59e8480
--- /dev/null
+++ b/lib/trails/Exceptions/MissingFile.php
@@ -0,0 +1,10 @@
+<?php
+namespace Trails\Exceptions;
+
+class MissingFile extends \Trails\Exception
+{
+ public function __construct(string $message)
+ {
+ parent::__construct(500, $message);
+ }
+}
diff --git a/lib/trails/Exceptions/RoutingError.php b/lib/trails/Exceptions/RoutingError.php
new file mode 100644
index 0000000..e62459e
--- /dev/null
+++ b/lib/trails/Exceptions/RoutingError.php
@@ -0,0 +1,10 @@
+<?php
+namespace Trails\Exceptions;
+
+class RoutingError extends \Trails\Exception
+{
+ public function __construct(string $message)
+ {
+ parent::__construct(400, $message);
+ }
+}
diff --git a/lib/trails/Exceptions/SessionRequiredException.php b/lib/trails/Exceptions/SessionRequiredException.php
new file mode 100644
index 0000000..b77a344
--- /dev/null
+++ b/lib/trails/Exceptions/SessionRequiredException.php
@@ -0,0 +1,11 @@
+<?php
+namespace Trails\Exceptions;
+
+class SessionRequiredException extends \Trails\Exception
+{
+ public function __construct()
+ {
+ $message = 'Tried to access a non existing session.';
+ parent::__construct(500, $message);
+ }
+}
diff --git a/lib/trails/Exceptions/UnknownAction.php b/lib/trails/Exceptions/UnknownAction.php
new file mode 100644
index 0000000..f749a51
--- /dev/null
+++ b/lib/trails/Exceptions/UnknownAction.php
@@ -0,0 +1,10 @@
+<?php
+namespace Trails\Exceptions;
+
+class UnknownAction extends \Trails\Exception
+{
+ public function __construct(string $message)
+ {
+ parent::__construct(404, $message);
+ }
+}
diff --git a/lib/trails/Exceptions/UnknownController.php b/lib/trails/Exceptions/UnknownController.php
new file mode 100644
index 0000000..420c238
--- /dev/null
+++ b/lib/trails/Exceptions/UnknownController.php
@@ -0,0 +1,10 @@
+<?php
+namespace Trails\Exceptions;
+
+class UnknownController extends \Trails\Exception
+{
+ public function __construct(string $message)
+ {
+ parent::__construct(404, $message);
+ }
+}
diff --git a/lib/trails/Flash.php b/lib/trails/Flash.php
new file mode 100644
index 0000000..eede7af
--- /dev/null
+++ b/lib/trails/Flash.php
@@ -0,0 +1,184 @@
+<?php
+
+namespace Trails;
+
+use Trails\Exceptions\SessionRequiredException;
+
+/**
+ * The flash provides a way to pass temporary objects between actions.
+ * Anything you place in the flash will be exposed to the very next action and
+ * then cleared out. This is a great way of doing notices and alerts, such as
+ * a create action that sets
+ * <tt>$flash->set('notice', "Successfully created")</tt>
+ * before redirecting to a display action that can then expose the flash to its
+ * template.
+ *
+ * @package trails
+ *
+ * @author mlunzena
+ * @copyright (c) Authors
+ * @version $Id: trails.php 7001 2008-04-04 11:20:27Z mlunzena $
+ */
+final class Flash implements \ArrayAccess
+{
+ private array $flash = [];
+ private array $used = [];
+
+ /**
+ * @return self
+ * @throws SessionRequiredException
+ */
+ public static function instance()
+ {
+ if (!isset($_SESSION)) {
+ throw new Exceptions\SessionRequiredException();
+ }
+
+ if (!isset($_SESSION[self::class])) {
+ $_SESSION[self::class] = new self();
+ }
+ return $_SESSION[self::class];
+ }
+
+ public function offsetExists($offset): bool
+ {
+ return isset($this->flash[$offset]);
+ }
+
+ public function offsetGet($offset): mixed
+ {
+ return $this->get($offset);
+ }
+
+ public function offsetSet($offset, $value): void
+ {
+ $this->set($offset, $value);
+ }
+
+ public function offsetUnset($offset): void
+ {
+ unset($this->flash[$offset], $this->used[$offset]);
+ }
+
+ /**
+ * Used internally by the <tt>keep</tt> and <tt>discard</tt> methods
+ * use() # marks the entire flash as used
+ * use('msg') # marks the "msg" entry as used
+ * use(null, false) # marks the entire flash as unused
+ * # (keeps it around for one more action)
+ * use('msg', false) # marks the "msg" entry as unused
+ * # (keeps it around for one more action)
+ *
+ */
+ private function use(?string $key = null, bool $isUsed = true): void
+ {
+ if ($key) {
+ $this->used[$key] = $isUsed;
+ } else {
+ foreach (array_keys($this->used) as $key) {
+ $this->use($key, $isUsed);
+ }
+ }
+ }
+
+ /**
+ * Marks the entire flash or a single flash entry to be discarded by the end
+ * of the current action.
+ *
+ * $flash->discard() # discards entire flash
+ * # (it'll still be available for the
+ * # current action)
+ * $flash->discard('warning') # discard the "warning" entry
+ * # (it'll still be available for the
+ * # current action)
+ */
+ public function discard(?string $key = null): void
+ {
+ $this->use($key);
+ }
+
+ /**
+ * Returns the value to the specified key.
+ *
+ * @return mixed the key's value.
+ */
+ public function &get(string $key): mixed
+ {
+ $return = null;
+ if (isset($this->flash[$key])) {
+ $return =& $this->flash[$key];
+ }
+ return $return;
+ }
+
+ /**
+ * Keeps either the entire current flash or a specific flash entry available
+ * for the next action:
+ *
+ * $flash->keep() # keeps the entire flash
+ * $flash->keep('notice') # keeps only the "notice" entry, the rest of
+ * # the flash is discarded
+ */
+ public function keep(?string $key = null): void
+ {
+ $this->use($key, false);
+ }
+
+ /**
+ * Sets a key's value.
+ */
+ public function set(string $key, mixed $value): void
+ {
+ $this->keep($key);
+ $this->flash[$key] = $value;
+ }
+
+ /**
+ * Sets a key's value by reference.
+ */
+ public function set_ref(string $key, mixed &$value): void
+ {
+ $this->keep($key);
+ $this->flash[$key] =& $valze;
+ }
+
+ /**
+ * Removes all used values
+ */
+ public function sweep(): void
+ {
+ foreach (array_keys($this->flash) as $k) {
+ if ($this->used[$k]) {
+ unset($this->flash[$k], $this->used[$k]);
+ } else {
+ $this->use($k);
+ }
+ }
+ }
+
+ public function __toString()
+ {
+ $values = [];
+ foreach ($this->flash as $key => $value) {
+ $values[] = sprintf(
+ "'%s': [%s, '%s']",
+ $key,
+ var_export($value, true),
+ !empty($this->used[$key]) ? 'used' : 'unused'
+ );
+ }
+ return '{' . implode(', ', $values) . '}'. "\n";
+ }
+
+ public function __sleep(): array
+ {
+ $this->sweep();
+ return ['flash', 'used'];
+ }
+
+
+ public function __wakeUp()
+ {
+ $this->discard();
+ }
+}
diff --git a/lib/trails/Inflector.php b/lib/trails/Inflector.php
new file mode 100644
index 0000000..bbc6b58
--- /dev/null
+++ b/lib/trails/Inflector.php
@@ -0,0 +1,44 @@
+<?php
+namespace Trails;
+
+/**
+ * The Inflector class is a namespace for inflections methods.
+ *
+ * @package trails
+ *
+ * @author mlunzena
+ * @copyright (c) Authors
+ * @version $Id: trails.php 7001 2008-04-04 11:20:27Z mlunzena $
+ */
+class Inflector
+{
+ /**
+ * Returns a camelized string from a lower case and underscored string by
+ * replacing slash with underscore and upper-casing each letter preceded
+ * by an underscore.
+ *
+ * @param string $word String to camelize.
+ * @return string Camelized string.
+ */
+ public static function camelize(string $word): string
+ {
+ $parts = explode('/', $word);
+ foreach ($parts as $key => $part) {
+ $parts[$key] = strtopascalcase($part);
+ }
+ return implode('_', $parts);
+ }
+
+ /**
+ * @param string $word
+ * @return string
+ */
+ public static function underscore(string $word): string
+ {
+ $parts = explode('_', $word);
+ foreach ($parts as $key => $part) {
+ $parts[$key] = strtosnakecase($part);
+ }
+ return implode('/', $parts);
+ }
+}
diff --git a/lib/trails/Response.php b/lib/trails/Response.php
new file mode 100644
index 0000000..b3f14d8
--- /dev/null
+++ b/lib/trails/Response.php
@@ -0,0 +1,185 @@
+<?php
+namespace Trails;
+
+/**
+ * This class represents a response returned by a controller that was asked to
+ * perform for a given request. A Response contains the body, status and
+ * additional headers which can be renderer back to the client.
+ *
+ * @package trails
+ *
+ * @author mlunzena
+ * @copyright (c) Authors
+ * @version $Id: trails.php 7001 2008-04-04 11:20:27Z mlunzena $
+ */
+class Response
+{
+ public $body = '';
+ public $status;
+ public $reason;
+ public $headers = [];
+
+ /**
+ * Constructor.
+ *
+ * @param string $body the body of the response defaulting to ''
+ * @param array $headers an array of additional headers defaulting to an
+ * empty array
+ * @param int|null $status the status code of the response defaulting to a
+ * regular 200
+ * @param string|null $reason the descriptional reason for a status code defaulting to
+ * the standard reason phrases defined in RFC 2616
+ */
+ public function __construct(
+ string $body = '',
+ array $headers = [],
+ ?int $status = null,
+ ?string $reason = null
+ ) {
+ $this->set_body($body);
+
+ $this->headers = $headers;
+
+ if (isset($status)) {
+ $this->set_status($status, $reason);
+ }
+ }
+
+ /**
+ * Sets the body of the response.
+ *
+ * @param string $body the body
+ * @return static this response object. Useful for cascading method calls.
+ */
+ public function set_body($body)
+ {
+ $this->body = $body;
+ return $this;
+ }
+
+ /**
+ * Sets the status code and an optional custom reason. If none is given, the
+ * standard reason phrase as of RFC 2616 is used.
+ *
+ * @param integer $status the status code
+ * @param string $reason the custom reason, defaulting to the one given in RFC 2616
+ * @return static this response object. Useful for cascading method calls.
+ */
+ public function set_status($status, $reason = null)
+ {
+ $this->status = $status;
+ $this->reason = $reason ?? self::get_reason($status);
+ return $this;
+ }
+
+ /**
+ * Returns the reason phrase of this response according to RFC2616.
+ *
+ * @param int $status the response's status
+ * @return string the reason phrase for this response's status
+ */
+ public static function get_reason($status)
+ {
+ return match ($status) {
+ 100 => 'Continue',
+ 101 => 'Switching Protocols',
+
+ 200 => 'OK',
+ 201 => 'Created',
+ 202 => 'Accepted',
+ 203 => 'Non-Authoritative Information',
+ 204 => 'No Content',
+ 205 => 'Reset Content',
+ 206 => 'Partial Content',
+
+ 300 => 'Multiple Choices',
+ 301 => 'Moved Permanently',
+ 302 => 'Found',
+ 303 => 'See Other',
+ 304 => 'Not Modified',
+ 305 => 'Use Proxy',
+ 306 => '(Unused)',
+ 307 => 'Temporary Redirect',
+
+ 400 => 'Bad Request',
+ 401 => 'Unauthorized',
+ 402 => 'Payment Required',
+ 403 => 'Forbidden',
+ 404 => 'Not Found',
+ 405 => 'Method Not Allowed',
+ 406 => 'Not Acceptable',
+ 407 => 'Proxy Authentication Required',
+ 408 => 'Request Timeout',
+ 409 => 'Conflict',
+ 410 => 'Gone',
+ 411 => 'Length Required',
+ 412 => 'Precondition Failed',
+ 413 => 'Request Entity Too Large',
+ 414 => 'Request-URI Too Long',
+ 415 => 'Unsupported Media Type',
+ 416 => 'Requested Range Not Satisfiable',
+ 417 => 'Expectation Failed',
+
+ 500 => 'Internal Server Error',
+ 501 => 'Not Implemented',
+ 502 => 'Bad Gateway',
+ 503 => 'Service Unavailable',
+ 504 => 'Gateway Timeout',
+ 505 => 'HTTP Version Not Supported',
+
+ default => '',
+ };
+ }
+
+ /**
+ * Adds an additional header to the response.
+ *
+ * @param string $key the left hand key part
+ * @param string $value the right hand value part
+ * @return static this response object. Useful for cascading method calls.
+ */
+ public function add_header($key, $value)
+ {
+ $this->headers[$key] = $value;
+ return $this;
+ }
+
+ /**
+ * Outputs this response to the client using "echo" and "header".
+ * @return void
+ */
+ public function output()
+ {
+ if (isset($this->status)) {
+ $this->send_header(
+ "HTTP/1.1 {$this->status} {$this->reason}",
+ true,
+ $this->status
+ );
+ }
+
+ foreach ($this->headers as $k => $v) {
+ $this->send_header("{$k}: {$v}");
+ }
+
+ echo $this->body;
+ }
+
+ /**
+ * Internally used function to actually send headers
+ *
+ * @param string $header the HTTP header
+ * @param bool $replace optional; TRUE if previously sent header should be
+ * replaced - FALSE otherwise (default)
+ * @param integer $status optional; the HTTP response code
+ * @return void
+ */
+ public function send_header($header, $replace = false, $status = null)
+ {
+ if (isset($status)) {
+ header($header, $replace, $status);
+ } else {
+ header($header, $replace);
+ }
+ }
+}