Browse Source

Initial rewrite of REST class; needs more testing, but should be functional

- improves #53
- improves #66
J. King 11 months ago
parent
commit
3fa2d38f31

+ 5
- 0
CHANGELOG View File

@@ -1,6 +1,11 @@
1 1
 Version 0.3.0 (2018-??-??)
2 2
 ==========================
3 3
 
4
+Bug fixes:
5
+- Correctly handle %-encoded request URLs
6
+- Overhaul protocol detection to fix various subtle bugs
7
+- Overhaul HTTP response handling for more consistent results
8
+
4 9
 Changes:
5 10
 - Make date strings in TTRSS explicitly UTC
6 11
 

+ 8
- 0
UPGRADING View File

@@ -9,6 +9,14 @@ When upgrading between any two versions of The Arsse, the following are usually
9 9
 - If installing from source, update dependencies with `composer install -o --no-dev`
10 10
 
11 11
 
12
+Upgrading from 0.2.1 to 0.3.0
13
+=============================
14
+
15
+- The following Composer dependencies have been added:
16
+ - zendframework/zend-diactoros
17
+ - psr/http-message
18
+
19
+
12 20
 Upgrading from 0.2.0 to 0.2.1
13 21
 =============================
14 22
 

+ 3
- 1
arsse.php View File

@@ -24,5 +24,7 @@ if (\PHP_SAPI=="cli") {
24 24
         Arsse::$conf->importFile(BASE."config.php");
25 25
     }
26 26
     // handle Web requests
27
-    (new REST)->dispatch()->output();
27
+    $emitter = new \Zend\Diactoros\Response\SapiEmitter();
28
+    $response = (new REST)->dispatch();
29
+    $emitter->emit($response);
28 30
 }

+ 78
- 19
lib/REST.php View File

@@ -6,8 +6,14 @@
6 6
 declare(strict_types=1);
7 7
 namespace JKingWeb\Arsse;
8 8
 
