Initial rewrite of REST class; needs more testing, but should be functional
- improves #53 - improves #66
This commit is contained in:
parent
890f9b07d4
commit
3fa2d38f31
14 changed files with 171 additions and 89 deletions
|
@ -1,6 +1,11 @@
|
|||
Version 0.3.0 (2018-??-??)
|
||||
==========================
|
||||
|
||||
Bug fixes:
|
||||
- Correctly handle %-encoded request URLs
|
||||
- Overhaul protocol detection to fix various subtle bugs
|
||||
- Overhaul HTTP response handling for more consistent results
|
||||
|
||||
Changes:
|
||||
- Make date strings in TTRSS explicitly UTC
|
||||
|
||||
|
|
|
@ -9,6 +9,14 @@ When upgrading between any two versions of The Arsse, the following are usually
|
|||
- If installing from source, update dependencies with `composer install -o --no-dev`
|
||||
|
||||
|
||||
Upgrading from 0.2.1 to 0.3.0
|
||||
=============================
|
||||
|
||||
- The following Composer dependencies have been added:
|
||||
- zendframework/zend-diactoros
|
||||
- psr/http-message
|
||||
|
||||
|
||||
Upgrading from 0.2.0 to 0.2.1
|
||||
=============================
|
||||
|
||||
|
|
|
@ -24,5 +24,7 @@ if (\PHP_SAPI=="cli") {
|
|||
Arsse::$conf->importFile(BASE."config.php");
|
||||
}
|
||||
// handle Web requests
|
||||
(new REST)->dispatch()->output();
|
||||
$emitter = new \Zend\Diactoros\Response\SapiEmitter();
|
||||
$response = (new REST)->dispatch();
|
||||
$emitter->emit($response);
|
||||
}
|
||||
|
|
97
lib/REST.php
97
lib/REST.php
|
@ -6,8 +6,14 @@
|
|||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Zend\Diactoros\ServerRequest;
|
||||
use Zend\Diactoros\ServerRequestFactory;
|
||||
use Zend\Diactoros\Response\EmptyResponse;
|
||||
|
||||
class REST {
|
||||
protected $apis = [
|
||||
const API_LIST = [
|
||||
// NextCloud News version enumerator
|
||||
'ncn' => [
|
||||
'match' => '/index.php/apps/news/api',
|
||||
|
@ -21,7 +27,7 @@ class REST {
|
|||
'class' => REST\NextCloudNews\V1_2::class,
|
||||
],
|
||||
'ttrss_api' => [ // Tiny Tiny RSS https://git.tt-rss.org/git/tt-rss/wiki/ApiReference
|
||||
'match' => '/tt-rss/api/',
|
||||
'match' => '/tt-rss/api',
|
||||
'strip' => '/tt-rss/api',
|
||||
'class' => REST\TinyTinyRSS\API::class,
|
||||
],
|
||||
|
@ -44,40 +50,93 @@ class REST {
|
|||
// NewsBlur http://www.newsblur.com/api
|
||||
// Feedly https://developer.feedly.com/
|
||||
];
|
||||
protected $apis = [];
|
||||
|
||||
public function __construct() {
|
||||
public function __construct(array $apis = null) {
|
||||
$this->apis = $apis ?? self::API_LIST;
|
||||
}
|
||||
|
||||
public function dispatch(REST\Request $req = null): \Psr\Http\Message\ResponseInterface {
|
||||
if ($req===null) {
|
||||
$req = new REST\Request();
|
||||
}
|
||||
$api = $this->apiMatch($req->url, $this->apis);
|
||||
$req->url = substr($req->url, strlen($this->apis[$api]['strip']));
|
||||
$req->refreshURL();
|
||||
$class = $this->apis[$api]['class'];
|
||||
$drv = new $class();
|
||||
if ($req->head) {
|
||||
$res = $drv->dispatch($req);
|
||||
$res->head = true;
|
||||
return $res;
|
||||
public function dispatch(ServerRequestInterface $req = null): ResponseInterface {
|
||||
// create a request object if not provided
|
||||
$req = $req ?? ServerRequestFactory::fromGlobals();
|
||||
// find the API to handle
|
||||
list ($api, $target, $class) = $this->apiMatch($req->getRequestTarget(), $this->apis);
|
||||
// modify the request to have a stripped target
|
||||
$req = $req->withRequestTarget($target);
|
||||
// generate a response
|
||||
$res = $this->handOffRequest($class, $req);
|
||||
// modify the response so that it has all the required metadata
|
||||
$res = $this->normalizeResponse($res, $req);
|
||||
}
|
||||
|
||||
protected function handOffRequest(string $className, ServerRequestInterface $req): ResponseInterface {
|
||||
// instantiate the API handler
|
||||
$drv = new $className();
|
||||
// perform the request and return the response
|
||||
if ($req->getMethod()=="HEAD") {
|
||||
// if the request is a HEAD request, we act exactly as if it were a GET request, and simply remove the response body later
|
||||
return $drv->dispatch($req->withMethod("GET"));
|
||||
} else {
|
||||
return $drv->dispatch($req);
|
||||
}
|
||||
}
|
||||
|
||||
public function apiMatch(string $url, array $map): string {
|
||||
public function apiMatch(string $url): array {
|
||||
$map = $this->apis;
|
||||
// sort the API list so the longest URL prefixes come first
|
||||
uasort($map, function ($a, $b) {
|
||||
return (strlen($a['match']) <=> strlen($b['match'])) * -1;
|
||||
});
|
||||
// normalize the target URL
|
||||
$url = REST\Target::normalize($url);
|
||||
// find a match
|
||||
foreach ($map as $id => $api) {
|
||||
// first try a simple substring match
|
||||
if (strpos($url, $api['match'])===0) {
|
||||
return $id;
|
||||
// if it matches, perform a more rigorous match and then strip off any defined prefix
|
||||
$pattern = "<^".preg_quote($api['match'])."([/\?#]|$)>";
|
||||
if ($url==$api['match'] || in_array(substr($api['match'], -1, 1), ["/", "?", "#"]) || preg_match($pattern, $url)) {
|
||||
$target = substr($url, strlen($api['strip']));
|
||||
} else {
|
||||
// if the match fails we are not able to handle the request
|
||||
throw new REST\Exception501();
|
||||
}
|
||||
// return the API name, stripped URL, and API class name
|
||||
return [$id, $target, $api['class']];
|
||||
}
|
||||
}
|
||||
// or throw an exception otherwise
|
||||
// or throw an exception otherwise
|
||||
throw new REST\Exception501();
|
||||
}
|
||||
|
||||
public function normalizeResponse(ResponseInterface $res, RequestInterface $req = null): ResponseInterface {
|
||||
// set or clear the Content-Length header field
|
||||
$body = $res->getBody();
|
||||
$bodySize = $body->getSize();
|
||||
if ($bodySize || $res->getStatusCode()==200) {
|
||||
// if there is a message body or the response is 200, make sure Content-Length is included
|
||||
$res = $res->withHeader("Content-Length", (string) $bodySize);
|
||||
} else {
|
||||
// for empty responses of other statuses, omit it
|
||||
$res = $res->withoutHeader("Content-Length");
|
||||
}
|
||||
// if the response is to a HEAD request, the body should be omitted
|
||||
if ($req->getMethod()=="HEAD") {
|
||||
$res = new EmptyResponse($res->getStatusCode(), $res->getHeaders());
|
||||
}
|
||||
// if an Allow header field is present, normalize it
|
||||
if ($res->hasHeader("Allow")) {
|
||||
$methods = preg_split("<\s+,\s+>", strtoupper($res->getHeaderLine()));
|
||||
// if GET is allowed, HEAD should be allowed as well
|
||||
if (in_array("GET", $methods) && !in_array("HEAD", $methods)) {
|
||||
$methods[] = "HEAD";
|
||||
}
|
||||
// OPTIONS requests are always allowed by our handlers
|
||||
if (!in_array("OPTIONS", $methods)) {
|
||||
$methods[] = "OPTIONS";
|
||||
}
|
||||
$res = $res->withHeader("Allow", implode(", ", $methods));
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\REST\NextCloudNews;
|
||||
namespace JKingWeb\Arsse\REST;
|
||||
|
||||
class Exception404 extends \Exception {
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\REST\NextCloudNews;
|
||||
namespace JKingWeb\Arsse\REST;
|
||||
|
||||
class Exception405 extends \Exception {
|
||||
}
|
10
lib/REST/Exception501.php
Normal file
10
lib/REST/Exception501.php
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
/** @license MIT
|
||||
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\REST;
|
||||
|
||||
class Exception501 extends \Exception {
|
||||
}
|
|
@ -16,6 +16,8 @@ use JKingWeb\Arsse\AbstractException;
|
|||
use JKingWeb\Arsse\Db\ExceptionInput;
|
||||
use JKingWeb\Arsse\Feed\Exception as FeedException;
|
||||
use JKingWeb\Arsse\REST\Target;
|
||||
use JKingWeb\Arsse\REST\Exception404;
|
||||
use JKingWeb\Arsse\REST\Exception405;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Zend\Diactoros\Response\JsonResponse as Response;
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
<?php
|
||||
/** @license MIT
|
||||
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\REST;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
||||
class Response {
|
||||
const T_JSON = "application/json";
|
||||
const T_XML = "application/xml";
|
||||
const T_TEXT = "text/plain";
|
||||
|
||||
public $head = false;
|
||||
public $code;
|
||||
public $payload;
|
||||
public $type;
|
||||
public $fields;
|
||||
|
||||
|
||||
public function __construct(int $code, $payload = null, string $type = self::T_JSON, array $extraFields = []) {
|
||||
$this->code = $code;
|
||||
$this->payload = $payload;
|
||||
$this->type = $type;
|
||||
$this->fields = $extraFields;
|
||||
}
|
||||
|
||||
public function output() {
|
||||
if (!headers_sent()) {
|
||||
foreach ($this->fields as $field) {
|
||||
header($field);
|
||||
}
|
||||
$body = "";
|
||||
if (!is_null($this->payload)) {
|
||||
switch ($this->type) {
|
||||
case self::T_JSON:
|
||||
$body = (string) json_encode($this->payload, \JSON_PRETTY_PRINT);
|
||||
break;
|
||||
default:
|
||||
$body = (string) $this->payload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (strlen($body)) {
|
||||
header("Content-Type: ".$this->type);
|
||||
header("Content-Length: ".strlen($body));
|
||||
} elseif ($this->code==200) {
|
||||
$this->code = 204;
|
||||
}
|
||||
try {
|
||||
$statusText = Arsse::$lang->msg("HTTP.Status.".$this->code);
|
||||
} catch (\JKingWeb\Arsse\Lang\Exception $e) {
|
||||
$statusText = "";
|
||||
}
|
||||
header("Status: ".$this->code." ".$statusText);
|
||||
if (!$this->head) {
|
||||
echo $body;
|
||||
}
|
||||
} else {
|
||||
throw new REST\Exception("headersSent");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,4 +9,5 @@ namespace JKingWeb\Arsse;
|
|||
const NS_BASE = __NAMESPACE__."\\";
|
||||
define(NS_BASE."BASE", dirname(__DIR__).DIRECTORY_SEPARATOR);
|
||||
ini_set("memory_limit", "-1");
|
||||
error_reporting(\E_ALL);
|
||||
require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php";
|
||||
|
|
50
tests/cases/REST/TestREST.php
Normal file
50
tests/cases/REST/TestREST.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
/** @license MIT
|
||||
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\TestCase\REST;
|
||||
|
||||
use JKingWeb\Arsse\REST;
|
||||
use JKingWeb\Arsse\REST\Exception501;
|
||||
|
||||
/** @covers \JKingWeb\Arsse\REST */
|
||||
class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
|
||||
|
||||
/** @dataProvider provideApiMatchData */
|
||||
public function testMatchAUrlToAnApi($apiList, string $input, array $exp) {
|
||||
$r = new REST($apiList);
|
||||
try {
|
||||
$out = $r->apiMatch($input);
|
||||
} catch (Exception501 $e) {
|
||||
$out = [];
|
||||
}
|
||||
$this->assertEquals($exp, $out);
|
||||
}
|
||||
|
||||
public function provideApiMatchData() {
|
||||
$real = null;
|
||||
$fake = [
|
||||
'unstripped' => ['match' => "/full/url", 'strip' => "", 'class' => "UnstrippedProtocol"],
|
||||
];
|
||||
return [
|
||||
[$real, "/index.php/apps/news/api/v1-2/feeds", ["ncn_v1-2", "/feeds", \JKingWeb\Arsse\REST\NextCloudNews\V1_2::class]],
|
||||
[$real, "/index.php/apps/news/api/v1-2", ["ncn", "/v1-2", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
|
||||
[$real, "/index.php/apps/news/api/", ["ncn", "/", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
|
||||
[$real, "/index%2Ephp/apps/news/api/", ["ncn", "/", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
|
||||
[$real, "/index.php/apps/news/", []],
|
||||
[$real, "/index!php/apps/news/api/", []],
|
||||
[$real, "/tt-rss/api/index.php", ["ttrss_api", "/index.php", \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]],
|
||||
[$real, "/tt-rss/api", ["ttrss_api", "", \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]],
|
||||
[$real, "/tt-rss/API", []],
|
||||
[$real, "/tt-rss/api-bogus", []],
|
||||
[$real, "/tt-rss/api bogus", []],
|
||||
[$real, "/tt-rss/feed-icons/", ["ttrss_icon", "", \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]],
|
||||
[$real, "/tt-rss/feed-icons/", ["ttrss_icon", "", \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]],
|
||||
[$real, "/tt-rss/feed-icons", []],
|
||||
[$fake, "/full/url/", ["unstripped", "/full/url/", "UnstrippedProtocol"]],
|
||||
[$fake, "/full/url-not", []],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ namespace JKingWeb\Arsse\TestCase\REST;
|
|||
|
||||
use JKingWeb\Arsse\REST\Target;
|
||||
|
||||
/** @covers \JKingWeb\Arsse\REST\Target<extended> */
|
||||
/** @covers \JKingWeb\Arsse\REST\Target */
|
||||
class TestTarget extends \JKingWeb\Arsse\Test\AbstractTest {
|
||||
|
||||
/** @dataProvider provideTargetUrls */
|
||||
|
|
|
@ -15,6 +15,14 @@ use Zend\Diactoros\Response\EmptyResponse;
|
|||
|
||||
/** @coversNothing */
|
||||
abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
|
||||
public function setUp() {
|
||||
$this->clearData();
|
||||
}
|
||||
|
||||
public function tearDown() {
|
||||
$this->clearData();
|
||||
}
|
||||
|
||||
public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") {
|
||||
if (func_num_args()) {
|
||||
$class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type;
|
||||
|
@ -34,10 +42,11 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
|
|||
|
||||
protected function assertResponse(ResponseInterface $exp, ResponseInterface $act, string $text = null) {
|
||||
$this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text);
|
||||
$this->assertInstanceOf(get_class($exp), $act);
|
||||
if ($exp instanceof JsonResponse) {
|
||||
$this->assertEquals($exp->getPayload(), $act->getPayload(), $text);
|
||||
$this->assertSame($exp->getPayload(), $act->getPayload(), $text);
|
||||
} else {
|
||||
$this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text);
|
||||
}
|
||||
$this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text);
|
||||
}
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
</testsuite>
|
||||
<testsuite name="REST">
|
||||
<file>cases/REST/TestTarget.php</file>
|
||||
<file>cases/REST/TestREST.php</file>
|
||||
</testsuite>
|
||||
<testsuite name="NCNv1">
|
||||
<file>cases/REST/NextCloudNews/TestVersions.php</file>
|
||||
|
|
Loading…
Reference in a new issue