Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ Este projeto usa o gerador OpenAPI do Laravel via `dedoc/scramble`.
- UI interativa: `/docs/api`
- Especificação JSON: `/docs/api.json`

Para executar `Try it out` sem erro `401 Unauthorized`:

1. Abra `/docs/api`.
2. Clique em `Authorize`.
3. Informe `Bearer local-demo-token`.
4. Execute o endpoint `POST /api/products`.

Opcionalmente, você pode abrir `/login-temporario` para testar o fluxo por sessão no navegador.

Os endpoints de produto já incluem exemplos de parâmetros e payloads, além de códigos de status documentados na especificação gerada.

### Listar Produtos
Expand Down
28 changes: 15 additions & 13 deletions app/Http/Controllers/Api/ProductController.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,26 @@ public function index(Request $request)
$page = max(1, (int) $request->integer('page', self::DEFAULT_PAGE));
$cacheKey = $this->buildIndexCacheKey($filters, $page, $perPage);

$products = Cache::remember($cacheKey, now()->addMinutes(10), function () use ($filters, $perPage, $page) {
return Product::query()
$payload = Cache::remember($cacheKey, now()->addMinutes(10), function () use ($filters, $perPage, $page) {
$products = Product::query()
->filter($filters)
->orderByDesc('id')
->paginate($perPage, ['*'], 'page', $page);

return [
'items' => ProductResource::collection($products->items())->resolve(),
'meta' => [
'current_page' => $products->currentPage(),
'last_page' => $products->lastPage(),
'per_page' => $products->perPage(),
'total' => $products->total(),
'from' => $products->firstItem(),
'to' => $products->lastItem(),
],
];
});

return $this->success('Products retrieved successfully.', [
'items' => ProductResource::collection($products->items()),
'meta' => [
'current_page' => $products->currentPage(),
'last_page' => $products->lastPage(),
'per_page' => $products->perPage(),
'total' => $products->total(),
'from' => $products->firstItem(),
'to' => $products->lastItem(),
],
]);
return $this->success('Products retrieved successfully.', $payload);
}

private function buildIndexCacheKey(array $filters, int $page, int $perPage): string
Expand Down
29 changes: 29 additions & 0 deletions app/Http/Middleware/AuthenticateProductRequests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

class AuthenticateProductRequests
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::check()) {
return $next($request);
}

$expectedToken = env('PRODUCTS_API_TOKEN', 'local-demo-token');
$token = $request->bearerToken();

if ($token !== null && hash_equals($expectedToken, $token)) {
return $next($request);
}

return response()->json([
'message' => 'Unauthenticated.',
], 401);
}
}
8 changes: 8 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace App\Providers;

use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
Expand All @@ -22,6 +25,11 @@ public function register(): void
*/
public function boot(): void
{
Scramble::configure()
->withDocumentTransformers(function (OpenApi $openApi): void {
$openApi->secure(SecurityScheme::http('bearer'));
});

RateLimiter::for('products-api', function (Request $request): Limit {
return Limit::perMinute(5)->by($request->user()?->id ?? $request->ip());
});
Expand Down
4 changes: 3 additions & 1 deletion bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->alias([
'product.auth' => \App\Http\Middleware\AuthenticateProductRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
Expand Down
237 changes: 237 additions & 0 deletions resources/views/session-login.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login temporário</title>
<style>
:root {
color-scheme: light;
--bg: #0f172a;
--panel: rgba(15, 23, 42, 0.92);
--panel-border: rgba(148, 163, 184, 0.2);
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #22c55e;
--accent-strong: #16a34a;
--danger: #ef4444;
}

* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(34, 197, 94, 0.18), transparent 30%),
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.2), transparent 28%),
var(--bg);
color: var(--text);
padding: 24px;
}

.card {
width: min(100%, 720px);
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 24px;
padding: 28px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(14px);
}

.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
color: var(--accent);
margin-bottom: 8px;
}

h1 {
margin: 0 0 8px;
font-size: clamp(28px, 4vw, 40px);
}

p {
margin: 0 0 20px;
color: var(--muted);
line-height: 1.6;
}

.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}

label {
display: block;
font-size: 14px;
color: var(--text);
margin-bottom: 8px;
}

input {
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 14px 16px;
background: rgba(15, 23, 42, 0.8);
color: var(--text);
outline: none;
}

input:focus {
border-color: rgba(34, 197, 94, 0.7);
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.15);
}

.actions {
display: flex;
gap: 12px;
margin-top: 18px;
flex-wrap: wrap;
}

button, .link {
border: 0;
border-radius: 999px;
padding: 12px 18px;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}

button {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: white;
}

.link {
background: rgba(148, 163, 184, 0.12);
color: var(--text);
}

.status {
margin-top: 18px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(148, 163, 184, 0.1);
border: 1px solid rgba(148, 163, 184, 0.15);
white-space: pre-wrap;
}

.status.success { border-color: rgba(34, 197, 94, 0.4); }
.status.error { border-color: rgba(239, 68, 68, 0.5); color: #fecaca; }

.note {
margin-top: 16px;
font-size: 13px;
color: var(--muted);
}

.token {
margin-top: 16px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
color: var(--text);
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}

@media (max-width: 720px) {
.card { padding: 20px; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="card">
<div class="eyebrow">Acesso temporário</div>
<h1>Login no navegador</h1>
<p>Use esta página para gerar uma sessão autenticada e testar a API sem `curl`.</p>

<form id="login-form">
<div class="grid">
<div>
<label for="email">E-mail</label>
<input id="email" name="email" type="email" value="admin@example.com" required>
</div>
<div>
<label for="password">Senha</label>
<input id="password" name="password" type="password" value="password" required>
</div>
</div>

<div class="actions">
<button type="submit">Entrar e testar API</button>
<a class="link" href="/docs/api" target="_blank" rel="noreferrer">Abrir Swagger</a>
<a class="link" href="/browser/products" target="_blank" rel="noreferrer">Abrir produtos</a>
</div>
</form>

<div id="status" class="status">Ainda não autenticado.</div>
<div class="token">
Token do Swagger: <strong>{{ $swaggerToken }}</strong>
<br>
No Swagger, clique em Authorize e use <strong>Bearer {{ $swaggerToken }}</strong>.
</div>
<div class="note">Depois do login, a mesma sessão do navegador será usada para acessar /browser/products.</div>
</main>

<script>
const form = document.getElementById('login-form');
const statusBox = document.getElementById('status');

form.addEventListener('submit', async (event) => {
event.preventDefault();

statusBox.className = 'status';
statusBox.textContent = 'Autenticando...';

const formData = new FormData(form);

const response = await fetch('/session-login', {
method: 'POST',
headers: {
'Accept': 'application/json',
},
credentials: 'same-origin',
body: new URLSearchParams(formData),
});

const payload = await response.json();

if (!response.ok) {
statusBox.className = 'status error';
statusBox.textContent = payload.message ?? 'Falha ao autenticar.';
return;
}

const apiResponse = await fetch('/browser/products', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});

const apiPayload = await apiResponse.json();

statusBox.className = 'status success';
statusBox.textContent = [
payload.message,
'',
'Resposta da API protegida:',
JSON.stringify(apiPayload, null, 2),
].join('\n');
});
</script>
</body>
</html>
2 changes: 1 addition & 1 deletion routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
use App\Http\Controllers\Api\ProductController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth', 'throttle:products-api'])->apiResource('products', ProductController::class);
Route::middleware(['product.auth', 'throttle:products-api'])->apiResource('products', ProductController::class);
Loading
Loading