API Updates

Brings the API in close sync to the rest of the site.
- Adds resources for License, UserRole, and ModVersion models
- Adds filtering on attribute data
- The `includes` data is now disabled by default and available conditionally
This commit is contained in:
Refringe 2024-08-08 16:11:50 -04:00
parent 65e416e4d9
commit 3a334033fe
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
13 changed files with 369 additions and 20 deletions

View File

@ -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);
}
}

View File

@ -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());
}
/**

View File

@ -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());
}
/**

View File

@ -0,0 +1,116 @@
<?php
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class ModFilter extends QueryFilter
{
// TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait.
// Also, consider using common filter types and making the field names dynamic.
public function name(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('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);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
abstract class QueryFilter
{
protected Builder $builder;
public function __construct(protected Request $request) {}
public function apply(Builder $builder): Builder
{
$this->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;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class UserFilter extends QueryFilter
{
// TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait.
// Also, consider using common filter types and making the field names dynamic.
public function name(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('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);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Api\V0;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin License */
class LicenseResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'type' => 'license',
'id' => $this->id,
'attributes' => [
'name' => $this->name,
'link' => $this->link,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
],
];
}
}

View File

@ -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,

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Resources\Api\V0;
use App\Models\ModVersion;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ModVersion */
class ModVersionResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'type' => '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,
],
],
],
],
];
}
}

View File

@ -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(),
],

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Api\V0;
use App\Models\UserRole;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin UserRole */
class UserRoleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'type' => 'user_role',
'id' => $this->id,
'attributes' => [
'name' => $this->name,
'short_name' => $this->short_name,
'description' => $this->description,
'color_class' => $this->color_class,
],
];
}
}

View File

@ -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.
*/

View File

@ -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.
*/