feature: add api for categories

feature: add initial docker build and compose for development mode
This commit is contained in:
jon brookes 2026-01-15 15:29:42 +00:00
parent 6bf486e52b
commit f980be8e28
14 changed files with 520 additions and 0 deletions

28
.dockerignore Normal file
View file

@ -0,0 +1,28 @@
# Ignore .env files
.env
.env.*
# Ignore node_modules
node_modules/
# Ignore vendor folder
vendor/
# Ignore log files
*.log
# Ignore IDE and editor files
.idea/
.vscode/
# Ignore system files
.DS_Store
Thumbs.db
# Ignore Laravel storage
/storage/*.key
/storage/*.log
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/views/*
/storage/logs/*

89
Dockerfile Normal file
View file

@ -0,0 +1,89 @@
FROM php:8.5-fpm-alpine
# Copy composer.lock and composer.json
COPY composer.lock composer.json /var/www/
# Set working directory
WORKDIR /var/www
# # Install dependencies
RUN apk update && apk add --no-cache \
build-base \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
zip \
jpegoptim optipng pngquant gifsicle \
vim \
unzip \
git \
curl \
libzip-dev \
oniguruma-dev \
icu-dev \
sqlite \
sqlite-dev \
nodejs \
npm
# # Clear cache
RUN rm -rf /var/cache/apk/*
# # Install extensions
RUN docker-php-ext-install mbstring zip exif pcntl intl pdo_sqlite
RUN docker-php-ext-install gd
# # Install composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Add user for laravel application
RUN addgroup -g 1000 www
RUN adduser -u 1000 -G www -s /bin/sh -D www
# # Copy existing application directory contents
COPY . /var/www
# # Copy existing application directory permissions
COPY --chown=www-data:www-data . /var/www
# # Install PHP dependencies
RUN composer install --no-dev --optimize-autoloader
# Install Node.js dependencies
RUN npm install
# Build assets
RUN npm run build
# Change ownership of /var/www to www-data
RUN chmod -R u+rw /var/www && chown -R www:www /var/www
# # RUN php artisan optimize
# # removed as it calls all 4 commands below and fails du to
# # multi-site configuration
# # Clear any cached configurations
RUN php artisan config:clear
# RUN php artisan cache:clear
RUN php artisan route:clear
RUN php artisan view:clear
# # Build optimizations
RUN php artisan config:cache
RUN php artisan event:cache
RUN php artisan view:cache
# # RUN php artisan route:cache
# # Run Laravel artisan command
RUN php artisan storage:link
# # RUN composer install --optimize-autoloader --no-dev
# # Change current user to www
USER www
# # Expose port 9000 and start php-fpm server
# EXPOSE 9000

View file

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreCategoryRequest;
use App\Http\Requests\UpdateCategoryRequest;
use App\Http\Resources\CategoryResource;
use App\Models\Category;
class CategoryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return CategoryResource::collection(Category::all());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreCategoryRequest $request)
{
return new CategoryResource(Category::create($request->validated()));
}
/**
* Display the specified resource.
*/
public function show(Category $category)
{
return new CategoryResource($category);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateCategoryRequest $request, Category $category)
{
$category->update($request->validated());
return new CategoryResource($category);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Category $category)
{
$user = auth()->user();
if (! $user || $user->email !== config('app.admin_email')) {
return response()->json(['message' => 'Forbidden'], 403);
}
$category->delete();
return response()->noContent();
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreCategoryRequest 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 [
'name' => 'required|string|max:255|unique:categories,name',
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCategoryRequest 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 [
'name' => 'sometimes|required|string|max:255|unique:categories,name,'.$this->category->id,
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CategoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -2,9 +2,12 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class Category extends Model class Category extends Model
{ {
use HasFactory;
protected $fillable = ['name']; protected $fillable = ['name'];
} }

11
cmd/build_container.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
LARAVEL_CONTAINER_NAME="quay.io/marshyon/share-lt"
CONTAINER_LABEL="0.0.1"
CACHE="--no-cache"
CACHE=""
docker build \
$CACHE \
-t ${LARAVEL_CONTAINER_NAME}:${CONTAINER_LABEL} \
-f Dockerfile .

15
cmd/curl_get_categories.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/categories'
curl -s -X GET \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
$URL

14
cmd/curl_get_categories_anon.sh Executable file
View file

@ -0,0 +1,14 @@
#!/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/categories'
curl -s -X GET \
-H "Accept: application/json" \
$URL

View file

@ -0,0 +1,23 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Category>
*/
class CategoryFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->unique()->words(2, true),
];
}
}

18
docker-compose-dev.yml Normal file
View file

@ -0,0 +1,18 @@
services:
#PHP Service
app:
image: quay.io/marshyon/share-lt:0.0.1
ports:
- "8000:8000"
command: sh -c "php artisan optimize:clear && php artisan serve --host=0.0.0.0 --port=8000"
volumes:
- ./.env:/var/www/.env
- ./database/database.sqlite:/var/www/database/database.sqlite
- ./storage:/var/www/storage
restart: unless-stopped
tty: true
working_dir: /var/www

View file

