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;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Http\Response;
use Spatie\LaravelData\Data;
interface Mediator
{
/**

View File

@ -3,7 +3,7 @@
namespace App\Contracts;
use App\JikanApiModel;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Collection;
/**
@ -24,13 +24,13 @@ interface Repository extends RepositoryQuery
public function getAllByMalId(int $id): Collection;
public function queryByMalId(int $id): EloquentBuilder;
public function queryByMalId(int $id): Builder;
public function tableName(): string;
// fixme: this should not be here.
// 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;
}

View File

@ -3,7 +3,7 @@
namespace App\Contracts;
use App\JikanApiModel;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Collection;
use Laravel\Scout\Builder as ScoutBuilder;
@ -14,9 +14,9 @@ interface RepositoryQuery
{
/**
* @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
@ -24,4 +24,12 @@ interface RepositoryQuery
* @return ScoutBuilder<T>
*/
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;
use App\Repositories\DocumentRepository;
interface UnitOfWork
{
public function anime(): AnimeRepository;
@ -23,4 +25,11 @@ interface UnitOfWork
public function animeGenres(): 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\Max;
use Spatie\LaravelData\Attributes\Validation\Min;
use Spatie\LaravelData\Attributes\Validation\Numeric;
use Spatie\LaravelData\Attributes\Validation\Size;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Attributes\WithCast;
@ -26,6 +27,9 @@ class SearchCommand extends Data
#[Max(255), StringType]
public string|Optional $q;
#[Numeric, Min(1)]
public int|Optional $page;
#[IntegerType, Min(1)]
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;
use App\Concerns\ScraperResultCache;
use App\Contracts\DataRequest;
use App\Contracts\Repository;
use App\Contracts\CachedScraperService;
use App\Dto\LookupDataCommand;
use App\Contracts\RequestHandler;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection;
@ -14,34 +13,28 @@ use Spatie\LaravelData\Data;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* @template TRequest of DataRequest<TResponse>
* @template TRequest of LookupDataCommand<TResponse>
* @template TResponse of ResourceCollection|JsonResource|Response
* @implements RequestHandler<TRequest, TResponse>
*/
abstract class ItemLookupHandler extends Data implements RequestHandler
{
use ScraperResultCache;
public function __construct(protected readonly Repository $repository)
public function __construct(protected readonly CachedScraperService $scraperService)
{
}
/**
* @param TRequest $request
* @param TRequest|LookupDataCommand<TResponse> $request
* @return TResponse
* @throws NotFoundHttpException
*/
public function handle($request)
{
$requestFingerprint = $request->getFingerPrint();
$results = $this->queryFromScraperCacheById(
$this->repository,
$request->id,
$request->getFingerPrint(),
);
$results = $this->scraperService->find($request->id, $requestFingerprint);
$resource = $this->resource($results);
return $this->prepareResponse($requestFingerprint, $results, $resource->response());
$resource = $this->resource($results->collect());
return $this->scraperService->augmentResponse($resource->response(), $requestFingerprint, $results);
}
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;
use App\Concerns\ScraperResultCache;
use App\Contracts\AnimeRepository;
use App\Dto\QueryFullAnimeCommand;
use App\Dto\AnimeFullLookupCommand;
use App\Http\Resources\V4\AnimeFullResource;
use Illuminate\Http\JsonResponse;
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
{
public function __construct(AnimeRepository $repository)
{
parent::__construct($repository);
}
public function requestClass(): string
{
return QueryFullAnimeCommand::class;
return AnimeFullLookupCommand::class;
}
protected function resource(Collection $results): JsonResource

View File

@ -2,42 +2,19 @@
namespace App\Features;
use App\Concerns\ScraperResultCache;
use App\Contracts\RequestHandler;
use App\Dto\QueryTopReviewsCommand;
use App\Http\Resources\V4\ResultsResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Jikan\Helper\Constants;
use Jikan\MyAnimeList\MalClient;
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
*/
@ -45,4 +22,12 @@ class QueryTopReviewsHandler implements RequestHandler
{
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;
use App\Concerns\ScraperResultCache;
use App\Contracts\RequestHandler;
use App\Dto\UsersSearchCommand;
use App\Http\Resources\V4\ResultsResource;
use App\Support\CachedData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
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
*/
@ -25,16 +22,9 @@ final class UserSearchHandler implements RequestHandler
return UsersSearchCommand::class;
}
/**
* @param UsersSearchCommand $request
* @return JsonResponse
*/
public function handle($request): JsonResponse
protected function getScraperData(string $requestFingerPrint, Collection $requestParams): CachedData
{
$requestParams = collect($request->all());
$requestFingerPrint = $request->getFingerPrint();
$results = $this->queryFromScraperCacheByFingerPrint(
"users",
return $this->scraperService->findList(
$requestFingerPrint,
fn (MalClient $jikan, int $page) => $jikan->getUserSearch((new UserSearchRequest())
->setQuery($requestParams->get("q"))
@ -45,9 +35,5 @@ final class UserSearchHandler implements RequestHandler
->setPage($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;
use App\Anime;
use App\Http\HttpHelper;
use App\Http\HttpResponse;
use App\Http\Resources\V4\AnimeCharactersResource;
use App\Http\Resources\V4\AnimeEpisodeResource;
use App\Http\Resources\V4\ExternalLinksResource;
use App\Http\Resources\V4\AnimeRelationsResource;
use App\Http\Resources\V4\AnimeThemesResource;
use App\Http\Resources\V4\MoreInfoResource;
use App\Http\Resources\V4\PicturesResource;
use App\Http\Resources\V4\RecommendationsResource;
use App\Http\Resources\V4\ResultsResource;
use App\Http\Resources\V4\AnimeStaffResource;
use App\Http\Resources\V4\AnimeStatisticsResource;
use App\Http\Resources\V4\StreamingLinksResource;
use App\Http\Resources\V4\UserUpdatesResource;
use App\Http\Resources\V4\AnimeVideosResource;
use App\Http\Resources\V4\ForumResource;
use App\Dto\AnimeCharactersLookupCommand;
use App\Dto\AnimeEpisodeLookupCommand;
use App\Dto\AnimeEpisodesLookupCommand;
use App\Dto\AnimeExternalLookupCommand;
use App\Dto\AnimeForumLookupCommand;
use App\Dto\AnimeFullLookupCommand;
use App\Dto\AnimeLookupCommand;
use App\Dto\AnimeMoreInfoLookupCommand;
use App\Dto\AnimeNewsLookupCommand;
use App\Dto\AnimePicturesLookupCommand;
use App\Dto\AnimeRecommendationsLookupCommand;
use App\Dto\AnimeRelationsLookupCommand;
use App\Dto\AnimeReviewsLookupCommand;
use App\Dto\AnimeStaffLookupCommand;
use App\Dto\AnimeStatsLookupCommand;
use App\Dto\AnimeStreamingLookupCommand;
use App\Dto\AnimeThemesLookupCommand;
use App\Dto\AnimeUserUpdatesLookupCommand;
use App\Dto\AnimeVideosEpisodesLookupCommand;
use App\Dto\AnimeVideosLookupCommand;
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
{
@ -75,59 +57,8 @@ class AnimeController extends Controller
*/
public function full(Request $request, int $id)
{
$results = Anime::query()
->where('mal_id', $id)
->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
);
$command = AnimeFullLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -161,59 +92,8 @@ class AnimeController extends Controller
*/
public function main(Request $request, int $id)
{
$results = Anime::query()
->where('mal_id', $id)
->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
);
$command = AnimeLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -244,29 +124,8 @@ class AnimeController extends Controller
*/
public function characters(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeCharactersLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -297,30 +156,8 @@ class AnimeController extends Controller
*/
public function staff(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeStaffLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -426,30 +263,8 @@ class AnimeController extends Controller
*/
public function episodes(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeEpisodesLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -490,30 +305,8 @@ class AnimeController extends Controller
*/
public function episode(Request $request, int $id, int $episodeId)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeEpisodeLookupCommand::from($request, $id, $episodeId);
return $this->mediator->send($command);
}
/**
@ -558,30 +351,8 @@ class AnimeController extends Controller
*/
public function news(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeNewsLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -620,35 +391,8 @@ class AnimeController extends Controller
*/
public function forum(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeForumLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -679,29 +423,8 @@ class AnimeController extends Controller
*/
public function videos(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeVideosLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -777,30 +500,8 @@ class AnimeController extends Controller
*/
public function videosEpisodes(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeVideosEpisodesLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -833,29 +534,8 @@ class AnimeController extends Controller
*/
public function pictures(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimePicturesLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -886,29 +566,8 @@ class AnimeController extends Controller
*/
public function stats(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeStatsLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -939,29 +598,8 @@ class AnimeController extends Controller
*/
public function moreInfo(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeMoreInfoLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -992,29 +630,8 @@ class AnimeController extends Controller
*/
public function recommendations(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeRecommendationsLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -1047,30 +664,8 @@ class AnimeController extends Controller
*/
public function userupdates(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeUserUpdatesLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -1103,48 +698,8 @@ class AnimeController extends Controller
*/
public function reviews(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->where('request_hash', $this->fingerprint)
->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
);
$command = AnimeReviewsLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
@ -1184,55 +739,8 @@ class AnimeController extends Controller
*/
public function relations(Request $request, int $id)
{
$results = Anime::query()
->where('mal_id', $id)
->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
);
$command = AnimeRelationsLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -1263,56 +771,8 @@ class AnimeController extends Controller
*/
public function themes(Request $request, int $id)
{
$results = Anime::query()
->where('mal_id', $id)
->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
);
$command = AnimeThemesLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -1343,56 +803,8 @@ class AnimeController extends Controller
*/
public function external(Request $request, int $id)
{
$results = Anime::query()
->where('mal_id', $id)
->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
);
$command = AnimeExternalLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
/**
@ -1423,56 +835,7 @@ class AnimeController extends Controller
*/
public function streaming(Request $request, int $id)
{
$results = Anime::query()
->where('mal_id', $id)
->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
);
$command = AnimeStreamingLookupCommand::from($request, $id);
return $this->mediator->send($command);
}
}

View File

@ -8,6 +8,7 @@ use App\Dto\ClubSearchCommand;
use App\Dto\MangaSearchCommand;
use App\Dto\PeopleSearchCommand;
use App\Dto\ProducersSearchCommand;
use App\Dto\UserByIdLookupCommand;
use App\Dto\UsersSearchCommand;
use App\Http\Resources\V4\ResultsResource;
use Illuminate\Http\Request;
@ -518,29 +519,7 @@ class SearchController extends Controller
*/
public function userById(Request $request, int $id)
{
$results = DB::table($this->getRouteTable($request))
->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
);
return $this->mediator->send(UserByIdLookupCommand::from($request, $id));
}
/**

View File

@ -3,6 +3,7 @@
namespace App\Providers;
use App\Contracts\AnimeRepository;
use App\Contracts\CachedScraperService;
use App\Contracts\CharacterRepository;
use App\Contracts\ClubRepository;
use App\Contracts\MagazineRepository;
@ -15,18 +16,39 @@ use App\Contracts\RequestHandler;
use App\Contracts\UnitOfWork;
use App\Contracts\UserRepository;
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\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\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\ClubSearchHandler;
use App\Features\MagazineSearchHandler;
use App\Features\MangaGenreListHandler;
use App\Features\MangaSearchHandler;
use App\Features\PeopleSearchHandler;
use App\Features\ProducerSearchHandler;
use App\Features\QueryAnimeHandler;
use App\Features\QueryFullAnimeHandler;
use App\Features\QueryTopAnimeItemsHandler;
use App\Features\QueryTopCharactersHandler;
use App\Features\QueryTopMangaItemsHandler;
use App\Features\QueryTopReviewsHandler;
use App\Features\UserByIdLookupHandler;
use App\Features\UserSearchHandler;
use App\GenreAnime;
use App\GenreManga;
@ -52,6 +74,7 @@ use App\Repositories\DefaultPeopleRepository;
use App\Repositories\DefaultProducerRepository;
use App\Repositories\DefaultUserRepository;
use App\Repositories\MangaGenresRepository;
use App\Services\DefaultCachedScraperService;
use App\Services\DefaultQueryBuilderService;
use App\Services\DefaultScoutSearchService;
use App\Services\ElasticScoutSearchService;
@ -67,6 +90,7 @@ use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Collection;
use Jikan\MyAnimeList\MalClient;
use Laravel\Scout\Builder as ScoutBuilder;
use Typesense\LaravelTypesense\Typesense;
@ -195,6 +219,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(MangaGenresRepository::class);
$this->app->singleton(UnitOfWork::class, JikanUnitOfWork::class);
$this->app->singleton(CachedScraperService::class, DefaultCachedScraperService::class);
}
private function registerRequestHandlers()
@ -209,9 +234,7 @@ class AppServiceProvider extends ServiceProvider
* Validation for requests is specified in the DTOs.
* Querying/Filtering entirely happens on the model side.
* 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)
* Every request handler gets a dedicated instance of QueryBuilderService which will be configured with a
* specific repository instance.
* Repositories are just a bit of abstraction over models.
*/
$this->app->when(DefaultMediator::class)
->needs(RequestHandler::class)
@ -229,11 +252,10 @@ class AppServiceProvider extends ServiceProvider
ClubSearchHandler::class => $unitOfWorkInstance->clubs(),
MagazineSearchHandler::class => $unitOfWorkInstance->magazines(),
ProducerSearchHandler::class => $unitOfWorkInstance->producers(),
UserSearchHandler::class => $unitOfWorkInstance->users()
];
$requestHandlers = [];
foreach ($searchRequestHandlersDescriptors as $handlerClass => $repositoryInstance) {
$requestHandlers[] = $this->app->make($handlerClass, [
$requestHandlers[] = $app->make($handlerClass, [
$app->make(DefaultQueryBuilderService::class, [
static::makeSearchService($app, $searchIndexesEnabled, $repositoryInstance),
$app->make($searchIndexesEnabled ? ScoutBuilderPaginatorService::class : EloquentBuilderPaginatorService::class)
@ -249,23 +271,64 @@ class AppServiceProvider extends ServiceProvider
];
foreach ($queryTopItemsDescriptors as $handlerClass => $repositoryInstance) {
$requestHandlers[] = $this->app->make($handlerClass, [
$requestHandlers[] = $app->make($handlerClass, [
$repositoryInstance,
// 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(),
MangaGenresRepository::class => $unitOfWorkInstance->mangaGenres()
MangaGenreListHandler::class => $unitOfWorkInstance->mangaGenres(),
];
foreach ($genreRequestHandlerDescriptors as $handlerClass => $repositoryInstance) {
$requestHandlers[] = $this->app->make($handlerClass, [$repositoryInstance]);
foreach ($requestHandlersWithOnlyRepositoryDependency as $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;
});

View File

@ -4,7 +4,7 @@ namespace App\Repositories;
use App\Contracts\Repository;
use App\Support\RepositoryQuery;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Collection;
class DatabaseRepository extends RepositoryQuery implements Repository
@ -30,7 +30,7 @@ class DatabaseRepository extends RepositoryQuery implements Repository
->get();
}
public function queryByMalId(int $id): EloquentBuilder
public function queryByMalId(int $id): Builder
{
return $this->queryable(true)
->where('mal_id', $id);
@ -43,7 +43,7 @@ class DatabaseRepository extends RepositoryQuery implements Repository
// fixme: this should not be here.
// 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());

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\Repositories\AnimeGenresRepository;
use App\Repositories\DatabaseRepository;
use App\Repositories\DocumentRepository;
use App\Repositories\MangaGenresRepository;
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));
}
public function documents(string $tableName): DocumentRepository
{
return new DocumentRepository($tableName);
}
}

View File

@ -3,13 +3,13 @@
namespace App\Support;
use App\Contracts\RepositoryQuery as RepositoryQueryContract;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Collection;
use Laravel\Scout\Builder as ScoutBuilder;
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);
}
@ -18,4 +18,9 @@ class RepositoryQuery extends RepositoryQueryBase implements RepositoryQueryCont
{
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;
use Laravel\Scout\Builder as ScoutBuilder;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Contracts\Database\Query\Builder;
class RepositoryQueryBase
{
private ?EloquentBuilder $queryableBuilder;
private ?Builder $queryableBuilder;
private ?ScoutBuilder $searchableBuilder;
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) {
$callback = $this->getQueryable;