Constaia
Integraciones

Slim

Valida documentos en Slim 4 con constaia/constaia-php, con UploadedFileInterface de PSR-7 movido a un temporal o como stream, y un webhook firmado.

Esta guía integra Constaia en una aplicación Slim 4 con el SDK oficial de PHP: una ruta que recibe el fichero como UploadedFileInterface de PSR-7 y lo envía a Constaia, un webhook que verifica la firma con el cuerpo crudo y tests con PHPUnit sobre la propia aplicación.

Requisitos

  • PHP ≥ 8.1 con ext-curl y ext-json.
  • Una clave de test ck_test_… del panel. En modo test no se consumen créditos y el resultado depende del nombre del fichero.

Instalación

composer require slim/slim:"^4.0" slim/psr7 constaia/constaia-php
composer require --dev phpunit/phpunit

Variables de entorno

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

El SDK lee CONSTAIA_API_KEY con getenv() (o de $_ENV / $_SERVER). Define las variables en el servidor (pool de PHP-FPM, contenedor) o cárgalas con la librería de .env que ya uses. La clave nunca sale del servidor.

Aplicación

La aplicación se define en src/app.php para poder usarla tanto desde public/index.php como desde los tests. expect y checks los fija el servidor; del navegador solo se acepta el fichero y el idioma.

src/app.php
<?php

declare(strict_types=1);

use Constaia\Client;
use Constaia\Exception\ConstaiaException;
use Constaia\Exception\InsufficientCreditsException;
use Constaia\Exception\InvalidRequestException;
use Constaia\Exception\RateLimitException;
use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\UploadedFileInterface;
use Slim\App;
use Slim\Factory\AppFactory;

return (static function (): App {
    $constaia = new Client(null, ['timeout' => 60, 'max_retries' => 2]);
    $app = AppFactory::create();
    $app->addErrorMiddleware(false, true, true);

    $json = static function (Response $response, array $data, int $status = 200): Response {
        $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

        return $response->withHeader('Content-Type', 'application/json')->withStatus($status);
    };

    // Añade aquí tu middleware de autenticación y de límite de frecuencia: cada análisis live consume créditos.
    $app->post('/documents', function (Request $request, Response $response) use ($constaia, $json): Response {
        $upload = $request->getUploadedFiles()['file'] ?? null;
        if (!$upload instanceof UploadedFileInterface || $upload->getError() !== UPLOAD_ERR_OK) {
            return $json($response, ['error' => ['message' => 'No se ha recibido el documento.']], 400);
        }
        if (($upload->getSize() ?? 0) > 20 * 1024 * 1024) {
            return $json($response, ['error' => ['message' => 'El archivo supera los 20 MB.']], 413);
        }

        $fromBrowser = json_decode((string) ($request->getParsedBody()['options'] ?? ''), true);
        $language = is_array($fromBrowser) && in_array($fromBrowser['language'] ?? '', ['es', 'en', 'pt', 'fr'], true)
            ? $fromBrowser['language']
            : 'es';

        $tmp = tempnam(sys_get_temp_dir(), 'constaia_');
        try {
            $upload->moveTo($tmp);
            $analysis = $constaia->analyze($tmp, [
                'filename' => $upload->getClientFilename() ?? 'document',
                'expect'   => ['es_dni', 'es_nie', 'passport'],
                'checks'   => ['not_expired' => true, 'min_age_years' => 18],
                'storage'  => 'none',
                'language' => $language,
            ]);
        } catch (InvalidRequestException $e) {
            $message = match ($e->errorCode) {
                'file_too_large' => 'El archivo supera los 20 MB.',
                'unsupported_file_type' => 'Sube una imagen (JPG, PNG, WEBP, HEIC) o un PDF.',
                'unreadable_image', 'unreadable_pdf' => 'No se puede leer el documento. Prueba con otra foto.',
                default => 'El documento no se ha podido procesar.',
            };

            return $json($response, ['error' => ['message' => $message]], 422);
        } catch (InsufficientCreditsException | RateLimitException $e) {
            error_log("constaia {$e->httpStatus} {$e->errorCode} request_id={$e->requestId}");

            return $json($response, ['error' => ['message' => 'El servicio está ocupado. Inténtalo en unos minutos.']], 503);
        } catch (ConstaiaException $e) {
            error_log("constaia {$e->httpStatus} {$e->errorCode} request_id={$e->requestId}");

            return $json($response, ['error' => ['message' => 'No hemos podido comprobar el documento.']], 502);
        } finally {
            if (is_file($tmp)) {
                unlink($tmp);
            }
        }

        // Guarda aquí $analysis->id y $analysis->verdictStatus().

        return $json($response, [
            'id'       => $analysis->id,
            'object'   => 'analysis',
            'status'   => $analysis->status,
            'document' => $analysis->document?->toArray(),
            'verdict'  => $analysis->verdict?->toArray(),
            'warnings' => $analysis->warnings ?? [],
        ]);
    });

    $app->post('/webhooks/constaia', function (Request $request, Response $response): Response {
        try {
            $event = Webhook::verify(
                (string) $request->getBody(),
                $request->getHeaders(),
                (string) getenv('CONSTAIA_WEBHOOK_SECRET'),
            );
        } catch (SignatureVerificationException) {
            return $response->withStatus(400);
        }

        // Deduplica por $request->getHeaderLine('webhook-id') en tu base de datos y encola el trabajo pesado.
        if (in_array($event['type'], ['analysis.completed', 'analysis.review_required', 'analysis.failed'], true)) {
            $analysis = $event['data'];
            error_log("constaia {$analysis['id']}: " . ($analysis['verdict']['status'] ?? $analysis['status']));
        }

        return $response->withStatus(204);
    });

    return $app;
})();
public/index.php
<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

