Merge remote-tracking branch 'upstream/develop' into user-profile-info

This commit is contained in:
IsWaffle 2024-09-17 09:39:43 -04:00
commit 0f3c7e03d0
28 changed files with 29960 additions and 175 deletions

View File

@ -89,3 +89,6 @@ DB_HUB_COLLATION=utf8mb4_0900_ai_ci
GITEA_DOMAIN=
GITEA_TOKEN=
# API key for Scribe documentation.
SCRIBE_AUTH_KEY=

View File

@ -42,3 +42,6 @@ SCOUT_DRIVER=collection
MAIL_MAILER=log
MAIL_FROM_ADDRESS="no-reply@sp-tarkov.com"
MAIL_FROM_NAME="${APP_NAME}"
# API key for Scribe documentation.
SCRIBE_AUTH_KEY=

1
.gitignore vendored
View File

@ -24,3 +24,4 @@ Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
.scribe

View File

@ -9,41 +9,77 @@ use App\Traits\ApiResponses;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Knuckles\Scribe\Attributes\BodyParam;
use Knuckles\Scribe\Attributes\Response;
use Knuckles\Scribe\Attributes\ResponseField;
use Laravel\Sanctum\PersonalAccessToken;
class AuthController extends Controller
{
use ApiResponses;
/**
* Login
*
* Authenticates the user and returns a read-only API token. This API token can then be saved and used for future
* requests that require authentication. <aside class="warning">This method is made available for mod authors to
* incorporate into their mods so that users can easily authenticate using their own API token. For typical API use,
* you should log into the website, create an API token, and use that token for your API requests.</aside>
*
* @unauthenticated
*
* @group Authentication
*/
#[BodyParam('token_name', 'string', 'The name of the API token.', required: false, example: 'Dynamic API Token')]
#[Response(['message' => 'authenticated', 'data' => ['token' => 'YOUR_API_KEY'], 'status' => 200], status: 200, description: 'Authenticated successfully')]
#[Response(['message' => 'invalid credentials', 'status' => 401], status: 401, description: 'Invalid credentials')]
#[ResponseField('token', description: 'The newly created read-only API token to use for future authenticated requests.')]
public function login(LoginUserRequest $request): JsonResponse
{
$request->validated($request->all());
if (! Auth::attempt($request->only('email', 'password'))) {
return $this->error(__('Invalid credentials'), 401);
return $this->error(__('invalid credentials'), 401);
}
$user = User::firstWhere('email', $request->email);
$tokenName = $request->token_name ?? __('Dynamic API Token');
return $this->success(__('Authenticated'), [
return $this->success(__('authenticated'), [
// Only allowing the 'read' scope to be dynamically created. Can revisit later when writes are possible.
'token' => $user->createToken($tokenName, ['read'])->plainTextToken,
]);
}
/**
* Logout
*
* Destroys the user's current API token, effectively logging them out.
*
* @group Authentication
*/
#[Response(['message' => 'success', 'status' => 200], status: 200, description: 'Token destroyed successfully')]
public function logout(Request $request): JsonResponse
{
/** @var \Laravel\Sanctum\PersonalAccessToken $token */
/** @var PersonalAccessToken $token */
$token = $request->user()->currentAccessToken();
$token->delete();
return $this->success(__('Revoked API token'));
return $this->success(__('success'));
}
/**
* Logout All
*
* Destroys all the user's API tokens, effectively logging everyone out of the account.
*
* @group Authentication
*/
#[Response(['message' => 'success', 'status' => 200], status: 200, description: 'Tokens destroyed successfully')]
public function logoutAll(Request $request): JsonResponse
{
$request->user()->tokens()->delete();
return $this->success(__('Revoked all API tokens'));
return $this->success(__('success'));
}
}

View File

@ -3,43 +3,58 @@
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;
use App\Models\Mod;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\JsonResource;
use Knuckles\Scribe\Attributes\QueryParam;
use Knuckles\Scribe\Attributes\UrlParam;
class ModController extends ApiController
{
/**
* Display a listing of the resource.
* Get Mods
*
* List, filter, and sort basic information about mods.
*
* @group Mods
*/
#[QueryParam('include', 'string', 'The relationships to include within the `includes` key. By default no relationships are automatically included.', required: false, example: 'users,versions,license')]
#[QueryParam('filter[id]', 'string', 'Filter by the `id`. Select multiple by separating the IDs with a comma.', required: false, example: '5,10,15')]
#[QueryParam('filter[hub_id]', 'string', 'Filter by the `hub_id` attribute. Select multiple by separating the IDs with a comma.', required: false, example: '20')]
#[QueryParam('filter[name]', 'string', 'Filter by the `name` attribute. Use `*` as the wildcard character.', required: false, example: '*SAIN*')]
#[QueryParam('filter[slug]', 'string', 'Filter by the `slug` attribute. Use `*` as the wildcard character.', required: false, example: '*raid-times')]
#[QueryParam('filter[teaser]', 'string', 'Filter by the `teaser` attribute. Use `*` as the wildcard character.', required: false, example: '*weighted*random*times*')]
#[QueryParam('filter[source_code_link]', 'string', 'Filter by the `source_code_link` attribute. Use `*` as the wildcard character.', required: false, example: '*https*.net*')]
#[QueryParam('filter[featured]', 'boolean', 'Filter by the `featured` attribute. All "truthy" or "falsy" values are supported.', required: false, example: 'true')]
#[QueryParam('filter[contains_ads]', 'boolean', 'Filter by the `contains_ads` attribute. All "truthy" or "falsy" values are supported.', required: false, example: 'true')]
#[QueryParam('filter[contains_ai_content]', 'boolean', 'Filter by the `contains_ai_content` attribute. All "truthy" or "falsy" values are supported.', required: false, example: 'true')]
#[QueryParam('filter[created_at]', 'string', 'Filter by the `created_at` attribute. Ranges are possible by separating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('filter[updated_at]', 'string', 'Filter by the `updated_at` attribute. Ranges are possible by separating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('filter[published_at]', 'string', 'Filter by the `published_at` attribute. Ranges are possible by seperating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('sort', 'string', 'Sort the results by a comma seperated list of attributes. The default sort direction is ASC, append the attribute name with a minus to sort DESC.', required: false, example: '-featured,name')]
public function index(ModFilter $filters): AnonymousResourceCollection
{
return ModResource::collection(Mod::filter($filters)->paginate());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreModRequest $request): void {}
//public function store(StoreModRequest $request): void {}
/**
* Display the specified resource.
* Get Mod
*
* Display more detailed information about a specific mod.
*
* @group Mods
*/
#[UrlParam('id', 'integer', 'The ID of the mod.', required: true, example: 558)]
#[QueryParam('include', 'string', 'The relationships to include within the `includes` key. By default no relationships are automatically included.', required: false, example: 'users,versions,license')]
public function show(Mod $mod): JsonResource
{
return new ModResource($mod);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateModRequest $request, Mod $mod): void {}
//public function update(UpdateModRequest $request, Mod $mod): void {}
/**
* Remove the specified resource from storage.
*/
public function destroy(Mod $mod): void {}
//public function destroy(Mod $mod): void {}
}

View File

@ -3,43 +3,48 @@
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;
use App\Models\User;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\JsonResource;
use Knuckles\Scribe\Attributes\QueryParam;
class UsersController extends ApiController
{
/**
* Display a listing of the resource.
* Get Users
*
* List, filter, and sort basic information about users.
*
* @group Users
*/
#[QueryParam('include', 'string', 'The relationships to include within the `includes` key. By default no relationships are automatically included.', required: false, example: 'user_role')]
#[QueryParam('filter[id]', 'string', 'Filter by the `id`. Select multiple by separating the IDs with a comma.', required: false, example: '5,10,15')]
#[QueryParam('filter[name]', 'string', 'Filter by the `name` attribute. Use `*` as the wildcard character.', required: false, example: '*fringe')]
#[QueryParam('filter[created_at]', 'string', 'Filter by the `created_at` attribute. Ranges are possible by separating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('filter[updated_at]', 'string', 'Filter by the `updated_at` attribute. Ranges are possible by separating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('sort', 'string', 'Sort the results by a comma seperated list of attributes. The default sort direction is ASC, append the attribute name with a minus to sort DESC.', required: false, example: 'created_at,-name')]
public function index(UserFilter $filters): AnonymousResourceCollection
{
return UserResource::collection(User::filter($filters)->paginate());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreUserRequest $request): void {}
//public function store(StoreUserRequest $request): void {}
/**
* Display the specified resource.
* Get User
*
* Display more detailed information about a specific user.
*
* @group Users
*/
#[QueryParam('include', 'string', 'The relationships to include within the `includes` key. By default no relationships are automatically included.', required: false, example: 'user_role')]
public function show(User $user): JsonResource
{
return new UserResource($user);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUserRequest $request, User $user): void {}
//public function update(UpdateUserRequest $request, User $user): void {}
/**
* Remove the specified resource from storage.
*/
public function destroy(User $user): void {}
//public function destroy(User $user): void {}
}

View File

@ -91,7 +91,7 @@ class ModFilter
private function order(string $type): Builder
{
return match ($type) {
'updated' => $this->builder->orderByDesc('mods.updated_at'), // TODO: This needs to be updated when a version is updated.
'updated' => $this->builder->orderByDesc('mods.updated_at'),
'downloaded' => $this->builder->orderByDesc('mods.downloads'),
default => $this->builder->orderByDesc('mods.created_at'),
};

View File

@ -4,13 +4,17 @@ namespace App\Http\Filters\V1;
use App\Models\Mod;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
/**
* @extends QueryFilter<Mod>
*/
class ModFilter extends QueryFilter
{
/**
* The sortable fields.
*
* @var array<string>
*/
protected array $sortable = [
'name',
'slug',
@ -24,9 +28,6 @@ class ModFilter extends QueryFilter
'published_at',
];
// 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.
/**
* Filter by ID.
*
@ -34,9 +35,7 @@ class ModFilter extends QueryFilter
*/
public function id(string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn('id', $ids);
return $this->filterWhereIn('id', $value);
}
/**
@ -46,9 +45,7 @@ class ModFilter extends QueryFilter
*/
public function hub_id(string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn('hub_id', $ids);
return $this->filterWhereIn('hub_id', $value);
}
/**
@ -58,10 +55,7 @@ class ModFilter extends QueryFilter
*/
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);
return $this->filterByWildcardLike('name', $value);
}
/**
@ -71,10 +65,7 @@ class ModFilter extends QueryFilter
*/
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);
return $this->filterByWildcardLike('slug', $value);
}
/**
@ -84,10 +75,7 @@ class ModFilter extends QueryFilter
*/
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);
return $this->filterByWildcardLike('teaser', $value);
}
/**
@ -97,10 +85,7 @@ class ModFilter extends QueryFilter
*/
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);
return $this->filterByWildcardLike('source_code_link', $value);
}
/**
@ -110,13 +95,7 @@ class ModFilter extends QueryFilter
*/
public function created_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('created_at', $dates);
}
return $this->builder->whereDate('created_at', $value);
return $this->filterByDate('created_at', $value);
}
/**
@ -126,13 +105,7 @@ class ModFilter extends QueryFilter
*/
public function updated_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('updated_at', $dates);
}
return $this->builder->whereDate('updated_at', $value);
return $this->filterByDate('updated_at', $value);
}
/**
@ -142,13 +115,7 @@ class ModFilter extends QueryFilter
*/
public function published_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('published_at', $dates);
}
return $this->builder->whereDate('published_at', $value);
return $this->filterByDate('published_at', $value);
}
/**
@ -158,15 +125,7 @@ class ModFilter extends QueryFilter
*/
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);
return $this->filterByBoolean('featured', $value);
}
/**
@ -176,15 +135,7 @@ class ModFilter extends QueryFilter
*/
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);
return $this->filterByBoolean('contains_ads', $value);
}
/**
@ -194,14 +145,6 @@ class ModFilter extends QueryFilter
*/
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);
return $this->filterByBoolean('contains_ai_content', $value);
}
}