9
+use Psr\Http\Message\RequestInterface;
10
+use Psr\Http\Message\ResponseInterface;
11
+use Zend\Diactoros\ServerRequest;
12
+use Zend\Diactoros\ServerRequestFactory;
13
+use Zend\Diactoros\Response\EmptyResponse;
14
+
9 15
 class REST {
10
-    protected $apis = [
16
+    const API_LIST = [
11 17
         // NextCloud News version enumerator
12 18
         'ncn' => [
13 19
             'match' => '/index.php/apps/news/api',
@@ -21,7 +27,7 @@ class REST {
21 27
             'class' => REST\NextCloudNews\V1_2::class,
22 28
         ],
23 29
         'ttrss_api' => [ // Tiny Tiny RSS  https://git.tt-rss.org/git/tt-rss/wiki/ApiReference
24
-            'match' => '/tt-rss/api/',
30
+            'match' => '/tt-rss/api',
25 31
             'strip' => '/tt-rss/api',
26 32
             'class' => REST\TinyTinyRSS\API::class,
27 33
         ],
@@ -44,40 +50,93 @@ class REST {
44 50
         // NewsBlur             http://www.newsblur.com/api
45 51
         // Feedly               https://developer.feedly.com/
46 52
     ];
53
+    protected $apis = [];
47 54
 
48
-    public function __construct() {
55
+    public function __construct(array $apis = null) {
56
+        $this->apis = $apis ?? self::API_LIST;
49 57
     }
50 58
 
51
-    public function dispatch(REST\Request $req = null): \Psr\Http\Message\ResponseInterface {
52
-        if ($req===null) {
53
-            $req = new REST\Request();
54
-        }
55
-        $api = $this->apiMatch($req->url, $this->apis);
56
-        $req->url = substr($req->url, strlen($this->apis[$api]['strip']));
57
-        $req->refreshURL();
58
-        $class = $this->apis[$api]['class'];
59
-        $drv = new $class();
60
-        if ($req->head) {
61
-            $res =  $drv->dispatch($req);
62
-            $res->head = true;
63
-            return $res;
59
+    public function dispatch(ServerRequestInterface $req = null): ResponseInterface {
60
+        // create a request object if not provided
61
+        $req = $req ?? ServerRequestFactory::fromGlobals();
62
+        // find the API to handle 
63
+        list ($api, $target, $class) = $this->apiMatch($req->getRequestTarget(), $this->apis);
64
+        // modify the request to have a stripped target
65
+        $req = $req->withRequestTarget($target);
66
+        // generate a response
67
+        $res = $this->handOffRequest($class, $req);
68
+        // modify the response so that it has all the required metadata
69
+        $res = $this->normalizeResponse($res, $req);
70
+    }
71
+
72
+    protected function handOffRequest(string $className, ServerRequestInterface $req): ResponseInterface {
73
+        // instantiate the API handler
74
+        $drv = new $className();
75
+        // perform the request and return the response
76
+        if ($req->getMethod()=="HEAD") {
77
+            // if the request is a HEAD request, we act exactly as if it were a GET request, and simply remove the response body later
78
+            return $drv->dispatch($req->withMethod("GET"));
64 79
         } else {
65 80
             return $drv->dispatch($req);
66 81
         }
67 82
     }
68 83
 
69
-    public function apiMatch(string $url, array $map): string {
84
+    public function apiMatch(string $url): array {
85
+        $map = $this->apis;
70 86
         // sort the API list so the longest URL prefixes come first
71 87
         uasort($map, function ($a, $b) {
72 88
             return (strlen($a['match']) <=> strlen($b['match'])) * -1;
73 89
         });
90
+        // normalize the target URL
91
+        $url = REST\Target::normalize($url);
74 92
         // find a match
75 93
         foreach ($map as $id => $api) {
94
+            // first try a simple substring match
76 95
             if (strpos($url, $api['match'])===0) {
77
-                return $id;
96
+                // if it matches, perform a more rigorous match and then strip off any defined prefix
97
+                $pattern = "<^".preg_quote($api['match'])."([/\?#]|$)>";
98
+                if ($url==$api['match'] || in_array(substr($api['match'], -1, 1), ["/", "?", "#"]) || preg_match($pattern, $url)) {
99
+                    $target = substr($url, strlen($api['strip']));
100
+                } else {
101
+                    // if the match fails we are not able to handle the request
102
+                    throw new REST\Exception501();
103
+                }
104
+                // return the API name, stripped URL, and API class name
105
+                return [$id, $target, $api['class']];
78 106
             }
79 107
         }
80
-        // or throw an exception otherwise
108
+        // or throw an exception otherwise 
81 109
         throw new REST\Exception501();
82 110
     }
111
+
112
+    public function normalizeResponse(ResponseInterface $res, RequestInterface $req = null): ResponseInterface {
113
+        // set or clear the Content-Length header field
114
+        $body = $res->getBody();
115
+        $bodySize = $body->getSize();
116
+        if ($bodySize || $res->getStatusCode()==200) {
117
+            // if there is a message body or the response is 200, make sure Content-Length is included
118
+            $res = $res->withHeader("Content-Length", (string) $bodySize);
119
+        } else {
120
+            // for empty responses of other statuses, omit it
121
+            $res = $res->withoutHeader("Content-Length");
122
+        }
123
+        // if the response is to a HEAD request, the body should be omitted
124
+        if ($req->getMethod()=="HEAD") {
125
+            $res = new EmptyResponse($res->getStatusCode(), $res->getHeaders());
126
+        }
127
+        // if an Allow header field is present, normalize it
128
+        if ($res->hasHeader("Allow")) {
129
+            $methods = preg_split("<\s+,\s+>", strtoupper($res->getHeaderLine()));
130
+            // if GET is allowed, HEAD should be allowed as well
131
+            if (in_array("GET", $methods) && !in_array("HEAD", $methods)) {
132
+                $methods[] = "HEAD";
133
+            }
134
+            // OPTIONS requests are always allowed by our handlers
135
+            if (!in_array("OPTIONS", $methods)) {
136
+                $methods[] = "OPTIONS";
137
+            }
138
+            $res = $res->withHeader("Allow", implode(", ", $methods));
139
+        }
140
+        return $res;
141
+    }
83 142
 }

lib/REST/NextCloudNews/Exception404.php → lib/REST/Exception404.php View File

@@ -4,7 +4,7 @@
4 4
  * See LICENSE and AUTHORS files for details */
5 5
 
6 6
 declare(strict_types=1);
7
-namespace JKingWeb\Arsse\REST\NextCloudNews;
7
+namespace JKingWeb\Arsse\REST;
8 8
 
9 9
 class Exception404 extends \Exception {
10 10
 }

lib/REST/NextCloudNews/Exception405.php → lib/REST/Exception405.php View File

@@ -4,7 +4,7 @@
4 4
  * See LICENSE and AUTHORS files for details */
5 5
 
6 6
 declare(strict_types=1);
7
-namespace JKingWeb\Arsse\REST\NextCloudNews;
7
+namespace JKingWeb\Arsse\REST;
8 8
 
9 9
 class Exception405 extends \Exception {
10 10
 }

+ 10
- 0
lib/REST/Exception501.php View File

@@ -0,0 +1,10 @@
1
+<?php
2
+/** @license MIT
3
+ * Copyright 2017 J. King, Dustin Wilson et al.
4
+ * See LICENSE and AUTHORS files for details */
5
+
6
+declare(strict_types=1);
7
+namespace JKingWeb\Arsse\REST;
8
+
9
+class Exception501 extends \Exception {
10
+}

+ 2
- 0
lib/REST/NextCloudNews/V1_2.php View File

@@ -16,6 +16,8 @@ use JKingWeb\Arsse\AbstractException;
16 16
 use JKingWeb\Arsse\Db\ExceptionInput;
17 17
 use JKingWeb\Arsse\Feed\Exception as FeedException;
18 18
 use JKingWeb\Arsse\REST\Target;
19
+use JKingWeb\Arsse\REST\Exception404;
20
+use JKingWeb\Arsse\REST\Exception405;
19 21
 use Psr\Http\Message\ServerRequestInterface;
20 22
 use Psr\Http\Message\ResponseInterface;
21 23
 use Zend\Diactoros\Response\JsonResponse as Response;

+ 0
- 65
lib/REST/Response.php View File

@@ -1,65 +0,0 @@
1
-<?php
2
-/** @license MIT
3
- * Copyright 2017 J. King, Dustin Wilson et al.
4
- * See LICENSE and AUTHORS files for details */
5
-
6
-declare(strict_types=1);
7
-namespace JKingWeb\Arsse\REST;
8
-
9
-use JKingWeb\Arsse\Arsse;
10
-
11
-class Response {
12
-    const T_JSON = "application/json";
13
-    const T_XML  = "application/xml";
14
-    const T_TEXT = "text/plain";
15
-
16
-    public $head = false;
17
-    public $code;
18
-    public $payload;
19
-    public $type;
20
-    public $fields;
21
-
22
-
23
-    public function __construct(int $code, $payload = null, string $type = self::T_JSON, array $extraFields = []) {
24
-        $this->code    = $code;
25
-        $this->payload = $payload;
26
-        $this->type    = $type;
27
-        $this->fields  = $extraFields;
28
-    }
29
-
30
-    public function output() {
31
-        if (!headers_sent()) {
32
-            foreach ($this->fields as $field) {
33
-                header($field);
34
-            }
35
-            $body = "";
36
-            if (!is_null($this->payload)) {
37
-                switch ($this->type) {
38
-                    case self::T_JSON:
39
-                        $body = (string) json_encode($this->payload, \JSON_PRETTY_PRINT);
40
-                        break;
41
-                    default:
42
-                        $body = (string) $this->payload;
43
-                        break;
44
-                }
45
-            }
46
-            if (strlen($body)) {
47
-                header("Content-Type: ".$this->type);
48
-                header("Content-Length: ".strlen($body));
49
-            } elseif ($this->code==200) {
50
-                $this->code = 204;
51
-            }
52
-            try {
53
-                $statusText = Arsse::$lang->msg("HTTP.Status.".$this->code);
54
-            } catch (\JKingWeb\Arsse\Lang\Exception $e) {
55
-                $statusText = "";
56
-            }
57
-            header("Status: ".$this->code." ".$statusText);
58
-            if (!$this->head) {
59
-                echo $body;
60
-            }
61
-        } else {
62
-            throw new REST\Exception("headersSent");
63
-        }
64
-    }
65
-}

+ 1
- 0
tests/bootstrap.php View File

@@ -9,4 +9,5 @@ namespace JKingWeb\Arsse;
9 9
 const NS_BASE = __NAMESPACE__."\\";
10 10
 define(NS_BASE."BASE", dirname(__DIR__).DIRECTORY_SEPARATOR);
11 11
 ini_set("memory_limit", "-1");
12
+error_reporting(\E_ALL);
12 13
 require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php";

+ 50
- 0
tests/cases/REST/TestREST.php View File

@@ -0,0 +1,50 @@
1
+<?php
2
+/** @license MIT
3
+ * Copyright 2017 J. King, Dustin Wilson et al.
4
+ * See LICENSE and AUTHORS files for details */
5
+
6
+declare(strict_types=1);
7
+namespace JKingWeb\Arsse\TestCase\REST;
8
+
9
+use JKingWeb\Arsse\REST;
10
+use JKingWeb\Arsse\REST\Exception501;
11
+
12
+/** @covers \JKingWeb\Arsse\REST */
13
+class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
14
+
15
+    /** @dataProvider provideApiMatchData */
16
+    public function testMatchAUrlToAnApi($apiList, string $input, array $exp) {
17
+        $r = new REST($apiList);
18
+        try {
19
+            $out = $r->apiMatch($input);
20
+        } catch (Exception501 $e) {
21
+            $out = [];
22
+        }
23
+        $this->assertEquals($exp, $out);
24
+    }
25
+
26
+    public function provideApiMatchData() {
27
+        $real = null;
28
+        $fake = [
29
+            'unstripped' => ['match' => "/full/url", 'strip' => "", 'class' => "UnstrippedProtocol"],
30
+        ];
31
+        return [
32
+            [$real, "/index.php/apps/news/api/v1-2/feeds", ["ncn_v1-2",    "/feeds",     \JKingWeb\Arsse\REST\NextCloudNews\V1_2::class]],
33
+            [$real, "/index.php/apps/news/api/v1-2",       ["ncn",         "/v1-2",      \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
34
+            [$real, "/index.php/apps/news/api/",           ["ncn",         "/",          \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
35
+            [$real, "/index%2Ephp/apps/news/api/",         ["ncn",         "/",          \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
36
+            [$real, "/index.php/apps/news/",               []],
37
+            [$real, "/index!php/apps/news/api/",           []],
38
+            [$real, "/tt-rss/api/index.php",               ["ttrss_api",   "/index.php", \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]],
39
+            [$real, "/tt-rss/api",                         ["ttrss_api",   "",           \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]],
40
+            [$real, "/tt-rss/API",                         []],
41
+            [$real, "/tt-rss/api-bogus",                   []],
42
+            [$real, "/tt-rss/api bogus",                   []],
43
+            [$real, "/tt-rss/feed-icons/",                 ["ttrss_icon",  "",           \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]],
44
+            [$real, "/tt-rss/feed-icons/",                 ["ttrss_icon",  "",           \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]],
45
+            [$real, "/tt-rss/feed-icons",                  []],
46
+            [$fake, "/full/url/",                          ["unstripped",  "/full/url/", "UnstrippedProtocol"]],
47
+            [$fake, "/full/url-not",                       []],
48
+        ];
49
+    }
50
+}

+ 1
- 1
tests/cases/REST/TestTarget.php View File

@@ -8,7 +8,7 @@ namespace JKingWeb\Arsse\TestCase\REST;
8 8
 
9 9
 use JKingWeb\Arsse\REST\Target;
10 10
 
11
-/** @covers \JKingWeb\Arsse\REST\Target<extended> */
11
+/** @covers \JKingWeb\Arsse\REST\Target */
12 12
 class TestTarget extends \JKingWeb\Arsse\Test\AbstractTest {
13 13
 
14 14
     /** @dataProvider provideTargetUrls */

+ 10
- 1
tests/lib/AbstractTest.php View File

@@ -15,6 +15,14 @@ use Zend\Diactoros\Response\EmptyResponse;
15 15
 
16 16
 /** @coversNothing */
17 17
 abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
18
+    public function setUp() {
19
+        $this->clearData();
20
+    }
21
+
22
+    public function tearDown() {
23
+        $this->clearData();
24
+    }
25
+
18 26
     public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") {
19 27
         if (func_num_args()) {
20 28
             $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type;
@@ -34,10 +42,11 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
34 42
 
35 43
     protected function assertResponse(ResponseInterface $exp, ResponseInterface $act, string $text = null) {
36 44
         $this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text);
37
-        $this->assertInstanceOf(get_class($exp), $act);
38 45
         if ($exp instanceof JsonResponse) {
39 46
             $this->assertEquals($exp->getPayload(), $act->getPayload(), $text);
40 47
             $this->assertSame($exp->getPayload(), $act->getPayload(), $text);
48
+        } else {
49
+            $this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text);
41 50
         }
42 51
         $this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text);
43 52
     }

+ 1
- 0
tests/phpunit.xml View File

@@ -67,6 +67,7 @@
67 67
     </testsuite>
68 68
     <testsuite name="REST">
69 69
         <file>cases/REST/TestTarget.php</file>
70
+        <file>cases/REST/TestREST.php</file>
70 71
     </testsuite>
71 72
     <testsuite name="NCNv1">
72 73
         <file>cases/REST/NextCloudNews/TestVersions.php</file>

Loading…
Cancel
Save