Projeto final: API completa com Laravel, testes e deploy

1. Planejamento e Estruturação da API

Antes de escrever uma linha de código, é fundamental definir os recursos da API. Para este projeto, construiremos uma API de blog com três entidades principais: Usuários, Posts e Categorias. As rotas RESTful seguirão o padrão:

  • GET /api/v1/posts — listar posts
  • POST /api/v1/posts — criar post
  • GET /api/v1/posts/{id} — exibir post
  • PUT /api/v1/posts/{id} — atualizar post
  • DELETE /api/v1/posts/{id} — remover post

O mesmo padrão se aplica a usuários e categorias, com relacionamentos: um usuário tem muitos posts (hasMany), e um post pertence a muitas categorias (belongsToMany).

A modelagem do banco de dados começa com as migrations:

// database/migrations/xxxx_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->string('title');
    $table->text('body');
    $table->timestamps();
});
// database/migrations/xxxx_create_category_post_table.php
Schema::create('category_post', function (Blueprint $table) {
    $table->id();
    $table->foreignId('category_id')->constrained();
    $table->foreignId('post_id')->constrained();
    $table->timestamps();
});

Para o ambiente de desenvolvimento, utilizaremos Laravel Sail, que fornece Docker pré-configurado com PHP, MySQL e Redis. Basta executar:

composer require laravel/sail --dev
php artisan sail:install
./vendor/bin/sail up

2. Implementação dos Recursos Principais

Com a estrutura definida, criamos os Models, Controllers e Factories. Vamos gerar tudo com Artisan:

php artisan make:model Post -mfc
php artisan make:model Category -mfc
php artisan make:model User -mfc

A autenticação será feita com Laravel Sanctum, que fornece tokens de API simples e seguros. Instale e configure:

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

No model User, adicione o trait HasApiTokens:

<?php
// app/Models/User.php
namespace App\Models;

use Laravel\Sanctum\HasApiTokens;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use HasApiTokens;
    // ...
}

A validação de dados será feita com Form Requests personalizados. Exemplo para criação de posts:

<?php
// app/Http/Requests/StorePostRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // A autorização será testada separadamente
    }

    public function rules(): array
    {
        return [
            'title' => 'required|string|max:255',
            'body'  => 'required|string',
            'categories' => 'required|array',
            'categories.*' => 'exists:categories,id',
        ];
    }
}

No controller, usamos o request personalizado:

<?php
// app/Http/Controllers/Api/V1/PostController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Requests\StorePostRequest;
use App\Models\Post;
use App\Http\Resources\PostResource;

class PostController extends Controller
{
    public function store(StorePostRequest $request)
    {
        $post = auth()->user()->posts()->create($request->validated());
        $post->categories()->sync($request->categories);
        return new PostResource($post);
    }
}

3. Relacionamentos e Recursos Avançados

Os relacionamentos Eloquent são configurados nos models:

// app/Models/User.php
public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}
// app/Models/Post.php
public function categories(): BelongsToMany
{
    return $this->belongsToMany(Category::class);
}

Para transformar as respostas, criamos API Resources:

<?php
// app/Http/Resources/PostResource.php
namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'body' => $this->body,
            'author' => new UserResource($this->whenLoaded('user')),
            'categories' => CategoryResource::collection($this->whenLoaded('categories')),
            'created_at' => $this->created_at,
        ];
    }
}

Para paginação, filtros e ordenação, implementamos no controller:

public function index(Request $request)
{
    $query = Post::with(['user', 'categories']);

    if ($request->has('category')) {
        $query->whereHas('categories', fn($q) => $q->where('slug', $request->category));
    }

    $sortField = $request->get('sort', 'created_at');
    $sortDir = $request->get('direction', 'desc');
    $query->orderBy($sortField, $sortDir);

    return PostResource::collection($query->paginate(15));
}

4. Testes Automatizados