@ -1,10 +1,17 @@
<?php <?php
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\EntryController; use App\Http\Controllers\EntryController;
use App\Http\Controllers\TextWidgetController; use App\Http\Controllers\TextWidgetController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () {
Route::get('/categories', [CategoryController::class, 'index']);
Route::post('/categories', [CategoryController::class, 'store']);
Route::get('/categories/{category}', [CategoryController::class, 'show']);
Route::put('/categories/{category}', [CategoryController::class, 'update']);
Route::delete('/categories/{category}', [CategoryController::class, 'destroy']);
Route::get('/entries', [EntryController::class, 'index']); Route::get('/entries', [EntryController::class, 'index']);
Route::post('/entries', [EntryController::class, 'store']); Route::post('/entries', [EntryController::class, 'store']);
Route::get('/entries/{entry}', [EntryController::class, 'show']); Route::get('/entries/{entry}', [EntryController::class, 'show']);

View file

@ -0,0 +1,171 @@
<?php
use App\Models\Category;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\deleteJson;
use function Pest\Laravel\getJson;
use function Pest\Laravel\postJson;
use function Pest\Laravel\putJson;
uses(RefreshDatabase::class);
test('index returns all categories', function () {
$user = User::factory()->create();
Category::factory()->count(3)->create();
actingAs($user)
->getJson('/api/categories')
->assertSuccessful()
->assertJsonCount(3, 'data');
});
test('index requires authentication', function () {
getJson('/api/categories')->assertUnauthorized();
});
test('store creates a new category when user is admin', function () {
$adminEmail = config('app.admin_email');
$admin = User::factory()->create(['email' => $adminEmail]);
actingAs($admin)
->postJson('/api/categories', ['name' => 'Technology'])
->assertSuccessful()
->assertJsonPath('data.name', 'Technology');
expect(Category::where('name', 'Technology')->exists())->toBeTrue();
});
test('store fails when name is missing', function () {
$adminEmail = config('app.admin_email');
$admin = User::factory()->create(['email' => $adminEmail]);
actingAs($admin)
->postJson('/api/categories', [])
->assertUnprocessable()
->assertJsonValidationErrors(['name']);
});
test('store fails when name is not unique', function () {
$adminEmail = config('app.admin_email');
$admin = User::factory()->create(['email' => $adminEmail]);
Category::factory()->create(['name' => 'Existing']);
actingAs($admin)
->postJson('/api/categories', ['name' => 'Existing'])
->assertUnprocessable()
->assertJsonValidationErrors(['name']);
});
test('store fails when user is not admin', function () {
$user = User::factory()->create();
actingAs($user)
->postJson('/api/categories', ['name' => 'Technology'])
->assertForbidden();
});
test('store requires authentication', function () {
postJson('/api/categories', ['name' => 'Technology'])->assertUnauthorized();
});
test('show returns a single category', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['name' => 'Science']);
actingAs($user)
->getJson("/api/categories/{$category->id}")
->assertSuccessful()
->assertJsonPath('data.name', 'Science')
->assertJsonPath('data.id', $category->id);
});
test('show requires authentication', function () {
$category = Category::factory()->create();
getJson("/api/categories/{$category->id}")->assertUnauthorized();
});
test('update modifies an existing category when user is admin', function () {
$adminEmail = config('app.admin_email');
$admin = User::factory()->create(['email' => $adminEmail]);
$category = Category::factory()->create(['name' => 'Old Name']);
actingAs($admin)
->putJson("/api/categories/{$category->id}", ['name' => 'New Name'])
->assertSuccessful()
->assertJsonPath('data.name', 'New Name');
expect($category->refresh()->name)->toBe('New Name');
});
test('update allows partial updates', function () {
$adminEmail = config('app.admin_email');
$admin = User::factory()->create(['email' => $adminEmail]);
$category = Category::factory()->create(['name' => 'Original']);
actingAs($admin)
->putJson("/api/categories/{$category->id}", [])
->assertSuccessful();
expect($category->refresh()->name)->toBe('Original');
});
test('update fails when name is not unique', function () {
$adminEmail = config('app.admin_email');
$admin = User::factory()->create(['email' => $adminEmail]);
Category::factory()->create(['name' => 'Existing']);
$category = Category::factory()->create(['name' => 'Original']);
actingAs($admin)
->putJson("/api/categories/{$category->id}", ['name' => 'Existing'])
->assertUnprocessable()
->assertJsonValidationErrors(['name']);
});
test('update fails when user is not admin', function () {
$user = User::factory()->create();
$category = Category::factory()->create();
actingAs($user)
->putJson("/api/categories/{$category->id}", ['name' => 'New Name'])
->assertForbidden();
});
test('update requires authentication', function () {
$category = Category::factory()->create();
putJson("/api/categories/{$category->id}", ['name' => 'New Name'])->assertUnauthorized();
});
test('destroy deletes a category when user is admin', function () {
$adminEmail = config('app.admin_email');
$admin = User::factory()->create(['email' => $adminEmail]);
$category = Category::factory()->create();
actingAs($admin)
->deleteJson("/api/categories/{$category->id}")
->assertNoContent();
expect(Category::find($category->id))->toBeNull();
});
test('destroy fails when user is not admin', function () {
$user = User::factory()->create();
$category = Category::factory()->create();
actingAs($user)
->deleteJson("/api/categories/{$category->id}")
->assertForbidden();
});
test('destroy requires authentication', function () {
$category = Category::factory()->create();
deleteJson("/api/categories/{$category->id}")->assertUnauthorized();
});