From c3b00963fe22479e87998c82bc83827a54c8d972 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 22 Dec 2016 14:36:45 +0100 Subject: [PATCH] REST API: implement getLinks service See http://shaarli.github.io/api-documentation/#links-links-collection-get --- application/api/ApiUtils.php | 31 ++ application/api/controllers/Links.php | 86 ++++++ index.php | 1 + tests/api/ApiUtilsTest.php | 65 +++++ tests/api/controllers/LinksTest.php | 393 ++++++++++++++++++++++++++ 5 files changed, 576 insertions(+) create mode 100644 application/api/controllers/Links.php create mode 100644 tests/api/controllers/LinksTest.php diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php index fbb1e72..d024291 100644 --- a/application/api/ApiUtils.php +++ b/application/api/ApiUtils.php @@ -48,4 +48,35 @@ class ApiUtils throw new ApiAuthorizationException('Invalid JWT issued time'); } } + + /** + * Format a Link for the REST API. + * + * @param array $link Link data read from the datastore. + * @param string $indexUrl Shaarli's index URL (used for relative URL). + * + * @return array Link data formatted for the REST API. + */ + public static function formatLink($link, $indexUrl) + { + $out['id'] = $link['id']; + // Not an internal link + if ($link['url'][0] != '?') { + $out['url'] = $link['url']; + } else { + $out['url'] = $indexUrl . $link['url']; + } + $out['shorturl'] = $link['shorturl']; + $out['title'] = $link['title']; + $out['description'] = $link['description']; + $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY); + $out['private'] = $link['private'] == true; + $out['created'] = $link['created']->format(\DateTime::ATOM); + if (! empty($link['updated'])) { + $out['updated'] = $link['updated']->format(\DateTime::ATOM); + } else { + $out['updated'] = ''; + } + return $out; + } } diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php new file mode 100644 index 0000000..1c7b41c --- /dev/null +++ b/application/api/controllers/Links.php @@ -0,0 +1,86 @@ +getParam('private'); + $links = $this->linkDb->filterSearch( + [ + 'searchtags' => $request->getParam('searchtags', ''), + 'searchterm' => $request->getParam('searchterm', ''), + ], + false, + $private === 'true' || $private === '1' + ); + + // Return links from the {offset}th link, starting from 0. + $offset = $request->getParam('offset'); + if (! empty($offset) && ! ctype_digit($offset)) { + throw new ApiBadParametersException('Invalid offset'); + } + $offset = ! empty($offset) ? intval($offset) : 0; + if ($offset > count($links)) { + return $response->withJson([], 200, $this->jsonStyle); + } + + // limit parameter is either a number of links or 'all' for everything. + $limit = $request->getParam('limit'); + if (empty($limit)) { + $limit = self::$DEFAULT_LIMIT; + } + else if (ctype_digit($limit)) { + $limit = intval($limit); + } else if ($limit === 'all') { + $limit = count($links); + } else { + throw new ApiBadParametersException('Invalid limit'); + } + + // 'environment' is set by Slim and encapsulate $_SERVER. + $index = index_url($this->ci['environment']); + + $out = []; + $cpt = 0; + foreach ($links as $link) { + if (count($out) >= $limit) { + break; + } + if ($cpt++ >= $offset) { + $out[] = ApiUtils::formatLink($link, $index); + } + } + + return $response->withJson($out, 200, $this->jsonStyle); + } +} diff --git a/index.php b/index.php index 2ed14d4..ff24ed7 100644 --- a/index.php +++ b/index.php @@ -2232,6 +2232,7 @@ $app = new \Slim\App($container); // REST API routes $app->group('/api/v1', function() { $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo'); + $this->get('/links', '\Shaarli\Api\Controllers\Links:getLinks'); })->add('\Shaarli\Api\ApiMiddleware'); $response = $app->run(true); diff --git a/tests/api/ApiUtilsTest.php b/tests/api/ApiUtilsTest.php index 10da145..516ee68 100644 --- a/tests/api/ApiUtilsTest.php +++ b/tests/api/ApiUtilsTest.php @@ -203,4 +203,69 @@ class ApiUtilsTest extends \PHPUnit_Framework_TestCase $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() + 60) . '}', 'secret'); ApiUtils::validateJwtToken($token, 'secret'); } + + /** + * Test formatLink() with a link using all useful fields. + */ + public function testFormatLinkComplete() + { + $indexUrl = 'https://domain.tld/sub/'; + $link = [ + 'id' => 12, + 'url' => 'http://lol.lol', + 'shorturl' => 'abc', + 'title' => 'Important Title', + 'description' => 'It is very lol' . PHP_EOL . 'new line', + 'tags' => 'blip .blop ', + 'private' => '1', + 'created' => \DateTime::createFromFormat('Ymd_His', '20170107_160102'), + 'updated' => \DateTime::createFromFormat('Ymd_His', '20170107_160612'), + ]; + + $expected = [ + 'id' => 12, + 'url' => 'http://lol.lol', + 'shorturl' => 'abc', + 'title' => 'Important Title', + 'description' => 'It is very lol' . PHP_EOL . 'new line', + 'tags' => ['blip', '.blop'], + 'private' => true, + 'created' => '2017-01-07T16:01:02+00:00', + 'updated' => '2017-01-07T16:06:12+00:00', + ]; + + $this->assertEquals($expected, ApiUtils::formatLink($link, $indexUrl)); + } + + /** + * Test formatLink() with only minimal fields filled, and internal link. + */ + public function testFormatLinkMinimalNote() + { + $indexUrl = 'https://domain.tld/sub/'; + $link = [ + 'id' => 12, + 'url' => '?abc', + 'shorturl' => 'abc', + 'title' => 'Note', + 'description' => '', + 'tags' => '', + 'private' => '', + 'created' => \DateTime::createFromFormat('Ymd_His', '20170107_160102'), + ]; + + $expected = [ + 'id' => 12, + 'url' => 'https://domain.tld/sub/?abc', + 'shorturl' => 'abc', + 'title' => 'Note', + 'description' => '', + 'tags' => [], + 'private' => false, + 'created' => '2017-01-07T16:01:02+00:00', + 'updated' => '', + ]; + + $this->assertEquals($expected, ApiUtils::formatLink($link, $indexUrl)); + } } diff --git a/tests/api/controllers/LinksTest.php b/tests/api/controllers/LinksTest.php new file mode 100644 index 0000000..4ead26b --- /dev/null +++ b/tests/api/controllers/LinksTest.php @@ -0,0 +1,393 @@ +conf = new \ConfigManager('tests/utils/config/configJson.json.php'); + $this->refDB = new \ReferenceLinkDB(); + $this->refDB->write(self::$testDatastore); + + $this->container = new Container(); + $this->container['conf'] = $this->conf; + $this->container['db'] = new \LinkDB(self::$testDatastore, true, false); + + $this->controller = new Links($this->container); + } + + /** + * After every test, remove the test datastore. + */ + public function tearDown() + { + @unlink(self::$testDatastore); + } + + /** + * Test basic getLinks service: returns all links. + */ + public function testGetLinks() + { + // Used by index_url(). + $_SERVER['SERVER_NAME'] = 'domain.tld'; + $_SERVER['SERVER_PORT'] = 80; + $_SERVER['SCRIPT_NAME'] = '/'; + + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + ]); + $request = Request::createFromEnvironment($env); + + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals($this->refDB->countLinks(), count($data)); + + // Check order + $order = [41, 8, 6, 7, 0, 1, 4, 42]; + $cpt = 0; + foreach ($data as $link) { + $this->assertEquals(self::NB_FIELDS_LINK, count($link)); + $this->assertEquals($order[$cpt++], $link['id']); + } + + // Check first element fields\ + $first = $data[0]; + $this->assertEquals('http://domain.tld/?WDWyig', $first['url']); + $this->assertEquals('WDWyig', $first['shorturl']); + $this->assertEquals('Link title: @website', $first['title']); + $this->assertEquals( + 'Stallman has a beard and is part of the Free Software Foundation (or not). Seriously, read this. #hashtag', + $first['description'] + ); + $this->assertEquals('sTuff', $first['tags'][0]); + $this->assertEquals(false, $first['private']); + $this->assertEquals( + \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20150310_114651')->format(\DateTime::ATOM), + $first['created'] + ); + $this->assertEmpty($first['updated']); + + // Multi tags + $link = $data[1]; + $this->assertEquals(7, count($link['tags'])); + + // Update date + $this->assertEquals( + \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20160803_093033')->format(\DateTime::ATOM), + $link['updated'] + ); + } + + /** + * Test getLinks service with offset and limit parameter: + * limit=1 and offset=1 should return only the second link, ID=8 (ordered by creation date DESC). + */ + public function testGetLinksOffsetLimit() + { + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'offset=1&limit=1' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(1, count($data)); + $this->assertEquals(8, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + } + + /** + * Test getLinks with limit=all (return all link). + */ + public function testGetLinksLimitAll() + { + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'limit=all' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals($this->refDB->countLinks(), count($data)); + // Check order + $order = [41, 8, 6, 7, 0, 1, 4, 42]; + $cpt = 0; + foreach ($data as $link) { + $this->assertEquals(self::NB_FIELDS_LINK, count($link)); + $this->assertEquals($order[$cpt++], $link['id']); + } + } + + /** + * Test getLinks service with offset and limit parameter: + * limit=1 and offset=1 should return only the second link, ID=8 (ordered by creation date DESC). + */ + public function testGetLinksOffsetTooHigh() + { + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'offset=100' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEmpty(count($data)); + } + + /** + * Test getLinks with private attribute to 1 or true. + */ + public function testGetLinksPrivate() + { + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'private=true' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals($this->refDB->countPrivateLinks(), count($data)); + $this->assertEquals(6, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'private=1' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals($this->refDB->countPrivateLinks(), count($data)); + $this->assertEquals(6, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + } + + /** + * Test getLinks with private attribute to false or 0 + */ + public function testGetLinksNotPrivate() + { + $env = Environment::mock( + [ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'private=0' + ] + ); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string)$response->getBody(), true); + $this->assertEquals($this->refDB->countLinks(), count($data)); + $this->assertEquals(41, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + + $env = Environment::mock( + [ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'private=false' + ] + ); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string)$response->getBody(), true); + $this->assertEquals($this->refDB->countLinks(), count($data)); + $this->assertEquals(41, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + } + + /** + * Test getLinks service with offset and limit parameter: + * limit=1 and offset=1 should return only the second link, ID=8 (ordered by creation date DESC). + */ + public function testGetLinksSearchTerm() + { + // Only in description - 1 result + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'searchterm=Tropical' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(1, count($data)); + $this->assertEquals(1, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + + // Only in tags - 1 result + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'searchterm=tag3' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(1, count($data)); + $this->assertEquals(0, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + + // Multiple results (2) + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'searchterm=stallman' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(2, count($data)); + $this->assertEquals(41, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + $this->assertEquals(8, $data[1]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[1])); + + // Multiword - 2 results + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'searchterm=stallman+software' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(2, count($data)); + $this->assertEquals(41, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + $this->assertEquals(8, $data[1]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[1])); + + // URL encoding + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'searchterm='. urlencode('@web') + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(2, count($data)); + $this->assertEquals(41, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + $this->assertEquals(8, $data[1]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[1])); + } + + public function testGetLinksSearchTermNoResult() + { + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'searchterm=nope' + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(0, count($data)); + } + + public function testGetLinksSearchTags() + { + // Single tag + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'searchtags=dev', + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(2, count($data)); + $this->assertEquals(0, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + $this->assertEquals(4, $data[1]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[1])); + + // Multitag + exclude + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'searchtags=stuff+-gnu', + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(1, count($data)); + $this->assertEquals(41, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + } + + /** + * Test getLinks service with search tags+terms. + */ + public function testGetLinksSearchTermsAndTags() + { + $env = Environment::mock([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'searchterm=poke&searchtags=dev', + ]); + $request = Request::createFromEnvironment($env); + $response = $this->controller->getLinks($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(1, count($data)); + $this->assertEquals(0, $data[0]['id']); + $this->assertEquals(self::NB_FIELDS_LINK, count($data[0])); + } +}