$app = require __DIR__ . '/../src/app.php';
$app->run();

Notas:

  • Con multipart/form-data PHP rellena $_FILES y slim/psr7 lo expone en getUploadedFiles(); no necesitas addBodyParsingMiddleware() para esta ruta.
  • moveTo() deja el fichero en una ruta temporal y filename conserva el nombre original: en modo test decide la respuesta. El finally borra el temporal pase lo que pase.
  • Si el análisis tarda más de 30 s la API responde 202 y $analysis->status es queued o processing: el resultado llega por el webhook.

Alternativa: pasar el stream

El SDK también acepta un recurso de stream. Con detach() obtienes el recurso PHP del StreamInterface sin escribir otro fichero:

$stream = $upload->getStream()->detach();
$analysis = $constaia->analyze($stream, [
    'filename' => $upload->getClientFilename() ?? 'document',
    'expect'   => 'es_dni',
]);

El SDK lee el stream completo en memoria (hasta 20 MB), así que las dos opciones son equivalentes para ficheros de este tamaño.

Frontend con el widget

El widget envía el fichero en el campo file, que es el que espera /documents:

public/upload.html
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<constaia-upload endpoint="/documents" document="es_dni" lang="es"></constaia-upload>

Si proteges la ruta con cookies de sesión, añade también protección CSRF (por ejemplo slim/csrf) y pasa el token en el atributo headers del widget.

Webhook

La ruta /webhooks/constaia de arriba verifica la firma con (string) $request->getBody(), el cuerpo crudo tal cual llegó; getHeaders() devuelve arrays de valores y Webhook::verify() los acepta. No pongas autenticación de usuario ni CSRF en esta ruta: la firma es la autenticación. Crea el endpoint en el panel con la URL https://tu-dominio.com/webhooks/constaia, guarda el secret en CONSTAIA_WEBHOOK_SECRET y responde 2xx en menos de 15 s. Formato y reintentos en Webhooks.

Errores

