Skip to content

Commit 27fe518

Browse files
authored
Merge pull request #49 from mubbi/develop
Develop to main
2 parents 7a355d7 + bfdc7ee commit 27fe518

File tree

10 files changed

+243
-142
lines changed

10 files changed

+243
-142
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Middleware;
6+
7+
use App\Models\User;
8+
use Closure;
9+
use Illuminate\Http\Request;
10+
use Laravel\Sanctum\PersonalAccessToken;
11+
12+
/**
13+
* Middleware to optionally authenticate a user via Sanctum token.
14+
* Sets the user resolver to null if no valid token is present.
15+
*/
16+
class OptionalSanctumAuthenticate
17+
{
18+
/**
19+
* Handle an incoming request.
20+
*
21+
* @param Closure(Request): mixed $next
22+
*/
23+
public function handle(Request $request, Closure $next): mixed
24+
{
25+
$user = null;
26+
27+
$token = $request->bearerToken();
28+
if (is_string($token) && $token !== '') {
29+
$accessToken = PersonalAccessToken::findToken($token);
30+
if ($accessToken !== null) {
31+
$tokenable = $accessToken->tokenable;
32+
// Check for 'access-api' ability
33+
if ($tokenable instanceof User && $accessToken->can('access-api')) {
34+
$user = $tokenable;
35+
}
36+
}
37+
}
38+
39+
$request->setUserResolver(static fn (): ?User => $user);
40+
41+
return $next($request);
42+
}
43+
}

app/Http/Resources/V1/Article/ArticleResource.php

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

55
namespace App\Http\Resources\V1\Article;
66

7+
use App\Enums\UserRole;
78
use Illuminate\Http\Request;
89
use Illuminate\Http\Resources\Json\JsonResource;
910

@@ -36,11 +37,12 @@ public function toArray(Request $request): array
3637
'updated_at' => $this->updated_at?->toISOString(),
3738

