diff --git a/.env.example b/.env.example index 7a68a57..c26c0a9 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,6 @@ MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" L5_SWAGGER_CONST_HOST=${APP_URL}/api +L5_SWAGGER_USE_ABSOLUTE_PATH=true +L5_FORMAT_TO_USE_FOR_DOCS=json +L5_SWAGGER_GENERATE_ALWAYS=true diff --git a/README.md b/README.md index fa48d7b..9006642 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +```markdown # Eleven Soft Backend Refactoring Test Welcome to the Eleven Soft Backend Refactoring Test repository! This Laravel-based project is designed to evaluate your backend skills, particularly in refactoring and improving the existing codebase. @@ -16,7 +17,7 @@ Ensure that you have the following prerequisites installed on your local machine ### Installation and Setup -Ensure that docker is running on your local machine. +Ensure that Docker is running on your local machine. 1. Clone this repository to your local machine: @@ -42,11 +43,11 @@ Ensure that docker is running on your local machine. make up ``` -The application will be accessible at `http://localhost:8000`. +The application will be accessible at `http://localhost:8080`. ### Documentation -The API is documented using Swagger. Ensure that your refactoring maintains or improves the clarity of the API documentation. The Swagger documentation can be accessed at `http://localhost:8000/api/documentation`. +The API is documented using Swagger. Ensure that your refactoring maintains or improves the clarity of the API documentation. The Swagger documentation can be accessed at `http://localhost:8080/api/documentation`. ### Useful Makefile Targets @@ -93,8 +94,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file --- ### Commit Message Format -Each commit message consists of a **header**, a **body** and a **footer**. The header has a special -format that includes a **type** and a **subject**: +Each commit message consists of a **header**, a **body** and a **footer**. The header has a special format that includes a **type** and a **subject**: ``` : @@ -106,8 +106,7 @@ format that includes a **type** and a **subject**: The **header** is mandatory. -Any line of the commit message cannot be longer 100 characters! This allows the message to be easier -to read on GitHub as well as in various git tools. +Any line of the commit message cannot be longer than 100 characters! This allows the message to be easier to read on GitHub as well as in various git tools. The footer should contain a [closing reference to an issue](https://help.github.com/articles/closing-issues-via-commit-messages/) if any. @@ -116,6 +115,7 @@ Samples: (even more [samples](https://github.com/angular/angular/commits/main)) ``` docs(changelog): update changelog to beta.5 ``` + ``` fix(release): need to depend on latest rxjs and zone.js @@ -138,7 +138,6 @@ Must be one of the following: * **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) * **test**: Adding missing tests or correcting existing tests - ### Subject The subject contains a succinct description of the change: @@ -151,12 +150,11 @@ Just as in the **subject**, use the imperative, present tense: "change" not "cha The body should include the motivation for the change and contrast this with previous behavior. ### Footer -The footer should contain any information about **Breaking Changes** and is also the place to -reference GitHub issues that this commit **Closes**. +The footer should contain any information about **Breaking Changes** and is also the place to reference GitHub issues that this commit **Closes**. **Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. A detailed explanation can be found in this [document][commit-message-format]. [commit-message-format]: https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit# - +``` diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index f4a5826..e74e7c8 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -2,14 +2,16 @@ namespace App\Http\Controllers; +use App\Http\Requests\StoreUserRequest; +use App\Http\Requests\UpdateUserRequest; use App\Models\User; -use Illuminate\Http\Request; +use Illuminate\Http\JsonResponse; class UserController extends Controller { private User $user; - function __construct(User $user) + public function __construct(User $user) { $this->user = $user; } @@ -17,7 +19,7 @@ function __construct(User $user) /** * Display a listing of the resource. * - * @return Response + * @return JsonResponse * * @OA\Get( * path="/users", @@ -48,15 +50,17 @@ function __construct(User $user) * ) * ) */ - public function index(Request $request) + public function index(): JsonResponse { - return $this->user->get(); + $users = $this->user->all(); + return response()->json($users, 200); } /** - * Show a specific user resource + * Show a specific user resource. * - * @return User + * @param int $id + * @return JsonResponse * * @OA\Get( * path="/users/{id}", @@ -85,18 +89,29 @@ public function index(Request $request) * @OA\Response( * response=403, * description="Forbidden" + * ), + * @OA\Response( + * response=404, + * description="Not Found" * ) * ) */ - public function show(User $user) + public function show(int $id): JsonResponse { - return $user; + $user = $this->user->find($id); + + if (!$user) { + return response()->json(['error' => 'User not found'], 404); + } + + return response()->json($user, 200); } /** * Store a newly created user in storage. * - * @return User + * @param StoreUserRequest $request + * @return JsonResponse * * @OA\Post( * path="/users", @@ -112,11 +127,15 @@ public function show(User $user) * @OA\JsonContent(ref="#/components/schemas/User") * ), * @OA\Response( - * response=200, + * response=201, * description="Successful operation", * @OA\JsonContent(ref="#/components/schemas/User") * ), * @OA\Response( + * response=400, + * description="Bad Request" + * ), + * @OA\Response( * response=401, * description="Unauthenticated", * ), @@ -126,21 +145,22 @@ public function show(User $user) * ) * ) */ - public function store(Request $request) + public function store(StoreUserRequest $request): JsonResponse { - $data = $request->only([ - 'name', - 'email', - 'password', - ]); + $data = $request->validated(); + $data['password'] = bcrypt($data['password']); + + $user = $this->user->create($data); - return $this->user->create($data); + return response()->json($user, 201); } /** - * Update a specific user resource + * Update a specific user resource. * - * @return User + * @param UpdateUserRequest $request + * @param int $id + * @return JsonResponse * * @OA\Put( * path="/users/{id}", @@ -167,32 +187,47 @@ public function store(Request $request) * @OA\JsonContent(ref="#/components/schemas/User") * ), * @OA\Response( + * response=400, + * description="Bad Request" + * ), + * @OA\Response( * response=401, * description="Unauthenticated", * ), * @OA\Response( * response=403, * description="Forbidden" + * ), + * @OA\Response( + * response=404, + * description="Not Found" * ) * ) */ - public function update(Request $request, User $user) + public function update(UpdateUserRequest $request, int $id): JsonResponse { - $data = $request->only([ - 'name', - 'email', - 'password', - ]); + $user = $this->user->find($id); + + if (!$user) { + return response()->json(['error' => 'User not found'], 404); + } + + $data = $request->validated(); + + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } $user->update($data); - return $user; + return response()->json($user, 200); } /** - * Remove a specific user resource + * Remove a specific user resource. * - * @return User + * @param int $id + * @return JsonResponse * * @OA\Delete( * path="/users/{id}", @@ -210,9 +245,8 @@ public function update(Request $request, User $user) * in="path", * ), * @OA\Response( - * response=200, + * response=204, * description="Successful operation", - * @OA\JsonContent(ref="#/components/schemas/User") * ), * @OA\Response( * response=401, @@ -221,14 +255,23 @@ public function update(Request $request, User $user) * @OA\Response( * response=403, * description="Forbidden" + * ), + * @OA\Response( + * response=404, + * description="Not Found" * ) * ) */ - public function destroy(User $user) + public function destroy(int $id): JsonResponse { + $user = $this->user->find($id); + + if (!$user) { + return response()->json(['error' => 'User not found'], 404); + } + $user->delete(); - return $user; + return response()->json(null, 204); } } - diff --git a/app/Http/Requests/StoreUserRequest.php b/app/Http/Requests/StoreUserRequest.php new file mode 100644 index 0000000..374c180 --- /dev/null +++ b/app/Http/Requests/StoreUserRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8', + ]; + } +} diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php new file mode 100644 index 0000000..d27b45c --- /dev/null +++ b/app/Http/Requests/UpdateUserRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + $id = $this->route('id'); + + return [ + 'name' => 'sometimes|required|string|max:255', + 'email' => 'sometimes|required|string|email|max:255|unique:users,email,' . $id, + 'password' => 'sometimes|required|string|min:8', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8ceeafd..890be41 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Laravel\Sanctum\HasApiTokens; +use Laravel\Passport\HasApiTokens; /** * @OA\Schema( @@ -61,10 +61,8 @@ class User extends Authenticatable * type="integer", * example=1 * ) - * - * @var int */ - private int $id; + public int $id; /** * User name @@ -75,10 +73,8 @@ class User extends Authenticatable * type="string", * example="John Doe" * ) - * - * @var string */ - private string $name; + public string $name; /** * User email @@ -89,10 +85,8 @@ class User extends Authenticatable * type="string", * example="example@elevensoft.dev" * ) - * - * @var string */ - private string $email; + public string $email; /** * User verified at @@ -103,10 +97,8 @@ class User extends Authenticatable * type="datetime", * example="2021-01-01 00:00:00" * ) - * - * @var Carbon */ - private Carbon $email_verified_at; + public ?Carbon $email_verified_at; /** * User password @@ -117,10 +109,8 @@ class User extends Authenticatable * type="string", * example="password" * ) - * - * @var string */ - private string $password; + public string $password; /** * User remember token @@ -131,10 +121,8 @@ class User extends Authenticatable * type="string", * example="token" * ) - * - * @var string */ - private string $remember_token; + public ?string $remember_token; /** * User created at @@ -145,10 +133,8 @@ class User extends Authenticatable * type="datetime", * example="2021-01-01 00:00:00" * ) - * - * @var Carbon */ - private Carbon $created_at; + public ?Carbon $created_at; /** * User updated at @@ -159,9 +145,6 @@ class User extends Authenticatable * type="datetime", * example="2021-01-01 00:00:00" * ) - * - * @var Carbon */ - private Carbon $updated_at; + public ?Carbon $updated_at; } - diff --git a/config/auth.php b/config/auth.php index 9548c15..881f4fb 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,12 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'passport', + 'provider' => 'users', + 'hash' => false, + ], ], /* diff --git a/database/seeders/UserTableSeeder.php b/database/seeders/UserTableSeeder.php index 00fec09..f3dadd4 100644 --- a/database/seeders/UserTableSeeder.php +++ b/database/seeders/UserTableSeeder.php @@ -14,7 +14,7 @@ public function run(): void \App\Models\User::factory()->create([ 'name' => 'example', 'email' => 'example@elevensoft.dev', - 'password' => bcrypt('password') + 'password' => 'password', ]); } } diff --git a/makefile b/makefile index 5d94f95..46bfe59 100644 --- a/makefile +++ b/makefile @@ -1,70 +1,101 @@ +# Variables +DOCKER_COMPOSE = docker-compose +API_CONTAINER = api +DB_CONTAINER = mysql + +# Phony targets +.PHONY: install sleep ps up up-recreate down forget db-shell api-build api-db api-key api-env api-config-cache api-composer-install api-shell api-root-shell api-test api-test-feature api-test-php-unit api-build-swagger api-passport-key api-passport-generate fix-permissions + +# Install and setup the project install: api-env api-composer-install api-build up sleep api-db api-key api-passport-key api-passport-generate +# Sleep for 3 seconds to allow services to start sleep: sleep 3 +# Show Docker container status ps: - docker-compose ps + $(DOCKER_COMPOSE) ps +# Start Docker containers up: - docker-compose up -d + $(DOCKER_COMPOSE) up -d +# Recreate Docker containers up-recreate: - docker-compose up -d --force-recreate + $(DOCKER_COMPOSE) up -d --force-recreate +# Stop and remove Docker containers down: - docker-compose down + $(DOCKER_COMPOSE) down +# Stop and remove Docker containers, images, and volumes forget: - docker-compose down --rmi all --volumes - docker volume rm backend-test_sail-mysql 2>/dev/null + $(DOCKER_COMPOSE) down --rmi all --volumes @docker volume rm backend-test_sail-mysql 2>/dev/null + || echo "Volume not found" +# Access the MySQL shell db-shell: mysql -h 127.0.0.1 -P 3306 -u sail -ppassword +# Build the API Docker container without cache api-build: - USER_ID=$(shell id -u) GROUP_ID=$(shell id -g) docker-compose build --no-cache + USER_ID=$(shell id -u) GROUP_ID=$(shell id -g) $(DOCKER_COMPOSE) build --no-cache +# Run database migrations and seed the database api-db: - docker-compose exec -it api php /var/www/html/artisan migrate:fresh - docker-compose exec -it api php /var/www/html/artisan db:seed + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan migrate:fresh + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan db:seed +# Generate the application key api-key: - docker-compose exec -it api php /var/www/html/artisan key:generate + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan key:generate +# Copy the example environment file api-env: cp .env.example .env +# Cache the configuration api-config-cache: - docker-compose exec -it api php /var/www/html/artisan config:cache + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan config:cache +# Install Composer dependencies api-composer-install: composer install --ignore-platform-reqs +# Access the API container shell as the sail user api-shell: - docker-compose exec -it api bash -c 'su sail' + $(DOCKER_COMPOSE) exec $(API_CONTAINER) bash -c 'su sail' +# Access the API container shell as root api-root-shell: - docker-compose exec -it api bash + $(DOCKER_COMPOSE) exec $(API_CONTAINER) bash +# Run all PHPUnit tests api-test: - docker-compose exec -it api php /var/www/html/artisan test + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan test +# Run PHPUnit Feature tests api-test-feature: - docker-compose exec -it api php /var/www/html/artisan test --testsuite=Feature --stop-on-failure + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan test --testsuite=Feature --stop-on-failure +# Run PHPUnit tests using phpunit command api-test-php-unit: - docker-compose exec -it api php /var/www/html/artisan phpunit + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan phpunit +# Generate Swagger documentation api-build-swagger: - docker-compose exec -it api php /var/www/html/artisan l5-swagger:generate + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan l5-swagger:generate +# Generate Passport keys api-passport-key: - docker-compose exec -it api php /var/www/html/artisan passport:keys --force + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan passport:keys --force +# Create a Passport client and save the output to a file api-passport-generate: - docker-compose exec -it api php /var/www/html/artisan passport:client --password --name='Laravel Password Grant Client' --provider=users > .passport - cat .passport + $(DOCKER_COMPOSE) exec $(API_CONTAINER) php /var/www/html/artisan passport:client --password --name='Laravel Password Grant Client' --provider=users > .passport + $(DOCKER_COMPOSE) exec $(API_CONTAINER) cat .passport +# Fix permissions for storage and framework directories fix-permissions: - docker-compose exec -it api bash -c 'chmod -R 777 /var/www/html/storage/logs && chmod -R 777 /var/www/html/storage/framework' + $(DOCKER_COMPOSE) exec $(API_CONTAINER) bash -c 'chmod -R 755 /var/www/html/storage/logs && chmod -R 755 /var/www/html/storage/framework && find /var/www/html/storage -type f -exec chmod 644 {} \;' diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 244d983..c21c619 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -68,7 +68,7 @@ } }, "responses": { - "200": { + "201": { "description": "Successful operation", "content": { "application/json": { @@ -78,6 +78,9 @@ } } }, + "400": { + "description": "Bad Request" + }, "401": { "description": "Unauthenticated" }, @@ -124,6 +127,9 @@ }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Not Found" } }, "security": [ @@ -168,11 +174,17 @@ } } }, + "400": { + "description": "Bad Request" + }, "401": { "description": "Unauthenticated" }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Not Found" } }, "security": [ @@ -197,21 +209,17 @@ } ], "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - } + "204": { + "description": "Successful operation" }, "401": { "description": "Unauthenticated" }, "403": { "description": "Forbidden" + }, + "404": { + "description": "Not Found" } }, "security": [