Files

309 lines
14 KiB
PHP

<?php
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
namespace CloudObjects\PhpMAE;
use InvalidArgumentException;
use Psr\Http\Message\RequestInterface, Psr\Http\Message\ResponseInterface;
use Psr\Container\ContainerInterface;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Factory\ServerRequestCreatorFactory;
use Slim\ResponseEmitter;
use Slim\Psr7\Response;
use Tuupola\Middleware\CorsMiddleware;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Psr7\ServerRequest;
use ML\IRI\IRI;
use ML\JsonLD\Node, ML\JsonLD\JsonLD;
use CloudObjects\Utilities\RDF\Arc2JsonLdConverter;
use CloudObjects\SDK\NodeReader, CloudObjects\SDK\ObjectRetriever;
use CloudObjects\SDK\JSON\SchemaValidator;
use CloudObjects\PhpMAE\Exceptions\PhpMAEException;
use CloudObjects\PhpMAE\ObjectRetrieverPool;
class Router {
const STATIC_CACHE_TTL = 60;
private $engine;
private $objectRetriever;
private $container;
public function __construct(Engine $engine, ObjectRetriever $objectRetriever,
ContainerInterface $container) {
$this->engine = $engine;
$this->objectRetriever = $objectRetriever;
$this->container = $container;
}
private function getRequestHeaders(RequestInterface $request) {
$output = [];
foreach (array_keys($request->getHeaders()) as $key) {
$key = strtolower($key);
if (substr($key, 0, 9) == 'c-phpmae-' || $key == 'host') {
// do not forward "Host" header or custom phpMAE headers
continue;
}
$output[$key] = $request->getHeaderLine($key);
}
return $output;
}
private function configure(App $app, Node $object) {
$reader = new NodeReader([
'prefixes' => [
'co' => 'coid://cloudobjects.io/',
'phpmae' => 'coid://phpmae.dev/',
'wa' => 'coid://webapis.co-n.net/',
'agws' => 'coid://aauid.net/',
]
]);
if ($reader->hasProperty($object, 'phpmae:enableCORSWithOrigin')) {
// Add CORS support to router
$origins = $reader->getAllValuesString($object, 'phpmae:enableCORSWithOrigin');
$app->add(new CorsMiddleware([
'origin' => $origins,
'headers.allow' => [ 'Content-Type' ],
'credentials' => true,
'cache' => 600
]));
}
$routes = array_merge(
$reader->getAllValuesNode($object, 'phpmae:hasRoute'),
$reader->getAllValuesNode($object, 'agws:hasMethod')
);
$router = $this;
$engine = $this->engine;
$retriever = $this->getObjectRetriever(new IRI($object->getId()));
foreach ($routes as $r) {
if (!$reader->hasProperty($r, 'wa:hasVerb') || !$reader->hasProperty($r, 'wa:hasPath'))
throw new PhpMAEException("Incomplete route configuration! Routes must have wa:hasVerb and wa:hasPath.");
$app->map([ $reader->getFirstValueString($r, 'wa:hasVerb') ],
$reader->getFirstValueString($r, 'wa:hasPath'),
function(RequestInterface $request, ResponseInterface $response, $args) use ($r, $reader, $router, $engine, $retriever, $object) {
if ($reader->hasProperty($r, 'phpmae:runsClass')) {
try {
$params = [];
if ($reader->hasProperty($r, 'wa:hasJSONBodyWithSchema')) {
// Validate request against schema
$schemaValidator = new SchemaValidator($retriever);
$schemaValidator->validateAgainstCOID($request->getParsedBody(),
$reader->getFirstValueIRI($r, 'wa:hasJSONBodyWithSchema'));
// Then, map to single argument
$params = [ $request->getParsedBody() ];
} elseif (is_array($args) && count($args) > 0) {
// Use path arguments as params
$params = $args;
} elseif (is_array($request->getParsedBody())) {
// Use body as params
$params = $request->getParsedBody();
} elseif (is_array($request->getQueryParams())) {
// Use query as params
$params = $request->getQueryParams();
}
} catch (InvalidArgumentException $e) {
$errorResponse = (new Response(400))
->withHeader('Content-Type', 'application/json');
$errorResponse->getBody()->write(json_encode([
'error' => 'InputValidationFailed',
'message' => $e->getMessage()
]));
return $errorResponse;
}
// Add static parameters from Router
foreach ($reader->getAllValuesNode($r, 'phpmae:includeParameterWithDefault') as $p) {
if (!$reader->hasProperty($p, 'wa:hasKey') || !$reader->hasProperty($p, 'wa:hasDefaultValue')) {
// Ignore incomplete parameter
continue;
}
if ($reader->hasType($p, 'wa:HeaderParameter')) {
// Add custom header
$request = $request->withHeader(
'HTTP_'.strtoupper($reader->getFirstValueString($p, 'wa:hasKey')),
$reader->getFirstValueString($p, 'wa:hasDefaultValue')
);
}
}
// Route is mapped to a class
$engine->loadRunClass($reader->getFirstValueIRI($r, 'phpmae:runsClass'), $request);
if ($reader->hasProperty($r, 'phpmae:runsMethod')) {
// Calls to specific methods must be rewritten
// Path, request and query parameters are merged and used as method input
$rpcBody = json_encode([
'jsonrpc' => '2.0',
'method' => $reader->getFirstValueString($r, 'phpmae:runsMethod'),
'params' => $params,
'id' => 'R'
]);
$innerRequest = new ServerRequest('POST', $request->getUri(),
$request->getHeaders(), $rpcBody);
$innerResponse = $engine->handle($innerRequest);
if ($innerResponse->hasHeader('C-PhpMae-Passthru'))
return $innerResponse->withoutHeader('C-PhpMae-Passthru');
$rpcResponse = json_decode($innerResponse->getBody(), true);
if (isset($rpcResponse['result'])) {
return $engine->generateResponse($rpcResponse['result']);
} else {
$errResponse = (new Response(500))
->withHeader('Content-Type', 'application/json');
$errResponse->getBody()->write(json_encode($rpcResponse));
return $errResponse;
}
} else {
// Generic class execution (invokable)
return $engine->handle($request, $params);
}
} elseif ($reader->hasProperty($r, 'phpmae:redirectsToURL')) {
// Route to redirect to a specific external URL
return (new Response(302))
->withHeader('Location', $reader->getFirstValueString($r, 'phpmae:redirectsToURL'));
} elseif ($reader->hasProperty($r, 'phpmae:redirectsToBaseURL')) {
// Route to redirect to an external base URL
$baseUrl = $reader->getFirstValueIRI($r, 'phpmae:redirectsToBaseURL');
$uri = $request->getUri();
$targetUrl = (string)$baseUrl->resolve(substr($uri->getPath(), 1) . ($uri->getQuery() != '' ? '?'.$uri->getQuery() : ''));
return (new Response(302))->withHeader('Location', $targetUrl);
} elseif ($reader->hasProperty($r, 'phpmae:proxiesRequestsToBaseURL')) {
// Route to proxy requests to an external URL
$client = new Client([
'base_uri' => $reader->getFirstValueString($r, 'phpmae:proxiesRequestsToBaseURL')
]);
$uri = $request->getUri();
try {
return $client->request($request->getMethod(),
$uri->getPath() . ($uri->getQuery() != '' ? '?'.$uri->getQuery() : ''), [
'headers' => $router->getRequestHeaders($request),
'body' => $request->getBody()
]);
} catch (BadResponseException $e) {
return $e->getResponse();
}
} elseif ($reader->hasProperty($r, 'phpmae:servesStaticFileAttachment')) {
// Rule to serve an attached file
$etag = '"'.md5($reader->getFirstValueString($object, 'co:isAtRevision')
.'+'.$reader->getFirstValueString($r, 'phpmae:servesStaticFileAttachment')).'"';
if ($request->hasHeader('If-None-Match') && strpos($request->getHeaderLine('If-None-Match'), $etag) > -1) {
// Can use cached version
return (new Response(304))
->withHeader('ETag', $etag)
->withHeader('Cache-Control', 'max-age='.self::STATIC_CACHE_TTL.', public');
}
$attachmentContent = $retriever->getAttachment(new IRI($object->getId()),
$reader->getFirstValueString($r, 'phpmae:servesStaticFileAttachment'));
if (!isset($attachmentContent)) {
$notFoundResponse = new Response(501);
$notFoundResponse->getBody()->write("Requested static file attachment cannot be found.");
return $notFoundResponse;
}
return $engine->generateResponse($attachmentContent)
->withHeader('ETag', $etag)
->withHeader('Cache-Control', 'max-age='.self::STATIC_CACHE_TTL.', public');
} else {
// Route has no implementation
$notImplResponse = new Response(501);
$notImplResponse->getBody()->write("Route implementation not available or no access granted.");
return $notImplResponse;
}
});
}
}
private function getObjectRetriever(IRI $coid) {
return $this->container->get(ObjectRetrieverPool::class)
->getObjectRetriever($coid->getHost());
}
private function getRouter(IRI $coid) {
return $this->getObjectRetriever($coid)
->get($coid);
}
/**
* Setup and run router.
*/
public function run() {
try {
$modes = explode('|', $this->container->get('mode'));
$fallback = false;
$app = AppFactory::create();
$serverRequest = ServerRequestCreatorFactory::create()->createServerRequestFromGlobals();
$router = null;
foreach ($modes as $m) {
switch ($m) {
case 'router:vhost':
if ($router == null) {
$uri = $serverRequest->getUri();
if ($uri->getHost() == 'localhost' || filter_var($uri->getHost(), FILTER_VALIDATE_IP) !== false) break;
$namespace = $this->objectRetriever->get('coid://'.$uri->getHost());
if (isset($namespace) && $routerCoid = $namespace->getProperty('coid://phpmae.dev/hasRouter'))
$router = $this->getRouter(new IRI($routerCoid->getId()));
}
break;
case 'router:header':
if ($router == null) {
if ($serverRequest->hasHeader('C-PhpMae-Router-COID'))
$router = $this->getRouter(new IRI($serverRequest->getHeaderLine('C-PhpMae-Router-COID')));
}
break;
case 'default':
$fallback = true;
break;
default:
if (substr($m, 0, 14) == 'router:coid://')
$router = $this->getRouter(new IRI(substr($m, 7)));
else
throw new PhpMAEException("Unsupported mode!");
}
}
if (isset($router)) {
$this->configure($app, $router);
$response = $app->handle($serverRequest);
} elseif ($fallback) {
$this->container->get(Engine::class)
->run();
exit;
} else {
throw new PhpMAEException("No router found!");
}
} catch (PhpMAEException $e) {
// Create plain-text error response
$response = (new Response(500))->withHeader('Content-Type', 'text/plain');
$response->getBody()->write($e->getMessage());
}
(new ResponseEmitter())->emit($response);
}
}