From ce2ac8f758b4d32c814a7393bc4125e87b7c27a4 Mon Sep 17 00:00:00 2001 From: Srijith Nair Date: Tue, 1 Jul 2025 07:13:33 +0400 Subject: [PATCH] Add annotations filter to entries API endpoint Implement a new filter parameter 'annotations' for the GET /api/entries endpoint that allows filtering entries based on whether they have annotations. When annotations=1, only entries with one or more annotations are returned. When annotations=0, only entries without annotations are returned. This feature enables users to easily find annotated content through the API. --- src/Controller/Api/EntryRestController.php | 16 +++- src/Repository/EntryRepository.php | 15 +++- .../Api/EntryRestControllerTest.php | 82 +++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/Controller/Api/EntryRestController.php b/src/Controller/Api/EntryRestController.php index 52e27ebc3..848ce1aff 100644 --- a/src/Controller/Api/EntryRestController.php +++ b/src/Controller/Api/EntryRestController.php @@ -290,6 +290,17 @@ class EntryRestController extends WallabagRestController * example="200", * ) * ), + * @OA\Parameter( + * name="annotations", + * in="query", + * description="filter entries with annotations. all entries by default", + * required=false, + * @OA\Schema( + * type="integer", + * enum={"1", "0"}, + * default="0" + * ) + * ), * @OA\Response( * response="200", * description="Returned when successful" @@ -315,6 +326,7 @@ class EntryRestController extends WallabagRestController $detail = strtolower($request->query->get('detail', 'full')); $domainName = (null === $request->query->get('domain_name')) ? '' : (string) $request->query->get('domain_name'); $httpStatus = (!\array_key_exists((int) $request->query->get('http_status'), Response::$statusTexts)) ? null : (int) $request->query->get('http_status'); + $hasAnnotations = (null === $request->query->get('annotations')) ? null : (bool) $request->query->get('annotations'); try { /** @var Pagerfanta $pager */ @@ -330,7 +342,8 @@ class EntryRestController extends WallabagRestController $detail, $domainName, $isNotParsed, - $httpStatus + $httpStatus, + $hasAnnotations ); } catch (\Exception $e) { throw new BadRequestHttpException($e->getMessage()); @@ -356,6 +369,7 @@ class EntryRestController extends WallabagRestController 'tags' => $tags, 'since' => $since, 'detail' => $detail, + 'annotations' => $hasAnnotations, ], true ) diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 6c48d5b3e..b60bafe84 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -268,16 +268,17 @@ class EntryRepository extends ServiceEntityRepository * @param string $order * @param int $since * @param string $tags - * @param string $detail 'metadata' or 'full'. Include content field if 'full' + * @param string $detail 'metadata' or 'full'. Include content field if 'full' * @param string $domainName * @param int $httpStatus * @param bool $isNotParsed + * @param bool $hasAnnotations * * @todo Breaking change: replace default detail=full by detail=metadata in a future version * * @return Pagerfanta */ - public function findEntries($userId, $isArchived = null, $isStarred = null, $isPublic = null, $sort = 'created', $order = 'asc', $since = 0, $tags = '', $detail = 'full', $domainName = '', $isNotParsed = null, $httpStatus = null) + public function findEntries($userId, $isArchived = null, $isStarred = null, $isPublic = null, $sort = 'created', $order = 'asc', $since = 0, $tags = '', $detail = 'full', $domainName = '', $isNotParsed = null, $httpStatus = null, $hasAnnotations = null) { if (!\in_array(strtolower($detail), ['full', 'metadata'], true)) { throw new \Exception('Detail "' . $detail . '" parameter is wrong, allowed: full or metadata'); @@ -342,6 +343,16 @@ class EntryRepository extends ServiceEntityRepository $qb->andWhere('e.domainName = :domainName')->setParameter('domainName', $domainName); } + if (null !== $hasAnnotations) { + if ($hasAnnotations) { + $qb->leftJoin('e.annotations', 'a') + ->andWhere('a.id IS NOT NULL'); + } else { + $qb->leftJoin('e.annotations', 'a') + ->andWhere('a.id IS NULL'); + } + } + if (!\in_array(strtolower($order), ['asc', 'desc'], true)) { throw new \Exception('Order "' . $order . '" parameter is wrong, allowed: asc or desc'); } diff --git a/tests/Controller/Api/EntryRestControllerTest.php b/tests/Controller/Api/EntryRestControllerTest.php index 4719a746b..edb379447 100644 --- a/tests/Controller/Api/EntryRestControllerTest.php +++ b/tests/Controller/Api/EntryRestControllerTest.php @@ -227,6 +227,7 @@ class EntryRestControllerTest extends WallabagApiTestCase 'public' => 0, 'notParsed' => 0, 'http_status' => 200, + 'annotations' => 1, ]); $this->assertSame(200, $this->client->getResponse()->getStatusCode()); @@ -254,6 +255,7 @@ class EntryRestControllerTest extends WallabagApiTestCase $this->assertStringContainsString('tags=foo', $content['_links'][$link]['href']); $this->assertStringContainsString('since=1443274283', $content['_links'][$link]['href']); $this->assertStringContainsString('public=0', $content['_links'][$link]['href']); + $this->assertStringContainsString('annotations=1', $content['_links'][$link]['href']); } $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); @@ -305,6 +307,86 @@ class EntryRestControllerTest extends WallabagApiTestCase $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); } + public function testGetEntriesWithAnnotationsFilter() + { + // Test filter for entries WITH annotations + // From fixtures: entry1 and entry2 have annotations, entry4, entry5, entry6, entry7 don't + $this->client->request('GET', '/api/entries', [ + 'annotations' => 1, + ]); + + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + + $content = json_decode($this->client->getResponse()->getContent(), true); + + $this->assertArrayHasKey('items', $content['_embedded']); + + // Check that only entries with annotations are returned + $entriesWithAnnotations = ['http://0.0.0.0/entry1', 'http://0.0.0.0/entry2']; + $entriesWithoutAnnotations = ['http://0.0.0.0/entry4', 'http://0.0.0.0/entry5', 'http://0.0.0.0/entry6', 'http://0.0.0.0/entry7']; + + foreach ($content['_embedded']['items'] as $item) { + if (\in_array($item['url'], $entriesWithAnnotations, true)) { + $this->assertNotEmpty($item['annotations'], 'Entry with URL ' . $item['url'] . ' should have annotations'); + } + $this->assertNotContains($item['url'], $entriesWithoutAnnotations, 'Entry without annotations should NOT be in the results'); + } + + // Ensure we have at least the entries with annotations + $foundUrls = array_column($content['_embedded']['items'], 'url'); + $this->assertContains('http://0.0.0.0/entry1', $foundUrls, 'entry1 with annotations should be in the results'); + $this->assertContains('http://0.0.0.0/entry2', $foundUrls, 'entry2 with annotations should be in the results'); + + // Check pagination links contain the filter + $this->assertArrayHasKey('_links', $content); + foreach (['self', 'first', 'last'] as $link) { + $this->assertArrayHasKey('href', $content['_links'][$link]); + $this->assertStringContainsString('annotations=1', $content['_links'][$link]['href']); + } + + $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); + } + + public function testGetEntriesWithoutAnnotationsFilter() + { + // Test filter for entries WITHOUT annotations + // From fixtures: entry1 and entry2 have annotations, entry4, entry5, entry6, entry7 don't + $this->client->request('GET', '/api/entries', [ + 'annotations' => 0, + ]); + + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + + $content = json_decode($this->client->getResponse()->getContent(), true); + + $this->assertArrayHasKey('items', $content['_embedded']); + + // Check that only entries without annotations are returned + $entriesWithAnnotations = ['http://0.0.0.0/entry1', 'http://0.0.0.0/entry2']; + $entriesWithoutAnnotations = ['http://0.0.0.0/entry4', 'http://0.0.0.0/entry5', 'http://0.0.0.0/entry6', 'http://0.0.0.0/entry7']; + + foreach ($content['_embedded']['items'] as $item) { + $this->assertNotContains($item['url'], $entriesWithAnnotations, 'Entry with annotations should NOT be in the results'); + if (\in_array($item['url'], $entriesWithoutAnnotations, true)) { + $this->assertEmpty($item['annotations'], 'Entry with URL ' . $item['url'] . ' should not have annotations'); + } + } + + // Ensure we have at least some entries without annotations + $foundUrls = array_column($content['_embedded']['items'], 'url'); + $foundWithoutAnnotations = array_intersect($foundUrls, $entriesWithoutAnnotations); + $this->assertNotEmpty($foundWithoutAnnotations, 'Should have at least one entry without annotations in the results'); + + // Check pagination links contain the filter + $this->assertArrayHasKey('_links', $content); + foreach (['self', 'first', 'last'] as $link) { + $this->assertArrayHasKey('href', $content['_links'][$link]); + $this->assertStringContainsString('annotations=0', $content['_links'][$link]['href']); + } + + $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); + } + public function testGetEntriesOnPageTwo() { $this->client->request('GET', '/api/entries', [