1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2024-12-22 13:12:41 +00:00

Some work on categories

This commit is contained in:
J. King 2020-12-11 23:47:13 -05:00
parent 2e6c5d2ad2
commit 3ebb46f48e
5 changed files with 135 additions and 34 deletions

View file

@ -13,9 +13,9 @@
<dd><a href="https://miniflux.app/docs/api.html">API Reference</a></dd> <dd><a href="https://miniflux.app/docs/api.html">API Reference</a></dd>
</dl> </dl>
The Miniflux protocol is a well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient. The Miniflux protocol is a fairly well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient and has more capabilities.
Miniflux version 2.0.25 is emulated, though not all features are implemented Miniflux version 2.0.26 is emulated, though not all features are implemented
# Missing features # Missing features
@ -28,8 +28,15 @@ Miniflux version 2.0.25 is emulated, though not all features are implemented
# Differences # Differences
- Various error messages differ due to significant implementation differences
- Only the URL should be considered reliable in feed discovery results - Only the URL should be considered reliable in feed discovery results
- The "All" category is treated specially (see below for details)
- Category names consisting only of whitespace are rejected along with the empty string
# Interaction with nested folders # Special handling of the "All" category
Tiny Tiny RSS is unique in allowing newsfeeds to be grouped into folders nested to arbitrary depth. When newsfeeds are placed into nested folders, they simply appear in the top-level folder when accessed via the Miniflux protocol. This does not affect OPML exports, where full nesting is preserved. Nextcloud News' root folder and Tiny Tiny RSS' "Uncategorized" catgory are mapped to Miniflux's initial "All" category. This Miniflux category can be renamed, but it cannot be deleted. Attempting to do so will delete the child feeds it contains, but not the category itself.
# Interaction with nested categories
Tiny Tiny RSS is unique in allowing newsfeeds to be grouped into categories nested to arbitrary depth. When newsfeeds are placed into nested categories, they simply appear in the top-level category when accessed via the Miniflux protocol. This does not affect OPML exports, where full nesting is preserved.

View file

