From f980be8e28c18f98f63626b9b4883ab365e7e335 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Thu, 15 Jan 2026 15:29:42 +0000 Subject: [PATCH] feature: add api for categories feature: add initial docker build and compose for development mode --- .dockerignore | 28 ++++ Dockerfile | 89 ++++++++++ app/Http/Controllers/CategoryController.php | 61 +++++++ app/Http/Requests/StoreCategoryRequest.php | 28 ++++ app/Http/Requests/UpdateCategoryRequest.php | 28 ++++ app/Http/Resources/CategoryResource.php | 24 +++ app/Models/Category.php | 3 + cmd/build_container.sh | 11 ++ cmd/curl_get_categories.sh | 15 ++ cmd/curl_get_categories_anon.sh | 14 ++ database/factories/CategoryFactory.php | 23 +++ docker-compose-dev.yml | 18 +++ routes/api.php | 7 + tests/Feature/CategoryApiTest.php | 171 ++++++++++++++++++++ 14 files changed, 520 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 app/Http/Controllers/CategoryController.php create mode 100644 app/Http/Requests/StoreCategoryRequest.php create mode 100644 app/Http/Requests/UpdateCategoryRequest.php create mode 100644 app/Http/Resources/CategoryResource.php create mode 100755 cmd/build_container.sh create mode 100755 cmd/curl_get_categories.sh create mode 100755 cmd/curl_get_categories_anon.sh create mode 100644 database/factories/CategoryFactory.php create mode 100644 docker-compose-dev.yml create mode 100644 tests/Feature/CategoryApiTest.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..30fb8f3 --- /dev/null +++ b/.dockerignore @@ -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/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..196a9b9 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php new file mode 100644 index 0000000..feda710 --- /dev/null +++ b/app/Http/Controllers/CategoryController.php @@ -0,0 +1,61 @@ +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(); + } +} diff --git a/app/Http/Requests/StoreCategoryRequest.php b/app/Http/Requests/StoreCategoryRequest.php new file mode 100644 index 0000000..da5e2ce --- /dev/null +++ b/app/Http/Requests/StoreCategoryRequest.php @@ -0,0 +1,28 @@ +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 [ + 'name' => 'required|string|max:255|unique:categories,name', + ]; + } +} diff --git a/app/Http/Requests/UpdateCategoryRequest.php b/app/Http/Requests/UpdateCategoryRequest.php new file mode 100644 index 0000000..53e0b06 --- /dev/null +++ b/app/Http/Requests/UpdateCategoryRequest.php @@ -0,0 +1,28 @@ +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 [ + 'name' => 'sometimes|required|string|max:255|unique:categories,name,'.$this->category->id, + ]; + } +} diff --git a/app/Http/Resources/CategoryResource.php b/app/Http/Resources/CategoryResource.php new file mode 100644 index 0000000..5c82c39 --- /dev/null +++ b/app/Http/Resources/CategoryResource.php @@ -0,0 +1,24 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php index e82a9cc..6fe7cb4 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -2,9 +2,12 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Category extends Model { + use HasFactory; + protected $fillable = ['name']; } diff --git a/cmd/build_container.sh b/cmd/build_container.sh new file mode 100755 index 0000000..4d6db9a --- /dev/null +++ b/cmd/build_container.sh @@ -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 . diff --git a/cmd/curl_get_categories.sh b/cmd/curl_get_categories.sh new file mode 100755 index 0000000..a50c125 --- /dev/null +++ b/cmd/curl_get_categories.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/categories' + +curl -s -X GET \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" \ + $URL + diff --git a/cmd/curl_get_categories_anon.sh b/cmd/curl_get_categories_anon.sh new file mode 100755 index 0000000..1ae77fa --- /dev/null +++ b/cmd/curl_get_categories_anon.sh @@ -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 + diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..43031ce --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,23 @@ + + */ +class CategoryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->words(2, true), + ]; + } +} diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000..fc6a749 --- /dev/null +++ b/docker-compose-dev.yml @@ -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 + + + diff --git a/routes/api.php b/routes/api.php index 07117ff..b169cb8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,10 +1,17 @@ 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::post('/entries', [EntryController::class, 'store']); Route::get('/entries/{entry}', [EntryController::class, 'show']); diff --git a/tests/Feature/CategoryApiTest.php b/tests/Feature/CategoryApiTest.php new file mode 100644 index 0000000..814d296 --- /dev/null +++ b/tests/Feature/CategoryApiTest.php @@ -0,0 +1,171 @@ +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(); +});