diff --git a/.github/workflows/container-image-release.yml b/.github/workflows/container-image-release.yml index bc73b12..72e835b 100644 --- a/.github/workflows/container-image-release.yml +++ b/.github/workflows/container-image-release.yml @@ -69,7 +69,9 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 + env: + DOCKER_BUILD_NO_SUMMARY: true with: context: . platforms: ${{ matrix.platform }} diff --git a/COMMANDS.MD b/COMMANDS.MD index b0656eb..dc050be 100644 --- a/COMMANDS.MD +++ b/COMMANDS.MD @@ -14,14 +14,15 @@ For an entire list of commands, you can run `php artisan list` - [Indexer](#indexer) - [Anime](#indexer-anime) - [Manga](#indexer-manga) - + - [Incremental](#indexer-incremental) + ## Commands ### Serve Command: `serve` Example: `php artisan serve` -Serve the application on the PHP development server +Serve the application on the PHP development server ### Queue @@ -66,7 +67,7 @@ Example: `cache:method queue` Since v4 uses MongoDB as a means to index cache on some endpoints, having a built cache is important since it works best for endpoints like search or top. -`Indexer:Anime` uses [https://github.com/seanbreckenridge/mal-id-cache](https://github.com/seanbreckenridge/mal-id-cache) to fetch available MAL IDs and indexes them. +`Indexer:Anime` uses [https://github.com/purarue/mal-id-cache](https://github.com/purarue/mal-id-cache) to fetch available MAL IDs and indexes them. This function only needs to be run once. Any entry's cache updating will automatically be taken care of if it's expired, and a client makes a request for that entry. @@ -90,7 +91,7 @@ This translates to running entries that previously failed to index or update, in Since v4 uses MongoDB as a means to index cache on some endpoints, having a built cache is important since it works best for endpoints like search or top. -`Indexer:Manga` uses [https://github.com/seanbreckenridge/mal-id-cache](https://github.com/seanbreckenridge/mal-id-cache) to fetch available MAL IDs and indexes them. +`Indexer:Manga` uses [https://github.com/purarue/mal-id-cache](https://github.com/purarue/mal-id-cache) to fetch available MAL IDs and indexes them. This function only needs to be run once. Any entry's cache updating will automatically be taken care of if it's expired, and a client makes a request for that entry. @@ -98,7 +99,7 @@ This function only needs to be run once. Any entry's cache updating will automat Command: ``` -indexer:anime +indexer:manga {--failed : Run only entries that failed to index last time} {--resume : Resume from the last position} {--reverse : Start from the end of the array} @@ -109,3 +110,16 @@ indexer:anime Example: `indexer:manga` This simply translates to running the indexer without any additional configuration. + +#### Indexer: Incremental +Incrementally indexes media entries from MAL. +This command will compare the latest version of MAL ids from the [mal_id_cache](https://github.com/purarue/mal-id-cache) +github repository and compares them with the downloaded ids from the previous run. If no ids found from the previous run, a full indexing session is started. + +Command: +``` +indexer:incremental {mediaType*} + {--failed : Run only entries that failed to index last time} + {--resume : Resume from the last position} + {--delay=3 : Set a delay between requests} +``` diff --git a/README.MD b/README.MD index 5ede423..4d3f2e8 100644 --- a/README.MD +++ b/README.MD @@ -52,7 +52,6 @@ For any additional help, join our [Discord server](http://discord.jikan.moe/). | TypeScript | [jikants](https://github.com/Julien-Broyard/jikants) by Julien Broyard
[jikan-client](https://github.com/javi11/jikan-client) by Javier Blanco
šŸ†• **(v4)** [jikan-ts](https://github.com/tutkli/jikan-ts) by Clara Castillo | | PHP | [jikan-php](https://github.com/janvernieuwe/jikan-jikanPHP) by Jan Vernieuwe | | .NET | šŸ†• **(v4)** [Jikan.net](https://github.com/Ervie/jikan.net) by Ervie | -| Elixir | [JikanEx](https://github.com/seanbreckenridge/jikan_ex) by Sean Breckenridge | | Go | šŸ†• **(v4)** [jikan-go](https://github.com/darenliang/jikan-go) by Daren Liang
[jikan2go](https://github.com/nokusukun/jikan2go) by nokusukun | | Ruby | [Jikan.rb](https://github.com/Zerocchi/jikan.rb) by Zerocchi | | Dart | [jikan-dart](https://github.com/charafau/jikan-dart) by Rafal Wachol | diff --git a/app/Console/Commands/Indexer/AnimeIndexer.php b/app/Console/Commands/Indexer/AnimeIndexer.php index 3e03f30..8704fea 100644 --- a/app/Console/Commands/Indexer/AnimeIndexer.php +++ b/app/Console/Commands/Indexer/AnimeIndexer.php @@ -2,7 +2,6 @@ namespace App\Console\Commands\Indexer; -use App\Exceptions\Console\CommandAlreadyRunningException; use App\Exceptions\Console\FileNotFoundException; use Illuminate\Console\Command; use Illuminate\Support\Facades\Storage; @@ -67,7 +66,7 @@ class AnimeIndexer extends Command $index = (int)$index; $delay = (int)$delay; - $this->info("Info: AnimeIndexer uses seanbreckenridge/mal-id-cache fetch available MAL IDs and updates/indexes them\n\n"); + $this->info("Info: AnimeIndexer uses purarue/mal-id-cache fetch available MAL IDs and updates/indexes them\n\n"); if ($failed && Storage::exists('indexer/indexer_anime.failed')) { $this->ids = $this->loadFailedMalIds(); @@ -140,14 +139,14 @@ class AnimeIndexer extends Command /** * @return array - * @url https://github.com/seanbreckenridge/mal-id-cache + * @url https://github.com/purarue/mal-id-cache */ private function fetchMalIds() : array { - $this->info("Fetching MAL ID Cache https://raw.githubusercontent.com/seanbreckenridge/mal-id-cache/master/cache/anime_cache.json...\n"); + $this->info("Fetching MAL ID Cache https://raw.githubusercontent.com/purarue/mal-id-cache/master/cache/anime_cache.json...\n"); $ids = json_decode( - file_get_contents('https://raw.githubusercontent.com/seanbreckenridge/mal-id-cache/master/cache/anime_cache.json'), + file_get_contents('https://raw.githubusercontent.com/purarue/mal-id-cache/master/cache/anime_cache.json'), true ); diff --git a/app/Console/Commands/Indexer/AnimeSweepIndexer.php b/app/Console/Commands/Indexer/AnimeSweepIndexer.php index ebfdffe..f29954e 100644 --- a/app/Console/Commands/Indexer/AnimeSweepIndexer.php +++ b/app/Console/Commands/Indexer/AnimeSweepIndexer.php @@ -74,14 +74,14 @@ class AnimeSweepIndexer extends Command /** * @return array - * @url https://github.com/seanbreckenridge/mal-id-cache + * @url https://github.com/purarue/mal-id-cache */ private function fetchMalIds(): array { - $this->info("Fetching MAL ID Cache https://raw.githubusercontent.com/seanbreckenridge/mal-id-cache/master/cache/anime_cache.json...\n"); + $this->info("Fetching MAL ID Cache https://raw.githubusercontent.com/purarue/mal-id-cache/master/cache/anime_cache.json...\n"); $ids = json_decode( - file_get_contents('https://raw.githubusercontent.com/seanbreckenridge/mal-id-cache/master/cache/anime_cache.json'), + file_get_contents('https://raw.githubusercontent.com/purarue/mal-id-cache/master/cache/anime_cache.json'), true ); diff --git a/app/Console/Commands/Indexer/IncrementalIndexer.php b/app/Console/Commands/Indexer/IncrementalIndexer.php new file mode 100644 index 0000000..eb076ac --- /dev/null +++ b/app/Console/Commands/Indexer/IncrementalIndexer.php @@ -0,0 +1,228 @@ + ['The media type to index.', 'Valid values: anime, manga'] + ]; + } + + private function getExistingIds(string $mediaType): array + { + $existingIdsHash = ""; + $existingIdsRaw = ""; + + if (Storage::exists("indexer/incremental/$mediaType.json")) + { + $existingIdsRaw = Storage::get("indexer/incremental/$mediaType.json"); + $existingIdsHash = sha1($existingIdsRaw); + } + + return [$existingIdsHash, $existingIdsRaw]; + } + + private function getIdsToFetch(string $mediaType): array + { + $idsToFetch = []; + [$existingIdsHash, $existingIdsRaw] = $this->getExistingIds($mediaType); + + if ($this->cancelled) + { + return []; + } + + $newIdsRaw = file_get_contents("https://raw.githubusercontent.com/purarue/mal-id-cache/master/cache/${mediaType}_cache.json"); + $newIdsHash = sha1($newIdsRaw); + + /** @noinspection PhpConditionAlreadyCheckedInspection */ + if ($this->cancelled) + { + return []; + } + + if ($newIdsHash !== $existingIdsHash) + { + $newIds = json_decode($newIdsRaw, true); + $existingIds = json_decode($existingIdsRaw, true); + + if (is_null($existingIds) || count($existingIds) === 0) + { + $idsToFetch = $newIds; + } + else + { + foreach (["sfw", "nsfw"] as $t) + { + $idsToFetch[$t] = array_diff($existingIds[$t], $newIds[$t]); + } + } + + Storage::put("indexer/incremental/$mediaType.json.tmp", $newIdsRaw); + } + + return $idsToFetch; + } + + private function getFailedIdsToFetch(string $mediaType): array + { + return json_decode(Storage::get("indexer/incremental/{$mediaType}_failed.json")); + } + + private function fetchIds(string $mediaType, array $idsToFetch, bool $resume): void + { + $index = 0; + $success = []; + $failedIds = []; + $idCount = count($idsToFetch); + if ($resume && Storage::exists("indexer/incremental/{$mediaType}_resume.save")) + { + $index = (int)Storage::get("indexer/incremental/{$mediaType}_resume.save"); + $this->info("Resuming from index: $index"); + } + + $ids = array_merge($idsToFetch['sfw'], $idsToFetch['nsfw']); + + if ($index > 0 && !isset($ids[$index])) + { + $index = 0; + $this->warn('Invalid index; set back to 0'); + } + + Storage::put("indexer/incremental/{$mediaType}_resume.save", 0); + + $this->info("$idCount $mediaType entries available"); + + for ($i = $index; $i <= ($idCount - 1); $i++) + { + if ($this->cancelled) + { + return; + } + + $id = $ids[$index]; + + $url = env('APP_URL') . "/v4/$mediaType/$id"; + $this->info("Indexing/Updating " . ($i + 1) . "/$idCount $url [MAL ID: $id]"); + + try + { + $response = json_decode(file_get_contents($url), true); + if (!isset($response['error']) || $response['status'] == 404) + { + continue; + } + + $this->error("[SKIPPED] Failed to fetch $url - {$response['error']}"); + } + catch (\Exception) + { + $this->warn("[SKIPPED] Failed to fetch $url"); + $failedIds[] = $id; + Storage::put("indexer/incremental/$mediaType.failed", json_encode($failedIds)); + } + + $success[] = $id; + Storage::put("indexer/incremental/{$mediaType}_resume.save", $index); + } + + Storage::delete("indexer/incremental/{$mediaType}_resume.save"); + + $this->info("--- Indexing of $mediaType is complete."); + $this->info(count($success) . ' entries indexed or updated.'); + if (count($failedIds) > 0) + { + $this->info(count($failedIds) . ' entries failed to index or update. Re-run with --failed to requeue failed entries only.'); + } + + // finalize the latest state + Storage::move("indexer/incremental/$mediaType.json.tmp", "indexer/incremental/$mediaType.json"); + } + + public function handle(): int + { + // validate inputs + $validator = Validator::make( + [ + 'mediaType' => $this->argument('mediaType'), + 'delay' => $this->option('delay'), + 'resume' => $this->option('resume') ?? false, + 'failed' => $this->option('failed') ?? false + ], + [ + 'mediaType' => 'required|in:anime,manga', + 'delay' => 'integer|min:1', + 'resume' => 'bool|prohibited_with:failed', + 'failed' => 'bool|prohibited_with:resume' + ] + ); + + if ($validator->fails()) { + $this->error($validator->errors()->toJson()); + return 1; + } + + // we want to handle signals from the OS + $this->trap([SIGTERM, SIGQUIT, SIGINT], fn () => $this->cancelled = true); + + $resume = $this->option('resume') ?? false; + $onlyFailed = $this->option('failed') ?? false; + + /** + * @var $mediaTypes array + */ + $mediaTypes = $this->argument("mediaType"); + + foreach ($mediaTypes as $mediaType) + { + $idsToFetch = []; + + // if "--failed" option is specified just run the failed ones + if ($onlyFailed && Storage::exists("indexer/incremental/{$mediaType}_failed.json")) + { + $idsToFetch["sfw"] = $this->getFailedIdsToFetch($mediaType); + } + else + { + $idsToFetch = $this->getIdsToFetch($mediaType); + } + + if ($this->cancelled) + { + return 127; + } + + $idCount = count($idsToFetch); + if ($idCount === 0) + { + continue; + } + + $this->fetchIds($mediaType, $idsToFetch, $resume); + } + + return 0; + } +} diff --git a/app/Console/Commands/Indexer/MangaIndexer.php b/app/Console/Commands/Indexer/MangaIndexer.php index a3ccefd..12d4f67 100644 --- a/app/Console/Commands/Indexer/MangaIndexer.php +++ b/app/Console/Commands/Indexer/MangaIndexer.php @@ -67,7 +67,7 @@ class MangaIndexer extends Command $index = (int)$index; $delay = (int)$delay; - $this->info("Info: MangaIndexer uses seanbreckenridge/mal-id-cache fetch available MAL IDs and updates/indexes them\n\n"); + $this->info("Info: MangaIndexer uses purarue/mal-id-cache fetch available MAL IDs and updates/indexes them\n\n"); if ($failed && Storage::exists('indexer/indexer_manga.failed')) { $this->ids = $this->loadFailedMalIds(); @@ -140,14 +140,14 @@ class MangaIndexer extends Command /** * @return array - * @url https://github.com/seanbreckenridge/mal-id-cache + * @url https://github.com/purarue/mal-id-cache */ private function fetchMalIds() : array { - $this->info("Fetching MAL ID Cache https://raw.githubusercontent.com/seanbreckenridge/mal-id-cache/master/cache/manga_cache.json...\n"); + $this->info("Fetching MAL ID Cache https://raw.githubusercontent.com/purarue/mal-id-cache/master/cache/manga_cache.json...\n"); $ids = json_decode( - file_get_contents('https://raw.githubusercontent.com/seanbreckenridge/mal-id-cache/master/cache/manga_cache.json'), + file_get_contents('https://raw.githubusercontent.com/purarue/mal-id-cache/master/cache/manga_cache.json'), true ); diff --git a/app/Console/Commands/Indexer/MangaSweepIndexer.php b/app/Console/Commands/Indexer/MangaSweepIndexer.php index 09dfdb3..1f45e3e 100644 --- a/app/Console/Commands/Indexer/MangaSweepIndexer.php +++ b/app/Console/Commands/Indexer/MangaSweepIndexer.php @@ -71,14 +71,14 @@ class MangaSweepIndexer extends Command /** * @return array - * @url https://github.com/seanbreckenridge/mal-id-cache + * @url https://github.com/purarue/mal-id-cache */ private function fetchMalIds(): array { - $this->info("Fetching MAL ID Cache https://raw.githubusercontent.com/seanbreckenridge/mal-id-cache/master/cache/manga_cache.json...\n"); + $this->info("Fetching MAL ID Cache https://raw.githubusercontent.com/purarue/mal-id-cache/master/cache/manga_cache.json...\n"); $ids = json_decode( - file_get_contents('https://raw.githubusercontent.com/seanbreckenridge/mal-id-cache/master/cache/manga_cache.json'), + file_get_contents('https://raw.githubusercontent.com/purarue/mal-id-cache/master/cache/manga_cache.json'), true ); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 22c9b19..eafe555 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -24,7 +24,8 @@ class Kernel extends ConsoleKernel Indexer\GenreIndexer::class, Indexer\ProducersIndexer::class, Indexer\AnimeSweepIndexer::class, - Indexer\MangaSweepIndexer::class + Indexer\MangaSweepIndexer::class, + Indexer\IncrementalIndexer::class ]; /** diff --git a/app/Dto/Concerns/HasSpoilersParameter.php b/app/Dto/Concerns/HasSpoilersParameter.php index b704e46..7ba499c 100644 --- a/app/Dto/Concerns/HasSpoilersParameter.php +++ b/app/Dto/Concerns/HasSpoilersParameter.php @@ -10,7 +10,7 @@ use Spatie\LaravelData\Optional; /** * @OA\Parameter( - * name="spoiler", + * name="spoilers", * in="query", * required=false, * description="Any reviews that are tagged as a spoiler. Spoiler reviews are not returned by default. e.g usage: `?spoiler=true`", diff --git a/app/Dto/LookupDataCommand.php b/app/Dto/LookupDataCommand.php index 5949d97..9d60f2d 100644 --- a/app/Dto/LookupDataCommand.php +++ b/app/Dto/LookupDataCommand.php @@ -4,7 +4,6 @@ namespace App\Dto; use App\Concerns\HasRequestFingerprint; use App\Contracts\DataRequest; -use App\DataPipes\MapRouteParametersDataPipe; use App\Dto\Concerns\MapsRouteParameters; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\ResourceCollection; @@ -13,12 +12,6 @@ use Spatie\LaravelData\Attributes\Validation\Min; use Spatie\LaravelData\Attributes\Validation\Numeric; use Spatie\LaravelData\Attributes\Validation\Required; use Spatie\LaravelData\Data; -use Spatie\LaravelData\DataPipeline; -use Spatie\LaravelData\DataPipes\AuthorizedDataPipe; -use Spatie\LaravelData\DataPipes\CastPropertiesDataPipe; -use Spatie\LaravelData\DataPipes\DefaultValuesDataPipe; -use Spatie\LaravelData\DataPipes\MapPropertiesDataPipe; -use Spatie\LaravelData\DataPipes\ValidatePropertiesDataPipe; /** * Base class for all requests/commands which are for looking up things by id. diff --git a/app/Dto/QueryTopReviewsCommand.php b/app/Dto/QueryTopReviewsCommand.php index b9ede8e..322b66f 100644 --- a/app/Dto/QueryTopReviewsCommand.php +++ b/app/Dto/QueryTopReviewsCommand.php @@ -2,14 +2,12 @@ namespace App\Dto; -use App\Casts\ContextualBooleanCast; use App\Casts\EnumCast; use App\Concerns\HasRequestFingerprint; use App\Contracts\DataRequest; use App\Dto\Concerns\HasPreliminaryParameter; use App\Dto\Concerns\HasSpoilersParameter; use App\Dto\Concerns\PreparesData; -use App\Enums\TopAnimeFilterEnum; use App\Enums\TopReviewsTypeEnum; use App\Rules\Attributes\EnumValidation; use Illuminate\Http\JsonResponse; @@ -23,6 +21,6 @@ final class QueryTopReviewsCommand extends QueryTopItemsCommand implements DataR { use HasRequestFingerprint, HasPreliminaryParameter, HasSpoilersParameter, PreparesData; - #[WithCast(EnumCast::class, TopAnimeFilterEnum::class), EnumValidation(TopReviewsTypeEnum::class)] + #[WithCast(EnumCast::class, TopReviewsTypeEnum::class), EnumValidation(TopReviewsTypeEnum::class)] public TopReviewsTypeEnum|Optional $type; } diff --git a/app/Features/QuerySpecificAnimeSeasonHandler.php b/app/Features/QuerySpecificAnimeSeasonHandler.php index 1ec6821..e073fa0 100644 --- a/app/Features/QuerySpecificAnimeSeasonHandler.php +++ b/app/Features/QuerySpecificAnimeSeasonHandler.php @@ -3,8 +3,6 @@ namespace App\Features; use App\Dto\QuerySpecificAnimeSeasonCommand; -use App\Enums\AnimeSeasonEnum; -use App\Enums\AnimeStatusEnum; use App\Enums\AnimeTypeEnum; use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Support\Carbon; diff --git a/app/Features/QueryTopReviewsHandler.php b/app/Features/QueryTopReviewsHandler.php index 6eaae8b..a967d99 100644 --- a/app/Features/QueryTopReviewsHandler.php +++ b/app/Features/QueryTopReviewsHandler.php @@ -7,7 +7,6 @@ use App\Enums\TopReviewsTypeEnum; use App\Support\CachedData; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; -use Jikan\Helper\Constants; use Jikan\MyAnimeList\MalClient; use Jikan\Request\Reviews\ReviewsRequest; @@ -31,7 +30,7 @@ final class QueryTopReviewsHandler extends RequestHandlerWithScraperCache $preliminary = $requestParams->get("preliminary", true); return $this->scraperService->findList( $requestFingerPrint, - fn (MalClient $jikan, ?int $page = null) => $jikan->getReviews(new ReviewsRequest($type, $page, $spoilers, $preliminary)), + fn (MalClient $jikan, ?int $page = null) => $jikan->getReviews(new ReviewsRequest(ensureEnumPrimitiveValue($type), $page, $spoilers, $preliminary)), $requestParams->get("page")); } } diff --git a/app/Http/Controllers/V4DB/AnimeController.php b/app/Http/Controllers/V4DB/AnimeController.php index b6c26af..abdd98d 100644 --- a/app/Http/Controllers/V4DB/AnimeController.php +++ b/app/Http/Controllers/V4DB/AnimeController.php @@ -22,7 +22,7 @@ use App\Dto\AnimeThemesLookupCommand; use App\Dto\AnimeUserUpdatesLookupCommand; use App\Dto\AnimeVideosEpisodesLookupCommand; use App\Dto\AnimeVideosLookupCommand; -use Illuminate\Http\Request; +use OpenApi\Annotations as OA; class AnimeController extends Controller { @@ -225,15 +225,17 @@ class AnimeController extends Controller * nullable=true * ), * @OA\Property( - * property="duration", - * type="integer", - * description="Episode duration in seconds", - * nullable=true - * ), - * @OA\Property( * property="aired", * type="string", * description="Aired Date ISO8601", + * nullable=true + * ), + * @OA\Property( + * property="score", + * type="float", + * description="Aggregated episode score (1.00 - 5.00) based on MyAnimeList user voting", +* minimum="1", +* maximum="5", * nullable=true * ), * @OA\Property( @@ -669,7 +671,7 @@ class AnimeController extends Controller * * @OA\Parameter(ref="#/components/parameters/page"), * @OA\Parameter(ref="#/components/parameters/preliminary"), - * @OA\Parameter(ref="#/components/parameters/spoiler"), + * @OA\Parameter(ref="#/components/parameters/spoilers"), * * @OA\Response( * response="200", diff --git a/app/Http/Controllers/V4DB/MangaController.php b/app/Http/Controllers/V4DB/MangaController.php index e1794eb..f863a2e 100644 --- a/app/Http/Controllers/V4DB/MangaController.php +++ b/app/Http/Controllers/V4DB/MangaController.php @@ -15,7 +15,7 @@ use App\Dto\MangaRelationsLookupCommand; use App\Dto\MangaReviewsLookupCommand; use App\Dto\MangaStatsLookupCommand; use App\Dto\MangaUserUpdatesLookupCommand; -use Illuminate\Http\Request; +use OpenApi\Annotations as OA; class MangaController extends Controller { @@ -387,7 +387,7 @@ class MangaController extends Controller * * @OA\Parameter(ref="#/components/parameters/page"), * @OA\Parameter(ref="#/components/parameters/preliminary"), - * @OA\Parameter(ref="#/components/parameters/spoiler"), + * @OA\Parameter(ref="#/components/parameters/spoilers"), * * @OA\Response( * response="200", diff --git a/app/Http/Controllers/V4DB/ReviewsController.php b/app/Http/Controllers/V4DB/ReviewsController.php index dcf8ac2..8dca6a9 100644 --- a/app/Http/Controllers/V4DB/ReviewsController.php +++ b/app/Http/Controllers/V4DB/ReviewsController.php @@ -4,18 +4,20 @@ namespace App\Http\Controllers\V4DB; use App\Dto\QueryAnimeReviewsCommand; use App\Dto\QueryMangaReviewsCommand; +use OpenApi\Annotations as OA; class ReviewsController extends Controller { /** - * @OA\Get( + * @OA\ + * Get( * path="/reviews/anime", * operationId="getRecentAnimeReviews", * tags={"reviews"}, * * @OA\Parameter(ref="#/components/parameters/page"), * @OA\Parameter(ref="#/components/parameters/preliminary"), - * @OA\Parameter(ref="#/components/parameters/spoiler"), + * @OA\Parameter(ref="#/components/parameters/spoilers"), * * * @OA\Response( @@ -70,7 +72,7 @@ class ReviewsController extends Controller * * @OA\Parameter(ref="#/components/parameters/page"), * @OA\Parameter(ref="#/components/parameters/preliminary"), - * @OA\Parameter(ref="#/components/parameters/spoiler"), + * @OA\Parameter(ref="#/components/parameters/spoilers"), * * @OA\Response( * response="200", diff --git a/app/Http/Resources/V4/ReviewsResource.php b/app/Http/Resources/V4/ReviewsResource.php index 256beb2..0bca9e0 100644 --- a/app/Http/Resources/V4/ReviewsResource.php +++ b/app/Http/Resources/V4/ReviewsResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources\V4; use Illuminate\Http\Resources\Json\JsonResource; +use OpenApi\Annotations as OA; class ReviewsResource extends JsonResource { diff --git a/app/Producers.php b/app/Producers.php index f46216d..dd5441c 100644 --- a/app/Producers.php +++ b/app/Producers.php @@ -72,6 +72,33 @@ class Producers extends JikanApiSearchableModel ]; } + public function getCollectionSchema(): array + { + return [ + 'name' => $this->searchableAs(), + 'fields' => [ + [ + 'name' => '.*', + 'type' => 'auto', + ], + [ + 'name' => 'titles', + 'type' => 'string', + 'optional' => false, + 'infix' => true, + 'sort' => true + ], + [ + 'name' => 'url', + 'type' => 'string', + 'optional' => false, + 'infix' => true, + 'sort' => true + ], + ] + ]; + } + public function typesenseQueryBy(): array { return [ diff --git a/app/Repositories/DefaultAnimeRepository.php b/app/Repositories/DefaultAnimeRepository.php index ffbf103..9843629 100644 --- a/app/Repositories/DefaultAnimeRepository.php +++ b/app/Repositories/DefaultAnimeRepository.php @@ -167,19 +167,39 @@ final class DefaultAnimeRepository extends DatabaseRepository implements AnimeRe $finalFilter['$or'][] = [ // note: this expression only works with mongodb version 5.0.0 or higher '$expr' => [ - '$lte' => [ + '$and' => [ [ - '$dateDiff' => [ - 'startDate' => [ - '$dateFromString' => [ - 'dateString' => '$aired.from' + '$lte' => [ + [ + '$dateDiff' => [ + 'startDate' => [ + '$dateFromString' => [ + 'dateString' => '$aired.from' + ] + ], + 'endDate' => new UTCDateTime($from), + 'unit' => 'month' ] ], - 'endDate' => new UTCDateTime($from), - 'unit' => 'month' - ] + 3 // there are 3 months in a season, so anything that started in 3 months or less will be included + ], ], - 3 // there are 3 months in a season, so anything that started in 3 months or less will be included + [ + '$gt' => [ + [ + '$dateDiff' => [ + 'startDate' => [ + '$dateFromString' => [ + 'dateString' => '$aired.from' + ] + ], + 'endDate' => new UTCDateTime($from), + 'unit' => 'month' + ] + ], + 0 + ] + ] ] ], 'aired.to' => null, diff --git a/app/Support/helpers.php b/app/Support/helpers.php index dc9094d..df69542 100644 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -114,3 +114,12 @@ if (! function_exists('cache')) { return app('cache')->put(key($arguments[0]), reset($arguments[0]), $arguments[1] ?? null); } } + +if (!function_exists("ensureEnumPrimitiveValue")) { + function ensureEnumPrimitiveValue(int|string|bool|float|null|\Spatie\Enum\Laravel\Enum $value): mixed { + if ($value instanceof \Spatie\Enum\Laravel\Enum) { + return $value->value; + } + return $value; + } +} diff --git a/composer.json b/composer.json index 8234697..55e6a70 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "php": "^8.1", "ext-json": "*", "ext-mongodb": "*", + "ext-pcntl": "*", "amphp/http-client": "^4.6", "danielmewes/php-rql": "dev-master", "darkaonline/swagger-lume": "^9.0", diff --git a/composer.lock b/composer.lock index b7d9f1a..572b6df 100644 --- a/composer.lock +++ b/composer.lock @@ -4554,16 +4554,16 @@ }, { "name": "jikan-me/jikan", - "version": "v4.0.11", + "version": "v4.0.12", "source": { "type": "git", "url": "https://github.com/jikan-me/jikan.git", - "reference": "fcc8d20817ce29332b496a21652b9965c6c8196c" + "reference": "dcb47237a9407473f484bd28a5e44479cdd916fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jikan-me/jikan/zipball/fcc8d20817ce29332b496a21652b9965c6c8196c", - "reference": "fcc8d20817ce29332b496a21652b9965c6c8196c", + "url": "https://api.github.com/repos/jikan-me/jikan/zipball/dcb47237a9407473f484bd28a5e44479cdd916fc", + "reference": "dcb47237a9407473f484bd28a5e44479cdd916fc", "shasum": "" }, "require": { @@ -4602,7 +4602,7 @@ "description": "Jikan is an unofficial MyAnimeList API", "support": { "issues": "https://github.com/jikan-me/jikan/issues", - "source": "https://github.com/jikan-me/jikan/tree/v4.0.11" + "source": "https://github.com/jikan-me/jikan/tree/v4.0.12" }, "funding": [ { @@ -4610,7 +4610,7 @@ "type": "patreon" } ], - "time": "2024-05-30T08:15:50+00:00" + "time": "2024-09-20T22:15:42+00:00" }, { "name": "jms/metadata", @@ -13591,5 +13591,5 @@ "ext-mongodb": "*" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/config/swagger-lume.php b/config/swagger-lume.php index f9182e3..d5ffb8a 100644 --- a/config/swagger-lume.php +++ b/config/swagger-lume.php @@ -260,6 +260,7 @@ return [ | `BadRequestException` | `405 - Method Not Allowed` | Requested Method is not supported for resource. Only `GET` requests are allowed | | `RateLimitException` | `429 - Too Many Request` | You are being rate limited by Jikan or MyAnimeList is rate-limiting our servers (specified in the error response) | | `UpstreamException`,`ParserException`,etc. | `500 - Internal Server Error` | Something didn't work. Try again later. If you see an error response with a `report_url` URL, please click on it to open an auto-generated GitHub issue | + | `ServiceUnavailableException` | `503 - Service Unavailable` | In most cases this is intentionally done if the service is down for maintenance. | ## JSON Error Response diff --git a/container-setup.sh b/container-setup.sh index 89c39c6..3328af2 100755 --- a/container-setup.sh +++ b/container-setup.sh @@ -34,6 +34,7 @@ display_help() { echo "stop Stop Jikan API" echo "validate-prereqs Validate pre-reqs installed (docker, docker-compose)" echo "execute-indexers Execute the indexers, which will scrape and index data from MAL. (Notice: This can take days)" + echo "index-incrementally Executes the incremental indexers for each media type. (anime, manga)" echo "" } @@ -168,6 +169,11 @@ case "$1" in $DOCKER_COMPOSE_CMD -p "$DOCKER_COMPOSE_PROJECT_NAME" exec jikan_rest php /app/artisan indexer:producers echo "Indexing done!" ;; + "index-incrementally") + echo "Indexing..." + $DOCKER_COMPOSE_CMD -p "$DOCKER_COMPOSE_PROJECT_NAME" exec jikan_rest php /app/artisan indexer:incremental anime manga + echo "Indexing done!" + ;; *) echo "No command specified, displaying help" display_help diff --git a/container_usage.md b/container_usage.md index bce6bd7..f20e110 100644 --- a/container_usage.md +++ b/container_usage.md @@ -16,6 +16,9 @@ This will: > **Note**: The script supports both `docker` and `podman`. In case of `podman` please bare in mind that sometimes the container name resolution doesn't work on the container network. > In those cases you might have to install `aardvark-dns` package. On `Arch Linux` podman uses `netavark` network by default (in 2023) so you will need to install the before mentioned package. +> **Note 2**: The script will start the jikan API, but if you start it for the first time, it won't have any data in it! +> You will have to run the indexers through artisan to have data. See ["Running the indexer with the script"](#running-the-indexer-with-the-script) section. + The script has the following prerequisites and will notify you if these are not present: - git @@ -36,6 +39,7 @@ start Start Jikan API (mongodb, typesense, redis, jikan-api wor stop Stop Jikan API validate-prereqs Validate pre-reqs installed (docker, docker-compose) execute-indexers Execute the indexers, which will scrape and index data from MAL. (Notice: This can take days) +index-incrementally Executes the incremental indexers for each media type. (anime, manga) ``` ### Running the indexer with the script diff --git a/docker-compose.yml b/docker-compose.yml index e1c0843..edd17cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ secrets: services: jikan_rest: - image: "jikanme/jikan-rest:${_JIKAN_API_VERSION:-latest}" + image: "docker.io/jikanme/jikan-rest:${_JIKAN_API_VERSION:-latest}" user: "${APP_UID:-10001}:${APP_GID:-10001}" networks: - jikan_network @@ -116,7 +116,3 @@ services: - typesense-data:/data ports: - "8108/tcp" - healthcheck: - test: [ 'CMD-SHELL', '{ ! [ -f "curl_created" ] && apt -qq update -y && apt -qq install -y curl && touch curl_created && curl -s -f http://localhost:8108/health; } || { curl -s -f http://localhost:8108/health; }' ] - interval: 5s - timeout: 2s diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 8ab520a..a8bc1b7 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "Jikan API", - "description": "[Jikan](https://jikan.moe) is an **Unofficial** MyAnimeList API.\nIt scrapes the website to satisfy the need for a complete API - which MyAnimeList lacks.\n\n# Information\n\nāš” Jikan is powered by its awesome backers - šŸ™ [Become a backer](https://www.patreon.com/jikan)\n\n## Rate Limiting\n\n| Duration | Requests |\n|----|----|\n| Daily | **Unlimited** |\n| Per Minute | 60 requests |\n| Per Second | 3 requests |\n\nNote: It's still possible to get rate limited from MyAnimeList.net instead.\n\n\n## JSON Notes\n- Any property (except arrays or objects) whose value does not exist or is undetermined, will be `null`.\n- Any array or object property whose value does not exist or is undetermined, will be empty.\n- Any `score` property whose value does not exist or is undetermined, will be `0`.\n- All dates and timestamps are returned in [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) format and in UTC timezone\n\n## Caching\nBy **CACHING**, we refer to the data parsed from MyAnimeList which is stored temporarily on our servers to provide better API performance.\n\nAll requests are cached for **24 hours**.\n\nThe following response headers will detail cache information.\n\n| Header | Remarks |\n| ---- | ---- |\n| `Expires` | Cache expiry date |\n| `Last-Modified` | Cache set date |\n| `X-Request-Fingerprint` | Unique request fingerprint (only for cachable requests, not queries) |\n\n\nNote: `X-Request-Fingerprint` will only be available on single resource requests and their child endpoints. e.g `/anime/1`, `/anime/1/relations`.\nThey won't be available on pages which perform queries, like /anime, or /top/anime, etc.\n\n## Allowed HTTP(s) requests\n\n**Jikan REST API does not provide authenticated requests for MyAnimeList.** This means you can not use it to update your anime/manga list.\nOnly GET requests are supported which return READ-ONLY data.\n\n## HTTP Responses\n\nAll error responses are accompanied by a JSON Error response.\n\n| Exception | HTTP Status | Remarks |\n| ---- | ---- | ---- |\n| N/A | `200 - OK` | The request was successful |\n| N/A | `304 - Not Modified` | You have the latest data (Cache Validation response) |\n| `BadRequestException`,`ValidationException` | `400 - Bad Request` | You've made an invalid request. Recheck documentation |\n| `BadResponseException` | `404 - Not Found` | The resource was not found or MyAnimeList responded with a `404` |\n| `BadRequestException` | `405 - Method Not Allowed` | Requested Method is not supported for resource. Only `GET` requests are allowed |\n| `RateLimitException` | `429 - Too Many Request` | You are being rate limited by Jikan or MyAnimeList is rate-limiting our servers (specified in the error response) |\n| `UpstreamException`,`ParserException`,etc. | `500 - Internal Server Error` | Something didn't work. Try again later. If you see an error response with a `report_url` URL, please click on it to open an auto-generated GitHub issue |\n\n## JSON Error Response\n\n```json\n {\n \"status\": 500,\n \"type\": \"InternalException\",\n \"message\": \"Exception Message\",\n \"error\": \"Exception Trace\",\n \"report_url\": \"https://github.com...\"\n }\n```\n\n| Property | Remarks |\n| ---- | ---- |\n| `status` | Returned HTTP Status Code |\n| `type` | Thrown Exception |\n| `message` | Human-readable error message |\n| `error` | Error response and trace from the API |\n| `report_url` | Clicking this would redirect you to a generated GitHub issue |\n\n\n## Cache Validation\n\n- All requests return a `ETag` header which is an MD5 hash of the response\n- You can use this hash to verify if there's new or updated content by suppliying it as the value for the `If-None-Match` in your next request header\n- You will get a HTTP `304 - Not Modified` response if the content has not changed\n- If the content has changed, you'll get a HTTP `200 - OK` response with the updated JSON response\n\n![Cache Validation](https://i.imgur.com/925ozVn.png 'Cache Validation')\n\n## Disclaimer\n\n- Jikan is not affiliated with MyAnimeList.net.\n- Jikan is a free, open-source API. Please use it responsibly.\n\n----\n\nBy using the API, you are agreeing to Jikan's [terms of use](https://jikan.moe/terms) policy.\n\n[v3 Documentation](https://jikan.docs.apiary.io/) - [Wrappers/SDKs](https://github.com/jikan-me/jikan#wrappers) - [Report an issue](https://github.com/jikan-me/jikan-rest/issues/new) - [Host your own server](https://github.com/jikan-me/jikan-rest)", + "description": "[Jikan](https://jikan.moe) is an **Unofficial** MyAnimeList API.\nIt scrapes the website to satisfy the need for a complete API - which MyAnimeList lacks.\n\n# Information\n\nāš” Jikan is powered by its awesome backers - šŸ™ [Become a backer](https://www.patreon.com/jikan)\n\n## Rate Limiting\n\n| Duration | Requests |\n|----|----|\n| Daily | **Unlimited** |\n| Per Minute | 60 requests |\n| Per Second | 3 requests |\n\nNote: It's still possible to get rate limited from MyAnimeList.net instead.\n\n\n## JSON Notes\n- Any property (except arrays or objects) whose value does not exist or is undetermined, will be `null`.\n- Any array or object property whose value does not exist or is undetermined, will be empty.\n- Any `score` property whose value does not exist or is undetermined, will be `0`.\n- All dates and timestamps are returned in [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) format and in UTC timezone\n\n## Caching\nBy **CACHING**, we refer to the data parsed from MyAnimeList which is stored temporarily on our servers to provide better API performance.\n\nAll requests are cached for **24 hours**.\n\nThe following response headers will detail cache information.\n\n| Header | Remarks |\n| ---- | ---- |\n| `Expires` | Cache expiry date |\n| `Last-Modified` | Cache set date |\n| `X-Request-Fingerprint` | Unique request fingerprint (only for cachable requests, not queries) |\n\n\nNote: `X-Request-Fingerprint` will only be available on single resource requests and their child endpoints. e.g `/anime/1`, `/anime/1/relations`.\nThey won't be available on pages which perform queries, like /anime, or /top/anime, etc.\n\n## Allowed HTTP(s) requests\n\n**Jikan REST API does not provide authenticated requests for MyAnimeList.** This means you can not use it to update your anime/manga list.\nOnly GET requests are supported which return READ-ONLY data.\n\n## HTTP Responses\n\nAll error responses are accompanied by a JSON Error response.\n\n| Exception | HTTP Status | Remarks |\n| ---- | ---- | ---- |\n| N/A | `200 - OK` | The request was successful |\n| N/A | `304 - Not Modified` | You have the latest data (Cache Validation response) |\n| `BadRequestException`,`ValidationException` | `400 - Bad Request` | You've made an invalid request. Recheck documentation |\n| `BadResponseException` | `404 - Not Found` | The resource was not found or MyAnimeList responded with a `404` |\n| `BadRequestException` | `405 - Method Not Allowed` | Requested Method is not supported for resource. Only `GET` requests are allowed |\n| `RateLimitException` | `429 - Too Many Request` | You are being rate limited by Jikan or MyAnimeList is rate-limiting our servers (specified in the error response) |\n| `UpstreamException`,`ParserException`,etc. | `500 - Internal Server Error` | Something didn't work. Try again later. If you see an error response with a `report_url` URL, please click on it to open an auto-generated GitHub issue |\n| `ServiceUnavailableException` | `503 - Service Unavailable` | In most cases this is intentionally done if the service is down for maintenance. |\n\n## JSON Error Response\n\n```json\n {\n \"status\": 500,\n \"type\": \"InternalException\",\n \"message\": \"Exception Message\",\n \"error\": \"Exception Trace\",\n \"report_url\": \"https://github.com...\"\n }\n```\n\n| Property | Remarks |\n| ---- | ---- |\n| `status` | Returned HTTP Status Code |\n| `type` | Thrown Exception |\n| `message` | Human-readable error message |\n| `error` | Error response and trace from the API |\n| `report_url` | Clicking this would redirect you to a generated GitHub issue |\n\n\n## Cache Validation\n\n- All requests return a `ETag` header which is an MD5 hash of the response\n- You can use this hash to verify if there's new or updated content by suppliying it as the value for the `If-None-Match` in your next request header\n- You will get a HTTP `304 - Not Modified` response if the content has not changed\n- If the content has changed, you'll get a HTTP `200 - OK` response with the updated JSON response\n\n![Cache Validation](https://i.imgur.com/925ozVn.png 'Cache Validation')\n\n## Disclaimer\n\n- Jikan is not affiliated with MyAnimeList.net.\n- Jikan is a free, open-source API. Please use it responsibly.\n\n----\n\nBy using the API, you are agreeing to Jikan's [terms of use](https://jikan.moe/terms) policy.\n\n[v3 Documentation](https://jikan.docs.apiary.io/) - [Wrappers/SDKs](https://github.com/jikan-me/jikan#wrappers) - [Report an issue](https://github.com/jikan-me/jikan-rest/issues/new) - [Host your own server](https://github.com/jikan-me/jikan-rest)", "termsOfService": "https://jikan.moe/terms", "contact": { "name": "API Support (Discord)", @@ -587,7 +587,7 @@ "$ref": "#/components/parameters/preliminary" }, { - "$ref": "#/components/parameters/spoiler" + "$ref": "#/components/parameters/spoilers" } ], "responses": { @@ -1605,7 +1605,7 @@ "$ref": "#/components/parameters/preliminary" }, { - "$ref": "#/components/parameters/spoiler" + "$ref": "#/components/parameters/spoilers" } ], "responses": { @@ -2226,7 +2226,7 @@ "$ref": "#/components/parameters/preliminary" }, { - "$ref": "#/components/parameters/spoiler" + "$ref": "#/components/parameters/spoilers" } ], "responses": { @@ -2258,7 +2258,7 @@ "$ref": "#/components/parameters/preliminary" }, { - "$ref": "#/components/parameters/spoiler" + "$ref": "#/components/parameters/spoilers" } ], "responses": { @@ -4480,16 +4480,18 @@ "type": "string", "nullable": true }, - "duration": { - "description": "Episode duration in seconds", - "type": "integer", - "nullable": true - }, "aired": { "description": "Aired Date ISO8601", "type": "string", "nullable": true }, + "score": { + "description": "Aggregated episode score (1.00 - 5.00) based on MyAnimeList user voting", + "type": "float", + "maximum": "5", + "minimum": "1", + "nullable": true + }, "filler": { "description": "Filler episode", "type": "boolean" @@ -9086,8 +9088,8 @@ "type": "boolean" } }, - "spoiler": { - "name": "spoiler", + "spoilers": { + "name": "spoilers", "in": "query", "description": "Any reviews that are tagged as a spoiler. Spoiler reviews are not returned by default. e.g usage: `?spoiler=true`", "required": false, diff --git a/tests/Integration/SeasonControllerTest.php b/tests/Integration/SeasonControllerTest.php index 6d6a752..ffcde26 100644 --- a/tests/Integration/SeasonControllerTest.php +++ b/tests/Integration/SeasonControllerTest.php @@ -190,4 +190,39 @@ class SeasonControllerTest extends TestCase $this->assertIsArray($content["data"]); $this->assertCount(2, $content["data"]); } + + public function testShouldNotIncludeNewlyStartedSeasonOfAnimeInPreviousSeasons() + { + Carbon::setTestNow(Carbon::parse("2024-10-26")); + $f = Anime::factory(1); + $startDate = "2024-10-02"; + $carbonStartDate = Carbon::parse($startDate); + $state = $f->serializeStateDefinition([ + "aired" => new CarbonDateRange($carbonStartDate, null) + ]); + $state["aired"]["string"] = "Oct 2, 2024 to ?"; + $state["airing"] = true; + $state["status"] = "Currently Airing"; + $state["premiered"] = "Fall 2024"; + $state["mal_id"] = 54857; + $state["title"] = "Re:Zero kara Hajimeru Isekai Seikatsu 3rd Season"; + $state["episodes"] = 16; + $state["type"] = "TV"; + $state["duration"] = "23 min per ep"; + $state["score"] = 8.9; + $f->create($state); + + $content = $this->getJsonResponse([], "/v4/seasons/now?filter=tv&continuing&page=1"); + $this->seeStatusCode(200); + $this->assertCount(1, $content["data"]); + + $content = $this->getJsonResponse([], "/v4/seasons/2024/summer?filter=tv&continuing&page=1"); + $this->seeStatusCode(200); + $this->assertCount(0, $content["data"]); + + $content = $this->getJsonResponse([], "/v4/seasons/2024/spring?filter=tv&continuing&page=1"); + $this->seeStatusCode(200); + $this->assertCount(0, $content["data"]); + Carbon::setTestNow(); + } } diff --git a/tests/Integration/TopControllerTest.php b/tests/Integration/TopControllerTest.php index e85ca9f..22f0e46 100644 --- a/tests/Integration/TopControllerTest.php +++ b/tests/Integration/TopControllerTest.php @@ -7,6 +7,11 @@ use App\Person; use App\Testing\ScoutFlush; use App\Testing\SyntheticMongoDbTransaction; use Illuminate\Database\Eloquent\Factories\Sequence; +use Jikan\Exception\BadResponseException; +use Jikan\Exception\ParserException; +use Jikan\Model\Reviews\Reviews; +use Jikan\MyAnimeList\MalClient; +use Jikan\Parser\Reviews\ReviewsParser; use Tests\TestCase; class TopControllerTest extends TestCase @@ -14,6 +19,15 @@ class TopControllerTest extends TestCase use SyntheticMongoDbTransaction; use ScoutFlush; + public function topReviewTypeParametersProvider(): array + { + return [ + "empty query string" => [[]], + "query string = `?type=anime`" => [["type" => "anime"]], + "query string = `?type=manga`" => [["type" => "manga"]], + ]; + } + public function testTopAnime() { Anime::factory(3)->state(new Sequence( @@ -290,4 +304,27 @@ class TopControllerTest extends TestCase $this->get('/v4/top/anime/999') ->seeStatusCode(404); } + + /** + * @dataProvider topReviewTypeParametersProvider + * @param $params + * @return void + * @throws BadResponseException + * @throws ParserException + */ + public function testTopReviews($params) + { + $jikanParser = \Mockery::mock(MalClient::class)->makePartial(); + + $reviewsParser = \Mockery::mock(ReviewsParser::class)->makePartial(); + $reviewsParser->allows()->getReviews()->andReturn([]); + $reviewsParser->allows()->hasNextPage()->andReturn(false); + $reviewsFacade = Reviews::fromParser($reviewsParser); + + /** @noinspection PhpParamsInspection */ + $jikanParser->allows()->getReviews(\Mockery::any())->andReturn($reviewsFacade); + $this->app->instance('JikanParser', $jikanParser); + $this->getJsonResponse($params,"/v4/top/reviews"); + $this->seeStatusCode(200); + } }