feat: implement basic API

with authorization and validation
This commit is contained in:
jon brookes 2026-01-07 16:37:50 +00:00
parent b033262bd7
commit 6fbeedd50c
21 changed files with 599 additions and 10 deletions

View file

@ -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 ===

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ yarn-error.log
*.deleted
.env.dusk.local
log*.txt
.envrc

View file

@ -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

View file

@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers;
use App\Models\Entry;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class EntryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return Entry::all();
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$user = Auth::user();
if (!$user || $user->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();
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEntryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() && $this->user()->email === config('app.admin_email');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'content' => 'required|string',
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEntryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() && $this->user()->email === config('app.admin_email');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => 'sometimes|required|string|max:255',
'content' => 'sometimes|required|string',
];
}
}

View file

@ -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);
}
});
}
}

View file

@ -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.

View file

@ -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',
)

15
cmd/curl_get_entries.sh Executable file
View file

@ -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

13
cmd/curl_get_entries_anon.sh Executable file
View file

@ -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

20
cmd/curl_post_entry.sh Executable file
View file

@ -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

16
cmd/curl_post_entry_anon.sh Executable file
View file

@ -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

View file

@ -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"

65
composer.lock generated
View file

@ -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",

84
config/sanctum.php Normal file
View file

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => 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,
],
];

View file

@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Entry>
*/
class EntryFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'title' => $this->faker->sentence,
'content' => $this->faker->paragraph,
'slug' => Str::slug($this->faker->sentence) . '-' . Str::uuid(),
];
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->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');
}
};

53
docs/decisions/006-api Normal file
View file

@ -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 <your-token-here>" \
-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();
```

13
routes/api.php Normal file
View file

@ -0,0 +1,13 @@
<?php
use App\Http\Controllers\EntryController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->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']);
});

View file

@ -0,0 +1,92 @@
<?php
use App\Models\Entry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use App\Models\User;
uses(RefreshDatabase::class);
test('can list entries', function () {
$user = User::factory()->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();
});