Skip to content

Commit fa3831e

Browse files
authored
Merge pull request #22 from mubbi/feature/get-articles-api
Feature/get articles api
2 parents 6bae7b1 + 644b63a commit fa3831e

File tree

12 files changed

+822
-8
lines changed

12 files changed

+822
-8
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: 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/Models/Article.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Database\Eloquent\Factories\HasFactory;
99
use Illuminate\Database\Eloquent\Model;
1010
use Illuminate\Database\Eloquent\Relations\BelongsTo;
11+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
1112
use Illuminate\Database\Eloquent\Relations\HasMany;
1213

1314
/**
@@ -81,4 +82,28 @@ public function comments(): HasMany
8182
{
8283
return $this->hasMany(Comment::class);
8384
}
85+
86+
/**
87+
* @return BelongsToMany<Category,Article>
88+
*/
89+
public function categories(): BelongsToMany
90+
{
91+
return $this->belongsToMany(Category::class, 'article_categories');
92+
}
93+
94+
/**
95+
* @return BelongsToMany<Tag,Article>
96+
*/
97+
public function tags(): BelongsToMany
98+
{
99+
return $this->belongsToMany(Tag::class, 'article_tags');
100+
}
101+
102+
/**
103+
* @return BelongsToMany<User,Article>
104+
*/
105+
public function authors(): BelongsToMany
106+
{
107+
return $this->belongsToMany(User::class, 'article_authors')->withPivot('role');
108+
}
84109
}

app/Models/Category.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
use Illuminate\Database\Eloquent\Factories\HasFactory;
88
use Illuminate\Database\Eloquent\Model;
9-
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
1010

1111
/**
1212
* @property int $id
@@ -34,10 +34,10 @@ protected function casts(): array
3434
}
3535

3636
/**
37-
* @return HasMany<ArticleCategory,Category>
37+
* @return BelongsToMany<Article,Category>
3838
*/
39-
public function articles(): HasMany
39+
public function articles(): BelongsToMany
4040
{
41-
return $this->hasMany(ArticleCategory::class);
41+
return $this->belongsToMany(Article::class, 'article_categories');
4242
}
4343
}

app/Models/Tag.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Illuminate\Database\Eloquent\Factories\HasFactory;
88
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
910

1011
/**
1112
* @property int $id
@@ -33,4 +34,12 @@ protected function casts(): array
3334
{
3435
return [];
3536
}
37+
38+
/**
39+
* @return BelongsToMany<Article,Tag>
40+
*/
41+
public function articles(): BelongsToMany
42+
{
43+
return $this->belongsToMany(Article::class, 'article_tags');
44+
}
3645
}

0 commit comments

Comments
 (0)