Skip to content
71 changes: 71 additions & 0 deletions app/Http/Controllers/Api/V1/Article/GetArticlesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1\Article;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Article\GetArticlesRequest;
use App\Http\Resources\Api\V1\Article\ArticleResource;
use App\Services\ArticleService;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

#[Group('Articles', weight: 1)]
class GetArticlesController extends Controller
{
public function __construct(
private readonly ArticleService $articleService
) {}

/**
* Get Articles List
*
* Retrieve a paginated list of articles with optional filtering by category, tags, author, and search terms
*
* @unauthenticated
*
* @response array{status: true, message: string, data: array{data: ArticleResource[], links: array, meta: array}}
*/
public function __invoke(GetArticlesRequest $request): JsonResponse
{
try {
$params = $request->withDefaults();

$articles = $this->articleService->getArticles($params);

$articleCollection = ArticleResource::collection($articles);

/**
* Successful articles retrieval
*/
$articleCollectionData = $articleCollection->response()->getData(true);

// Ensure we have the expected array structure
if (! is_array($articleCollectionData) || ! isset($articleCollectionData['data'], $articleCollectionData['meta'])) {
throw new \RuntimeException('Unexpected response format from ArticleResource collection');
}

return response()->apiSuccess(
[
'articles' => $articleCollectionData['data'],
'meta' => $articleCollectionData['meta'],
],
__('common.success')
);
} catch (\Throwable $e) {
/**
* Internal server error
*
* @status 500
*
* @body array{status: false, message: string, data: null, error: null}
*/
return response()->apiError(
__('common.something_went_wrong'),
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
}
69 changes: 69 additions & 0 deletions app/Http/Controllers/Api/V1/Article/ShowArticleController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1\Article;

use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\Article\ArticleResource;
use App\Services\ArticleService;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

#[Group('Articles', weight: 1)]
class ShowArticleController extends Controller
{
public function __construct(
private readonly ArticleService $articleService
) {}

/**
* Get Article by Slug
*
* Retrieve a specific article by its slug identifier
*
* @unauthenticated
*
* @response array{status: true, message: string, data: ArticleResource}
*/
public function __invoke(string $slug): JsonResponse
{
try {
$article = $this->articleService->getArticleBySlug($slug);

/**
* Successful article retrieval
*/
return response()->apiSuccess(
new ArticleResource($article),
__('common.success')
);
} catch (ModelNotFoundException $e) {
/**
* Article not found
*
* @status 404
*
* @body array{status: false, message: string, data: null, error: null}
*/
return response()->apiError(
__('common.not_found'),
Response::HTTP_NOT_FOUND
);
} catch (\Throwable $e) {
/**
* Internal server error
*
* @status 500
*
* @body array{status: false, message: string, data: null, error: null}
*/
return response()->apiError(
__('common.something_went_wrong'),
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
}
38 changes: 38 additions & 0 deletions app/Http/Controllers/Api/V1/User/MeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1\User;

use App\Http\Controllers\Controller;
use App\Http\Resources\V1\Auth\UserResource;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

#[Group('User', weight: 0)]
class MeController extends Controller
{
/**
* User Profile API
*
* Handle the incoming request to get the authenticated user.
*
* @response array{status: true, message: string, data: UserResource}
*/
public function __invoke(Request $request): JsonResponse
{
/**
* Successful response
*/

/** @var \App\Models\User $user */
$user = $request->user();
$user->load(['roles.permissions']);

return response()->apiSuccess(
new \App\Http\Resources\V1\Auth\UserResource($user),
__('common.success')
);
}
}
61 changes: 61 additions & 0 deletions app/Http/Requests/Api/V1/Article/GetArticlesRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\Api\V1\Article;

use App\Enums\ArticleStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class GetArticlesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'page' => ['integer', 'min:1'],
'per_page' => ['integer', 'min:1', 'max:100'],
'search' => ['string', 'max:255'],
'status' => [Rule::enum(ArticleStatus::class)],
'category_slug' => ['string'],
'category_slug.*' => ['string', 'exists:categories,slug'],
'tag_slug' => ['string'],
'tag_slug.*' => ['string', 'exists:tags,slug'],
'author_id' => ['integer', 'exists:users,id'],
'created_by' => ['integer', 'exists:users,id'],
'published_after' => ['date'],
'published_before' => ['date'],
'sort_by' => [Rule::in(['title', 'published_at', 'created_at', 'updated_at'])],
'sort_direction' => [Rule::in(['asc', 'desc'])],
];
}

/**
* Get the default values for missing parameters
*
* @return array<string, mixed>
*/
public function withDefaults(): array
{
return array_merge([
'page' => 1,
'per_page' => 15,
'sort_by' => 'published_at',
'sort_direction' => 'desc',
'status' => ArticleStatus::PUBLISHED->value,
], $this->validated());
}
}
107 changes: 107 additions & 0 deletions app/Http/Resources/Api/V1/Article/ArticleResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace App\Http\Resources\Api\V1\Article;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* @mixin \App\Models\Article
*/
class ArticleResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'title' => $this->title,
'subtitle' => $this->subtitle,
'excerpt' => $this->excerpt,
'content_html' => $this->content_html,
'content_markdown' => $this->content_markdown,
'featured_image' => $this->featured_image,
'status' => $this->status,
'published_at' => $this->published_at?->toISOString(),
'meta_title' => $this->meta_title,
'meta_description' => $this->meta_description,
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),

// Relationships
'author' => $this->whenLoaded('author', function () {
return $this->author ? [
'id' => $this->author->id,
'name' => $this->author->name,
'email' => $this->author->email,
'avatar_url' => $this->author->avatar_url,
'bio' => $this->author->bio,
'twitter' => $this->author->twitter,
'facebook' => $this->author->facebook,
'linkedin' => $this->author->linkedin,
'github' => $this->author->github,
'website' => $this->author->website,
] : null;
}),

'categories' => $this->whenLoaded('categories', function () {
/** @var \Illuminate\Database\Eloquent\Collection<int, \App\Models\Category> $categories */
$categories = $this->categories;

return $categories->map(function ($category) {
return [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
];
})->values()->all();
}),

'tags' => $this->whenLoaded('tags', function () {
/** @var \Illuminate\Database\Eloquent\Collection<int, \App\Models\Tag> $tags */
$tags = $this->tags;

return $tags->map(function ($tag) {
return [
'id' => $tag->id,
'name' => $tag->name,
'slug' => $tag->slug,
];
})->values()->all();
}),

'authors' => $this->whenLoaded('authors', function () {
/** @var \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $authors */
$authors = $this->authors;

return $authors->map(function ($author) {
/** @var \Illuminate\Database\Eloquent\Relations\Pivot|null $pivot */
$pivot = $author->getAttribute('pivot');

return [
'id' => $author->id,
'name' => $author->name,
'email' => $author->email,
'avatar_url' => $author->avatar_url,
'bio' => $author->bio,
'twitter' => $author->twitter,
'facebook' => $author->facebook,
'linkedin' => $author->linkedin,
'github' => $author->github,
'website' => $author->website,
'role' => $pivot?->getAttribute('role'),
];
})->values()->all();
}),

'comments_count' => $this->whenCounted('comments'),
];
}
}
17 changes: 10 additions & 7 deletions app/Http/Resources/V1/Auth/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,16 @@ public function toArray(Request $request): array

return array_values(array_unique($permissionSlugs));
}),
$this->mergeWhen(isset($this->resource->access_token), [
'access_token' => $this->resource->access_token,
'refresh_token' => $this->resource->refresh_token,
'access_token_expires_at' => $this->resource->access_token_expires_at?->toISOString(),
'refresh_token_expires_at' => $this->resource->refresh_token_expires_at?->toISOString(),
'token_type' => 'Bearer',
]),
$this->mergeWhen(
array_key_exists('access_token', $this->resource->getAttributes()),
fn () => [
'access_token' => $this->resource->getAttributes()['access_token'],
'refresh_token' => $this->resource->getAttributes()['refresh_token'] ?? null,
'access_token_expires_at' => optional($this->resource->getAttributes()['access_token_expires_at'] ?? null)?->toISOString(),
'refresh_token_expires_at' => optional($this->resource->getAttributes()['refresh_token_expires_at'] ?? null)?->toISOString(),
'token_type' => 'Bearer',
]
),
];
}
}
Loading