Skip to content

Commit 0f8a444

Browse files
use new AP resolve code from SearchController in SearchRetrieveApi (#2013)
Co-authored-by: BentiGorlich <[email protected]>
1 parent 6e78037 commit 0f8a444

File tree

9 files changed

+621
-365
lines changed

9 files changed

+621
-365
lines changed

config/mbin_routes/search_api.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
api_search:
2-
controller: App\Controller\Api\Search\SearchRetrieveApi
2+
controller: App\Controller\Api\Search\SearchRetrieveApi::searchV1
33
path: /api/search
44
methods: [ GET ]
55
format: json
6+
7+
api_search_v2:
8+
controller: App\Controller\Api\Search\SearchRetrieveApi::searchV2
9+
path: /api/search/v2
10+
methods: [ GET ]
11+
format: json

src/Controller/Api/Search/SearchRetrieveApi.php

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66

77
use App\Controller\Api\BaseApi;
88
use App\Controller\Traits\PrivateContentTrait;
9+
use App\DTO\SearchResponseDto;
910
use App\Entity\Contracts\ContentInterface;
11+
use App\Entity\Entry;
12+
use App\Entity\EntryComment;
13+
use App\Entity\Magazine;
14+
use App\Entity\Post;
15+
use App\Entity\PostComment;
16+
use App\Entity\User;
1017
use App\Factory\MagazineFactory;
1118
use App\Factory\UserFactory;
1219
use App\Repository\SearchRepository;
@@ -18,6 +25,8 @@
1825
use Nelmio\ApiDocBundle\Attribute\Model;
1926
use OpenApi\Attributes as OA;
2027
use Symfony\Component\HttpFoundation\JsonResponse;
28+
use Symfony\Component\HttpFoundation\Response;
29+
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
2130
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
2231
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
2332

@@ -125,7 +134,8 @@ class SearchRetrieveApi extends BaseApi
125134
schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post'])
126135
)]
127136
#[OA\Tag(name: 'search')]
128-
public function __invoke(
137+
#[OA\Get(deprecated: true)]
138+
public function searchV1(
129139
SearchManager $manager,
130140
UserFactory $userFactory,
131141
MagazineFactory $magazineFactory,
@@ -197,4 +207,190 @@ public function __invoke(
197207
headers: $headers
198208
);
199209
}
210+
211+
#[OA\Response(
212+
response: 200,
213+
description: 'Returns a paginated list of content, along with any ActivityPub actors that matched the query by username, or ActivityPub objects that matched the query by URL. AP-Objects are not paginated.',
214+
content: new OA\JsonContent(
215+
type: 'object',
216+
properties: [
217+
new OA\Property(
218+
property: 'items',
219+
type: 'array',
220+
items: new OA\Items(ref: new Model(type: SearchResponseDto::class))
221+
),
222+
new OA\Property(
223+
property: 'pagination',
224+
ref: new Model(type: PaginationSchema::class)
225+
),
226+
new OA\Property(
227+
property: 'apResults',
228+
type: 'array',
229+
items: new OA\Items(ref: new Model(type: SearchResponseDto::class))
230+
),
231+
]
232+
),
233+
headers: [
234+
new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),
235+
new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),
236+
new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'),
237+
]
238+
)]
239+
#[OA\Response(
240+
response: 400,
241+
description: 'The search query parameter `q` is required!',
242+
content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class))
243+
)]
244+
#[OA\Response(
245+
response: 401,
246+
description: 'Permission denied due to missing or expired token',
247+
content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class))
248+
)]
249+
#[OA\Response(
250+
response: 429,
251+
description: 'You are being rate limited',
252+
content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)),
253+
headers: [
254+
new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),
255+
new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),
256+
new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'),
257+
]
258+
)]
259+
#[OA\Parameter(
260+
name: 'p',
261+
description: 'Page of items to retrieve',
262+
in: 'query',
263+
schema: new OA\Schema(type: 'integer', default: 1, minimum: 1)
264+
)]
265+
#[OA\Parameter(
266+
name: 'perPage',
267+
description: 'Number of items per page',
268+
in: 'query',
269+
schema: new OA\Schema(type: 'integer', default: SearchRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)
270+
)]
271+
#[OA\Parameter(
272+
name: 'q',
273+
description: 'Search term',
274+
in: 'query',
275+
required: true,
276+
schema: new OA\Schema(type: 'string')
277+
)]
278+
#[OA\Parameter(
279+
name: 'authorId',
280+
description: 'User id of the author',
281+
in: 'query',
282+
required: false,
283+
schema: new OA\Schema(type: 'integer')
284+
)]
285+
#[OA\Parameter(
286+
name: 'magazineId',
287+
description: 'Id of the magazine',
288+
in: 'query',
289+
required: false,
290+
schema: new OA\Schema(type: 'integer')
291+
)]
292+
#[OA\Parameter(
293+
name: 'type',
294+
description: 'The type of content',
295+
in: 'query',
296+
required: false,
297+
schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post'])
298+
)]
299+
#[OA\Tag(name: 'search')]
300+
public function searchV2(
301+
SearchManager $manager,
302+
RateLimiterFactoryInterface $apiReadLimiter,
303+
RateLimiterFactoryInterface $anonymousApiReadLimiter,
304+
#[MapQueryParameter(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
305+
string $q,
306+
#[MapQueryParameter(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
307+
int $perPage = SearchRepository::PER_PAGE,
308+
#[MapQueryParameter('authorId', validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
309+
?int $authorId = null,
310+
#[MapQueryParameter('magazineId', validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
311+
?int $magazineId = null,
312+
#[MapQueryParameter]
313+
?string $type = null,
314+
): JsonResponse {
315+
$headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);
316+
317+
$request = $this->request->getCurrentRequest();
318+
$page = $this->getPageNb($request);
319+
320+
if ('entry' !== $type && 'post' !== $type && null !== $type) {
321+
throw new BadRequestHttpException();
322+
}
323+
324+
/** @var ?SearchResponseDto[] $searchResults */
325+
$searchResults = [];
326+
$items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type);
327+
foreach ($items->getCurrentPageResults() as $item) {
328+
$searchResults[] = $this->serializeItem($item);
329+
}
330+
331+
/** @var ?SearchResponseDto $apResults */
332+
$apResults = [];
333+
if ($this->federatedSearchAllowed()) {
334+
$objects = $manager->findActivityPubActorsOrObjects($q);
335+
336+
foreach ($objects['errors'] as $error) {
337+
/** @var \Throwable $error */
338+
$this->logger->warning(
339+
'Exception while resolving AP handle / url {q}: {type}: {msg}',
340+
[
341+
'q' => $q,
342+
'type' => \get_class($error),
343+
'msg' => $error->getMessage(),
344+
]
345+
);
346+
}
347+
348+
foreach ($objects['results'] as $object) {
349+
$apResults[] = $this->serializeItem($object['object']);
350+
}
351+
}
352+
353+
$response = $this->serializePaginated($searchResults, $items);
354+
$response['apResults'] = $apResults;
355+
356+
return new JsonResponse(
357+
$response,
358+
headers: $headers
359+
);
360+
}
361+
362+
private function federatedSearchAllowed(): bool
363+
{
364+
return !$this->settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN')
365+
|| $this->getUser();
366+
}
367+
368+
private function serializeItem(object $item): ?SearchResponseDto
369+
{
370+
if ($item instanceof Entry) {
371+
$this->handlePrivateContent($item);
372+
373+
return new SearchResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
374+
} elseif ($item instanceof Post) {
375+
$this->handlePrivateContent($item);
376+
377+
return new SearchResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
378+
} elseif ($item instanceof EntryComment) {
379+
$this->handlePrivateContent($item);
380+
381+
return new SearchResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
382+
} elseif ($item instanceof PostComment) {
383+
$this->handlePrivateContent($item);
384+
385+
return new SearchResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
386+
} elseif ($item instanceof Magazine) {
387+
return new SearchResponseDto(magazine: $this->serializeMagazine($this->magazineFactory->createDto($item)));
388+
} elseif ($item instanceof User) {
389+
return new SearchResponseDto(user: $this->serializeUser($this->userFactory->createDto($item)));
390+
} else {
391+
$this->logger->error('Unexpected result type: '.\get_class($item));
392+
393+
return null;
394+
}
395+
}
200396
}

0 commit comments

Comments
 (0)