3839
// Relationships
39-
'author' => $this->whenLoaded('author', function () {
40+
// Original Author
41+
'author' => $this->whenLoaded('author', function () use ($request) {
4042
return $this->author ? [
4143
'id' => $this->author->id,
4244
'name' => $this->author->name,
43-
'email' => $this->author->email,
45+
'email' => $this->when((bool) $request->user()?->hasRole(UserRole::ADMINISTRATOR->value), $this->author->email),
4446
'avatar_url' => $this->author->avatar_url,
4547
'bio' => $this->author->bio,
4648
'twitter' => $this->author->twitter,
@@ -77,26 +79,23 @@ public function toArray(Request $request): array
7779
})->values()->all();
7880
}),
7981

80-
'authors' => $this->whenLoaded('authors', function () {
82+
// Co-Authors
83+
'authors' => $this->whenLoaded('authors', function () use ($request) {
8184
/** @var \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $authors */
8285
$authors = $this->authors;
8386

84-
return $authors->map(function ($author) {
85-
/** @var \Illuminate\Database\Eloquent\Relations\Pivot|null $pivot */
86-
$pivot = $author->getAttribute('pivot');
87-
87+
return $authors->map(function ($author) use ($request) {
8888
return [
8989
'id' => $author->id,
9090
'name' => $author->name,
91-
'email' => $author->email,
91+
'email' => $this->when((bool) $request->user()?->hasRole(UserRole::ADMINISTRATOR->value), $author->email),
9292
'avatar_url' => $author->avatar_url,
9393
'bio' => $author->bio,
9494
'twitter' => $author->twitter,
9595
'facebook' => $author->facebook,
9696
'linkedin' => $author->linkedin,
9797
'github' => $author->github,
9898
'website' => $author->website,
99-
'role' => $pivot?->getAttribute('role'),
10099
];
101100
})->values()->all();
102101
}),

app/Services/ArticleService.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ class ArticleService
2222
public function getArticles(array $params): LengthAwarePaginator
2323
{
2424
$query = Article::query()
25-
->with(['author:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website', 'categories:id,name,slug', 'tags:id,name,slug'])
25+
->with([
26+
'author:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website',
27+
'categories:id,name,slug',
28+
'tags:id,name,slug',
29+
'authors:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website',
30+
])
2631
->withCount('comments');
2732

2833
// Apply filters

bootstrap/app.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
'ability' => \App\Http\Middleware\CheckTokenAbility::class,
1919
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
2020
'api.logger' => \App\Http\Middleware\ApiLogger::class,
21+
'optional.sanctum' => \App\Http\Middleware\OptionalSanctumAuthenticate::class,
2122
]);
2223
})
2324
->withExceptions(function (Exceptions $exceptions): void {

database/seeders/ArticleCommentSeeder.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ public function run(): void
2828
$articles = Article::factory(100)->create();
2929

3030
foreach ($articles as $article) {
31+
// Add 0-3 authors to the authors relation, excluding the main author/created_by
32+
$possibleAuthors = $users->where('id', '!=', $article->created_by);
33+
$authorCount = rand(0, 3);
34+
if ($authorCount > 0 && $possibleAuthors->count() > 0) {
35+
$authorIds = $possibleAuthors->random(min($authorCount, $possibleAuthors->count()))->pluck('id')->toArray();
36+
$article->authors()->attach($authorIds);
37+
}
3138
// Attach 1-3 random categories to each article
3239
$article->categories()->attach($categories->random(rand(1, 3))->pluck('id')->toArray());
3340
// Attach 2-5 random tags to each article

routes/api_v1.php

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,20 @@
1919
Route::post('/auth/logout', \App\Http\Controllers\Api\V1\Auth\LogoutController::class)->name('api.v1.auth.logout');
2020
});
2121

22-
// Article Routes (Public)
23-
Route::prefix('articles')->group(function () {
24-
Route::get('/', \App\Http\Controllers\Api\V1\Article\GetArticlesController::class)->name('api.v1.articles.index');
25-
Route::get('/{slug}', \App\Http\Controllers\Api\V1\Article\ShowArticleController::class)->name('api.v1.articles.show');
26-
Route::get('/{article:slug}/comments', \App\Http\Controllers\Api\V1\Article\GetCommentsController::class)->name('api.v1.articles.comments.index');
22+
// Public Routes
23+
Route::middleware(['optional.sanctum'])->group(function () {
24+
// Article Routes
25+
Route::prefix('articles')->group(function () {
26+
Route::get('/', \App\Http\Controllers\Api\V1\Article\GetArticlesController::class)->name('api.v1.articles.index');
27+
Route::get('/{slug}', \App\Http\Controllers\Api\V1\Article\ShowArticleController::class)->name('api.v1.articles.show');
28+
Route::get('/{article:slug}/comments', \App\Http\Controllers\Api\V1\Article\GetCommentsController::class)->name('api.v1.articles.comments.index');
29+
});
30+
31+
// Category Routes
32+
Route::get('categories', \App\Http\Controllers\Api\V1\Category\GetCategoriesController::class)->name('api.v1.categories.index');
33+
34+
// Tag Routes
35+
Route::get('tags', \App\Http\Controllers\Api\V1\Tag\GetTagsController::class)->name('api.v1.tags.index');
2736
});
2837

29-
// Category Routes (Public)
30-
Route::get('categories', \App\Http\Controllers\Api\V1\Category\GetCategoriesController::class)->name('api.v1.categories.index');
31-
32-
// Tag Routes (Public)
33-
Route::get('tags', \App\Http\Controllers\Api\V1\Tag\GetTagsController::class)->name('api.v1.tags.index');
3438
});

tests/Feature/API/V1/Article/GetArticlesControllerTest.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
->published()
1919
->create();
2020

21-
$response = $this->getJson('/api/v1/articles');
21+
$response = $this->getJson(route('api.v1.articles.index'));
2222

2323
$response->assertStatus(200)
2424
->assertJsonStructure([
@@ -75,7 +75,7 @@
7575
->published()
7676
->create();
7777

78-
$response = $this->getJson("/api/v1/articles?category_slug={$category->slug}");
78+
$response = $this->getJson(route('api.v1.articles.index', ['category_slug' => $category->slug]));
7979

8080
$response->assertStatus(200);
8181
expect($response->json('data.articles'))->toHaveCount(1);
@@ -102,7 +102,7 @@
102102
->published()
103103
->create();
104104

105-
$response = $this->getJson("/api/v1/articles?tag_slug={$tag->slug}");
105+
$response = $this->getJson(route('api.v1.articles.index', ['tag_slug' => $tag->slug]));
106106

107107
$response->assertStatus(200);
108108
expect($response->json('data.articles'))->toHaveCount(1);
@@ -124,7 +124,7 @@
124124
->published()
125125
->create(['title' => 'PHP Best Practices']);
126126

127-
$response = $this->getJson('/api/v1/articles?search=Laravel');
127+
$response = $this->getJson(route('api.v1.articles.index', ['search' => 'Laravel']));
128128

129129
$response->assertStatus(200);
130130
expect($response->json('data.articles'))->toHaveCount(1);
@@ -147,7 +147,7 @@
147147
->published()
148148
->create();
149149

150-
$response = $this->getJson("/api/v1/articles?created_by={$author1->id}");
150+
$response = $this->getJson(route('api.v1.articles.index', ['created_by' => $author1->id]));
151151

152152
$response->assertStatus(200);
153153
expect($response->json('data.articles'))->toHaveCount(1);
@@ -169,7 +169,7 @@
169169
->draft()
170170
->create();
171171

172-
$response = $this->getJson('/api/v1/articles?status=published');
172+
$response = $this->getJson(route('api.v1.articles.index', ['status' => 'published']));
173173

174174
$response->assertStatus(200);
175175
expect($response->json('data.articles'))->toHaveCount(1);
@@ -185,7 +185,7 @@
185185
->published()
186186
->create();
187187

188-
$response = $this->getJson('/api/v1/articles?per_page=5&page=2');
188+
$response = $this->getJson(route('api.v1.articles.index', ['per_page' => 5, 'page' => 2]));
189189

190190
$response->assertStatus(200);
191191
expect($response->json('data.articles'))->toHaveCount(5);
@@ -208,7 +208,7 @@
208208
->published()
209209
->create(['title' => 'Z Article']);
210210

211-
$response = $this->getJson('/api/v1/articles?sort_by=title&sort_direction=asc');
211+
$response = $this->getJson(route('api.v1.articles.index', ['sort_by' => 'title', 'sort_direction' => 'asc']));
212212

213213
$response->assertStatus(200);
214214
expect($response->json('data.articles.0.id'))->toBe($article1->id);
@@ -222,7 +222,7 @@
222222
->andThrow(new \Exception('Database connection failed'));
223223
});
224224

225-
$response = $this->getJson('/api/v1/articles');
225+
$response = $this->getJson(route('api.v1.articles.index'));
226226

227227
$response->assertStatus(500)
228228
->assertJson([

tests/Feature/API/V1/Article/ShowArticleControllerTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
$article->categories()->attach($category->id);
2323
$article->tags()->attach($tag->id);
2424

25-
$response = $this->getJson("/api/v1/articles/{$article->slug}");
25+
$response = $this->getJson(route('api.v1.articles.show', ['slug' => $article->slug]));
2626

2727
$response->assertStatus(200)
2828
->assertJsonStructure([
@@ -46,7 +46,6 @@
4646
'author' => [
4747
'id',
4848
'name',
49-
'email',
5049
'avatar_url',
5150
'bio',
5251
],
@@ -76,6 +75,7 @@
7675
it('returns 404 when article not found by slug', function () {
7776
$response = $this->getJson('/api/v1/articles/non-existent-slug');
7877

78+
$response = $this->getJson(route('api.v1.articles.show', ['slug' => 'non-existent-slug']));
7979
$response->assertStatus(404)
8080
->assertJson([
8181
'status' => false,
@@ -93,7 +93,7 @@
9393
->andThrow(new \Exception('Database connection failed'));
9494
});
9595

96-
$response = $this->getJson('/api/v1/articles/test-slug');
96+
$response = $this->getJson(route('api.v1.articles.show', ['slug' => 'test-slug']));
9797

9898
$response->assertStatus(500)
9999
->assertJson([

0 commit comments

Comments
 (0)