Compare commits

...

10 Commits

Author SHA1 Message Date
Антон Михайлов e1efce8d59 added docker settings and basic env config 2026-06-10 15:22:13 +03:00
amikhaylov 7fdb0399ad Updates 2026-05-27 13:27:46 +03:00
amikhaylov e327148cb1 Updates 2026-05-27 13:25:03 +03:00
amikhaylov 2a43a8f8e0 Updated README 2026-05-27 12:56:57 +03:00
amikhaylov b176ff53f6 Updated README 2026-05-27 12:51:07 +03:00
amikhaylov 0cbf5f3dc9 Updated README 2026-05-27 12:43:15 +03:00
amikhaylov 3688645458 Updated README 2026-05-27 12:20:50 +03:00
amikhaylov 8d717426c8 Updated README 2026-05-27 11:59:34 +03:00
amikhaylov d8d49f7bba Added login controller and reset_user_password console script 2026-05-27 11:38:11 +03:00
amikhaylov ac47c7114d README update 2026-05-27 11:10:30 +03:00
12 changed files with 696 additions and 18 deletions
+55
View File
@@ -0,0 +1,55 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost:8080
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_LEVEL=debug
# Настройки подключения к MySQL в Docker
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=gigs_api
DB_USERNAME=gigs_api
DB_PASSWORD=gigs_api
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
+38
View File
@@ -0,0 +1,38 @@
# Секретные файлы конфигурации (их нельзя пушить!)
.env
.env.backup
.env.production
# Папка зависимостей Composer
/vendor
# Папка зависимостей Node.js (фронтенд)
/node_modules
/npm-debug.log
/yarn-error.log
# Скомпилированные ассеты фронтенда
/public/build
/public/hot
/public/storage
# Логи и временные файлы Laravel
/storage/*.key
/storage/framework/cache/data/*
/storage/framework/sessions/*
/storage/framework/testing/*
/storage/framework/views/*
/storage/logs/*
# Исключения для папок Laravel (чтобы Git сохранял саму структуру папок)
!/storage/framework/cache/data/.gitignore
!/storage/framework/sessions/.gitignore
!/storage/framework/testing/.gitignore
!/storage/framework/views/.gitignore
!/storage/logs/.gitignore
# Файлы окружения IDE и операционных систем
.idea/
.vscode/
.DS_Store
Thumbs.db
+25
View File
@@ -0,0 +1,25 @@
.PHONY: up down install shell status logs
# Запуск контейнеров в фоновом режиме
up:
docker compose up -d
# Остановка и удаление контейнеров
down:
docker compose down
# Установка зависимостей Composer внутри контейнера web_server
install:
docker compose exec --workdir /var/www/html web composer install
# Быстрый вход в терминал веб-сервера
shell:
docker compose exec web bash
# Проверить статус контейнеров
status:
docker compose ps
# Посмотреть живые логи
logs:
docker compose logs -f
+117 -7
View File
@@ -1,10 +1,120 @@
# ВДНХ Афиша API (Тестовое задание) # ВДНХ Афиша API (Тестовое задание)
## Развертывание проекта Бэкенд-приложение для фильтрации и вывода афиши событий Выставки (ВДНХ) на основе предоставленного технического задания.
1. `composer install`
2. Настроить подключение к БД в `.env` ## 🛠 Стек технологий
3. `php artisan migrate:fresh --seed` (команда развернет БД, создаст тестового юзера, 11 категорий ВДНХ и 40 сгенерированных событий). - **PHP:** 8.2 / 8.3
- **Framework:** Laravel 12.x
- **Database:** MySQL / PostgreSQL
- **API Specification:** Postman (коллекция в корне проекта)
---
## 📋 Постановка задачи
Разработать JSON API для Афиши событий (https://vdnh.ru/) со следующими требованиями:
1. Метод API для фильтрации событий по нескольким категориям и промежутку дат.
2. Реализация пагинации для списка событий.
3. Наличие сидеров (Database Seeding) для наполнения тестовыми данными.
4. Публикация на GitHub + предоставление коллекции для Postman.
## 🚀 Развертывание проекта
Выполните последовательно следующие команды в терминале:
1. **Клонирование репозитория и установка зависимостей:**
```bash
composer install
```
2. **Настройка окружения:**
```bash
cp .env.example .env
php artisan key:generate
```
*Отредактируйте файл `.env`, указав ваши доступы к базе данных (`DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`).*
3. **Миграции и наполнение базы (Сидинг):**
```bash
php artisan migrate:fresh --seed
```
> 💡 **Что произойдет:** Команда полностью пересоздаст структуру БД и запустит сидеры, которые сгенерируют **11 реальных категорий** и **40 тестовых событий** со случайными датами для удобной проверки фильтрации.
4. **Пользователи:**
- В базе уже есть сгенерирован "test@example.com" с паролем "password".
- Чтобы использовать другие методы, находящиеся под защитой Sanctum, необходимо вызвать метод [/api/login](#1-авторизация-получение-токена)) и получить токен, который сохранится в переменную token в [POSTMAN](#-тестирование-в-postman).
- Для генерации нового пользователя или изменения его пароля/токена можно воспользоваться консольными командами, описанными ниже.
## 💻 Консольные команды (CLI)
Для удобства управления пользователями и токенами добавлены кастомные Artisan-команды:
1. **Создание пользователя и генерация API-токена:**
```bash
php artisan user:create-api-user "Ivan Ivanov" ivan@example.com "secret123"
```
*Команда регистрирует нового пользователя в базе данных и сразу генерирует для него первый Sanctum-токен.*
2. **Сброс пароля и выдача нового токена:**
```bash
php artisan user:reset-password ivan@example.com "new_secret123"
```
*Команда находит пользователя по email, обновляет его пароль и выводит в консоль новый рабочий Sanctum-токен.*
## 📡 Документация API (Эндпоинты)
*Для всех запросов обязательно передавайте заголовки:*
- `Accept: application/json`
- `Content-Type: application/json`
### 1. Авторизация (Получение токена)
* **URL:** `/api/login`
* **Метод:** `POST`
#### Тело запроса (JSON):
```json
{
"email": "user@example.com",
"password": "your_password"
}
```
*В ответе возвращаются данные пользователя и его `access_token` для авторизации в защищенных эндпоинтах.*
### 2. Выход из системы (Logout)
* **URL:** `/api/logout`
* **Метод:** `POST`
* **Описание:** Деактивирует и удаляет текущий API-токен Sanctum, с которым был выполнен запрос. Заголовок Authorization c токеном обязателен.
### 3. Получение данных пользователя
* **URL:** `/api/user`
* **Метод:** `GET`
* **Описание:**: описание пользователя, заголовок Authorization c токеном обязателен.
### 4. Получение списка событий с фильтрацией и пагинацией
* **URL:** `/api/gigs`
* **Метод:** `GET`
* **Описание:** для метода необходим заголовок Authorization: Bearer {{token}}, который можно получить методом /login.
#### Параметры запроса (Query Parameters):
| Параметр | Тип | Обязательный | Описание | Пример / Значение |
| :--- | :--- | :--- | :--- | :--- |
| `categories` | `array` | Нет | Массив ID или слагов категорий | `categories[]=1&categories[]=2` |
| `date_from` | `string` | Нет | Начало диапазона дат (`YYYY-MM-DD`) | `2026-05-01` |
| `date_to` | `string` | Нет | Конец диапазона дат (`YYYY-MM-DD`) | `2026-05-31` |
| `page` | `integer` | Нет | Номер страницы пагинации | `2` |
| `per_page` | `integer` | Нет | Количество элементов на страницу | `15` *(По умолчанию: `10`)* |
#### Пример запроса:
`GET /api/gigs?categories[]=1&categories[]=4&date_from=2026-02-01&date_to=2026-05-31`
---
## 🧪 Тестирование в Postman
В корне репозитория находится файл коллекции для быстрого тестирования всех эндпоинтов: **`gigs_api_collection.json`**.
## Эндпоинты
- `GET /api/gigs` — Получение списка событий с пагинацией и фильтрами.
- Для тестирования импортируйте файл `gigs_api_collection.json` в Postman.
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ResetUserPassword extends Command
{
// Название и аргументы команды (email обязательный, password — опциональный)
protected $signature = 'user:reset-password {email} {password?}';
// Описание команды для списка php artisan
protected $description = 'Сбрасывает пароль пользователя по email и генерирует новый API-токен Sanctum';
public function handle()
{
$email = $this->argument('email');
$password = $this->argument('password');
// 1. Поиск пользователя
$user = User::where('email', $email)->first();
if (!$user) {
$this->error("Пользователь с email {$email} не найден.");
return Command::FAILURE;
}
// 2. Генерация или использование переданного пароля
if (!$password) {
$password = Str::random(12); // Случайный пароль из 12 символов
}
// 3. Обновление пароля в базе данных
$user->password = Hash::make($password);
$user->save();
// 4. Отзыв старых токенов (опционально, для безопасности)
$user->tokens()->delete();
// 5. Создание нового токена Sanctum
$token = $user->createToken('console_auth_token')->plainTextToken;
// 6. Вывод результата в консоль
$this->info("Пароль для пользователя {$email} успешно изменен!");
$this->line("Новый пароль: <comment>{$password}</comment>");
$this->line("Новый Sanctum токен: <comment>{$token}</comment>");
return Command::SUCCESS;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class LoginController extends Controller
{
public function login(Request $request)
{
// 1. Валидация JSON-данных
$validator = Validator::make($request->all(), [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'status' => 'error',
'message' => 'Validation failed',
'errors' => $validator->errors()
], 422);
}
// 2. Поиск пользователя
$user = User::where('email', $request->email)->first();
// 3. Проверка пароля
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json([
'status' => 'error',
'message' => 'Invalid credentials'
], 401);
}
// 4. Генерация нового токена Sanctum
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'status' => 'success',
'message' => 'Logged in successfully',
'data' => [
'user' => $user,
'token_type' => 'Bearer',
'access_token' => $token,
]
], 200);
}
public function logout(Request $request)
{
// Удаление текущего токена, с которым пришел пользователь
$request->user()->currentAccessToken()->delete();
return response()->json([
'status' => 'success',
'message' => 'Logged out successfully'
], 200);
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\ORM\Category;
class CategoryController extends Controller
{
public function index()
{
// Получаем все категории, отсортированные по имени
$categories = Category::orderBy('name', 'asc')->get();
return response()->json([
'status' => 'success',
'message' => 'Categories retrieved successfully',
'data' => $categories
], 200);
}
}
+19 -10
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Carbon\Carbon;
use App\Http\Requests\GetGigsRequest; use App\Http\Requests\GetGigsRequest;
use App\Http\Resources\GigResource; use App\Http\Resources\GigResource;
use App\Models\ORM\Gig; use App\Models\ORM\Gig;
@@ -11,32 +12,40 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class GigsController extends Controller class GigsController extends Controller
{ {
CONST PER_PAGE = 10;
public function index(GetGigsRequest $request): AnonymousResourceCollection public function index(GetGigsRequest $request): AnonymousResourceCollection
{ {
// Используем eager loading (with), чтобы избежать проблемы N+1 запросов к категориям // 1. Берем СТРОГО валидированные данные
$validated = $request->validated();
// Используем eager loading (with), чтобы избежать проблемы N+1
$query = Gig::with('categories'); $query = Gig::with('categories');
// Фильтрация по нескольким категориям (Many-to-Many) // Фильтрация по нескольким категориям (Many-to-Many)
if ($request->filled('categories')) { if (!empty($validated['categories'])) {
$query->whereHas('categories', function ($q) use ($request) { $query->whereHas('categories', function ($q) use ($validated) {
$q->whereIn('categories.id', $request->input('categories')); $q->whereIn('categories.id', $validated['categories']);
}); });
} }
// Фильтрация по промежутку дат // Фильтрация по промежутку дат
if ($request->filled('date_from')) { if (!empty($validated['date_from'])) {
$query->whereDate('event_date', '>=', $request->input('date_from')); $query->where('event_date', '>=', Carbon::parse($validated['date_from'])->startOfDay());
} }
if ($request->filled('date_to')) { if (!empty($validated['date_to'])) {
$query->whereDate('event_date', '<=', $request->input('date_to')); $query->where('event_date', '<=', Carbon::parse($validated['date_to'])->endOfDay());
} }
// Получаем per_page из валидированных данных, либо берем дефолт
$per_page = $validated['per_page'] ?? self::PER_PAGE;
// Сортируем события: сначала ближайшие // Сортируем события: сначала ближайшие
$query->orderBy('event_date', 'asc'); $query->orderBy('event_date', 'asc');
// Пагинация по 10 элементов на страницу // Пагинация
$gigs = $query->paginate(10); $gigs = $query->paginate((int)$per_page);
return GigResource::collection($gigs); return GigResource::collection($gigs);
} }
+2
View File
@@ -20,6 +20,8 @@ class GetGigsRequest extends FormRequest
'categories.*' => ['integer', 'exists:categories,id'], // Проверяем, что ID категорий существуют 'categories.*' => ['integer', 'exists:categories,id'], // Проверяем, что ID категорий существуют
'date_from' => ['nullable', 'date', 'date_format:Y-m-d'], 'date_from' => ['nullable', 'date', 'date_format:Y-m-d'],
'date_to' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:date_from'], 'date_to' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:date_from'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'page' => ['nullable', 'integer', 'min:1'],
]; ];
} }
} }
+39
View File
@@ -0,0 +1,39 @@
services:
# Веб-сервер Apache + PHP
web:
image: webdevops/php-apache:8.2
container_name: web_server
ports:
- "8080:80"
volumes:
- .:/var/www/html
environment:
- WEB_DOCUMENT_ROOT=/var/www/html/public
networks:
- lamp-network
# База данных MySQL
db:
image: mysql:8.0
container_name: db_server
restart: always
environment:
MYSQL_ROOT_PASSWORD: gigs_api
MYSQL_DATABASE: gigs_api
MYSQL_USER: gigs_api
MYSQL_PASSWORD: gigs_api
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
networks:
- lamp-network
networks:
lamp-network:
driver: bridge
volumes:
db_data:
+242
View File
@@ -0,0 +1,242 @@
{
"info": {
"_postman_id": "2e44665b-610d-4022-9d35-c5d4ba3182cc",
"name": "Gigs API",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "55246252",
"_collection_link": "https://blacksnowman-7b5016ee-929558.postman.co/workspace/RoadCrew's-Workspace~f1936404-0a84-43c8-873a-5676fefcbc04/collection/55246252-2e44665b-610d-4022-9d35-c5d4ba3182cc?action=share&source=collection_link&creator=55246252"
},
"item": [
{
"name": "Get basic user",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
},
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{server}}/api/user",
"host": [
"{{server}}"
],
"path": [
"api",
"user"
]
}
},
"response": []
},
{
"name": "Get gigs",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});"
],
"type": "text/javascript",
"packages": {},
"requests": {}
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
},
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{server}}/api/gigs?categories[]=1&categories[]=4&date_from=2026-02-01&date_to=2026-05-31",
"host": [
"{{server}}"
],
"path": [
"api",
"gigs"
],
"query": [
{
"key": "categories[]",
"value": "1"
},
{
"key": "categories[]",
"value": "4"
},
{
"key": "date_from",
"value": "2026-02-01"
},
{
"key": "date_to",
"value": "2026-05-31"
}
]
}
},
"response": []
},
{
"name": "Login",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// 1. Проверяем, что сервер вернул успешный HTTP-статус (200 OK)",
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"",
"// 2. Парсим JSON-ответ и сохраняем токен",
"if (pm.response.code === 200) {",
" const responseJson = pm.response.json();",
" ",
" // Проверяем, что в ответе есть статус 'success' и сам токен",
" if (responseJson.status === \"success\" && responseJson.data && responseJson.data.access_token) {",
" const token = responseJson.data.access_token;",
" ",
" // Сохраняем токен в ГЛОБАЛЬНУЮ переменную",
" pm.globals.set(\"token\", token);",
" ",
" console.log(\"Токен успешно сохранен в глобальную переменную 'token':\", token);",
" } else {",
" console.error(\"Не удалось найти 'access_token' в ответе сервера\");",
" }",
"}"
],
"type": "text/javascript",
"packages": {},
"requests": {}
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{ \n \"email\": \"test@example.com\",\n \"password\": \"123456\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{server}}/api/login",
"host": [
"{{server}}"
],
"path": [
"api",
"login"
]
}
},
"response": []
},
{
"name": "Loout",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// 1. Проверяем, что сервер вернул успешный HTTP-статус (200 OK)",
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"",
"// 2. Парсим JSON-ответ и сохраняем токен",
"if (pm.response.code === 200) {",
" const responseJson = pm.response.json();",
" ",
" // Проверяем, что в ответе есть статус 'success' и сам токен",
" if (responseJson.status === \"success\" && responseJson.data && responseJson.data.access_token) {",
" const token = responseJson.data.access_token;",
" ",
" // Сохраняем токен в ГЛОБАЛЬНУЮ переменную",
" pm.globals.set(\"token\", token);",
" ",
" console.log(\"Токен успешно сохранен в глобальную переменную 'token':\", token);",
" } else {",
" console.error(\"Не удалось найти 'access_token' в ответе сервера\");",
" }",
"}"
],
"type": "text/javascript",
"packages": {},
"requests": {}
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": {
"raw": "{{server}}/api/logout",
"host": [
"{{server}}"
],
"path": [
"api",
"logout"
]
}
},
"response": []
}
]
}
+16 -1
View File
@@ -1,12 +1,27 @@
<?php <?php
declare(strict_types=1);
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\GigsController; use App\Http\Controllers\GigsController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\CategoryController;
// Публичный маршрут для входа
Route::post('/login', [ LoginController::class, 'login' ]);
Route::get('/user', function (Request $request) { Route::get('/user', function (Request $request) {
return $request->user(); return $request->user();
})->middleware('auth:sanctum'); })->middleware('auth:sanctum');
Route::middleware('auth:sanctum')->group(function () {
// Получение списка категорий
Route::get('/categories', [ CategoryController::class, 'index' ]);
Route::get('/gigs', [ GigsController::class, 'index' ]); // Получение списка событий (с фильтрами и пагинацией)
Route::get('/gigs', [ GigsController::class, 'index' ]);
// Выход из системы (отзыв токена)
Route::post('/logout', [ LoginController::class, 'logout' ]);
});