aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--application/api/ApiUtils.php31
-rw-r--r--application/api/controllers/Links.php86
-rw-r--r--index.php1
-rw-r--r--tests/api/ApiUtilsTest.php65
-rw-r--r--tests/api/controllers/LinksTest.php393
5 files changed, 576 insertions, 0 deletions
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
index fbb1e72f..d0242919 100644
--- a/application/api/ApiUtils.php
+++ b/application/api/ApiUtils.php
@@ -48,4 +48,35 @@ class ApiUtils
48 throw new ApiAuthorizationException('Invalid JWT issued time'); 48 throw new ApiAuthorizationException('Invalid JWT issued time');
49 } 49 }
50 } 50 }
51
52 /**
53 * Format a Link for the REST API.
54 *
55 * @param array $link Link data read from the datastore.
56 * @param string $indexUrl Shaarli's index URL (used for relative URL).
57 *
58 * @return array Link data formatted for the REST API.
59 */
60 public static function formatLink($link, $indexUrl)
61 {
62 $out['id'] = $link['id'];
63 // Not an internal link
64 if ($link['url'][0] != '?') {
65 $out['url'] = $link['url'];
66 } else {
67 $out['url'] = $indexUrl . $link['url'];
68 }
69 $out['shorturl'] = $link['shorturl'];
70 $out['title'] = $link['title'];
71 $out['description'] = $link['description'];
72 $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY);
73 $out['private'] = $link['private'] == true;
74 $out['created'] = $link['created']->format(\DateTime::ATOM);
75 if (! empty($link['updated'])) {
76 $out['updated'] = $link['updated']->format(\DateTime::ATOM);
77 } else {
78 $out['updated'] = '';
79 }
80 return $out;
81 }
51} 82}
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
new file mode 100644
index 00000000..1c7b41cd
--- /dev/null
+++ b/application/api/controllers/Links.php
@@ -0,0 +1,86 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Shaarli\Api\ApiUtils;
6use Shaarli\Api\Exceptions\ApiBadParametersException;
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class Links
12 *
13 * REST API Controller: all services related to links collection.
14 *
15 * @package Api\Controllers
16 * @see http://shaarli.github.io/api-documentation/#links-links-collection
17 */
18class Links extends ApiController
19{
20 /**
21 * @var int Number of links returned if no limit is provided.
22 */
23 public static $DEFAULT_LIMIT = 20;
24
25 /**
26 * Retrieve a list of links, allowing different filters.
27 *
28 * @param Request $request Slim request.
29 * @param Response $response Slim response.
30 *
31 * @return Response response.
32 *
33 * @throws ApiBadParametersException Invalid parameters.
34 */
35 public function getLinks($request, $response)
36 {
37 $private = $request->getParam('private');
38 $links = $this->linkDb->filterSearch(
39 [
40 'searchtags' => $request->getParam('searchtags', ''),
41 'searchterm' => $request->getParam('searchterm', ''),
42 ],
43 false,
44 $private === 'true' || $private === '1'
45 );
46
47 // Return links from the {offset}th link, starting from 0.
48 $offset = $request->getParam('offset');
49 if (! empty($offset) && ! ctype_digit($offset)) {
50 throw new ApiBadParametersException('Invalid offset');
51 }
52 $offset = ! empty($offset) ? intval($offset) : 0;
53 if ($offset > count($links)) {
54 return $response->withJson([], 200, $this->jsonStyle);
55 }
56
57 // limit parameter is either a number of links or 'all' for everything.
58 $limit = $request->getParam('limit');
59 if (empty($limit)) {
60 $limit = self::$DEFAULT_LIMIT;
61 }
62 else if (ctype_digit($limit)) {
63 $limit = intval($limit);
64 } else if ($limit === 'all') {
65 $limit = count($links);
66 } else {
67 throw new ApiBadParametersException('Invalid limit');
68 }
69
70 // 'environment' is set by Slim and encapsulate $_SERVER.
71 $index = index_url($this->ci['environment']);
72
73 $out = [];
74 $cpt = 0;
75 foreach ($links as $link) {
76 if (count($out) >= $limit) {
77 break;
78 }
79 if ($cpt++ >= $offset) {
80 $out[] = ApiUtils::formatLink($link, $index);
81 }
82 }
83
84 return $response->withJson($out, 200, $this->jsonStyle);
85 }
86}
diff --git a/index.php b/index.php
index 2ed14d4f..ff24ed7e 100644
--- a/index.php
+++ b/index.php
@@ -2232,6 +2232,7 @@ $app = new \Slim\App($container);
2232// REST API routes 2232// REST API routes
2233$app->group('/api/v1', function() { 2233$app->group('/api/v1', function() {
2234 $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo'); 2234 $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo');
2235 $this->get('/links', '\Shaarli\Api\Controllers\Links:getLinks');
2235})->add('\Shaarli\Api\ApiMiddleware'); 2236})->add('\Shaarli\Api\ApiMiddleware');
2236 2237
2237$response = $app->run(true); 2238$response = $app->run(true);
diff --git a/tests/api/ApiUtilsTest.php b/tests/api/ApiUtilsTest.php
index 10da1459..516ee686 100644
--- a/tests/api/ApiUtilsTest.php
+++ b/tests/api/ApiUtilsTest.php
@@ -203,4 +203,69 @@ class ApiUtilsTest extends \PHPUnit_Framework_TestCase
203 $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() + 60) . '}', 'secret'); 203 $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() + 60) . '}', 'secret');
204 ApiUtils::validateJwtToken($token, 'secret'); 204 ApiUtils::validateJwtToken($token, 'secret');
205 } 205 }
206
207 /**
208 * Test formatLink() with a link using all useful fields.
209 */
210 public function testFormatLinkComplete()
211 {
212 $indexUrl = 'https://domain.tld/sub/';
213 $link = [
214 'id' => 12,
215 'url' => 'http://lol.lol',
216 'shorturl' => 'abc',
217 'title' => 'Important Title',
218 'description' => 'It is very lol<tag>' . PHP_EOL . 'new line',
219 'tags' => 'blip .blop ',
220 'private' => '1',
221 'created' => \DateTime::createFromFormat('Ymd_His', '20170107_160102'),
222 'updated' => \DateTime::createFromFormat('Ymd_His', '20170107_160612'),
223 ];
224
225 $expected = [
226 'id' => 12,
227 'url' => 'http://lol.lol',
228 'shorturl' => 'abc',
229 'title' => 'Important Title',
230 'description' => 'It is very lol<tag>' . PHP_EOL . 'new line',
231 'tags' => ['blip', '.blop'],
232 'private' => true,
233 'created' => '2017-01-07T16:01:02+00:00',
234 'updated' => '2017-01-07T16:06:12+00:00',
235 ];
236
237 $this->assertEquals($expected, ApiUtils::formatLink($link, $indexUrl));
238 }
239
240 /**
241 * Test formatLink() with only minimal fields filled, and internal link.
242 */
243 public function testFormatLinkMinimalNote()
244 {
245 $indexUrl = 'https://domain.tld/sub/';
246 $link = [
247 'id' => 12,
248 'url' => '?abc',
249 'shorturl' => 'abc',
250 'title' => 'Note',
251 'description' => '',
252 'tags' => '',
253 'private' => '',
254 'created' => \DateTime::createFromFormat('Ymd_His', '20170107_160102'),
255 ];
256
257 $expected = [
258 'id' => 12,
259 'url' => 'https://domain.tld/sub/?abc',
260 'shorturl' => 'abc',
261 'title' => 'Note',
262 'description' => '',
263 'tags' => [],
264 'private' => false,
265 'created' => '2017-01-07T16:01:02+00:00',
266 'updated' => '',
267 ];
268
269 $this->assertEquals($expected, ApiUtils::formatLink($link, $indexUrl));
270 }
206} 271}
diff --git a/tests/api/controllers/LinksTest.php b/tests/api/controllers/LinksTest.php
new file mode 100644
index 00000000..4ead26b9
--- /dev/null
+++ b/tests/api/controllers/LinksTest.php
@@ -0,0 +1,393 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5
6use Slim\Container;
7use Slim\Http\Environment;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class LinksTest
13 *
14 * Test Links REST API services.
15 * Note that api call results are tightly related to data contained in ReferenceLinkDB.
16 *
17 * @package Shaarli\Api\Controllers
18 */
19class LinksTest extends \PHPUnit_Framework_TestCase
20{
21 /**
22 * @var string datastore to test write operations
23 */
24 protected static $testDatastore = 'sandbox/datastore.php';
25
26 /**
27 * @var \ConfigManager instance
28 */
29 protected $conf;
30
31 /**
32 * @var \ReferenceLinkDB instance.
33 */
34 protected $refDB = null;
35
36 /**
37 * @var Container instance.
38 */
39 protected $container;
40
41 /**
42 * @var Links controller instance.
43 */
44 protected $controller;
45
46 /**
47 * Number of JSON field per link.
48 */
49 const NB_FIELDS_LINK = 9;
50
51 /**
52 * Before every test, instantiate a new Api with its config, plugins and links.
53 */
54 public function setUp()
55 {
56 $this->conf = new \ConfigManager('tests/utils/config/configJson.json.php');
57 $this->refDB = new \ReferenceLinkDB();
58 $this->refDB->write(self::$testDatastore);
59
60 $this->container = new Container();
61 $this->container['conf'] = $this->conf;
62 $this->container['db'] = new \LinkDB(self::$testDatastore, true, false);
63
64 $this->controller = new Links($this->container);
65 }
66
67 /**
68 * After every test, remove the test datastore.
69 */
70 public function tearDown()
71 {
72 @unlink(self::$testDatastore);
73 }
74
75 /**
76 * Test basic getLinks service: returns all links.
77 */
78 public function testGetLinks()
79 {
80 // Used by index_url().
81 $_SERVER['SERVER_NAME'] = 'domain.tld';
82 $_SERVER['SERVER_PORT'] = 80;
83 $_SERVER['SCRIPT_NAME'] = '/';
84
85 $env = Environment::mock([
86 'REQUEST_METHOD' => 'GET',
87 ]);
88 $request = Request::createFromEnvironment($env);
89
90 $response = $this->controller->getLinks($request, new Response());
91 $this->assertEquals(200, $response->getStatusCode());
92 $data = json_decode((string) $response->getBody(), true);
93 $this->assertEquals($this->refDB->countLinks(), count($data));
94
95 // Check order
96 $order = [41, 8, 6, 7, 0, 1, 4, 42];
97 $cpt = 0;
98 foreach ($data as $link) {
99 $this->assertEquals(self::NB_FIELDS_LINK, count($link));
100 $this->assertEquals($order[$cpt++], $link['id']);
101 }
102
103 // Check first element fields\
104 $first = $data[0];
105 $this->assertEquals('http://domain.tld/?WDWyig', $first['url']);
106 $this->assertEquals('WDWyig', $first['shorturl']);
107 $this->assertEquals('Link title: @website', $first['title']);
108 $this->assertEquals(
109 'Stallman has a beard and is part of the Free Software Foundation (or not). Seriously, read this. #hashtag',
110 $first['description']
111 );
112 $this->assertEquals('sTuff', $first['tags'][0]);
113 $this->assertEquals(false, $first['private']);
114 $this->assertEquals(
115 \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20150310_114651')->format(\DateTime::ATOM),
116 $first['created']
117 );
118 $this->assertEmpty($first['updated']);
119
120 // Multi tags
121 $link = $data[1];
122 $this->assertEquals(7, count($link['tags']));
123
124 // Update date
125 $this->assertEquals(
126 \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20160803_093033')->format(\DateTime::ATOM),
127 $link['updated']
128 );
129 }
130
131 /**
132 * Test getLinks service with offset and limit parameter:
133 * limit=1 and offset=1 should return only the second link, ID=8 (ordered by creation date DESC).
134 */
135 public function testGetLinksOffsetLimit()
136 {
137 $env = Environment::mock([
138 'REQUEST_METHOD' => 'GET',
139 'QUERY_STRING' => 'offset=1&limit=1'
140 ]);
141 $request = Request::createFromEnvironment($env);
142 $response = $this->controller->getLinks($request, new Response());
143 $this->assertEquals(200, $response->getStatusCode());
144 $data = json_decode((string) $response->getBody(), true);
145 $this->assertEquals(1, count($data));
146 $this->assertEquals(8, $data[0]['id']);
147 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
148 }
149
150 /**
151 * Test getLinks with limit=all (return all link).
152 */
153 public function testGetLinksLimitAll()
154 {
155 $env = Environment::mock([
156 'REQUEST_METHOD' => 'GET',
157 'QUERY_STRING' => 'limit=all'
158 ]);
159 $request = Request::createFromEnvironment($env);
160 $response = $this->controller->getLinks($request, new Response());
161 $this->assertEquals(200, $response->getStatusCode());
162 $data = json_decode((string) $response->getBody(), true);
163 $this->assertEquals($this->refDB->countLinks(), count($data));
164 // Check order
165 $order = [41, 8, 6, 7, 0, 1, 4, 42];
166 $cpt = 0;
167 foreach ($data as $link) {
168 $this->assertEquals(self::NB_FIELDS_LINK, count($link));
169 $this->assertEquals($order[$cpt++], $link['id']);
170 }
171 }
172
173 /**
174 * Test getLinks service with offset and limit parameter:
175 * limit=1 and offset=1 should return only the second link, ID=8 (ordered by creation date DESC).
176 */
177 public function testGetLinksOffsetTooHigh()
178 {
179 $env = Environment::mock([
180 'REQUEST_METHOD' => 'GET',
181 'QUERY_STRING' => 'offset=100'
182 ]);
183 $request = Request::createFromEnvironment($env);
184 $response = $this->controller->getLinks($request, new Response());
185 $this->assertEquals(200, $response->getStatusCode());
186 $data = json_decode((string) $response->getBody(), true);
187 $this->assertEmpty(count($data));
188 }
189
190 /**
191 * Test getLinks with private attribute to 1 or true.
192 */
193 public function testGetLinksPrivate()
194 {
195 $env = Environment::mock([
196 'REQUEST_METHOD' => 'GET',
197 'QUERY_STRING' => 'private=true'
198 ]);
199 $request = Request::createFromEnvironment($env);
200 $response = $this->controller->getLinks($request, new Response());
201 $this->assertEquals(200, $response->getStatusCode());
202 $data = json_decode((string) $response->getBody(), true);
203 $this->assertEquals($this->refDB->countPrivateLinks(), count($data));
204 $this->assertEquals(6, $data[0]['id']);
205 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
206
207 $env = Environment::mock([
208 'REQUEST_METHOD' => 'GET',
209 'QUERY_STRING' => 'private=1'
210 ]);
211 $request = Request::createFromEnvironment($env);
212 $response = $this->controller->getLinks($request, new Response());
213 $this->assertEquals(200, $response->getStatusCode());
214 $data = json_decode((string) $response->getBody(), true);
215 $this->assertEquals($this->refDB->countPrivateLinks(), count($data));
216 $this->assertEquals(6, $data[0]['id']);
217 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
218 }
219
220 /**
221 * Test getLinks with private attribute to false or 0
222 */
223 public function testGetLinksNotPrivate()
224 {
225 $env = Environment::mock(
226 [
227 'REQUEST_METHOD' => 'GET',
228 'QUERY_STRING' => 'private=0'
229 ]
230 );
231 $request = Request::createFromEnvironment($env);
232 $response = $this->controller->getLinks($request, new Response());
233 $this->assertEquals(200, $response->getStatusCode());
234 $data = json_decode((string)$response->getBody(), true);
235 $this->assertEquals($this->refDB->countLinks(), count($data));
236 $this->assertEquals(41, $data[0]['id']);
237 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
238
239 $env = Environment::mock(
240 [
241 'REQUEST_METHOD' => 'GET',
242 'QUERY_STRING' => 'private=false'
243 ]
244 );
245 $request = Request::createFromEnvironment($env);
246 $response = $this->controller->getLinks($request, new Response());
247 $this->assertEquals(200, $response->getStatusCode());
248 $data = json_decode((string)$response->getBody(), true);
249 $this->assertEquals($this->refDB->countLinks(), count($data));
250 $this->assertEquals(41, $data[0]['id']);
251 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
252 }
253
254 /**
255 * Test getLinks service with offset and limit parameter:
256 * limit=1 and offset=1 should return only the second link, ID=8 (ordered by creation date DESC).
257 */
258 public function testGetLinksSearchTerm()
259 {
260 // Only in description - 1 result
261 $env = Environment::mock([
262 'REQUEST_METHOD' => 'GET',
263 'QUERY_STRING' => 'searchterm=Tropical'
264 ]);
265 $request = Request::createFromEnvironment($env);
266 $response = $this->controller->getLinks($request, new Response());
267 $this->assertEquals(200, $response->getStatusCode());
268 $data = json_decode((string) $response->getBody(), true);
269 $this->assertEquals(1, count($data));
270 $this->assertEquals(1, $data[0]['id']);
271 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
272
273 // Only in tags - 1 result
274 $env = Environment::mock([
275 'REQUEST_METHOD' => 'GET',
276 'QUERY_STRING' => 'searchterm=tag3'
277 ]);
278 $request = Request::createFromEnvironment($env);
279 $response = $this->controller->getLinks($request, new Response());
280 $this->assertEquals(200, $response->getStatusCode());
281 $data = json_decode((string) $response->getBody(), true);
282 $this->assertEquals(1, count($data));
283 $this->assertEquals(0, $data[0]['id']);
284 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
285
286 // Multiple results (2)
287 $env = Environment::mock([
288 'REQUEST_METHOD' => 'GET',
289 'QUERY_STRING' => 'searchterm=stallman'
290 ]);
291 $request = Request::createFromEnvironment($env);
292 $response = $this->controller->getLinks($request, new Response());
293 $this->assertEquals(200, $response->getStatusCode());
294 $data = json_decode((string) $response->getBody(), true);
295 $this->assertEquals(2, count($data));
296 $this->assertEquals(41, $data[0]['id']);
297 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
298 $this->assertEquals(8, $data[1]['id']);
299 $this->assertEquals(self::NB_FIELDS_LINK, count($data[1]));
300
301 // Multiword - 2 results
302 $env = Environment::mock([
303 'REQUEST_METHOD' => 'GET',
304 'QUERY_STRING' => 'searchterm=stallman+software'
305 ]);
306 $request = Request::createFromEnvironment($env);
307 $response = $this->controller->getLinks($request, new Response());
308 $this->assertEquals(200, $response->getStatusCode());
309 $data = json_decode((string) $response->getBody(), true);
310 $this->assertEquals(2, count($data));
311 $this->assertEquals(41, $data[0]['id']);
312 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
313 $this->assertEquals(8, $data[1]['id']);
314 $this->assertEquals(self::NB_FIELDS_LINK, count($data[1]));
315
316 // URL encoding
317 $env = Environment::mock([
318 'REQUEST_METHOD' => 'GET',
319 'QUERY_STRING' => 'searchterm='. urlencode('@web')
320 ]);
321 $request = Request::createFromEnvironment($env);
322 $response = $this->controller->getLinks($request, new Response());
323 $this->assertEquals(200, $response->getStatusCode());
324 $data = json_decode((string) $response->getBody(), true);
325 $this->assertEquals(2, count($data));
326 $this->assertEquals(41, $data[0]['id']);
327 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
328 $this->assertEquals(8, $data[1]['id']);
329 $this->assertEquals(self::NB_FIELDS_LINK, count($data[1]));
330 }
331
332 public function testGetLinksSearchTermNoResult()
333 {
334 $env = Environment::mock([
335 'REQUEST_METHOD' => 'GET',
336 'QUERY_STRING' => 'searchterm=nope'
337 ]);
338 $request = Request::createFromEnvironment($env);
339 $response = $this->controller->getLinks($request, new Response());
340 $this->assertEquals(200, $response->getStatusCode());
341 $data = json_decode((string) $response->getBody(), true);
342 $this->assertEquals(0, count($data));
343 }
344
345 public function testGetLinksSearchTags()
346 {
347 // Single tag
348 $env = Environment::mock([
349 'REQUEST_METHOD' => 'GET',
350 'QUERY_STRING' => 'searchtags=dev',
351 ]);
352 $request = Request::createFromEnvironment($env);
353 $response = $this->controller->getLinks($request, new Response());
354 $this->assertEquals(200, $response->getStatusCode());
355 $data = json_decode((string) $response->getBody(), true);
356 $this->assertEquals(2, count($data));
357 $this->assertEquals(0, $data[0]['id']);
358 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
359 $this->assertEquals(4, $data[1]['id']);
360 $this->assertEquals(self::NB_FIELDS_LINK, count($data[1]));
361
362 // Multitag + exclude
363 $env = Environment::mock([
364 'REQUEST_METHOD' => 'GET',
365 'QUERY_STRING' => 'searchtags=stuff+-gnu',
366 ]);
367 $request = Request::createFromEnvironment($env);
368 $response = $this->controller->getLinks($request, new Response());
369 $this->assertEquals(200, $response->getStatusCode());
370 $data = json_decode((string) $response->getBody(), true);
371 $this->assertEquals(1, count($data));
372 $this->assertEquals(41, $data[0]['id']);
373 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
374 }
375
376 /**
377 * Test getLinks service with search tags+terms.
378 */
379 public function testGetLinksSearchTermsAndTags()
380 {
381 $env = Environment::mock([
382 'REQUEST_METHOD' => 'GET',
383 'QUERY_STRING' => 'searchterm=poke&searchtags=dev',
384 ]);
385 $request = Request::createFromEnvironment($env);
386 $response = $this->controller->getLinks($request, new Response());
387 $this->assertEquals(200, $response->getStatusCode());
388 $data = json_decode((string) $response->getBody(), true);
389 $this->assertEquals(1, count($data));
390 $this->assertEquals(0, $data[0]['id']);
391 $this->assertEquals(self::NB_FIELDS_LINK, count($data[0]));
392 }
393}