Ir al contenido principal
Laravel, shipping fast.
Capitulo 11 · Evolucion de la API

Versionar en el borde, no por todo el codigo

Julian Beaujardin

La versión ingenua del versionado pone la versión en la URL y duplica el controlador para cada una: LicenseControllerV1, LicenseControllerV2, dos grupos de rutas, dos de todo. Parece disciplinado. En realidad es una bifurcación a cámara lenta. Seis meses después, un arreglo hay que aplicarlo dos veces, y alguien siempre olvida el segundo.

Resuelve la versión una vez, en el borde, y deja que una sola base de código ramifique sobre ella. Esta flota ya hace exactamente eso con el idioma: SetRequestLocaleMiddleware lee una señal de la petición, la comprueba contra una lista configurada de lo que se soporta de verdad y fija estado para el resto de la petición. La resolución de versión sigue la forma idéntica.

// app/Enums/ApiVersion.php
declare(strict_types=1);

namespace App\Enums;

/**
 * The API versions this service still serves. New integrations should
 * default to the highest case; older cases exist only for consumers
 * who integrated before it shipped.
 */
enum ApiVersion: string
{
    case V1 = 'v1'; // original license response shape
    case V2 = 'v2'; // adds `status`

    public static function default(): self
    {
        return self::V2;
    }
}

Y luego un middleware que la resuelve, modelado directamente sobre la forma «cabecera, luego valor por defecto de configuración»:

// app/Http/Middleware/ResolveApiVersionMiddleware.php
final readonly class ResolveApiVersionMiddleware
{
    /**
     * @param  Closure(Request): Response  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        $requested = $request->header('Api-Version');
        $version = is_string($requested) ? ApiVersion::tryFrom($requested) : null;

        $request->attributes->set('api_version', $version ?? ApiVersion::default());

        return $next($request);
    }
}

$request->attributes no es una idea nueva aquí. LogApiRequestsMiddleware ya lee un atributo bearer que fijó un middleware anterior. La resolución de versión viaja por el mismo raíl. Ese es todo el mecanismo: un middleware, un enum, un sitio que sabe qué significa «actual».

Servir dos versiones sin dos bases de codigo

Con la versión resuelta una vez en el borde, el controlador no se bifurca en dos clases. Elige un resource:

// Bad: a second controller for a second version
final class LicenseControllerV2
{
    public function index(): Responsable
    {
        return new CollectionResponse(
            data: LicenseResourceV2::collection(
                resource: LicenseDTOMapper::toDTOCollection(
                    licenses: LicenseFacade::licenses(),
                ),
            )
        );
    }
}
// Good: one controller, one route, a version-aware resource
// app/Http/Controllers/LicenseController.php
public function index(Request $request): Responsable
{
    /** @var ApiVersion $version */
    $version = $request->attributes->get('api_version', ApiVersion::default());

    $resourceClass = $version === ApiVersion::V2
        ? LicenseResourceV2::class
        : LicenseResource::class;

    return new CollectionResponse(
        data: $resourceClass::collection(
            resource: LicenseDTOMapper::toDTOCollection(
                licenses: LicenseFacade::licenses(),
            ),
        )
    );
}

LicenseResourceV2 extiende el original en lugar de copiarlo. Eso sólo compila si LicenseResource lo permite, y por defecto no lo hace: los resources de esta flota están marcados como final. Quitar final de LicenseResource es una excepción deliberada y única, un punto de extensión que eliges abrir, no una invitación general a dejar todas las clases abiertas por si acaso.

// app/Http/Resources/LicenseResourceV2.php
final class LicenseResourceV2 extends LicenseResource
{
    public function toArray(Request $request): array
    {
        return [
            ...parent::toArray($request),
            'status' => $this->resource->status,
        ];
    }
}

Un controlador. La lógica de obtención y mapeo existe exactamente una vez. Una ruta. Ningún prefijo v1/v2 que mantener sincronizado. Un sitio donde se arregla un fallo. La versión mala significa que cada arreglo hay que copiarlo a un segundo controlador, y la versión de esa historia seis meses después siempre es la misma: alguien parchea LicenseControllerV1 y olvida que LicenseControllerV2 existe, y un consumidor en la versión «antigua» conserva calladamente el fallo que supuestamente se arregló hace semanas.

La superficie versionada es el resource, porque ahí es genuinamente donde difiere la forma. Todo lo anterior al resource —la consulta, el DTO, la lógica de negocio— no sabe ni le importa qué versión lo pidió.