View File

@ -2,16 +2,23 @@
namespace App\Http\Filters\V1;
use App\Traits\V1\FilterMethods;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
/**
* @template TModelClass of Model
*/
abstract class QueryFilter
{
/**
* Include general filter methods.
*
* @use FilterMethods<TModelClass>
*/
use FilterMethods;
/**
* The query builder instance.
*
@ -19,18 +26,45 @@ abstract class QueryFilter
*/
protected Builder $builder;
/**
* The request instance.
*/
protected Request $request;
/** @var array<string> */
/**
* The sortable fields.
*
* @var array<string>
*/
protected array $sortable = [];
/**
* Create a new QueryFilter instance.
*/
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Apply the filter to the query builder.
* Iterate over each of the filter options and call the appropriate method if it exists.
*
* @param array<string, string> $filters
* @return Builder<TModelClass>
*/
public function filter(array $filters): Builder
{
foreach ($filters as $attribute => $value) {
if (method_exists($this, $attribute)) {
$this->$attribute($value);
}
}
return $this->builder;
}
/**
* Iterate over all request data and call the appropriate method if it exists.
*
* @param Builder<TModelClass> $builder
* @return Builder<TModelClass>
@ -47,25 +81,4 @@ abstract class QueryFilter
return $this->builder;
}
/**
* Apply the sort type to the query.
*
* @return Builder<TModelClass>
*/
protected function sort(string $values): Builder
{
$sortables = array_map('trim', explode(',', $values));
foreach ($sortables as $sortable) {
$direction = Str::startsWith($sortable, '-') ? 'desc' : 'asc';
$column = Str::of($sortable)->remove('-')->value();
if (in_array($column, $this->sortable)) {
$this->builder->orderBy($column, $direction);
}
}
return $this->builder;
}
}

View File

@ -4,7 +4,6 @@ namespace App\Http\Filters\V1;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
/**
* @extends QueryFilter<User>
@ -22,9 +21,6 @@ class UserFilter extends QueryFilter
'updated_at',
];
// 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.
/**
* Filter by ID.
*
@ -32,9 +28,7 @@ class UserFilter extends QueryFilter
*/
public function id(string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn('id', $ids);
return $this->filterWhereIn('id', $value);
}
/**
@ -44,10 +38,7 @@ class UserFilter extends QueryFilter
*/
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);
return $this->filterByWildcardLike('name', $value);
}
/**
@ -57,13 +48,7 @@ class UserFilter extends QueryFilter
*/
public function created_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('created_at', $dates);
}
return $this->builder->whereDate('created_at', $value);
return $this->filterByDate('created_at', $value);
}
/**
@ -73,12 +58,6 @@ class UserFilter extends QueryFilter
*/
public function updated_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('updated_at', $dates);
}
return $this->builder->whereDate('updated_at', $value);
return $this->filterByDate('updated_at', $value);
}
}

View File

