Apariencia
API REST
Diseño del contrato HTTP del backend. Todo bajo /api/v1/*.
Versionado
Una sola versión activa (v1). Cuando introducimos breaking changes:
- Crear
/api/v2/...manteniendov1por al menos 6 meses. - Documentar deprecation en
CHANGELOG.md. - Agregar header
Sunset: Wed, 01 Jan 2027 00:00:00 GMTa responsesv1deprecadas. - Remover
v1tras periodo de gracia.
La URL https://api.humae.com.mx/api/v1/ se publica pública; v2 tendría su propia.
Envelope estándar
Toda respuesta JSON usa el mismo formato.
Éxito
json
{
"success": true,
"message": "OK",
"data": { /* payload */ },
"meta": {
"total": 42,
"page": 1,
"per_page": 20
}
}Error
json
{
"success": false,
"message": "Validation failed",
"errors": {
"email": ["El correo ya está registrado"],
"password": ["Mínimo 8 caracteres"]
}
}Implementación
app/Support/ApiResponse.php es el trait que todos los controllers usan:
php
use App\Support\ApiResponse;
class VacancyController extends Controller
{
use ApiResponse;
public function show(Vacancy $vacancy): JsonResponse
{
return $this->success('OK', VacancyResource::make($vacancy));
}
public function destroy(Vacancy $vacancy): JsonResponse
{
$vacancy->delete();
return $this->success('Vacante eliminada', null, status: 204);
}
}Firma del trait:
php
protected function success(
string $message = 'OK',
mixed $data = null,
array $meta = [],
int $status = 200,
): JsonResponse;
protected function error(
string $message,
array $errors = [],
int $status = 400,
): JsonResponse;Códigos HTTP semánticos
| Code | Uso |
|---|---|
200 | GET, PATCH, DELETE exitoso |
201 | POST que crea recurso |
204 | DELETE sin body (alternativa a 200) |
400 | Error genérico de cliente |
401 | Sin autenticar (falta token) |
403 | Autenticado pero sin permiso |
404 | Recurso no existe |
409 | Conflicto (ej. duplicado único) |
422 | Validación falló (cuerpo inválido) |
429 | Rate limit |
500 | Error del servidor |
503 | Mantenimiento / health fail |
Error handler global
app/Support/ApiExceptionHandler.php transforma excepciones en envelope JSON.
Excepciones manejadas
| Exception | Status | Behavior |
|---|---|---|
ValidationException | 422 | errors con reglas fallidas |
AuthenticationException | 401 | "No autenticado" |
AuthorizationException | 403 | "No autorizado para esta acción" |
ModelNotFoundException | 404 | "Recurso no encontrado" |
ThrottleRequestsException | 429 | "Demasiadas solicitudes. Intenta en X segundos" + header Retry-After |
QueryException | 500 | "Error de base de datos" (sin exponer la query) |
RuntimeException (en services) | 422 | Mensaje del servicio en message |
Throwable (genérico) | 500 | "Error interno del servidor" |
En producción vs debug
APP_DEBUG=false(prod): mensaje genérico + code. Nunca expone stacktrace.APP_DEBUG=true(local): incluye stacktrace + query + bindings.
Registrado en bootstrap/app.php:
php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (Throwable $e, Request $request) {
if ($request->wantsJson() || $request->is('api/*')) {
return ApiExceptionHandler::render($e, $request);
}
});
})Rate limiting
Laravel rate limiting con backoff por minuto. Configurado en app/Providers/RouteServiceProvider.php o directamente en rutas con ->middleware('throttle:N,1').
Rate limits por endpoint
| Endpoint | Limit | Reason |
|---|---|---|
POST /auth/register | 10/min | prevenir creación masiva de cuentas (candidato) |
POST /auth/register/recruiter | 5/min | self-service de reclutador, requiere aprobación admin |
POST /auth/register/company | 5/min | self-service de empresa, requiere aprobación admin |
POST /auth/login | 5/min (por IP + email) | prevenir brute force |
POST /auth/password-reset | 5/min | prevenir spam de correos |
POST /auth/verify-email | 10/min | balance entre UX y spam |
POST /auth/verify-email/resend | 3/min | reenvío conservador |
POST /me/membership/checkout | 10/min | prevenir abuse del endpoint Stripe |
POST /me/profile/avatar | 10/min | control de uploads |
POST /me/profile/documents | 20/min | subir varios docs seguidos |
GET /me/profile/cv.pdf | 30/min | generación es costosa |
POST /api/webhooks/stripe | 60/min (por IP) | webhook legítimo nunca excede |
| Endpoints autenticados genéricos | 120/min (default Laravel) | balance razonable |
Headers de respuesta
Todo response autenticado incluye:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 87Al hit del límite:
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Reset: 1713516345
{
"success": false,
"message": "Demasiadas solicitudes. Intenta en 45 segundos.",
"errors": null
}Definir un nuevo rate limit
Dos formas:
- Inline en la ruta:
php
Route::post('/me/custom', [MyController::class, 'store'])
->middleware('throttle:10,1');- Named limiter (recomendado):
php
// AppServiceProvider::boot()
RateLimiter::for('checkout', fn (Request $r) =>
Limit::perMinute(10)->by($r->user()->id)
);
// Route
Route::post('/me/membership/checkout', ...)
->middleware('throttle:checkout');Pagination
Eloquent ->paginate() devuelve un LengthAwarePaginator. En el controller:
php
public function index(Request $request): JsonResponse
{
$perPage = min(50, max(1, (int) $request->input('per_page', 20)));
$vacancies = Vacancy::query()
->with('company')
->paginate($perPage);
return $this->success('OK', VacancyResource::collection($vacancies), [
'total' => $vacancies->total(),
'current_page' => $vacancies->currentPage(),
'last_page' => $vacancies->lastPage(),
'per_page' => $vacancies->perPage(),
]);
}Reglas
- Siempre validar
per_page(max: 50,min: 1) — previene queries abusivas. - Siempre incluir
metacontotal,current_page,last_page. - Query params para filtros:
?q=react&state=activo&per_page=20.
Filtros y búsqueda
Los filtros se pasan como query params. Ejemplo:
GET /directory/candidates?q=react&years_exp_min=3&skills[]=5&skills[]=12&candidate_kind=intern&functional_area_ids[]=4&functional_area_ids[]=7&has_active_membership=1Los filtros nuevos del PDF cosasfaltanteshumae:
candidate_kind=intern|employee— categoría exacta del candidato (losnullno pasan).functional_area_ids[]=...&functional_area_ids[]=...— OR semántico: aparece quien tenga alguna de las áreas en su pivotecandidate_functional_areas.primary_functional_area_id=...— solo si el área es la principal del candidato (is_primary = true).
Endpoint relacionado sin paginación, no usa los filtros del directorio sino el MatchingService:
GET /vacancies/{id}/suggested-candidates?min_score=70&limit=20Devuelve [{ candidate, score, breakdown }] ordenado por score descendente. Detalles del cálculo en Capa de servicios → MatchingService.
El controller los valida con FormRequest (o $request->validate() si es trivial) y los pasa al service.
php
class DirectorySearchService
{
public function search(Request $request): LengthAwarePaginator
{
$query = CandidateProfile::query()->with([...]);
$this->applyMembershipFilter($query, $request);
$this->applyStateFilter($query, $request);
$this->applyTextSearch($query, $request);
// ...
return $query->paginate(...);
}
}Patrón "apply each filter in its own method" mantiene el service legible.
Idempotencia
Endpoints que deben ser idempotentes:
POST /api/webhooks/stripe— Stripe reintenta. Service verificaPayment.statusantes de activar.POST /me/interviews/{id}/confirm— confirmar dos veces no falla.POST /me/psychometric-attempts/{id}/submit— segundo submit devuelve el result existente.
Patrón estándar en services:
php
public function activate(Payment $payment): Payment
{
if ($payment->status === PaymentStatus::Succeeded) {
return $payment; // ya procesado, idempotente
}
// ... lógica ...
}Headers custom
HUMAE no usa muchos custom headers, pero cuando lo hace:
| Header | Uso |
|---|---|
X-Request-Id | ID único del request (middleware) para tracing |
X-HUMAE-Feature-Flag | (futuro) flags para A/B tests |
El frontend puede leerlos con response.headers.get('...').
CORS
Configuración en config/cors.php:
php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
'allowed_headers' => ['*'],
'exposed_headers' => ['X-RateLimit-Limit', 'X-RateLimit-Remaining'],
'max_age' => 3600,
'supports_credentials' => true, // requerido para Sanctum SPA
];Solo permite FRONTEND_URL (del .env). Para ambientes staging:
env
# Support multiple origins
FRONTEND_URL=https://humae.com.mx,https://staging.humae.com.mxY en config/cors.php:
php
'allowed_origins' => explode(',', env('FRONTEND_URL', '')),Documentación — Scribe
bash
composer docsGenera:
- HTML en
public/docs/index.html - OpenAPI spec en
public/docs/openapi.yaml
Scribe lee:
- PHPDoc del controller method
- FormRequest rules
- Resource structure
Anotaciones útiles:
php
/**
* Iniciar checkout de membresía
*
* Crea una Stripe Checkout Session y devuelve la URL.
*
* @group Membresía
* @authenticated
* @response 201 {
* "success": true,
* "data": { "url": "https://...", "session_id": "cs_...", "payment_id": 42 }
* }
*/
public function store(CreateCheckoutRequest $request, MembershipService $service) { /*...*/ }Re-generar antes de cada deploy.
Siguiente
Sistema de autenticación y autorización: Auth →

