|
6 | 6 |
|
7 | 7 | use App\Controller\Api\BaseApi; |
8 | 8 | use App\Controller\Traits\PrivateContentTrait; |
| 9 | +use App\DTO\SearchResponseDto; |
9 | 10 | 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; |
10 | 17 | use App\Factory\MagazineFactory; |
11 | 18 | use App\Factory\UserFactory; |
12 | 19 | use App\Repository\SearchRepository; |
|
18 | 25 | use Nelmio\ApiDocBundle\Attribute\Model; |
19 | 26 | use OpenApi\Attributes as OA; |
20 | 27 | use Symfony\Component\HttpFoundation\JsonResponse; |
| 28 | +use Symfony\Component\HttpFoundation\Response; |
| 29 | +use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; |
21 | 30 | use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; |
22 | 31 | use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; |
23 | 32 |
|
@@ -125,7 +134,8 @@ class SearchRetrieveApi extends BaseApi |
125 | 134 | schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post']) |
126 | 135 | )] |
127 | 136 | #[OA\Tag(name: 'search')] |
128 | | - public function __invoke( |
| 137 | + #[OA\Get(deprecated: true)] |
| 138 | + public function searchV1( |
129 | 139 | SearchManager $manager, |
130 | 140 | UserFactory $userFactory, |
131 | 141 | MagazineFactory $magazineFactory, |
@@ -197,4 +207,190 @@ public function __invoke( |
197 | 207 | headers: $headers |
198 | 208 | ); |
199 | 209 | } |
| 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 | + } |
200 | 396 | } |
0 commit comments