]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
REST API: implement getLinks service 727/head
authorArthurHoaro <arthur@hoa.ro>
Thu, 22 Dec 2016 13:36:45 +0000 (14:36 +0100)
committerArthurHoaro <arthur@hoa.ro>
Sun, 15 Jan 2017 12:55:22 +0000 (13:55 +0100)
See http://shaarli.github.io/api-documentation/#links-links-collection-get

application/api/ApiUtils.php
application/api/controllers/Links.php [new file with mode: 0644]
index.php
tests/api/ApiUtilsTest.php
tests/api/controllers/LinksTest.php [new file with mode: 0644]

index fbb1e72f97dfc30c2da4451e96f51d055fea50e0..d024291988db8b7a439e3a0e7986749fa629467a 100644 (file)
@@ -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 (file)
index 0000000..1c7b41c
--- /dev/null
@@ -0,0 +1,86 @@
+<?php
+
+namespace Shaarli\Api\Controllers;
+
+use Shaarli\Api\ApiUtils;
+use Shaarli\Api\Exceptions\ApiBadParametersException;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class Links
+ *
+ * REST API Controller: all services related to links collection.
+ *
+ * @package Api\Controllers
+ * @see http://shaarli.github.io/api-documentation/#links-links-collection
+ */
+class Links extends ApiController
+{
+    /**
+     * @var int Number of links returned if no limit is provided.
+     */
+    public static $DEFAULT_LIMIT = 20;
+
+    /**
+     * Retrieve a list of links, allowing different filters.
+     *
+     * @param Request  $request  Slim request.
+     * @param Response $response Slim response.
+     *
+     * @return Response response.
+     *
+     * @throws ApiBadParametersException Invalid parameters.
+     */
+    public function getLinks($request, $response)
+    {
+        $private = $request->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);
+    }
+}
index 2ed14d4f2f3ac67ed6bd5e041fc434b27a7aa050..ff24ed7eca2762b4c531570efd2aa97b3ee37753 100644 (file)
--- 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);
index 10da1459a1fbfee77f469ba251ea6036884d3c2d..516ee6860edd169ba2cf6ea962669e59a0296896 100644 (file)
@@ -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<tag>' . 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<tag>' . 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 (file)
index 0000000..4ead26b
--- /dev/null
@@ -0,0 +1,393 @@
+<?php
+
+namespace Shaarli\Api\Controllers;
+
+
+use Slim\Container;
+use Slim\Http\Environment;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class LinksTest
+ *
+ * Test Links REST API services.
+ * Note that api call results are tightly related to data contained in ReferenceLinkDB.
+ *
+ * @package Shaarli\Api\Controllers
+ */
+class LinksTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var string datastore to test write operations
+     */
+    protected static $testDatastore = 'sandbox/datastore.php';
+
+    /**
+     * @var \ConfigManager instance
+     */
+    protected $conf;
+
+    /**
+     * @var \ReferenceLinkDB instance.
+     */
+    protected $refDB = null;
+
+    /**
+     * @var Container instance.
+     */
+    protected $container;
+
+    /**
+     * @var Links controller instance.
+     */
+    protected $controller;
+
+    /**
+     * Number of JSON field per link.
+     */
+    const NB_FIELDS_LINK = 9;
+
+    /**
+     * Before every test, instantiate a new Api with its config, plugins and links.
+     */
+    public function setUp()
+    {
+        $this->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]));
+    }
+}