Constaia
Integraciones

CodeIgniter

Valida documentos en CodeIgniter 4 con constaia/constaia-php, con un servicio compartido, getFile() en el controlador y un webhook firmado sin CSRF.

Esta guía integra Constaia en CodeIgniter 4 con el SDK oficial de PHP: el cliente como servicio en app/Config/Services.php, un controlador que recibe el fichero con $this->request->getFile(), un webhook que verifica la firma con el cuerpo crudo y la excepción de CSRF que necesita.

Requisitos

  • CodeIgniter 4 instalado con Composer (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 constaia/constaia-php

Variables de entorno

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

CodeIgniter carga .env en el entorno, así que env('CONSTAIA_API_KEY') y getenv() devuelven la clave. No versiones .env: en producción usa variables de entorno reales.

Servicio compartido

app/Config/Services.php
<?php

namespace Config;

use CodeIgniter\Config\BaseService;
use Constaia\Client;

class Services extends BaseService
{
    public static function constaia(bool $getShared = true): Client
    {
        if ($getShared) {
            return static::getSharedInstance('constaia');
        }

        return new Client(env('CONSTAIA_API_KEY'), ['timeout' => 60, 'max_retries' => 2]);
    }
}

Desde cualquier sitio: service('constaia').

Controlador de subida

expect y checks los fija el servidor. El UploadedFile de CodeIgniter guarda el fichero en una ruta temporal (getTempName()) sin el nombre original, así que pasa filename con getClientName(): en modo test el nombre decide la respuesta.

app/Controllers/Documents.php
<?php

namespace App\Controllers;

use CodeIgniter\HTTP\ResponseInterface;
use Constaia\Exception\ConstaiaException;
use Constaia\Exception\InsufficientCreditsException;
use Constaia\Exception\InvalidRequestException;
use Constaia\Exception\RateLimitException;

class Documents extends BaseController
{
    public function upload(): ResponseInterface
    {
        $rules = ['file' => 'uploaded[file]|max_size[file,20480]|ext_in[file,jpg,jpeg,png,webp,heic,pdf]'];
        if (!$this->validate($rules)) {
            return $this->error(implode(' ', $this->validator->getErrors()), 400);
        }

        $file = $this->request->getFile('file');
        if (!$file->isValid() || $file->hasMoved()) {
            return $this->error('No se ha recibido el documento.', 400);
        }

        // Del widget solo se acepta el idioma.
        $fromBrowser = json_decode((string) $this->request->getPost('options'), true);
        $language = is_array($fromBrowser) && in_array($fromBrowser['language'] ?? '', ['es', 'en', 'pt', 'fr'], true)
            ? $fromBrowser['language']
            : 'es';

        try {
            $analysis = service('constaia')->analyze($file->getTempName(), [
                'filename' => $file->getClientName(),
                'expect'   => ['es_dni', 'es_nie', 'passport'],
                'checks'   => ['not_expired' => true, 'min_age_years' => 18],
                'storage'  => 'none',
                'language' => $language,
                'metadata' => ['user_id' => (string) session('user_id')],
            ]);
        } catch (InvalidRequestException $e) {
            return $this->error(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.',
            }, 422);
        } catch (InsufficientCreditsException | RateLimitException $e) {
            log_message('error', 'Constaia {code} request_id={id}', ['code' => $e->errorCode, 'id' => $e->requestId]);

            return $this->error('El servicio está ocupado. Inténtalo en unos minutos.', 503);
        } catch (ConstaiaException $e) {
            log_message('error', 'Constaia {status} {code} request_id={id}', [
                'status' => $e->httpStatus, 'code' => $e->errorCode, 'id' => $e->requestId,
            ]);

            return $this->error('No hemos podido comprobar el documento. Inténtalo más tarde.', 502);
        }

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

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

    private function error(string $message, int $status): ResponseInterface
    {
        return $this->response->setStatusCode($status)->setJSON(['error' => ['message' => $message]]);
    }
}

La respuesta lleva solo lo que el widget necesita para pintar el resultado; los campos extraídos ($analysis->field('document_number')…) se quedan en el servidor. 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.

Vista con el widget

El widget envía el token CSRF como cabecera X-CSRF-TOKEN (el nombre por defecto de Config\Security::$headerName):

app/Views/identity_upload.php
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>

<constaia-upload
    endpoint="<?= site_url('documents') ?>"
    document="es_dni"
    lang="es"
    headers='<?= json_encode([csrf_header() => csrf_hash()]) ?>'
></constaia-upload>

Con Config\Security::$regenerate = true (valor por defecto) el token cambia tras cada petición: si el usuario reintenta sin recargar la página, la segunda subida falla por CSRF. Pon $regenerate = false o recarga la página tras cada intento.

Rutas y CSRF

app/Config/Routes.php
$routes->post('documents', 'Documents::upload', ['filter' => 'session']);
$routes->post('webhooks/constaia', 'ConstaiaWebhook::receive');

session es el filtro de autenticación de CodeIgniter Shield; usa el de tu sistema de login. El webhook no lleva autenticación de usuario (la firma lo es) y hay que excluirlo del filtro CSRF:

app/Config/Filters.php
public array $globals = [
    'before' => [
        'csrf' => ['except' => ['webhooks/constaia']],
    ],
    'after' => [],
];

Webhook

Verifica la firma con el cuerpo crudo, $this->request->getBody(), responde rápido y deduplica por webhook-id.

app/Controllers/ConstaiaWebhook.php
<?php

namespace App\Controllers;

use CodeIgniter\HTTP\ResponseInterface;
use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;

class ConstaiaWebhook extends BaseController
{
    public function receive(): ResponseInterface
    {
        $headers = [];
        foreach (['webhook-id', 'webhook-timestamp', 'webhook-signature'] as $name) {
            $headers[$name] = $this->request->getHeaderLine($name);
        }

        try {
            $event = Webhook::verify((string) $this->request->getBody(), $headers, (string) env('CONSTAIA_WEBHOOK_SECRET'));
        } catch (SignatureVerificationException) {
            return $this->response->setStatusCode(400);
        }

        $cacheKey = 'constaia_wh_' . md5($this->request->getHeaderLine('webhook-id'));
        if (cache($cacheKey) !== null) {
            return $this->response->setStatusCode(204);
        }
        cache()->save($cacheKey, 1, 4 * DAY);

        switch ($event['type']) {
            case 'analysis.completed':
            case 'analysis.review_required':
            case 'analysis.failed':
                $analysis = $event['data'];
                log_message('info', 'Constaia {id}: {status}', [
                    'id' => $analysis['id'],
                    'status' => $analysis['verdict']['status'] ?? $analysis['status'],
                ]);
                // Actualiza tu modelo; si el trabajo es pesado, déjalo en una cola.
                break;
            case 'credits.low':
                log_message('warning', 'Constaia credits low: {n}', ['n' => $event['data']['credits_available']]);
                break;
        }

        return $this->response->setStatusCode(204);
    }
}

Crea el endpoint en el panel con la URL https://tu-dominio.com/webhooks/constaia y guarda el secret en CONSTAIA_WEBHOOK_SECRET. 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; revisa .env
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/_support/fixtures/ como dni_valid.jpg y dni_expired.jpg.

tests/app/ConstaiaTest.php
<?php

use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
use Constaia\Webhook;

final class ConstaiaTest extends CIUnitTestCase
{
    use FeatureTestTrait;

    public function testValidDni(): void
    {
        $analysis = service('constaia')->analyze(SUPPORTPATH . 'fixtures/dni_valid.jpg', ['expect' => 'es_dni']);

        $this->assertTrue($analysis->isValid());
        $this->assertSame('12345678Z', $analysis->field('document_number'));
    }

    public function testExpiredDni(): void
    {
        $analysis = service('constaia')->analyze(SUPPORTPATH . 'fixtures/dni_expired.jpg', ['expect' => 'es_dni']);

        $this->assertTrue($analysis->isInvalid());
        $this->assertSame('Caducado el 15/06/2020.', $analysis->verdict->reasons[1]->message);
    }

    public function testSignedWebhook(): void
    {
        $payload = json_encode(['type' => 'analysis.completed', 'created_at' => date(DATE_ATOM), 'data' => ['id' => 'an_test', 'status' => 'completed']]);
        $headers = Webhook::headers($payload, env('CONSTAIA_WEBHOOK_SECRET'));

        $this->withHeaders($headers + ['Content-Type' => 'application/json'])
            ->withBody($payload)
            ->post('webhooks/constaia')
            ->assertStatus(204);
    }
}

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.
  • Ruta de subida con filtro de autenticación y límite de frecuencia (por ejemplo, un filtro propio con el servicio Throttler de CodeIgniter): cada análisis live consume créditos.
  • expect y checks fijos en el controlador; del navegador solo aceptas el fichero y language.
  • Clave ck_live_ solo en producción, como variable de entorno real.
  • Webhook excluido de CSRF, con firma verificada y deduplicado por webhook-id.
  • Revisa Almacenamiento y privacidad para elegir storage.

Siguientes pasos

En esta página