Excepción (Constaia\Exception\…)HTTPQué hacer
InvalidRequestException400, 409, 413, 415, 422Mira errorCode (file_too_large, unsupported_file_type, unreadable_image…); pide otro fichero
AuthenticationException401Clave ausente o revocada (el constructor la lanza si no hay clave)
InsufficientCreditsException402Recarga créditos en el panel
RateLimitException429Ya reintentado por el SDK; retryAfter indica la espera
ApiException5xxYa reintentado; 503 live_mode_unavailable indica que el modo live no está disponible, usa ck_test_
ConnectionException—Red o timeout tras los reintentos

Todas extienden ConstaiaException con errorCode, param, requestId y httpStatus. Códigos en Errores.

Tests

Con CONSTAIA_API_KEY=ck_test_... la API responde según el nombre del fichero y no cobra. Copia cualquier JPEG real a tests/fixtures/ como dni_valid.jpg y dni_expired.jpg. Los tests llaman a la aplicación directamente con $app->handle().

tests/AppTest.php
<?php

declare(strict_types=1);

use Constaia\Webhook;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Factory\StreamFactory;
use Slim\Psr7\UploadedFile;

final class AppTest extends TestCase
{
    private function uploadRequest(string $fixture)
    {
        $tmp = tempnam(sys_get_temp_dir(), 'fixture_');
        copy(__DIR__ . '/fixtures/' . $fixture, $tmp);
        $file = new UploadedFile($tmp, $fixture, 'image/jpeg', filesize($tmp), UPLOAD_ERR_OK);

        return (new ServerRequestFactory())
            ->createServerRequest('POST', '/documents')
            ->withUploadedFiles(['file' => $file]);
    }

    public function testValidDni(): void
    {
        $app = require __DIR__ . '/../src/app.php';
        $response = $app->handle($this->uploadRequest('dni_valid.jpg'));
        $body = json_decode((string) $response->getBody(), true);

        self::assertSame(200, $response->getStatusCode());
        self::assertSame('valid', $body['verdict']['status']);
    }

    public function testExpiredDni(): void
    {
        $app = require __DIR__ . '/../src/app.php';
        $body = json_decode((string) $app->handle($this->uploadRequest('dni_expired.jpg'))->getBody(), true);

        self::assertSame('invalid', $body['verdict']['status']);
        self::assertContains('Caducado el 15/06/2020.', array_column($body['verdict']['reasons'], 'message'));
    }

    public function testSignedWebhook(): void
    {
        $app = require __DIR__ . '/../src/app.php';
        $payload = json_encode(['type' => 'analysis.completed', 'created_at' => date(DATE_ATOM), 'data' => ['id' => 'an_test', 'status' => 'completed']]);

        $request = (new ServerRequestFactory())
            ->createServerRequest('POST', '/webhooks/constaia')
            ->withBody((new StreamFactory())->createStream($payload));
        foreach (Webhook::headers($payload, (string) getenv('CONSTAIA_WEBHOOK_SECRET')) as $name => $value) {
            $request = $request->withHeader($name, $value);
        }

        self::assertSame(204, $app->handle($request)->getStatusCode());
        self::assertSame(400, $app->handle($request->withHeader('webhook-signature', 'v1,bad'))->getStatusCode());
    }
}
CONSTAIA_API_KEY=ck_test_... CONSTAIA_WEBHOOK_SECRET=whsec_... vendor/bin/phpunit tests

CONSTAIA_WEBHOOK_SECRET debe tener el formato whsec_<base64> (por ejemplo, el de un endpoint de test). Más ficheros de prueba en Modo test.

Checklist de producción

  • php.ini: upload_max_filesize = 20M, post_max_size = 21M, max_execution_time = 90.
  • nginx client_max_body_size 21M; y fastcgi_read_timeout 90s;; timeouts ≥ 60 s en proxy y balanceador.
  • Middleware de autenticación y de límite de frecuencia en /documents: cada análisis live consume créditos.
  • expect y checks fijos en la ruta; del navegador solo aceptas el fichero y language.
  • Clave ck_live_ solo en producción, como variable de entorno.
  • Webhook con firma verificada, sin CSRF ni login, deduplicado por webhook-id.
  • Revisa Almacenamiento y privacidad para elegir storage.

Siguientes pasos

En esta página