aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorArthurHoaro <arthur@hoa.ro>2017-01-05 15:58:24 +0100
committerArthurHoaro <arthur@hoa.ro>2017-03-27 18:44:50 +0200
commit68016e37983b882c51c6ac92da6f6cc1250676e5 (patch)
tree9f4f2cef8c8908f2f551f0678e4b6686de6dd5cc
parentb320c860f5c794c57c08ee2a65c9b73768aac23c (diff)
downloadShaarli-68016e37983b882c51c6ac92da6f6cc1250676e5.tar.gz
Shaarli-68016e37983b882c51c6ac92da6f6cc1250676e5.tar.zst
Shaarli-68016e37983b882c51c6ac92da6f6cc1250676e5.zip
REST API: implement POST link service
-rw-r--r--application/api/ApiUtils.php35
-rw-r--r--application/api/controllers/ApiController.php10
-rw-r--r--application/api/controllers/Links.php44
-rw-r--r--index.php7
-rw-r--r--tests/api/controllers/PostLinkTest.php193
-rw-r--r--tests/utils/ReferenceLinkDB.php2
6 files changed, 284 insertions, 7 deletions
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
index d4015865..b8155a34 100644
--- a/application/api/ApiUtils.php
+++ b/application/api/ApiUtils.php
@@ -12,7 +12,7 @@ class ApiUtils
12 /** 12 /**
13 * Validates a JWT token authenticity. 13 * Validates a JWT token authenticity.
14 * 14 *
15 * @param string $token JWT token extracted from the headers. 15 * @param string $token JWT token extracted from the headers.
16 * @param string $secret API secret set in the settings. 16 * @param string $secret API secret set in the settings.
17 * 17 *
18 * @throws ApiAuthorizationException the token is not valid. 18 * @throws ApiAuthorizationException the token is not valid.
@@ -50,7 +50,7 @@ class ApiUtils
50 /** 50 /**
51 * Format a Link for the REST API. 51 * Format a Link for the REST API.
52 * 52 *
53 * @param array $link Link data read from the datastore. 53 * @param array $link Link data read from the datastore.
54 * @param string $indexUrl Shaarli's index URL (used for relative URL). 54 * @param string $indexUrl Shaarli's index URL (used for relative URL).
55 * 55 *
56 * @return array Link data formatted for the REST API. 56 * @return array Link data formatted for the REST API.
@@ -77,4 +77,35 @@ class ApiUtils
77 } 77 }
78 return $out; 78 return $out;
79 } 79 }
80
81 /**
82 * Convert a link given through a request, to a valid link for LinkDB.
83 *
84 * If no URL is provided, it will generate a local note URL.
85 * If no title is provided, it will use the URL as title.
86 *
87 * @param array $input Request Link.
88 * @param bool $defaultPrivate Request Link.
89 *
90 * @return array Formatted link.
91 */
92 public static function buildLinkFromRequest($input, $defaultPrivate)
93 {
94 $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : '';
95 if (isset($input['private'])) {
96 $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
97 } else {
98 $private = $defaultPrivate;
99 }
100
101 $link = [
102 'title' => ! empty($input['title']) ? $input['title'] : $input['url'],
103 'url' => $input['url'],
104 'description' => ! empty($input['description']) ? $input['description'] : '',
105 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '',
106 'private' => $private,
107 'created' => new \DateTime(),
108 ];
109 return $link;
110 }
80} 111}
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
index 1dd47f17..f35b923a 100644
--- a/application/api/controllers/ApiController.php
+++ b/application/api/controllers/ApiController.php
@@ -51,4 +51,14 @@ abstract class ApiController
51 $this->jsonStyle = null; 51 $this->jsonStyle = null;
52 } 52 }
53 } 53 }
54
55 /**
56 * Get the container.
57 *
58 * @return Container
59 */
60 public function getCi()
61 {
62 return $this->ci;
63 }
54} 64}
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
index d4f1a09c..0db10fd0 100644
--- a/application/api/controllers/Links.php
+++ b/application/api/controllers/Links.php
@@ -97,11 +97,53 @@ class Links extends ApiController
97 */ 97 */
98 public function getLink($request, $response, $args) 98 public function getLink($request, $response, $args)
99 { 99 {
100 if (! isset($this->linkDb[$args['id']])) { 100 if (!isset($this->linkDb[$args['id']])) {
101 throw new ApiLinkNotFoundException(); 101 throw new ApiLinkNotFoundException();
102 } 102 }
103 $index = index_url($this->ci['environment']); 103 $index = index_url($this->ci['environment']);
104 $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); 104 $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index);
105
105 return $response->withJson($out, 200, $this->jsonStyle); 106 return $response->withJson($out, 200, $this->jsonStyle);
106 } 107 }
108
109 /**
110 * Creates a new link from posted request body.
111 *
112 * @param Request $request Slim request.
113 * @param Response $response Slim response.
114 *
115 * @return Response response.
116 */
117 public function postLink($request, $response)
118 {
119 $data = $request->getParsedBody();
120 $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
121 // duplicate by URL, return 409 Conflict
122 if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) {
123 return $response->withJson(
124 ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
125 409,
126 $this->jsonStyle
127 );
128 }
129
130 $link['id'] = $this->linkDb->getNextId();
131 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
132
133 // note: general relative URL
134 if (empty($link['url'])) {
135 $link['url'] = '?' . $link['shorturl'];
136 }
137
138 if (empty($link['title'])) {
139 $link['title'] = $link['url'];
140 }
141
142 $this->linkDb[$link['id']] = $link;
143 $this->linkDb->save($this->conf->get('resource.page_cache'));
144 $out = ApiUtils::formatLink($link, index_url($this->ci['environment']));
145 $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]);
146 return $response->withAddedHeader('Location', $redirect)
147 ->withJson($out, 201, $this->jsonStyle);
148 }
107} 149}
diff --git a/index.php b/index.php
index 5c21c2f6..021fc6e9 100644
--- a/index.php
+++ b/index.php
@@ -2240,9 +2240,10 @@ $app = new \Slim\App($container);
2240 2240
2241// REST API routes 2241// REST API routes
2242$app->group('/api/v1', function() { 2242$app->group('/api/v1', function() {
2243 $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo'); 2243 $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo')->setName('getInfo');
2244 $this->get('/links', '\Shaarli\Api\Controllers\Links:getLinks'); 2244 $this->get('/links', '\Shaarli\Api\Controllers\Links:getLinks')->setName('getLinks');
2245 $this->get('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:getLink'); 2245 $this->get('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:getLink')->setName('getLink');
2246 $this->post('/links', '\Shaarli\Api\Controllers\Links:postLink')->setName('postLink');
2246})->add('\Shaarli\Api\ApiMiddleware'); 2247})->add('\Shaarli\Api\ApiMiddleware');
2247 2248
2248$response = $app->run(true); 2249$response = $app->run(true);
diff --git a/tests/api/controllers/PostLinkTest.php b/tests/api/controllers/PostLinkTest.php
new file mode 100644
index 00000000..3ed7bcb0
--- /dev/null
+++ b/tests/api/controllers/PostLinkTest.php
@@ -0,0 +1,193 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5
6use Shaarli\Config\ConfigManager;
7use Slim\Container;
8use Slim\Http\Environment;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class PostLinkTest
14 *
15 * Test POST Link REST API service.
16 *
17 * @package Shaarli\Api\Controllers
18 */
19class PostLinkTest 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 $mock = $this->getMock('\Slim\Router', ['relativePathFor']);
67 $mock->expects($this->any())
68 ->method('relativePathFor')
69 ->willReturn('api/v1/links/1');
70
71 // affect @property-read... seems to work
72 $this->controller->getCi()->router = $mock;
73
74 // Used by index_url().
75 $this->controller->getCi()['environment'] = [
76 'SERVER_NAME' => 'domain.tld',
77 'SERVER_PORT' => 80,
78 'SCRIPT_NAME' => '/',
79 ];
80 }
81
82 /**
83 * After every test, remove the test datastore.
84 */
85 public function tearDown()
86 {
87 @unlink(self::$testDatastore);
88 }
89
90 /**
91 * Test link creation without any field: creates a blank note.
92 */
93 public function testPostLinkMinimal()
94 {
95 $env = Environment::mock([
96 'REQUEST_METHOD' => 'POST',
97 ]);
98
99 $request = Request::createFromEnvironment($env);
100
101 $response = $this->controller->postLink($request, new Response());
102 $this->assertEquals(201, $response->getStatusCode());
103 $this->assertEquals('api/v1/links/1', $response->getHeader('Location')[0]);
104 $data = json_decode((string) $response->getBody(), true);
105 $this->assertEquals(self::NB_FIELDS_LINK, count($data));
106 $this->assertEquals(43, $data['id']);
107 $this->assertRegExp('/[\w-_]{6}/', $data['shorturl']);
108 $this->assertEquals('http://domain.tld/?' . $data['shorturl'], $data['url']);
109 $this->assertEquals('?' . $data['shorturl'], $data['title']);
110 $this->assertEquals('', $data['description']);
111 $this->assertEquals([], $data['tags']);
112 $this->assertEquals(false, $data['private']);
113 $this->assertTrue(new \DateTime('5 seconds ago') < \DateTime::createFromFormat(\DateTime::ATOM, $data['created']));
114 $this->assertEquals('', $data['updated']);
115 }
116
117 /**
118 * Test link creation with all available fields.
119 */
120 public function testPostLinkFull()
121 {
122 $link = [
123 'url' => 'website.tld/test?foo=bar',
124 'title' => 'new entry',
125 'description' => 'shaare description',
126 'tags' => ['one', 'two'],
127 'private' => true,
128 ];
129 $env = Environment::mock([
130 'REQUEST_METHOD' => 'POST',
131 'CONTENT_TYPE' => 'application/json'
132 ]);
133
134 $request = Request::createFromEnvironment($env);
135 $request = $request->withParsedBody($link);
136 $response = $this->controller->postLink($request, new Response());
137
138 $this->assertEquals(201, $response->getStatusCode());
139 $this->assertEquals('api/v1/links/1', $response->getHeader('Location')[0]);
140 $data = json_decode((string) $response->getBody(), true);
141 $this->assertEquals(self::NB_FIELDS_LINK, count($data));
142 $this->assertEquals(43, $data['id']);
143 $this->assertRegExp('/[\w-_]{6}/', $data['shorturl']);
144 $this->assertEquals('http://' . $link['url'], $data['url']);
145 $this->assertEquals($link['title'], $data['title']);
146 $this->assertEquals($link['description'], $data['description']);
147 $this->assertEquals($link['tags'], $data['tags']);
148 $this->assertEquals(true, $data['private']);
149 $this->assertTrue(new \DateTime('2 seconds ago') < \DateTime::createFromFormat(\DateTime::ATOM, $data['created']));
150 $this->assertEquals('', $data['updated']);
151 }
152
153 /**
154 * Test link creation with an existing link (duplicate URL). Should return a 409 HTTP error and the existing link.
155 */
156 public function testPostLinkDuplicate()
157 {
158 $link = [
159 'url' => 'mediagoblin.org/',
160 'title' => 'new entry',
161 'description' => 'shaare description',
162 'tags' => ['one', 'two'],
163 'private' => true,
164 ];
165 $env = Environment::mock([
166 'REQUEST_METHOD' => 'POST',
167 'CONTENT_TYPE' => 'application/json'
168 ]);
169
170 $request = Request::createFromEnvironment($env);
171 $request = $request->withParsedBody($link);
172 $response = $this->controller->postLink($request, new Response());
173
174 $this->assertEquals(409, $response->getStatusCode());
175 $data = json_decode((string) $response->getBody(), true);
176 $this->assertEquals(self::NB_FIELDS_LINK, count($data));
177 $this->assertEquals(7, $data['id']);
178 $this->assertEquals('IuWvgA', $data['shorturl']);
179 $this->assertEquals('http://mediagoblin.org/', $data['url']);
180 $this->assertEquals('MediaGoblin', $data['title']);
181 $this->assertEquals('A free software media publishing platform #hashtagOther', $data['description']);
182 $this->assertEquals(['gnu', 'media', 'web', '.hidden', 'hashtag'], $data['tags']);
183 $this->assertEquals(false, $data['private']);
184 $this->assertEquals(
185 \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20130614_184135'),
186 \DateTime::createFromFormat(\DateTime::ATOM, $data['created'])
187 );
188 $this->assertEquals(
189 \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20130615_184230'),
190 \DateTime::createFromFormat(\DateTime::ATOM, $data['updated'])
191 );
192 }
193}
diff --git a/tests/utils/ReferenceLinkDB.php b/tests/utils/ReferenceLinkDB.php
index 36d58c68..1f4b3063 100644
--- a/tests/utils/ReferenceLinkDB.php
+++ b/tests/utils/ReferenceLinkDB.php
@@ -56,7 +56,7 @@ class ReferenceLinkDB
56 0, 56 0,
57 DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20130614_184135'), 57 DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20130614_184135'),
58 'gnu media web .hidden hashtag', 58 'gnu media web .hidden hashtag',
59 null, 59 DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20130615_184230'),
60 'IuWvgA' 60 'IuWvgA'
61 ); 61 );
62 62