diff --git a/.env.example b/.env.example index 9118cb3..52a28f0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ -APP_NAME="Laravel App" -APP_VERSION=1.0 -APP_URL=http://laravel-app.local +RUN_MODE=octane + +APP_NAME="GameWatch" +APP_VERSION=1.0.0 +APP_URL=http://gamewatch.local APP_ENV=local APP_KEY= @@ -14,4 +16,18 @@ DB_USERNAME=root DB_PASSWORD=secret REDIS_HOST=redis -REDIS_PASSWORD=redis \ No newline at end of file +REDIS_PASSWORD=redis + +RAWG_API_KEY= +RAWG_API_HOST=https://api.rawg.io + +JWT_EXPIRES=false + +DISCORD_APP_ID= +DISCORD_PUBLIC_KEY= +DISCORD_BOT_TOKEN= +DISCORD_API_HOST=https://discord.com + +ROOT_DISCORD_USER_ID= +ROOT_DISCORD_USERNAME= +ROOT_DISCORD_CHANNEL_ID= \ No newline at end of file diff --git a/.github/workflows/application-ci.yml b/.github/workflows/application-ci.yml deleted file mode 100644 index 6986068..0000000 --- a/.github/workflows/application-ci.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Application CI - -on: - pull_request: - branches: [ "master" ] - push: - branches: [ "master" ] - -jobs: - tests-n-cs: - uses: ./.github/workflows/tests-n-cs.yml - - docker-image: - runs-on: ubuntu-latest - needs: tests-n-cs - - steps: - - uses: actions/checkout@v4 - - - name: Build Docker image (PHP-fpm) - run: docker build . --file Dockerfile.app --tag laravel-app/php-fpm - - - name: Build Docker image (Nginx) - run: docker build . --file Dockerfile.nginx --tag laravel-app/nginx - diff --git a/github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml similarity index 87% rename from github/workflows/ci-cd.yml rename to .github/workflows/ci-cd.yml index 723702c..005594c 100644 --- a/github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -23,8 +23,8 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ vars.AWS_REGION }} - - name: Build Docker image (PHP-fpm) - run: docker build . --file Dockerfile.app --tag ${{ vars.DOCKER_IMAGE_TAG }}/php-fpm + - name: Build Docker image (Octane) + run: docker build . --file Dockerfile.app --tag ${{ vars.DOCKER_IMAGE_TAG }}/octane - name: Build Docker image (Nginx) run: docker build . --file Dockerfile.nginx --tag ${{ vars.DOCKER_IMAGE_TAG }}/nginx @@ -36,12 +36,12 @@ jobs: - name: Tag Docker images run: | - docker tag ${{ vars.DOCKER_IMAGE_TAG }}/php-fpm:latest ${{ secrets.ECR_REPOSITORY_URI }}:latest-php-fpm + docker tag ${{ vars.DOCKER_IMAGE_TAG }}/octane:latest ${{ secrets.ECR_REPOSITORY_URI }}:latest-octane docker tag ${{ vars.DOCKER_IMAGE_TAG }}/nginx:latest ${{ secrets.ECR_REPOSITORY_URI }}:latest-nginx - name: Push Docker images to ECR run: | - docker push ${{ secrets.ECR_REPOSITORY_URI }}:latest-php-fpm + docker push ${{ secrets.ECR_REPOSITORY_URI }}:latest-octane docker push ${{ secrets.ECR_REPOSITORY_URI }}:latest-nginx ecs-service-deploy: @@ -64,7 +64,7 @@ jobs: sed -i 's||${{ secrets.ECS_TASK_EXEC_ROLE }}|' ./ecs/deployment-task.json sed -i 's||${{ vars.ECS_SERVICE_NAME }}|' ./ecs/deployment-task.json sed -i 's||${{ secrets.SSM_NAMESPACE }}|' ./ecs/deployment-task.json - sed -i 's||${{ secrets.ECR_REPOSITORY_URI }}:latest-php-fpm|' ./ecs/deployment-task.json + sed -i 's||${{ secrets.ECR_REPOSITORY_URI }}:latest-octane|' ./ecs/deployment-task.json sed -i 's||${{ secrets.ECR_REPOSITORY_URI }}:latest-nginx|' ./ecs/deployment-task.json - name: Register updated task definition diff --git a/github/workflows/pull-request.yml b/.github/workflows/pull-request.yml similarity index 86% rename from github/workflows/pull-request.yml rename to .github/workflows/pull-request.yml index c2b18b9..7c4d6dc 100644 --- a/github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -15,8 +15,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Build Docker image (PHP-fpm) - run: docker build . --file Dockerfile.app --tag ${{ vars.DOCKER_IMAGE_TAG }}/php-fpm + - name: Build Docker image (Octane) + run: docker build . --file Dockerfile.app --tag ${{ vars.DOCKER_IMAGE_TAG }}/octane - name: Build Docker image (Nginx) run: docker build . --file Dockerfile.nginx --tag ${{ vars.DOCKER_IMAGE_TAG }}/nginx diff --git a/.gitignore b/.gitignore index ad0c1f1..2f239e8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ vendor .env .vscode .phpunit.result.cache -*info.php \ No newline at end of file +*info.php diff --git a/Dockerfile.app b/Dockerfile.app index 9ad01ce..5d8b7a6 100755 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -1,42 +1,37 @@ -FROM php:8.3-fpm +FROM php:8.3-cli -WORKDIR /var/www/laravel-app +WORKDIR /srv/gamewatch RUN apt-get update && apt-get install -y \ + libcurl4-openssl-dev \ + libbrotli-dev \ + libc-ares-dev \ + libssl-dev \ apt-utils \ curl \ wget \ zip \ git \ nano \ - supervisor + npm RUN docker-php-ext-configure pcntl --enable-pcntl RUN docker-php-ext-install pdo pdo_mysql pcntl opcache RUN pecl install xdebug \ - redis \ - && docker-php-ext-enable xdebug \ redis -RUN mkdir -p /var/run/php && \ - chown -R www-data:www-data /var/run/php && \ - chmod 755 /var/run/php +RUN printf "\n" | pecl install swoole -COPY . /var/www/laravel-app +RUN docker-php-ext-enable xdebug \ + redis \ + swoole +COPY . . COPY ./docker/php "${PHP_INI_DIR}/conf.d/" -COPY ./docker/php-fpm/www.conf /usr/local/etc/php-fpm.d/www.conf -COPY ./docker/supervisor /etc/supervisor/ RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer -RUN find /var/www/laravel-app -not -path "/var/www/laravel-app/vendor/*" -type f -exec chmod 644 {} \; -RUN find /var/www/laravel-app -type d -exec chmod 755 {} \; -RUN chown -R www-data:www-data /var/www/laravel-app -RUN chgrp -R www-data storage bootstrap/cache -RUN chmod -R ug+rwx storage bootstrap/cache - COPY ./docker/entrypoint.app.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"] \ No newline at end of file diff --git a/Dockerfile.nginx b/Dockerfile.nginx index 3518de9..bafa7fd 100644 --- a/Dockerfile.nginx +++ b/Dockerfile.nginx @@ -1,6 +1,6 @@ FROM nginx:alpine -COPY . /var/www/laravel-app +COPY . /var/www/gamewatch COPY ./docker/nginx/default.conf /etc/nginx/conf.d/default.conf.template COPY ./docker/entrypoint.nginx.sh /usr/local/bin/entrypoint.sh diff --git a/README.md b/README.md index 3463658..d2d3066 100755 --- a/README.md +++ b/README.md @@ -1,53 +1,12 @@ ![version](https://img.shields.io/badge/version-0.9.0-blue?style=flat) -[![build](https://github.com/danieltrolezi/laravel-app/actions/workflows/application-ci.yml/badge.svg)](https://github.com/danieltrolezi/laravel-app/actions/workflows/application-ci.yml) +[![build](https://github.com/danieltrolezi/gamewatch/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/danieltrolezi/gamewatch/actions/workflows/ci-cd.yml.yml) -## :package: Base Laravel Application +## GameWatch -This setup serves as the foundation for my personal Laravel projects and a playground for my coding experiments. +GameWatch makes it easy to track upcoming game releases. -If you prefer a streamlined development experience, consider checking out tools like [phpctl](https://github.com/opencodeco/phpctl), [docker-php](https://github.com/serversideup/docker-php), or [devpod](https://github.com/loft-sh/devpod). - -However, if you enjoy diving deep into the workings of everything, feel free to take a look. - -#### Documentation - -:sparkle: [Full documentation available on the Wiki](https://github.com/danieltrolezi/laravel-app/wiki). - -Explore examples on how to use: - -* [Swagger](https://github.com/danieltrolezi/laravel-app/wiki/07.-Swagger) -* [PHP's Yaml](https://github.com/danieltrolezi/laravel-app/wiki/98.-Appendix#yaml) -* [Laravel Octane](https://github.com/danieltrolezi/laravel-app/wiki/08.-Laravel-Octane) -* [Authentication & Authorization](https://github.com/danieltrolezi/laravel-app/wiki/09.-Authentication-&-Authorization) - -#### Key Technologies - -* Backend: PHP 8.3 + Laravel 11 Framework -* Databases: MySQL 8 + Redis -* Containerization: Docker -* Web Server: Nginx -* PHP Processor: PHP-fpm -* Process Supervisor: Supervisord - -#### Development Experience - -* Code Sniffer (PSR-12): Enforces consistent and clean coding practices -* Git Hooks: Automates tasks to enhance your workflow -* Unit Testing: Ensures your code works as intended -* Xdebug Integration: Elevates your debugging capabilities -* Swagger: For comprehensive OpenAPI documentation -* Rector: Simplifies PHP/Laravel version upgrades - -#### CI/CD Ready - -This repository includes GitHub Actions templates for: - -* Running Tests -* Code Sniffer -* Building Docker Images -* Uploading Docker Images to AWS ECR -* Updating Task Definitions on AWS ECS -* Updating Services on AWS ECS +Customize your notifications by selecting your preferred platforms and genres, +set your frequency, and never miss a game launch again! ## License diff --git a/app/Console/Commands/CreateRoot.php b/app/Console/Commands/CreateRoot.php new file mode 100644 index 0000000..f5fa5aa --- /dev/null +++ b/app/Console/Commands/CreateRoot.php @@ -0,0 +1,41 @@ +createRoot(); + + if ($created && app()->environment(['local', 'testing'])) { + $jwt = resolve(AuthService::class)->generateJWT([ + 'email' => config('auth.root.email'), + 'password' => config('auth.root.password') + ]); + + $this->info('JWT: ' . $jwt['token']); + } + } +} diff --git a/app/Console/Commands/DispatchNotifications.php b/app/Console/Commands/DispatchNotifications.php new file mode 100644 index 0000000..fa14a1f --- /dev/null +++ b/app/Console/Commands/DispatchNotifications.php @@ -0,0 +1,33 @@ +dispatchNotifications(); + } +} diff --git a/app/Console/Commands/RegisterCommand.php b/app/Console/Commands/RegisterCommand.php new file mode 100644 index 0000000..b6b074c --- /dev/null +++ b/app/Console/Commands/RegisterCommand.php @@ -0,0 +1,35 @@ +info( + resolve(DiscordAppService::class)->registerCommand( + $this->argument('name') + ) + ); + } +} diff --git a/app/Enums/BaseEnum.php b/app/Enums/BaseEnum.php new file mode 100644 index 0000000..5c6ff78 --- /dev/null +++ b/app/Enums/BaseEnum.php @@ -0,0 +1,73 @@ +name) === $case->name + ? $case->name + : Str::headline($case->name); + + return [ + 'name' => $name, + 'value' => $case->value, + ]; + }, self::cases()); + } +} diff --git a/app/Enums/Discord/Acknowledge.php b/app/Enums/Discord/Acknowledge.php new file mode 100644 index 0000000..2c1987a --- /dev/null +++ b/app/Enums/Discord/Acknowledge.php @@ -0,0 +1,17 @@ +value => 'week', + self::Next_30_Days->value => 'month', + self::Next_12_Months->value => 'year', + }; + } +} diff --git a/app/Enums/Platform.php b/app/Enums/Platform.php new file mode 100644 index 0000000..5b1ef96 --- /dev/null +++ b/app/Enums/Platform.php @@ -0,0 +1,20 @@ +authService = resolve(AuthService::class); + } + + public function attempt(array $credentials = []) + { + $user = $this->provider->retrieveByCredentials($credentials); + + if ($user && $this->provider->validateCredentials($user, $credentials)) { + $this->user = $user; + return true; + } + + return false; + } + + public function check() + { + return !is_null($this->user()); + } + + public function guest() + { + return !$this->check(); + } + + public function user() + { + if (!is_null($this->user)) { + return $this->user; + } + + $token = $this->request->bearerToken(); + + if (empty($token)) { + return null; + } + + try { + $jwt = $this->authService->decodeJWT($token); + $this->user = $this->provider->retrieveById($jwt->sub); + } catch (\Exception $e) { + return null; + } + + return $this->user; + } + + public function id() + { + return $this->user()?->getAuthIdentifier(); + } + + public function validate(array $credentials = []) + { + return false; + } + + public function setUser(Authenticatable $user) + { + $this->user = $user; + return $this; + } + + public function hasUser() + { + return !is_null($this->user); + } +} diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php new file mode 100644 index 0000000..f75c40e --- /dev/null +++ b/app/Http/Controllers/AccountController.php @@ -0,0 +1,166 @@ +json( + $request->user() + ); + } + + #[OA\Post( + path: '/api/account/register', + tags: ['account'], + responses: [ + new OA\Response( + response: 201, + description: 'Account data', + content: new OA\JsonContent( + ref: '#/components/schemas/User' + ) + ) + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string', nullable: false), + new OA\Property(property: 'email', type: 'string', nullable: false), + new OA\Property(property: 'password', type: 'string', format: 'password', nullable: false) + ] + ) + ) + )] + public function register(RegisterAccountRequest $request): JsonResponse + { + return response()->json( + $this->userRepository->create($request->validated()), + Response::HTTP_CREATED + ); + } + + #[OA\Put( + path: '/api/account/update', + tags: ['account'], + responses: [ + new OA\Response( + response: 200, + description: 'Account data', + content: new OA\JsonContent( + ref: '#/components/schemas/User' + ) + ) + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string', nullable: true), + new OA\Property(property: 'email', type: 'string', nullable: true), + new OA\Property(property: 'username', type: 'string', nullable: true), + new OA\Property(property: 'password', type: 'string', format: 'password', nullable: true), + new OA\Property(property: 'discord_user_id', type: 'string', nullable: true), + ] + ) + ) + )] + public function update(UpdateAccountRequest $request): JsonResponse + { + return response()->json( + $this->userRepository->update( + $request->user(), + $request->validated() + ) + ); + } + + #[OA\Put( + path: '/api/account/settings', + tags: ['account'], + responses: [ + new OA\Response( + response: 200, + description: 'Account data', + content: new OA\JsonContent( + ref: '#/components/schemas/User' + ) + ) + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'platforms', + type: 'array', + items: new OA\Items( + type: 'string', + enum: 'App\Enums\Platform' + ), + nullable: true + ), + new OA\Property( + property: 'genres', + type: 'array', + items: new OA\Items( + type: 'string', + enum: 'App\Enums\Rawg\RawgGenre' + ), + nullable: true + ), + new OA\Property( + property: 'period', + type: 'string', + nullable: true, + enum: 'App\Enums\Period' + ), + new OA\Property( + property: 'frequency', + type: 'string', + nullable: true, + enum: 'App\Enums\Frequency' + ), + ] + ) + ) + )] + public function settings(UpdateSettingsRequest $request): JsonResponse + { + return response()->json( + $this->userRepository->updateSettings( + $request->user(), + $request->validated() + ) + ); + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..149a6db --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,37 @@ +json( + resolve(AuthService::class)->generateJWT( + $request->only(['email', 'password']) + ) + ); + } +} diff --git a/app/Http/Controllers/DiscordController.php b/app/Http/Controllers/DiscordController.php new file mode 100644 index 0000000..d058a00 --- /dev/null +++ b/app/Http/Controllers/DiscordController.php @@ -0,0 +1,30 @@ +validated(); + + $user = $this->appService->findOrCreateUser($payload); + Auth::setUser($user); + + return response()->json( + $this->interactionsService->handleInteractions($payload) + ); + } +} diff --git a/app/Http/Controllers/RawgDomainController.php b/app/Http/Controllers/RawgDomainController.php new file mode 100644 index 0000000..4d228e4 --- /dev/null +++ b/app/Http/Controllers/RawgDomainController.php @@ -0,0 +1,57 @@ +rawgDomainService->getGenres(); + + return response()->json($data); + } + + #[OA\Get( + path: '/api/rawg/domain/tags', + tags: ['domain'], + responses: [ + new OA\Response(response: 200, description: 'List of RAWG tags') + ] + )] + public function tags(): JsonResponse + { + $data = $this->rawgDomainService->getTags(); + + return response()->json($data); + } + + #[OA\Get( + path: '/api/rawg/domain/platforms', + tags: ['domain'], + responses: [ + new OA\Response(response: 200, description: 'List of RAWG platforms') + ] + )] + public function platforms(): JsonResponse + { + $data = $this->rawgDomainService->getPlatforms(); + + return response()->json($data); + } +} diff --git a/app/Http/Controllers/RawgGamesController.php b/app/Http/Controllers/RawgGamesController.php new file mode 100644 index 0000000..d5de726 --- /dev/null +++ b/app/Http/Controllers/RawgGamesController.php @@ -0,0 +1,206 @@ +rawgGamesService->getRecommendations($genre, $request->validated()); + + return response()->json($response->getContents()); + } + + /** + * @return JsonResponse + */ + #[OA\Get( + path: '/api/rawg/games/upcoming-releases/{period}', + tags: ['games'], + parameters: [ + new OA\Parameter( + name: 'period', + in: 'path', + description: 'Get releases for selected period', + required: true, + schema: new OA\Schema( + ref: '#/components/schemas/Period' + ) + ), + new OA\Parameter( + name: 'genres', + in: 'query', + description: 'Filter by genres (accepts comma separated list)', + style: 'form', + explode: false, + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'string', + enum: 'App\Enums\Rawg\RawgGenre' + ) + ) + ), + new OA\Parameter( + name: 'platforms', + in: 'query', + description: 'Filter by platforms (accepts comma separated list)', + style: 'form', + explode: false, + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'string', + enum: 'App\Enums\Platform' + ) + ) + ), + new OA\Parameter(name: 'ordering', in: 'query', description: 'Rawg field to order by'), + new OA\Parameter(name: 'page', in: 'query', description: 'Page number to request'), + new OA\Parameter(name: 'page_size', in: 'query', description: 'How many items per page') + + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of RAWG games', + content: new OA\JsonContent( + ref: '#/components/schemas/PaginatedResponse' + ) + ) + ] + )] + public function upcomingReleases(RawgGamesRequest $request, string $period): JsonResponse + { + $response = $this->rawgGamesService->getUpcomingReleases($period, $request->validated()); + + return response()->json($response->getContents()); + } + + /** + * @param Request $request + * @param string $game + * @return JsonResponse + */ + #[OA\Get( + path: '/api/rawg/games/{game}/achievements', + tags: ['games'], + parameters: [ + new OA\Parameter( + name: 'game', + in: 'path', + description: 'Rawg slug of the game', + required: true + ), + new OA\Parameter(name: 'order_by', in: 'query', description: 'Field to order by'), + new OA\Parameter( + name: 'sort_order', + in: 'query', + description: 'Sorting order', + schema: new OA\Schema( + ref: '#/components/schemas/SortOrder' + ) + ), + new OA\Parameter(name: 'page', in: 'query', description: 'Page number to request'), + new OA\Parameter(name: 'page_size', in: 'query', description: 'How many items per page') + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of RAWG game\'s achievements', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + new OA\Property( + property: "id", + type: "integer" + ), + new OA\Property( + property: "name", + type: "string", + ), + new OA\Property( + property: "description", + type: "string", + ), + new OA\Property( + property: "image", + type: "string", + ), + new OA\Property( + property: "percent", + type: "string", + ) + ] + ) + ) + ) + ] + )] + public function achievements(RawgAchievementRequest $request, string $game): JsonResponse + { + $rawgAchievementService = resolve(RawgAchievementService::class); + $response = $rawgAchievementService->getGameAchievements($game, $request->validated()); + + return response()->json($response); + } +} diff --git a/app/Http/Middleware/CheckScopesMiddleware.php b/app/Http/Middleware/CheckScopesMiddleware.php new file mode 100644 index 0000000..b419750 --- /dev/null +++ b/app/Http/Middleware/CheckScopesMiddleware.php @@ -0,0 +1,23 @@ +checkScopes(...$scopes); + + return $next($request); + } +} diff --git a/app/Http/Middleware/VerifyDiscordSignature.php b/app/Http/Middleware/VerifyDiscordSignature.php new file mode 100644 index 0000000..f512926 --- /dev/null +++ b/app/Http/Middleware/VerifyDiscordSignature.php @@ -0,0 +1,28 @@ +verifyDiscordSignature( + $request->header('X-Signature-Ed25519'), + $request->header('X-Signature-Timestamp'), + $request->getContent() + ); + + return $next($request); + } +} diff --git a/app/Http/Requests/DiscordInteractionRequest.php b/app/Http/Requests/DiscordInteractionRequest.php new file mode 100644 index 0000000..a8ee085 --- /dev/null +++ b/app/Http/Requests/DiscordInteractionRequest.php @@ -0,0 +1,74 @@ +|string> + */ + public function rules(): array + { + $rules = $this->getRulesForInteractionType( + InteractionType::from($this->get('type')) + ); + + return array_merge([ + 'type' => 'required|integer|in:' . InteractionType::valuesAsString(), + 'data' => 'required|array', + 'data.options' => 'sometimes|array', + 'data.options.*' => 'required|array', + 'data.options.*.value' => 'required', + 'user' => 'required|array', + 'user.id' => 'required|string', + 'user.username' => 'required|string', + 'user.global_name' => 'required|string', + 'channel.id' => 'required|string', + 'message.components' => 'sometimes|array' + ], $rules); + } + + /** + * @param InteractionType $type + * @return array + */ + private function getRulesForInteractionType(InteractionType $type): array + { + return match ($type) { + InteractionType::Command => [ + 'data.type' => 'required|int', + 'data.name' => 'required|string', + ], + InteractionType::MessageComponent => [ + 'data.component_type' => 'required|int|in:' . ComponentType::valuesAsString(), + 'data.custom_id' => 'required|string', + 'data.values' => 'sometimes|array', + ], + default => [] + }; + } + + #[Override] + public function validated($key = null, $default = null): array + { + $validated = parent::validated($key, $default); + $validated['type'] = InteractionType::from($validated['type']); + + return $validated; + } +} diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..1eb6dcb --- /dev/null +++ b/app/Http/Requests/LoginRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => 'required|email', + 'password' => 'required' + ]; + } +} diff --git a/app/Http/Requests/RawgAchievementRequest.php b/app/Http/Requests/RawgAchievementRequest.php new file mode 100644 index 0000000..777d9c8 --- /dev/null +++ b/app/Http/Requests/RawgAchievementRequest.php @@ -0,0 +1,33 @@ +|string> + */ + public function rules(): array + { + return [ + 'order_by' => 'nullable|string', + 'sort_order' => 'nullable|string|in:' . SortOrder::valuesAsString(), + RawgField::PageSize->value => 'nullable|int', + RawgField::Page->value => 'nullable|int' + ]; + } +} diff --git a/app/Http/Requests/RawgGamesRequest.php b/app/Http/Requests/RawgGamesRequest.php new file mode 100644 index 0000000..e0f98a7 --- /dev/null +++ b/app/Http/Requests/RawgGamesRequest.php @@ -0,0 +1,44 @@ +|string> + */ + public function rules(): array + { + return [ + RawgField::Genres->value => [ + 'nullable', + 'string', + new KeywordList(RawgGenre::values()) + ], + RawgField::Platforms->value => [ + 'nullable', + 'string', + new KeywordList(Platform::values()) + ], + RawgField::Ordering->value => ['nullable', 'string'], + RawgField::PageSize->value => ['nullable', 'int'], + RawgField::Page->value => ['nullable', 'int'] + ]; + } +} diff --git a/app/Http/Requests/RegisterAccountRequest.php b/app/Http/Requests/RegisterAccountRequest.php new file mode 100644 index 0000000..9f6c233 --- /dev/null +++ b/app/Http/Requests/RegisterAccountRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => 'required|string|min:5|max:255', + 'email' => 'required|email|unique:users,email', + 'password' => 'required|string|min:6|max:18' + ]; + } +} diff --git a/app/Http/Requests/UpdateAccountRequest.php b/app/Http/Requests/UpdateAccountRequest.php new file mode 100644 index 0000000..b39b9ac --- /dev/null +++ b/app/Http/Requests/UpdateAccountRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => 'sometimes|string|min:5|max:255', + 'email' => ['sometimes', 'email', Rule::unique('users', 'email')->ignore($this->user())], + 'password' => 'sometimes|string|min:6|max:18', + ]; + } +} diff --git a/app/Http/Requests/UpdateSettingsRequest.php b/app/Http/Requests/UpdateSettingsRequest.php new file mode 100644 index 0000000..fa6ab94 --- /dev/null +++ b/app/Http/Requests/UpdateSettingsRequest.php @@ -0,0 +1,37 @@ +|string> + */ + public function rules(): array + { + return [ + 'platforms' => ['sometimes', 'array'], + 'platforms.*' => ['required', 'string', 'in:' . Platform::valuesAsString()], + 'genres' => ['sometimes', 'array'], + 'genres.*' => ['required', 'string', 'in:' . RawgGenre::valuesAsString()], + 'period' => ['sometimes', 'string', 'in:' . Period::valuesAsString()], + 'frequency' => ['sometimes', 'string', 'in:' . Frequency::valuesAsString()], + ]; + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php new file mode 100644 index 0000000..2a00c47 --- /dev/null +++ b/app/Models/Game.php @@ -0,0 +1,85 @@ +validateData($data); + $this->setData($data); + } + + private function validateData(array $data): void + { + $validator = Validator::make($data, [ + 'id' => 'required|int', + 'name' => 'required|string', + 'slug' => 'required|string', + 'background_image' => 'nullable|string', + 'released' => 'nullable|date', + 'platforms' => 'nullable|array', + 'platforms.*.id' => 'required|int', + 'platforms.*.name' => 'required|string', + 'platforms.*.slug' => 'required|string', + 'stores' => 'nullable|array', + 'stores.*.id' => 'required|int', + 'stores.*.name' => 'required|string', + 'stores.*.slug' => 'required|string', + 'genres' => 'nullable|array', + 'genres.*.id' => 'required|int', + 'genres.*.name' => 'required|string', + 'genres.*.slug' => 'required|string', + ]); + + $validator->validate(); + } + + private function setData(array $data): void + { + foreach ($data as $key => $value) { + $this->$key = $value; + } + } +} diff --git a/app/Models/HealthCheck.php b/app/Models/HealthCheck.php new file mode 100644 index 0000000..f984898 --- /dev/null +++ b/app/Models/HealthCheck.php @@ -0,0 +1,9 @@ +lastPage = ceil($this->total / $this->pageSize); + $this->nextPageUrl = $this->getPageUrl(1); + $this->prevPageUrl = $this->getPageUrl(-1); + } + + /** + * @return array + */ + public function getContents(): array + { + return [ + 'total' => $this->total, + 'page_size' => $this->pageSize, + 'current_page' => $this->currentPage, + 'last_page' => $this->lastPage, + 'next_page_url' => $this->nextPageUrl, + 'prev_page_url' => $this->prevPageUrl, + 'data' => $this->data + ]; + } + + /** + * @param integer $pageIncrement + * @return string + */ + private function getPageUrl(int $pageIncrement): string + { + if ( + $this->isFirstPageAndPrevLink($pageIncrement) + || $this->isLastPageAndNextLink($pageIncrement) + ) { + return ''; + } + + $query = request()->query(); + $url = url()->current(); + $query['page'] = $this->currentPage + $pageIncrement; + + return $url . '?' . http_build_query($query); + } + + /** + * @param integer $pageIncrement + * @return boolean + */ + private function isFirstPageAndPrevLink(int $pageIncrement): bool + { + return $this->currentPage === 1 && $pageIncrement < 0; + } + + /** + * @param integer $pageIncrement + * @return boolean + */ + private function isLastPageAndNextLink(int $pageIncrement): bool + { + return $this->currentPage >= $this->lastPage && $pageIncrement > 0; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 90155c7..04f681e 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,13 +2,18 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\Scope; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; +use OpenApi\Attributes as OA; +#[OA\Schema()] class User extends Authenticatable { + use HasApiTokens; use HasFactory; use Notifiable; @@ -30,9 +35,31 @@ class User extends Authenticatable */ protected $hidden = [ 'password', - 'remember_token', + 'discord_user_id', + 'discord_channel_id' ]; + /** + * @var array + */ + protected $with = [ + 'settings' + ]; + + #[OA\Property(property: 'id', type: 'integer')] + #[OA\Property(property: 'name', type: 'string')] + #[OA\Property(property: 'email', type: 'string')] + #[OA\Property(property: 'discord_user_id', type: 'string')] + #[OA\Property(property: 'discord_username', type: 'string')] + #[OA\Property(property: 'discord_channel_id', type: 'string')] + #[OA\Property(property: 'created_at', type: 'datetime')] + #[OA\Property(property: 'updated_at', type: 'datetime')] + #[OA\Property(property: 'settings', ref: '#/components/schemas/UserSetting')] + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + } + /** * Get the attributes that should be cast. * @@ -41,8 +68,24 @@ class User extends Authenticatable protected function casts(): array { return [ - 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'scopes' => 'array' ]; } + + /** + * @return boolean + */ + public function isRoot(): bool + { + return in_array(Scope::Root->value, $this->scopes); + } + + /** + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(UserSetting::class); + } } diff --git a/app/Models/UserSetting.php b/app/Models/UserSetting.php new file mode 100644 index 0000000..b853eeb --- /dev/null +++ b/app/Models/UserSetting.php @@ -0,0 +1,84 @@ + + */ + protected $fillable = [ + 'platforms', + 'genres', + 'period', + 'frequency' + ]; + + #[OA\Property(property: 'id', type: 'integer')] + #[OA\Property(property: 'user_id', type: 'integer')] + #[OA\Property( + property: 'platforms', + type: 'array', + items: new OA\Items( + type: 'string', + enum: 'App\Enums\Platform' + ), + )] + #[OA\Property( + property: 'genres', + type: 'array', + items: new OA\Items( + type: 'string', + enum: 'App\Enums\Rawg\RawgGenre' + ), + )] + #[OA\Property( + property: 'period', + type: 'string', + enum: 'App\Enums\Period' + )] + #[OA\Property( + property: 'frequency', + type: 'string', + enum: 'App\Enums\Frequency' + )] + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + } + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'platforms' => 'array', + 'genres' => 'array', + 'period' => Period::class, + 'frequency' => Frequency::class + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..09837cf 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,18 @@ namespace App\Providers; +use App\Guards\JwtGuard; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; +use Spatie\Health\Checks\Checks\DatabaseCheck; +use Spatie\Health\Checks\Checks\EnvironmentCheck; +use Spatie\Health\Checks\Checks\RedisCheck; +use Spatie\Health\Checks\Checks\UsedDiskSpaceCheck; +use Spatie\Health\Facades\Health; class AppServiceProvider extends ServiceProvider { @@ -19,6 +30,58 @@ public function register(): void */ public function boot(): void { - // + $this->setHealthCheck(); + $this->setJwtGuard(); + $this->setRateLimit(); + } + + private function setHealthCheck(): void + { + $env = match (config('app.url')) { + 'http://gamewatch.local' => 'local', + default => 'prod' + }; + + Health::checks([ + EnvironmentCheck::new()->expectEnvironment($env), + UsedDiskSpaceCheck::new(), + DatabaseCheck::new(), + RedisCheck::new() + ]); + } + + private function setJwtGuard(): void + { + Auth::extend('jwt', function (Application $app, string $name, array $config) { + return new JwtGuard( + Auth::createUserProvider($config['provider']), + $app['request'] + ); + }); + } + + private function setRateLimit(): void + { + RateLimiter::for('api', function (Request $request) { + if ($request->user()) { + $user = $request->user(); + + return $user->isRoot() + ? Limit::none() + : Limit::perMinute(config('app.rate_limit.user'))->by($user->id); + } + + return Limit::perMinute(config('app.rate_limit.guest'))->by($request->ip()); + }); + + RateLimiter::for('discord', function (Request $request) { + $user = $request->get('user'); + + if (!empty($user)) { + return Limit::perMinute(config('app.rate_limit.user'))->by($user['id']); + } + + return Limit::perMinute(config('app.rate_limit.discord')); + }); } } diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php new file mode 100644 index 0000000..d68ee8f --- /dev/null +++ b/app/Repositories/UserRepository.php @@ -0,0 +1,165 @@ +name = $data['name']; + $user->email = $data['email']; + $user->password = bcrypt($data['password']); + $user->scopes = [Scope::Default->value]; + $user->save(); + + $this->createSettings($user); + + DB::commit(); + } catch (Exception $e) { + DB::rollBack(); + throw $e; + } + + return $user; + } + + public function createRoot(): bool + { + $user = User::where('email', config('auth.root.email'))->first(); + + if ($user) { + return false; + } + + DB::beginTransaction(); + + try { + $user = new User(); + $user->name = config('auth.root.name'); + $user->email = config('auth.root.email'); + $user->password = bcrypt(config('auth.root.password')); + $user->scopes = Scope::values(); + $user->discord_user_id = config('auth.root.discord_user_id'); + $user->discord_username = config('auth.root.discord_username'); + $user->discord_channel_id = config('auth.root.discord_channel_id'); + $user->save(); + + $this->createSettings($user); + + DB::commit(); + } catch (Exception $e) { + DB::rollBack(); + throw $e; + } + + return true; + } + + /** + * @param array $data + * @param array $settings + * @return User + */ + public function createFromDiscord(array $data): User + { + DB::beginTransaction(); + + try { + $user = new User(); + $user->name = $data['name']; + $user->scopes = [Scope::Default->value]; + $user->discord_user_id = $data['discord_user_id']; + $user->discord_username = $data['discord_username']; + $user->discord_channel_id = $data['discord_channel_id']; + $user->save(); + + $this->createSettings($user); + + DB::commit(); + } catch (Exception $e) { + DB::rollBack(); + throw $e; + } + + return $user; + } + + /** + * @param string $discordUserId + * @return User|null + */ + public function findByDiscordId(string $discordUserId): ?User + { + return User::where('discord_user_id', $discordUserId)->first(); + } + + /** + * @param User $user + * @param array $data + * @return User + */ + public function update(User $user, array $data): User + { + if (!empty($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + + $user->update($data); + + return $user; + } + + private function createSettings(User $user, array $settings = []): void + { + $user->settings()->create([ + 'platforms' => Arr::get($settings, 'platforms', Platform::values()), + 'genres' => Arr::get($settings, 'genres', RawgGenre::values()), + 'period' => Arr::get($settings, 'period', Period::Next_30_Days->value), + 'frequency' => Arr::get($settings, 'frequency', Frequency::Monthly->value), + ]); + + $user->load('settings'); + } + + /** + * @param User $user + * @param array $data + * @return User + */ + public function updateSettings(User $user, array $data): User + { + $user->settings()->update($data); + $user->refresh(); + + return $user; + } + + /** + * @return LazyCollection + */ + public function getDiscordUsersAndSettings(): LazyCollection + { + return User::whereNotNull('discord_user_id') + ->with('settings') + ->lazy(); + } +} diff --git a/app/Rules/KeywordList.php b/app/Rules/KeywordList.php new file mode 100644 index 0000000..72d5fa2 --- /dev/null +++ b/app/Rules/KeywordList.php @@ -0,0 +1,29 @@ +keywords = implode('|', $keywords); + } + + /** + * Run the validation rule. + * + * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (preg_match("/^(?($this->keywords)+)(,(?&keywords))*$/i", $value) !== 1) { + $fail('The :attribute must be a comma-separated list of keywords.'); + } + } +} diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php new file mode 100644 index 0000000..4ea5463 --- /dev/null +++ b/app/Services/AuthService.php @@ -0,0 +1,85 @@ +key = config('app.key'); + $this->algorithm = config('auth.jwt.algorithm'); + $this->ttl = config('auth.jwt.ttl'); + } + + /** + * @param array $credentials + * @return array + */ + public function generateJWT(array $credentials): array + { + if (Auth::attempt($credentials)) { + $user = Auth::user(); + + $payload = [ + 'iss' => env('APP_URL'), + 'sub' => $user->getAuthIdentifier(), + 'iat' => time() + ]; + + if (config('auth.jwt.expires')) { + $payload['exp'] = time() + $this->ttl; + } + + $jwt = JWT::encode($payload, $this->key, $this->algorithm); + + return [ + 'token' => $jwt, + 'expires_at' => isset($payload['exp']) ? $payload['exp'] : 'never' + ]; + } + + throw new InvalidCredentialsException(); + } + + /** + * @param string $token + * @return stdClass + */ + public function decodeJWT(string $token): stdClass + { + return JWT::decode($token, new Key($this->key, $this->algorithm)); + } + + /** + * @param [string] ...$scopes + * @return boolean + */ + public function checkScopes(...$scopes): bool + { + $user = Auth::user(); + + if (!$user) { + throw new AuthenticationException(); + } + + foreach ($scopes as $scope) { + if (!in_array($scope, $user->scopes)) { + throw new InvalidScopeException(); + } + } + + return true; + } +} diff --git a/app/Services/Discord/Commands/BaseCommand.php b/app/Services/Discord/Commands/BaseCommand.php new file mode 100644 index 0000000..74cce05 --- /dev/null +++ b/app/Services/Discord/Commands/BaseCommand.php @@ -0,0 +1,28 @@ +user = Auth::user(); + } + + /** + * @return string + */ + public function getCommandName(): string + { + $className = get_called_class(); + $className = explode('\\', $className); + $className = strtolower(array_pop($className)); + + return str_replace('command', '', $className); + } +} diff --git a/app/Services/Discord/Commands/ClearCommand.php b/app/Services/Discord/Commands/ClearCommand.php new file mode 100644 index 0000000..b2f696b --- /dev/null +++ b/app/Services/Discord/Commands/ClearCommand.php @@ -0,0 +1,24 @@ +deleteBotMessages($payload['channel']['id']); + + return [ + 'content' => 'Command finished!', + 'results' => $results + ]; + } +} diff --git a/app/Services/Discord/Commands/Contracts/CallbackCommandInterface.php b/app/Services/Discord/Commands/Contracts/CallbackCommandInterface.php new file mode 100644 index 0000000..753dccf --- /dev/null +++ b/app/Services/Discord/Commands/Contracts/CallbackCommandInterface.php @@ -0,0 +1,9 @@ + '', + 'embeds' => [ + $this->makeEmbed( + title: config('app.name'), + description: $description, + url: $repository, + fields: [ + 'Version' => config('app.version'), + 'Release Date' => '2024-10-01' + ] + ) + ] + ]; + } +} diff --git a/app/Services/Discord/Commands/ReleasesCommand.php b/app/Services/Discord/Commands/ReleasesCommand.php new file mode 100644 index 0000000..8d4b3fe --- /dev/null +++ b/app/Services/Discord/Commands/ReleasesCommand.php @@ -0,0 +1,153 @@ +getGameReleases($payload); + } + + /** + * @param array $payload + * @return array + */ + public function callback(array $payload): array + { + $customId = $this->parseCustomId($payload); + $currentPage = $this->getCurrentPage($payload); + + return match ($customId['component']) { + self::COMPONENT_PREV_PAGE => $this->getGameReleases($payload, $currentPage - 1), + self::COMPONENT_NEXT_PAGE => $this->getGameReleases($payload, $currentPage + 1), + }; + } + + /** + * @param array $payload + * @param integer $page + * @return array + */ + private function getGameReleases(array $payload = [], int $page = 1): array + { + $period = Arr::get( + $payload, + 'data.options.0.value', + $this->user->settings->period->value + ); + + $response = $this->rawgGamesService->getUpcomingReleases( + period: $period, + filters: [ + 'platforms' => implode(',', $this->user->settings->platforms), + 'genres' => implode(',', $this->user->settings->genres), + 'page' => $page, + 'page_size' => 10, + ] + ); + + return $this->makeResponse($response, $period); + } + + /** + * @param User $user + * @return array + */ + public function makeNotificationForUser(User $user): array + { + $period = $user->settings->period->value; + + $response = $this->rawgGamesService->getUpcomingReleases( + period: $period, + filters: [ + 'platforms' => implode(',', $user->settings->platforms), + 'genres' => implode(',', $user->settings->genres), + 'page_size' => 10, + ] + ); + + if ($response->data->isEmpty()) { + return []; + } + + return $this->makeResponse($response, $period); + } + + /** + * @param PaginatedResponse $response + * @param string $period + * @return array + */ + private function makeResponse(PaginatedResponse $response, string $period): array + { + $friendlyPeriod = str_replace('-', ' ', $period); + + if ($response->data->isEmpty()) { + return [ + 'content' => 'No upcoming releases found for the ' . $friendlyPeriod + ]; + } + + return [ + 'content' => '**Here are the upcoming releases for the ' . $friendlyPeriod . ':**', + 'embeds' => $this->makeGameEmbeds($response), + 'components' => [ + $this->makeActionRow( + $this->makePaginationComponents($response) + ) + ] + ]; + } + + /** + * @param PaginatedResponse $response + * @return void + */ + private function makeGameEmbeds(PaginatedResponse $response) + { + $embeds = []; + + foreach ($response->data as $game) { + $platforms = array_map(fn($platform) => $platform['name'], $game->platforms); + $platforms = implode(', ', $platforms); + + $genres = array_map(fn($genre) => $genre['name'], $game->genres); + $genres = implode(', ', $genres); + + $embeds[] = $this->makeEmbed( + title: $game->name, + description: 'Release Date: ' . $game->released->format('F j, Y'), + url: 'https://rawg.io/games/' . $game->slug, + fields: [ + 'Platforms' => $platforms, + 'Genres' => $genres + ], + image: $game->background_image + ); + } + + return $embeds; + } +} diff --git a/app/Services/Discord/Commands/SettingsCommand.php b/app/Services/Discord/Commands/SettingsCommand.php new file mode 100644 index 0000000..a4bcd79 --- /dev/null +++ b/app/Services/Discord/Commands/SettingsCommand.php @@ -0,0 +1,215 @@ + 'Select your preferred platforms:', + 'components' => [ + $this->makeActionRow([ + $this->makeMenuComponent( + name: self::COMPONENT_PLATFORMS, + options: Platform::cases(), + defaults: $this->user->settings->platforms + ) + ]), + $this->makeActionRow([ + $this->makeButtonComponent( + label: 'Next', + name: self::COMPONENT_PLATFORMS + ) + ]) + ] + ]; + } + + /** + * @param array $payload + * @return array + */ + public function callback(array $payload): array + { + $customId = $this->parseCustomId($payload); + $values = Arr::get($payload, 'data.values', []); + + if (!empty($values)) { + $userRepository = resolve(UserRepository::class); + $values = $this->prepValues($customId['component'], $values); + + try { + $this->validateSettings($values); + $userRepository->updateSettings($this->user, $values); + } catch (ValidationException $e) { + return $this->makeError($e); + } + } + + $handler = 'handle' . Str::studly($customId['component']); + return call_user_func([$this, $handler], $values); + } + + /** + * @param string $component + * @param array $values + * @return array + */ + private function prepValues(string $component, array $values): array + { + switch ($component) { + case self::COMPONENT_PERIOD: + case self::COMPONENT_FREQUENCY: + return [$component => $values[0]]; + + default: + return [$component => $values]; + } + } + + /** + * @param array $settings + * @return void + */ + private function validateSettings(array $settings) + { + Validator::make( + $settings, + [ + 'platforms' => ['sometimes', 'array'], + 'platforms.*' => ['required', 'string', 'in:' . Platform::valuesAsString()], + 'genres' => ['sometimes', 'array'], + 'genres.*' => ['required', 'string', 'in:' . RawgGenre::valuesAsString()], + 'period' => ['sometimes', 'string', 'in:' . Period::valuesAsString()], + 'frequency' => ['sometimes', 'string', 'in:' . Frequency::valuesAsString()], + ], + [ + 'platforms.*.in' => 'Invalid platform. Pick one or many: ' . Platform::valuesAsString(), + 'genres.*.in' => 'Invalid genre. Pick one or many: ' . RawgGenre::valuesAsString() + ] + )->validate(); + } + + /** + * @param ValidationException $e + * @return array + */ + private function makeError(ValidationException $e): array + { + return [ + 'content' => $e->validator->errors()->first() + ]; + } + + /** + * @return array + */ + private function handlePlatforms(array $values = []): array + { + return [ + 'content' => 'Select your preferred genres:', + 'components' => [ + $this->makeActionRow([ + $this->makeMenuComponent( + name: self::COMPONENT_GENRES, + options: RawgGenre::cases(), + defaults: $this->user->settings->genres, + ) + ]), + $this->makeActionRow([ + $this->makeButtonComponent( + label: 'Next', + name: self::COMPONENT_GENRES + ) + ]) + ] + ]; + } + + /** + * @return array + */ + private function handleGenres(array $values = []): array + { + return [ + 'content' => 'Choose the period for game releases:', + 'components' => [ + $this->makeActionRow([ + $this->makeMenuComponent( + name: self::COMPONENT_PERIOD, + options: Period::cases(), + defaults: [$this->user->settings->period->value], + maxValues: 1 + ) + ]), + $this->makeActionRow([ + $this->makeButtonComponent( + label: 'Next', + name: self::COMPONENT_PERIOD + ) + ]) + ] + ]; + } + + /** + * @return array + */ + private function handlePeriod(array $values = []): array + { + return [ + 'content' => 'Choose how often you want notifications:', + 'components' => [ + $this->makeActionRow([ + $this->makeMenuComponent( + name: self::COMPONENT_FREQUENCY, + options: Frequency::cases(), + defaults: [$this->user->settings->frequency->value], + maxValues: 1 + ) + ]), + $this->makeActionRow([ + $this->makeButtonComponent( + label: 'Next', + name: self::COMPONENT_FREQUENCY + ) + ]) + ] + ]; + } + + /** + * @return array + */ + private function handleFrequency(array $values = []): array + { + return [ + 'content' => 'Your preferences have been updated!', + 'components' => [] + ]; + } +} diff --git a/app/Services/Discord/DiscordAppService.php b/app/Services/Discord/DiscordAppService.php new file mode 100644 index 0000000..58d4a00 --- /dev/null +++ b/app/Services/Discord/DiscordAppService.php @@ -0,0 +1,201 @@ +publicKey); + + $isValid = sodium_crypto_sign_verify_detached($signature, $message, $publicKey); + + if (!$isValid) { + throw new InvalidDiscordSignatureException(); + } + } + + /** + * @param array $payload + * @return User + */ + public function findOrCreateUser(array $payload): User + { + $user = $this->userRepository->findByDiscordId( + $payload['user']['id'] + ); + + if (!$user) { + $user = $this->userRepository->createFromDiscord([ + 'name' => $payload['user']['global_name'], + 'discord_user_id' => $payload['user']['id'], + 'discord_username' => $payload['user']['username'], + 'discord_channel_id' => $payload['channel']['id'] + ]); + } + + return $user->load('settings'); + } + + /** + * @param string $commandName + * @return string + */ + public function registerCommand(string $commandName): string + { + $command = $this->getCommandConfig($commandName); + + $res = $this->makeRequest( + uri: "applications/$this->appId/commands", + payload: $command + ); + + return $res->getBody()->getContents(); + } + + /** + * @param string $commandName + * @return array + */ + private function getCommandConfig(string $commandName): array + { + $command = Arr::first( + Arr::where( + config('discord.commands'), + fn($command) => $command['name'] === $commandName + ) + ); + + if (empty($command)) { + throw new InvalidArgumentException( + sprintf('Command %s not found.', $commandName) + ); + } + + return $command; + } + + /** + * @param User $user + * @param array $payload + * @return boolean + */ + public function sendMessage(User $user, array $payload): bool + { + $res = $this->makeRequest( + uri: "/channels/$user->discord_channel_id/messages", + payload: $payload + ); + + + if ($res->getStatusCode() !== 200) { + Log::error($res->getBody()->getContents()); + return false; + } + + return true; + } + + /** + * @param string $channelId + * @param integer $limit + * @return array + */ + public function getBotMessages(string $channelId, int $limit = 50): array + { + $res = $this->makeRequest( + method: 'GET', + uri: "channels/$channelId/messages", + query: ['limit' => $limit] + ); + + $messages = json_decode($res->getBody()->getContents(), true); + + return array_filter($messages, function ($message) { + return $message['author']['id'] === $this->appId; + }); + } + + /** + * @param string $channelId + * @return void + */ + public function deleteBotMessages(string $channelId): array + { + $results = []; + $messages = $this->getBotMessages($channelId); + + foreach ($messages as $message) { + $res = $this->makeRequest( + method: 'DELETE', + uri: "/channels/{$channelId}/messages/" . $message['id'], + ); + + $results[] = [ + 'message_id' => $message['id'], + 'status_code' => $res->getStatusCode(), + 'contents' => $res->getBody()->getContents() + ]; + } + + return $results; + } + + /** + * @return void + */ + public function dispatchNotifications(): void + { + $command = resolve(ReleasesCommand::class); + $users = $this->userRepository->getDiscordUsersAndSettings(); + + Log::info('Dispatching notifications for ' . $users->count() . ' user(s)...'); + + $users->each(function ($user) use ($command) { + $message = $command->makeNotificationForUser($user); + + if (!empty($message)) { + $result = $this->sendMessage($user, $message); + + Log::info( + sprintf( + 'Notification for %s (%s): %s', + $user->discord_username, + $user->discord_user_id, + $result ? 'SUCCESS' : 'FAILED' + ) + ); + } else { + Log::info( + sprintf( + 'Notification for %s (%s): SKIPPED', + $user->discord_username, + $user->discord_user_id, + ) + ); + } + }); + } +} diff --git a/app/Services/Discord/DiscordBaseService.php b/app/Services/Discord/DiscordBaseService.php new file mode 100644 index 0000000..e6bd8c8 --- /dev/null +++ b/app/Services/Discord/DiscordBaseService.php @@ -0,0 +1,72 @@ +appId = config('services.discord.app_id'); + $this->botToken = config('services.discord.bot_token'); + $this->publicKey = config('services.discord.public_key'); + $this->apiUrl = config('services.discord.host') . '/api/v10/'; + } + + /** + * @param string $uri + * @param array $payload + * @param string $method + * @return ResponseInterface + */ + protected function makeRequest( + string $uri, + array $payload = [], + array $query = [], + string $method = 'POST', + ): ResponseInterface { + $endpoint = $this->apiUrl . $uri; + + $options = [ + 'http_errors' => false, + 'headers' => [ + 'Authorization' => 'Bot ' . $this->botToken, + 'Content-Type' => 'application/json' + ] + ]; + + if (!empty($payload)) { + $options['json'] = $payload; + } + + if (!empty($query)) { + $options['query'] = $query; + } + + return $this->client->request($method, $endpoint, $options); + } + + /** + * @param Acknowledge $ack + * @param array $data + * @return array + */ + protected function makeResponse(Acknowledge $ack, array $data = []): array + { + return [ + 'type' => $ack->value, + 'data' => $data + ]; + } +} diff --git a/app/Services/Discord/DiscordInteractionsService.php b/app/Services/Discord/DiscordInteractionsService.php new file mode 100644 index 0000000..d9389db --- /dev/null +++ b/app/Services/Discord/DiscordInteractionsService.php @@ -0,0 +1,85 @@ +makeResponse( + Acknowledge::Pong + ); + + case InteractionType::Command: + return $this->makeResponse( + Acknowledge::ChannelMessageWithSource, + $this->execCommand($payload) + ); + + case InteractionType::MessageComponent: + return $this->makeResponse( + Acknowledge::UpdateMessage, + $this->callbackCommand($payload) + ); + + default: + throw new InvalidArgumentException('Interaction not supported: ' . $payload['type']); + } + } + + /** + * @param string $command + * @param array $payload + * @return array + */ + private function execCommand(array $payload): array + { + $commandName = Arr::get($payload, 'data.name'); + + return $this->makeCommand($commandName) + ->exec($payload); + } + + /** + * @param array $payload + * @return array + */ + private function callbackCommand(array $payload): array + { + $customId = $this->parseCustomId($payload); + + return $this->makeCommand($customId['command']) + ->callback($payload); + } + + /** + * @param string $commandName + * @return CommandInterface|CallbackCommandInterface + */ + private function makeCommand(string $commandName): CommandInterface|CallbackCommandInterface + { + return resolve( + sprintf( + 'App\Services\Discord\Commands\%sCommand', + Str::studly($commandName) + ) + ); + } +} diff --git a/app/Services/Discord/Utils/DiscordCallbackUtils.php b/app/Services/Discord/Utils/DiscordCallbackUtils.php new file mode 100644 index 0000000..6462944 --- /dev/null +++ b/app/Services/Discord/Utils/DiscordCallbackUtils.php @@ -0,0 +1,45 @@ + $customId[0], + 'component' => $customId[1], + 'uid' => $customId[2], + ]; + } +} diff --git a/app/Services/Discord/Utils/DiscordComponentUtils.php b/app/Services/Discord/Utils/DiscordComponentUtils.php new file mode 100644 index 0000000..dd8038e --- /dev/null +++ b/app/Services/Discord/Utils/DiscordComponentUtils.php @@ -0,0 +1,166 @@ +formatCustomId($this->getCommandName(), $componentName); + } + + /** + * @return array + */ + private function makeActionRow(array $components): array + { + return [ + 'type' => ComponentType::ActionRow, + 'components' => $components + ]; + } + + /** + * @param string $name + * @param string $placeholder + * @param array $options + * @param integer $minValues + * @param integer $maxValues + * @param array $defaults + * @return array + */ + private function makeMenuComponent( + string $name, + array $options, + string $placeholder = '', + int $minValues = 1, + int $maxValues = -1, + array $defaults = [] + ): array { + return [ + 'type' => ComponentType::StringSelect, + 'custom_id' => $this->makeCustomId($name), + 'options' => $this->makeSelectOptions($options, $defaults), + 'placeholder' => $placeholder, + 'min_values' => $minValues, + 'max_values' => $maxValues === -1 ? count($options) : $maxValues + ]; + } + + /** + * @param array $cases + * @param array $defaults + * @return array + */ + private static function makeSelectOptions( + array $cases, + array $defaults = [] + ): array { + return array_map(function ($case) use ($defaults) { + $name = strtoupper($case->name) === $case->name + ? $case->name + : Str::headline($case->name); + + return [ + 'label' => $name, + 'value' => $case->value, + 'default' => in_array($case->value, $defaults) + ]; + }, $cases); + } + + /** + * @param string $label + * @param string $name + * @param [type] $style + * @param boolean $disabled + * @param string $emoji + * @return array + */ + private function makeButtonComponent( + string $label, + string $name, + ButtonStyle $style = ButtonStyle::Primary, + bool $disabled = false, + string $emoji = '' + ): array { + $button = [ + 'type' => ComponentType::Button, + 'custom_id' => $this->makeCustomId($name), + 'label' => strtoupper($label), + 'style' => $style, + 'disabled' => $disabled + ]; + + if (!empty($emoji)) { + $button['emoji'] = [ + 'name' => $emoji + ]; + } + + return $button; + } + + /** + * @param PaginatedResponse $response + * @return array + */ + private function makePaginationComponents(PaginatedResponse $response): array + { + return [ + $this->makeButtonComponent( + name: self::COMPONENT_GAMES_FOUND, + label: sprintf('Found %s game(s)', $response->total), + style: ButtonStyle::Secundary, + disabled: true, + emoji: '🔥' + ), + $this->makeButtonComponent( + name: self::COMPONENT_CURRENT_PAGE . $response->currentPage, + label: sprintf('Page %s of %s', $response->currentPage, $response->lastPage), + style: ButtonStyle::Secundary, + disabled: true, + emoji: '📖' + ), + $this->makeButtonComponent( + name: self::COMPONENT_PREV_PAGE, + label: 'Previous Page', + disabled: $response->currentPage === 1, + ), + $this->makeButtonComponent( + name: self::COMPONENT_NEXT_PAGE, + label: 'Next Page', + disabled: $response->currentPage === $response->lastPage, + ) + ]; + } + + /** + * @param array $payload + * @return integer + */ + private function getCurrentPage(array $payload): int + { + $path = 'message.components.0.components.1.custom_id'; + $customId = $this->parseCustomId($payload, $path); + + return str_replace(self::COMPONENT_CURRENT_PAGE, '', $customId['component']); + } +} diff --git a/app/Services/Discord/Utils/DiscordEmbedUtils.php b/app/Services/Discord/Utils/DiscordEmbedUtils.php new file mode 100644 index 0000000..cd9a8da --- /dev/null +++ b/app/Services/Discord/Utils/DiscordEmbedUtils.php @@ -0,0 +1,64 @@ + $title, + 'description' => $description, + 'url' => $url, + 'color' => hexdec($color), + 'fields' => $this->makeEmbedFields($fields), + ]; + + if (!empty($image)) { + $embed['image']['url'] = $image; + } + + return $embed; + } + + /** + * @param array $fields + * @param boolean $inline + * @return array + */ + private function makeEmbedFields( + array $fields, + bool $inline = true + ): array { + $embedFields = []; + + foreach ($fields as $name => $value) { + $embedFields[] = [ + 'name' => $name, + 'value' => $value, + 'inline' => $inline + ]; + } + + return $embedFields; + } +} diff --git a/app/Services/Rawg/RawgAchievementService.php b/app/Services/Rawg/RawgAchievementService.php new file mode 100644 index 0000000..2204cf1 --- /dev/null +++ b/app/Services/Rawg/RawgAchievementService.php @@ -0,0 +1,65 @@ +call(uri: 'games/' . $game . '/achievements'); + + $response = $this->orderAchievements( + $response['results'], + Arr::get($order, 'order_by', 'id'), + Arr::get($order, 'sort_order', SortOrder::ASC->value) + ); + + return collect($response); + } + + /** + * @param array $data + * @param string $orderBy + * @param string $sortOrder + * @return array + */ + private function orderAchievements( + array $data, + string $orderBy = 'id', + string $sortOrder = 'ASC' + ): array { + if (!isset(array_shift($data)[$orderBy])) { + $orderBy = 'id'; + } + + usort($data, function ($a, $b) use ($orderBy, $sortOrder) { + if ($sortOrder === 'ASC') { + if ($a[$orderBy] === $b[$orderBy]) { + return $a[RawgField::Slug->value] > $b[RawgField::Slug->value]; + } + + return $a[$orderBy] > $b[$orderBy]; + } + + if ($a[$orderBy] === $b[$orderBy]) { + return $a[RawgField::Slug->value] < $b[RawgField::Slug->value]; + } + + return $a[$orderBy] < $b[$orderBy]; + }); + + return $data; + } +} diff --git a/app/Services/Rawg/RawgBaseService.php b/app/Services/Rawg/RawgBaseService.php new file mode 100644 index 0000000..f549a11 --- /dev/null +++ b/app/Services/Rawg/RawgBaseService.php @@ -0,0 +1,88 @@ +apiUrl = config('services.rawg.host') . '/api/'; + $this->apiKey = config('services.rawg.key'); + } + + /** + * @param string $uri + * @param array $data + * @param string $method + * @return array + */ + protected function call( + string $uri, + array $data = ['query' => []], + string $method = 'GET', + ): array { + $contents = $this->getCacheContents($uri, $data); + + if (empty($contents)) { + $endpoint = $this->apiUrl . $uri; + + $res = $this->client->request($method, $endpoint, [ + 'query' => array_merge($data['query'], [ + 'key' => $this->apiKey + ]) + ]); + + $contents = $res->getBody()->getContents(); + $this->setCacheContents($uri, $data, $contents); + } + + return json_decode($contents, true); + } + + /** + * @param string $uri + * @param array $data + * @return null|string + */ + private function getCacheContents(string $uri, array $data): ?string + { + $key = $this->getCacheKey($uri, $data['query']); + return Redis::get($key); + } + + /** + * @param string $uri + * @param array $data + * @param string $payload + * @return void + */ + private function setCacheContents(string $uri, array $data, string $contents): void + { + $key = $this->getCacheKey($uri, $data['query']); + Redis::setEx($key, self::CACHE_TTL, $contents); + } + + /** + * @param string $uri + * @param array $data + * @return string + */ + private function getCacheKey(string $uri, array $query): string + { + return sprintf( + 'rawg_%s_%s_', + $uri, + implode('_', $query) + ); + } +} diff --git a/app/Services/Rawg/RawgDomainService.php b/app/Services/Rawg/RawgDomainService.php new file mode 100644 index 0000000..25b7bba --- /dev/null +++ b/app/Services/Rawg/RawgDomainService.php @@ -0,0 +1,30 @@ +call(uri: 'genres')['results']; + } + + /** + * @return array + */ + public function getTags(): array + { + return $this->call(uri: 'tags')['results']; + } + + /** + * @return array + */ + public function getPlatforms(): array + { + return $this->call(uri: 'platforms')['results']; + } +} diff --git a/app/Services/Rawg/RawgFilterService.php b/app/Services/Rawg/RawgFilterService.php new file mode 100644 index 0000000..e5bc8dc --- /dev/null +++ b/app/Services/Rawg/RawgFilterService.php @@ -0,0 +1,83 @@ +getAvailableApiFilters()); + $filters = array_merge($default, $filters); + + $this->handlePlatformsFilter($filters); + + return $filters; + } + + /** + * @return array + */ + private function getAvailableApiFilters(): array + { + return [ + RawgField::Genres->value, + RawgField::Platforms->value, + RawgField::Ordering->value, + RawgField::PageSize->value, + RawgField::Page->value + ]; + } + + /** + * @param array $filters + * @return void + */ + private function handlePlatformsFilter(array &$filters): void + { + $plaformsKey = RawgField::Platforms->value; + + if (!empty($filters[$plaformsKey])) { + $filters[$plaformsKey] = $this->parsePlatformsToRawgValues( + $filters[$plaformsKey] + ); + } else { + $filters[$plaformsKey] = RawgPlatform::valuesAsString(); + } + } + + /** + * @param string $slugList + * @return string + */ + private function parsePlatformsToRawgValues(string $slugList): string + { + $slugs = explode(',', $slugList); + $parsed = []; + + foreach ($slugs as $slug) { + $platform = Platform::from($slug); + $parsed[] = $this->parsePlatform($platform)->value; + } + + return implode(', ', $parsed); + } + + /** + * @param Platform $platform + * @return RawgPlatform + */ + private function parsePlatform(Platform $platform): RawgPlatform + { + return RawgPlatform::{$platform->name}; + } +} diff --git a/app/Services/Rawg/RawgGamesService.php b/app/Services/Rawg/RawgGamesService.php new file mode 100644 index 0000000..7630cd7 --- /dev/null +++ b/app/Services/Rawg/RawgGamesService.php @@ -0,0 +1,99 @@ +filterService->getQueryFilters(filters: $filters, default: [ + RawgField::Dates->value => date('Y-m-d', strtotime('-1 year')) . ',' . date('Y-m-d'), + RawgField::Genres->value => $genre, + RawgField::Ordering->value => 'updated', + RawgField::PageSize->value => 5, + RawgField::Page->value => 1 + ]); + + $response = $this->call(uri: 'games', data: [ + 'query' => $query + ]); + + return new PaginatedResponse( + $this->parseGames($response), + $query[RawgField::PageSize->value], + $query[RawgField::Page->value], + $response['count'] + ); + } + + /** + * @param string $period + * @param integer $perPage + * @param integer $page + * @return PaginatedResponse + */ + public function getUpcomingReleases( + string $period = Period::Next_7_Days->value, + array $filters = [] + ): PaginatedResponse { + $timeUnit = Period::getTimeUnit($period); + $query = $this->filterService->getQueryFilters(filters: $filters, default: [ + RawgField::Dates->value => date('Y-m-d') . ',' . date('Y-m-d', strtotime('+1 ' . $timeUnit)), + RawgField::Ordering->value => 'released', + RawgField::PageSize->value => 25, + RawgField::Page->value => 1 + ]); + + $response = $this->call(uri: 'games', data: [ + 'query' => $query + ]); + + return new PaginatedResponse( + $this->parseGames($response), + $query[RawgField::PageSize->value], + $query[RawgField::Page->value], + $response['count'] + ); + } + + /** + * @param array $data + * @return Collection + */ + private function parseGames(array $data): Collection + { + $collection = collect([]); + + foreach ($data['results'] as $game) { + $collection->push( + new Game([ + 'id' => $game[RawgField::Id->value], + 'name' => $game[RawgField::Name->value], + 'slug' => $game[RawgField::Slug->value], + 'background_image' => $game[RawgField::BgImage->value], + 'released' => new DateTime($game[RawgField::Released->value]), + 'platforms' => array_column($game[RawgField::Platforms->value] ?: [], 'platform'), + 'stores' => array_column($game[RawgField::Stores->value] ?: [], 'store'), + 'genres' => $game[RawgField::Genres->value] + ]) + ); + } + + return $collection; + } +} diff --git a/app/Swagger/Application.php b/app/Swagger/Application.php index 889a9b3..328df53 100644 --- a/app/Swagger/Application.php +++ b/app/Swagger/Application.php @@ -6,12 +6,40 @@ #[OA\Info(title: APP_NAME, version: APP_VERSION)] #[OA\Server(url: APP_URL)] +#[OA\OpenApi( + security: [ + ['bearerAuth' => []] + ] +)] +#[OA\SecurityScheme( + securityScheme: 'bearerAuth', + in: 'header', + name: 'bearerAuth', + type: 'http', + scheme: 'bearer', +)] class Application { #[OA\Tag( name: 'application', description: 'health and other application routes' )] + #[OA\Tag( + name: 'domain', + description: 'rawg domain routes' + )] + #[OA\Tag( + name: 'games', + description: 'rawg games routes' + )] + #[OA\Tag( + name: 'auth', + description: 'authentication routes' + )] + #[OA\Tag( + name: 'account', + description: 'account management routes' + )] public function tags() { } @@ -27,4 +55,16 @@ public function tags() public function up() { } + + #[OA\Get( + path: '/api/up', + tags: ['application'], + security: [], + responses: [ + new OA\Response(response: 200, description: 'OK') + ] + )] + public function apiUp() + { + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c805926..46cfd54 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,7 @@ withMiddleware(function (Middleware $middleware) { - // + $middleware->alias([ + 'scopes' => CheckScopesMiddleware::class, + 'discord.sign' => VerifyDiscordSignature::class + ]); + + $middleware->redirectGuestsTo(fn() => response()); + + $middleware->throttleWithRedis(); }) ->withExceptions(function (Exceptions $exceptions) { $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { @@ -42,6 +51,6 @@ } return response()->json($response, $e->getStatusCode()); - } + } }); })->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..e429ce2 100755 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,5 +1,5 @@ =8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.1.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-08-15T22:48:53+00:00" + }, { "name": "symfony/routing", "version": "v7.1.4", @@ -8560,6 +9235,38 @@ ], "time": "2024-09-18T10:38:58+00:00" }, + { + "name": "swoole/ide-helper", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/swoole/ide-helper.git", + "reference": "4b6e615cb27c251b6248b8bd9501edbd02a45c18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swoole/ide-helper/zipball/4b6e615cb27c251b6248b8bd9501edbd02a45c18", + "reference": "4b6e615cb27c251b6248b8bd9501edbd02a45c18", + "shasum": "" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Team Swoole", + "email": "team@swoole.com" + } + ], + "description": "IDE help files for Swoole.", + "support": { + "issues": "https://github.com/swoole/ide-helper/issues", + "source": "https://github.com/swoole/ide-helper/tree/5.0.3" + }, + "time": "2023-04-28T22:20:18+00:00" + }, { "name": "theseer/tokenizer", "version": "1.2.3", diff --git a/config/app.php b/config/app.php index f467267..8eb1d95 100755 --- a/config/app.php +++ b/config/app.php @@ -15,6 +15,19 @@ 'name' => env('APP_NAME', 'Laravel'), + /* + |-------------------------------------------------------------------------- + | Application Version + |-------------------------------------------------------------------------- + | + | This value is the version of your application, which will be used when the + | framework needs to place the application's version in a notification or + | other UI elements where an application version needs to be displayed. + | + */ + + 'version' => env('APP_VERSION', '1.0.0'), + /* |-------------------------------------------------------------------------- | Application Environment @@ -123,4 +136,15 @@ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + /* + |-------------------------------------------------------------------------- + | API Rate Limit + |-------------------------------------------------------------------------- + */ + + 'rate_limit' => [ + 'user' => env('RATE_LIMIT_USER', 100), + 'guest' => env('RATE_LIMIT_GUEST', 10), + 'discord' => env('RATE_LIMIT_DISCORD', 1000) + ] ]; diff --git a/config/auth.php b/config/auth.php index 0ba5d5d..bb597f1 100755 --- a/config/auth.php +++ b/config/auth.php @@ -14,7 +14,7 @@ */ 'defaults' => [ - 'guard' => env('AUTH_GUARD', 'web'), + 'guard' => env('AUTH_GUARD', 'api'), 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), ], @@ -40,6 +40,10 @@ 'driver' => 'session', 'provider' => 'users', ], + 'api' => [ + 'driver' => 'jwt', + 'provider' => 'users', + ], ], /* @@ -112,4 +116,31 @@ 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + /* + |-------------------------------------------------------------------------- + | JWT + |-------------------------------------------------------------------------- + | + */ + 'jwt' => [ + 'algorithm' => env('JWT_ALGORITHM', 'HS256'), + 'expires' => env('JWT_EXPIRES', true), + 'ttl' => env('JWT_TTL', 3600), + ], + + /* + |-------------------------------------------------------------------------- + | Root User + |-------------------------------------------------------------------------- + | + */ + 'root' => [ + 'name' => env('ROOT_NAME', 'root'), + 'email' => env('ROOT_EMAIL', 'root@localhost'), + 'password' => env('ROOT_PASSWORD', 'secret'), + 'discord_user_id' => env('ROOT_DISCORD_USER_ID'), + 'discord_username' => env('ROOT_DISCORD_USERNAME'), + 'discord_channel_id' => env('ROOT_DISCORD_CHANNEL_ID'), + ] + ]; diff --git a/config/cache.php b/config/cache.php index 6b57b18..1b4bda0 100755 --- a/config/cache.php +++ b/config/cache.php @@ -15,7 +15,7 @@ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/config/discord.php b/config/discord.php new file mode 100644 index 0000000..57e4366 --- /dev/null +++ b/config/discord.php @@ -0,0 +1,58 @@ + [ + 'primary' => '7332C7' + ], + + 'commands' => [ + [ + 'name' => 'settings', + 'description' => 'Set your preferences for game releases and notifications', + ], + [ + 'name' => 'releases', + 'description' => 'List upcoming game releases based on your preferences', + 'options' => [ + [ + 'type' => OptionType::String->value, + 'name' => 'period', + 'description' => 'Choose the period', + 'required' => false, + 'choices' => Period::friendlyCases() + ], + ] + ], + [ + 'name' => 'clear', + 'description' => 'Clear Bot messages', + ], + [ + 'name' => 'help', + 'description' => 'Show help message', + ] + ] + +]; diff --git a/config/health.php b/config/health.php new file mode 100644 index 0000000..653cdf0 --- /dev/null +++ b/config/health.php @@ -0,0 +1,127 @@ + [ + Spatie\Health\ResultStores\EloquentHealthResultStore::class => [ + 'connection' => env('HEALTH_DB_CONNECTION', env('DB_CONNECTION')), + 'model' => App\Models\HealthCheck::class, + 'keep_history_for_days' => 5, + ], + + /* + Spatie\Health\ResultStores\CacheHealthResultStore::class => [ + 'store' => 'file', + ], + + Spatie\Health\ResultStores\JsonFileHealthResultStore::class => [ + 'disk' => 's3', + 'path' => 'health.json', + ], + + Spatie\Health\ResultStores\InMemoryHealthResultStore::class, + */ + ], + + /* + * You can get notified when specific events occur. Out of the box you can use 'mail' and 'slack'. + * For Slack you need to install laravel/slack-notification-channel. + */ + 'notifications' => [ + /* + * Notifications will only get sent if this option is set to `true`. + */ + 'enabled' => false, + + 'notifications' => [ + Spatie\Health\Notifications\CheckFailedNotification::class => ['mail'], + ], + + /* + * Here you can specify the notifiable to which the notifications should be sent. The default + * notifiable will use the variables specified in this config file. + */ + 'notifiable' => Spatie\Health\Notifications\Notifiable::class, + + /* + * When checks start failing, you could potentially end up getting + * a notification every minute. + * + * With this setting, notifications are throttled. By default, you'll + * only get one notification per hour. + */ + 'throttle_notifications_for_minutes' => 60, + 'throttle_notifications_key' => 'health:latestNotificationSentAt:', + + 'mail' => [ + 'to' => 'your@example.com', + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + ], + + 'slack' => [ + 'webhook_url' => env('HEALTH_SLACK_WEBHOOK_URL', ''), + + /* + * If this is set to null the default channel of the webhook will be used. + */ + 'channel' => null, + + 'username' => null, + + 'icon' => null, + ], + ], + + /* + * You can let Oh Dear monitor the results of all health checks. This way, you'll + * get notified of any problems even if your application goes totally down. Via + * Oh Dear, you can also have access to more advanced notification options. + */ + 'oh_dear_endpoint' => [ + 'enabled' => false, + + /* + * When this option is enabled, the checks will run before sending a response. + * Otherwise, we'll send the results from the last time the checks have run. + */ + 'always_send_fresh_results' => true, + + /* + * The secret that is displayed at the Application Health settings at Oh Dear. + */ + 'secret' => env('OH_DEAR_HEALTH_CHECK_SECRET'), + + /* + * The URL that should be configured in the Application health settings at Oh Dear. + */ + 'url' => '/oh-dear-health-check-results', + ], + + /* + * You can set a theme for the local results page + * + * - light: light mode + * - dark: dark mode + */ + 'theme' => 'light', + + /* + * When enabled, completed `HealthQueueJob`s will be displayed + * in Horizon's silenced jobs screen. + */ + 'silence_health_queue_job' => true, + + /* + * The response code to use for HealthCheckJsonResultsController when a health + * check has failed + */ + 'json_results_failure_status' => 200, +]; diff --git a/config/logging.php b/config/logging.php index d526b64..c71f742 100755 --- a/config/logging.php +++ b/config/logging.php @@ -18,7 +18,7 @@ | */ - 'default' => env('LOG_CHANNEL', 'stack'), + 'default' => env('LOG_CHANNEL', 'stdout'), /* |-------------------------------------------------------------------------- @@ -105,6 +105,16 @@ 'processors' => [PsrLogMessageProcessor::class], ], + 'stdout' => [ + 'driver' => 'monolog', + 'handler' => StreamHandler::class, + 'with' => [ + 'stream' => 'php://stdout', + ], + 'level' => env('LOG_LEVEL', 'debug'), + 'processors' => [PsrLogMessageProcessor::class], + ], + 'syslog' => [ 'driver' => 'syslog', 'level' => env('LOG_LEVEL', 'debug'), diff --git a/config/octane.php b/config/octane.php new file mode 100644 index 0000000..fdbc2ee --- /dev/null +++ b/config/octane.php @@ -0,0 +1,238 @@ + env('OCTANE_SERVER', 'roadrunner'), + + /* + |-------------------------------------------------------------------------- + | Force HTTPS + |-------------------------------------------------------------------------- + | + | When this configuration value is set to "true", Octane will inform the + | framework that all absolute links must be generated using the HTTPS + | protocol. Otherwise your links may be generated using plain HTTP. + | + */ + + 'https' => env('OCTANE_HTTPS', false), + + /* + |-------------------------------------------------------------------------- + | Octane Listeners + |-------------------------------------------------------------------------- + | + | All of the event listeners for Octane's events are defined below. These + | listeners are responsible for resetting your application's state for + | the next request. You may even add your own listeners to the list. + | + */ + + 'listeners' => [ + WorkerStarting::class => [ + EnsureUploadedFilesAreValid::class, + EnsureUploadedFilesCanBeMoved::class, + ], + + RequestReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ...Octane::prepareApplicationForNextRequest(), + // + ], + + RequestHandled::class => [ + // + ], + + RequestTerminated::class => [ + // FlushUploadedFiles::class, + ], + + TaskReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TaskTerminated::class => [ + // + ], + + TickReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TickTerminated::class => [ + // + ], + + OperationTerminated::class => [ + FlushOnce::class, + FlushTemporaryContainerInstances::class, + // DisconnectFromDatabases::class, + // CollectGarbage::class, + ], + + WorkerErrorOccurred::class => [ + ReportException::class, + StopWorkerIfNecessary::class, + ], + + WorkerStopping::class => [ + CloseMonologHandlers::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Warm / Flush Bindings + |-------------------------------------------------------------------------- + | + | The bindings listed below will either be pre-warmed when a worker boots + | or they will be flushed before every new request. Flushing a binding + | will force the container to resolve that binding again when asked. + | + */ + + 'warm' => [ + ...Octane::defaultServicesToWarm(), + ], + + 'flush' => [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Tables + |-------------------------------------------------------------------------- + | + | While using Swoole, you may define additional tables as required by the + | application. These tables can be used to store data that needs to be + | quickly accessed by other workers on the particular Swoole server. + | + */ + + 'tables' => [ + 'example:1000' => [ + 'name' => 'string:1000', + 'votes' => 'int', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Cache Table + |-------------------------------------------------------------------------- + | + | While using Swoole, you may leverage the Octane cache, which is powered + | by a Swoole table. You may set the maximum number of rows as well as + | the number of bytes per row using the configuration options below. + | + */ + + 'cache' => [ + 'rows' => 1000, + 'bytes' => 10000, + ], + + /* + |-------------------------------------------------------------------------- + | File Watching + |-------------------------------------------------------------------------- + | + | The following list of files and directories will be watched when using + | the --watch option offered by Octane. If any of the directories and + | files are changed, Octane will automatically reload your workers. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config/**/*.php', + 'database/**/*.php', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + '.env', + ], + + /* + |-------------------------------------------------------------------------- + | Garbage Collection Threshold + |-------------------------------------------------------------------------- + | + | When executing long-lived PHP scripts such as Octane, memory can build + | up before being cleared by PHP. You can force Octane to run garbage + | collection if your application consumes this amount of megabytes. + | + */ + + 'garbage' => 50, + + /* + |-------------------------------------------------------------------------- + | Maximum Execution Time + |-------------------------------------------------------------------------- + | + | The following setting configures the maximum execution time for requests + | being handled by Octane. You may set this value to 0 to indicate that + | there isn't a specific time limit on Octane request execution time. + | + */ + + 'max_execution_time' => 30, + + /* + |-------------------------------------------------------------------------- + | Swoole Configuration + |-------------------------------------------------------------------------- + | + */ + + 'swoole' => [ + 'options' => [ + 'log_file' => '/dev/stdout', + 'package_max_length' => 10 * 1024 * 1024, + ], + ], + +]; diff --git a/config/sanctum.php b/config/sanctum.php index 764a82f..58ed90e 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -46,7 +46,7 @@ | */ - 'expiration' => null, + 'expiration' => 60, /* |-------------------------------------------------------------------------- diff --git a/config/services.php b/config/services.php index 27a3617..c5733b0 100755 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,16 @@ ], ], + 'rawg' => [ + 'host' => env('RAWG_API_HOST'), + 'key' => env('RAWG_API_KEY') + ], + + 'discord' => [ + 'app_id' => env('DISCORD_APP_ID'), + 'public_key' => env('DISCORD_PUBLIC_KEY'), + 'bot_token' => env('DISCORD_BOT_TOKEN'), + 'host' => env('DISCORD_API_HOST') + ] + ]; diff --git a/config/session.php b/config/session.php index f0b6541..b209e78 100755 --- a/config/session.php +++ b/config/session.php @@ -18,7 +18,7 @@ | */ - 'driver' => env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/database/factories/GameFactory.php b/database/factories/GameFactory.php new file mode 100755 index 0000000..c9e2f55 --- /dev/null +++ b/database/factories/GameFactory.php @@ -0,0 +1,52 @@ + + */ +class GameFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'id' => fake()->randomNumber(1), + 'name' => fake()->name(), + 'slug' => fake()->slug(), + 'backgroundImage' => fake()->imageUrl(), + 'released' => new DateTime(fake()->date()), + 'platforms' => [ + [ + 'id' => RawgPlatform::PC->value, + 'name' => RawgPlatform::PC->name, + 'slug' => Platform::PC->value + ] + ], + 'stores' => [ + [ + 'id' => fake()->randomNumber(1), + 'name' => fake()->name(), + 'slug' => fake()->slug() + ] + ], + 'genres' => [ + [ + 'id' => fake()->randomNumber(1), + 'name' => RawgGenre::Action->name, + 'slug' => RawgGenre::Action->value + ] + ], + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..8a83c65 100755 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,9 +2,9 @@ namespace Database\Factories; +use App\Enums\Scope; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Str; /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> @@ -24,21 +24,13 @@ class UserFactory extends Factory public function definition(): array { return [ - 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), - 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'password' => static::$password ??= Hash::make('password'), + 'scopes' => [Scope::Default->value], + 'discord_user_id' => null, + 'discord_username' => null, + 'discord_channel_id' => null ]; } - - /** - * Indicate that the model's email address should be unverified. - */ - public function unverified(): static - { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, - ]); - } } diff --git a/database/factories/UserSettingFactory.php b/database/factories/UserSettingFactory.php new file mode 100644 index 0000000..cbd3f02 --- /dev/null +++ b/database/factories/UserSettingFactory.php @@ -0,0 +1,30 @@ + + */ +class UserSettingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'platforms' => Platform::values(), + 'genres' => RawgGenre::values(), + 'period' => Period::Next_30_Days->value, + 'frequency' => Frequency::Monthly->value + ]; + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9..6db8871 100755 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -14,27 +14,14 @@ public function up(): void Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); + $table->string('email')->unique()->nullable(); + $table->string('password')->nullable(); + $table->string('scopes'); + $table->string('discord_user_id')->unique()->nullable(); + $table->string('discord_username')->unique()->nullable(); + $table->string('discord_channel_id')->unique()->nullable(); $table->timestamps(); }); - - Schema::create('password_reset_tokens', function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - - Schema::create('sessions', function (Blueprint $table) { - $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - }); } /** @@ -43,7 +30,5 @@ public function up(): void public function down(): void { Schema::dropIfExists('users'); - Schema::dropIfExists('password_reset_tokens'); - Schema::dropIfExists('sessions'); } }; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php deleted file mode 100755 index b9c106b..0000000 --- a/database/migrations/0001_01_01_000001_create_cache_table.php +++ /dev/null @@ -1,35 +0,0 @@ -string('key')->primary(); - $table->mediumText('value'); - $table->integer('expiration'); - }); - - Schema::create('cache_locks', function (Blueprint $table) { - $table->string('key')->primary(); - $table->string('owner'); - $table->integer('expiration'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('cache'); - Schema::dropIfExists('cache_locks'); - } -}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php deleted file mode 100755 index 425e705..0000000 --- a/database/migrations/0001_01_01_000002_create_jobs_table.php +++ /dev/null @@ -1,57 +0,0 @@ -id(); - $table->string('queue')->index(); - $table->longText('payload'); - $table->unsignedTinyInteger('attempts'); - $table->unsignedInteger('reserved_at')->nullable(); - $table->unsignedInteger('available_at'); - $table->unsignedInteger('created_at'); - }); - - Schema::create('job_batches', function (Blueprint $table) { - $table->string('id')->primary(); - $table->string('name'); - $table->integer('total_jobs'); - $table->integer('pending_jobs'); - $table->integer('failed_jobs'); - $table->longText('failed_job_ids'); - $table->mediumText('options')->nullable(); - $table->integer('cancelled_at')->nullable(); - $table->integer('created_at'); - $table->integer('finished_at')->nullable(); - }); - - Schema::create('failed_jobs', function (Blueprint $table) { - $table->id(); - $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); - $table->longText('payload'); - $table->longText('exception'); - $table->timestamp('failed_at')->useCurrent(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('jobs'); - Schema::dropIfExists('job_batches'); - Schema::dropIfExists('failed_jobs'); - } -}; diff --git a/database/migrations/2024_07_08_195242_create_personal_access_tokens_table.php b/database/migrations/2024_07_08_195242_create_personal_access_tokens_table.php deleted file mode 100644 index e828ad8..0000000 --- a/database/migrations/2024_07_08_195242_create_personal_access_tokens_table.php +++ /dev/null @@ -1,33 +0,0 @@ -id(); - $table->morphs('tokenable'); - $table->string('name'); - $table->string('token', 64)->unique(); - $table->text('abilities')->nullable(); - $table->timestamp('last_used_at')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('personal_access_tokens'); - } -}; diff --git a/database/migrations/2024_08_14_210442_create_health_tables.php b/database/migrations/2024_08_14_210442_create_health_tables.php new file mode 100644 index 0000000..d0f0f80 --- /dev/null +++ b/database/migrations/2024_08_14_210442_create_health_tables.php @@ -0,0 +1,46 @@ +getConnectionName(); + $tableName = EloquentHealthResultStore::getHistoryItemInstance()->getTable(); + + Schema::connection($connection)->create($tableName, function (Blueprint $table) { + $table->id(); + + $table->string('check_name'); + $table->string('check_label'); + $table->string('status'); + $table->text('notification_message')->nullable(); + $table->string('short_summary')->nullable(); + $table->json('meta'); + $table->timestamp('ended_at'); + $table->uuid('batch'); + + $table->timestamps(); + }); + + Schema::connection($connection)->table($tableName, function(Blueprint $table) { + $table->index('created_at'); + $table->index('batch'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableName = EloquentHealthResultStore::getHistoryItemInstance()->getTable(); + + Schema::dropIfExists($tableName); + } +}; diff --git a/database/migrations/2024_08_18_053353_create_user_settings_table.php b/database/migrations/2024_08_18_053353_create_user_settings_table.php new file mode 100644 index 0000000..40f5696 --- /dev/null +++ b/database/migrations/2024_08_18_053353_create_user_settings_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->string('platforms'); + $table->string('genres'); + $table->string('period'); + $table->string('frequency'); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_settings'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef..e2fd25c 100755 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ use App\Models\User; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Artisan; class DatabaseSeeder extends Seeder { @@ -13,11 +14,6 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + Artisan::call('app:create-root'); } } diff --git a/docker-compose.yml b/docker-compose.yml index 2178333..b2cacde 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: - php-fpm: - container_name: php-fpm + octane: + container_name: octane build: context: . dockerfile: Dockerfile.app @@ -9,9 +9,9 @@ services: extra_hosts: - "host.docker.internal:host-gateway" volumes: - - .:/var/www/laravel-app - - ./vendor:/var/www/laravel-app/vendor - working_dir: /var/www/laravel-app + - .:/srv/gamewatch + - ./vendor:/srv/gamewatch/vendor + working_dir: /srv/gamewatch depends_on: mysql: condition: service_healthy @@ -24,11 +24,11 @@ services: context: . dockerfile: Dockerfile.nginx environment: - - PHP_UPSTREAM_HOST=php-fpm + - PHP_UPSTREAM_HOST=octane ports: - '80:80' depends_on: - - php-fpm + - octane networks: - bridge diff --git a/docker/entrypoint.app.sh b/docker/entrypoint.app.sh index d92210e..06e8812 100644 --- a/docker/entrypoint.app.sh +++ b/docker/entrypoint.app.sh @@ -1,9 +1,14 @@ #!/bin/bash +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +fi + if [ ! -d "vendor" ] || [ -z "$(ls -A vendor)" ]; then if [ "$APP_ENV" == "local" ]; then composer install --no-interaction composer dump-autoload + npm install php artisan key:generate --ansi else @@ -13,4 +18,19 @@ if [ ! -d "vendor" ] || [ -z "$(ls -A vendor)" ]; then php artisan migrate --seed fi -supervisord -n -c /etc/supervisor/supervisord.conf +case "$RUN_MODE" in + octane) + if [ "$APP_ENV" == "local" ]; then + php artisan octane:swoole --host=0.0.0.0 --watch + else + php artisan octane:swoole --host=0.0.0.0 --workers=4 --task-workers=4 + fi + ;; + notif) + php artisan app:dispatch-notifications + ;; + *) + echo "Invalid RUN_MODE. Please set it to 'octane' or 'notif'." + exit 1 + ;; +esac \ No newline at end of file diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index fe51816..b976ae5 100755 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -1,37 +1,56 @@ -upstream php-fpm { - server ${PHP_UPSTREAM_HOST}:9000; +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; } +upstream php_upstream { + server ${PHP_UPSTREAM_HOST}:8000; +} + server { listen 80; listen [::]:80; - server_name laravel-app.local; - root /var/www/laravel-app/public; - - add_header X-Frame-Options "SAMEORIGIN"; - add_header X-Content-Type-Options "nosniff"; - + server_name gamewatch; + server_tokens off; + root /var/www/gamewatch/public; + index index.php; charset utf-8; - + + location /index.php { + try_files /not_exists @octane; + } + location / { - try_files $uri $uri/ /index.php?$query_string; + try_files $uri $uri/ @octane; } - + location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; } - + + access_log off; + error_log /var/log/nginx/gamewatch.log error; + error_page 404 /index.php; - - location ~ \.php$ { - fastcgi_pass php-fpm; - fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; - include fastcgi_params; - fastcgi_hide_header X-Powered-By; - } - - location ~ /\.(?!well-known).* { - deny all; + + location @octane { + set $suffix ""; + + if ($uri = /index.php) { + set $suffix ?$query_string; + } + + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Scheme $scheme; + proxy_set_header SERVER_PORT $server_port; + proxy_set_header REMOTE_ADDR $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_pass http://php_upstream$suffix; } } \ No newline at end of file diff --git a/docker/supervisor/conf.d/php-fpm.conf b/docker/supervisor/conf.d/php-fpm.conf deleted file mode 100755 index 2059539..0000000 --- a/docker/supervisor/conf.d/php-fpm.conf +++ /dev/null @@ -1,9 +0,0 @@ -[program:php-fpm] -command=/usr/local/sbin/php-fpm -F -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes = 0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes = 0 -exitcodes=0 \ No newline at end of file diff --git a/docker/supervisor/conf.d/worker.bkp b/docker/supervisor/conf.d/worker.bkp deleted file mode 100755 index a5a0314..0000000 --- a/docker/supervisor/conf.d/worker.bkp +++ /dev/null @@ -1,8 +0,0 @@ -[program:laravel-worker] -process_name=%(program_name)s_%(process_num)02d -command=php /var/www/html/artisan queue:work --sleep=3 --tries=3 -autostart=true -autorestart=true -numprocs=2 -redirect_stderr=true -stdout_logfile=/dev/stdout \ No newline at end of file diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf deleted file mode 100644 index 7f482fa..0000000 --- a/docker/supervisor/supervisord.conf +++ /dev/null @@ -1,23 +0,0 @@ -[unix_http_server] -file=/var/run/supervisor.sock -chmod=0700 -chown=www-data:www-data - -[supervisord] -logfile=/dev/null -logfile_maxbytes=0 -loglevel=info -pidfile=/var/run/supervisord.pid -nodaemon=false -minfds=1024 -minprocs=200 -user=root - -[rpcinterface:supervisor] -supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface - -[supervisorctl] -serverurl=unix:///var/run/supervisor.sock - -[include] -files = /etc/supervisor/conf.d/*.conf \ No newline at end of file diff --git a/ecs/deployment-task.json b/ecs/deployment-task.json index 373e3bf..d61c870 100644 --- a/ecs/deployment-task.json +++ b/ecs/deployment-task.json @@ -4,25 +4,19 @@ "networkMode": "bridge", "containerDefinitions": [ { - "name": "php-fpm", - "image": "", + "name": "octane", + "image": "", "essential": true, "portMappings": [ { - "containerPort": 9000, + "containerPort": 8000, "protocol": "tcp" } ], - "dependsOn": [ - { - "containerName": "mysql", - "condition": "HEALTHY" - } - ], "healthCheck": { "command": [ "CMD-SHELL", - "supervisorctl status php-fpm | grep RUNNING || exit 1" + "curl -f http://localhost:8000/up || exit 1" ], "interval": 30, "timeout": 10, @@ -32,13 +26,17 @@ "logConfiguration": { "logDriver": "awslogs", "options": { - "awslogs-group": "/ecs/laravel-app", + "awslogs-group": "/ecs/gamewatch", "awslogs-region": "us-east-2", - "awslogs-stream-prefix": "php-fpm", + "awslogs-stream-prefix": "octane", "awslogs-create-group": "true" } }, "secrets": [ + { + "name": "RUN_MODE", + "valueFrom": "/RUN_MODE" + }, { "name": "APP_NAME", "valueFrom": "/APP_DEBUG" @@ -94,7 +92,47 @@ { "name": "REDIS_PASSWORD", "valueFrom": "/REDIS_PASSWORD" - } + }, + { + "name": "RAWG_API_KEY", + "valueFrom": "/RAWG_API_KEY" + }, + { + "name": "RAWG_API_HOST", + "valueFrom": "/RAWG_API_HOST" + }, + { + "name": "JWT_EXPIRES", + "valueFrom": "/JWT_EXPIRES" + }, + { + "name": "DISCORD_APP_ID", + "valueFrom": "/DISCORD_APP_ID" + }, + { + "name": "DISCORD_PUBLIC_KEY", + "valueFrom": "/DISCORD_PUBLIC_KEY" + }, + { + "name": "DISCORD_BOT_TOKEN", + "valueFrom": "/DISCORD_BOT_TOKEN" + }, + { + "name": "DISCORD_API_HOST", + "valueFrom": "/DISCORD_API_HOST" + }, + { + "name": "ROOT_DISCORD_USER_ID", + "valueFrom": "/ROOT_DISCORD_USER_ID" + }, + { + "name": "ROOT_DISCORD_USERNAME", + "valueFrom": "/ROOT_DISCORD_USERNAME" + }, + { + "name": "ROOT_DISCORD_CHANNEL_ID", + "valueFrom": "/ROOT_DISCORD_CHANNEL_ID" + }, ] }, { @@ -110,7 +148,7 @@ ], "dependsOn": [ { - "containerName": "php-fpm", + "containerName": "octane", "condition": "HEALTHY" } ], @@ -133,7 +171,7 @@ "logConfiguration": { "logDriver": "awslogs", "options": { - "awslogs-group": "/ecs/laravel-app", + "awslogs-group": "/ecs/gamewatch", "awslogs-region": "us-east-2", "awslogs-stream-prefix": "nginx", "awslogs-create-group": "true" diff --git a/github/workflows/tests-n-cs-container.yml b/github/workflows/tests-n-cs-container.yml deleted file mode 100644 index 91dc35e..0000000 --- a/github/workflows/tests-n-cs-container.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Tests & Code Sniffer - -on: - workflow_call: - -jobs: - phpunit-phpcs: - runs-on: ubuntu-latest - - container: - image: php:8.3-fpm - - services: - redis: - image: redis:latest - ports: - - 6379:6379 - options: --health-cmd "redis-cli ping || exit 1" --health-interval 5s --health-timeout 3s --health-retries 3 - - steps: - - uses: actions/checkout@v4 - - - name: Install system dependencies - run: apt-get update && apt-get install -y apt-utils curl wget zip git redis-tools - - - name: Install PHP extensions - run: | - docker-php-ext-configure pcntl --enable-pcntl - docker-php-ext-install pdo pdo_mysql pcntl - pecl install xdebug redis - docker-php-ext-enable xdebug redis - - - name: Install Composer - run: curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - - - name: Install Dependencies - run: | - composer install --no-interaction - composer dump-autoload - - - name: Create .env file - run: | - cp .env.example .env - sed -i 's/REDIS_HOST=.*/REDIS_HOST=redis/' .env - sed -i 's/REDIS_PASSWORD=.*/REDIS_PASSWORD=null/' .env - - - name: Generate Key - run: php artisan key:generate - - - name: Set Directory Permissions - run: chmod -R 777 storage bootstrap/cache - - - name: Run Code Sniffer - run: composer phpcs - - - name: Check Redis Connectivity - run: | - redis-cli -h redis ping - - - name: Run Tests - run: composer test diff --git a/github/workflows/tests-n-cs.yml b/github/workflows/tests-n-cs.yml deleted file mode 100644 index 2f9671f..0000000 --- a/github/workflows/tests-n-cs.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Tests & Code Sniffer - -on: - workflow_call: - -jobs: - phpunit-phpcs: - runs-on: ubuntu-latest - - services: - redis: - image: redis:latest - ports: - - 6379:6379 - - steps: - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.3' - - - uses: actions/checkout@v4 - - - name: Create .env file - run: | - cp .env.example .env - sed -i 's/REDIS_HOST=.*/REDIS_HOST=localhost/' .env - sed -i 's/REDIS_PASSWORD=.*/REDIS_PASSWORD=null/' .env - - - name: Install Dependencies - run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - - - name: Generate Key - run: php artisan key:generate - - - name: Set Directory Permissions - run: chmod -R 777 storage bootstrap/cache - - - name: Run Code Sniffer - run: composer phpcs - - - name: Wait for Redis - run: sleep 10 - - - name: Check Redis Connectivity - run: | - nc -zv localhost 6379 - - - name: Run Tests - run: composer test diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8fec4f5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1104 @@ +{ + "name": "gamewatch", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "axios": "^1.6.4", + "chokidar": "^3.6.0", + "laravel-vite-plugin": "^1.0", + "vite": "^5.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", + "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", + "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", + "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", + "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", + "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", + "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", + "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", + "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", + "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", + "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", + "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", + "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", + "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", + "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", + "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", + "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.0.5.tgz", + "integrity": "sha512-Zv+to82YLBknDCZ6g3iwOv9wZ7f6EWStb9pjSm7MGe9Mfoy5ynT2ssZbGsMr1udU6rDg9HOoYEVGw5Qf+p9zbw==", + "dev": true, + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.45", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", + "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rollup": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", + "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.21.3", + "@rollup/rollup-android-arm64": "4.21.3", + "@rollup/rollup-darwin-arm64": "4.21.3", + "@rollup/rollup-darwin-x64": "4.21.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", + "@rollup/rollup-linux-arm-musleabihf": "4.21.3", + "@rollup/rollup-linux-arm64-gnu": "4.21.3", + "@rollup/rollup-linux-arm64-musl": "4.21.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", + "@rollup/rollup-linux-riscv64-gnu": "4.21.3", + "@rollup/rollup-linux-s390x-gnu": "4.21.3", + "@rollup/rollup-linux-x64-gnu": "4.21.3", + "@rollup/rollup-linux-x64-musl": "4.21.3", + "@rollup/rollup-win32-arm64-msvc": "4.21.3", + "@rollup/rollup-win32-ia32-msvc": "4.21.3", + "@rollup/rollup-win32-x64-msvc": "4.21.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/vite": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.5.tgz", + "integrity": "sha512-pXqR0qtb2bTwLkev4SE3r4abCNioP3GkjvIDLlzziPpXtHgiJIjuKl+1GN6ESOT3wMjG3JTeARopj2SwYaHTOA==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + } + } +} diff --git a/package.json b/package.json index 4e934ca..72d9b53 100755 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ }, "devDependencies": { "axios": "^1.6.4", + "chokidar": "^3.6.0", "laravel-vite-plugin": "^1.0", "vite": "^5.0" } diff --git a/public/storage b/public/storage new file mode 120000 index 0000000..14e9b0c --- /dev/null +++ b/public/storage @@ -0,0 +1 @@ +/var/www/laravel-app/storage/app/public \ No newline at end of file diff --git a/public/swagger.yaml b/public/swagger.yaml index 1817348..15be61a 100644 --- a/public/swagger.yaml +++ b/public/swagger.yaml @@ -1,11 +1,336 @@ openapi: 3.0.0 info: - title: 'Laravel App' + title: GameWatch version: 1.0.0 servers: - - url: 'http://laravel-app.local' + url: 'http://gamewatch.local' paths: + /api/account/show: + get: + tags: + - account + operationId: 7b10175533fd9d5dd4be1d8b950e90ec + responses: + '200': + description: 'Account data' + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /api/account/register: + post: + tags: + - account + operationId: dd1184c14a98467480820df6a52e04bb + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + nullable: false + email: + type: string + nullable: false + password: + type: string + format: password + nullable: false + type: object + responses: + '201': + description: 'Account data' + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /api/account/update: + put: + tags: + - account + operationId: 5cfed2aa6285b24797f79bc20c3f7106 + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + nullable: true + email: + type: string + nullable: true + username: + type: string + nullable: true + password: + type: string + format: password + nullable: true + discord_user_id: + type: string + nullable: true + type: object + responses: + '200': + description: 'Account data' + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /api/account/settings: + put: + tags: + - account + operationId: 2eacf149a4144a183a782362f4140e28 + requestBody: + required: true + content: + application/json: + schema: + properties: + platforms: + type: array + items: { type: string, enum: [pc, playstation5, xbox-series-x, switch, linux, android, ios] } + nullable: true + genres: + type: array + items: { type: string, enum: [racing, shooter, adventure, action, rpg, fighting, puzzle, strategy, arcade, simulation, sports, card, family, board-games, educational, casual, indie, massively-multiplayer, platformer] } + nullable: true + period: + type: string + enum: [next-7-days, next-30-days, next-12-months] + nullable: true + frequency: + type: string + enum: [none, daily, weekly, monthly] + nullable: true + type: object + responses: + '200': + description: 'Account data' + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /api/auth/login: + post: + tags: + - auth + operationId: 8dcb70df1020986038d098cc08d05dae + requestBody: + required: true + content: + application/json: + schema: + properties: + email: + type: string + password: + type: string + format: password + type: object + responses: + '200': + description: JWT + security: [] + /api/rawg/domain/genres: + get: + tags: + - domain + operationId: daa3484b0cfbf1f84c09190d386fb6aa + responses: + '200': + description: 'List of RAWG genres' + /api/rawg/domain/tags: + get: + tags: + - domain + operationId: b51af1f826392611185f7b288fedb845 + responses: + '200': + description: 'List of RAWG tags' + /api/rawg/domain/platforms: + get: + tags: + - domain + operationId: b1a9bad8e0b69f95fd054ffcf6c4b9b3 + responses: + '200': + description: 'List of RAWG platforms' + '/api/rawg/games/recommendations/{genre}': + get: + tags: + - games + operationId: 9ce6b8a31793e7f454284c21b860704b + parameters: + - + name: genre + in: path + description: 'Filter by genres' + required: true + schema: + $ref: '#/components/schemas/RawgGenre' + - + name: platforms + in: query + description: 'Filter by platforms (accepts comma separated list)' + style: form + explode: false + schema: + type: array + items: + type: string + enum: + - pc + - playstation5 + - xbox-series-x + - switch + - linux + - android + - ios + - + name: ordering + in: query + description: 'Rawg field to order by' + - + name: page + in: query + description: 'Page number to request' + - + name: page_size + in: query + description: 'How many items per page' + responses: + '200': + description: 'List of RAWG games' + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedResponse' + '/api/rawg/games/upcoming-releases/{period}': + get: + tags: + - games + operationId: afce02b91592c98f28a021c4a88bbfde + parameters: + - + name: period + in: path + description: 'Get releases for selected period' + required: true + schema: + $ref: '#/components/schemas/Period' + - + name: genres + in: query + description: 'Filter by genres (accepts comma separated list)' + style: form + explode: false + schema: + type: array + items: + type: string + enum: + - racing + - shooter + - adventure + - action + - rpg + - fighting + - puzzle + - strategy + - arcade + - simulation + - sports + - card + - family + - board-games + - educational + - casual + - indie + - massively-multiplayer + - platformer + - + name: platforms + in: query + description: 'Filter by platforms (accepts comma separated list)' + style: form + explode: false + schema: + type: array + items: + type: string + enum: + - pc + - playstation5 + - xbox-series-x + - switch + - linux + - android + - ios + - + name: ordering + in: query + description: 'Rawg field to order by' + - + name: page + in: query + description: 'Page number to request' + - + name: page_size + in: query + description: 'How many items per page' + responses: + '200': + description: 'List of RAWG games' + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedResponse' + '/api/rawg/games/{game}/achievements': + get: + tags: + - games + operationId: 6eca34be51c7b0acaf24caf35cbd7a90 + parameters: + - + name: game + in: path + description: 'Rawg slug of the game' + required: true + - + name: order_by + in: query + description: 'Field to order by' + - + name: sort_order + in: query + description: 'Sorting order' + schema: + $ref: '#/components/schemas/SortOrder' + - + name: page + in: query + description: 'Page number to request' + - + name: page_size + in: query + description: 'How many items per page' + responses: + '200': + description: "List of RAWG game's achievements" + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, description: { type: string }, image: { type: string }, percent: { type: string } } + type: object /up: get: tags: @@ -15,7 +340,281 @@ paths: '200': description: OK security: [] + /api/up: + get: + tags: + - application + operationId: 0209dbce81fba7988605af0dd5da1f73 + responses: + '200': + description: OK + security: [] +components: + schemas: + Acknowledge: + type: integer + enum: + - Pong + - ChannelMessageWithSource + - UpdateMessage + - AutoCompleteResult + ButtonStyle: + type: integer + enum: + - Primary + - Secundary + ComponentType: + type: integer + enum: + - ActionRow + - Button + - StringSelect + InteractionType: + type: integer + enum: + - Ping + - Command + - MessageComponent + - AutoComplete + OptionType: + type: integer + enum: + - String + Frequency: + type: string + enum: + - none + - daily + - weekly + - monthly + Period: + type: string + enum: + - next-7-days + - next-30-days + - next-12-months + Platform: + type: string + enum: + - pc + - playstation5 + - xbox-series-x + - switch + - linux + - android + - ios + RawgField: + type: string + enum: + - id + - name + - slug + - background_image + - released + - dates + - genres + - platforms + - stores + - ordering + - page_size + - page + RawgGenre: + type: string + enum: + - racing + - shooter + - adventure + - action + - rpg + - fighting + - puzzle + - strategy + - arcade + - simulation + - sports + - card + - family + - board-games + - educational + - casual + - indie + - massively-multiplayer + - platformer + RawgPlatform: + type: integer + enum: + - 4 + - 187 + - 186 + - 7 + - 6 + - 21 + - 3 + Scope: + type: string + enum: + - default + - root + SortOrder: + type: string + enum: + - ASC + - DESC + Game: + properties: + id: + type: integer + name: + type: string + slug: + type: string + background_image: + type: string + released: + type: string + format: date-time + platforms: + type: array + items: + properties: + id: + type: integer + name: + type: string + slug: + type: string + type: object + stores: + type: array + items: + type: object + genres: + type: array + items: + type: object + type: object + PaginatedResponse: + properties: + total: + type: integer + pageSize: + type: integer + currentPage: + type: integer + lastPage: + type: integer + nextPageUrl: + type: string + prevPageUrl: + type: string + data: + type: array + items: + oneOf: + - + $ref: '#/components/schemas/Game' + type: object + User: + properties: + id: + type: integer + name: + type: string + email: + type: string + discord_user_id: + type: string + discord_username: + type: string + discord_channel_id: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + settings: + $ref: '#/components/schemas/UserSetting' + type: object + UserSetting: + properties: + id: + type: integer + user_id: + type: integer + platforms: + type: array + items: + type: string + enum: + - pc + - playstation5 + - xbox-series-x + - switch + - linux + - android + - ios + genres: + type: array + items: + type: string + enum: + - racing + - shooter + - adventure + - action + - rpg + - fighting + - puzzle + - strategy + - arcade + - simulation + - sports + - card + - family + - board-games + - educational + - casual + - indie + - massively-multiplayer + - platformer + period: + type: string + enum: + - next-7-days + - next-30-days + - next-12-months + frequency: + type: string + enum: + - none + - daily + - weekly + - monthly + type: object + securitySchemes: + bearerAuth: + type: http + name: bearerAuth + in: header + scheme: bearer +security: + - + bearerAuth: [] tags: - name: application description: 'health and other application routes' + - + name: domain + description: 'rawg domain routes' + - + name: games + description: 'rawg games routes' + - + name: auth + description: 'authentication routes' + - + name: account + description: 'account management routes' diff --git a/resources/css/app.css b/resources/css/app.css deleted file mode 100755 index e69de29..0000000 diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100755 index e59d6a0..0000000 --- a/resources/js/app.js +++ /dev/null @@ -1 +0,0 @@ -import './bootstrap'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js deleted file mode 100755 index 5f1390b..0000000 --- a/resources/js/bootstrap.js +++ /dev/null @@ -1,4 +0,0 @@ -import axios from 'axios'; -window.axios = axios; - -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/routes/api.php b/routes/api.php index cefaf7d..b1ac8fe 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,10 +1,54 @@ user(); -})->middleware('auth:sanctum'); +Route::prefix('discord') + ->middleware(['discord.sign', 'throttle:discord']) + ->controller(DiscordController::class) + ->group(function () { + Route::post('/interactions', 'interactions'); + }); + +Route::middleware('throttle:api')->group(function () { + Route::post('/auth/login', [AuthController::class, 'login']); + + Route::middleware(['auth', 'scopes:' . Scope::Default->value])->group(function () { + Route::prefix('account')->controller(AccountController::class)->group(function () { + Route::get('/show', 'show'); + Route::put('/update', 'update'); + Route::put('/settings', 'settings'); + }); + + Route::middleware('scopes:' . Scope::Root->value)->group(function () { + Route::post('/account/register', [AccountController::class, 'register']); + + Route::prefix('rawg') + ->group(function () { + Route::prefix('domain')->controller(RawgDomainController::class)->group(function() { + Route::get('/genres', 'genres'); + Route::get('/tags', 'tags'); + Route::get('/platforms', 'platforms'); + }); + + Route::prefix('games')->controller(RawgGamesController::class)->group(function () { + Route::get('/recommendations/{genre}', 'recommendations')->where('genre', RawgGenre::valuesAsString('|')); + Route::get('/upcoming-releases/{period}', 'upcomingReleases')->where('period', Period::valuesAsString('|')); + Route::get('/{game}/achievements', 'achievements'); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 86a06c5..ad58fa2 100755 --- a/routes/web.php +++ b/routes/web.php @@ -4,4 +4,4 @@ Route::get('/', function () { return view('welcome'); -}); +}); \ No newline at end of file diff --git a/storage/tests/rawg_achievements.json b/storage/tests/rawg_achievements.json new file mode 100644 index 0000000..ba58620 --- /dev/null +++ b/storage/tests/rawg_achievements.json @@ -0,0 +1 @@ +{"count":49,"next":"https://api.rawg.io/api/games/the-witcher-3-wild-hunt/achievements?key=a8befb20f4194f96a61584b8e1bc8ae1&page=2","previous":null,"results":[{"id":162470,"name":"The Limits of the Possible","description":"Collect all trophies.","image":"https://media.rawg.io/media/achievements/7c7/7c75d8755d93f4acbde15d052b96534d.jpg","percent":"3.12"},{"id":167134,"name":"That Is the Evilest Thing…","description":"Ignite the gas produced by a Dragon's Dream bomb using a burning opponent. Do this 10 times.","image":"https://media.rawg.io/media/achievements/f06/f06f572a1bfabd3347cfb31ed729d498.jpg","percent":"5.97"},{"id":177407,"name":"That Is the Evilest Thing","description":"Ignite the gas produced by a Dragon's Dream bomb using a burning opponent. Do this 10 times.","image":"https://media.rawg.io/media/achievements/164/1644c71b90efb9d90b40c95242d1e527.jpg","percent":"8.04"},{"id":126805,"name":"Master Marksman","description":"Kill 50 human and nonhuman opponents by striking them in the head with a crossbow bolt.","image":"https://media.rawg.io/media/achievements/233/23314da942731f8ce34378917a703aaf.jpg","percent":"9.14"},{"id":135938,"name":"Kling of the Clink","description":"Serve time in Toussaint.","image":"https://media.rawg.io/media/achievements/fe1/fe1f40abaa5d0b82ed2d5c761f20a1ea.jpg","percent":"9.36"},{"id":140770,"name":"I Wore Ofieri Before It Was Cool","description":"Collect all available Ofieri armor and horse gear, and at least one Ofieri sword.","image":"https://media.rawg.io/media/achievements/925/9255dcf348e803736db894718fef4d7f.jpg","percent":"10.06"},{"id":148021,"name":"Humpty Dumpty","description":"Kill 10 opponents by knocking them off somewhere high with the Aard Sign.","image":"https://media.rawg.io/media/achievements/f5f/f5f07f4e476091783e36b5b10e6d272f.jpg","percent":"11.02"},{"id":140771,"name":"Overkill","description":"Make an opponent suffer from bleeding, poisoning and burning simultaneously. Do this 10 times.","image":"https://media.rawg.io/media/achievements/804/804a4a18db68d27011945cfef90978e4.jpg","percent":"12.25"},{"id":152158,"name":"Return to Sender","description":"Kill 3 opponents with their own arrows.","image":"https://media.rawg.io/media/achievements/06a/06adaeba628be4c589fa55053a6abcdb.jpg","percent":"12.50"},{"id":152157,"name":"Can Quit Anytime I Want","description":"Be under the influence of seven potions or decoctions at the same time.","image":"https://media.rawg.io/media/achievements/a85/a85825ad322b6f436c9cdc3b4f1773b8.jpg","percent":"13.08"}]} \ No newline at end of file diff --git a/storage/tests/rawg_domain_genres.json b/storage/tests/rawg_domain_genres.json new file mode 100644 index 0000000..eaee575 --- /dev/null +++ b/storage/tests/rawg_domain_genres.json @@ -0,0 +1 @@ +{"count":19,"next":null,"previous":null,"results":[{"id":4,"name":"Action","slug":"action","games_count":181998,"image_background":"https://media.rawg.io/media/games/7cf/7cfc9220b401b7a300e409e539c9afd5.jpg","games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907}]},{"id":51,"name":"Indie","slug":"indie","games_count":68718,"image_background":"https://media.rawg.io/media/games/48c/48cb04ca483be865e3a83119c94e6097.jpg","games":[{"id":1030,"slug":"limbo","name":"Limbo","added":13486},{"id":422,"slug":"terraria","name":"Terraria","added":12679},{"id":3272,"slug":"rocket-league","name":"Rocket League","added":12393},{"id":9767,"slug":"hollow-knight","name":"Hollow Knight","added":10972},{"id":3612,"slug":"hotline-miami","name":"Hotline Miami","added":10410},{"id":3790,"slug":"outlast","name":"Outlast","added":10353}]},{"id":3,"name":"Adventure","slug":"adventure","games_count":141678,"image_background":"https://media.rawg.io/media/games/0fd/0fd84d36596a83ef2e5a35f63a072218.jpg","games":[{"id":3439,"slug":"life-is-strange-episode-1-2","name":"Life is Strange","added":15188},{"id":23027,"slug":"the-walking-dead","name":"The Walking Dead: Season 1","added":11211},{"id":41,"slug":"little-nightmares","name":"Little Nightmares","added":10903},{"id":13668,"slug":"amnesia-the-dark-descent","name":"Amnesia: The Dark Descent","added":10005},{"id":19487,"slug":"alan-wake","name":"Alan Wake","added":9980},{"id":4386,"slug":"saints-row-the-third","name":"Saints Row: The Third","added":9893}]},{"id":5,"name":"RPG","slug":"role-playing-games-rpg","games_count":56982,"image_background":"https://media.rawg.io/media/games/995/9951d9d55323d08967640f7b9ab3e342.jpg","games":[{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":802,"slug":"borderlands-2","name":"Borderlands 2","added":15182},{"id":3070,"slug":"fallout-4","name":"Fallout 4","added":13494},{"id":41494,"slug":"cyberpunk-2077","name":"Cyberpunk 2077","added":12821},{"id":278,"slug":"horizon-zero-dawn","name":"Horizon Zero Dawn","added":12489}]},{"id":10,"name":"Strategy","slug":"strategy","games_count":57255,"image_background":"https://media.rawg.io/media/games/d4b/d4bcd78873edd9992d93aff9cc8db0c8.jpg","games":[{"id":10243,"slug":"company-of-heroes-2","name":"Company of Heroes 2","added":9420},{"id":13633,"slug":"civilization-v","name":"Sid Meier's Civilization V","added":9371},{"id":11147,"slug":"ark-survival-of-the-fittest","name":"ARK: Survival Of The Fittest","added":8339},{"id":10065,"slug":"cities-skylines","name":"Cities: Skylines","added":8248},{"id":13910,"slug":"xcom-enemy-unknown","name":"XCOM: Enemy Unknown","added":8226},{"id":5525,"slug":"brutal-legend","name":"Brutal Legend","added":8151}]},{"id":2,"name":"Shooter","slug":"shooter","games_count":59499,"image_background":"https://media.rawg.io/media/games/6c5/6c55e22185876626881b76c11922b073.jpg","games":[{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473},{"id":4062,"slug":"bioshock-infinite","name":"BioShock Infinite","added":15359},{"id":802,"slug":"borderlands-2","name":"Borderlands 2","added":15182},{"id":13537,"slug":"half-life-2","name":"Half-Life 2","added":14722}]},{"id":40,"name":"Casual","slug":"casual","games_count":55683,"image_background":"https://media.rawg.io/media/screenshots/4f4/4f4722571e32954af43a4508607c1748.jpg","games":[{"id":9721,"slug":"garrys-mod","name":"Garry's Mod","added":9834},{"id":326292,"slug":"fall-guys","name":"Fall Guys: Ultimate Knockout","added":8569},{"id":9830,"slug":"brawlhalla","name":"Brawlhalla","added":7713},{"id":356714,"slug":"among-us","name":"Among Us","added":7335},{"id":1959,"slug":"goat-simulator","name":"Goat Simulator","added":6283},{"id":42187,"slug":"the-sims-4","name":"The Sims 4","added":5985}]},{"id":14,"name":"Simulation","slug":"simulation","games_count":70743,"image_background":"https://media.rawg.io/media/games/48c/48cb04ca483be865e3a83119c94e6097.jpg","games":[{"id":10035,"slug":"hitman","name":"Hitman","added":10528},{"id":654,"slug":"stardew-valley","name":"Stardew Valley","added":10094},{"id":9721,"slug":"garrys-mod","name":"Garry's Mod","added":9834},{"id":9882,"slug":"dont-starve-together","name":"Don't Starve Together","added":9336},{"id":22509,"slug":"minecraft","name":"Minecraft","added":8375},{"id":10065,"slug":"cities-skylines","name":"Cities: Skylines","added":8248}]},{"id":7,"name":"Puzzle","slug":"puzzle","games_count":97297,"image_background":"https://media.rawg.io/media/games/74d/74dafeb9a442b87b9dd4a1d4a2faa37b.jpg","games":[{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":1030,"slug":"limbo","name":"Limbo","added":13486},{"id":19709,"slug":"half-life-2-episode-two","name":"Half-Life 2: Episode Two","added":11013},{"id":1450,"slug":"inside","name":"INSIDE","added":7942},{"id":3853,"slug":"trine-2-complete-story","name":"Trine 2: Complete Story","added":7199}]},{"id":11,"name":"Arcade","slug":"arcade","games_count":22658,"image_background":"https://media.rawg.io/media/games/e0f/e0f05a97ff926acf4c8f43e0849b6832.jpg","games":[{"id":3612,"slug":"hotline-miami","name":"Hotline Miami","added":10410},{"id":17540,"slug":"injustice-gods-among-us-ultimate-edition","name":"Injustice: Gods Among Us Ultimate Edition","added":9477},{"id":22509,"slug":"minecraft","name":"Minecraft","added":8375},{"id":4003,"slug":"grid-2","name":"GRID 2","added":7449},{"id":3408,"slug":"hotline-miami-2-wrong-number","name":"Hotline Miami 2: Wrong Number","added":6116},{"id":58753,"slug":"forza-horizon-4","name":"Forza Horizon 4","added":5969}]},{"id":83,"name":"Platformer","slug":"platformer","games_count":100797,"image_background":"https://media.rawg.io/media/games/cd3/cd3c9c7d3e95cb1608fd6250f1b90b7a.jpg","games":[{"id":1030,"slug":"limbo","name":"Limbo","added":13486},{"id":422,"slug":"terraria","name":"Terraria","added":12679},{"id":9767,"slug":"hollow-knight","name":"Hollow Knight","added":10972},{"id":41,"slug":"little-nightmares","name":"Little Nightmares","added":10903},{"id":3144,"slug":"super-meat-boy","name":"Super Meat Boy","added":9358},{"id":17572,"slug":"batman-aa-goty","name":"Batman: Arkham Asylum Game of the Year Edition","added":8126}]},{"id":1,"name":"Racing","slug":"racing","games_count":24934,"image_background":"https://media.rawg.io/media/games/fc0/fc076b974197660a582abd34ebccc27f.jpg","games":[{"id":3272,"slug":"rocket-league","name":"Rocket League","added":12393},{"id":4003,"slug":"grid-2","name":"GRID 2","added":7449},{"id":2572,"slug":"dirt-rally","name":"DiRT Rally","added":6742},{"id":58753,"slug":"forza-horizon-4","name":"Forza Horizon 4","added":5969},{"id":5578,"slug":"grid","name":"GRID (2008)","added":5335},{"id":19491,"slug":"burnout-paradise-the-ultimate-box","name":"Burnout Paradise: The Ultimate Box","added":4689}]},{"id":59,"name":"Massively Multiplayer","slug":"massively-multiplayer","games_count":3750,"image_background":"https://media.rawg.io/media/games/82b/82be203e68d737762846203811165933.jpg","games":[{"id":10213,"slug":"dota-2","name":"Dota 2","added":12463},{"id":766,"slug":"warframe","name":"Warframe","added":12418},{"id":10533,"slug":"path-of-exile","name":"Path of Exile","added":10033},{"id":10142,"slug":"playerunknowns-battlegrounds","name":"PlayerUnknown’s Battlegrounds","added":9956},{"id":362,"slug":"for-honor","name":"For Honor","added":9341},{"id":326292,"slug":"fall-guys","name":"Fall Guys: Ultimate Knockout","added":8569}]},{"id":15,"name":"Sports","slug":"sports","games_count":21674,"image_background":"https://media.rawg.io/media/games/a1c/a1cea552040aecf9414548e209f9c0d8.jpg","games":[{"id":3272,"slug":"rocket-league","name":"Rocket League","added":12393},{"id":326292,"slug":"fall-guys","name":"Fall Guys: Ultimate Knockout","added":8569},{"id":2572,"slug":"dirt-rally","name":"DiRT Rally","added":6742},{"id":53341,"slug":"jet-set-radio-2012","name":"Jet Set Radio","added":5138},{"id":9575,"slug":"vrchat","name":"VRChat","added":4755},{"id":36,"slug":"tekken-7","name":"TEKKEN 7","added":4013}]},{"id":6,"name":"Fighting","slug":"fighting","games_count":11756,"image_background":"https://media.rawg.io/media/games/450/450ccffea136d06ef8d18802d583f8e5.jpg","games":[{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":17540,"slug":"injustice-gods-among-us-ultimate-edition","name":"Injustice: Gods Among Us Ultimate Edition","added":9477},{"id":108,"slug":"mortal-kombat-x","name":"Mortal Kombat X","added":8704},{"id":28179,"slug":"sega-mega-drive-and-genesis-classics","name":"SEGA Mega Drive and Genesis Classics","added":8082},{"id":9830,"slug":"brawlhalla","name":"Brawlhalla","added":7713},{"id":274480,"slug":"mortal-kombat-11","name":"Mortal Kombat 11","added":5425}]},{"id":19,"name":"Family","slug":"family","games_count":5396,"image_background":"https://media.rawg.io/media/screenshots/b1b/b1bde44ad4c3164f81d1249f866ad83c.jpeg","games":[{"id":3254,"slug":"journey","name":"Journey","added":8361},{"id":3729,"slug":"lego-the-hobbit","name":"LEGO The Hobbit","added":5064},{"id":3350,"slug":"broken-age","name":"Broken Age","added":5007},{"id":1259,"slug":"machinarium","name":"Machinarium","added":4520},{"id":1140,"slug":"world-of-goo","name":"World of Goo","added":4430},{"id":4331,"slug":"sonic-generations","name":"Sonic Generations","added":4161}]},{"id":28,"name":"Board Games","slug":"board-games","games_count":8378,"image_background":"https://media.rawg.io/media/games/3af/3af386b6e26be6741b711ae6215ef42f.jpg","games":[{"id":23557,"slug":"gwent-the-witcher-card-game","name":"Gwent: The Witcher Card Game","added":4792},{"id":327999,"slug":"dota-underlords","name":"Dota Underlords","added":4048},{"id":2055,"slug":"adventure-capitalist","name":"AdVenture Capitalist","added":3400},{"id":758,"slug":"hue","name":"Hue","added":2544},{"id":2306,"slug":"poker-night-2","name":"Poker Night 2","added":2051},{"id":3187,"slug":"armello","name":"Armello","added":2028}]},{"id":34,"name":"Educational","slug":"educational","games_count":15682,"image_background":"https://media.rawg.io/media/screenshots/31d/31d70d348acf9924a218ddcb80c171be.jpg","games":[{"id":1358,"slug":"papers-please","name":"Papers, Please","added":6850},{"id":1140,"slug":"world-of-goo","name":"World of Goo","added":4430},{"id":2778,"slug":"surgeon-simulator-cpr","name":"Surgeon Simulator","added":3930},{"id":9768,"slug":"gameguru","name":"GameGuru","added":2569},{"id":13777,"slug":"sid-meiers-civilization-iv-colonization","name":"Sid Meier's Civilization IV: Colonization","added":2325},{"id":6885,"slug":"pirates-3","name":"Sid Meier's Pirates!","added":2263}]},{"id":17,"name":"Card","slug":"card","games_count":4529,"image_background":"https://media.rawg.io/media/games/431/4317e294e88e4c9d77327693b15f499a.jpg","games":[{"id":28121,"slug":"slay-the-spire","name":"Slay the Spire","added":4878},{"id":23557,"slug":"gwent-the-witcher-card-game","name":"Gwent: The Witcher Card Game","added":4792},{"id":18852,"slug":"poker-night-at-the-inventory","name":"Poker Night at the Inventory","added":2739},{"id":332,"slug":"the-elder-scrolls-legends","name":"The Elder Scrolls: Legends","added":2178},{"id":8923,"slug":"faeria","name":"Faeria","added":2159},{"id":2306,"slug":"poker-night-2","name":"Poker Night 2","added":2051}]}]} \ No newline at end of file diff --git a/storage/tests/rawg_domain_platforms.json b/storage/tests/rawg_domain_platforms.json new file mode 100644 index 0000000..1f01bb0 --- /dev/null +++ b/storage/tests/rawg_domain_platforms.json @@ -0,0 +1 @@ +{"count":51,"next":"https://api.rawg.io/api/platforms?key=a8befb20f4194f96a61584b8e1bc8ae1&page=2","previous":null,"results":[{"id":4,"name":"PC","slug":"pc","games_count":535324,"image_background":"https://media.rawg.io/media/games/bc0/bc06a29ceac58652b684deefe7d56099.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":13536,"slug":"portal","name":"Portal","added":16589}]},{"id":187,"name":"PlayStation 5","slug":"playstation5","games_count":1107,"image_background":"https://media.rawg.io/media/games/20a/20aa03a10cda45239fe22d035c0ebe64.jpg","image":null,"year_start":2020,"year_end":null,"games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":32,"slug":"destiny-2","name":"Destiny 2","added":13786},{"id":3070,"slug":"fallout-4","name":"Fallout 4","added":13494},{"id":41494,"slug":"cyberpunk-2077","name":"Cyberpunk 2077","added":12821}]},{"id":1,"name":"Xbox One","slug":"xbox-one","games_count":5638,"image_background":"https://media.rawg.io/media/games/8a0/8a02f84a5916ede2f923b88d5f8217ba.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":28,"slug":"red-dead-redemption-2","name":"Red Dead Redemption 2","added":15552}]},{"id":18,"name":"PlayStation 4","slug":"playstation4","games_count":6825,"image_background":"https://media.rawg.io/media/games/511/5118aff5091cb3efec399c808f8c598f.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":28,"slug":"red-dead-redemption-2","name":"Red Dead Redemption 2","added":15552},{"id":4062,"slug":"bioshock-infinite","name":"BioShock Infinite","added":15359}]},{"id":186,"name":"Xbox Series S/X","slug":"xbox-series-x","games_count":973,"image_background":"https://media.rawg.io/media/games/51c/51c430f1795c79b78f863a9f22dc422d.jpg","image":null,"year_start":2020,"year_end":null,"games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":32,"slug":"destiny-2","name":"Destiny 2","added":13786},{"id":41494,"slug":"cyberpunk-2077","name":"Cyberpunk 2077","added":12821},{"id":766,"slug":"warframe","name":"Warframe","added":12418}]},{"id":7,"name":"Nintendo Switch","slug":"nintendo-switch","games_count":5505,"image_background":"https://media.rawg.io/media/games/4fb/4fb548e4816c84d1d70f1a228fb167cc.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":4062,"slug":"bioshock-infinite","name":"BioShock Infinite","added":15359},{"id":1030,"slug":"limbo","name":"Limbo","added":13486},{"id":2454,"slug":"doom","name":"DOOM (2016)","added":13347}]},{"id":3,"name":"iOS","slug":"ios","games_count":77352,"image_background":"https://media.rawg.io/media/games/713/713269608dc8f2f40f5a670a14b2de94.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3439,"slug":"life-is-strange-episode-1-2","name":"Life is Strange","added":15188},{"id":1030,"slug":"limbo","name":"Limbo","added":13486},{"id":422,"slug":"terraria","name":"Terraria","added":12679},{"id":766,"slug":"warframe","name":"Warframe","added":12418},{"id":416,"slug":"grand-theft-auto-san-andreas","name":"Grand Theft Auto: San Andreas","added":11282},{"id":23027,"slug":"the-walking-dead","name":"The Walking Dead: Season 1","added":11211}]},{"id":21,"name":"Android","slug":"android","games_count":52384,"image_background":"https://media.rawg.io/media/games/35b/35b47c4d85cd6e08f3e2ca43ea5ce7bb.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":3439,"slug":"life-is-strange-episode-1-2","name":"Life is Strange","added":15188},{"id":802,"slug":"borderlands-2","name":"Borderlands 2","added":15182},{"id":13537,"slug":"half-life-2","name":"Half-Life 2","added":14722},{"id":1030,"slug":"limbo","name":"Limbo","added":13486},{"id":422,"slug":"terraria","name":"Terraria","added":12679}]},{"id":8,"name":"Nintendo 3DS","slug":"nintendo-3ds","games_count":1693,"image_background":"https://media.rawg.io/media/screenshots/a5a/a5a8e5820043aa1285643f3d243e5664.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":422,"slug":"terraria","name":"Terraria","added":12679},{"id":22509,"slug":"minecraft","name":"Minecraft","added":8375},{"id":2597,"slug":"lego-lord-of-the-rings","name":"LEGO The Lord of the Rings","added":5527},{"id":250,"slug":"the-binding-of-isaac-rebirth","name":"The Binding of Isaac: Rebirth","added":5452},{"id":3729,"slug":"lego-the-hobbit","name":"LEGO The Hobbit","added":5064},{"id":4012,"slug":"resident-evil-revelations-biohazard-revelations","name":"Resident Evil Revelations","added":4235}]},{"id":9,"name":"Nintendo DS","slug":"nintendo-ds","games_count":2484,"image_background":"https://media.rawg.io/media/games/852/8522935d8ab27b610a254b52de0da212.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":865,"slug":"call-of-duty-black-ops","name":"Call of Duty: Black Ops","added":6670},{"id":3486,"slug":"syberia","name":"Syberia","added":6498},{"id":2597,"slug":"lego-lord-of-the-rings","name":"LEGO The Lord of the Rings","added":5527},{"id":5578,"slug":"grid","name":"GRID (2008)","added":5335},{"id":4869,"slug":"tomb-raider-underworld","name":"Tomb Raider: Underworld","added":4577},{"id":5528,"slug":"call-of-duty-world-at-war","name":"Call of Duty: World at War","added":4416}]},{"id":13,"name":"Nintendo DSi","slug":"nintendo-dsi","games_count":37,"image_background":"https://media.rawg.io/media/games/096/0962642c3f74cd6306ad8bfdfd3d6150.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":19309,"slug":"plants-vs-zombies-goty-edition","name":"Plants vs. Zombies GOTY Edition","added":3725},{"id":949,"slug":"cut-the-rope","name":"Cut the Rope","added":647},{"id":223378,"slug":"ace-attorney-investigations-miles-edgeworth","name":"Ace Attorney Investigations - Miles Edgeworth","added":200},{"id":22727,"slug":"jagged-alliance","name":"Jagged Alliance","added":146},{"id":53802,"slug":"dragons-lair","name":"Dragon's Lair","added":72},{"id":25953,"slug":"mario-vs-donkey-kong-minis-march-again","name":"Mario vs. Donkey Kong: Minis March Again!","added":36}]},{"id":5,"name":"macOS","slug":"macos","games_count":104615,"image_background":"https://media.rawg.io/media/games/6c5/6c55e22185876626881b76c11922b073.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473},{"id":3439,"slug":"life-is-strange-episode-1-2","name":"Life is Strange","added":15188}]},{"id":6,"name":"Linux","slug":"linux","games_count":77502,"image_background":"https://media.rawg.io/media/games/73e/73eecb8909e0c39fb246f457b5d6cbbe.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473},{"id":4062,"slug":"bioshock-infinite","name":"BioShock Infinite","added":15359},{"id":3439,"slug":"life-is-strange-episode-1-2","name":"Life is Strange","added":15188}]},{"id":14,"name":"Xbox 360","slug":"xbox360","games_count":2806,"image_background":"https://media.rawg.io/media/games/157/15742f2f67eacff546738e1ab5c19d20.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473}]},{"id":80,"name":"Xbox","slug":"xbox-old","games_count":739,"image_background":"https://media.rawg.io/media/games/9d4/9d45e22df640fcb6f4b754aa3491ae09.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":13537,"slug":"half-life-2","name":"Half-Life 2","added":14722},{"id":416,"slug":"grand-theft-auto-san-andreas","name":"Grand Theft Auto: San Andreas","added":11282},{"id":430,"slug":"grand-theft-auto-vice-city","name":"Grand Theft Auto: Vice City","added":9291},{"id":19301,"slug":"counter-strike","name":"Counter-Strike","added":8791},{"id":2361,"slug":"psychonauts","name":"Psychonauts","added":8004},{"id":432,"slug":"grand-theft-auto-iii","name":"Grand Theft Auto III","added":6992}]},{"id":16,"name":"PlayStation 3","slug":"playstation3","games_count":3166,"image_background":"https://media.rawg.io/media/games/021/021c4e21a1824d2526f925eff6324653.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907}]},{"id":15,"name":"PlayStation 2","slug":"playstation2","games_count":2040,"image_background":"https://media.rawg.io/media/games/d9b/d9bbb8e69f53c4c42b8ff928cb581548.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":416,"slug":"grand-theft-auto-san-andreas","name":"Grand Theft Auto: San Andreas","added":11282},{"id":18080,"slug":"half-life","name":"Half-Life","added":10610},{"id":430,"slug":"grand-theft-auto-vice-city","name":"Grand Theft Auto: Vice City","added":9291},{"id":2361,"slug":"psychonauts","name":"Psychonauts","added":8004},{"id":432,"slug":"grand-theft-auto-iii","name":"Grand Theft Auto III","added":6992},{"id":56184,"slug":"resident-evil-4","name":"Resident Evil 4 (2005)","added":6633}]},{"id":27,"name":"PlayStation","slug":"playstation1","games_count":1670,"image_background":"https://media.rawg.io/media/screenshots/010/0101f021b2dc123c98969fda7e4bcd92.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":5159,"slug":"resident-evil-2","name":"Resident Evil 2 (1998)","added":6127},{"id":5193,"slug":"oddworld-abes-oddysee","name":"Oddworld: Abe's Oddysee","added":5550},{"id":3449,"slug":"resident-evil","name":"Resident Evil","added":5224},{"id":52939,"slug":"final-fantasy-vii","name":"Final Fantasy VII (1997)","added":4148},{"id":20569,"slug":"ufo-enemy-unknown","name":"X-COM: UFO Defense","added":3538},{"id":57908,"slug":"tomb-raider-ii","name":"Tomb Raider II","added":3133}]},{"id":19,"name":"PS Vita","slug":"ps-vita","games_count":1447,"image_background":"https://media.rawg.io/media/games/926/926928beb8a9f9b31cf202965aa4cbbc.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":802,"slug":"borderlands-2","name":"Borderlands 2","added":15182},{"id":1030,"slug":"limbo","name":"Limbo","added":13486},{"id":422,"slug":"terraria","name":"Terraria","added":12679},{"id":23027,"slug":"the-walking-dead","name":"The Walking Dead: Season 1","added":11211},{"id":3612,"slug":"hotline-miami","name":"Hotline Miami","added":10410},{"id":654,"slug":"stardew-valley","name":"Stardew Valley","added":10094}]},{"id":17,"name":"PSP","slug":"psp","games_count":1371,"image_background":"https://media.rawg.io/media/games/4ad/4ad6ab9cfe8146224330598a4a62fb14.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":13886,"slug":"star-wars-battlefront-ii-2","name":"Star Wars: Battlefront II (2005)","added":4657},{"id":5298,"slug":"tomb-raider-legend","name":"Tomb Raider: Legend","added":4114},{"id":5297,"slug":"tomb-raider-anniversary","name":"Tomb Raider: Anniversary","added":4009},{"id":57908,"slug":"tomb-raider-ii","name":"Tomb Raider II","added":3133},{"id":16543,"slug":"lego-batman","name":"LEGO Batman","added":3050},{"id":58890,"slug":"need-for-speed-most-wanted","name":"Need For Speed: Most Wanted","added":2905}]},{"id":10,"name":"Wii U","slug":"wii-u","games_count":1126,"image_background":"https://media.rawg.io/media/games/eb2/eb24800b4491701eca481e7c990bce4a.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":422,"slug":"terraria","name":"Terraria","added":12679},{"id":3144,"slug":"super-meat-boy","name":"Super Meat Boy","added":9358},{"id":3841,"slug":"assassins-creed-iv-black-flag","name":"Assassin’s Creed IV: Black Flag","added":9264},{"id":22509,"slug":"minecraft","name":"Minecraft","added":8375},{"id":3687,"slug":"watch-dogs","name":"Watch Dogs","added":7999},{"id":3876,"slug":"deus-ex-human-revolution-directors-cut","name":"Deus Ex: Human Revolution - Director's Cut","added":7368}]},{"id":11,"name":"Wii","slug":"wii","games_count":2230,"image_background":"https://media.rawg.io/media/games/e2e/e2eac90903c56886e39d21ac71b958e5.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":865,"slug":"call-of-duty-black-ops","name":"Call of Duty: Black Ops","added":6670},{"id":56184,"slug":"resident-evil-4","name":"Resident Evil 4 (2005)","added":6633},{"id":11276,"slug":"call-of-duty-modern-warfare-3","name":"Call of Duty: Modern Warfare 3","added":6084},{"id":2597,"slug":"lego-lord-of-the-rings","name":"LEGO The Lord of the Rings","added":5527},{"id":4869,"slug":"tomb-raider-underworld","name":"Tomb Raider: Underworld","added":4577},{"id":1140,"slug":"world-of-goo","name":"World of Goo","added":4430}]},{"id":105,"name":"GameCube","slug":"gamecube","games_count":635,"image_background":"https://media.rawg.io/media/games/459/459ac8745027643ed7338516c0025cf7.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":56184,"slug":"resident-evil-4","name":"Resident Evil 4 (2005)","added":6633},{"id":5159,"slug":"resident-evil-2","name":"Resident Evil 2 (1998)","added":6127},{"id":19592,"slug":"hitman-2-silent-assassin","name":"Hitman 2: Silent Assassin","added":4455},{"id":5298,"slug":"tomb-raider-legend","name":"Tomb Raider: Legend","added":4114},{"id":12018,"slug":"star-wars-jedi-knight-ii-jedi-outcast","name":"Star Wars Jedi Knight II: Jedi Outcast","added":3638},{"id":13909,"slug":"prince-of-persia-the-sands-of-time","name":"Prince of Persia: The Sands of Time","added":3300}]},{"id":83,"name":"Nintendo 64","slug":"nintendo-64","games_count":363,"image_background":"https://media.rawg.io/media/screenshots/c1f/c1fd8b15793743563367688b3dd5faa6.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":5159,"slug":"resident-evil-2","name":"Resident Evil 2 (1998)","added":6127},{"id":54491,"slug":"quake","name":"Quake","added":3184},{"id":20466,"slug":"worms-armageddon","name":"Worms Armageddon","added":3022},{"id":54492,"slug":"quake-2","name":"Quake II","added":2489},{"id":25097,"slug":"the-legend-of-zelda-ocarina-of-time","name":"The Legend of Zelda: Ocarina of Time","added":1730},{"id":28532,"slug":"banjo-kazooie","name":"Banjo-Kazooie","added":1543}]},{"id":24,"name":"Game Boy Advance","slug":"game-boy-advance","games_count":954,"image_background":"https://media.rawg.io/media/games/3c8/3c872330c4e9966a5a06c1371525e760.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":53341,"slug":"jet-set-radio-2012","name":"Jet Set Radio","added":5138},{"id":17975,"slug":"doom-ii","name":"DOOM II","added":3192},{"id":19646,"slug":"splinter-cell","name":"Tom Clancy's Splinter Cell","added":2152},{"id":53446,"slug":"need-for-speed-underground-2-2","name":"Need for Speed: Underground 2","added":2089},{"id":4005,"slug":"wolfenstein-3d","name":"Wolfenstein 3D","added":1839},{"id":5265,"slug":"need-for-speed-carbon","name":"Need For Speed Carbon","added":1635}]},{"id":43,"name":"Game Boy Color","slug":"game-boy-color","games_count":419,"image_background":"https://media.rawg.io/media/screenshots/6fe/6fee3969b73bfccd935517c0c15826d8.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":20466,"slug":"worms-armageddon","name":"Worms Armageddon","added":3022},{"id":57607,"slug":"metal-gear-solid-1","name":"Metal Gear Solid","added":2139},{"id":52997,"slug":"grand-theft-auto-2-1999","name":"Grand Theft Auto 2","added":1932},{"id":52998,"slug":"grand-theft-auto-1998","name":"Grand Theft Auto","added":1856},{"id":25080,"slug":"super-mario-bros","name":"Super Mario Bros.","added":1439},{"id":24030,"slug":"super-mario-bros-3","name":"Super Mario Bros. 3","added":1137}]},{"id":26,"name":"Game Boy","slug":"game-boy","games_count":611,"image_background":"https://media.rawg.io/media/games/b21/b21555abc69d04d9b5d7663d478ca81e.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":5383,"slug":"worms","name":"Worms","added":2054},{"id":52998,"slug":"grand-theft-auto-1998","name":"Grand Theft Auto","added":1856},{"id":52175,"slug":"battletoads","name":"Battletoads","added":1822},{"id":54285,"slug":"mortal-kombat","name":"Mortal Kombat","added":1755},{"id":14829,"slug":"turok","name":"Turok: Dinosaur Hunter","added":1359},{"id":23762,"slug":"pokemon-red","name":"Pokémon Red, Blue, Yellow","added":1038}]},{"id":79,"name":"SNES","slug":"snes","games_count":969,"image_background":"https://media.rawg.io/media/games/363/363045c496b712600d0ff2dbbae1394c.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":5383,"slug":"worms","name":"Worms","added":2054},{"id":4005,"slug":"wolfenstein-3d","name":"Wolfenstein 3D","added":1839},{"id":54285,"slug":"mortal-kombat","name":"Mortal Kombat","added":1755},{"id":52884,"slug":"doom-2","name":"DOOM","added":1564},{"id":24899,"slug":"super-mario-world","name":"Super Mario World","added":1473},{"id":1063,"slug":"final-fantasy-vi","name":"FINAL FANTASY VI","added":1466}]},{"id":49,"name":"NES","slug":"nes","games_count":989,"image_background":"https://media.rawg.io/media/games/a75/a75e4cb9742bb172d6bd3deb4cc4109e.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":54122,"slug":"ultima-4-quest-of-the-avatar","name":"Ultima IV: Quest of the Avatar","added":2142},{"id":52175,"slug":"battletoads","name":"Battletoads","added":1822},{"id":25080,"slug":"super-mario-bros","name":"Super Mario Bros.","added":1439},{"id":24030,"slug":"super-mario-bros-3","name":"Super Mario Bros. 3","added":1137},{"id":24881,"slug":"pac-man","name":"Pac-Man","added":796},{"id":53239,"slug":"disneys-aladdin-1993","name":"Disney's Aladdin","added":734}]},{"id":55,"name":"Classic Macintosh","slug":"macintosh","games_count":674,"image_background":"https://media.rawg.io/media/games/dd7/dd72d8a527cd9245c7eb7cd05aa53efa.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":13554,"slug":"fallout-a-post-nuclear-role-playing-game","name":"Fallout","added":8038},{"id":2518,"slug":"max-payne","name":"Max Payne","added":5432},{"id":12018,"slug":"star-wars-jedi-knight-ii-jedi-outcast","name":"Star Wars Jedi Knight II: Jedi Outcast","added":3638},{"id":54491,"slug":"quake","name":"Quake","added":3184},{"id":17975,"slug":"doom-ii","name":"DOOM II","added":3192},{"id":57908,"slug":"tomb-raider-ii","name":"Tomb Raider II","added":3133}]},{"id":41,"name":"Apple II","slug":"apple-ii","games_count":424,"image_background":"https://media.rawg.io/media/screenshots/773/7730495e8fc0fe7e1e747cb9449399ac.jpeg","image":null,"year_start":null,"year_end":null,"games":[{"id":30119,"slug":"wasteland","name":"Wasteland","added":2208},{"id":54122,"slug":"ultima-4-quest-of-the-avatar","name":"Ultima IV: Quest of the Avatar","added":2142},{"id":22991,"slug":"akalabeth-world-of-doom-2","name":"Akalabeth: World of Doom","added":1432},{"id":24881,"slug":"pac-man","name":"Pac-Man","added":796},{"id":29908,"slug":"another-world","name":"Another World","added":795},{"id":51175,"slug":"leisure-suit-larry-1-in-the-land-of-the-lounge-l-2","name":"Leisure Suit Larry 1 - In the Land of the Lounge Lizards","added":697}]},{"id":166,"name":"Commodore / Amiga","slug":"commodore-amiga","games_count":2080,"image_background":"https://media.rawg.io/media/games/e9a/e9a782a3f40f0e53ab64c7018251053e.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":20569,"slug":"ufo-enemy-unknown","name":"X-COM: UFO Defense","added":3538},{"id":54491,"slug":"quake","name":"Quake","added":3184},{"id":22734,"slug":"beneath-a-steel-sky","name":"Beneath a Steel Sky","added":2652},{"id":54492,"slug":"quake-2","name":"Quake II","added":2489},{"id":30119,"slug":"wasteland","name":"Wasteland","added":2208},{"id":54122,"slug":"ultima-4-quest-of-the-avatar","name":"Ultima IV: Quest of the Avatar","added":2142}]},{"id":28,"name":"Atari 7800","slug":"atari-7800","games_count":64,"image_background":"https://media.rawg.io/media/screenshots/565/56504b28b184dbc630a7de118e39d822.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3802,"slug":"double-dragon","name":"Double Dragon","added":467},{"id":52512,"slug":"arcade-archives-donkey-kong","name":"Donkey Kong","added":412},{"id":52434,"slug":"mario-bros-1983","name":"Mario Bros. (1983)","added":306},{"id":28279,"slug":"joust","name":"Joust","added":187},{"id":52513,"slug":"donkey-kong-jr","name":"Donkey Kong Jr.","added":141},{"id":53830,"slug":"galaga-1981","name":"Galaga (1981)","added":135}]},{"id":31,"name":"Atari 5200","slug":"atari-5200","games_count":64,"image_background":"https://media.rawg.io/media/screenshots/678/6786598cba3939d48ed60cbd1a3723f4.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":24881,"slug":"pac-man","name":"Pac-Man","added":796},{"id":52434,"slug":"mario-bros-1983","name":"Mario Bros. (1983)","added":306},{"id":28279,"slug":"joust","name":"Joust","added":187},{"id":52423,"slug":"galaxian","name":"Galaxian","added":165},{"id":52444,"slug":"space-invaders-1978","name":"Space Invaders (1978)","added":146},{"id":52418,"slug":"dig-dug-1982","name":"Dig Dug (1982)","added":129}]},{"id":23,"name":"Atari 2600","slug":"atari-2600","games_count":286,"image_background":"https://media.rawg.io/media/screenshots/b12/b12ed274eed80e4aced37badf228d1cf.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":24881,"slug":"pac-man","name":"Pac-Man","added":796},{"id":52623,"slug":"tetris-1984","name":"Tetris (1984)","added":590},{"id":3802,"slug":"double-dragon","name":"Double Dragon","added":467},{"id":52512,"slug":"arcade-archives-donkey-kong","name":"Donkey Kong","added":412},{"id":52434,"slug":"mario-bros-1983","name":"Mario Bros. (1983)","added":306},{"id":52467,"slug":"track-field","name":"Track & Field","added":209}]},{"id":22,"name":"Atari Flashback","slug":"atari-flashback","games_count":30,"image_background":"https://media.rawg.io/media/screenshots/2aa/2aa07f58491e14b0183333f8956bc802.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":53138,"slug":"pong-1972","name":"Pong (1972)","added":115},{"id":52391,"slug":"adventure-game-atari","name":"Adventure","added":63},{"id":52563,"slug":"pitfall-1982","name":"Pitfall! (1982)","added":58},{"id":52402,"slug":"breakout-1976","name":"Breakout (1976)","added":56},{"id":52436,"slug":"missile-command-1980","name":"Missile Command (1980)","added":50},{"id":52409,"slug":"combat-1977","name":"Combat (1977)","added":42}]},{"id":25,"name":"Atari 8-bit","slug":"atari-8-bit","games_count":308,"image_background":"https://media.rawg.io/media/screenshots/038/0385a47d3a43b218204268af5361a19e.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":54122,"slug":"ultima-4-quest-of-the-avatar","name":"Ultima IV: Quest of the Avatar","added":2142},{"id":24881,"slug":"pac-man","name":"Pac-Man","added":796},{"id":52512,"slug":"arcade-archives-donkey-kong","name":"Donkey Kong","added":412},{"id":52434,"slug":"mario-bros-1983","name":"Mario Bros. (1983)","added":306},{"id":28279,"slug":"joust","name":"Joust","added":187},{"id":25161,"slug":"lode-runner","name":"Lode Runner","added":175}]},{"id":34,"name":"Atari ST","slug":"atari-st","games_count":836,"image_background":"https://media.rawg.io/media/screenshots/241/24188738ed8141b03c767e6bbba28401.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":54122,"slug":"ultima-4-quest-of-the-avatar","name":"Ultima IV: Quest of the Avatar","added":2142},{"id":22733,"slug":"lure-of-the-temptress","name":"Lure of the Temptress","added":1783},{"id":29908,"slug":"another-world","name":"Another World","added":795},{"id":16122,"slug":"loom","name":"LOOM","added":771},{"id":31542,"slug":"indiana-jones-and-the-last-crusade","name":"Indiana Jones and the Last Crusade: The Graphic Adventure","added":744},{"id":51175,"slug":"leisure-suit-larry-1-in-the-land-of-the-lounge-l-2","name":"Leisure Suit Larry 1 - In the Land of the Lounge Lizards","added":697}]},{"id":46,"name":"Atari Lynx","slug":"atari-lynx","games_count":57,"image_background":"https://media.rawg.io/media/screenshots/d71/d71b68d3f6b1810bc9d64d7aea746d30.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":29391,"slug":"eye-of-the-beholder","name":"Eye of the Beholder","added":1130},{"id":3802,"slug":"double-dragon","name":"Double Dragon","added":467},{"id":30501,"slug":"chips-challenge","name":"Chip's Challenge","added":441},{"id":53467,"slug":"paperboy","name":"Paperboy","added":194},{"id":52438,"slug":"ms-pac-man","name":"Ms. Pac-Man","added":105},{"id":53975,"slug":"ninja-gaiden-iii-the-ancient-ship-of-doom","name":"Ninja Gaiden III: The Ancient Ship of Doom (1991)","added":104}]},{"id":50,"name":"Atari XEGS","slug":"atari-xegs","games_count":22,"image_background":"https://media.rawg.io/media/screenshots/769/7691726d70c23c029903df08858df001.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":52512,"slug":"arcade-archives-donkey-kong","name":"Donkey Kong","added":412},{"id":52434,"slug":"mario-bros-1983","name":"Mario Bros. (1983)","added":306},{"id":34571,"slug":"lode-runner-1983","name":"Lode Runner (1983)","added":131},{"id":53687,"slug":"archon-the-light-and-the-dark","name":"Archon: The Light and the Dark","added":19},{"id":52605,"slug":"summer-games","name":"Summer Games","added":21},{"id":52413,"slug":"crossbow","name":"Crossbow","added":14}]},{"id":167,"name":"Genesis","slug":"genesis","games_count":836,"image_background":"https://media.rawg.io/media/screenshots/347/347e1979dcf9b62dc48202b73317a186.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":5383,"slug":"worms","name":"Worms","added":2054},{"id":52175,"slug":"battletoads","name":"Battletoads","added":1822},{"id":54285,"slug":"mortal-kombat","name":"Mortal Kombat","added":1755},{"id":53551,"slug":"sonic-the-hedgehog","name":"Sonic the Hedgehog (1991)","added":1539},{"id":2552,"slug":"sonic-the-hedgehog-2","name":"Sonic the Hedgehog 2","added":1319},{"id":28510,"slug":"duke-nukem-3d","name":"Duke Nukem 3D","added":960}]},{"id":107,"name":"SEGA Saturn","slug":"sega-saturn","games_count":368,"image_background":"https://media.rawg.io/media/screenshots/681/68145c58e234705ed4559a05c043f41a.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":3449,"slug":"resident-evil","name":"Resident Evil","added":5224},{"id":54491,"slug":"quake","name":"Quake","added":3184},{"id":28300,"slug":"nights-into-dreams","name":"NiGHTS into dreams...","added":2627},{"id":5383,"slug":"worms","name":"Worms","added":2054},{"id":52790,"slug":"castlevania-sotn","name":"Castlevania: Symphony of the Night","added":1685},{"id":52884,"slug":"doom-2","name":"DOOM","added":1564}]},{"id":119,"name":"SEGA CD","slug":"sega-cd","games_count":162,"image_background":"https://media.rawg.io/media/games/a9a/a9a2472f862b041d2980103ddbb61c91.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":1559,"slug":"sonic-cd","name":"Sonic CD","added":2274},{"id":54285,"slug":"mortal-kombat","name":"Mortal Kombat","added":1755},{"id":29391,"slug":"eye-of-the-beholder","name":"Eye of the Beholder","added":1130},{"id":25663,"slug":"earthworm-jim","name":"Earthworm Jim","added":781},{"id":45957,"slug":"prince-of-persia-nes","name":"Prince of Persia (1989)","added":641},{"id":4377,"slug":"myst","name":"Myst","added":478}]},{"id":117,"name":"SEGA 32X","slug":"sega-32x","games_count":46,"image_background":"https://media.rawg.io/media/screenshots/d9f/d9f308b5d824ae8f047edc0a998a587b.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":52884,"slug":"doom-2","name":"DOOM","added":1564},{"id":29426,"slug":"mortal-kombat-2","name":"Mortal Kombat 2","added":490},{"id":5463,"slug":"rayman","name":"Rayman","added":319},{"id":53781,"slug":"darkwing-duck","name":"Disney's Darkwing Duck","added":246},{"id":32519,"slug":"wwf-wrestlemania-the-arcade-game","name":"WWF WrestleMania: The Arcade Game","added":143},{"id":53975,"slug":"ninja-gaiden-iii-the-ancient-ship-of-doom","name":"Ninja Gaiden III: The Ancient Ship of Doom (1991)","added":104}]},{"id":74,"name":"SEGA Master System","slug":"sega-master-system","games_count":231,"image_background":"https://media.rawg.io/media/games/a9a/a9a2472f862b041d2980103ddbb61c91.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":54122,"slug":"ultima-4-quest-of-the-avatar","name":"Ultima IV: Quest of the Avatar","added":2142},{"id":54285,"slug":"mortal-kombat","name":"Mortal Kombat","added":1755},{"id":4678,"slug":"streets-of-rage-2","name":"Streets of Rage 2","added":907},{"id":914,"slug":"wonder-boy-the-dragons-trap","name":"Wonder Boy: The Dragon's Trap","added":814},{"id":25663,"slug":"earthworm-jim","name":"Earthworm Jim","added":781},{"id":53207,"slug":"comix-zone-1995","name":"Comix Zone","added":780}]},{"id":106,"name":"Dreamcast","slug":"dreamcast","games_count":363,"image_background":"https://media.rawg.io/media/screenshots/79c/79ccb0bf14ae8a14b582b9e4cb47b3d6.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":18080,"slug":"half-life","name":"Half-Life","added":10610},{"id":19542,"slug":"half-life-blue-shift","name":"Half-Life: Blue Shift","added":6676},{"id":5159,"slug":"resident-evil-2","name":"Resident Evil 2 (1998)","added":6127},{"id":53341,"slug":"jet-set-radio-2012","name":"Jet Set Radio","added":5138},{"id":20466,"slug":"worms-armageddon","name":"Worms Armageddon","added":3022},{"id":54629,"slug":"crazy-taxi","name":"Crazy Taxi (1999)","added":2671}]},{"id":111,"name":"3DO","slug":"3do","games_count":97,"image_background":"https://media.rawg.io/media/screenshots/d8c/d8c399c09701ae2603043a3bb3a0bff5.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":4005,"slug":"wolfenstein-3d","name":"Wolfenstein 3D","added":1839},{"id":52884,"slug":"doom-2","name":"DOOM","added":1564},{"id":29908,"slug":"another-world","name":"Another World","added":795},{"id":53432,"slug":"ultimate-mortal-kombat-3","name":"Ultimate Mortal Kombat 3","added":693},{"id":4377,"slug":"myst","name":"Myst","added":478},{"id":5498,"slug":"lemmings","name":"Lemmings","added":339}]},{"id":112,"name":"Jaguar","slug":"jaguar","games_count":39,"image_background":"https://media.rawg.io/media/screenshots/241/24188738ed8141b03c767e6bbba28401.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":5383,"slug":"worms","name":"Worms","added":2054},{"id":4005,"slug":"wolfenstein-3d","name":"Wolfenstein 3D","added":1839},{"id":52884,"slug":"doom-2","name":"DOOM","added":1564},{"id":29908,"slug":"another-world","name":"Another World","added":795},{"id":4377,"slug":"myst","name":"Myst","added":478},{"id":5463,"slug":"rayman","name":"Rayman","added":319}]},{"id":77,"name":"Game Gear","slug":"game-gear","games_count":223,"image_background":"https://media.rawg.io/media/screenshots/72c/72c8ed772cb73bad06a313551749e8ad.jpg","image":null,"year_start":null,"year_end":null,"games":[{"id":52175,"slug":"battletoads","name":"Battletoads","added":1822},{"id":54285,"slug":"mortal-kombat","name":"Mortal Kombat","added":1755},{"id":53551,"slug":"sonic-the-hedgehog","name":"Sonic the Hedgehog (1991)","added":1539},{"id":2552,"slug":"sonic-the-hedgehog-2","name":"Sonic the Hedgehog 2","added":1319},{"id":4678,"slug":"streets-of-rage-2","name":"Streets of Rage 2","added":907},{"id":24881,"slug":"pac-man","name":"Pac-Man","added":796}]},{"id":12,"name":"Neo Geo","slug":"neogeo","games_count":123,"image_background":"https://media.rawg.io/media/screenshots/4cc/4ccee6c3e367f4dd94d19d4857dfc1c9.jpeg","image":null,"year_start":null,"year_end":null,"games":[{"id":1488,"slug":"metal-slug-3","name":"METAL SLUG 3","added":2607},{"id":14948,"slug":"metal-slug","name":"METAL SLUG","added":1206},{"id":23669,"slug":"the-king-of-fighters-2002","name":"THE KING OF FIGHTERS 2002","added":876},{"id":24881,"slug":"pac-man","name":"Pac-Man","added":796},{"id":6256,"slug":"metal-slug-2","name":"METAL SLUG 2","added":739},{"id":54646,"slug":"garou-mark-of-the-wolves","name":"Garou: Mark of the Wolves","added":292}]}]} \ No newline at end of file diff --git a/storage/tests/rawg_domain_tags.json b/storage/tests/rawg_domain_tags.json new file mode 100644 index 0000000..ec6e960 --- /dev/null +++ b/storage/tests/rawg_domain_tags.json @@ -0,0 +1 @@ +{"count":9673,"next":"https://api.rawg.io/api/tags?key=a8befb20f4194f96a61584b8e1bc8ae1&page=2","previous":null,"results":[{"id":31,"name":"Singleplayer","slug":"singleplayer","games_count":227137,"image_background":"https://media.rawg.io/media/games/587/587588c64afbff80e6f444eb2e46f9da.jpg","language":"eng","games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473}]},{"id":40847,"name":"Steam Achievements","slug":"steam-achievements","games_count":39497,"image_background":"https://media.rawg.io/media/games/d82/d82990b9c67ba0d2d09d4e6fa88885a7.jpg","language":"eng","games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907}]},{"id":7,"name":"Multiplayer","slug":"multiplayer","games_count":38598,"image_background":"https://media.rawg.io/media/games/b7b/b7b8381707152afc7d91f5d95de70e39.jpg","language":"eng","games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473},{"id":28,"slug":"red-dead-redemption-2","name":"Red Dead Redemption 2","added":15552}]},{"id":40836,"name":"Full controller support","slug":"full-controller-support","games_count":18760,"image_background":"https://media.rawg.io/media/games/157/15742f2f67eacff546738e1ab5c19d20.jpg","language":"eng","games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473}]},{"id":40849,"name":"Steam Cloud","slug":"steam-cloud","games_count":18775,"image_background":"https://media.rawg.io/media/games/8cc/8cce7c0e99dcc43d66c8efd42f9d03e3.jpg","language":"eng","games":[{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":4062,"slug":"bioshock-infinite","name":"BioShock Infinite","added":15359},{"id":802,"slug":"borderlands-2","name":"Borderlands 2","added":15182},{"id":13537,"slug":"half-life-2","name":"Half-Life 2","added":14722}]},{"id":13,"name":"Atmospheric","slug":"atmospheric","games_count":34084,"image_background":"https://media.rawg.io/media/games/fc1/fc1307a2774506b5bd65d7e8424664a7.jpg","language":"eng","games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907}]},{"id":7808,"name":"steam-trading-cards","slug":"steam-trading-cards","games_count":7569,"image_background":"https://media.rawg.io/media/games/490/49016e06ae2103881ff6373248843069.jpg","language":"eng","games":[{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":4062,"slug":"bioshock-infinite","name":"BioShock Infinite","added":15359},{"id":3439,"slug":"life-is-strange-episode-1-2","name":"Life is Strange","added":15188}]},{"id":42,"name":"Great Soundtrack","slug":"great-soundtrack","games_count":3406,"image_background":"https://media.rawg.io/media/games/8a0/8a02f84a5916ede2f923b88d5f8217ba.jpg","language":"eng","games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":13536,"slug":"portal","name":"Portal","added":16589},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":28,"slug":"red-dead-redemption-2","name":"Red Dead Redemption 2","added":15552},{"id":4062,"slug":"bioshock-infinite","name":"BioShock Infinite","added":15359}]},{"id":24,"name":"RPG","slug":"rpg","games_count":21742,"image_background":"https://media.rawg.io/media/games/49c/49c3dfa4ce2f6f140cc4825868e858cb.jpg","language":"eng","games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":3328,"slug":"the-witcher-3-wild-hunt","name":"The Witcher 3: Wild Hunt","added":20605},{"id":5286,"slug":"tomb-raider","name":"Tomb Raider (2013)","added":16785},{"id":5679,"slug":"the-elder-scrolls-v-skyrim","name":"The Elder Scrolls V: Skyrim","added":15907},{"id":4062,"slug":"bioshock-infinite","name":"BioShock Infinite","added":15359},{"id":802,"slug":"borderlands-2","name":"Borderlands 2","added":15182}]},{"id":18,"name":"Co-op","slug":"co-op","games_count":11948,"image_background":"https://media.rawg.io/media/games/0bd/0bd5646a3d8ee0ac3314bced91ea306d.jpg","language":"eng","games":[{"id":3498,"slug":"grand-theft-auto-v","name":"Grand Theft Auto V","added":21122},{"id":4200,"slug":"portal-2","name":"Portal 2","added":19489},{"id":4291,"slug":"counter-strike-global-offensive","name":"Counter-Strike: Global Offensive","added":17175},{"id":12020,"slug":"left-4-dead-2","name":"Left 4 Dead 2","added":16473},{"id":28,"slug":"red-dead-redemption-2","name":"Red Dead Redemption 2","added":15552},{"id":802,"slug":"borderlands-2","name":"Borderlands 2","added":15182}]}]} \ No newline at end of file diff --git a/storage/tests/rawg_games.json b/storage/tests/rawg_games.json new file mode 100644 index 0000000..e14dda3 --- /dev/null +++ b/storage/tests/rawg_games.json @@ -0,0 +1 @@ +{"count":5714,"next":"https://api.rawg.io/api/games?dates=2023-08-30%2C2024-08-30&genres=action&key=a8befb20f4194f96a61584b8e1bc8ae1&ordering=updated&page=2&page_size=5&platforms=4%2C187%2C186%2C7","previous":null,"results":[{"slug":"memory-trees-forget-me-not-2","name":"Memory Trees: forget me not","playtime":0,"platforms":[{"platform":{"id":4,"name":"PC","slug":"pc"}}],"stores":[{"store":{"id":1,"name":"Steam","slug":"steam"}}],"released":"2024-02-15","tba":false,"background_image":"https://media.rawg.io/media/screenshots/66e/66e8357d7200a2f33ecb860d0dd8b847.jpg","rating":0.0,"rating_top":0,"ratings":[],"ratings_count":0,"reviews_text_count":0,"added":2,"added_by_status":{"toplay":2},"metacritic":null,"suggestions_count":286,"updated":"2019-08-28T21:47:24","id":302877,"score":null,"clip":null,"tags":[{"id":31,"name":"Singleplayer","slug":"singleplayer","language":"eng","games_count":207792,"image_background":"https://media.rawg.io/media/games/34b/34b1f1850a1c06fd971bc6ab3ac0ce0e.jpg"},{"id":42417,"name":"Экшен","slug":"ekshen","language":"rus","games_count":32980,"image_background":"https://media.rawg.io/media/games/021/021c4e21a1824d2526f925eff6324653.jpg"},{"id":42392,"name":"Приключение","slug":"prikliuchenie","language":"rus","games_count":30905,"image_background":"https://media.rawg.io/media/games/618/618c2031a07bbff6b4f611f10b6bcdbc.jpg"},{"id":42398,"name":"Инди","slug":"indi-2","language":"rus","games_count":46796,"image_background":"https://media.rawg.io/media/games/6a2/6a2e48933245e2cd3c92248c75c925e1.jpg"},{"id":24,"name":"RPG","slug":"rpg","language":"eng","games_count":17681,"image_background":"https://media.rawg.io/media/games/4be/4be6a6ad0364751a96229c56bf69be59.jpg"},{"id":42412,"name":"Ролевая игра","slug":"rolevaia-igra","language":"rus","games_count":14090,"image_background":"https://media.rawg.io/media/games/e6d/e6de699bd788497f4b52e2f41f9698f2.jpg"},{"id":40845,"name":"Partial Controller Support","slug":"partial-controller-support","language":"eng","games_count":10024,"image_background":"https://media.rawg.io/media/games/4a0/4a0a1316102366260e6f38fd2a9cfdce.jpg"},{"id":42413,"name":"Симулятор","slug":"simuliator","language":"rus","games_count":15533,"image_background":"https://media.rawg.io/media/games/c22/c22d804ac753c72f2617b3708a625dec.jpg"},{"id":79,"name":"Free to Play","slug":"free-to-play","language":"eng","games_count":5566,"image_background":"https://media.rawg.io/media/games/58a/58ac7f6569259dcc0b60b921869b19fc.jpg"},{"id":42538,"name":"Бесплатная игра","slug":"besplatnaia-igra","language":"rus","games_count":5558,"image_background":"https://media.rawg.io/media/games/ec3/ec3a7db7b8ab5a71aad622fe7c62632f.jpg"},{"id":42406,"name":"Нагота","slug":"nagota","language":"rus","games_count":4749,"image_background":"https://media.rawg.io/media/games/26d/26d4437715bee60138dab4a7c8c59c92.jpg"},{"id":44,"name":"Nudity","slug":"nudity","language":"eng","games_count":5134,"image_background":"https://media.rawg.io/media/games/0af/0af85e8edddfa55368e47c539914a220.jpg"},{"id":42405,"name":"Сексуальный контент","slug":"seksualnyi-kontent","language":"rus","games_count":4742,"image_background":"https://media.rawg.io/media/games/eb5/eb514db62d397c64288160d5bd8fd67a.jpg"},{"id":50,"name":"Sexual Content","slug":"sexual-content","language":"eng","games_count":4764,"image_background":"https://media.rawg.io/media/games/d9e/d9e868382c48ec98c9b23b8fbe6a2045.jpg"}],"esrb_rating":{"id":5,"name":"Adults Only","slug":"adults-only","name_en":"Adults Only","name_ru":"Только для взрослых"},"user_game":null,"reviews_count":0,"community_rating":0,"saturated_color":"0f0f0f","dominant_color":"0f0f0f","short_screenshots":[{"id":-1,"image":"https://media.rawg.io/media/screenshots/66e/66e8357d7200a2f33ecb860d0dd8b847.jpg"},{"id":1873106,"image":"https://media.rawg.io/media/screenshots/61a/61a7993257a093463393ee7d420cf4c1.jpg"},{"id":1873107,"image":"https://media.rawg.io/media/screenshots/a8e/a8ee52cb36959bac08353be6642afb93.jpg"},{"id":1873108,"image":"https://media.rawg.io/media/screenshots/8dd/8dd1f381779b3f9b5185fb897820cdcf.jpg"},{"id":1873109,"image":"https://media.rawg.io/media/screenshots/f33/f330bad874131c5aa549e26613f11d11.jpg"},{"id":1873110,"image":"https://media.rawg.io/media/screenshots/66e/66e8357d7200a2f33ecb860d0dd8b847.jpg"}],"parent_platforms":[{"platform":{"id":1,"name":"PC","slug":"pc"}}],"genres":[{"id":3,"name":"Adventure","slug":"adventure"},{"id":4,"name":"Action","slug":"action"},{"id":5,"name":"RPG","slug":"role-playing-games-rpg"},{"id":14,"name":"Simulation","slug":"simulation"},{"id":51,"name":"Indie","slug":"indie"}]},{"slug":"rat-race-2","name":"RAT RACE","playtime":0,"platforms":[{"platform":{"id":4,"name":"PC","slug":"pc"}},{"platform":{"id":6,"name":"Linux","slug":"linux"}}],"stores":[{"store":{"id":1,"name":"Steam","slug":"steam"}}],"released":"2024-02-02","tba":true,"background_image":"https://media.rawg.io/media/screenshots/8f7/8f7a69d64cccc6488def83cf5ddb2774.jpg","rating":0.0,"rating_top":0,"ratings":[],"ratings_count":0,"reviews_text_count":0,"added":1,"added_by_status":{"owned":1},"metacritic":null,"suggestions_count":0,"updated":"2020-07-31T02:19:22","id":473530,"score":null,"clip":null,"tags":[],"esrb_rating":null,"user_game":null,"reviews_count":0,"community_rating":0,"saturated_color":"0f0f0f","dominant_color":"0f0f0f","short_screenshots":[],"parent_platforms":[{"platform":{"id":1,"name":"PC","slug":"pc"}},{"platform":{"id":6,"name":"Linux","slug":"linux"}}],"genres":[{"id":11,"name":"Arcade","slug":"arcade"},{"id":4,"name":"Action","slug":"action"}]},{"slug":"unbeatable-arcade-mix","name":"UNBEATABLE: ARCADE MIX","playtime":0,"platforms":[{"platform":{"id":4,"name":"PC","slug":"pc"}}],"stores":[{"store":{"id":9,"name":"itch.io","slug":"itch"}}],"released":"2023-12-30","tba":true,"background_image":"https://media.rawg.io/media/screenshots/b05/b05531b415e12177769ae07117813981.jpg","rating":0.0,"rating_top":0,"ratings":[],"ratings_count":0,"reviews_text_count":0,"added":4,"added_by_status":{"toplay":4},"metacritic":null,"suggestions_count":166,"updated":"2021-06-11T05:28:04","id":583306,"score":null,"clip":null,"tags":[{"id":45,"name":"2D","slug":"2d","language":"eng","games_count":195697,"image_background":"https://media.rawg.io/media/games/d1a/d1a1202a378607b6c635c8f18ace95dd.jpg"},{"id":136,"name":"Music","slug":"music","language":"eng","games_count":25991,"image_background":"https://media.rawg.io/media/games/baf/baf9905270314e07e6850cffdb51df41.jpg"},{"id":96,"name":"Kickstarter","slug":"kickstarter","language":"eng","games_count":592,"image_background":"https://media.rawg.io/media/games/87a/87a29bcc56b6b6082ead1dd5e2510aaa.jpg"},{"id":63595,"name":"rhythm-adventure","slug":"rhythm-adventure","language":"eng","games_count":1,"image_background":"https://media.rawg.io/media/screenshots/b05/b05531b415e12177769ae07117813981.jpg"}],"esrb_rating":null,"user_game":null,"reviews_count":0,"community_rating":0,"saturated_color":"0f0f0f","dominant_color":"0f0f0f","short_screenshots":[{"id":-1,"image":"https://media.rawg.io/media/screenshots/b05/b05531b415e12177769ae07117813981.jpg"},{"id":2788952,"image":"https://media.rawg.io/media/screenshots/ad7/ad7fb3d149176ee8e7edc085e733f67f.jpg"},{"id":2788953,"image":"https://media.rawg.io/media/screenshots/39e/39e639ce23bdfa65c10d92070053a6b0.jpg"},{"id":2788954,"image":"https://media.rawg.io/media/screenshots/9ed/9ed50679b7997f4dbdbeb45cb7f218b9.jpg"},{"id":2788955,"image":"https://media.rawg.io/media/screenshots/150/15026a3715a117de70cf4e2afc608d15.jpg"},{"id":2788956,"image":"https://media.rawg.io/media/screenshots/b05/b05531b415e12177769ae07117813981.jpg"}],"parent_platforms":[{"platform":{"id":1,"name":"PC","slug":"pc"}}],"genres":[{"id":4,"name":"Action","slug":"action"}]},{"slug":"redemption-of-the-damned","name":"Redemption of the Damned","playtime":0,"platforms":[{"platform":{"id":4,"name":"PC","slug":"pc"}},{"platform":{"id":18,"name":"PlayStation 4","slug":"playstation4"}}],"stores":[{"store":{"id":1,"name":"Steam","slug":"steam"}}],"released":"2023-12-01","tba":false,"background_image":"https://media.rawg.io/media/screenshots/67c/67c833faa386a748f3f27115fadd8a9c.jpg","rating":0.0,"rating_top":0,"ratings":[],"ratings_count":0,"reviews_text_count":0,"added":3,"added_by_status":{"owned":1,"toplay":2},"metacritic":null,"suggestions_count":235,"updated":"2022-07-14T11:58:52","id":816341,"score":null,"clip":null,"tags":[{"id":24,"name":"RPG","slug":"rpg","language":"eng","games_count":19144,"image_background":"https://media.rawg.io/media/games/15c/15c95a4915f88a3e89c821526afe05fc.jpg"},{"id":16,"name":"Horror","slug":"horror","language":"eng","games_count":43052,"image_background":"https://media.rawg.io/media/games/b54/b54598d1d5cc31899f4f0a7e3122a7b0.jpg"},{"id":1,"name":"Survival","slug":"survival","language":"eng","games_count":7943,"image_background":"https://media.rawg.io/media/games/9c4/9c47f320eb73c9a02d462e12f6206b26.jpg"},{"id":259,"name":"Metroidvania","slug":"metroidvania","language":"eng","games_count":4183,"image_background":"https://media.rawg.io/media/screenshots/2ff/2ffe731b3388bd795f94c38e9afed60f.jpg"},{"id":54225,"name":"resident evil","slug":"resident-evil-2","language":"eng","games_count":2,"image_background":"https://media.rawg.io/media/screenshots/67c/67c833faa386a748f3f27115fadd8a9c.jpg"},{"id":2221,"name":"diablo","slug":"diablo","language":"eng","games_count":30,"image_background":"https://media.rawg.io/media/screenshots/a98/a9806d05019784b2213485381a8c4950.jpg"}],"esrb_rating":null,"user_game":null,"reviews_count":0,"community_rating":0,"saturated_color":"0f0f0f","dominant_color":"0f0f0f","short_screenshots":[{"id":-1,"image":"https://media.rawg.io/media/screenshots/67c/67c833faa386a748f3f27115fadd8a9c.jpg"},{"id":3462666,"image":"https://media.rawg.io/media/screenshots/43f/43f118c01b735a94cd457693c5156df7.jpg"},{"id":3462667,"image":"https://media.rawg.io/media/screenshots/7f2/7f2581dca03bbdc5ce878964f9eda737.jpg"},{"id":3462668,"image":"https://media.rawg.io/media/screenshots/374/37471d5fff418eee33c3fa8ef3cd59e6.jpg"},{"id":3462669,"image":"https://media.rawg.io/media/screenshots/67c/67c833faa386a748f3f27115fadd8a9c.jpg"}],"parent_platforms":[{"platform":{"id":1,"name":"PC","slug":"pc"}},{"platform":{"id":2,"name":"PlayStation","slug":"playstation"}}],"genres":[{"id":2,"name":"Shooter","slug":"shooter"},{"id":4,"name":"Action","slug":"action"},{"id":6,"name":"Fighting","slug":"fighting"}]},{"slug":"unknown-carnivores-dinosaur-hunt-sequel","name":"Unknown Carnivores Dinosaur Hunt Sequel","playtime":0,"platforms":[{"platform":{"id":4,"name":"PC","slug":"pc"}}],"stores":null,"released":"2023-09-01","tba":false,"background_image":null,"rating":0.0,"rating_top":0,"ratings":[],"ratings_count":0,"reviews_text_count":0,"added":0,"added_by_status":null,"metacritic":null,"suggestions_count":0,"updated":"2022-07-24T10:12:28","id":825322,"score":null,"clip":null,"tags":null,"esrb_rating":null,"user_game":null,"reviews_count":0,"community_rating":0,"saturated_color":"0f0f0f","dominant_color":"0f0f0f","short_screenshots":null,"parent_platforms":[{"platform":{"id":1,"name":"PC","slug":"pc"}}],"genres":[{"id":51,"name":"Indie","slug":"indie"},{"id":4,"name":"Action","slug":"action"}]}],"user_platforms":false} \ No newline at end of file diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100755 index 8364a84..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/tests/Feature/Http/Controllers/AccountControllerTest.php b/tests/Feature/Http/Controllers/AccountControllerTest.php new file mode 100644 index 0000000..7527b4b --- /dev/null +++ b/tests/Feature/Http/Controllers/AccountControllerTest.php @@ -0,0 +1,108 @@ +user = $this->createUser(); + } + + private function getUserJsonStructure(): array + { + return [ + 'id', + 'name', + 'email', + 'scopes', + 'created_at', + 'updated_at', + 'settings' + ]; + } + + public function test_should_return_authenticated_user() + { + $response = $this->actingAs($this->user)->get('/api/account/show'); + + $response->assertStatus(200); + $response->assertJsonStructure( + $this->getUserJsonStructure() + ); + + $response->assertJsonFragment([ + 'id' => $this->user->id + ]); + } + + public function test_should_register_user() + { + $user = $this->createRootUser(); + $response = $this->actingAs($user)->post('/api/account/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure( + $this->getUserJsonStructure() + ); + } + + public function test_should_update_user() + { + $response = $this->actingAs($this->user)->put('/api/account/update', [ + 'name' => 'Test User Updated', + ]); + + $response->assertStatus(200); + $response->assertJsonStructure( + $this->getUserJsonStructure() + ); + + $response->assertJson([ + 'name' => 'Test User Updated' + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $this->user->id, + 'name' => 'Test User Updated' + ]); + } + + public function test_should_updated_user_settings() + { + $platforms = [Platform::PC->value]; + $response = $this->actingAs($this->user)->put('/api/account/settings', [ + 'platforms' => $platforms + ]); + + $response->assertStatus(200); + $response->assertJsonStructure( + $this->getUserJsonStructure() + ); + + $response->assertJson([ + 'settings' => [ + 'platforms' => $platforms + ] + ]); + + $this->assertDatabaseHas('user_settings', [ + 'user_id' => $this->user->id, + 'platforms' => json_encode([Platform::PC->value]) + ]); + } +} diff --git a/tests/Feature/Http/Controllers/AuthControllerTest.php b/tests/Feature/Http/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..c616f2d --- /dev/null +++ b/tests/Feature/Http/Controllers/AuthControllerTest.php @@ -0,0 +1,45 @@ +faker->password(8, 12)); + } + + public function test_should_login() + { + $password = $this->faker->password(8, 12); + $user = $this->createUser($password); + + $response = $this->postJson('/api/auth/login', [ + 'email' => $user->email, + 'password' => $password + ]); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'token', + 'expires_at' + ]); + } + + public function test_should_not_login() + { + $response = $this->postJson('/api/auth/login', [ + 'email' => $this->faker->email(), + 'password' => $this->faker->password(8, 12) + ]); + + $response->assertStatus(401); + } +} diff --git a/tests/Feature/Http/Controllers/RawgDomainControllerTest.php b/tests/Feature/Http/Controllers/RawgDomainControllerTest.php new file mode 100644 index 0000000..e1e2665 --- /dev/null +++ b/tests/Feature/Http/Controllers/RawgDomainControllerTest.php @@ -0,0 +1,67 @@ +user = $this->createRootUser(); + } + + private function getCommonJsonStructure(): array + { + return [ + '*' => [ + 'id', + 'name', + 'slug', + 'games_count', + 'image_background' + ] + ]; + } + + public function test_should_return_genres() + { + $clientMock = $this->createClientMock('rawg_domain_genres.json'); + $this->app->offsetSet(Client::class, $clientMock); + + $res = $this->actingAs($this->user)->get('/api/rawg/domain/genres'); + + $res->assertStatus(200); + $res->assertJsonStructure($this->getCommonJsonStructure()); + } + + public function test_should_return_tags() + { + $clientMock = $this->createClientMock('rawg_domain_tags.json'); + $this->app->offsetSet(Client::class, $clientMock); + + $res = $this->actingAs($this->user)->get('/api/rawg/domain/tags'); + + $res->assertStatus(200); + $res->assertJsonStructure($this->getCommonJsonStructure()); + } + + public function test_should_return_platforms() + { + $clientMock = $this->createClientMock('rawg_domain_platforms.json'); + $this->app->offsetSet(Client::class, $clientMock); + + $res = $this->actingAs($this->user)->get('/api/rawg/domain/platforms'); + + $res->assertStatus(200); + $res->assertJsonStructure($this->getCommonJsonStructure()); + } +} diff --git a/tests/Feature/Http/Controllers/RawgGamesControllerTest.php b/tests/Feature/Http/Controllers/RawgGamesControllerTest.php new file mode 100644 index 0000000..a953db0 --- /dev/null +++ b/tests/Feature/Http/Controllers/RawgGamesControllerTest.php @@ -0,0 +1,103 @@ +user = $this->createRootUser(); + } + + private function getGamesJsonStructure(): array + { + return [ + 'total', + 'page_size', + 'current_page', + 'last_page', + 'next_page_url', + 'prev_page_url', + 'data' => [ + '*' => [ + 'id', + 'name', + 'background_image', + 'released', + 'platforms', + 'stores', + 'genres' + ] + ] + ]; + } + + public function test_should_return_recommendations() + { + $clientMock = $this->createClientMock('rawg_games.json'); + $this->app->offsetSet(Client::class, $clientMock); + + $res = $this->actingAs($this->user) + ->get('/api/rawg/games/recommendations/action?platforms=pc,playstation5'); + + $res->assertStatus(200); + $res->assertJsonStructure($this->getGamesJsonStructure()); + } + + public function test_should_return_validation_error() + { + $clientMock = $this->createClientMock('rawg_games.json'); + $this->app->offsetSet(Client::class, $clientMock); + + $res = $this->actingAs($this->user) + ->get('/api/rawg/games/recommendations/action?platforms=' . $this->faker->word()); + + $res->assertStatus(422); + $res->assertJsonStructure([ + 'message', + 'errors' => [ + 'platforms' + ] + ]); + } + + public function test_should_return_upcoming_releases() + { + $clientMock = $this->createClientMock('rawg_games.json'); + $this->app->offsetSet(Client::class, $clientMock); + + $res = $this->actingAs($this->user)->get('/api/rawg/games/upcoming-releases/next-7-days'); + + $res->assertStatus(200); + $res->assertJsonStructure($this->getGamesJsonStructure()); + } + + public function test_should_return_achievements() + { + $clientMock = $this->createClientMock('rawg_achievements.json'); + $this->app->offsetSet(Client::class, $clientMock); + + $res = $this->actingAs($this->user)->get('/api/rawg/games/' . $this->faker->name . '/achievements'); + + $res->assertStatus(200); + $res->assertJsonStructure([ + '*' => [ + 'id', + 'name', + 'description', + 'image', + 'percent' + ] + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..bbf14d9 100755 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,94 @@ namespace Tests; +use App\Enums\Scope; +use App\Models\Game; +use App\Models\User; +use Faker\Factory; +use Faker\Generator; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Illuminate\Support\Collection; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Stream; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Redis; +use Mockery; abstract class TestCase extends BaseTestCase { - // + protected Generator $faker; + + protected function setUp(): void + { + parent::setUp(); + $this->faker = Factory::create(); + } + + protected function createUser(?string $password = null) + { + $data = !empty($password) + ? ['password' => bcrypt($password)] + : []; + + return User::factory() + ->hasSettings(1) + ->create($data) + ->load('settings'); + } + + protected function createRootUser() + { + return User::factory()->hasSettings(1)->create([ + 'scopes' => Scope::values() + ])->load('settings'); + } + + protected function createGameCollection(int $total): Collection + { + $games = []; + + for ($i = 0; $i < $total; $i++) { + $games[] = Game::factory()->make(); + } + + return collect($games); + } + + protected function prepRawgForUnitTesting(): void + { + Config::set('services.rawg.host', $this->faker->url()); + Config::set('services.rawg.api_key', $this->faker->password(8, 12)); + + $this->mockRedis(); + } + + protected function mockRedis(): void + { + Redis::shouldReceive('get') + ->once() + ->andReturn(null); + + Redis::shouldReceive('setEx') + ->once() + ->andReturn(true); + } + + protected function createClientMock(string $file) + { + $contents = file_get_contents( + storage_path("tests/$file") + ); + + $body = Mockery::mock(Stream::class)->makePartial(); + $body->shouldReceive('getContents')->andReturn($contents); + + $response = Mockery::mock(Response::class)->makePartial(); + $response->shouldReceive('getBody')->andReturn($body); + + $client = Mockery::mock(Client::class)->makePartial(); + $client->shouldReceive('request')->andReturn($response); + + return $client; + } } diff --git a/tests/Unit/Console/Commands/CreateRootTest.php b/tests/Unit/Console/Commands/CreateRootTest.php new file mode 100644 index 0000000..55c2498 --- /dev/null +++ b/tests/Unit/Console/Commands/CreateRootTest.php @@ -0,0 +1,23 @@ +assertDatabaseHas('users', [ + 'name' => config('auth.root.name'), + 'email' => config('auth.root.email'), + ]); + } +} diff --git a/tests/Unit/Enums/EnumTestCase.php b/tests/Unit/Enums/EnumTestCase.php new file mode 100644 index 0000000..804cb8f --- /dev/null +++ b/tests/Unit/Enums/EnumTestCase.php @@ -0,0 +1,65 @@ +enumerator, 'cases']); + } + + protected function getNames(): array + { + return array_column($this->getCases(), 'name'); + } + + protected function getValues(): array + { + return array_column($this->getCases(), 'value'); + } + + public function test_names() + { + $this->assertEquals( + $this->getNames(), + call_user_func([$this->enumerator, 'names']) + ); + } + + public function test_values() + { + $this->assertEquals( + $this->getValues(), + call_user_func([$this->enumerator, 'values']) + ); + } + + public function test_array() + { + $this->assertEquals( + array_combine($this->getNames(), $this->getValues()), + call_user_func([$this->enumerator, 'array']) + ); + } + + public function test_names_as_string() + { + $this->assertEquals( + implode(',', $this->getNames()), + call_user_func([$this->enumerator, 'namesAsString']) + ); + } + + public function test_values_as_string() + { + $this->assertEquals( + implode(',', $this->getValues()), + call_user_func([$this->enumerator, 'valuesAsString']) + ); + } +} diff --git a/tests/Unit/Enums/FrequencyTest.php b/tests/Unit/Enums/FrequencyTest.php new file mode 100644 index 0000000..ee99f91 --- /dev/null +++ b/tests/Unit/Enums/FrequencyTest.php @@ -0,0 +1,10 @@ +assertTrue(true); - } -} diff --git a/tests/Unit/Exceptions/InvalidCredentialsExceptionTest.php b/tests/Unit/Exceptions/InvalidCredentialsExceptionTest.php new file mode 100644 index 0000000..1adf5b1 --- /dev/null +++ b/tests/Unit/Exceptions/InvalidCredentialsExceptionTest.php @@ -0,0 +1,20 @@ +expectException(InvalidCredentialsException::class); + $this->expectExceptionMessage('The provided credentials do not match our records.'); + $this->assertEquals($exception->getStatusCode(), 401); + + throw $exception; + } +} diff --git a/tests/Unit/Exceptions/InvalidScopeExceptionTest.php b/tests/Unit/Exceptions/InvalidScopeExceptionTest.php new file mode 100644 index 0000000..0303f7f --- /dev/null +++ b/tests/Unit/Exceptions/InvalidScopeExceptionTest.php @@ -0,0 +1,20 @@ +expectException(InvalidScopeException::class); + $this->expectExceptionMessage('You don\'t have the required scope to access this route.'); + $this->assertEquals($exception->getStatusCode(), 401); + + throw $exception; + } +} diff --git a/tests/Unit/Guards/JwtGuardTest.php b/tests/Unit/Guards/JwtGuardTest.php new file mode 100644 index 0000000..543691b --- /dev/null +++ b/tests/Unit/Guards/JwtGuardTest.php @@ -0,0 +1,188 @@ +guard = new JwtGuard( + Auth::createUserProvider('users'), + request() + ); + } + + public function test_attempt_should_succeed() + { + $password = $this->faker->password(8, 12); + $user = $this->createUser($password); + + $this->assertTrue( + $this->guard->attempt([ + 'email' => $user->email, + 'password' => $password + ]) + ); + } + + public function test_attempt_should_fail() + { + $user = $this->createUser(); + + $this->assertFalse( + $this->guard->attempt([ + 'email' => $user->email, + 'password' => $this->faker->password(8, 12) + ]) + ); + } + + public function test_check_should_return_true() + { + $user = $this->createUser(); + $this->guard->setUser($user); + + $this->assertTrue( + $this->guard->check() + ); + } + + public function test_check_should_return_false() + { + $this->assertFalse( + $this->guard->check() + ); + } + + public function test_guest_should_return_true() + { + $this->assertTrue( + $this->guard->guest() + ); + } + + public function test_guest_should_return_false() + { + $user = $this->createUser(); + $this->guard->setUser($user); + + $this->assertFalse( + $this->guard->guest() + ); + } + + public function test_user_should_return_null() + { + $this->assertNull( + $this->guard->user() + ); + } + + public function test_user_should_return_user() + { + $user = $this->createUser(); + $this->guard->setUser($user); + + $this->assertEquals( + $user->toArray(), + $this->guard->user()->toArray() + ); + } + + public function test_user_should_return_user_using_jwt() + { + $password = $this->faker->password(8, 12); + $user = $this->createUser($password); + + $jwt = resolve(AuthService::class)->generateJWT([ + 'email' => $user->email, + 'password' => $password + ]); + + $request = Mockery::mock(\Illuminate\Http\Request::class)->makePartial(); + $request->shouldReceive('bearerToken') + ->andReturn($jwt['token']); + + $guard = new JwtGuard( + Auth::createUserProvider('users'), + $request + ); + + $this->assertEquals( + $user->toArray(), + $guard->user()->toArray() + ); + } + + public function test_user_should_return_null_invalid_jwt() + { + $request = Mockery::mock(\Illuminate\Http\Request::class)->makePartial(); + $request->shouldReceive('bearerToken') + ->andReturn($this->faker->text(12)); + + $guard = new JwtGuard( + Auth::createUserProvider('users'), + $request + ); + + $this->assertNull( + $guard->user() + ); + } + + public function test_id_should_return_null() + { + $this->assertNull( + $this->guard->id() + ); + } + + public function test_should_set_user() + { + $user = $this->createUser(); + $this->guard->setUser($user); + + $this->assertEquals( + $user->id, + $this->guard->user()->id + ); + } + + public function test_validate_should_return_false() + { + $this->assertFalse( + $this->guard->validate([]) + ); + } + + public function test_has_user_should_return_false() + { + $this->assertFalse( + $this->guard->hasUser() + ); + } + + public function test_has_user_should_return_true() + { + $user = $this->createUser(); + $this->guard->setUser($user); + + $this->assertTrue( + $this->guard->hasUser() + ); + } +} diff --git a/tests/Unit/Models/GameTest.php b/tests/Unit/Models/GameTest.php new file mode 100644 index 0000000..cbdbc7c --- /dev/null +++ b/tests/Unit/Models/GameTest.php @@ -0,0 +1,42 @@ +make(); + $this->assertInstanceOf(Game::class, $game); + } + + #[DataProvider('provider_required_fields')] + public function test_should_validate_required_fields(string $field) + { + $payload = (new GameFactory())->definition(); + Arr::forget($payload, $field); + + $this->expectException(ValidationException::class); + + new Game($payload); + } + + public static function provider_required_fields(): array + { + return [ + ['id'], + ['name'], + ['slug'], + ['platforms.0.id'], + ['platforms.0.name'], + ['platforms.0.slug'], + ]; + } +} diff --git a/tests/Unit/Models/PaginatedResponseTest.php b/tests/Unit/Models/PaginatedResponseTest.php new file mode 100644 index 0000000..c002827 --- /dev/null +++ b/tests/Unit/Models/PaginatedResponseTest.php @@ -0,0 +1,53 @@ +createGameCollection($total); + + $url = url()->current(); + $nextPageUrl = $prevPageUrl = ''; + + if ($currentPage < $lastPage) { + $nextPageUrl = $url . '?' . http_build_query(['page' => $currentPage + 1]); + } + + if ($currentPage > 1) { + $prevPageUrl = $url . '?' . http_build_query(['page' => $currentPage - 1]); + } + + $paginatedResponse = new PaginatedResponse($games, $pageSize, $currentPage, $total); + $contents = $paginatedResponse->getContents(); + + $this->assertEquals($total, $contents['total']); + $this->assertEquals($pageSize, $contents['page_size']); + $this->assertEquals($currentPage, $contents['current_page']); + $this->assertEquals($lastPage, $contents['last_page']); + $this->assertEquals($nextPageUrl, $contents['next_page_url']); + $this->assertEquals($prevPageUrl, $contents['prev_page_url']); + $this->assertEquals($games, $contents['data']); + } + + public static function provider_contents_settings(): array + { + return [ + [10, 10, 1], + [22, 10, 1], + [22, 10, 2], + [22, 10, 3], + [22, 10, 4] + ]; + } +} diff --git a/tests/Unit/Models/UserSettingTest.php b/tests/Unit/Models/UserSettingTest.php new file mode 100644 index 0000000..fdd40fe --- /dev/null +++ b/tests/Unit/Models/UserSettingTest.php @@ -0,0 +1,21 @@ +createUser(); + $settings = UserSetting::first(); + + $this->assertInstanceOf(User::class, $settings->user); + } +} diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php new file mode 100644 index 0000000..eda5f9d --- /dev/null +++ b/tests/Unit/Models/UserTest.php @@ -0,0 +1,32 @@ +create([ + 'scopes' => $scopes + ]); + + $this->assertEquals($expected, $user->isRoot()); + } + + public static function provider_scopes(): array + { + return [ + [Scope::values(), true], + [[Scope::Default->value], false], + ]; + } +} diff --git a/tests/Unit/Repositories/UserRepositoryTest.php b/tests/Unit/Repositories/UserRepositoryTest.php new file mode 100644 index 0000000..5fac23c --- /dev/null +++ b/tests/Unit/Repositories/UserRepositoryTest.php @@ -0,0 +1,96 @@ +repository = resolve(UserRepository::class); + } + + public function test_should_create_user(): void + { + $user = $this->repository->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + ]); + + $this->assertInstanceOf(User::class, $user); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'scopes' => json_encode([Scope::Default->value]) + ]); + + $this->assertDatabaseHas('user_settings', [ + 'user_id' => $user->id, + 'platforms' => json_encode(Platform::values()), + 'genres' => json_encode(RawgGenre::values()), + 'period' => Period::Next_30_Days->value, + 'frequency' => Frequency::Monthly->value + ]); + } + + public function test_should_not_create_more_than_one_root_user() + { + $result1 = $this->repository->createRoot(); + $result2 = $this->repository->createRoot(); + + $this->assertTrue($result1); + $this->assertFalse($result2); + + $this->assertDatabaseCount('users', 1); + } + + public function test_should_update_user(): void + { + $user = $this->createUser(); + + $result = $this->repository->update($user, [ + 'name' => 'Updated User', + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'name' => 'Updated User', + ]); + + $this->assertInstanceOf(User::class, $result); + } + + public function test_should_update_user_settings() + { + $user = $this->createUser(); + $platforms = [Platform::PC->value]; + + $result = $this->repository->updateSettings($user, [ + 'platforms' => $platforms + ]); + + $this->assertDatabaseHas('user_settings', [ + 'user_id' => $user->id, + 'platforms' => json_encode($platforms) + ]); + + $this->assertInstanceOf(User::class, $result); + } +} diff --git a/tests/Unit/Services/AuthServiceTest.php b/tests/Unit/Services/AuthServiceTest.php new file mode 100644 index 0000000..c4d0081 --- /dev/null +++ b/tests/Unit/Services/AuthServiceTest.php @@ -0,0 +1,131 @@ +service = resolve(AuthService::class); + Config::set('app.key', $this->faker->password(8, 12)); + } + + public function test_should_generate_jwt() + { + $password = $this->faker->password(8, 12); + $user = $this->createUser($password); + + $jwt = $this->service->generateJWT([ + 'email' => $user->email, + 'password' => $password + ]); + + $this->assertArrayHasKey('token', $jwt); + $this->assertArrayHasKey('expires_at', $jwt); + } + + public function test_should_throw_invalid_credentials_exception() + { + $password = $this->faker->password(8, 12); + $user = $this->createUser($password); + + $this->expectException(InvalidCredentialsException::class); + + $this->service->generateJWT([ + 'email' => $user->email, + 'password' => $this->faker->password(8, 12) + ]); + } + + public function test_should_decode_jwt() + { + $password = $this->faker->password(8, 12); + $user = $this->createUser($password); + + $jwt = $this->service->generateJWT([ + 'email' => $user->email, + 'password' => $password + ]); + + $decoded = $this->service->decodeJWT($jwt['token']); + + $this->assertEquals($user->id, $decoded->sub); + } + + public function test_should_throw_invalid_signature_exception() + { + $token = JWT::encode([ + 'iss' => env('APP_URL'), + 'sub' => $this->faker->randomNumber(1), + 'iat' => time(), + 'exp' => time() + 3600, + ], $this->faker->password(8, 12), 'HS256'); + + $this->expectException(SignatureInvalidException::class); + + $this->service->decodeJWT($token); + } + + public function test_should_throw_unexpected_value_exception() + { + $this->expectException(UnexpectedValueException::class); + + $this->service->decodeJWT( + $this->faker->password(8, 12) + ); + } + + public function test_should_have_scope() + { + $user = $this->createUser(); + Auth::setUser($user); + + $this->assertTrue($this->service->checkScopes(Scope::Default->value)); + } + + public function test_should_throw_authentication_exception() + { + $this->expectException(AuthenticationException::class); + + $this->service->checkScopes(Scope::Default->value); + } + + #[DataProvider('provider_scopes')] + public function test_should_throw_invalid_scope_exception(array $scopes) + { + $user = $this->createUser(); + Auth::setUser($user); + + $this->expectException(InvalidScopeException::class); + + $this->assertFalse($this->service->checkScopes(...$scopes)); + } + + public static function provider_scopes(): array + { + return [ + [[Scope::Root->value]], + [Scope::values()], + ]; + } +} diff --git a/tests/Unit/Services/Rawg/RawgAchievementServiceTest.php b/tests/Unit/Services/Rawg/RawgAchievementServiceTest.php new file mode 100644 index 0000000..e236add --- /dev/null +++ b/tests/Unit/Services/Rawg/RawgAchievementServiceTest.php @@ -0,0 +1,66 @@ +prepRawgForUnitTesting(); + $this->service = resolve(RawgAchievementService::class, [ + 'client' => $this->createClientMock('rawg_achievements.json') + ]); + } + + public function test_should_return_achievements() + { + $result = $this->service->getGameAchievements($this->faker->name()); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertTrue($result->isNotEmpty()); + + $item = $result->first(); + + $this->assertEquals(126805, $item['id']); + $this->assertEquals('Master Marksman', $item['name']); + $this->assertEquals('Kill 50 human and nonhuman opponents by striking ' . + 'them in the head with a crossbow bolt.', $item['description']); + $this->assertEquals('https://media.rawg.io/media/achievements/' . + '233/23314da942731f8ce34378917a703aaf.jpg', $item['image']); + $this->assertEquals('9.14', $item['percent']); + } + + public function test_should_order_achievements() + { + $result = $this->service->getGameAchievements( + $this->faker->name(), + [ + 'order_by' => 'percent', + 'sort_order' => SortOrder::DESC->value + ] + ); + + $result = $result->pluck('id')->toArray(); + + $this->assertEquals([ + 152157, + 152158, + 140771, + 148021, + 140770, + 135938, + 126805, + 177407, + 167134, + ], $result); + } +} diff --git a/tests/Unit/Services/Rawg/RawgDomainServiceTest.php b/tests/Unit/Services/Rawg/RawgDomainServiceTest.php new file mode 100644 index 0000000..160ab75 --- /dev/null +++ b/tests/Unit/Services/Rawg/RawgDomainServiceTest.php @@ -0,0 +1,61 @@ +prepRawgForUnitTesting(); + } + + public function test_should_return_genres(): void + { + $service = resolve(RawgDomainService::class, [ + 'client' => $this->createClientMock('rawg_domain_genres.json') + ]); + + $result = $service->getGenres(); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + $this->assertEquals(4, $result[0]['id']); + $this->assertEquals('Action', $result[0]['name']); + $this->assertEquals('action', $result[0]['slug']); + } + + public function test_should_return_tags() + { + $service = resolve(RawgDomainService::class, [ + 'client' => $this->createClientMock('rawg_domain_tags.json') + ]); + + $result = $service->getTags(); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + $this->assertEquals(31, $result[0]['id']); + $this->assertEquals('Singleplayer', $result[0]['name']); + $this->assertEquals('singleplayer', $result[0]['slug']); + } + + public function test_should_return_platforms() + { + $service = resolve(RawgDomainService::class, [ + 'client' => $this->createClientMock('rawg_domain_platforms.json') + ]); + + $result = $service->getPlatforms(); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + $this->assertEquals(4, $result[0]['id']); + $this->assertEquals('PC', $result[0]['name']); + $this->assertEquals('pc', $result[0]['slug']); + } +} diff --git a/tests/Unit/Services/Rawg/RawgFilterServiceTest.php b/tests/Unit/Services/Rawg/RawgFilterServiceTest.php new file mode 100644 index 0000000..1c09cce --- /dev/null +++ b/tests/Unit/Services/Rawg/RawgFilterServiceTest.php @@ -0,0 +1,42 @@ +value => date('Y-m-d', strtotime('-1 year')) . ',' . date('Y-m-d'), + RawgField::Genres->value => RawgGenre::Action->value, + RawgField::Ordering->value => 'updated', + RawgField::Platforms->value => RawgPlatform::PC->value, + RawgField::Page->value => 2, + ]; + + $result = $service->getQueryFilters( + filters: [ + RawgField::Dates->value => $this->faker->date() . ',' . $this->faker->date(), + RawgField::Platforms->value => Platform::PC->value, + RawgField::Page->value => $expected[RawgField::Page->value], + ], + default: [ + RawgField::Dates->value => $expected[RawgField::Dates->value], + RawgField::Genres->value => $expected[RawgField::Genres->value], + RawgField::Ordering->value => $expected[RawgField::Ordering->value], + RawgField::Page->value => 1 + ] + ); + + $this->assertEquals($expected, $result); + } +} diff --git a/tests/Unit/Services/Rawg/RawgGamesServiceTest.php b/tests/Unit/Services/Rawg/RawgGamesServiceTest.php new file mode 100644 index 0000000..5f16c30 --- /dev/null +++ b/tests/Unit/Services/Rawg/RawgGamesServiceTest.php @@ -0,0 +1,93 @@ +prepRawgForUnitTesting(); + } + + private function createFilterServiceSpy(array $defaultFilters, array $filters) + { + $expectedFilters = array_merge($defaultFilters, $filters); + + $mock = Mockery::spy(RawgFilterService::class); + $mock->shouldReceive('getQueryFilters') + ->once() + ->with($filters, $defaultFilters) + ->andReturn($expectedFilters); + + + return $mock; + } + + public function test_should_get_recommendations() + { + $genre = RawgGenre::Action->value; + + $filters = [ + RawgField::Page->value => 2, + RawgField::PageSize->value => 10 + ]; + + $filterServiceSpy = $this->createFilterServiceSpy([ + RawgField::Dates->value => date('Y-m-d', strtotime('-1 year')) . ',' . date('Y-m-d'), + RawgField::Genres->value => $genre, + RawgField::Ordering->value => 'updated', + RawgField::PageSize->value => 5, + RawgField::Page->value => 1 + ], $filters); + + $service = resolve(RawgGamesService::class, [ + 'filterService' => $filterServiceSpy, + 'client' => $this->createClientMock('rawg_games.json') + ]); + + $result = $service->getRecommendations($genre, $filters); + + $this->assertInstanceOf(PaginatedResponse::class, $result); + $this->assertInstanceOf(Game::class, $result->getContents()['data']->first()); + } + + public function test_should_get_upcoming_releases() + { + $period = Period::Next_7_Days->value; + $timeUnit = Period::getTimeUnit($period); + + $filters = [ + RawgField::Page->value => 2, + RawgField::PageSize->value => 10 + ]; + + $filterServiceSpy = $this->createFilterServiceSpy([ + RawgField::Dates->value => date('Y-m-d') . ',' . date('Y-m-d', strtotime('+1 ' . $timeUnit)), + RawgField::Ordering->value => 'released', + RawgField::PageSize->value => 25, + RawgField::Page->value => 1 + ], $filters); + + $service = resolve(RawgGamesService::class, [ + 'filterService' => $filterServiceSpy, + 'client' => $this->createClientMock('rawg_games.json') + ]); + + $result = $service->getUpcomingReleases($period, $filters); + + $this->assertInstanceOf(PaginatedResponse::class, $result); + $this->assertInstanceOf(Game::class, $result->getContents()['data']->first()); + } +} diff --git a/tests/Unit/Swagger/ApplicationTest.php b/tests/Unit/Swagger/ApplicationTest.php new file mode 100644 index 0000000..a0c3b9b --- /dev/null +++ b/tests/Unit/Swagger/ApplicationTest.php @@ -0,0 +1,18 @@ +assertInstanceOf(Application::class, $app); + $this->assertNull($app->tags()); + $this->assertNull($app->up()); + $this->assertNull($app->apiUp()); + } +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100755 index 421b569..0000000 --- a/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; - -export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: true, - }), - ], -});