@ -32,27 +32,31 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'username' => "string", 'username' => "string",
'password' => "string", 'password' => "string",
'user_agent' => "string", 'user_agent' => "string",
'title' => "string",
]; ];
protected const PATHS = [ protected const PATHS = [
'/categories' => ['GET' => "getCategories", 'POST' => "createCategory"], '/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
'/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"], '/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
'/discover' => ['POST' => "discoverSubscriptions"], '/categories/1/mark-all-as-read' => ['PUT' => "markCategory"],
'/entries' => ['GET' => "getEntries", 'PUT' => "updateEntries"], '/discover' => ['POST' => "discoverSubscriptions"],
'/entries/1' => ['GET' => "getEntry"], '/entries' => ['GET' => "getEntries", 'PUT' => "updateEntries"],
'/entries/1/bookmark' => ['PUT' => "toggleEntryBookmark"], '/entries/1' => ['GET' => "getEntry"],
'/export' => ['GET' => "opmlExport"], '/entries/1/bookmark' => ['PUT' => "toggleEntryBookmark"],
'/feeds' => ['GET' => "getFeeds", 'POST' => "createFeed"], '/export' => ['GET' => "opmlExport"],
'/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"], '/feeds' => ['GET' => "getFeeds", 'POST' => "createFeed"],
'/feeds/1/entries/1' => ['GET' => "getFeedEntry"], '/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"],
'/feeds/1/entries' => ['GET' => "getFeedEntries"], '/feeds/1/mark-all-as-read' => ['PUT' => "markFeed"],
'/feeds/1/icon' => ['GET' => "getFeedIcon"], '/feeds/1/entries/1' => ['GET' => "getFeedEntry"],
'/feeds/1/refresh' => ['PUT' => "refreshFeed"], '/feeds/1/entries' => ['GET' => "getFeedEntries"],
'/feeds/refresh' => ['PUT' => "refreshAllFeeds"], '/feeds/1/icon' => ['GET' => "getFeedIcon"],
'/import' => ['POST' => "opmlImport"], '/feeds/1/refresh' => ['PUT' => "refreshFeed"],
'/me' => ['GET' => "getCurrentUser"], '/feeds/refresh' => ['PUT' => "refreshAllFeeds"],
'/users' => ['GET' => "getUsers", 'POST' => "createUser"], '/import' => ['POST' => "opmlImport"],
'/users/1' => ['GET' => "getUserByNum", 'PUT' => "updateUserByNum", 'DELETE' => "deleteUser"], '/me' => ['GET' => "getCurrentUser"],
'/users/*' => ['GET' => "getUserById"], '/users' => ['GET' => "getUsers", 'POST' => "createUser"],
'/users/1' => ['GET' => "getUserByNum", 'PUT' => "updateUserByNum", 'DELETE' => "deleteUser"],
'/users/1/mark-all-as-read' => ['PUT' => "markAll"],
'/users/*' => ['GET' => "getUserById"],
]; ];
protected const ADMIN_FUNCTIONS = [ protected const ADMIN_FUNCTIONS = [
'getUsers' => true, 'getUsers' => true,
@ -85,7 +89,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return true; return true;
} }
} }
// next check HTTP auth // next check HTTP auth
if ($req->getAttribute("authenticated", false)) { if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser"); Arsse::$user->id = $req->getAttribute("authenticatedUser");
return true; return true;
@ -255,7 +259,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return $out; return $out;
} }
protected function discoverSubscriptions(array $path, array $query, array $data) { protected function discoverSubscriptions(array $path, array $query, array $data): ResponseInterface {
try { try {
$list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']); $list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']);
} catch (FeedException $e) { } catch (FeedException $e) {
@ -274,11 +278,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out); return new Response($out);
} }
protected function getUsers(array $path, array $query, array $data) { protected function getUsers(array $path, array $query, array $data): ResponseInterface {
return new Response($this->listUsers(Arsse::$user->list(), false)); return new Response($this->listUsers(Arsse::$user->list(), false));
} }
protected function getUserById(array $path, array $query, array $data) { protected function getUserById(array $path, array $query, array $data): ResponseInterface {
try { try {
return new Response($this->listUsers([$path[1]], true)[0] ?? new \stdClass); return new Response($this->listUsers([$path[1]], true)[0] ?? new \stdClass);
} catch (UserException $e) { } catch (UserException $e) {
@ -286,7 +290,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} }
} }
protected function getUserByNum(array $path, array $query, array $data) { protected function getUserByNum(array $path, array $query, array $data): ResponseInterface {
try { try {
$user = Arsse::$user->lookup((int) $path[1]); $user = Arsse::$user->lookup((int) $path[1]);
return new Response($this->listUsers([$user], true)[0] ?? new \stdClass); return new Response($this->listUsers([$user], true)[0] ?? new \stdClass);
@ -295,11 +299,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} }
} }
protected function getCurrentUser(array $path, array $query, array $data) { protected function getCurrentUser(array $path, array $query, array $data): ResponseInterface {
return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass); return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
} }
protected function getCategories(array $path, array $query, array $data) { protected function getCategories(array $path, array $query, array $data): ResponseInterface {
$out = []; $out = [];
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false); $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
// add the root folder as a category // add the root folder as a category
@ -312,6 +316,45 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out); return new Response($out);
} }
protected function createCategory(array $path, array $query, array $data): ResponseInterface {
try {
$id = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => (string) $data['title']]);
} catch (ExceptionInput $e) {
if ($e->getCode() === 10236) {
return new ErrorResponse(["DuplicateCategory", 'title' => $data['title']], 500);
} else {
return new ErrorResponse(["InvalidCategory", 'title' => $data['title']], 500);
}
}
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']]);
}
protected function updateCategory(array $path, array $query, array $data): ResponseInterface {
$folder = $path[1] - 1;
$title = $data['title'] ?? "";
try {
if ($folder === 0) {
if (!strlen(trim($title))) {
throw new ExceptionInput("whitespace");
}
$title = Arsse::$user->propertiesSet(Arsse::$user->id, ['root_folder_name' => $title])['root_folder_name'];
} else {
Arsse::$db->folderPropertiesSet(Arsse::$user->id, $folder, ['name' => $title]);
}
} catch (ExceptionInput $e) {
if ($e->getCode() === 10236) {
return new ErrorResponse(["DuplicateCategory", 'title' => $title], 500);
} elseif ($e->getCode === 10239) {
return new ErrorResponse("404", 404);
} else {
return new ErrorResponse(["InvalidCategory", 'title' => $title], 500);
}
}
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
return new Response(['id' => (int) $path[1], 'title' => $title, 'user_id' => $meta['num']]);
}
public static function tokenGenerate(string $user, string $label): string { public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet // Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH))); $t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));

View file

@ -17,7 +17,9 @@ return [
'API.Miniflux.Error.Fetch401' => 'You are not authorized to access this resource (invalid username/password)', 'API.Miniflux.Error.Fetch401' => 'You are not authorized to access this resource (invalid username/password)',
'API.Miniflux.Error.Fetch403' => 'Unable to fetch this resource (Status Code = 403)', 'API.Miniflux.Error.Fetch403' => 'Unable to fetch this resource (Status Code = 403)',
'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource', 'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource',
'API.Miniflux.Error.DuplicateCategory' => 'Category "{title}" already exists',
'API.Miniflux.Error.InvalidCategory' => 'Invalid category title "{title}"',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized', 'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special', 'API.TTRSS.Category.Special' => 'Special',
'API.TTRSS.Category.Labels' => 'Labels', 'API.TTRSS.Category.Labels' => 'Labels',

View file

@ -16,7 +16,7 @@ class TestErrorResponse extends \JKingWeb\Arsse\Test\AbstractTest {
} }
public function testCreateVariableResponse(): void { public function testCreateVariableResponse(): void {
$act = new ErrorResponse(["invalidBodyJSON", "Doh!"], 401); $act = new ErrorResponse(["InvalidBodyJSON", "Doh!"], 401);
$this->assertSame('{"error_message":"Invalid JSON payload: Doh!"}', (string) $act->getBody()); $this->assertSame('{"error_message":"Invalid JSON payload: Doh!"}', (string) $act->getBody());
} }
} }