Testes são essenciais para garantir que a API funcione corretamente. Usamos PHPUnit com Laravel Testing. Exemplo de teste de unidade para um model:

<?php
// tests/Unit/PostTest.php
namespace Tests\Unit;

use Tests\TestCase;
use App\Models\Post;

class PostTest extends TestCase
{
    public function test_post_belongs_to_user()
    {
        $post = Post::factory()->create();
        $this->assertInstanceOf(User::class, $post->user);
    }
}

Teste de funcionalidade para endpoint:

<?php
// tests/Feature/PostApiTest.php
namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use App\Models\Post;
use Laravel\Sanctum\Sanctum;

class PostApiTest extends TestCase
{
    public function test_authenticated_user_can_create_post()
    {
        Sanctum::actingAs(User::factory()->create());

        $response = $this->postJson('/api/v1/posts', [
            'title' => 'Novo Post',
            'body' => 'Conteúdo do post',
            'categories' => [1, 2]
        ]);

        $response->assertStatus(201)
                 ->assertJsonStructure(['data' => ['id', 'title']]);
    }

    public function test_unauthenticated_user_cannot_create_post()
    {
        $response = $this->postJson('/api/v1/posts', [
            'title' => 'Teste',
            'body' => 'Conteúdo'
        ]);

        $response->assertStatus(401);
    }
}

Testes de autorização e validação:

public function test_post_requires_title()
{
    Sanctum::actingAs(User::factory()->create());

    $response = $this->postJson('/api/v1/posts', [
        'body' => 'Conteúdo sem título'
    ]);

    $response->assertStatus(422)
             ->assertJsonValidationErrors('title');
}

5. Documentação e Versionamento

Para documentar a API, usamos Scribe, que gera documentação OpenAPI automaticamente:

composer require --dev knuckleswtf/scribe
php artisan scribe:generate

A documentação será gerada em public/docs/. Versionamos a API usando prefixo v1 nas rotas:

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('posts', PostController::class);
    Route::apiResource('categories', CategoryController::class);
    Route::apiResource('users', UserController::class);
});

Para tratamento global de exceções, personalizamos o Handler:

// app/Exceptions/Handler.php
public function render($request, Throwable $e)
{
    if ($request->expectsJson()) {
        return response()->json([
            'message' => $e->getMessage(),
        ], $e instanceof ModelNotFoundException ? 404 : 500);
    }
    return parent::render($request, $e);
}

6. Configuração para Deploy

Antes do deploy, preparamos o ambiente:

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan optimize

Configuramos o servidor web com Nginx + PHP-FPM. Exemplo de configuração:

server {
    listen 80;
    server_name api.exemplo.com;
    root /var/www/api/public;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

Para conteinerização, criamos um Dockerfile:

FROM php:8.2-fpm

RUN apt-get update && apt-get install -y \
    git unzip libpq-dev \
    && docker-php-ext-install pdo_mysql

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /var/www
COPY . .
RUN composer install --optimize-autoloader --no-dev

RUN chown -R www-data:www-data storage bootstrap/cache

7. Deploy Contínuo e Monitoramento

Implementamos um pipeline CI/CD com GitHub Actions:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
      - name: Install dependencies
        run: composer install --no-progress
      - name: Run tests
        run: php artisan test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to DigitalOcean
        uses: appleboy/ssh-action@v0.1.5
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /var/www/api
            git pull origin main
            composer install --optimize-autoloader --no-dev
            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan queue:restart

Para monitoramento, configuramos health checks no Laravel:

// routes/api.php
Route::get('/health', function () {
    try {
        DB::connection()->getPdo();
        return response()->json(['status' => 'healthy']);
    } catch (\Exception $e) {
        return response()->json(['status' => 'unhealthy'], 500);
    }
});

Logs podem ser visualizados com tail -f storage/logs/laravel.log e monitorados com serviços como Laravel Horizon para filas ou Sentry para erros.


Referências