Skip to content

API REST

Diseño del contrato HTTP del backend. Todo bajo /api/v1/*.

Versionado

Una sola versión activa (v1). Cuando introducimos breaking changes:

  1. Crear /api/v2/... manteniendo v1 por al menos 6 meses.
  2. Documentar deprecation en CHANGELOG.md.
  3. Agregar header Sunset: Wed, 01 Jan 2027 00:00:00 GMT a responses v1 deprecadas.
  4. Remover v1 tras 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

CodeUso
200GET, PATCH, DELETE exitoso
201POST que crea recurso
204DELETE sin body (alternativa a 200)
400Error genérico de cliente
401Sin autenticar (falta token)
403Autenticado pero sin permiso
404Recurso no existe
409Conflicto (ej. duplicado único)
422Validación falló (cuerpo inválido)
429Rate limit
500Error del servidor
503Mantenimiento / health fail

Error handler global

app/Support/ApiExceptionHandler.php transforma excepciones en envelope JSON.

Excepciones manejadas

ExceptionStatusBehavior
ValidationException422errors con reglas fallidas
AuthenticationException401"No autenticado"
AuthorizationException403"No autorizado para esta acción"
ModelNotFoundException404"Recurso no encontrado"
ThrottleRequestsException429"Demasiadas solicitudes. Intenta en X segundos" + header Retry-After
QueryException500"Error de base de datos" (sin exponer la query)
RuntimeException (en services)422Mensaje 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

EndpointLimitReason
POST /auth/register10/minprevenir creación masiva de cuentas (candidato)
POST /auth/register/recruiter5/minself-service de reclutador, requiere aprobación admin
POST /auth/register/company5/minself-service de empresa, requiere aprobación admin
POST /auth/login5/min (por IP + email)prevenir brute force
POST /auth/password-reset5/minprevenir spam de correos
POST /auth/verify-email10/minbalance entre UX y spam
POST /auth/verify-email/resend3/minreenvío conservador
POST /me/membership/checkout10/minprevenir abuse del endpoint Stripe
POST /me/profile/avatar10/mincontrol de uploads
POST /me/profile/documents20/minsubir varios docs seguidos
GET /me/profile/cv.pdf30/mingeneración es costosa
POST /api/webhooks/stripe60/min (por IP)webhook legítimo nunca excede
Endpoints autenticados genéricos120/min (default Laravel)balance razonable

Headers de respuesta

Todo response autenticado incluye:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 87

Al 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:

  1. Inline en la ruta:
php
Route::post('/me/custom', [MyController::class, 'store'])
    ->middleware('throttle:10,1');
  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 meta con total, 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=1

Los filtros nuevos del PDF cosasfaltanteshumae:

  • candidate_kind=intern|employee — categoría exacta del candidato (los null no pasan).
  • functional_area_ids[]=...&functional_area_ids[]=... — OR semántico: aparece quien tenga alguna de las áreas en su pivote candidate_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=20

Devuelve [{ 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 verifica Payment.status antes 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:

HeaderUso
X-Request-IdID ú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.mx

Y en config/cors.php:

php
'allowed_origins' => explode(',', env('FRONTEND_URL', '')),

Documentación — Scribe

bash
composer docs

Genera:

  • 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 →

Manual de usuario HUMAE · Uso interno