View file

@ -18,6 +18,7 @@ use JKingWeb\Arsse\User\ExceptionConflict;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response; use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\EmptyResponse;
use JKingWeb\Arsse\Test\Result;
/** @covers \JKingWeb\Arsse\REST\Miniflux\V1<extended> */ /** @covers \JKingWeb\Arsse\REST\Miniflux\V1<extended> */
class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
@ -79,8 +80,9 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void { public function setUp(): void {
self::clearData(); self::clearData();
self::setConf(); self::setConf();
// create a mock user manager // create a mock user manager; we use a PHPUnitmock because Phake for reasons unknown is unable to mock the User class correctly, sometimes
Arsse::$user = \Phake::mock(User::class); Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("propertiesGet")->willReturn(['num' => 42, 'admin' => false, 'root_folder_name' => null]);
// create a mock database interface // create a mock database interface
Arsse::$db = \Phake::mock(Database::class); Arsse::$db = \Phake::mock(Database::class);
$this->transaction = \Phake::mock(Transaction::class); $this->transaction = \Phake::mock(Transaction::class);
@ -234,4 +236,51 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[false, "/users/47", new ErrorResponse("403", 403)], [false, "/users/47", new ErrorResponse("403", 403)],
]; ];
} }
public function testListCategories(): void {
\Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
['id' => 1, 'name' => "Science"],
['id' => 20, 'name' => "Technology"],
])));
$exp = new Response([
['id' => 1, 'title' => "All", 'user_id' => 42],
['id' => 2, 'title' => "Science", 'user_id' => 42],
['id' => 21, 'title' => "Technology", 'user_id' => 42],
]);
$this->assertMessage($exp, $this->req("GET", "/categories"));
\Phake::verify(Arsse::$db)->folderList("john.doe@example.com", null, false);
// run test again with a renamed root folder
Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("propertiesGet")->willReturn(['num' => 47, 'admin' => false, 'root_folder_name' => "Uncategorized"]);
$exp = new Response([
['id' => 1, 'title' => "Uncategorized", 'user_id' => 47],
['id' => 2, 'title' => "Science", 'user_id' => 47],
['id' => 21, 'title' => "Technology", 'user_id' => 47],
]);
$this->assertMessage($exp, $this->req("GET", "/categories"));
}
/** @dataProvider provideCategoryAdditions */
public function testAddACategory($title, ResponseInterface $exp): void {
if (!strlen((string) $title)) {
\Phake::when(Arsse::$db)->folderAdd->thenThrow(new ExceptionInput("missing"));
} elseif (!strlen(trim((string) $title))) {
\Phake::when(Arsse::$db)->folderAdd->thenThrow(new ExceptionInput("whitespace"));
} elseif ($title === "Duplicate") {
\Phake::when(Arsse::$db)->folderAdd->thenThrow(new ExceptionInput("constraintViolation"));
} else {
\Phake::when(Arsse::$db)->folderAdd->thenReturn(2111);
}
$this->assertMessage($exp, $this->req("POST", "/categories", ['title' => $title]));
}
public function provideCategoryAdditions(): iterable {
return [
["New", new Response(['id' => 2112, 'title' => "New", 'user_id' => 42])],
["Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)],
["", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
[" ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
[false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
];
}
} }