DefaultRequest.php
1 <?php
2 /**
3  * wCMF - wemove Content Management Framework
4  * Copyright (C) 2005-2020 wemove digital solutions GmbH
5  *
6  * Licensed under the terms of the MIT License.
7  *
8  * See the LICENSE file distributed with this work for
9  * additional information.
10  */
12 
20 use wcmf\lib\util\StringUtil;
21 
22 /**
23  * Default Request implementation.
24  *
25  * @author ingo herwig <ingo@wemove.com>
26  */
28 
29  private $response = null;
30  private $responseFormat = null;
31  private $method = null;
32 
33  private static $requestDataFixed = false;
34  private static $logger = null;
35  private static $errorsDefined = false;
36 
37  /**
38  * Constructor
39  * @param $formatter
40  */
41  public function __construct(Formatter $formatter) {
42  parent::__construct($formatter);
43  if (self::$logger == null) {
44  self::$logger = LogManager::getLogger(__CLASS__);
45  }
46  self::defineErrors();
47 
48  // set headers and method
49  foreach (self::getAllHeaders() as $name => $value) {
50  $this->setHeader($name, $value);
51  }
52  $this->method = isset($_SERVER['REQUEST_METHOD']) ?
53  strtoupper($_SERVER['REQUEST_METHOD']) : '';
54 
55  // fix get request parameters
56  if (!self::$requestDataFixed) {
57  if (isset($_SERVER['QUERY_STRING'])) {
58  self::fix($_GET, $_SERVER['QUERY_STRING']);
59  }
60  if (isset($_SERVER['COOKIES'])) {
61  self::fix($_COOKIE, $_SERVER['COOKIES']);
62  }
63  $requestBody = file_get_contents("php://input");
64  if ($this->getFormat() != 'json') {
65  self::fix($_POST, $requestBody);
66  }
67  else {
68  $_POST = json_decode($requestBody, true);
69  }
70  self::$requestDataFixed = true;
71  }
72  }
73 
74  /**
75  * @see Response::setResponse()
76  */
77  public function setResponse(Response $response) {
78  $this->response = $response;
79  if ($response->getRequest() !== $this) {
80  $response->setRequest($this);
81  }
82  }
83 
84  /**
85  * @see Request::getResponse()
86  */
87  public function getResponse() {
88  return $this->response;
89  }
90 
91  /**
92  * @see Request::initialize()
93  *
94  * The method tries to match the current request path against the routes
95  * defined in the configuration section 'routes' and constructs the request based on
96  * these parameters. It then adds all data contained in $_GET, $_POST, $_FILES and
97  * php://input (raw data from the request body).
98  *
99  * Examples for route definitions are:
100  * @code
101  * GET/ = action=cms
102  * GET,POST,PUT,DELETE/rest/{language}/{className} = action=restAction&collection=1
103  * GET,POST,PUT,DELETE/rest/{language}/{className}/{id|[0-9]+} = action=restAction&collection=0
104  * @endcode
105  */
106  public function initialize($controller=null, $context=null, $action=null) {
107  // get base request data from request path
108  $basePath = preg_replace('/\/?[^\/]*$/', '', $_SERVER['SCRIPT_NAME']);
109  $requestUri = preg_replace('/\?.*$/', '', $_SERVER['REQUEST_URI']);
110  $requestPath = preg_replace('/^'.StringUtil::escapeForRegex($basePath).'/', '', $requestUri);
111  $requestMethod = $this->getMethod();
112  if (self::$logger->isInfoEnabled()) {
113  self::$logger->info("Request: ".$requestMethod." ".$requestPath);
114  }
115 
116  // get all routes from the configuration that match the request path
117  $matchingRoutes = $this->getMatchingRoutes($requestPath);
118  if (self::$logger->isDebugEnabled()) {
119  self::$logger->debug("Matching routes:");
120  self::$logger->debug($matchingRoutes);
121  }
122 
123  // get client info error for logging
124  $clientInfo = [
125  'ip' => $_SERVER['REMOTE_ADDR'],
126  'agent' => $_SERVER['HTTP_USER_AGENT'],
127  'referrer' => $_SERVER['HTTP_REFERER']
128  ];
129 
130  // check if the requested route matches any configured route
131  if (sizeof($matchingRoutes) == 0) {
132  throw new ApplicationException($this, $this->getResponse(),
133  ApplicationError::get('ROUTE_NOT_FOUND', array_merge(
134  $clientInfo, ['route' => $requestPath])));
135  }
136 
137  // get the best matching route
138  $route = $this->getBestRoute($matchingRoutes);
139  if (self::$logger->isDebugEnabled()) {
140  self::$logger->debug("Best route:");
141  self::$logger->debug($route);
142  }
143 
144  // check if method is allowed
145  $allowedMethods = $route['methods'];
146  if ($allowedMethods != null && !in_array($requestMethod, $allowedMethods)) {
147  throw new ApplicationException($this, $this->getResponse(),
148  ApplicationError::get('METHOD_NOT_ALLOWED', array_merge(
149  $clientInfo, ['method' => $requestMethod, 'route' => $requestPath])));
150  }
151 
152  // get request parameters from route
153  $pathRequestData = $route['parameters'];
154 
155  // get other request data
156  $requestData = [];
157  switch ($requestMethod) {
158  case 'GET':
159  $requestData = $_GET;
160  break;
161  case 'POST':
162  case 'PUT':
163  $requestData = array_merge($_POST, $_FILES);
164  break;
165  }
166 
167  // get controller/context/action triple
168  $controller = isset($requestData['controller']) ?
169  filter_var($requestData['controller'], FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW) :
170  (isset($pathRequestData['controller']) ? $pathRequestData['controller'] : $controller);
171 
172  $context = isset($requestData['context']) ?
173  filter_var($requestData['context'], FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW) :
174  (isset($pathRequestData['context']) ? $pathRequestData['context'] : $context);
175 
176  $action = isset($requestData['action']) ?
177  filter_var($requestData['action'], FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW) :
178  (isset($pathRequestData['action']) ? $pathRequestData['action'] : $action);
179 
180  // setup request
181  $this->setSender($controller);
182  $this->setContext($context);
183  $this->setAction($action);
184  $this->setValues(array_merge($pathRequestData, $requestData));
185  }
186 
187  /**
188  * @see Request::getMethod()
189  */
190  public function getMethod() {
191  return $this->method;
192  }
193 
194  /**
195  * @see Request::setResponseFormat()
196  */
197  public function setResponseFormat($format) {
198  $this->responseFormat = $format;
199  }
200 
201  /**
202  * @see Request::getResponseFormat()
203  */
204  public function getResponseFormat() {
205  if ($this->responseFormat == null) {
206  $this->responseFormat = $this->getFormatter()->getFormatFromMimeType($this->getHeader('Accept'));
207  }
208  return $this->responseFormat;
209  }
210 
211  /**
212  * Get a string representation of the message
213  * @return The string
214  */
215  public function __toString() {
216  $str = 'method='.$this->method.', ';
217  $str .= 'responseformat='.$this->responseFormat.', ';
218  $str .= parent::__toString();
219  return $str;
220  }
221 
222  /**
223  * Get all routes from the Routes configuration section that match the given
224  * request path
225  * @param $requestPath
226  * @return Array of arrays with keys 'route', 'numPathParameters', 'numPathPatterns', 'parameters', 'methods'
227  */
228  protected function getMatchingRoutes($requestPath) {
229  $matchingRoutes = [];
230  $defaultValuePattern = '([^/]+)';
231  if (self::$logger->isDebugEnabled()) {
232  self::$logger->debug("Get mathching routes for request path: ".$requestPath);
233  }
234 
235  $config = ObjectFactory::getInstance('configuration');
236  if ($config->hasSection('routes')) {
237  $routes = $config->getSection('routes');
238  foreach ($routes as $route => $requestDef) {
239  // extract allowed http methods
240  $allowedMethods = null;
241  if (strpos($route, '/') !== false) {
242  list($methodStr, $route) = explode('/', $route, 2);
243  $allowedMethods = preg_split('/\s*,\s*/', trim($methodStr));
244  $route = '/'.trim($route);
245  }
246 
247  // extract parameters from route definition and prepare as regex pattern
248  $params = [];
249  $numPatterns = 0;
250  $routePattern = preg_replace_callback('/\/\{(.+?)\}(?=(\/|$))/', function ($match)
251  use($defaultValuePattern, &$params, &$numPatterns) {
252  // a variable may be either defined by {name} or by {name|pattern} where
253  // name is the variable's name and pattern is an optional regex pattern, the
254  // values should match
255  $paramParts = explode('|', $match[1], 2);
256  // add the variable name to the parameter list
257  $params[] = $paramParts[0];
258  // check for pattern
259  $hasPattern = sizeof($paramParts) > 1;
260  if ($hasPattern) {
261  $numPatterns++;
262  }
263  // return the value match pattern (defaults to defaultValuePattern)
264  return '/'.($hasPattern ? '('.$paramParts[1].')' : $defaultValuePattern);
265  }, $route);
266 
267  // replace wildcard character and slashes
268  $routePattern = str_replace(['*', '/'], ['.*', '\/'], $routePattern);
269 
270  // try to match the current request path
271  if (self::$logger->isDebugEnabled()) {
272  self::$logger->debug("Check path: ".$route." -> ".$routePattern);
273  }
274  $matches = [];
275  if (preg_match('/^'.$routePattern.'\/?$/', $requestPath, $matches)) {
276  if (self::$logger->isDebugEnabled()) {
277  self::$logger->debug("Match");
278  }
279  // ignore first match
280  array_shift($matches);
281 
282  // collect request variables
283  $requestParameters = [];
284 
285  // 1. path variables
286  for ($i=0, $count=sizeof($params); $i<$count; $i++) {
287  $requestParameters[$params[$i]] = isset($matches[$i]) ? $matches[$i] : null;
288  }
289 
290  // 2. parameters from route configuration (overriding path parameters)
291  $requestDefData = [];
292  parse_str($requestDef, $requestDefData);
293  $requestParameters = array_merge($requestParameters, $requestDefData);
294 
295  $routeData = [
296  'route' => $route,
297  'numPathParameters' => (preg_match('/\*/', $route) ? PHP_INT_MAX : sizeof($params)),
298  'numPathPatterns' => $numPatterns,
299  'parameters' => $requestParameters,
300  'methods' => $allowedMethods
301  ];
302 
303  // store match
304  if ($this->isMatch($routeData)) {
305  $matchingRoutes[] = $routeData;
306  }
307  else {
308  if (self::$logger->isDebugEnabled()) {
309  self::$logger->debug("Match removed by custom matching");
310  }
311  }
312  }
313  }
314  }
315  return $matchingRoutes;
316  }
317 
318  /**
319  * Check if the given route data match a route definition
320  * @note Subclasses will override this to implement custom matching. The default implementation returns true.
321  * @param $routeData Array as single match returned from DefaultRequest::getMatchingRoutes()
322  * @return Boolean
323  */
324  protected function isMatch($routeData) {
325  return true;
326  }
327 
328  /**
329  * Get the best matching route from the given list of routes
330  * @param $routes Array of route definitions as returned by getMatchingRoutes()
331  * @return Array with keys 'numPathParameters', 'parameters', 'methods'
332  */
333  protected function getBestRoute($routes) {
334  // order matching routes by number of parameters
335  $method = $this->getMethod();
336  usort($routes, function($a, $b) use ($method) {
337  $numParamsA = $a['numPathParameters'];
338  $numParamsB = $b['numPathParameters'];
339  if ($numParamsA == $numParamsB) {
340  $numPatternsA = $a['numPathPatterns'];
341  $numPatternsB = $b['numPathPatterns'];
342  if ($numPatternsA == $numPatternsB) {
343  $hasMethodA = in_array($method, $a['methods']);
344  $hasMethodB = in_array($method, $b['methods']);
345  return ($hasMethodA && !$hasMethodB) ? -1 :
346  ((!$hasMethodA && $hasMethodB) ? 1 : 0);
347  }
348  // more patterns is more specific
349  return ($numPatternsA < $numPatternsB) ? 1 : -1;
350  }
351  // less parameters is more specific
352  return ($numParamsA > $numParamsB) ? 1 : -1;
353  });
354 
355  if (self::$logger->isDebugEnabled()) {
356  self::$logger->debug("Ordered routes:");
357  self::$logger->debug($routes);
358  }
359  // return most specific route
360  return array_shift($routes);
361  }
362 
363  /**
364  * Get all http headers
365  * @return Associative array
366  */
367  private static function getAllHeaders() {
368  $headers = [];
369  if (function_exists('apache_request_headers')) {
370  foreach (apache_request_headers() as $name => $value) {
371  $headers[$name] = $value;
372  }
373  }
374  foreach ($_SERVER as $name => $value) {
375  if (substr($name, 0, 5) == 'HTTP_') {
376  $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))));
377  $headers[$name] = $value;
378  }
379  else if ($name == "CONTENT_TYPE") {
380  $headers["Content-Type"] = $value;
381  }
382  else if ($name == "CONTENT_LENGTH") {
383  $headers["Content-Length"] = $value;
384  }
385  }
386  return $headers;
387  }
388 
389  /**
390  * Fix request parameters (e.g. PHP replaces dots by underscore)
391  * Code from http://stackoverflow.com/questions/68651/get-php-to-stop-replacing-characters-in-get-or-post-arrays/18163411#18163411
392  * @param $target
393  * @param $source
394  * @param $keep
395  */
396  private static function fix(&$target, $source, $keep=false) {
397  if (!$source) {
398  return;
399  }
400  $keys = [];
401 
402  $source = preg_replace_callback(
403  '/
404  # Match at start of string or &
405  (?:^|(?<=&))
406  # Exclude cases where the period is in brackets, e.g. foo[bar.blarg]
407  [^=&\[]*
408  # Affected cases: periods and spaces
409  (?:\.|%20)
410  # Keep matching until assignment, next variable, end of string or
411  # start of an array
412  [^=&\[]*
413  /x',
414  function ($key) use (&$keys) {
415  $keys[] = $key = base64_encode(urldecode($key[0]));
416  return urlencode($key);
417  },
418  $source
419  );
420 
421  if (!$keep) {
422  $target = [];
423  }
424 
425  parse_str($source, $data);
426  foreach ($data as $key => $val) {
427  // Only unprocess encoded keys
428  if (!in_array($key, $keys)) {
429  $target[$key] = $val;
430  continue;
431  }
432 
433  $key = base64_decode($key);
434  $target[$key] = $val;
435 
436  if ($keep) {
437  // Keep a copy in the underscore key version
438  $key = preg_replace('/(\.| )/', '_', $key);
439  $target[$key] = $val;
440  }
441  }
442  }
443 
444  /**
445  * Define errors
446  */
447  private static function defineErrors() {
448  if (!self::$errorsDefined) {
449  $message = ObjectFactory::getInstance('message');
450  define('ROUTE_NOT_FOUND', serialize(['ROUTE_NOT_FOUND', ApplicationError::LEVEL_WARNING, 404,
451  $message->getText('No route matching the request path can be found.')
452  ]));
453  define('METHOD_NOT_ALLOWED', serialize(['METHOD_NOT_ALLOWED', ApplicationError::LEVEL_WARNING, 405,
454  $message->getText('The HTTP method is not allowed on the requested path.')
455  ]));
456  self::$errorsDefined = true;
457  }
458  }
459 }
460 ?>
Response holds the response values that are used as output from Controller instances.
Definition: Response.php:20
Request holds the request values that are used as input to Controller instances.
Definition: Request.php:18
AbstractControllerMessage is the base class for request/response implementations.
getBestRoute($routes)
Get the best matching route from the given list of routes.
initialize($controller=null, $context=null, $action=null)
getRequest()
Get the Request instance belonging to the response.
__toString()
Get a string representation of the message.
__construct(Formatter $formatter)
Constructor.
Formatter is the single entry point for request/response formatting.
Definition: Formatter.php:23
ApplicationError is used to signal errors that occur while processing a request.
static get($code, $data=null)
Factory method for retrieving a predefined error instance.
ApplicationException signals a general application exception.
isMatch($routeData)
Check if the given route data match a route definition.
static getLogger($name)
Get the logger with the given name.
Definition: LogManager.php:37
static getInstance($name, $dynamicConfiguration=[])
setRequest(Request $request)
Set the Request instance belonging to the response and vice versa.
LogManager is used to retrieve Logger instances.
Definition: LogManager.php:20
Default Request implementation.
ObjectFactory implements the service locator pattern by wrapping a Factory instance and providing sta...
getMatchingRoutes($requestPath)
Get all routes from the Routes configuration section that match the given request path.