Skip to content

Commit dadb3ff

Browse files
authored
Merge pull request #23 from mubbi/develop
feat: articles API
2 parents 48c23b6 + fa3831e commit dadb3ff

File tree

15 files changed

+922
-17
lines changed

15 files changed

+922
-17
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Api\V1\Article;
6+
7+
use App\Http\Controllers\Controller;
8+
use App\Http\Requests\Api\V1\Article\GetArticlesRequest;
9+
use App\Http\Resources\Api\V1\Article\ArticleResource;
10+
use App\Services\ArticleService;
11+
use Dedoc\Scramble\Attributes\Group;
12+
use Illuminate\Http\JsonResponse;
13+
use Symfony\Component\HttpFoundation\Response;
14+
15+
#[Group('Articles', weight: 1)]
16+
class GetArticlesController extends Controller
17+
{
18+
public function __construct(
19+
private readonly ArticleService $articleService
20+
) {}
21+
22+
/**
23+
* Get Articles List
24+
*
25+
* Retrieve a paginated list of articles with optional filtering by category, tags, author, and search terms
26+
*
27+
* @unauthenticated
28+
*
29+
* @response array{status: true, message: string, data: array{data: ArticleResource[], links: array, meta: array}}
30+
*/
31+
public function __invoke(GetArticlesRequest $request): JsonResponse
32+
{
33+
try {
34+
$params = $request->withDefaults();
35+
36+
$articles = $this->articleService->getArticles($params);
37+
38+
$articleCollection = ArticleResource::collection($articles);
39+
40+
/**
41+
* Successful articles retrieval
42+
*/
43+
$articleCollectionData = $articleCollection->response()->getData(true);
44+
45+
// Ensure we have the expected array structure
46+
if (! is_array($articleCollectionData) || ! isset($articleCollectionData['data'], $articleCollectionData['meta'])) {
47+
throw new \RuntimeException('Unexpected response format from ArticleResource collection');
48+
}
49+
50+
return response()->apiSuccess(
51+
[
52+
'articles' => $articleCollectionData['data'],
53+
'meta' => $articleCollectionData['meta'],
54+
],
55+
__('common.success')
56+
);
57+
} catch (\Throwable $e) {
58+
/**
59+
* Internal server error
60+
*
61+
* @status 500
62+
*
63+
* @body array{status: false, message: string, data: null, error: null}
64+
*/
65+
return response()->apiError(
66+
__('common.something_went_wrong'),
67+
Response::HTTP_INTERNAL_SERVER_ERROR
68+
);
69+
}
70+
}
71+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Api\V1\Article;
6+
7+
use App\Http\Controllers\Controller;
8+
use App\Http\Resources\Api\V1\Article\ArticleResource;
9+
use App\Services\ArticleService;
10+
use Dedoc\Scramble\Attributes\Group;
11+
use Illuminate\Database\Eloquent\ModelNotFoundException;
12+
use Illuminate\Http\JsonResponse;
13+
use Symfony\Component\HttpFoundation\Response;
14+
15+
#[Group('Articles', weight: 1)]
16+
class ShowArticleController extends Controller
17+
{
18+
public function __construct(
19+
private readonly ArticleService $articleService
20+
) {}
21+
22+
/**
23+
* Get Article by Slug
24+
*
25+
* Retrieve a specific article by its slug identifier
26+
*
27+
* @unauthenticated
28+
*
29+
* @response array{status: true, message: string, data: ArticleResource}
30+
*/
31+
public function __invoke(string $slug): JsonResponse
32+
{
33+
try {
34+
$article = $this->articleService->getArticleBySlug($slug);
35+
36+
/**
37+
* Successful article retrieval
38+
*/
39+
return response()->apiSuccess(
40+
new ArticleResource($article),
41+
__('common.success')
42+
);
43+
} catch (ModelNotFoundException $e) {
44+
/**
45+
* Article not found
46+
*
47+
* @status 404
48+
*
49+
* @body array{status: false, message: string, data: null, error: null}
50+
*/
51+
return response()->apiError(
52+
__('common.not_found'),
53+
Response::HTTP_NOT_FOUND
54+
);
55+
} catch (\Throwable $e) {
56+
/**
57+
* Internal server error
58+
*
59+
* @status 500
60+
*
61+
* @body array{status: false, message: string, data: null, error: null}
62+
*/
63+
return response()->apiError(
64+
__('common.something_went_wrong'),
65+
Response::HTTP_INTERNAL_SERVER_ERROR
66+
);
67+
}
68+
}
69+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Api\V1\User;
6+
7+
use App\Http\Controllers\Controller;
8+
use App\Http\Resources\V1\Auth\UserResource;
9+
use Dedoc\Scramble\Attributes\Group;
10+
use Illuminate\Http\JsonResponse;
11+
use Illuminate\Http\Request;
12+
13+
#[Group('User', weight: 0)]
14+
class MeController extends Controller
15+
{
16+
/**
17+
* User Profile API
18+
*
19+
* Handle the incoming request to get the authenticated user.
20+
*
21+
* @response array{status: true, message: string, data: UserResource}
22+
*/
23+
public function __invoke(Request $request): JsonResponse
24+
{
25+
/**
26+
* Successful response
27+
*/
28+
29+
/** @var \App\Models\User $user */
30+
$user = $request->user();
31+
$user->load(['roles.permissions']);
32+
33+
return response()->apiSuccess(
34+
new \App\Http\Resources\V1\Auth\UserResource($user),
35+
__('common.success')
36+
);
37+
}
38+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Requests\Api\V1\Article;
6+
7+
use App\Enums\ArticleStatus;
8+
use Illuminate\Foundation\Http\FormRequest;
9+
use Illuminate\Validation\Rule;
10+
11+
class GetArticlesRequest extends FormRequest
12+
{
13+
/**
14+
* Determine if the user is authorized to make this request.
15+
*/
16+
public function authorize(): bool
17+
{
18+
return true;
19+
}
20+
21+
/**
22+
* Get the validation rules that apply to the request.
23+
*
24+
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
25+
*/
26+
public function rules(): array
27+
{
28+
return [
29+
'page' => ['integer', 'min:1'],
30+
'per_page' => ['integer', 'min:1', 'max:100'],
31+
'search' => ['string', 'max:255'],
32+
'status' => [Rule::enum(ArticleStatus::class)],
33+
'category_slug' => ['string'],
34+
'category_slug.*' => ['string', 'exists:categories,slug'],
35+
'tag_slug' => ['string'],
36+
'tag_slug.*' => ['string', 'exists:tags,slug'],
37+
'author_id' => ['integer', 'exists:users,id'],
38+
'created_by' => ['integer', 'exists:users,id'],
39+
'published_after' => ['date'],
40+
'published_before' => ['date'],
41+
'sort_by' => [Rule::in(['title', 'published_at', 'created_at', 'updated_at'])],
42+
'sort_direction' => [Rule::in(['asc', 'desc'])],
43+
];
44+
}
45+
46+
/**
47+
* Get the default values for missing parameters
48+
*
49+
* @return array<string, mixed>
50+
*/
51+
public function withDefaults(): array
52+
{
53+
return array_merge([
54+
'page' => 1,
55+
'per_page' => 15,
56+
'sort_by' => 'published_at',
57+
'sort_direction' => 'desc',
58+
'status' => ArticleStatus::PUBLISHED->value,
59+
], $this->validated());
60+
}
61+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Resources\Api\V1\Article;
6+
7+
use Illuminate\Http\Request;
8+
use Illuminate\Http\Resources\Json\JsonResource;
9+
10+
/**
11+
* @mixin \App\Models\Article
12+
*/
13+
class ArticleResource extends JsonResource
14+
{
15+
/**
16+
* Transform the resource into an array.
17+
*
18+
* @return array<string, mixed>
19+
*/
20+
public function toArray(Request $request): array
21+
{
22+
return [
23+
'id' => $this->id,
24+
'slug' => $this->slug,
25+
'title' => $this->title,
26+
'subtitle' => $this->subtitle,
27+
'excerpt' => $this->excerpt,
28+
'content_html' => $this->content_html,
29+
'content_markdown' => $this->content_markdown,
30+
'featured_image' => $this->featured_image,
31+
'status' => $this->status,
32+
'published_at' => $this->published_at?->toISOString(),
33+
'meta_title' => $this->meta_title,
34+
'meta_description' => $this->meta_description,
35+
'created_at' => $this->created_at?->toISOString(),
36+
'updated_at' => $this->updated_at?->toISOString(),
37+
38+
// Relationships
39+
'author' => $this->whenLoaded('author', function () {
40+
return $this->author ? [
41+
'id' => $this->author->id,
42+
'name' => $this->author->name,
43+
'email' => $this->author->email,
44+
'avatar_url' => $this->author->avatar_url,
45+
'bio' => $this->author->bio,
46+
'twitter' => $this->author->twitter,
47+
'facebook' => $this->author->facebook,
48+
'linkedin' => $this->author->linkedin,
49+
'github' => $this->author->github,
50+
'website' => $this->author->website,
51+
] : null;
52+
}),
53+
54+
'categories' => $this->whenLoaded('categories', function () {
55+
/** @var \Illuminate\Database\Eloquent\Collection<int, \App\Models\Category> $categories */
56+
$categories = $this->categories;
57+
58+
return $categories->map(function ($category) {
59+
return [
60+
'id' => $category->id,
61+
'name' => $category->name,
62+
'slug' => $category->slug,
63+
];
64+
})->values()->all();
65+
}),
66+
67+
'tags' => $this->whenLoaded('tags', function () {
68+
/** @var \Illuminate\Database\Eloquent\Collection<int, \App\Models\Tag> $tags */
69+
$tags = $this->tags;
70+
71+
return $tags->map(function ($tag) {
72+
return [
73+
'id' => $tag->id,
74+
'name' => $tag->name,
75+
'slug' => $tag->slug,
76+
];
77+
})->values()->all();
78+
}),
79+
80+
'authors' => $this->whenLoaded('authors', function () {
81+
/** @var \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $authors */
82+
$authors = $this->authors;
83+
84+
return $authors->map(function ($author) {
85+
/** @var \Illuminate\Database\Eloquent\Relations\Pivot|null $pivot */
86+
$pivot = $author->getAttribute('pivot');
87+
88+
return [
89+
'id' => $author->id,
90+
'name' => $author->name,
91+
'email' => $author->email,
92+
'avatar_url' => $author->avatar_url,
93+
'bio' => $author->bio,
94+
'twitter' => $author->twitter,
95+
'facebook' => $author->facebook,
96+
'linkedin' => $author->linkedin,
97+
'github' => $author->github,
98+
'website' => $author->website,
99+
'role' => $pivot?->getAttribute('role'),
100+
];
101+
})->values()->all();
102+
}),
103+
104+
'comments_count' => $this->whenCounted('comments'),
105+
];
106+
}
107+
}

app/Http/Resources/V1/Auth/UserResource.php

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,16 @@ public function toArray(Request $request): array
4949

5050
return array_values(array_unique($permissionSlugs));
5151
}),
52-
$this->mergeWhen(isset($this->resource->access_token), [
53-
'access_token' => $this->resource->access_token,
54-
'refresh_token' => $this->resource->refresh_token,
55-
'access_token_expires_at' => $this->resource->access_token_expires_at?->toISOString(),
56-
'refresh_token_expires_at' => $this->resource->refresh_token_expires_at?->toISOString(),
57-
'token_type' => 'Bearer',
58-
]),
52+
$this->mergeWhen(
53+
array_key_exists('access_token', $this->resource->getAttributes()),
54+
fn () => [
55+
'access_token' => $this->resource->getAttributes()['access_token'],
56+
'refresh_token' => $this->resource->getAttributes()['refresh_token'] ?? null,
57+
'access_token_expires_at' => optional($this->resource->getAttributes()['access_token_expires_at'] ?? null)?->toISOString(),
58+
'refresh_token_expires_at' => optional($this->resource->getAttributes()['refresh_token_expires_at'] ?? null)?->toISOString(),
59+
'token_type' => 'Bearer',
60+
]
61+
),
5962
];
6063
}
6164
}

0 commit comments

Comments
 (0)