From 68016e37983b882c51c6ac92da6f6cc1250676e5 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 5 Jan 2017 15:58:24 +0100 Subject: [PATCH] REST API: implement POST link service --- application/api/ApiUtils.php | 35 +++- application/api/controllers/ApiController.php | 10 + application/api/controllers/Links.php | 44 +++- index.php | 7 +- tests/api/controllers/PostLinkTest.php | 193 ++++++++++++++++++ tests/utils/ReferenceLinkDB.php | 2 +- 6 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 tests/api/controllers/PostLinkTest.php 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 /** * Validates a JWT token authenticity. * - * @param string $token JWT token extracted from the headers. + * @param string $token JWT token extracted from the headers. * @param string $secret API secret set in the settings. * * @throws ApiAuthorizationException the token is not valid. @@ -50,7 +50,7 @@ class ApiUtils /** * Format a Link for the REST API. * - * @param array $link Link data read from the datastore. + * @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. @@ -77,4 +77,35 @@ class ApiUtils } return $out; } + + /** + * Convert a link given through a request, to a valid link for LinkDB. + * + * If no URL is provided, it will generate a local note URL. + * If no title is provided, it will use the URL as title. + * + * @param array $input Request Link. + * @param bool $defaultPrivate Request Link. + * + * @return array Formatted link. + */ + public static function buildLinkFromRequest($input, $defaultPrivate) + { + $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : ''; + if (isset($input['private'])) { + $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN); + } else { + $private = $defaultPrivate; + } + + $link = [ + 'title' => ! empty($input['title']) ? $input['title'] : $input['url'], + 'url' => $input['url'], + 'description' => ! empty($input['description']) ? $input['description'] : '', + 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '', + 'private' => $private, + 'created' => new \DateTime(), + ]; + return $link; + } } 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 $this->jsonStyle = null; } } + + /** + * Get the container. + * + * @return Container + */ + public function getCi() + { + return $this->ci; + } } 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 */ public function getLink($request, $response, $args) { - if (! isset($this->linkDb[$args['id']])) { + if (!isset($this->linkDb[$args['id']])) { throw new ApiLinkNotFoundException(); } $index = index_url($this->ci['environment']); $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); + return $response->withJson($out, 200, $this->jsonStyle); } + + /** + * Creates a new link from posted request body. + * + * @param Request $request Slim request. + * @param Response $response Slim response. + * + * @return Response response. + */ + public function postLink($request, $response) + { + $data = $request->getParsedBody(); + $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); + // duplicate by URL, return 409 Conflict + if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) { + return $response->withJson( + ApiUtils::formatLink($dup, index_url($this->ci['environment'])), + 409, + $this->jsonStyle + ); + } + + $link['id'] = $this->linkDb->getNextId(); + $link['shorturl'] = link_small_hash($link['created'], $link['id']); + + // note: general relative URL + if (empty($link['url'])) { + $link['url'] = '?' . $link['shorturl']; + } + + if (empty($link['title'])) { + $link['title'] = $link['url']; + } + + $this->linkDb[$link['id']] = $link; + $this->linkDb->save($this->conf->get('resource.page_cache')); + $out = ApiUtils::formatLink($link, index_url($this->ci['environment'])); + $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]); + return $response->withAddedHeader('Location', $redirect) + ->withJson($out, 201, $this->jsonStyle); + } } 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); // REST API routes $app->group('/api/v1', function() { - $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo'); - $this->get('/links', '\Shaarli\Api\Controllers\Links:getLinks'); - $this->get('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:getLink'); + $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo')->setName('getInfo'); + $this->get('/links', '\Shaarli\Api\Controllers\Links:getLinks')->setName('getLinks'); + $this->get('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:getLink')->setName('getLink'); + $this->post('/links', '\Shaarli\Api\Controllers\Links:postLink')->setName('postLink'); })->add('\Shaarli\Api\ApiMiddleware'); $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 @@ +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); + + $mock = $this->getMock('\Slim\Router', ['relativePathFor']); + $mock->expects($this->any()) + ->method('relativePathFor') + ->willReturn('api/v1/links/1'); + + // affect @property-read... seems to work + $this->controller->getCi()->router = $mock; + + // Used by index_url(). + $this->controller->getCi()['environment'] = [ + 'SERVER_NAME' => 'domain.tld', + 'SERVER_PORT' => 80, + 'SCRIPT_NAME' => '/', + ]; + } + + /** + * After every test, remove the test datastore. + */ + public function tearDown() + { + @unlink(self::$testDatastore); + } + + /** + * Test link creation without any field: creates a blank note. + */ + public function testPostLinkMinimal() + { + $env = Environment::mock([ + 'REQUEST_METHOD' => 'POST', + ]); + + $request = Request::createFromEnvironment($env); + + $response = $this->controller->postLink($request, new Response()); + $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals('api/v1/links/1', $response->getHeader('Location')[0]); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(self::NB_FIELDS_LINK, count($data)); + $this->assertEquals(43, $data['id']); + $this->assertRegExp('/[\w-_]{6}/', $data['shorturl']); + $this->assertEquals('http://domain.tld/?' . $data['shorturl'], $data['url']); + $this->assertEquals('?' . $data['shorturl'], $data['title']); + $this->assertEquals('', $data['description']); + $this->assertEquals([], $data['tags']); + $this->assertEquals(false, $data['private']); + $this->assertTrue(new \DateTime('5 seconds ago') < \DateTime::createFromFormat(\DateTime::ATOM, $data['created'])); + $this->assertEquals('', $data['updated']); + } + + /** + * Test link creation with all available fields. + */ + public function testPostLinkFull() + { + $link = [ + 'url' => 'website.tld/test?foo=bar', + 'title' => 'new entry', + 'description' => 'shaare description', + 'tags' => ['one', 'two'], + 'private' => true, + ]; + $env = Environment::mock([ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/json' + ]); + + $request = Request::createFromEnvironment($env); + $request = $request->withParsedBody($link); + $response = $this->controller->postLink($request, new Response()); + + $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals('api/v1/links/1', $response->getHeader('Location')[0]); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(self::NB_FIELDS_LINK, count($data)); + $this->assertEquals(43, $data['id']); + $this->assertRegExp('/[\w-_]{6}/', $data['shorturl']); + $this->assertEquals('http://' . $link['url'], $data['url']); + $this->assertEquals($link['title'], $data['title']); + $this->assertEquals($link['description'], $data['description']); + $this->assertEquals($link['tags'], $data['tags']); + $this->assertEquals(true, $data['private']); + $this->assertTrue(new \DateTime('2 seconds ago') < \DateTime::createFromFormat(\DateTime::ATOM, $data['created'])); + $this->assertEquals('', $data['updated']); + } + + /** + * Test link creation with an existing link (duplicate URL). Should return a 409 HTTP error and the existing link. + */ + public function testPostLinkDuplicate() + { + $link = [ + 'url' => 'mediagoblin.org/', + 'title' => 'new entry', + 'description' => 'shaare description', + 'tags' => ['one', 'two'], + 'private' => true, + ]; + $env = Environment::mock([ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/json' + ]); + + $request = Request::createFromEnvironment($env); + $request = $request->withParsedBody($link); + $response = $this->controller->postLink($request, new Response()); + + $this->assertEquals(409, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + $this->assertEquals(self::NB_FIELDS_LINK, count($data)); + $this->assertEquals(7, $data['id']); + $this->assertEquals('IuWvgA', $data['shorturl']); + $this->assertEquals('http://mediagoblin.org/', $data['url']); + $this->assertEquals('MediaGoblin', $data['title']); + $this->assertEquals('A free software media publishing platform #hashtagOther', $data['description']); + $this->assertEquals(['gnu', 'media', 'web', '.hidden', 'hashtag'], $data['tags']); + $this->assertEquals(false, $data['private']); + $this->assertEquals( + \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20130614_184135'), + \DateTime::createFromFormat(\DateTime::ATOM, $data['created']) + ); + $this->assertEquals( + \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20130615_184230'), + \DateTime::createFromFormat(\DateTime::ATOM, $data['updated']) + ); + } +} 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 0, DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20130614_184135'), 'gnu media web .hidden hashtag', - null, + DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20130615_184230'), 'IuWvgA' ); -- 2.41.0