added scraper service and rewritten anime controller

This commit is contained in:
pushrbx 2023-01-15 15:49:33 +00:00
parent a145f18bbd
commit 69d66378be
68 changed files with 1788 additions and 1030 deletions

View File

@ -0,0 +1,11 @@
<?php
namespace App\Concerns;
trait ScraperCacheTtl
{
protected function cacheTtl(): int
{
return (int) env('CACHE_DEFAULT_EXPIRE');
}
}

View File

@ -1,186 +0,0 @@
<?php
namespace App\Concerns;
use App\Contracts\Repository;
use App\Http\HttpHelper;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Jikan\MyAnimeList\MalClient;
use MongoDB\BSON\UTCDateTime;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
trait ScraperResultCache
{
protected function queryFromScraperCacheByFingerPrint(string $cacheTableName, string $requestFingerPrint, \Closure $getMalDataCallback, ?int $page = null): Collection
{
$queryable = DB::table($cacheTableName);
$results = $this->getScraperCacheByFingerPrint($queryable, $requestFingerPrint);
if (
$results->isEmpty()
|| $this->isExpired($cacheTableName, $results)
) {
$page = $page ?? 1;
$data = App::call(function (MalClient $jikan) use ($getMalDataCallback, $page) {
return $getMalDataCallback($jikan, $page);
});
$response = $this->serializeScraperResult($data);
$results = $this->updateCacheByFingerPrint($queryable, $requestFingerPrint, $results, $response);
}
return $results;
}
/**
* @param Repository $repository
* @param int $id
* @param string $requestFingerPrint
* @return Collection
* @throws NotFoundHttpException
*/
protected function queryFromScraperCacheById(Repository $repository, int $id, string $requestFingerPrint): Collection
{
$results = $repository->getAllByMalId($id);
$tableName = $repository->tableName();
if ($results->isEmpty() || $this->isExpired($tableName, $results)) {
$response = $repository->scrape($id);
if (HttpHelper::hasError($response)) {
abort(404, "Resource not found.");
}
$results = $this->updateCacheById($repository, $id, $requestFingerPrint, $results, $response);
}
if ($results->isEmpty()) {
abort(404, "Resource not found.");
}
return $results;
}
private function serializeScraperResult($data): array
{
$serializer = app("SerializerV4");
return $serializer->toArray($data);
}
private function prepareScraperResponse(string $requestFingerPrint, Collection $results, array $response): array
{
// If resource doesn't exist, prepare meta
if ($results->isEmpty()) {
$meta = [
'createdAt' => new UTCDateTime(),
'request_hash' => $requestFingerPrint
];
}
// Update `modifiedAt` meta
$meta['modifiedAt'] = new UTCDateTime();
// join meta data with response
return $meta + $response;
}
private function updateCacheById(Repository $repository, int $id, string $requestFingerPrint, Collection $results, array $response): Collection
{
$response = $this->prepareScraperResponse($requestFingerPrint, $results, $response);
if ($results->isEmpty()) {
$repository->insert($response);
}
if ($this->isExpired($repository->tableName(), $results)) {
$repository->queryByMalId($id)->update($response);
}
return $repository->getAllByMalId($id);
}
private function updateCacheByFingerPrint(QueryBuilder $queryable, string $requestFingerPrint, Collection $results, array $response): Collection
{
$response = $this->prepareScraperResponse($requestFingerPrint, $results, $response);
// insert cache if resource doesn't exist
if ($results->isEmpty()) {
$queryable->insert($response);
}
// update cache if resource exists
if ($this->isExpired($queryable->from, $results)) {
$this->getQueryableByFingerPrint($queryable, $requestFingerPrint)->update($response);
}
return $this->getScraperCacheByFingerPrint($queryable, $requestFingerPrint);
}
/**
* @template T of Response
* @param string $requestFingerPrint
* @param Collection $results
* @param T $response
* @return T
*/
protected function prepareResponse(string $requestFingerPrint, Collection $results, $response)
{
return $response
->header("X-Request-Fingerprint", $requestFingerPrint)
->setTtl($this->getTtl())
->setExpires(Carbon::createFromTimestamp($this->getExpiry($results)))
->setLastModified(Carbon::createFromTimestamp($this->getLastModified($results)));
}
protected function getQueryableByFingerPrint(QueryBuilder|EloquentBuilder $queryable, string $requestFingerPrint): EloquentBuilder
{
return $queryable->where("request_hash", $requestFingerPrint);
}
protected function getScraperCacheByFingerPrint(QueryBuilder|EloquentBuilder $queryable, string $requestFingerPrint): Collection
{
return $this->getQueryableByFingerPrint($queryable, $requestFingerPrint)->get();
}
private function getLastModified(Collection $results) : ?int
{
if (is_array($results->first())) {
return (int) $results->first()['modifiedAt']->toDateTime()->format('U');
}
if (is_object($results->first())) {
return (int) $results->first()->modifiedAt->toDateTime()->format('U');
}
return null;
}
protected function getTtl(): int
{
return (int) env('CACHE_DEFAULT_EXPIRE');
}
private function getExpiry(Collection $results): int
{
$modifiedAt = $this->getLastModified($results);
$ttl = $this->getTtl();
return $modifiedAt !== null ? $ttl + $modifiedAt : $ttl;
}
private function isExpired(string $cacheTableName, Collection $results): bool
{
$lastModified = $this->getLastModified($results);
if ($lastModified === null) {
return true;
}
$expiry = $this->getExpiry($results);
return time() > $expiry;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Contracts;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Representation of a service which knows about cached MAL scraper results.
*/
interface CachedScraperService
{
/**
* Finds cached scraper results by cacheKey, if not found scrapes them from MAL via the provided callback.
* @param string $cacheKey
* @param \Closure $getMalDataCallback
* @param int|null $page
* @return CachedData
*/
public function findList(string $cacheKey, \Closure $getMalDataCallback, ?int $page = null): CachedData;
/**
* Finds cached scraper results by id in the database, if not found scrapes them from MAL.
* @param int $id
* @param string $cacheKey
* @return CachedData
* @throws NotFoundHttpException
*/
public function find(int $id, string $cacheKey): CachedData;
public function findByKey(string $key, mixed $val, string $cacheKey): CachedData;
public function get(string $cacheKey): CachedData;
public function augmentResponse(JsonResponse|Response $response, string $cacheKey, CachedData $scraperResults): JsonResponse|Response;
}

View File

@ -2,11 +2,6 @@
namespace App\Contracts; namespace App\Contracts;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Http\Response;
use Spatie\LaravelData\Data;
interface Mediator interface Mediator
{ {
/** /**

View File

@ -3,7 +3,7 @@
namespace App\Contracts; namespace App\Contracts;
use App\JikanApiModel; use App\JikanApiModel;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
@ -24,13 +24,13 @@ interface Repository extends RepositoryQuery
public function getAllByMalId(int $id): Collection; public function getAllByMalId(int $id): Collection;
public function queryByMalId(int $id): EloquentBuilder; public function queryByMalId(int $id): Builder;
public function tableName(): string; public function tableName(): string;
// fixme: this should not be here. // fixme: this should not be here.
// this is here because we have the "scrape" static method on models // this is here because we have the "scrape" static method on models
public function scrape(int $id): array; public function scrape(int|string $id): array;
public function insert(array $attributes): bool; public function insert(array $attributes): bool;
} }

View File

@ -3,7 +3,7 @@
namespace App\Contracts; namespace App\Contracts;
use App\JikanApiModel; use App\JikanApiModel;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Laravel\Scout\Builder as ScoutBuilder; use Laravel\Scout\Builder as ScoutBuilder;
@ -14,9 +14,9 @@ interface RepositoryQuery
{ {
/** /**
* @param Collection $params * @param Collection $params
* @return EloquentBuilder<T>|ScoutBuilder<T> * @return Builder<T>|ScoutBuilder<T>
*/ */
public function filter(Collection $params): EloquentBuilder|ScoutBuilder; public function filter(Collection $params): Builder|ScoutBuilder;
/** /**
* @param string $keywords * @param string $keywords
@ -24,4 +24,12 @@ interface RepositoryQuery
* @return ScoutBuilder<T> * @return ScoutBuilder<T>
*/ */
public function search(string $keywords, ?\Closure $callback = null): ScoutBuilder; public function search(string $keywords, ?\Closure $callback = null): ScoutBuilder;
/**
* Get a where filter query
* @param string $key
* @param mixed $value
* @return Builder
*/
public function where(string $key, mixed $value): Builder;
} }

View File

@ -2,6 +2,8 @@
namespace App\Contracts; namespace App\Contracts;
use App\Repositories\DocumentRepository;
interface UnitOfWork interface UnitOfWork
{ {
public function anime(): AnimeRepository; public function anime(): AnimeRepository;
@ -23,4 +25,11 @@ interface UnitOfWork
public function animeGenres(): GenreRepository; public function animeGenres(): GenreRepository;
public function mangaGenres(): GenreRepository; public function mangaGenres(): GenreRepository;
/**
* Returns the repository instance for a document collection which doesn't have a model representation.
* @param string $tableName
* @return DocumentRepository
*/
public function documents(string $tableName): DocumentRepository;
} }

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeCharactersLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Spatie\LaravelData\Attributes\Validation\Numeric;
use Spatie\LaravelData\Attributes\Validation\Required;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeEpisodeLookupCommand extends LookupDataCommand
{
#[Numeric, Required]
public int $episodeId;
/** @noinspection PhpUnused */
public static function fromMultiple(Request $request, int $id, int $episodeId): ?self
{
/**
* @var AnimeEpisodeLookupCommand $data
*/
$data = self::fromRequestAndKey($request, $id);
$data->episodeId = $episodeId;
return $data;
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
use Spatie\LaravelData\Attributes\Validation\Numeric;
use Spatie\LaravelData\Optional;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeEpisodesLookupCommand extends LookupDataCommand
{
#[Numeric]
public int|Optional $page;
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeExternalLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Dto;
use App\Casts\EnumCast;
use App\Enums\AnimeForumFilterEnum;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Optional;
use Spatie\Enum\Laravel\Rules\EnumRule;
use Spatie\LaravelData\Attributes\WithCast;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeForumLookupCommand extends LookupDataCommand
{
#[WithCast(EnumCast::class, AnimeForumFilterEnum::class)]
public AnimeForumFilterEnum|Optional $filter;
public static function rules(): array
{
return [
"filter" => [new EnumRule(AnimeForumFilterEnum::class)]
];
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeFullLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeMoreInfoLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
use Spatie\LaravelData\Attributes\Validation\Numeric;
use Spatie\LaravelData\Optional;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeNewsLookupCommand extends LookupDataCommand
{
#[Numeric]
public int|Optional $page;
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimePicturesLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeRecommendationsLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeRelationsLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Dto;
use App\Casts\EnumCast;
use App\Enums\AnimeReviewsSortEnum;
use Illuminate\Http\JsonResponse;
use Spatie\Enum\Laravel\Rules\EnumRule;
use Spatie\LaravelData\Attributes\Validation\BooleanType;
use Spatie\LaravelData\Attributes\Validation\Numeric;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Optional;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeReviewsLookupCommand extends LookupDataCommand
{
#[Numeric]
public int|Optional $page;
#[WithCast(EnumCast::class, AnimeReviewsSortEnum::class)]
public AnimeReviewsSortEnum|Optional $sort;
#[BooleanType]
public bool|Optional $spoilers;
#[BooleanType]
public bool|Optional $preliminary;
public static function rules(): array
{
return [
"sort" => [new EnumRule(AnimeReviewsSortEnum::class)]
];
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeStaffLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeStatsLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeStreamingLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeThemesLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
use Spatie\LaravelData\Attributes\Validation\Numeric;
use Spatie\LaravelData\Optional;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeUserUpdatesLookupCommand extends LookupDataCommand
{
#[Numeric]
public int|Optional $page;
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Optional;
use Spatie\LaravelData\Attributes\Validation\Numeric;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeVideosEpisodesLookupCommand extends LookupDataCommand
{
#[Numeric]
public int|Optional $page;
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class AnimeVideosLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Dto;
use App\Concerns\HasRequestFingerprint;
use App\Contracts\DataRequest;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Http\Response;
use Spatie\LaravelData\Attributes\Validation\Numeric;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Data;
/**
* Base class for all requests/commands which are for looking up things by id.
* @template T of ResourceCollection|JsonResource|Response
* @implements DataRequest<T>
*/
abstract class LookupDataCommand extends Data implements DataRequest
{
use HasRequestFingerprint;
#[Numeric, Required]
public int $id;
/** @noinspection PhpUnused */
public static function fromRequestAndKey(Request $request, int $id): self
{
$data = static::fromRequest($request);
$data->id = $id;
return $data;
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Dto;
use App\Concerns\HasRequestFingerprint;
use App\Contracts\DataRequest;
use Illuminate\Http\JsonResponse;
use Spatie\LaravelData\Attributes\Validation\Numeric;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Data;
/**
* @implements DataRequest<JsonResponse>
*/
class QueryFullAnimeCommand extends Data implements DataRequest
{
use HasRequestFingerprint;
#[Numeric, Required]
public int $id;
}

View File

@ -11,6 +11,7 @@ use Spatie\LaravelData\Attributes\Validation\Alpha;
use Spatie\LaravelData\Attributes\Validation\IntegerType; use Spatie\LaravelData\Attributes\Validation\IntegerType;
use Spatie\LaravelData\Attributes\Validation\Max; use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Attributes\Validation\Min; use Spatie\LaravelData\Attributes\Validation\Min;
use Spatie\LaravelData\Attributes\Validation\Numeric;
use Spatie\LaravelData\Attributes\Validation\Size; use Spatie\LaravelData\Attributes\Validation\Size;
use Spatie\LaravelData\Attributes\Validation\StringType; use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Attributes\WithCast; use Spatie\LaravelData\Attributes\WithCast;
@ -26,6 +27,9 @@ class SearchCommand extends Data
#[Max(255), StringType] #[Max(255), StringType]
public string|Optional $q; public string|Optional $q;
#[Numeric, Min(1)]
public int|Optional $page;
#[IntegerType, Min(1)] #[IntegerType, Min(1)]
public int|Optional $limit; public int|Optional $limit;

View File

@ -0,0 +1,12 @@
<?php
namespace App\Dto;
use Illuminate\Http\JsonResponse;
/**
* @extends LookupDataCommand<JsonResponse>
*/
final class UserByIdLookupCommand extends LookupDataCommand
{
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Enums;
use Spatie\Enum\Laravel\Enum;
/**
* @method static self all()
* @method static self episode()
* @method static self other()
*/
final class AnimeForumFilterEnum extends Enum
{
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Enums;
use Jikan\Helper\Constants;
use Spatie\Enum\Laravel\Enum;
/**
* @method static self mostVoted()
* @method static self newest()
* @method static self oldest()
*/
final class AnimeReviewsSortEnum extends Enum
{
protected static function labels(): array
{
return [
"mostVoted" => Constants::REVIEWS_SORT_MOST_VOTED,
"newest" => Constants::REVIEWS_SORT_NEWEST,
"oldest" => Constants::REVIEWS_SORT_OLDEST
];
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Features;
use App\Dto\AnimeCharactersLookupCommand;
use App\Http\Resources\V4\AnimeCharactersResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeCharactersAndStaffRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeCharactersLookupCommand, JsonResponse>
*/
final class AnimeCharactersLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new AnimeCharactersResource($results->first());
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeCharactersLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getAnimeCharactersAndStaff(new AnimeCharactersAndStaffRequest($id))
);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Features;
use App\Dto\AnimeEpisodeLookupCommand;
use App\Http\Resources\V4\AnimeEpisodeResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeEpisodeRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeEpisodeLookupCommand, JsonResponse>
*/
final class AnimeEpisodeLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new AnimeEpisodeResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeEpisodeLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
$episodeId = $requestParams->get("episodeId");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getAnimeEpisode(new AnimeEpisodeRequest($id, $episodeId)),
);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Features;
use App\Dto\AnimeEpisodesLookupCommand;
use App\Support\CachedData;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeEpisodesRequest;
final class AnimeEpisodesLookupHandler extends RequestHandlerWithScraperCache
{
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeEpisodesLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getAnimeEpisodes(new AnimeEpisodesRequest($id, $page)),
$requestParams->get("page", 1)
);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Features;
use App\Dto\AnimeExternalLookupCommand;
use App\Http\Resources\V4\ExternalLinksResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/**
* @extends ItemLookupHandler<AnimeExternalLookupCommand, JsonResponse>
*/
final class AnimeExternalLookupHandler extends ItemLookupHandler
{
protected function resource(Collection $results): JsonResource
{
return new ExternalLinksResource(
$results->first()
);
}
public function requestClass(): string
{
return AnimeExternalLookupCommand::class;
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Features;
use App\Dto\AnimeForumLookupCommand;
use App\Enums\AnimeForumFilterEnum;
use App\Http\Resources\V4\ForumResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeForumRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeForumLookupCommand, JsonResponse>
*/
final class AnimeForumLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new ForumResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeForumLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
$topic = $requestParams->get("filter", AnimeForumFilterEnum::all()->value);
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => collect(
["topics" => $jikan->getAnimeForum(new AnimeForumRequest($id, $topic))]
)
);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Features;
use App\Dto\AnimeStatsLookupCommand;
use App\Http\Resources\V4\MoreInfoResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeMoreInfoRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeStatsLookupCommand, JsonResponse>
*/
final class AnimeMoreInfoLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new MoreInfoResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeStatsLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => collect(
["moreinfo" => $jikan->getAnimeMoreInfo(new AnimeMoreInfoRequest($id))]
)
);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Features;
use App\Dto\AnimeNewsLookupCommand;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeNewsRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeNewsLookupCommand, JsonResponse>
*/
final class AnimeNewsLookupHandler extends RequestHandlerWithScraperCache
{
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeNewsLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getNewsList(new AnimeNewsRequest($id, $page)),
$requestParams->get("page", 1)
);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Features;
use App\Dto\AnimePicturesLookupCommand;
use App\Http\Resources\V4\PicturesResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimePicturesRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimePicturesLookupCommand, JsonResponse>
*/
final class AnimePicturesLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new PicturesResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimePicturesLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => collect(
["pictures" => $jikan->getAnimePictures(new AnimePicturesRequest($id))]
)
);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Features;
use App\Dto\AnimeRecommendationsLookupCommand;
use App\Http\Resources\V4\RecommendationsResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeRecommendationsRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeRecommendationsLookupCommand, JsonResponse>
*/
final class AnimeRecommendationsLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new RecommendationsResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeRecommendationsLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => collect(
["recommendations" => $jikan->getAnimeRecommendations(new AnimeRecommendationsRequest($id))]
)
);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Features;
use App\Dto\AnimeRelationsLookupCommand;
use App\Http\Resources\V4\AnimeRelationsResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/**
* @extends ItemLookupHandler<AnimeRelationsLookupCommand, JsonResponse>
*/
final class AnimeRelationsLookupHandler extends ItemLookupHandler
{
protected function resource(Collection $results): JsonResource
{
return new AnimeRelationsResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeRelationsLookupCommand::class;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Features;
use App\Dto\AnimeReviewsLookupCommand;
use App\Enums\AnimeReviewsSortEnum;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeReviewsRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeReviewsLookupCommand, JsonResponse>
*/
final class AnimeReviewsLookupHandler extends RequestHandlerWithScraperCache
{
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeReviewsLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
$sort = $requestParams->get("sort", AnimeReviewsSortEnum::mostVoted()->value);
$spoilers = $requestParams->get("spoilers", false);
$preliminary = $requestParams->get("preliminary", false);
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getAnimeReviews(new AnimeReviewsRequest(
$id, $page, $sort, $spoilers, $preliminary
)),
$requestParams->get("page", 1)
);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Features;
use App\Dto\AnimeStaffLookupCommand;
use App\Http\Resources\V4\AnimeStaffResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeCharactersAndStaffRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeStaffLookupCommand, JsonResponse>
*/
final class AnimeStaffLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new AnimeStaffResource($results->first());
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeStaffLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getAnimeCharactersAndStaff(new AnimeCharactersAndStaffRequest($id))
);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Features;
use App\Dto\AnimeStatsLookupCommand;
use App\Http\Resources\V4\AnimeStatisticsResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
/**
* @extends RequestHandlerWithScraperCache<AnimeStatsLookupCommand, JsonResponse>
*/
final class AnimeStatsLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new AnimeStatisticsResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeStatsLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getAnimeStats($id)
);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Features;
use App\Dto\AnimeStreamingLookupCommand;
use App\Http\Resources\V4\StreamingLinksResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/**
* @extends ItemLookupHandler<AnimeStreamingLookupCommand, JsonResponse>
*/
final class AnimeStreamingLookupHandler extends ItemLookupHandler
{
protected function resource(Collection $results): JsonResource
{
return new StreamingLinksResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeStreamingLookupCommand::class;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Features;
use App\Dto\AnimeThemesLookupCommand;
use App\Http\Resources\V4\StreamingLinksResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/**
* @extends ItemLookupHandler<AnimeThemesLookupCommand, JsonResponse>
*/
final class AnimeThemesLookupHandler extends ItemLookupHandler
{
protected function resource(Collection $results): JsonResource
{
return new StreamingLinksResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeThemesLookupCommand::class;
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Features;
use App\Dto\AnimeUserUpdatesLookupCommand;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeRecentlyUpdatedByUsersRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeUserUpdatesLookupCommand, JsonResponse>
*/
final class AnimeUserUpdatesLookupHandler extends RequestHandlerWithScraperCache
{
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeUserUpdatesLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getAnimeRecentlyUpdatedByUsers(
new AnimeRecentlyUpdatedByUsersRequest($id, $page)
),
$requestParams->get("page", 1)
);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Features;
use App\Dto\AnimeVideosEpisodesLookupCommand;
use App\Http\Resources\V4\AnimeEpisodesResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeVideosEpisodesRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeVideosEpisodesLookupCommand, JsonResponse>
*/
final class AnimeVideosEpisodesLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new AnimeEpisodesResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeVideosEpisodesLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getAnimeVideosEpisodes(new AnimeVideosEpisodesRequest($id, $page)),
$requestParams->get("page", 1)
);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Features;
use App\Dto\AnimeVideosLookupCommand;
use App\Http\Resources\V4\AnimeVideosResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Anime\AnimeVideosRequest;
/**
* @extends RequestHandlerWithScraperCache<AnimeVideosLookupCommand, JsonResponse>
*/
final class AnimeVideosLookupHandler extends RequestHandlerWithScraperCache
{
protected function resource(Collection $results): JsonResource
{
return new AnimeVideosResource(
$results->first()
);
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeVideosLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$id = $requestParams->get("id");
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getAnimeVideos(new AnimeVideosRequest($id))
);
}
}

View File

@ -2,9 +2,8 @@
namespace App\Features; namespace App\Features;
use App\Concerns\ScraperResultCache; use App\Contracts\CachedScraperService;
use App\Contracts\DataRequest; use App\Dto\LookupDataCommand;
use App\Contracts\Repository;
use App\Contracts\RequestHandler; use App\Contracts\RequestHandler;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
@ -14,34 +13,28 @@ use Spatie\LaravelData\Data;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/** /**
* @template TRequest of DataRequest<TResponse> * @template TRequest of LookupDataCommand<TResponse>
* @template TResponse of ResourceCollection|JsonResource|Response * @template TResponse of ResourceCollection|JsonResource|Response
* @implements RequestHandler<TRequest, TResponse> * @implements RequestHandler<TRequest, TResponse>
*/ */
abstract class ItemLookupHandler extends Data implements RequestHandler abstract class ItemLookupHandler extends Data implements RequestHandler
{ {
use ScraperResultCache; public function __construct(protected readonly CachedScraperService $scraperService)
public function __construct(protected readonly Repository $repository)
{ {
} }
/** /**
* @param TRequest $request * @param TRequest|LookupDataCommand<TResponse> $request
* @return TResponse * @return TResponse
* @throws NotFoundHttpException * @throws NotFoundHttpException
*/ */
public function handle($request) public function handle($request)
{ {
$requestFingerprint = $request->getFingerPrint(); $requestFingerprint = $request->getFingerPrint();
$results = $this->queryFromScraperCacheById( $results = $this->scraperService->find($request->id, $requestFingerprint);
$this->repository,
$request->id,
$request->getFingerPrint(),
);
$resource = $this->resource($results); $resource = $this->resource($results->collect());
return $this->prepareResponse($requestFingerprint, $results, $resource->response()); return $this->scraperService->augmentResponse($resource->response(), $requestFingerprint, $results);
} }
protected abstract function resource(Collection $results): JsonResource; protected abstract function resource(Collection $results): JsonResource;

View File

@ -0,0 +1,28 @@
<?php
namespace App\Features;
use App\Dto\AnimeLookupCommand;
use App\Http\Resources\V4\AnimeResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/**
* @extends ItemLookupHandler<AnimeLookupCommand, JsonResponse>
*/
final class QueryAnimeHandler extends ItemLookupHandler
{
protected function resource(Collection $results): JsonResource
{
return new AnimeResource($results->first());
}
/**
* @inheritDoc
*/
public function requestClass(): string
{
return AnimeLookupCommand::class;
}
}

View File

@ -2,9 +2,7 @@
namespace App\Features; namespace App\Features;
use App\Concerns\ScraperResultCache; use App\Dto\AnimeFullLookupCommand;
use App\Contracts\AnimeRepository;
use App\Dto\QueryFullAnimeCommand;
use App\Http\Resources\V4\AnimeFullResource; use App\Http\Resources\V4\AnimeFullResource;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@ -12,18 +10,13 @@ use Illuminate\Support\Collection;
/** /**
* @extends ItemLookupHandler<QueryFullAnimeCommand, JsonResponse> * @extends ItemLookupHandler<AnimeFullLookupCommand, JsonResponse>
*/ */
final class QueryFullAnimeHandler extends ItemLookupHandler final class QueryFullAnimeHandler extends ItemLookupHandler
{ {
public function __construct(AnimeRepository $repository)
{
parent::__construct($repository);
}
public function requestClass(): string public function requestClass(): string
{ {
return QueryFullAnimeCommand::class; return AnimeFullLookupCommand::class;
} }
protected function resource(Collection $results): JsonResource protected function resource(Collection $results): JsonResource

View File

@ -2,42 +2,19 @@
namespace App\Features; namespace App\Features;
use App\Concerns\ScraperResultCache;
use App\Contracts\RequestHandler;
use App\Dto\QueryTopReviewsCommand; use App\Dto\QueryTopReviewsCommand;
use App\Http\Resources\V4\ResultsResource; use App\Support\CachedData;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Jikan\Helper\Constants; use Jikan\Helper\Constants;
use Jikan\MyAnimeList\MalClient; use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Reviews\RecentReviewsRequest; use Jikan\Request\Reviews\RecentReviewsRequest;
/** /**
* @implements RequestHandler<QueryTopReviewsCommand, JsonResponse> * @extends RequestHandlerWithScraperCache<QueryTopReviewsCommand, JsonResponse>
*/ */
class QueryTopReviewsHandler implements RequestHandler final class QueryTopReviewsHandler extends RequestHandlerWithScraperCache
{ {
use ScraperResultCache;
/**
* @param QueryTopReviewsCommand $request
* @returns JsonResponse
*/
public function handle($request): JsonResponse
{
$requestParams = collect($request->all());
$requestFingerPrint = $request->getFingerPrint();
$results = $this->queryFromScraperCacheByFingerPrint(
"reviews",
$requestFingerPrint,
fn (MalClient $jikan, int $page) => $jikan->getRecentReviews(
new RecentReviewsRequest(Constants::RECENT_REVIEW_BEST_VOTED, $page)
), $requestParams->get("page"));
return $this->prepareResponse($requestFingerPrint, $results, (new ResultsResource(
$results->first()
))->response());
}
/** /**
* @inheritDoc * @inheritDoc
*/ */
@ -45,4 +22,12 @@ class QueryTopReviewsHandler implements RequestHandler
{ {
return QueryTopReviewsCommand::class; return QueryTopReviewsCommand::class;
} }
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => $jikan->getRecentReviews(new RecentReviewsRequest(Constants::RECENT_REVIEW_BEST_VOTED, $page)),
$requestParams->get("page"));
}
} }

View File

@ -0,0 +1,58 @@
<?php
namespace App\Features;
use App\Contracts\CachedScraperService;
use App\Contracts\RequestHandler;
use App\Contracts\DataRequest;
use App\Http\Resources\V4\ResultsResource;
use App\Support\CachedData;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
/**
* @template TRequest of DataRequest<TResponse>
* @template TResponse of ResourceCollection|JsonResource|Response
* @implements RequestHandler<TRequest, TResponse>
*/
abstract class RequestHandlerWithScraperCache implements RequestHandler
{
public function __construct(protected readonly CachedScraperService $scraperService)
{
}
/**
* @inheritDoc
*/
public function handle($request)
{
$requestParams = collect($request->all());
$requestFingerPrint = $request->getFingerPrint();
$results = $this->getScraperData($requestFingerPrint, $requestParams);
return $this->renderResponse($requestFingerPrint, $results);
}
protected abstract function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData;
protected function resource(Collection $results): JsonResource
{
return new ResultsResource(
$results->first()
);
}
/**
* @param string $requestFingerPrint
* @param CachedData $results
* @return TResponse
*/
protected function renderResponse(string $requestFingerPrint, CachedData $results)
{
$finalResults = $results->collect();
$response = $this->resource($finalResults)->response();
return $this->scraperService->augmentResponse($response, $requestFingerPrint, $results);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Features;
use App\Dto\UserByIdLookupCommand;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Jikan\Request\User\UsernameByIdRequest;
/**
* @extends RequestHandlerWithScraperCache<UserByIdLookupCommand, JsonResponse>
*/
final class UserByIdLookupHandler extends RequestHandlerWithScraperCache
{
public function requestClass(): string
{
return UserByIdLookupCommand::class;
}
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, ?int $page = null) => collect(['results' => $jikan->getUsernameById(new UsernameByIdRequest($requestParams->get("id")))])
);
}
}

View File

@ -2,21 +2,18 @@
namespace App\Features; namespace App\Features;
use App\Concerns\ScraperResultCache;
use App\Contracts\RequestHandler;
use App\Dto\UsersSearchCommand; use App\Dto\UsersSearchCommand;
use App\Http\Resources\V4\ResultsResource; use App\Support\CachedData;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient; use Jikan\MyAnimeList\MalClient;
use Jikan\Request\Search\UserSearchRequest; use Jikan\Request\Search\UserSearchRequest;
/** /**
* @implements RequestHandler<UsersSearchCommand, JsonResponse> * @implements RequestHandlerWithScraperCache<UsersSearchCommand, JsonResponse>
*/ */
final class UserSearchHandler implements RequestHandler final class UserSearchHandler extends RequestHandlerWithScraperCache
{ {
use ScraperResultCache;
/** /**
* @inheritDoc * @inheritDoc
*/ */
@ -25,16 +22,9 @@ final class UserSearchHandler implements RequestHandler
return UsersSearchCommand::class; return UsersSearchCommand::class;
} }
/** protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
* @param UsersSearchCommand $request
* @return JsonResponse
*/
public function handle($request): JsonResponse
{ {
$requestParams = collect($request->all()); return $this->scraperService->findList(
$requestFingerPrint = $request->getFingerPrint();
$results = $this->queryFromScraperCacheByFingerPrint(
"users",
$requestFingerPrint, $requestFingerPrint,
fn (MalClient $jikan, int $page) => $jikan->getUserSearch((new UserSearchRequest()) fn (MalClient $jikan, int $page) => $jikan->getUserSearch((new UserSearchRequest())
->setQuery($requestParams->get("q")) ->setQuery($requestParams->get("q"))
@ -45,9 +35,5 @@ final class UserSearchHandler implements RequestHandler
->setPage($page)), ->setPage($page)),
$requestParams->get("page") $requestParams->get("page")
); );
return $this->prepareResponse($requestFingerPrint, $results, (new ResultsResource(
$results->first()
))->response());
} }
} }

View File

@ -2,45 +2,27 @@
namespace App\Http\Controllers\V4DB; namespace App\Http\Controllers\V4DB;
use App\Anime; use App\Dto\AnimeCharactersLookupCommand;
use App\Http\HttpHelper; use App\Dto\AnimeEpisodeLookupCommand;
use App\Http\HttpResponse; use App\Dto\AnimeEpisodesLookupCommand;
use App\Http\Resources\V4\AnimeCharactersResource; use App\Dto\AnimeExternalLookupCommand;
use App\Http\Resources\V4\AnimeEpisodeResource; use App\Dto\AnimeForumLookupCommand;
use App\Http\Resources\V4\ExternalLinksResource; use App\Dto\AnimeFullLookupCommand;
use App\Http\Resources\V4\AnimeRelationsResource; use App\Dto\AnimeLookupCommand;
use App\Http\Resources\V4\AnimeThemesResource; use App\Dto\AnimeMoreInfoLookupCommand;
use App\Http\Resources\V4\MoreInfoResource; use App\Dto\AnimeNewsLookupCommand;
use App\Http\Resources\V4\PicturesResource; use App\Dto\AnimePicturesLookupCommand;
use App\Http\Resources\V4\RecommendationsResource; use App\Dto\AnimeRecommendationsLookupCommand;
use App\Http\Resources\V4\ResultsResource; use App\Dto\AnimeRelationsLookupCommand;
use App\Http\Resources\V4\AnimeStaffResource; use App\Dto\AnimeReviewsLookupCommand;
use App\Http\Resources\V4\AnimeStatisticsResource; use App\Dto\AnimeStaffLookupCommand;
use App\Http\Resources\V4\StreamingLinksResource; use App\Dto\AnimeStatsLookupCommand;
use App\Http\Resources\V4\UserUpdatesResource; use App\Dto\AnimeStreamingLookupCommand;
use App\Http\Resources\V4\AnimeVideosResource; use App\Dto\AnimeThemesLookupCommand;
use App\Http\Resources\V4\ForumResource; use App\Dto\AnimeUserUpdatesLookupCommand;
use App\Dto\AnimeVideosEpisodesLookupCommand;
use App\Dto\AnimeVideosLookupCommand;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Jikan\Helper\Constants;
use Jikan\Request\Anime\AnimeCharactersAndStaffRequest;
use Jikan\Request\Anime\AnimeEpisodeRequest;
use Jikan\Request\Anime\AnimeEpisodesRequest;
use Jikan\Request\Anime\AnimeForumRequest;
use Jikan\Request\Anime\AnimeMoreInfoRequest;
use Jikan\Request\Anime\AnimeNewsRequest;
use Jikan\Request\Anime\AnimePicturesRequest;
use Jikan\Request\Anime\AnimeRecentlyUpdatedByUsersRequest;
use Jikan\Request\Anime\AnimeRecommendationsRequest;
use Jikan\Request\Anime\AnimeRequest;
use Jikan\Request\Anime\AnimeReviewsRequest;
use Jikan\Request\Anime\AnimeStatsRequest;
use Jikan\Request\Anime\AnimeVideosEpisodesRequest;
use Jikan\Request\Anime\AnimeVideosRequest;
use Laravel\Lumen\Http\ResponseFactory;
use MongoDB\BSON\UTCDateTime;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AnimeController extends Controller class AnimeController extends Controller
{ {
@ -75,59 +57,8 @@ class AnimeController extends Controller
*/ */
public function full(Request $request, int $id) public function full(Request $request, int $id)
{ {
$results = Anime::query() $command = AnimeFullLookupCommand::from($request, $id);
->where('mal_id', $id) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$response = Anime::scrape($id);
if (HttpHelper::hasError($response)) {
return HttpResponse::notFound($request);
}
if ($results->isEmpty()) {
$meta = [
'createdAt' => new UTCDateTime(),
'modifiedAt' => new UTCDateTime(),
'request_hash' => $this->fingerprint
];
}
$meta['modifiedAt'] = new UTCDateTime();
$response = $meta + $response;
if ($results->isEmpty()) {
Anime::create($response);
}
if ($this->isExpired($request, $results)) {
Anime::query()
->where('mal_id', $id)
->update($response);
}
$results = Anime::query()
->where('mal_id', $id)
->get();
}
if ($results->isEmpty()) {
return HttpResponse::notFound($request);
}
$response = (new \App\Http\Resources\V4\AnimeFullResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -161,59 +92,8 @@ class AnimeController extends Controller
*/ */
public function main(Request $request, int $id) public function main(Request $request, int $id)
{ {
$results = Anime::query() $command = AnimeLookupCommand::from($request, $id);
->where('mal_id', $id) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$response = Anime::scrape($id);
if (HttpHelper::hasError($response)) {
return HttpResponse::notFound($request);
}
if ($results->isEmpty()) {
$meta = [
'createdAt' => new UTCDateTime(),
'modifiedAt' => new UTCDateTime(),
'request_hash' => $this->fingerprint
];
}
$meta['modifiedAt'] = new UTCDateTime();
$response = $meta + $response;
if ($results->isEmpty()) {
Anime::create($response);
}
if ($this->isExpired($request, $results)) {
Anime::query()
->where('mal_id', $id)
->update($response);
}
$results = Anime::query()
->where('mal_id', $id)
->get();
}
if ($results->isEmpty()) {
return HttpResponse::notFound($request);
}
$response = (new \App\Http\Resources\V4\AnimeResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -244,29 +124,8 @@ class AnimeController extends Controller
*/ */
public function characters(Request $request, int $id) public function characters(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeCharactersLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$anime = $this->jikan->getAnimeCharactersAndStaff(new AnimeCharactersAndStaffRequest($id));
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new AnimeCharactersResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -297,30 +156,8 @@ class AnimeController extends Controller
*/ */
public function staff(Request $request, int $id) public function staff(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeStaffLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$page = $request->get('page') ?? 1;
$anime = $this->jikan->getAnimeCharactersAndStaff(new AnimeCharactersAndStaffRequest($id));
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new AnimeStaffResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -426,30 +263,8 @@ class AnimeController extends Controller
*/ */
public function episodes(Request $request, int $id) public function episodes(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeEpisodesLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$page = $request->get('page') ?? 1;
$anime = $this->jikan->getAnimeEpisodes(new AnimeEpisodesRequest($id, $page));
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new ResultsResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -490,30 +305,8 @@ class AnimeController extends Controller
*/ */
public function episode(Request $request, int $id, int $episodeId) public function episode(Request $request, int $id, int $episodeId)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeEpisodeLookupCommand::from($request, $id, $episodeId);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$page = $request->get('page') ?? 1;
$anime = $this->jikan->getAnimeEpisode(new AnimeEpisodeRequest($id, $episodeId));
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new AnimeEpisodeResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -558,30 +351,8 @@ class AnimeController extends Controller
*/ */
public function news(Request $request, int $id) public function news(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeNewsLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$page = $request->get('page') ?? 1;
$anime = $this->jikan->getNewsList(new AnimeNewsRequest($id, $page));
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new ResultsResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -620,35 +391,8 @@ class AnimeController extends Controller
*/ */
public function forum(Request $request, int $id) public function forum(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeForumLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$topic = $request->get('topic');
if ($request->get('filter') != null) {
$topic = $request->get('filter');
}
$anime = ['topics' => $this->jikan->getAnimeForum(new AnimeForumRequest($id, $topic))];
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new ForumResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -679,29 +423,8 @@ class AnimeController extends Controller
*/ */
public function videos(Request $request, int $id) public function videos(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeVideosLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$anime = $this->jikan->getAnimeVideos(new AnimeVideosRequest($id));
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new AnimeVideosResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -777,30 +500,8 @@ class AnimeController extends Controller
*/ */
public function videosEpisodes(Request $request, int $id) public function videosEpisodes(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeVideosEpisodesLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$page = $request->get('page') ?? 1;
$anime = $this->jikan->getAnimeVideosEpisodes(new AnimeVideosEpisodesRequest($id, $page));
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new AnimeEpisodesResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -833,29 +534,8 @@ class AnimeController extends Controller
*/ */
public function pictures(Request $request, int $id) public function pictures(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimePicturesLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$anime = ['pictures' => $this->jikan->getAnimePictures(new AnimePicturesRequest($id))];
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new PicturesResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -886,29 +566,8 @@ class AnimeController extends Controller
*/ */
public function stats(Request $request, int $id) public function stats(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeStatsLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$anime = $this->jikan->getAnimeStats(new AnimeStatsRequest($id));
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new AnimeStatisticsResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -939,29 +598,8 @@ class AnimeController extends Controller
*/ */
public function moreInfo(Request $request, int $id) public function moreInfo(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeMoreInfoLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$anime = ['moreinfo' => $this->jikan->getAnimeMoreInfo(new AnimeMoreInfoRequest($id))];
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new MoreInfoResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -992,29 +630,8 @@ class AnimeController extends Controller
*/ */
public function recommendations(Request $request, int $id) public function recommendations(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeRecommendationsLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$anime = ['recommendations' => $this->jikan->getAnimeRecommendations(new AnimeRecommendationsRequest($id))];
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new RecommendationsResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -1047,30 +664,8 @@ class AnimeController extends Controller
*/ */
public function userupdates(Request $request, int $id) public function userupdates(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeUserUpdatesLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$page = $request->get('page') ?? 1;
$anime = $this->jikan->getAnimeRecentlyUpdatedByUsers(new AnimeRecentlyUpdatedByUsersRequest($id, $page));
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new ResultsResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -1103,48 +698,8 @@ class AnimeController extends Controller
*/ */
public function reviews(Request $request, int $id) public function reviews(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) $command = AnimeReviewsLookupCommand::from($request, $id);
->where('request_hash', $this->fingerprint) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$page = $request->get('page') ?? 1;
$sort = $request->get('sort') ?? Constants::REVIEWS_SORT_MOST_VOTED;
if (!in_array($sort, [Constants::REVIEWS_SORT_MOST_VOTED, Constants::REVIEWS_SORT_NEWEST, Constants::REVIEWS_SORT_OLDEST])) {
throw new BadRequestException('Invalid sort for reviews. Please refer to the documentation: https://docs.api.jikan.moe/');
}
$spoilers = $request->get('spoilers') ?? false;
$preliminary = $request->get('preliminary') ?? false;
$anime = $this->jikan
->getAnimeReviews(
new AnimeReviewsRequest(
$id,
$page,
$sort,
$spoilers,
$preliminary
)
);
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new ResultsResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
@ -1184,55 +739,8 @@ class AnimeController extends Controller
*/ */
public function relations(Request $request, int $id) public function relations(Request $request, int $id)
{ {
$results = Anime::query() $command = AnimeRelationsLookupCommand::from($request, $id);
->where('mal_id', $id) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$response = Anime::scrape($id);
if ($results->isEmpty()) {
$meta = [
'createdAt' => new UTCDateTime(),
'modifiedAt' => new UTCDateTime(),
'request_hash' => $this->fingerprint
];
}
$meta['modifiedAt'] = new UTCDateTime();
$response = $meta + $response;
if ($results->isEmpty()) {
Anime::create($response);
}
if ($this->isExpired($request, $results)) {
Anime::query()
->where('mal_id', $id)
->update($response);
}
$results = Anime::query()
->where('mal_id', $id)
->get();
}
if ($results->isEmpty()) {
return HttpResponse::notFound($request);
}
$response = (new AnimeRelationsResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -1263,56 +771,8 @@ class AnimeController extends Controller
*/ */
public function themes(Request $request, int $id) public function themes(Request $request, int $id)
{ {
$results = Anime::query() $command = AnimeThemesLookupCommand::from($request, $id);
->where('mal_id', $id) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$response = Anime::scrape($id);
if ($results->isEmpty()) {
$meta = [
'createdAt' => new UTCDateTime(),
'modifiedAt' => new UTCDateTime(),
'request_hash' => $this->fingerprint
];
}
$meta['modifiedAt'] = new UTCDateTime();
$response = $meta + $response;
if ($results->isEmpty()) {
Anime::create($response);
}
if ($this->isExpired($request, $results)) {
Anime::query()
->where('mal_id', $id)
->update($response);
}
$results = Anime::query()
->where('mal_id', $id)
->get();
}
if ($results->isEmpty()) {
return HttpResponse::notFound($request);
}
$response = (new AnimeThemesResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -1343,56 +803,8 @@ class AnimeController extends Controller
*/ */
public function external(Request $request, int $id) public function external(Request $request, int $id)
{ {
$results = Anime::query() $command = AnimeExternalLookupCommand::from($request, $id);
->where('mal_id', $id) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$response = Anime::scrape($id);
if ($results->isEmpty()) {
$meta = [
'createdAt' => new UTCDateTime(),
'modifiedAt' => new UTCDateTime(),
'request_hash' => $this->fingerprint
];
}
$meta['modifiedAt'] = new UTCDateTime();
$response = $meta + $response;
if ($results->isEmpty()) {
Anime::create($response);
}
if ($this->isExpired($request, $results)) {
Anime::query()
->where('mal_id', $id)
->update($response);
}
$results = Anime::query()
->where('mal_id', $id)
->get();
}
if ($results->isEmpty()) {
return HttpResponse::notFound($request);
}
$response = (new ExternalLinksResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**
@ -1423,56 +835,7 @@ class AnimeController extends Controller
*/ */
public function streaming(Request $request, int $id) public function streaming(Request $request, int $id)
{ {
$results = Anime::query() $command = AnimeStreamingLookupCommand::from($request, $id);
->where('mal_id', $id) return $this->mediator->send($command);
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$response = Anime::scrape($id);
if ($results->isEmpty()) {
$meta = [
'createdAt' => new UTCDateTime(),
'modifiedAt' => new UTCDateTime(),
'request_hash' => $this->fingerprint
];
}
$meta['modifiedAt'] = new UTCDateTime();
$response = $meta + $response;
if ($results->isEmpty()) {
Anime::query()
->insert($response);
}
if ($this->isExpired($request, $results)) {
Anime::query()
->where('mal_id', $id)
->update($response);
}
$results = Anime::query()
->where('mal_id', $id)
->get();
}
if ($results->isEmpty()) {
return HttpResponse::notFound($request);
}
$response = (new StreamingLinksResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
} }

View File

@ -8,6 +8,7 @@ use App\Dto\ClubSearchCommand;
use App\Dto\MangaSearchCommand; use App\Dto\MangaSearchCommand;
use App\Dto\PeopleSearchCommand; use App\Dto\PeopleSearchCommand;
use App\Dto\ProducersSearchCommand; use App\Dto\ProducersSearchCommand;
use App\Dto\UserByIdLookupCommand;
use App\Dto\UsersSearchCommand; use App\Dto\UsersSearchCommand;
use App\Http\Resources\V4\ResultsResource; use App\Http\Resources\V4\ResultsResource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -518,29 +519,7 @@ class SearchController extends Controller
*/ */
public function userById(Request $request, int $id) public function userById(Request $request, int $id)
{ {
$results = DB::table($this->getRouteTable($request)) return $this->mediator->send(UserByIdLookupCommand::from($request, $id));
->where('request_hash', $this->fingerprint)
->get();
if (
$results->isEmpty()
|| $this->isExpired($request, $results)
) {
$anime = ['results'=>$this->jikan->getUsernameById(new UsernameByIdRequest($id))];
$response = \json_decode($this->serializer->serialize($anime, 'json'), true);
$results = $this->updateCache($request, $results, $response);
}
$response = (new ResultsResource(
$results->first()
))->response();
return $this->prepareResponse(
$response,
$results,
$request
);
} }
/** /**

View File

@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Contracts\AnimeRepository; use App\Contracts\AnimeRepository;
use App\Contracts\CachedScraperService;
use App\Contracts\CharacterRepository; use App\Contracts\CharacterRepository;
use App\Contracts\ClubRepository; use App\Contracts\ClubRepository;
use App\Contracts\MagazineRepository; use App\Contracts\MagazineRepository;
@ -15,18 +16,39 @@ use App\Contracts\RequestHandler;
use App\Contracts\UnitOfWork; use App\Contracts\UnitOfWork;
use App\Contracts\UserRepository; use App\Contracts\UserRepository;
use App\Dto\QueryTopPeopleCommand; use App\Dto\QueryTopPeopleCommand;
use App\Features\AnimeEpisodeLookupHandler;
use App\Features\AnimeEpisodesLookupHandler;
use App\Features\AnimeExternalLookupHandler;
use App\Features\AnimeForumLookupHandler;
use App\Features\AnimeGenreListHandler; use App\Features\AnimeGenreListHandler;
use App\Features\AnimeMoreInfoLookupHandler;
use App\Features\AnimeNewsLookupHandler;
use App\Features\AnimePicturesLookupHandler;
use App\Features\AnimeRecommendationsLookupHandler;
use App\Features\AnimeRelationsLookupHandler;
use App\Features\AnimeReviewsLookupHandler;
use App\Features\AnimeSearchHandler; use App\Features\AnimeSearchHandler;
use App\Features\AnimeCharactersLookupHandler;
use App\Features\AnimeStaffLookupHandler;
use App\Features\AnimeStatsLookupHandler;
use App\Features\AnimeStreamingLookupHandler;
use App\Features\AnimeThemesLookupHandler;
use App\Features\AnimeVideosEpisodesLookupHandler;
use App\Features\AnimeVideosLookupHandler;
use App\Features\CharacterSearchHandler; use App\Features\CharacterSearchHandler;
use App\Features\ClubSearchHandler; use App\Features\ClubSearchHandler;
use App\Features\MagazineSearchHandler; use App\Features\MagazineSearchHandler;
use App\Features\MangaGenreListHandler;
use App\Features\MangaSearchHandler; use App\Features\MangaSearchHandler;
use App\Features\PeopleSearchHandler; use App\Features\PeopleSearchHandler;
use App\Features\ProducerSearchHandler; use App\Features\ProducerSearchHandler;
use App\Features\QueryAnimeHandler;
use App\Features\QueryFullAnimeHandler;
use App\Features\QueryTopAnimeItemsHandler; use App\Features\QueryTopAnimeItemsHandler;
use App\Features\QueryTopCharactersHandler; use App\Features\QueryTopCharactersHandler;
use App\Features\QueryTopMangaItemsHandler; use App\Features\QueryTopMangaItemsHandler;
use App\Features\QueryTopReviewsHandler; use App\Features\QueryTopReviewsHandler;
use App\Features\UserByIdLookupHandler;
use App\Features\UserSearchHandler; use App\Features\UserSearchHandler;
use App\GenreAnime; use App\GenreAnime;
use App\GenreManga; use App\GenreManga;
@ -52,6 +74,7 @@ use App\Repositories\DefaultPeopleRepository;
use App\Repositories\DefaultProducerRepository; use App\Repositories\DefaultProducerRepository;
use App\Repositories\DefaultUserRepository; use App\Repositories\DefaultUserRepository;
use App\Repositories\MangaGenresRepository; use App\Repositories\MangaGenresRepository;
use App\Services\DefaultCachedScraperService;
use App\Services\DefaultQueryBuilderService; use App\Services\DefaultQueryBuilderService;
use App\Services\DefaultScoutSearchService; use App\Services\DefaultScoutSearchService;
use App\Services\ElasticScoutSearchService; use App\Services\ElasticScoutSearchService;
@ -67,6 +90,7 @@ use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Laravel\Scout\Builder as ScoutBuilder; use Laravel\Scout\Builder as ScoutBuilder;
use Typesense\LaravelTypesense\Typesense; use Typesense\LaravelTypesense\Typesense;
@ -195,6 +219,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(MangaGenresRepository::class); $this->app->singleton(MangaGenresRepository::class);
$this->app->singleton(UnitOfWork::class, JikanUnitOfWork::class); $this->app->singleton(UnitOfWork::class, JikanUnitOfWork::class);
$this->app->singleton(CachedScraperService::class, DefaultCachedScraperService::class);
} }
private function registerRequestHandlers() private function registerRequestHandlers()
@ -209,9 +234,7 @@ class AppServiceProvider extends ServiceProvider
* Validation for requests is specified in the DTOs. * Validation for requests is specified in the DTOs.
* Querying/Filtering entirely happens on the model side. * Querying/Filtering entirely happens on the model side.
* The lines below explicitly define the mapping between request handlers and repositories. * The lines below explicitly define the mapping between request handlers and repositories.
* Repositories are just a bit of abstraction over models. (A step away from magic class strings) * Repositories are just a bit of abstraction over models.
* Every request handler gets a dedicated instance of QueryBuilderService which will be configured with a
* specific repository instance.
*/ */
$this->app->when(DefaultMediator::class) $this->app->when(DefaultMediator::class)
->needs(RequestHandler::class) ->needs(RequestHandler::class)
@ -229,11 +252,10 @@ class AppServiceProvider extends ServiceProvider
ClubSearchHandler::class => $unitOfWorkInstance->clubs(), ClubSearchHandler::class => $unitOfWorkInstance->clubs(),
MagazineSearchHandler::class => $unitOfWorkInstance->magazines(), MagazineSearchHandler::class => $unitOfWorkInstance->magazines(),
ProducerSearchHandler::class => $unitOfWorkInstance->producers(), ProducerSearchHandler::class => $unitOfWorkInstance->producers(),
UserSearchHandler::class => $unitOfWorkInstance->users()
]; ];
$requestHandlers = []; $requestHandlers = [];
foreach ($searchRequestHandlersDescriptors as $handlerClass => $repositoryInstance) { foreach ($searchRequestHandlersDescriptors as $handlerClass => $repositoryInstance) {
$requestHandlers[] = $this->app->make($handlerClass, [ $requestHandlers[] = $app->make($handlerClass, [
$app->make(DefaultQueryBuilderService::class, [ $app->make(DefaultQueryBuilderService::class, [
static::makeSearchService($app, $searchIndexesEnabled, $repositoryInstance), static::makeSearchService($app, $searchIndexesEnabled, $repositoryInstance),
$app->make($searchIndexesEnabled ? ScoutBuilderPaginatorService::class : EloquentBuilderPaginatorService::class) $app->make($searchIndexesEnabled ? ScoutBuilderPaginatorService::class : EloquentBuilderPaginatorService::class)
@ -249,23 +271,64 @@ class AppServiceProvider extends ServiceProvider
]; ];
foreach ($queryTopItemsDescriptors as $handlerClass => $repositoryInstance) { foreach ($queryTopItemsDescriptors as $handlerClass => $repositoryInstance) {
$requestHandlers[] = $this->app->make($handlerClass, [ $requestHandlers[] = $app->make($handlerClass, [
$repositoryInstance, $repositoryInstance,
// top queries don't use the search engine, so it's enough for them to use eloquent paginator // top queries don't use the search engine, so it's enough for them to use eloquent paginator
$this->app->make(EloquentBuilderPaginatorService::class) $app->make(EloquentBuilderPaginatorService::class)
]); ]);
} }
$genreRequestHandlerDescriptors = [ // request handlers which only depend on a repository instance
$requestHandlersWithOnlyRepositoryDependency = [
AnimeGenreListHandler::class => $unitOfWorkInstance->animeGenres(), AnimeGenreListHandler::class => $unitOfWorkInstance->animeGenres(),
MangaGenresRepository::class => $unitOfWorkInstance->mangaGenres() MangaGenreListHandler::class => $unitOfWorkInstance->mangaGenres(),
]; ];
foreach ($genreRequestHandlerDescriptors as $handlerClass => $repositoryInstance) { foreach ($requestHandlersWithOnlyRepositoryDependency as $handlerClass => $repositoryInstance) {
$requestHandlers[] = $this->app->make($handlerClass, [$repositoryInstance]); $requestHandlers[] = $app->make($handlerClass, [$repositoryInstance]);
} }
$requestHandlers[] = $this->app->make(QueryTopReviewsHandler::class); // request handlers which are fetching data through the jikan library from MAL, and caching the result.
$requestHandlersWithScraperService = [
QueryFullAnimeHandler::class => $unitOfWorkInstance->anime(),
QueryAnimeHandler::class => $unitOfWorkInstance->anime(),
UserSearchHandler::class => $unitOfWorkInstance->documents("common"),
QueryTopReviewsHandler::class => $unitOfWorkInstance->documents("common"),
UserByIdLookupHandler::class => $unitOfWorkInstance->documents("common"),
AnimeCharactersLookupHandler::class => $unitOfWorkInstance->documents("anime_characters_staff"),
AnimeStaffLookupHandler::class => $unitOfWorkInstance->documents("anime_characters_staff"),
AnimeEpisodesLookupHandler::class => $unitOfWorkInstance->documents("anime_episodes"),
AnimeEpisodeLookupHandler::class => $unitOfWorkInstance->documents("anime_episode"),
AnimeNewsLookupHandler::class => $unitOfWorkInstance->documents("anime_news"),
AnimeForumLookupHandler::class => $unitOfWorkInstance->documents("anime_forum"),
AnimeVideosLookupHandler::class => $unitOfWorkInstance->documents("anime_videos"),
AnimeVideosEpisodesLookupHandler::class => $unitOfWorkInstance->documents("anime_videos_episodes"),
AnimePicturesLookupHandler::class => $unitOfWorkInstance->documents("anime_pictures"),
AnimeStatsLookupHandler::class => $unitOfWorkInstance->documents("anime_stats"),
AnimeMoreInfoLookupHandler::class => $unitOfWorkInstance->documents("anime_moreinfo"),
AnimeRecommendationsLookupHandler::class => $unitOfWorkInstance->documents("anime_recommendations"),
AnimeReviewsLookupHandler::class => $unitOfWorkInstance->documents("anime_reviews"),
AnimeRelationsLookupHandler::class => $unitOfWorkInstance->anime(),
AnimeExternalLookupHandler::class => $unitOfWorkInstance->anime(),
AnimeStreamingLookupHandler::class => $unitOfWorkInstance->anime(),
AnimeThemesLookupHandler::class => $unitOfWorkInstance->anime(),
];
foreach ($requestHandlersWithScraperService as $handlerClass => $repositoryInstance) {
$jikan = $app->make(MalClient::class);
$serializer = $app->make("SerializerV4");
$requestHandlers[] = $app->make($handlerClass, [
$app->make(DefaultCachedScraperService::class,
[$repositoryInstance, $jikan, $serializer])
]);
}
$requestHandlersWithNoDependencies = [
];
foreach ($requestHandlersWithNoDependencies as $handlerClass) {
$requestHandlers[] = $this->app->make($handlerClass);
}
return $requestHandlers; return $requestHandlers;
}); });

View File

@ -4,7 +4,7 @@ namespace App\Repositories;
use App\Contracts\Repository; use App\Contracts\Repository;
use App\Support\RepositoryQuery; use App\Support\RepositoryQuery;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class DatabaseRepository extends RepositoryQuery implements Repository class DatabaseRepository extends RepositoryQuery implements Repository
@ -30,7 +30,7 @@ class DatabaseRepository extends RepositoryQuery implements Repository
->get(); ->get();
} }
public function queryByMalId(int $id): EloquentBuilder public function queryByMalId(int $id): Builder
{ {
return $this->queryable(true) return $this->queryable(true)
->where('mal_id', $id); ->where('mal_id', $id);
@ -43,7 +43,7 @@ class DatabaseRepository extends RepositoryQuery implements Repository
// fixme: this should not be here. // fixme: this should not be here.
// this is here because we have the "scrape" static method on models // this is here because we have the "scrape" static method on models
public function scrape(int $id): array public function scrape(int|string $id): array
{ {
$modelClass = get_class($this->queryable(true)->newModelInstance()); $modelClass = get_class($this->queryable(true)->newModelInstance());

View File

@ -0,0 +1,34 @@
<?php
namespace App\Repositories;
use Illuminate\Support\Facades\DB;
/**
* Represents a table in the document database which doesn't have a model representation in the code base.
*/
final class DocumentRepository extends DatabaseRepository
{
private string $tableName;
public function __construct(string $tableName)
{
parent::__construct(fn() => DB::table($tableName), fn($x, $y) => throw new \Exception("Not supported"));
$this->tableName = $tableName;
}
public function scrape(int|string $id): array
{
throw new \Exception("Not supported");
}
public function tableName(): string
{
return $this->tableName;
}
public function createEntity()
{
throw new \Exception("Not supported");
}
}

View File

@ -0,0 +1,198 @@
<?php
namespace App\Services;
use App\Concerns\ScraperCacheTtl;
use App\Contracts\CachedScraperService;
use App\Contracts\Repository;
use App\Http\HttpHelper;
use App\Support\CachedData;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use MongoDB\BSON\UTCDateTime;
use JMS\Serializer\Serializer;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* A service which scrapes data from MAL if cache is expired or empty
*/
final class DefaultCachedScraperService implements CachedScraperService
{
use ScraperCacheTtl;
public function __construct(
private readonly Repository $repository,
private readonly MalClient $jikan,
private readonly Serializer $serializer,
)
{
}
/**
* Finds cached scraper results by cacheKey, if not found scrapes them from MAL via the provided callback.
* @param string $cacheKey
* @param \Closure $getMalDataCallback
* @param int|null $page
* @return CachedData
*/
public function findList(string $cacheKey, \Closure $getMalDataCallback, ?int $page = null): CachedData
{
$results = $this->get($cacheKey);
if ($results->isEmpty() || $results->isExpired()) {
$page = $page ?? 1;
$data = $getMalDataCallback($this->jikan, $page);
$scraperResponse = $this->serializeScraperResult($data);
$results = $this->updateCacheByKey($cacheKey, $results, $scraperResponse);
}
return $results;
}
/**
* Finds cached scraper results by id in the database, if not found scrapes them from MAL.
* @param int $id
* @param string $cacheKey
* @return CachedData
* @throws NotFoundHttpException
*/
public function find(int $id, string $cacheKey): CachedData
{
$results = CachedData::from($this->repository->getAllByMalId($id));
if ($results->isEmpty() || $results->isExpired()) {
$response = $this->repository->scrape($id);
$this->raiseNotFoundIfErrors($response);
$results = $this->updateCacheById($id, $cacheKey, $results, $response);
}
$this->raiseNotFoundIfEmpty($results);
return $results;
}
public function findByKey(string $key, mixed $val, string $cacheKey): CachedData
{
$results = CachedData::from($this->repository->where($key, $val)->get());
if ($results->isEmpty() || $results->isExpired()) {
$scraperResponse = $this->repository->scrape($key);
$this->raiseNotFoundIfErrors($scraperResponse);
$response = $this->prepareScraperResponse($cacheKey, $results->isEmpty(), $scraperResponse);
$response->offsetSet($key, $val);
if ($results->isEmpty()) {
$this->repository->insert($response->toArray());
}
if ($results->isExpired()) {
$this->repository->where($key, $val)->update($response->toArray());
}
$results = CachedData::from($this->repository->where($key, $val)->get());
}
$this->raiseNotFoundIfEmpty($results);
return $results;
}
public function get(string $cacheKey): CachedData
{
return CachedData::from($this->getByCacheKey($cacheKey));
}
public function augmentResponse(JsonResponse|Response $response, string $cacheKey, CachedData $scraperResults): JsonResponse|Response
{
return $response
->header("X-Request-Fingerprint", $cacheKey)
->setTtl($this->cacheTtl())
->setExpires(Carbon::createFromTimestamp($scraperResults->expiry()))
->setLastModified(Carbon::createFromTimestamp($scraperResults->lastModified()));
}
private function raiseNotFoundIfEmpty(CachedData $results)
{
if ($results->isEmpty()) {
abort(404, "Resource not found.");
}
}
private function raiseNotFoundIfErrors(mixed $response)
{
if (HttpHelper::hasError($response)) {
abort(404, "Resource not found.");
}
}
private function updateCacheById(int $id, string $cacheKey, CachedData $results, array $scraperResponse): CachedData
{
$response = $this->prepareScraperResponse($cacheKey, $results->isEmpty(), $scraperResponse);
if ($results->isEmpty()) {
$this->repository->insert($response->toArray());
}
if ($results->isExpired()) {
$this->repository->queryByMalId($id)->update($response->toArray());
}
return new CachedData(collect($this->repository->getAllByMalId($id)));
}
private function updateCacheByKey(string $cacheKey, CachedData $results, array $scraperResponse): CachedData
{
$response = $this->prepareScraperResponse($cacheKey, $results->isEmpty(), $scraperResponse);
// insert cache if resource doesn't exist
if ($results->isEmpty()) {
$this->repository->insert($response->toArray());
}
if ($results->isExpired()) {
$this->getQueryableByCacheKey($cacheKey)->update($response->toArray());
}
return new CachedData($this->getByCacheKey($cacheKey));
}
private function prepareScraperResponse(string $cacheKey, bool $resultsEmpty, array $scraperResponse): CachedData
{
$meta = [];
if ($resultsEmpty) {
$meta = [
'createdAt' => new UTCDateTime(),
'request_hash' => $cacheKey
];
}
// Update `modifiedAt` meta
$meta['modifiedAt'] = new UTCDateTime();
// join meta data with response
return new CachedData(collect($meta + $scraperResponse));
}
private function getByCacheKey(string $cacheKey): Collection
{
return $this->getQueryableByCacheKey($cacheKey)->get();
}
private function getQueryableByCacheKey(string $cacheKey): Builder
{
return $this->repository->where("request_hash", $cacheKey);
}
private function serializeScraperResult($data): array
{
return $this->serializer->toArray($data);
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Support;
use App\Concerns\ScraperCacheTtl;
use Illuminate\Support\Collection;
final class CachedData
{
use ScraperCacheTtl;
public function __construct(
private readonly Collection $scraperResult
)
{
}
public static function from(Collection $scraperResult): self
{
return new self($scraperResult);
}
public function collect(): Collection
{
return $this->scraperResult;
}
public function offsetGet(string $key): mixed
{
return $this->scraperResult->offsetGet($key);
}
public function offsetSet(string $key, mixed $value): void
{
$this->scraperResult->offsetSet($key, $value);
}
public function isEmpty(): bool
{
return $this->scraperResult->isEmpty();
}
public function isExpired(): bool
{
$lastModified = $this->lastModified();
if ($lastModified === null) {
return true;
}
$expiry = $this->expiry();
return time() > $expiry;
}
public function toArray(): array
{
return $this->scraperResult->toArray();
}
public function expiry(): int
{
$modifiedAt = $this->lastModified();
$ttl = $this->cacheTtl();
return $modifiedAt !== null ? $ttl + $modifiedAt : $ttl;
}
public function lastModified(): ?int
{
if ($this->scraperResult->isEmpty()) {
return null;
}
$result = $this->scraperResult->first();
if (is_array($result)) {
return (int) $result["modifiedAt"]->toDateTime()->format("U");
}
if (is_object($result)) {
return $result->modifiedAt->toDateTime()->format("U");
}
return null;
}
}

View File

@ -15,6 +15,7 @@ use App\Contracts\UnitOfWork;
use App\Contracts\UserRepository; use App\Contracts\UserRepository;
use App\Repositories\AnimeGenresRepository; use App\Repositories\AnimeGenresRepository;
use App\Repositories\DatabaseRepository; use App\Repositories\DatabaseRepository;
use App\Repositories\DocumentRepository;
use App\Repositories\MangaGenresRepository; use App\Repositories\MangaGenresRepository;
final class JikanUnitOfWork implements UnitOfWork final class JikanUnitOfWork implements UnitOfWork
@ -93,4 +94,9 @@ final class JikanUnitOfWork implements UnitOfWork
{ {
return new DatabaseRepository(fn () => $modelClass::query(), fn ($x, $y) => $modelClass::search($x, $y)); return new DatabaseRepository(fn () => $modelClass::query(), fn ($x, $y) => $modelClass::search($x, $y));
} }
public function documents(string $tableName): DocumentRepository
{
return new DocumentRepository($tableName);
}
} }

View File

@ -3,13 +3,13 @@
namespace App\Support; namespace App\Support;
use App\Contracts\RepositoryQuery as RepositoryQueryContract; use App\Contracts\RepositoryQuery as RepositoryQueryContract;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Laravel\Scout\Builder as ScoutBuilder; use Laravel\Scout\Builder as ScoutBuilder;
class RepositoryQuery extends RepositoryQueryBase implements RepositoryQueryContract class RepositoryQuery extends RepositoryQueryBase implements RepositoryQueryContract
{ {
public function filter(Collection $params): EloquentBuilder|ScoutBuilder public function filter(Collection $params): Builder|ScoutBuilder
{ {
return $this->queryable()->filter($params); return $this->queryable()->filter($params);
} }
@ -18,4 +18,9 @@ class RepositoryQuery extends RepositoryQueryBase implements RepositoryQueryCont
{ {
return $this->searchable($keywords, $callback); return $this->searchable($keywords, $callback);
} }
public function where(string $key, mixed $value): Builder
{
return $this->queryable()->where($key, $value);
}
} }

View File

@ -2,11 +2,11 @@
namespace App\Support; namespace App\Support;
use Laravel\Scout\Builder as ScoutBuilder; use Laravel\Scout\Builder as ScoutBuilder;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Contracts\Database\Query\Builder;
class RepositoryQueryBase class RepositoryQueryBase
{ {
private ?EloquentBuilder $queryableBuilder; private ?Builder $queryableBuilder;
private ?ScoutBuilder $searchableBuilder; private ?ScoutBuilder $searchableBuilder;
public function __construct( public function __construct(
@ -15,7 +15,7 @@ class RepositoryQueryBase
{ {
} }
protected function queryable(bool $createNew = false): EloquentBuilder protected function queryable(bool $createNew = false): Builder
{ {
if ($createNew) { if ($createNew) {
$callback = $this->getQueryable; $callback = $this->getQueryable;