From 6fbeedd50c6864e5a2cbae7dc104e3192208f5d1 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Wed, 7 Jan 2026 16:37:50 +0000 Subject: [PATCH] feat: implement basic API with authorization and validation --- .github/copilot-instructions.md | 8 +- .gitignore | 1 + GEMINI.md | 1 + app/Http/Controllers/EntryController.php | 92 +++++++++++++++++++ app/Http/Requests/StoreEntryRequest.php | 29 ++++++ app/Http/Requests/UpdateEntryRequest.php | 29 ++++++ app/Models/Entry.php | 14 ++- app/Models/User.php | 3 +- bootstrap/app.php | 1 + cmd/curl_get_entries.sh | 15 +++ cmd/curl_get_entries_anon.sh | 13 +++ cmd/curl_post_entry.sh | 20 ++++ cmd/curl_post_entry_anon.sh | 16 ++++ composer.json | 1 + composer.lock | 65 ++++++++++++- config/sanctum.php | 84 +++++++++++++++++ database/factories/EntryFactory.php | 26 ++++++ ...37_create_personal_access_tokens_table.php | 33 +++++++ docs/decisions/006-api | 53 +++++++++++ routes/api.php | 13 +++ tests/Unit/EntryApiTest.php | 92 +++++++++++++++++++ 21 files changed, 599 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/EntryController.php create mode 100644 app/Http/Requests/StoreEntryRequest.php create mode 100644 app/Http/Requests/UpdateEntryRequest.php create mode 100755 cmd/curl_get_entries.sh create mode 100755 cmd/curl_get_entries_anon.sh create mode 100755 cmd/curl_post_entry.sh create mode 100755 cmd/curl_post_entry_anon.sh create mode 100644 config/sanctum.php create mode 100644 database/factories/EntryFactory.php create mode 100644 database/migrations/2026_01_07_162137_create_personal_access_tokens_table.php create mode 100644 docs/decisions/006-api create mode 100644 routes/api.php create mode 100644 tests/Unit/EntryApiTest.php diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0077032..a386d2f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v3 - laravel/dusk (DUSK) - v8 @@ -44,13 +45,6 @@ This application is a Laravel application and its main Laravel ecosystems packag ## Documentation Files - You must only create documentation files if explicitly requested by the user. -## Testing Approach - CRITICAL -- **THE APPLICATION IS 100% WORKING** - All functionality works perfectly in production. -- When writing or debugging browser tests (Laravel Dusk), focus ONLY on test syntax, selectors, and Dusk interaction methods. -- NEVER assume the application has bugs or suggest app fixes - the issue is always in the test code. -- Trust the existing functionality and work on getting the correct CSS selectors, XPath expressions, and Dusk methods. -- If manual interaction works but the test fails, the problem is the test implementation, not the app. - === boost rules === diff --git a/.gitignore b/.gitignore index 6880550..d2bb60d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ yarn-error.log *.deleted .env.dusk.local log*.txt +.envrc diff --git a/GEMINI.md b/GEMINI.md index a715f25..a386d2f 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -13,6 +13,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v3 - laravel/dusk (DUSK) - v8 diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php new file mode 100644 index 0000000..21b0d6a --- /dev/null +++ b/app/Http/Controllers/EntryController.php @@ -0,0 +1,92 @@ +email !== config('app.admin_email')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'content' => 'required|string', + ]); + + $validated['slug'] = $this->generateUniqueSlug($validated['title']); + + return Entry::create($validated); + } + + private function generateUniqueSlug(string $title): string + { + do { + $slug = Str::slug($title) . '-' . Str::random(8); + } while (Entry::where('slug', $slug)->exists()); + + return $slug; + } + + /** + * Display the specified resource. + */ + public function show(Entry $entry) + { + $this->authorize('view', $entry); + + return $entry; + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, Entry $entry) + { + $validated = $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'content' => 'sometimes|required|string', + ]); + + $entry->update($validated); + + return $entry; + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Entry $entry) + { + $entry->delete(); + + return response()->noContent(); + } + + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return Auth::check(); + } +} diff --git a/app/Http/Requests/StoreEntryRequest.php b/app/Http/Requests/StoreEntryRequest.php new file mode 100644 index 0000000..ef2ec7b --- /dev/null +++ b/app/Http/Requests/StoreEntryRequest.php @@ -0,0 +1,29 @@ +user() && $this->user()->email === config('app.admin_email'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'title' => 'required|string|max:255', + 'content' => 'required|string', + ]; + } +} diff --git a/app/Http/Requests/UpdateEntryRequest.php b/app/Http/Requests/UpdateEntryRequest.php new file mode 100644 index 0000000..3d85337 --- /dev/null +++ b/app/Http/Requests/UpdateEntryRequest.php @@ -0,0 +1,29 @@ +user() && $this->user()->email === config('app.admin_email'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'title' => 'sometimes|required|string|max:255', + 'content' => 'sometimes|required|string', + ]; + } +} diff --git a/app/Models/Entry.php b/app/Models/Entry.php index e345941..98d1e2e 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -5,7 +5,9 @@ namespace App\Models; use Filament\Forms\Components\RichEditor\FileAttachmentProviders\SpatieMediaLibraryFileAttachmentProvider; use Filament\Forms\Components\RichEditor\Models\Concerns\InteractsWithRichContent; use Filament\Forms\Components\RichEditor\Models\Contracts\HasRichContent; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; @@ -17,7 +19,7 @@ class Entry extends Model implements HasRichContent, HasMedia { - use InteractsWithMedia, InteractsWithRichContent; + use InteractsWithMedia, InteractsWithRichContent, HasFactory; protected $fillable = [ 'title', @@ -43,4 +45,14 @@ class Entry extends Model implements HasRichContent, HasMedia ); } + protected static function boot() + { + parent::boot(); + + static::creating(function ($entry) { + if (empty($entry->slug)) { + $entry->slug = Str::slug($entry->title); + } + }); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 8cd9bb8..e27a401 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,11 +13,12 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Log as FacadesLog; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements FilamentUser { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasFactory, Notifiable, TwoFactorAuthenticatable, HasApiTokens; /** * The attributes that are mass assignable. diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..c3928c5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/cmd/curl_get_entries.sh b/cmd/curl_get_entries.sh new file mode 100755 index 0000000..79a1274 --- /dev/null +++ b/cmd/curl_get_entries.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# TOKEN="your_api_token_here" +# ensure to have set TOKEN to a valid value before running +# ideally add this to an .envrc file and source it +# tokens need to be created with tinker or similar method + + +URL='http://127.0.0.1:8000/api/entries' + +curl -s -X GET \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" \ + $URL + diff --git a/cmd/curl_get_entries_anon.sh b/cmd/curl_get_entries_anon.sh new file mode 100755 index 0000000..fe619d9 --- /dev/null +++ b/cmd/curl_get_entries_anon.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# this should fail, no token provided +# users need to be authenticated and have been +# granted access to view entries by being given +# a token + +URL='http://127.0.0.1:8000/api/entries' + +curl -s -X GET \ + -H "Accept: application/json" \ + $URL + diff --git a/cmd/curl_post_entry.sh b/cmd/curl_post_entry.sh new file mode 100755 index 0000000..0b2a74d --- /dev/null +++ b/cmd/curl_post_entry.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# TOKEN="your_api_token_here" +# ensure to have set TOKEN to a valid value before running +# ideally add this to an .envrc file and source it +# only the admin user can create entries so this should +# fail unless .env has ADMIN_EMAIL set to the user that +# the token belongs to + +URL='http://127.0.0.1:8000/api/entries' + +curl -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Yet Another New Entry Title", + "content": "This is the content yet again of the new entry." + }' \ + $URL \ No newline at end of file diff --git a/cmd/curl_post_entry_anon.sh b/cmd/curl_post_entry_anon.sh new file mode 100755 index 0000000..7bb22c6 --- /dev/null +++ b/cmd/curl_post_entry_anon.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# this should fail as no token is provided +# user is not authenticated +# no token has been granted + +URL='http://127.0.0.1:8000/api/entries' + +curl -X POST \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Yet Another New Entry Title", + "content": "This is the content yet again of the new entry." + }' \ + $URL \ No newline at end of file diff --git a/composer.json b/composer.json index 1e5cca6..3d29291 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "filament/spatie-laravel-media-library-plugin": "^4.4", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.9.0", "spatie/laravel-medialibrary": "^11.17" diff --git a/composer.lock b/composer.lock index 83dd478..4eaf27f 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "8074d7e5af3ede59ab12a880d70b7989", + "content-hash": "32f224d40d70f511e2d89add38a0d2cf", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2645,6 +2645,69 @@ }, "time": "2025-11-21T20:52:52+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.2.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fd447754d2d3f56950d53b930128af2e3b617de9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd447754d2d3f56950d53b930128af2e3b617de9", + "reference": "fd447754d2d3f56950d53b930128af2e3b617de9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-01-06T23:11:51+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.7", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/factories/EntryFactory.php b/database/factories/EntryFactory.php new file mode 100644 index 0000000..9d9d947 --- /dev/null +++ b/database/factories/EntryFactory.php @@ -0,0 +1,26 @@ + + */ +class EntryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'title' => $this->faker->sentence, + 'content' => $this->faker->paragraph, + 'slug' => Str::slug($this->faker->sentence) . '-' . Str::uuid(), + ]; + } +} diff --git a/database/migrations/2026_01_07_162137_create_personal_access_tokens_table.php b/database/migrations/2026_01_07_162137_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_01_07_162137_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/docs/decisions/006-api b/docs/decisions/006-api new file mode 100644 index 0000000..b6d8c60 --- /dev/null +++ b/docs/decisions/006-api @@ -0,0 +1,53 @@ +## known limitations + +at this stage, I just want a simple read api but have added some CRUD so as to have it in part and to test access is in place + +users need to be authenticated and have been given a token to read from entries + +an admin user can create entries however images cannot be uploaded or associated with posts, this is not a requirement to get static site generation in play + +## creating and using tokens + +this is in tinker for now + +```php +> $user = User::find(2); += App\Models\User {#7224 + id: 2, + name: "jon@test.com", + email: "jon@test.com", + email_verified_at: null, + #password: "...", + #remember_token: null, + created_at: "2026-01-04 16:28:21", + updated_at: "2026-01-04 16:28:21", + #two_factor_secret: null, + #two_factor_recovery_codes: null, + two_factor_confirmed_at: null, + } + +> $token = $user->createToken('API Token')->plainTextToken; += "generated token-string-shown-here" +``` + +then this token can be used to get posts + +```bash +curl -X GET \ + -H "Authorization: Bearer " \ + -H "Accept: application/json" \ + http://your-laravel-app.test/api/entries + +``` + +to delete a user token, again back in tinker + +```php +// having already found a user as in the above ... +// .. +$user->tokens()->delete(); +// +// or +$user->tokens()->where('id', $tokenId)->delete(); +``` + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..fce0a30 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,13 @@ +group(function () { + Route::get('/entries', [EntryController::class, 'index']); + Route::post('/entries', [EntryController::class, 'store']); + Route::get('/entries/{entry}', [EntryController::class, 'show']); + Route::put('/entries/{entry}', [EntryController::class, 'update']); + Route::delete('/entries/{entry}', [EntryController::class, 'destroy']); +}); diff --git a/tests/Unit/EntryApiTest.php b/tests/Unit/EntryApiTest.php new file mode 100644 index 0000000..cbd3e33 --- /dev/null +++ b/tests/Unit/EntryApiTest.php @@ -0,0 +1,92 @@ +create(); + Entry::factory()->count(3)->create(); + + $response = $this->actingAs($user)->getJson('/api/entries'); + + $response->assertOk() + ->assertJsonCount(3); +}); + +test('can create an entry', function () { + $admin = User::factory()->create(['email' => config('app.admin_email')]); + + $this->actingAs($admin); + + $data = [ + 'title' => 'Sample Title', + 'content' => 'Sample Content', + ]; + + $response = $this->postJson('/api/entries', $data); + + $response->assertCreated() + ->assertJsonFragment($data); +}); + +test('can show an entry', function () { + $user = User::factory()->create(); + $entry = Entry::factory()->create(); + + $response = $this->actingAs($user)->getJson("/api/entries/{$entry->id}"); + + $response->assertOk() + ->assertJsonFragment(['id' => $entry->id]); +}); + +test('can update an entry', function () { + $user = User::factory()->create(); + $entry = Entry::factory()->create(); + $data = ['title' => 'Updated Title']; + + $response = $this->actingAs($user)->putJson("/api/entries/{$entry->id}", $data); + + $response->assertOk() + ->assertJsonFragment($data); +}); + +test('can delete an entry', function () { + $user = User::factory()->create(); + $entry = Entry::factory()->create(); + + $response = $this->actingAs($user)->deleteJson("/api/entries/{$entry->id}"); + + $response->assertNoContent(); + + $this->assertDatabaseMissing('entries', ['id' => $entry->id]); +}); + +test('only admin can create entries', function () { + $adminEmail = Config::get('app.admin_email'); + $user = User::factory()->create(['email' => $adminEmail]); + + $this->actingAs($user) + ->postJson('/api/entries', ['title' => 'Test', 'content' => 'Test content']) + ->assertCreated(); + + $nonAdmin = User::factory()->create(['email' => 'nonadmin@example.com']); + + $this->actingAs($nonAdmin) + ->postJson('/api/entries', ['title' => 'Test', 'content' => 'Test content']) + ->assertForbidden(); +}); + +test('authenticated users can read entries', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson('/api/entries') + ->assertOk(); + + $this->getJson('/api/entries') + ->assertOk(); +});