diff --git a/app/Http/Controllers/Api/V0/ApiController.php b/app/Http/Controllers/Api/V0/ApiController.php index e6c3955..588e047 100644 --- a/app/Http/Controllers/Api/V0/ApiController.php +++ b/app/Http/Controllers/Api/V0/ApiController.php @@ -7,7 +7,11 @@ use Illuminate\Support\Str; class ApiController extends Controller { - public static function shouldInclude(string $relationship): bool + /** + * Determine if the given relationship should be included in the request. If more than one relationship is provided, + * only one needs to be present in the request for this method to return true. + */ + public static function shouldInclude(string|array $relationships): bool { $param = request()->get('include'); @@ -17,6 +21,16 @@ class ApiController extends Controller $includeValues = explode(',', Str::lower($param)); - return in_array(Str::lower($relationship), $includeValues); + if (is_array($relationships)) { + foreach ($relationships as $relationship) { + if (in_array(Str::lower($relationship), $includeValues)) { + return true; + } + } + + return false; + } + + return in_array(Str::lower($relationships), $includeValues); } } diff --git a/app/Http/Controllers/Api/V0/ModController.php b/app/Http/Controllers/Api/V0/ModController.php index 1f69eb2..9065bbc 100644 --- a/app/Http/Controllers/Api/V0/ModController.php +++ b/app/Http/Controllers/Api/V0/ModController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V0; +use App\Http\Filters\V1\ModFilter; use App\Http\Requests\Api\V0\StoreModRequest; use App\Http\Requests\Api\V0\UpdateModRequest; use App\Http\Resources\Api\V0\ModResource; @@ -12,9 +13,9 @@ class ModController extends ApiController /** * Display a listing of the resource. */ - public function index() + public function index(ModFilter $filters) { - return ModResource::collection(Mod::paginate()); + return ModResource::collection(Mod::filter($filters)->paginate()); } /** diff --git a/app/Http/Controllers/Api/V0/UsersController.php b/app/Http/Controllers/Api/V0/UsersController.php index a93f52c..8bf7824 100644 --- a/app/Http/Controllers/Api/V0/UsersController.php +++ b/app/Http/Controllers/Api/V0/UsersController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V0; +use App\Http\Filters\V1\UserFilter; use App\Http\Requests\Api\V0\StoreUserRequest; use App\Http\Requests\Api\V0\UpdateUserRequest; use App\Http\Resources\Api\V0\UserResource; @@ -12,9 +13,9 @@ class UsersController extends ApiController /** * Display a listing of the resource. */ - public function index() + public function index(UserFilter $filters) { - return UserResource::collection(User::paginate()); + return UserResource::collection(User::filter($filters)->paginate()); } /** diff --git a/app/Http/Filters/V1/ModFilter.php b/app/Http/Filters/V1/ModFilter.php new file mode 100644 index 0000000..722ba71 --- /dev/null +++ b/app/Http/Filters/V1/ModFilter.php @@ -0,0 +1,116 @@ +builder->where('name', 'like', $like); + } + + public function slug(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('slug', 'like', $like); + } + + public function teaser(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('teaser', 'like', $like); + } + + public function source_code_link(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('source_code_link', 'like', $like); + } + + public function created_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('created_at', $dates); + } + + return $this->builder->whereDate('created_at', $value); + } + + public function updated_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('updated_at', $dates); + } + + return $this->builder->whereDate('updated_at', $value); + } + + public function published_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('published_at', $dates); + } + + return $this->builder->whereDate('published_at', $value); + } + + public function featured(string $value): Builder + { + // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + // This column is not nullable. + if ($value === null) { + return $this->builder; + } + + return $this->builder->where('featured', $value); + } + + public function contains_ads(string $value): Builder + { + // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + // This column is not nullable. + if ($value === null) { + return $this->builder; + } + + return $this->builder->where('contains_ads', $value); + } + + public function contains_ai_content(string $value): Builder + { + // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + // This column is not nullable. + if ($value === null) { + return $this->builder; + } + + return $this->builder->where('contains_ai_content', $value); + } +} diff --git a/app/Http/Filters/V1/QueryFilter.php b/app/Http/Filters/V1/QueryFilter.php new file mode 100644 index 0000000..0e4b8e6 --- /dev/null +++ b/app/Http/Filters/V1/QueryFilter.php @@ -0,0 +1,37 @@ +builder = $builder; + + foreach ($this->request->all() as $attribute => $value) { + if (method_exists($this, $attribute)) { + $this->$attribute($value); + } + } + + return $this->builder; + } + + protected function filter(array $filters): Builder + { + foreach ($filters as $attribute => $value) { + if (method_exists($this, $attribute)) { + $this->$attribute($value); + } + } + + return $this->builder; + } +} diff --git a/app/Http/Filters/V1/UserFilter.php b/app/Http/Filters/V1/UserFilter.php new file mode 100644 index 0000000..9684150 --- /dev/null +++ b/app/Http/Filters/V1/UserFilter.php @@ -0,0 +1,42 @@ +builder->where('name', 'like', $like); + } + + public function created_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('created_at', $dates); + } + + return $this->builder->whereDate('created_at', $value); + } + + public function updated_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('updated_at', $dates); + } + + return $this->builder->whereDate('updated_at', $value); + } +} diff --git a/app/Http/Resources/Api/V0/LicenseResource.php b/app/Http/Resources/Api/V0/LicenseResource.php new file mode 100644 index 0000000..a64ebe2 --- /dev/null +++ b/app/Http/Resources/Api/V0/LicenseResource.php @@ -0,0 +1,25 @@ + 'license', + 'id' => $this->id, + 'attributes' => [ + 'name' => $this->name, + 'link' => $this->link, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ], + ]; + } +} diff --git a/app/Http/Resources/Api/V0/ModResource.php b/app/Http/Resources/Api/V0/ModResource.php index 6b2d2e9..1737e26 100644 --- a/app/Http/Resources/Api/V0/ModResource.php +++ b/app/Http/Resources/Api/V0/ModResource.php @@ -45,6 +45,19 @@ class ModResource extends JsonResource 'self' => $user->profileUrl(), ], ])->toArray(), + 'versions' => $this->versions->map(fn ($version) => [ + 'data' => [ + 'type' => 'version', + 'id' => $version->id, + ], + + // TODO: The download link to the version can be placed here, but I'd like to track the number of + // downloads that are made, so we'll need a new route/feature for that. #35 + 'links' => [ + 'self' => $version->link, + ], + + ])->toArray(), 'license' => [ [ 'data' => [ @@ -54,15 +67,17 @@ class ModResource extends JsonResource ], ], ], - 'includes' => $this->when( - ApiController::shouldInclude('users'), - fn () => $this->users->map(fn ($user) => new UserResource($user)) + ApiController::shouldInclude(['users', 'license', 'versions']), + fn () => collect([ + 'users' => $this->users->map(fn ($user) => new UserResource($user)), + 'license' => new LicenseResource($this->license), + 'versions' => $this->versions->map(fn ($version) => new ModVersionResource($version)), + ]) + ->filter(fn ($value, $key) => ApiController::shouldInclude($key)) + ->flatten(1) + ->values() ), - - // TODO: Provide 'included' data for attached 'license': - //new LicenseResource($this->license) - 'links' => [ 'self' => route('mod.show', [ 'mod' => $this->id, diff --git a/app/Http/Resources/Api/V0/ModVersionResource.php b/app/Http/Resources/Api/V0/ModVersionResource.php new file mode 100644 index 0000000..d42008d --- /dev/null +++ b/app/Http/Resources/Api/V0/ModVersionResource.php @@ -0,0 +1,54 @@ + 'mod_version', + 'id' => $this->id, + 'attributes' => [ + 'hub_id' => $this->hub_id, + 'mod_id' => $this->mod_id, + 'version' => $this->version, + + // TODO: This should only be visible on the mod version show route(?) which doesn't exist. + //'description' => $this->when( + // $request->routeIs('api.v0.modversion.show'), + // $this->description + //), + + // TODO: The download link to the version can be placed here, but I'd like to track the number of + // downloads that are made, so we'll need a new route/feature for that. #35 + 'link' => $this->link, + + 'spt_version_id' => $this->spt_version_id, + 'virus_total_link' => $this->virus_total_link, + 'downloads' => $this->downloads, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'published_at' => $this->published_at, + ], + 'relationships' => [ + 'spt_version' => [ + [ + 'data' => [ + 'type' => 'spt_version', + 'id' => $this->spt_version_id, + ], + ], + ], + ], + ]; + } +} diff --git a/app/Http/Resources/Api/V0/UserResource.php b/app/Http/Resources/Api/V0/UserResource.php index f4e63d1..903012f 100644 --- a/app/Http/Resources/Api/V0/UserResource.php +++ b/app/Http/Resources/Api/V0/UserResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\V0; +use App\Http\Controllers\Api\V0\ApiController; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -9,9 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource; /** @mixin User */ class UserResource extends JsonResource { - /** - * Transform the resource into an array. - */ public function toArray(Request $request): array { return [ @@ -21,6 +19,7 @@ class UserResource extends JsonResource 'name' => $this->name, 'user_role_id' => $this->user_role_id, 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ], 'relationships' => [ 'user_role' => [ @@ -30,10 +29,10 @@ class UserResource extends JsonResource ], ], ], - - // TODO: Provide 'included' data for attached 'user_role' - //'included' => [new UserRoleResource($this->role)], - + 'includes' => $this->when( + ApiController::shouldInclude('user_role'), + new UserRoleResource($this->role) + ), 'links' => [ 'self' => $this->profileUrl(), ], diff --git a/app/Http/Resources/Api/V0/UserRoleResource.php b/app/Http/Resources/Api/V0/UserRoleResource.php new file mode 100644 index 0000000..51d5146 --- /dev/null +++ b/app/Http/Resources/Api/V0/UserRoleResource.php @@ -0,0 +1,25 @@ + 'user_role', + 'id' => $this->id, + 'attributes' => [ + 'name' => $this->name, + 'short_name' => $this->short_name, + 'description' => $this->description, + 'color_class' => $this->color_class, + ], + ]; + } +} diff --git a/app/Models/Mod.php b/app/Models/Mod.php index 8a0e512..193cc17 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -2,8 +2,10 @@ namespace App\Models; +use App\Http\Filters\V1\QueryFilter; use App\Models\Scopes\DisabledScope; use App\Models\Scopes\PublishedScope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -144,6 +146,14 @@ class Mod extends Model }; } + /** + * Scope a query by applying QueryFilter filters. + */ + public function scopeFilter(Builder $builder, QueryFilter $filters): Builder + { + return $filters->apply($builder); + } + /** * The attributes that should be cast to native types. */ diff --git a/app/Models/User.php b/app/Models/User.php index bfbfa90..157248b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,10 +2,12 @@ namespace App\Models; +use App\Http\Filters\V1\QueryFilter; use App\Notifications\ResetPassword; use App\Notifications\VerifyEmail; use App\Traits\HasCoverPhoto; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -136,6 +138,14 @@ class User extends Authenticatable implements MustVerifyEmail return $this->belongsTo(UserRole::class, 'user_role_id'); } + /** + * Scope a query by applying QueryFilter filters. + */ + public function scopeFilter(Builder $builder, QueryFilter $filters): Builder + { + return $filters->apply($builder); + } + /** * Get the disk that profile photos should be stored on. */