@ -22,6 +22,13 @@ class ModVersion extends Model
use SoftDeletes;
/**
* Update the parent mod's updated_at timestamp when the mod version is updated.
*
* @var array<string>
*/
protected $touches = ['mod'];
/**
* Post boot method to configure the model.
*/

View File

@ -8,8 +8,6 @@ trait ApiResponses
{
/**
* Return a success JSON response.
*
* @param array<mixed> $data
*/
protected function success(string $message, ?array $data = []): JsonResponse
{
@ -18,15 +16,17 @@ trait ApiResponses
/**
* The base response.
*
* @param array<mixed> $data
*/
private function baseResponse(?string $message = '', ?array $data = [], ?int $code = 200): JsonResponse
{
return response()->json([
'message' => $message,
'data' => $data,
], $code);
$response = [];
$response['message'] = $message;
if ($data) {
$response['data'] = $data;
}
$response['status'] = $code;
return response()->json($response, $code);
}
/**

View File

@ -0,0 +1,106 @@
<?php
namespace App\Traits\V1;
use App\Http\Filters\V1\QueryFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/**
* @template TModelClass of Model
*
* @mixin QueryFilter<TModelClass>
*/
trait FilterMethods
{
/**
* Filter using a whereIn clause.
*
* @return Builder<TModelClass>
*/
public function filterWhereIn(string $column, string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
$result = $this->builder->whereIn($column, $ids);
/** @var Builder<TModelClass> $result */
return $result;
}
/**
* Filter using a LIKE clause with a wildcard characters.
*
* @return Builder<TModelClass>
*/
public function filterByWildcardLike(string $column, string $value): Builder
{
$like = Str::replace('*', '%', $value);
$result = $this->builder->where($column, 'like', $like);
/** @var Builder<TModelClass> $result */
return $result;
}
/**
* Filter by date range or specific date.
*
* @return Builder<TModelClass>
*/
public function filterByDate(string $column, string $value): Builder
{
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
$result = $this->builder->whereBetween($column, $dates);
} else {
$result = $this->builder->whereDate($column, $dates[0]);
}
/** @var Builder<TModelClass> $result */
return $result;
}
/**
* Filter by boolean value.
*
* @return Builder<TModelClass>
*/
public function filterByBoolean(string $column, string $value): Builder
{
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($value === null) {
$result = $this->builder; // The unmodified builder
} else {
$result = $this->builder->where($column, $value);
}
/** @var Builder<TModelClass> $result */
return $result;
}
/**
* Apply the sort type to the query.
*
* @return Builder<TModelClass>
*/
protected function sort(string $values): Builder
{
$result = $this->builder;
$sortables = array_map('trim', explode(',', $values));
foreach ($sortables as $sortable) {
$direction = Str::startsWith($sortable, '-') ? 'desc' : 'asc';
$column = Str::of($sortable)->remove('-')->value();
if (in_array($column, $this->sortable)) {
$result = $this->builder->orderBy($column, $direction);
}
}
/** @var Builder<TModelClass> $result */
return $result;
}
}

View File

@ -30,6 +30,7 @@
"require-dev": {
"barryvdh/laravel-debugbar": "^3.13",
"fakerphp/faker": "^1.23",
"knuckleswtf/scribe": "^4.37",
"larastan/larastan": "^2.9",
"laravel/pint": "^1.16",
"laravel/sail": "^1.29",

445
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "aef706ee9aa7b671ca81c5ced4a7bfb7",
"content-hash": "a687bfe8867c5e74bf6a974aaa969f62",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@ -9510,6 +9510,56 @@
],
"time": "2024-02-20T07:24:02+00:00"
},
{
"name": "erusev/parsedown",
"version": "1.7.4",
"source": {
"type": "git",
"url": "https://github.com/erusev/parsedown.git",
"reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/erusev/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3",
"reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35"
},
"type": "library",
"autoload": {
"psr-0": {
"Parsedown": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Emanuil Rusev",
"email": "hello@erusev.com",
"homepage": "http://erusev.com"
}
],
"description": "Parser for Markdown.",
"homepage": "http://parsedown.org",
"keywords": [
"markdown",
"parser"
],
"support": {
"issues": "https://github.com/erusev/parsedown/issues",
"source": "https://github.com/erusev/parsedown/tree/1.7.x"
},
"time": "2019-12-30T22:54:17+00:00"
},
{
"name": "fakerphp/faker",
"version": "v1.23.1",
@ -9815,6 +9865,101 @@
},
"time": "2024-03-08T09:58:59+00:00"
},
{
"name": "knuckleswtf/scribe",
"version": "4.37.2",
"source": {
"type": "git",
"url": "https://github.com/knuckleswtf/scribe.git",
"reference": "6318f3f68cbf09328e5cb6843ce1739e529ef1ac"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/knuckleswtf/scribe/zipball/6318f3f68cbf09328e5cb6843ce1739e529ef1ac",
"reference": "6318f3f68cbf09328e5cb6843ce1739e529ef1ac",
"shasum": ""
},
"require": {
"erusev/parsedown": "1.7.4",
"ext-fileinfo": "*",
"ext-json": "*",
"ext-pdo": "*",
"fakerphp/faker": "^1.9.1",
"illuminate/console": "^8.0|^9.0|^10.0|^11.0",
"illuminate/routing": "^8.0|^9.0|^10.0|^11.0",
"illuminate/support": "^8.0|^9.0|^10.0|^11.0",
"league/flysystem": "^1.1.4|^2.1.1|^3.0",
"mpociot/reflection-docblock": "^1.0.1",
"nikic/php-parser": "^5.0",
"nunomaduro/collision": "^5.10|^6.0|^7.0|^8.0",
"php": ">=8.0",
"ramsey/uuid": "^4.2.2",
"shalvah/clara": "^3.1.0",
"shalvah/upgrader": ">=0.6.0",
"spatie/data-transfer-object": "^2.6|^3.0",
"symfony/var-exporter": "^5.4|^6.0|^7.0",
"symfony/yaml": "^5.4|^6.0|^7.0"
},
"replace": {
"mpociot/laravel-apidoc-generator": "*"
},
"require-dev": {
"brianium/paratest": "^6.0",
"dms/phpunit-arraysubset-asserts": "^0.4",
"laravel/legacy-factories": "^1.3.0",
"laravel/lumen-framework": "^8.0|^9.0|^10.0",
"league/fractal": "^0.20",
"nikic/fast-route": "^1.3",
"orchestra/testbench": "^6.0|^7.0|^8.0",
"pestphp/pest": "^1.21",
"phpstan/phpstan": "^1.0",
"phpunit/phpunit": "^9.0|^10.0",
"symfony/css-selector": "^5.4|^6.0",
"symfony/dom-crawler": "^5.4|^6.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Knuckles\\Scribe\\ScribeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Knuckles\\Camel\\": "camel/",
"Knuckles\\Scribe\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Shalvah"
}
],
"description": "Generate API documentation for humans from your Laravel codebase.✍",
"homepage": "http://github.com/knuckleswtf/scribe",
"keywords": [
"api",
"dingo",
"documentation",
"laravel"
],
"support": {
"issues": "https://github.com/knuckleswtf/scribe/issues",
"source": "https://github.com/knuckleswtf/scribe/tree/4.37.2"
},
"funding": [
{
"url": "https://patreon.com/shalvah",
"type": "patreon"
}
],
"time": "2024-08-30T12:15:51+00:00"
},
{
"name": "larastan/larastan",
"version": "v2.9.8",
@ -10197,6 +10342,59 @@
},
"time": "2024-05-16T03:13:13+00:00"
},
{
"name": "mpociot/reflection-docblock",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/mpociot/reflection-docblock.git",
"reference": "c8b2e2b1f5cebbb06e2b5ccbf2958f2198867587"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpociot/reflection-docblock/zipball/c8b2e2b1f5cebbb06e2b5ccbf2958f2198867587",
"reference": "c8b2e2b1f5cebbb06e2b5ccbf2958f2198867587",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"suggest": {
"dflydev/markdown": "~1.0",
"erusev/parsedown": "~1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-0": {
"Mpociot": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "mike.vanriel@naenius.com"
}
],
"support": {
"issues": "https://github.com/mpociot/reflection-docblock/issues",
"source": "https://github.com/mpociot/reflection-docblock/tree/master"
},
"time": "2016-06-20T20:53:12+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.12.0",
@ -12426,6 +12624,111 @@
],
"time": "2023-02-07T11:34:05+00:00"
},
{
"name": "shalvah/clara",
"version": "3.2.0",
"source": {
"type": "git",
"url": "https://github.com/shalvah/clara.git",
"reference": "cdbb5737cbdd101756d97dd2279a979a1af7710b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/shalvah/clara/zipball/cdbb5737cbdd101756d97dd2279a979a1af7710b",
"reference": "cdbb5737cbdd101756d97dd2279a979a1af7710b",
"shasum": ""
},
"require": {
"php": ">=7.4",
"symfony/console": "^4.0|^5.0|^6.0|^7.0"
},
"require-dev": {
"eloquent/phony-phpunit": "^7.0",
"phpunit/phpunit": "^9.1"
},
"type": "library",
"autoload": {
"files": [
"helpers.php"
],
"psr-4": {
"Shalvah\\Clara\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "🔊 Simple, pretty, testable console output for CLI apps.",
"keywords": [
"cli",
"log",
"logging"
],
"support": {
"issues": "https://github.com/shalvah/clara/issues",
"source": "https://github.com/shalvah/clara/tree/3.2.0"
},
"time": "2024-02-27T20:30:59+00:00"
},
{
"name": "shalvah/upgrader",
"version": "0.6.0",
"source": {
"type": "git",
"url": "https://github.com/shalvah/upgrader.git",
"reference": "d95ed17fe9f5e1ee7d47ad835595f1af080a867f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/shalvah/upgrader/zipball/d95ed17fe9f5e1ee7d47ad835595f1af080a867f",
"reference": "d95ed17fe9f5e1ee7d47ad835595f1af080a867f",
"shasum": ""
},
"require": {
"illuminate/support": ">=8.0",
"nikic/php-parser": "^5.0",
"php": ">=8.0"
},
"require-dev": {
"dms/phpunit-arraysubset-asserts": "^0.2.0",
"pestphp/pest": "^1.21",
"phpstan/phpstan": "^1.0",
"spatie/ray": "^1.33"
},
"type": "library",
"autoload": {
"psr-4": {
"Shalvah\\Upgrader\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Shalvah",
"email": "hello@shalvah.me"
}
],
"description": "Create automatic upgrades for your package.",
"homepage": "http://github.com/shalvah/upgrader",
"keywords": [
"upgrade"
],
"support": {
"issues": "https://github.com/shalvah/upgrader/issues",
"source": "https://github.com/shalvah/upgrader/tree/0.6.0"
},
"funding": [
{
"url": "https://patreon.com/shalvah",
"type": "patreon"
}
],
"time": "2024-02-20T11:51:46+00:00"
},
{
"name": "spatie/backtrace",
"version": "1.6.2",
@ -12489,6 +12792,70 @@
],
"time": "2024-07-22T08:21:24+00:00"
},
{
"name": "spatie/data-transfer-object",
"version": "3.9.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/data-transfer-object.git",
"reference": "1df0906c4e9e3aebd6c0506fd82c8b7d5548c1c8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/data-transfer-object/zipball/1df0906c4e9e3aebd6c0506fd82c8b7d5548c1c8",
"reference": "1df0906c4e9e3aebd6c0506fd82c8b7d5548c1c8",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"illuminate/collections": "^8.36",
"jetbrains/phpstorm-attributes": "^1.0",
"larapack/dd": "^1.1",
"phpunit/phpunit": "^9.5.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\DataTransferObject\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brent Roose",
"email": "brent@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Data transfer objects with batteries included",
"homepage": "https://github.com/spatie/data-transfer-object",
"keywords": [
"data-transfer-object",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/data-transfer-object/issues",
"source": "https://github.com/spatie/data-transfer-object/tree/3.9.1"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"abandoned": "spatie/laravel-data",
"time": "2022-09-16T13:34:38+00:00"
},
{
"name": "spatie/error-solutions",
"version": "1.1.1",
@ -12806,6 +13173,82 @@
],
"time": "2024-06-12T15:01:18+00:00"
},
{
"name": "symfony/var-exporter",
"version": "v7.1.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-exporter.git",
"reference": "b80a669a2264609f07f1667f891dbfca25eba44c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/b80a669a2264609f07f1667f891dbfca25eba44c",
"reference": "b80a669a2264609f07f1667f891dbfca25eba44c",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"require-dev": {
"symfony/property-access": "^6.4|^7.0",
"symfony/serializer": "^6.4|^7.0",
"symfony/var-dumper": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\VarExporter\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows exporting any serializable PHP data structure to plain PHP code",
"homepage": "https://symfony.com",
"keywords": [
"clone",
"construct",
"export",
"hydrate",
"instantiate",
"lazy-loading",
"proxy",
"serialize"
],
"support": {
"source": "https://github.com/symfony/var-exporter/tree/v7.1.2"
},
"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-06-28T08:00:31+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.1.4",

269
config/scribe.php Normal file
View File

@ -0,0 +1,269 @@
<?php
use Knuckles\Scribe\Extracting\Strategies;
return [
// The HTML <title> for the generated documentation. If this is empty, Scribe will infer it from config('app.name').
'title' => 'The Forge API Documentation',
// A short description of your API. Will be included in the docs webpage, Postman collection and OpenAPI spec.
'description' => '',
// The base URL displayed in the docs. If this is empty, Scribe will use the value of config('app.url') at generation time.
// If you're using `laravel` type, you can set this to a dynamic string, like '{{ config("app.tenant_url") }}' to get a dynamic base URL.
'base_url' => null,
'routes' => [
[
// Routes that match these conditions will be included in the docs
'match' => [
// Match only routes whose paths match this pattern (use * as a wildcard to match any characters). Example: 'users/*'.
'prefixes' => ['api/*'],
// Match only routes whose domains match this pattern (use * as a wildcard to match any characters). Example: 'api.*'.
'domains' => ['*'],
// [Dingo router only] Match only routes registered under this version. Wildcards are NOT supported.
'versions' => ['v0'],
],
// Include these routes even if they did not match the rules above.
'include' => [
// 'users.index', 'POST /new', '/auth/*'
],
// Exclude these routes even if they matched the rules above.
'exclude' => [
// 'GET /health', 'admin.*'
],
],
],
// The type of documentation output to generate.
// - "static" will generate a static HTMl page in the /public/docs folder,
// - "laravel" will generate the documentation as a Blade view, so you can add routing and authentication.
// - "external_static" and "external_laravel" do the same as above, but generate a basic template,
// passing the OpenAPI spec as a URL, allowing you to easily use the docs with an external generator
'type' => 'static',
// See https://scribe.knuckles.wtf/laravel/reference/config#theme for supported options
'theme' => 'default',
'static' => [
// HTML documentation, assets and Postman collection will be generated to this folder.
// Source Markdown will still be in resources/docs.
'output_path' => 'public/docs',
],
'laravel' => [
// Whether to automatically create a docs endpoint for you to view your generated docs.
// If this is false, you can still set up routing manually.
'add_routes' => true,
// URL path to use for the docs endpoint (if `add_routes` is true).
// By default, `/docs` opens the HTML page, `/docs.postman` opens the Postman collection, and `/docs.openapi` the OpenAPI spec.
'docs_url' => '/docs',
// Directory within `public` in which to store CSS and JS assets.
// By default, assets are stored in `public/vendor/scribe`.
// If set, assets will be stored in `public/{{assets_directory}}`
'assets_directory' => null,
// Middleware to attach to the docs endpoint (if `add_routes` is true).
'middleware' => [],
],
'external' => [
'html_attributes' => [],
],
'try_it_out' => [
// Add a Try It Out button to your endpoints so consumers can test endpoints right from their browser.
// Don't forget to enable CORS headers for your endpoints.
'enabled' => true,
// The base URL for the API tester to use (for example, you can set this to your staging URL).
// Leave as null to use the current app URL when generating (config("app.url")).
'base_url' => null,
// [Laravel Sanctum] Fetch a CSRF token before each request, and add it as an X-XSRF-TOKEN header.
'use_csrf' => false,
// The URL to fetch the CSRF token from (if `use_csrf` is true).
'csrf_url' => '/sanctum/csrf-cookie',
],
// How is your API authenticated? This information will be used in the displayed docs, generated examples and response calls.
'auth' => [
// Set this to true if ANY endpoints in your API use authentication.
'enabled' => true,
// Set this to true if your API should be authenticated by default. If so, you must also set `enabled` (above) to true.
// You can then use @unauthenticated or @authenticated on individual endpoints to change their status from the default.
'default' => true,
// Where is the auth value meant to be sent in a request?
// Options: query, body, basic, bearer, header (for custom header)
'in' => 'bearer',
// The name of the auth parameter (eg token, key, apiKey) or header (eg Authorization, Api-Key).
'name' => 'Authorization',
// The value of the parameter to be used by Scribe to authenticate response calls.
// This will NOT be included in the generated documentation. If empty, Scribe will use a random value.
'use_value' => env('SCRIBE_AUTH_KEY'),
// Placeholder your users will see for the auth parameter in the example requests.
// Set this to null if you want Scribe to use a random value as placeholder instead.
'placeholder' => 'YOUR_API_KEY',
// Any extra authentication-related info for your users. Markdown and HTML are supported.
'extra_info' => 'You can generate your own API token by logging into The Forge, clicking your profile picture, and clicking <b>API Tokens</b>.',
],
// Text to place in the "Introduction" section, right after the `description`. Markdown and HTML are supported.
'intro_text' => <<<'INTRO'
This documentation aims to provide all the information you need to work with our API.
<aside>As you scroll, you'll see code examples for working with the API in different programming languages in the dark area to the right (or as part of the content on mobile).
You can switch the language used with the tabs at the top right (or from the nav menu at the top left on mobile).</aside>
INTRO
,
// Example requests for each endpoint will be shown in each of these languages.
// Supported options are: bash, javascript, php, python
// To add a language of your own, see https://scribe.knuckles.wtf/laravel/advanced/example-requests
'example_languages' => [
'javascript',
'php',
'python',
],
// Generate a Postman collection (v2.1.0) in addition to HTML docs.
// For 'static' docs, the collection will be generated to public/docs/collection.json.
// For 'laravel' docs, it will be generated to storage/app/scribe/collection.json.
// Setting `laravel.add_routes` to true (above) will also add a route for the collection.
'postman' => [
'enabled' => true,
'overrides' => [
// 'info.version' => '2.0.0',
],
],
// Generate an OpenAPI spec (v3.0.1) in addition to docs webpage.
// For 'static' docs, the collection will be generated to public/docs/openapi.yaml.
// For 'laravel' docs, it will be generated to storage/app/scribe/openapi.yaml.
// Setting `laravel.add_routes` to true (above) will also add a route for the spec.
'openapi' => [
'enabled' => true,
'overrides' => [
// 'info.version' => '2.0.0',
],
],
'groups' => [
// Endpoints which don't have a @group will be placed in this default group.
'default' => 'Endpoints',
// By default, Scribe will sort groups alphabetically, and endpoints in the order their routes are defined.
// You can override this by listing the groups, subgroups and endpoints here in the order you want them.
// See https://scribe.knuckles.wtf/blog/laravel-v4#easier-sorting and https://scribe.knuckles.wtf/laravel/reference/config#order for details
'order' => [],
],
// Custom logo path. This will be used as the value of the src attribute for the <img> tag,
// so make sure it points to an accessible URL or path. Set to false to not use a logo.
// For example, if your logo is in public/img:
// - 'logo' => '../img/logo.png' // for `static` type (output folder is public/docs)
// - 'logo' => 'img/logo.png' // for `laravel` type
'logo' => false,
// Customize the "Last updated" value displayed in the docs by specifying tokens and formats.
// Examples:
// - {date:F j Y} => March 28, 2022
// - {git:short} => Short hash of the last Git commit
// Available tokens are `{date:<format>}` and `{git:<format>}`.
// The format you pass to `date` will be passed to PHP's `date()` function.
// The format you pass to `git` can be either "short" or "long".
'last_updated' => 'Last updated: {date:F j, Y}',
'examples' => [
// Set this to any number (eg. 1234) to generate the same example values for parameters on each run,
'faker_seed' => null,
// With API resources and transformers, Scribe tries to generate example models to use in your API responses.
// By default, Scribe will try the model's factory, and if that fails, try fetching the first from the database.
// You can reorder or remove strategies here.
'models_source' => ['factoryCreate', 'factoryMake', 'databaseFirst'],
],
// The strategies Scribe will use to extract information about your routes at each stage.
// If you create or install a custom strategy, add it here.
'strategies' => [
'metadata' => [
Strategies\Metadata\GetFromDocBlocks::class,
Strategies\Metadata\GetFromMetadataAttributes::class,
],
'urlParameters' => [
Strategies\UrlParameters\GetFromLaravelAPI::class,
Strategies\UrlParameters\GetFromUrlParamAttribute::class,
Strategies\UrlParameters\GetFromUrlParamTag::class,
],
'queryParameters' => [
Strategies\QueryParameters\GetFromFormRequest::class,
Strategies\QueryParameters\GetFromInlineValidator::class,
Strategies\QueryParameters\GetFromQueryParamAttribute::class,
Strategies\QueryParameters\GetFromQueryParamTag::class,
],
'headers' => [
Strategies\Headers\GetFromHeaderAttribute::class,
Strategies\Headers\GetFromHeaderTag::class,
[
'override',
[
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
],
],
'bodyParameters' => [
Strategies\BodyParameters\GetFromFormRequest::class,
Strategies\BodyParameters\GetFromInlineValidator::class,
Strategies\BodyParameters\GetFromBodyParamAttribute::class,
Strategies\BodyParameters\GetFromBodyParamTag::class,
],
'responses' => [
Strategies\Responses\UseResponseAttributes::class,
Strategies\Responses\UseTransformerTags::class,
Strategies\Responses\UseApiResourceTags::class,
Strategies\Responses\UseResponseTag::class,
Strategies\Responses\UseResponseFileTag::class,
[
Strategies\Responses\ResponseCalls::class,
[
'only' => ['GET *'],
// Disable debug mode when generating response calls to avoid error stack traces in responses
'config' => [
'app.debug' => false,
],
],
],
],
'responseFields' => [
Strategies\ResponseFields\GetFromResponseFieldAttribute::class,
Strategies\ResponseFields\GetFromResponseFieldTag::class,
],
],
// For response calls, API resource responses and transformer responses,
// Scribe will try to start database transactions, so no changes are persisted to your database.
// Tell Scribe which connections should be transacted here. If you only use one db connection, you can leave this as is.
'database_connections_to_transact' => [config('database.default')],
'fractal' => [
// If you are using a custom serializer with league/fractal, you can specify it here.
'serializer' => null,
],
'routeMatcher' => \Knuckles\Scribe\Matching\RouteMatcher::class,
];

View File

@ -1,7 +1,7 @@
includes:
- ./vendor/larastan/larastan/extension.neon
parameters:
level: 6
level: 5
paths:
- app
- bootstrap

486
public/docs/collection.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,393 @@
/* Copied from https://github.com/slatedocs/slate/blob/c4b4c0b8f83e891ca9fab6bbe9a1a88d5fe41292/stylesheets/print.css and unminified */
/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
html {
font-family: sans-serif;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%
}
body {
margin: 0
}
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
menu,
nav,
section,
summary {
display: block
}
audio,
canvas,
progress,
video {
display: inline-block;
vertical-align: baseline
}
audio:not([controls]) {
display: none;
height: 0
}
[hidden],
template {
display: none
}
a {
background-color: transparent
}
a:active,
a:hover {
outline: 0
}
abbr[title] {
border-bottom: 1px dotted
}
b,
strong {
font-weight: bold
}
dfn {
font-style: italic
}
h1 {
font-size: 2em;
margin: 0.67em 0
}
mark {
background: #ff0;
color: #000
}
small {
font-size: 80%
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline
}
sup {
top: -0.5em
}
sub {
bottom: -0.25em
}
img {
border: 0
}
svg:not(:root) {
overflow: hidden
}
figure {
margin: 1em 40px
}
hr {
box-sizing: content-box;
height: 0
}
pre {
overflow: auto
}
code,
kbd,
pre,
samp {
font-family: monospace, monospace;
font-size: 1em
}
button,
input,
optgroup,
select,
textarea {
color: inherit;
font: inherit;
margin: 0
}
button {
overflow: visible
}
button,
select {
text-transform: none
}
button,
html input[type="button"],
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button;
cursor: pointer
}
button[disabled],
html input[disabled] {
cursor: default
}
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0
}
input {
line-height: normal
}
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box;
padding: 0
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
height: auto
}
input[type="search"] {
-webkit-appearance: textfield;
box-sizing: content-box
}
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none
}
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em
}
legend {
border: 0;
padding: 0
}
textarea {
overflow: auto
}
optgroup {
font-weight: bold
}
table {
border-collapse: collapse;
border-spacing: 0
}
td,
th {
padding: 0
}
.content h1,
.content h2,
.content h3,
.content h4,
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 14px
}
.content h1,
.content h2,
.content h3,
.content h4 {
font-weight: bold
}
.content pre,
.content code {
font-family: Consolas, Menlo, Monaco, "Lucida Console", "Liberation Mono", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Courier New", monospace, serif;
font-size: 12px;
line-height: 1.5
}
.content pre,
.content code {
word-break: break-all;
-webkit-hyphens: auto;
-ms-hyphens: auto;
hyphens: auto
}
@font-face {
font-family: 'slate';
src: url(../fonts/slate.eot?-syv14m);
src: url(../fonts/slate.eot?#iefix-syv14m) format("embedded-opentype"), url(../fonts/slate.woff2?-syv14m) format("woff2"), url(../fonts/slate.woff?-syv14m) format("woff"), url(../fonts/slate.ttf?-syv14m) format("truetype"), url(../fonts/slate.svg?-syv14m#slate) format("svg");
font-weight: normal;
font-style: normal
}
.content aside.warning:before,
.content aside.notice:before,
.content aside.success:before {
font-family: 'slate';
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1
}
.content aside.warning:before {
content: "\e600"
}
.content aside.notice:before {
content: "\e602"
}
.content aside.success:before {
content: "\e606"
}
.tocify,
.toc-footer,
.lang-selector,
.search,
#nav-button {
display: none
}
.tocify-wrapper>img {
margin: 0 auto;
display: block
}
.content {
font-size: 12px
}
.content pre,
.content code {
border: 1px solid #999;
border-radius: 5px;
font-size: 0.8em
}
.content pre code {
border: 0
}
.content pre {
padding: 1.3em
}
.content code {
padding: 0.2em
}
.content table {
border: 1px solid #999
}
.content table tr {
border-bottom: 1px solid #999
}
.content table td,
.content table th {
padding: 0.7em
}
.content p {
line-height: 1.5
}
.content a {
text-decoration: none;
color: #000
}
.content h1 {
font-size: 2.5em;
padding-top: 0.5em;
padding-bottom: 0.5em;
margin-top: 1em;
margin-bottom: 21px;
border: 2px solid #ccc;
border-width: 2px 0;
text-align: center
}
.content h2 {
font-size: 1.8em;
margin-top: 2em;
border-top: 2px solid #ccc;
padding-top: 0.8em
}
.content h1+h2,
.content h1+div+h2 {
border-top: none;
padding-top: 0;
margin-top: 0
}
.content h3,
.content h4 {
font-size: 0.8em;
margin-top: 1.5em;
margin-bottom: 0.8em;
text-transform: uppercase
}
.content h5,
.content h6 {
text-transform: uppercase
}
.content aside {
padding: 1em;
border: 1px solid #ccc;
border-radius: 5px;
margin-top: 1.5em;
margin-bottom: 1.5em;
line-height: 1.6
}
.content aside:before {
vertical-align: middle;
padding-right: 0.5em;
font-size: 14px
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

11426
public/docs/index.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,149 @@
document.addEventListener('DOMContentLoaded', function() {
const updateHash = function (id) {
window.location.hash = `#${id}`;
};
const navButton = document.getElementById('nav-button');
const menuWrapper = document.querySelector('.tocify-wrapper');
function toggleSidebar(event) {
event.preventDefault();
if (menuWrapper) {
menuWrapper.classList.toggle('open');
navButton.classList.toggle('open');
}
}
function closeSidebar() {
if (menuWrapper) {
menuWrapper.classList.remove('open');
navButton.classList.remove('open');
}
}
navButton.addEventListener('click', toggleSidebar);
window.hljs.highlightAll();
const wrapper = document.getElementById('toc');
// https://jets.js.org/
window.jets = new window.Jets({
// *OR - Selects elements whose values contains at least one part of search substring
searchSelector: '*OR',
searchTag: '#input-search',
contentTag: '#toc li',
didSearch: function(term) {
wrapper.classList.toggle('jets-searching', String(term).length > 0)
},
// map these accent keys to plain values
diacriticsMap: {
a: 'ÀÁÂÃÄÅàáâãäåĀāąĄ',
c: 'ÇçćĆčČ',
d: 'đĐďĎ',
e: 'ÈÉÊËèéêëěĚĒēęĘ',
i: 'ÌÍÎÏìíîïĪī',
l: 'łŁ',
n: 'ÑñňŇńŃ',
o: 'ÒÓÔÕÕÖØòóôõöøŌō',
r: 'řŘ',
s: 'ŠšśŚ',
t: 'ťŤ',
u: 'ÙÚÛÜùúûüůŮŪū',
y: 'ŸÿýÝ',
z: 'ŽžżŻźŹ'
}
});
function hashChange() {
const currentItems = document.querySelectorAll('.tocify-subheader.visible, .tocify-item.tocify-focus');
Array.from(currentItems).forEach((elem) => {
elem.classList.remove('visible', 'tocify-focus');
});
const currentTag = document.querySelector(`a[href="${window.location.hash}"]`);
if (currentTag) {
const parent = currentTag.closest('.tocify-subheader');
if (parent) {
parent.classList.add('visible');
}
const siblings = currentTag.closest('.tocify-header');
if (siblings) {
Array.from(siblings.querySelectorAll('.tocify-subheader')).forEach((elem) => {
elem.classList.add('visible');
});
}
currentTag.parentElement.classList.add('tocify-focus');
// wait for dom changes to be done
setTimeout(() => {
currentTag.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
// only close the sidebar on level-2 events
if (currentTag.parentElement.classList.contains('level-2')) {
closeSidebar();
}
}, 1500);
}
}
let languages = JSON.parse(document.body.getAttribute('data-languages'));
// Support a key => value object where the key is the name, or an array of strings where the value is the name
if (!Array.isArray(languages)) {
languages = Object.values(languages);
}
// if there is no language use the first one
const currentLanguage = window.localStorage.getItem('language') || languages[0];
const languageStyle = document.getElementById('language-style');
const langSelector = document.querySelectorAll('.lang-selector button.lang-button');
function setActiveLanguage(newLanguage) {
window.localStorage.setItem('language', newLanguage);
if (!languageStyle) {
return;
}
const newStyle = languages.map((language) => {
return language === newLanguage
// the current one should be visible
? `body .content .${language}-example pre { display: block; }`
// the inactive one should be hidden
: `body .content .${language}-example pre { display: none; }`;
}).join(`\n`);
Array.from(langSelector).forEach((elem) => {
elem.classList.toggle('active', elem.getAttribute('data-language-name') === newLanguage);
});
const activeHash = window.location.hash.slice(1);
languageStyle.innerHTML = newStyle;
setTimeout(() => {
updateHash(activeHash);
}, 200);
}
setActiveLanguage(currentLanguage);
Array.from(langSelector).forEach((elem) => {
elem.addEventListener('click', () => {
const newLanguage = elem.getAttribute('data-language-name');
setActiveLanguage(newLanguage);
});
});
window.addEventListener('hashchange', hashChange, false);
const divs = document.querySelectorAll('.content h1[id], .content h2[id]');
document.addEventListener('scroll', () => {
divs.forEach(item => {
const rect = item.getBoundingClientRect();
if (rect.top > 0 && rect.top < 150) {
const location = window.location.toString().split('#')[0];
history.replaceState(null, null, location + '#' + item.id);
hashChange();
}
});
});
hashChange();
});

View File

@ -0,0 +1,277 @@
window.abortControllers = {};
function cacheAuthValue() {
// Whenever the auth header is set for one endpoint, cache it for the others
window.lastAuthValue = '';
let authInputs = document.querySelectorAll(`.auth-value`)
authInputs.forEach(el => {
el.addEventListener('input', (event) => {
window.lastAuthValue = event.target.value;
authInputs.forEach(otherInput => {
if (otherInput === el) return;
// Don't block the main thread
setTimeout(() => {
otherInput.value = window.lastAuthValue;
}, 0);
});
});
});
}
window.addEventListener('DOMContentLoaded', cacheAuthValue);
function getCookie(name) {
if (!document.cookie) {
return null;
}
const cookies = document.cookie.split(';')
.map(c => c.trim())
.filter(c => c.startsWith(name + '='));
if (cookies.length === 0) {
return null;
}
return decodeURIComponent(cookies[0].split('=')[1]);
}
function tryItOut(endpointId) {
document.querySelector(`#btn-tryout-${endpointId}`).hidden = true;
document.querySelector(`#btn-canceltryout-${endpointId}`).hidden = false;
const executeBtn = document.querySelector(`#btn-executetryout-${endpointId}`).hidden = false;
executeBtn.disabled = false;
// Show all input fields
document.querySelectorAll(`input[data-endpoint=${endpointId}],label[data-endpoint=${endpointId}]`)
.forEach(el => el.style.display = 'block');
if (document.querySelector(`#form-${endpointId}`).dataset.authed === "1") {
const authElement = document.querySelector(`#auth-${endpointId}`);
authElement && (authElement.hidden = false);
}
// Expand all nested fields
document.querySelectorAll(`#form-${endpointId} details`)
.forEach(el => el.open = true);
}
function cancelTryOut(endpointId) {
if (window.abortControllers[endpointId]) {
window.abortControllers[endpointId].abort();
delete window.abortControllers[endpointId];
}
document.querySelector(`#btn-tryout-${endpointId}`).hidden = false;
const executeBtn = document.querySelector(`#btn-executetryout-${endpointId}`);
executeBtn.hidden = true;
executeBtn.textContent = executeBtn.dataset.initialText;
document.querySelector(`#btn-canceltryout-${endpointId}`).hidden = true;
// Hide inputs
document.querySelectorAll(`input[data-endpoint=${endpointId}],label[data-endpoint=${endpointId}]`)
.forEach(el => el.style.display = 'none');
document.querySelectorAll(`#form-${endpointId} details`)
.forEach(el => el.open = false);
const authElement = document.querySelector(`#auth-${endpointId}`);
authElement && (authElement.hidden = true);
document.querySelector('#execution-results-' + endpointId).hidden = true;
document.querySelector('#execution-error-' + endpointId).hidden = true;
// Revert to sample code blocks
document.querySelector('#example-requests-' + endpointId).hidden = false;
document.querySelector('#example-responses-' + endpointId).hidden = false;
}
function makeAPICall(method, path, body = {}, query = {}, headers = {}, endpointId = null) {
console.log({endpointId, path, body, query, headers});
if (!(body instanceof FormData) && typeof body !== "string") {
body = JSON.stringify(body)
}
const url = new URL(window.tryItOutBaseUrl + '/' + path.replace(/^\//, ''));
// We need this function because if you try to set an array or object directly to a URLSearchParams object,
// you'll get [object Object] or the array.toString()
function addItemToSearchParamsObject(key, value, searchParams) {
if (Array.isArray(value)) {
value.forEach((v, i) => {
// Append {filters: [first, second]} as filters[0]=first&filters[1]second
addItemToSearchParamsObject(key + '[' + i + ']', v, searchParams);
})
} else if (typeof value === 'object' && value !== null) {
Object.keys(value).forEach((i) => {
// Append {filters: {name: first}} as filters[name]=first
addItemToSearchParamsObject(key + '[' + i + ']', value[i], searchParams);
});
} else {
searchParams.append(key, value);
}
}
Object.keys(query)
.forEach(key => addItemToSearchParamsObject(key, query[key], url.searchParams));
window.abortControllers[endpointId] = new AbortController();
return fetch(url, {
method,
headers,
body: method === 'GET' ? undefined : body,
signal: window.abortControllers[endpointId].signal,
referrer: window.tryItOutBaseUrl,
mode: 'cors',
credentials: 'same-origin',
})
.then(response => Promise.all([response.status, response.statusText, response.text(), response.headers]));
}
function hideCodeSamples(endpointId) {
document.querySelector('#example-requests-' + endpointId).hidden = true;
document.querySelector('#example-responses-' + endpointId).hidden = true;
}
function handleResponse(endpointId, response, status, headers) {
hideCodeSamples(endpointId);
// Hide error views
document.querySelector('#execution-error-' + endpointId).hidden = true;
const responseContentEl = document.querySelector('#execution-response-content-' + endpointId);
// Prettify it if it's JSON
let isJson = false;
try {
const jsonParsed = JSON.parse(response);
if (jsonParsed !== null) {
isJson = true;
response = JSON.stringify(jsonParsed, null, 4);
}
} catch (e) {
}
responseContentEl.textContent = response === '' ? responseContentEl.dataset.emptyResponseText : response;
isJson && window.hljs.highlightElement(responseContentEl);
const statusEl = document.querySelector('#execution-response-status-' + endpointId);
statusEl.textContent = ` (${status})`;
document.querySelector('#execution-results-' + endpointId).hidden = false;
statusEl.scrollIntoView({behavior: "smooth", block: "center"});
}
function handleError(endpointId, err) {
hideCodeSamples(endpointId);
// Hide response views
document.querySelector('#execution-results-' + endpointId).hidden = true;
// Show error views
let errorMessage = err.message || err;
const $errorMessageEl = document.querySelector('#execution-error-message-' + endpointId);
$errorMessageEl.textContent = errorMessage + $errorMessageEl.textContent;
const errorEl = document.querySelector('#execution-error-' + endpointId);
errorEl.hidden = false;
errorEl.scrollIntoView({behavior: "smooth", block: "center"});
}
async function executeTryOut(endpointId, form) {
const executeBtn = document.querySelector(`#btn-executetryout-${endpointId}`);
executeBtn.textContent = executeBtn.dataset.loadingText;
executeBtn.disabled = true;
executeBtn.scrollIntoView({behavior: "smooth", block: "center"});
let body;
let setter;
if (form.dataset.hasfiles === "1") {
body = new FormData();
setter = (name, value) => body.append(name, value);
} else if (form.dataset.isarraybody === "1") {
body = [];
setter = (name, value) => _.set(body, name, value);
} else {
body = {};
setter = (name, value) => _.set(body, name, value);
}
const bodyParameters = form.querySelectorAll('input[data-component=body]');
bodyParameters.forEach(el => {
let value = el.value;
if (el.type === 'number' && typeof value === 'string') {
value = parseFloat(value);
}
if (el.type === 'file' && el.files[0]) {
setter(el.name, el.files[0]);
return;
}
if (el.type !== 'radio') {
if (value === "" && el.required === false) {
// Don't include empty optional values in the request
return;
}
setter(el.name, value);
return;
}
if (el.checked) {
value = (value === 'false') ? false : true;
setter(el.name, value);
}
});
const query = {};
const queryParameters = form.querySelectorAll('input[data-component=query]');
queryParameters.forEach(el => {
if (el.type !== 'radio' || (el.type === 'radio' && el.checked)) {
if (el.value === '') {
// Don't include empty values in the request
return;
}
_.set(query, el.name, el.value);
}
});
let path = form.dataset.path;
const urlParameters = form.querySelectorAll('input[data-component=url]');
urlParameters.forEach(el => (path = path.replace(new RegExp(`\\{${el.name}\\??}`), el.value)));
const headers = Object.fromEntries(Array.from(form.querySelectorAll('input[data-component=header]'))
.map(el => [el.name, el.value]));
// When using FormData, the browser sets the correct content-type + boundary
let method = form.dataset.method;
if (body instanceof FormData) {
delete headers['Content-Type'];
// When using FormData with PUT or PATCH, use method spoofing so PHP can access the post body
if (['PUT', 'PATCH'].includes(form.dataset.method)) {
method = 'POST';
setter('_method', form.dataset.method);
}
}
let preflightPromise = Promise.resolve();
if (window.useCsrf && window.csrfUrl) {
preflightPromise = makeAPICall('GET', window.csrfUrl).then(() => {
headers['X-XSRF-TOKEN'] = getCookie('XSRF-TOKEN');
});
}
return preflightPromise.then(() => makeAPICall(method, path, body, query, headers, endpointId))
.then(([responseStatus, statusText, responseContent, responseHeaders]) => {
handleResponse(endpointId, responseContent, responseStatus, responseHeaders)
})
.catch(err => {
if (err.name === "AbortError") {
console.log("Request cancelled");
return;
}
console.log("Error while making request: ", err);
handleError(endpointId, err);
})
.finally(() => {
executeBtn.disabled = false;
executeBtn.textContent = executeBtn.dataset.initialText;
});
}

15082
public/docs/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,10 +3,6 @@
use App\Http\Controllers\Api\AuthController;
use Illuminate\Support\Facades\Route;
Route::get('/status', function () {
return response()->json(['message' => 'okay']);
});
Route::post('/login', [AuthController::class, 'login']);
Route::group(['middleware' => 'auth:sanctum'], function () {
Route::delete('/logout', [AuthController::class, 'logout']);

View File

@ -0,0 +1,54 @@
<?php
use App\Http\Filters\V1\ModFilter;
use App\Models\Mod;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
beforeEach(function () {
Mod::factory()->create(['name' => 'Mod C', 'slug' => 'mod-c', 'featured' => true]);
Mod::factory()->create(['name' => 'Mod B', 'slug' => 'mod-b', 'featured' => false]);
Mod::factory()->create(['name' => 'Mod A', 'slug' => 'mod-a', 'featured' => true]);
});
it('can filter mods by id', function () {
$request = new Request(['id' => '1,2']);
$filter = new ModFilter($request);
$query = $filter->apply(Mod::query());
expect($query->get()->pluck('id')->toArray())->toBe([1, 2]);
});
it('can filter mods by name with wildcard', function () {
$request = new Request(['name' => 'Mod*']);
$filter = new ModFilter($request);
$query = $filter->apply(Mod::query());
expect($query->get()->pluck('name')->toArray())->toContain('Mod A', 'Mod B', 'Mod C');
});
it('can filter mods by featured status', function () {
$request = new Request(['featured' => 'true']);
$filter = new ModFilter($request);
$query = $filter->apply(Mod::query());
expect($query->get()->pluck('name')->toArray())->toContain('Mod A', 'Mod C');
});
it('can sort mods by name in ascending order', function () {
$request = new Request(['sort' => 'name']);
$filter = new ModFilter($request);
$query = $filter->apply(Mod::query());
expect($query->get()->pluck('name')->toArray())->toBe(['Mod A', 'Mod B', 'Mod C']);
});
it('can sort mods by name in descending order', function () {
$request = new Request(['sort' => '-name']);
$filter = new ModFilter($request);
$query = $filter->apply(Mod::query());
expect($query->get()->pluck('name')->toArray())->toBe(['Mod C', 'Mod B', 'Mod A']);
});

View File

@ -128,3 +128,16 @@ it('handles null published_at as not published', function () {
expect($mods->contains($modWithNoPublishDate))->toBeFalse();
});
it('updates the parent mods updated_at column when updated', function () {
$originalDate = now()->subDays(10);
$version = ModVersion::factory()->create(['updated_at' => $originalDate]);
$version->downloads++;
$version->save();
$version->refresh();
expect($version->mod->updated_at)->not->toEqual($originalDate)
->and($version->mod->updated_at->format('Y-m-d'))->toEqual(now()->format('Y-m-d'));
});