Vapor
Valida documentos con Constaia desde Swift en el servidor con Vapor 4, subida multipart con el cliente de Vapor, Codable y webhooks HMAC con swift-crypto.
Esta guía integra Constaia en una app Vapor 4: una ruta que decodifica el File que sube el usuario, un cliente que
lo reenvía a la API REST con el cliente HTTP de Vapor, structs Codable con CodingKeys y un webhook verificado con
HMAC<SHA256> de swift-crypto.
No hay SDK oficial para Swift: llamas a la API REST directamente. Si prefieres un cliente generado, la especificación
OpenAPI está en https://api.constaia.com/openapi.json (por ejemplo, con swift-openapi-generator).
Solo en el servidor
Este código es para tu backend Vapor. En una app iOS o macOS no incluyas la clave: la app sube la foto a tu backend (como en la guía de Flutter) y tu backend llama a Constaia.
Requisitos
- Swift 5.9 o superior y Vapor 4.
- Una clave de test
ck_test_…del panel (ver Autenticación). - El secreto
whsec_…de un endpoint de webhook (ver Webhooks).
Instalación
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "ConstaiaVapor",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.115.0"),
.package(url: "https://github.com/apple/swift-crypto.git", "3.0.0" ..< "5.0.0"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Crypto", package: "swift-crypto"),
]
),
]
)CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...Vapor carga .env al arrancar y la clave se lee con Environment.get("CONSTAIA_API_KEY").
Modelos
Structs Codable con la parte del análisis que usas. Los CodingKeys mapean los nombres en snake_case. Como los
valores de fields cambian de tipo según el documento, aquí se decodifican solo los campos de es_dni que usas.
import Vapor
struct Analysis: Codable {
let id: String
let status: String
let livemode: Bool?
let createdAt: String?
let document: DocumentInfo?
let verdict: Verdict?
let fields: DniFields?
let warnings: [String]?
let error: AnalysisError?
enum CodingKeys: String, CodingKey {
case id, status, livemode, document, verdict, fields, warnings, error
case createdAt = "created_at"
}
}
struct DocumentInfo: Codable {
let type: String
let label: String?
let confidence: Double?
}
struct Verdict: Codable {
let expected: [String]?
let match: Bool?
let status: String
let reasons: [Reason]
}
struct Reason: Codable {
let code: String
let severity: String
let message: String
}
struct FieldValue<Value: Codable>: Codable {
let value: Value?
let confidence: Double?
let validated: Bool?
}
/// Solo los campos de es_dni que usas; el resto se ignora.
struct DniFields: Codable {
let documentNumber: FieldValue<String>?
let birthDate: FieldValue<String>?
let expiryDate: FieldValue<String>?
enum CodingKeys: String, CodingKey {
case documentNumber = "document_number"
case birthDate = "birth_date"
case expiryDate = "expiry_date"
}
}
struct AnalysisError: Codable {
let code: String
let message: String?
}
struct AnalyzeOptions: Codable {
var expect: [String]
var checks: Checks?
var storage: String?
var async: Bool?
var language: String?
var metadata: [String: String]?
}
struct Checks: Codable {
var notExpired: Bool?
var holder: Holder?
enum CodingKeys: String, CodingKey {
case notExpired = "not_expired"
case holder
}
}
struct Holder: Codable {
var fullName: String?
enum CodingKeys: String, CodingKey {
case fullName = "full_name"
}
}
struct APIErrorBody: Codable {
let type: String?
let code: String?
let message: String?
let param: String?
let requestId: String?
enum CodingKeys: String, CodingKey {
case type, code, message, param
case requestId = "request_id"
}
}
struct ErrorEnvelope: Codable {
let error: APIErrorBody
}
struct WebhookEventType: Decodable {
let type: String
}
struct WebhookEvent<Payload: Codable>: Codable {
let type: String
let createdAt: String?
let data: Payload
enum CodingKeys: String, CodingKey {
case type, data
case createdAt = "created_at"
}
}Cliente
content.encode(_:as: .formData) envía multipart/form-data con la parte file (el File conserva nombre y tipo)
y la parte options (JSON en texto). Usa una Idempotency-Key por operación y reintenta los 429 respetando
Retry-After con la misma clave, así que no hay doble cobro.
import Foundation
import Vapor
struct ConstaiaError: Error {
let status: UInt
let type: String?
let code: String?
let message: String
let param: String?
let requestId: String?
}
private struct AnalyzeUpload: Content {
let file: File
let options: String
}
struct ConstaiaClient {
static let baseURL = "https://api.constaia.com"
let client: any Client
let apiKey: String
var maxRetries = 2
/// status "completed" (HTTP 200) o "queued"/"processing" (HTTP 202).
func analyze(file: File, options: AnalyzeOptions) async throws -> Analysis {
let optionsJSON = String(decoding: try JSONEncoder().encode(options), as: UTF8.self)
let idempotencyKey = UUID().uuidString
var attempt = 0
while true {
let response = try await client.post(URI(string: "\(Self.baseURL)/v1/analyze")) { req in
req.headers.bearerAuthorization = BearerAuthorization(token: apiKey)
req.headers.replaceOrAdd(name: "Idempotency-Key", value: idempotencyKey)
try req.content.encode(AnalyzeUpload(file: file, options: optionsJSON), as: .formData)
}
if (200..<300).contains(response.status.code) {
return try response.content.decode(Analysis.self)
}
if response.status == .tooManyRequests, attempt < maxRetries {
attempt += 1
let wait = response.headers.first(name: "Retry-After").flatMap { Int($0) } ?? 1
try await Task.sleep(for: .seconds(wait))
continue
}
throw toError(response)
}
}
func getAnalysis(id: String) async throws -> Analysis {
let response = try await client.get(URI(string: "\(Self.baseURL)/v1/analyses/\(id)")) { req in
req.headers.bearerAuthorization = BearerAuthorization(token: apiKey)
}
guard (200..<300).contains(response.status.code) else { throw toError(response) }
return try response.content.decode(Analysis.self)
}
private func toError(_ response: ClientResponse) -> ConstaiaError {
let body = try? response.content.decode(ErrorEnvelope.self).error
return ConstaiaError(
status: response.status.code,
type: body?.type,
code: body?.code,
message: body?.message ?? "HTTP \(response.status.code)",
param: body?.param,
requestId: body?.requestId ?? response.headers.first(name: "X-Request-Id")
)
}
}Verificación del webhook
HMAC<SHA256>.isValidAuthenticationCode compara en tiempo constante.
import Crypto
import Foundation
import Vapor
enum ConstaiaWebhook {
static let tolerance = 300
static func verify(body: String, headers: HTTPHeaders, secret: String, now: Date = Date()) -> Bool {
guard
let id = headers.first(name: "webhook-id"),
let timestamp = headers.first(name: "webhook-timestamp"),
let signatures = headers.first(name: "webhook-signature"),
let ts = Int(timestamp),
abs(Int(now.timeIntervalSince1970) - ts) <= tolerance
else { return false }
let encoded = secret.hasPrefix("whsec_") ? String(secret.dropFirst("whsec_".count)) : secret
guard let keyData = Data(base64Encoded: encoded) else { return false }
let key = SymmetricKey(data: keyData)
let signed = Data("\(id).\(timestamp).\(body)".utf8)
for part in signatures.split(separator: " ") {
let pieces = part.split(separator: ",", maxSplits: 1)
guard pieces.count == 2, pieces[0] == "v1",
let signature = Data(base64Encoded: String(pieces[1])) else { continue }
if HMAC<SHA256>.isValidAuthenticationCode(signature, authenticating: signed, using: key) {
return true
}
}
return false
}
}Rutas
La ruta de verificación recoge el cuerpo completo (hasta 21 MB) y decodifica el multipart en VerifyInput. Conserva
el nombre del fichero: en modo test el resultado depende de él. Tu backend decide expect y checks; no los aceptes
tal cual del cliente. Los errores salen como Abort, que Vapor serializa como { "error": true, "reason": "…" }. El
webhook lee el cuerpo crudo con req.body.string y verifica la firma antes de decodificarlo.
import Foundation
import Vapor
struct VerifyInput: Content {
var file: File
var fullName: String?
enum CodingKeys: String, CodingKey {
case file
case fullName = "full_name"
}
}
struct VerifyResult: Content {
let id: String
let status: String?
let reasons: [String]
let warnings: [String]
}
struct PendingResult: Content {
let id: String
let status: String
}
func routes(_ app: Application) throws {
// Añade aquí tu autenticación: cada análisis consume créditos.
app.on(.POST, "api", "verify", body: .collect(maxSize: "21mb")) { req async throws -> Response in
let input = try req.content.decode(VerifyInput.self)
guard input.file.data.readableBytes > 0 else {
throw Abort(.badRequest, reason: "Falta el documento.")
}
guard input.file.data.readableBytes <= 20 * 1024 * 1024 else {
throw Abort(.payloadTooLarge, reason: "Máximo 20 MB.")
}
guard let apiKey = Environment.get("CONSTAIA_API_KEY") else {
throw Abort(.internalServerError, reason: "Falta CONSTAIA_API_KEY")
}
var checks = Checks(notExpired: true)
if let name = input.fullName, !name.trimmingCharacters(in: .whitespaces).isEmpty {
checks.holder = Holder(fullName: name)
}
let options = AnalyzeOptions(expect: ["es_dni"], checks: checks, storage: "none", language: "es")
let constaia = ConstaiaClient(client: req.client, apiKey: apiKey)
let analysis: Analysis
do {
analysis = try await constaia.analyze(file: input.file, options: options)
} catch let error as ConstaiaError {
req.logger.warning("Constaia \(error.status) \(error.code ?? "-") request_id=\(error.requestId ?? "-")")
if error.type == "invalid_request" {
throw Abort(.unprocessableEntity, reason: error.message)
}
throw Abort(.badGateway, reason: "No se pudo verificar el documento. Inténtalo de nuevo.")
}
if analysis.status != "completed" {
// 202: el resultado llegará por webhook (o consulta getAnalysis).
let pending = PendingResult(id: analysis.id, status: analysis.status)
return try await pending.encodeResponse(status: .accepted, for: req)
}
let result = VerifyResult(
id: analysis.id,
status: analysis.verdict?.status,
reasons: analysis.verdict?.reasons.map(\.message) ?? [],
warnings: analysis.warnings ?? []
)
return try await result.encodeResponse(for: req)
}
app.on(.POST, "webhooks", "constaia", body: .collect(maxSize: "1mb")) { req async throws -> HTTPStatus in
guard let secret = Environment.get("CONSTAIA_WEBHOOK_SECRET") else {
throw Abort(.internalServerError, reason: "Falta CONSTAIA_WEBHOOK_SECRET")
}
guard let raw = req.body.string,
ConstaiaWebhook.verify(body: raw, headers: req.headers, secret: secret)
else { return .badRequest }
// webhook-id es estable entre reintentos: deduplica con él en tu base de datos.
let msgId = req.headers.first(name: "webhook-id") ?? "-"
let body = Data(raw.utf8)
let type = try JSONDecoder().decode(WebhookEventType.self, from: body).type
switch type {
case "analysis.completed", "analysis.review_required", "analysis.failed":
let event = try JSONDecoder().decode(WebhookEvent<Analysis>.self, from: body)
req.logger.info("\(msgId) \(type) \(event.data.id) \(event.data.verdict?.status ?? "-")")
// Encola el trabajo pesado (Vapor Queues…) y responde en menos de 15 s.
default:
break // batch.completed, credits.low, test…
}
return .noContent
}
}import Vapor
@main
enum Entrypoint {
static func main() async throws {
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = try await Application.make(env)
// El análisis síncrono puede tardar hasta 30 s antes de devolver 202.
app.http.client.configuration.timeout = .init(connect: .seconds(10), read: .seconds(60))
do {
try routes(app)
try await app.execute()
} catch {
app.logger.report(error: error)
try? await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
}verdict.status vale:
- Válido el tipo coincide y no hay avisos: acepta.
- No válido alguna razón con severidad
error(tipo distinto, caducado, titular distinto…): rechaza. - Revisar alguna razón con severidad
warning: revisión humana.
Los code son estables; los message vienen traducidos según language. Para un campo:
analysis.fields?.documentNumber?.value. Más en Veredictos.
200 frente a 202
El análisis síncrono espera hasta 30 s. Si termina, recibes 200 con status: "completed"; si no, 202 con
status: "queued" o "processing" y el resultado llega por webhook. Con async: true en AnalyzeOptions la API
responde 202 al momento. analysis.review_required llega además de analysis.completed cuando el veredicto es
review.
Probar en modo test
swift run App serve --hostname 0.0.0.0 --port 8080Con una clave ck_test_… no se gastan créditos y el resultado depende del nombre del fichero (tiene que ser una
imagen o un PDF real: renombra cualquier foto).
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:8080/api/verify
curl -F "file=@dni_expired.jpg" http://localhost:8080/api/verify
curl -F "file=@blurry.jpg" http://localhost:8080/api/verify
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:8080/api/verify| Fichero | Veredicto | Motivo |
|---|---|---|
dni_valid.jpg | valid | "Vigente hasta el 12/03/2031." |
dni_expired.jpg | invalid | not_expired con severidad error: "Caducado el 15/06/2020." |
blurry.jpg | review | low_quality con severidad warning; avisos blurry y low_quality |
dni_valid.jpg con full_name=Juan Pérez | invalid | holder con severidad error |
Más ficheros en Modo test.
Errores
Las respuestas de error tienen la forma { "error": { "type", "code", "message", "param", "request_id" } }, que el
cliente convierte en ConstaiaError. Decide por type y code, nunca por el mensaje, y registra requestId. Tabla
completa en Errores.
Checklist de producción
- Clave
ck_live_…(requiere email verificado) en tu gestor de secretos o variable de entorno. - Autenticación y límite por usuario en
/api/verify: cada análisis consume créditos. - Límite de 20 MB antes de llamar a la API.
- Una
Idempotency-Keypor operación lógica, reutilizada en los reintentos (Idempotencia). - Respeta el límite por clave (2 req/s en el plan gratuito, 10 en el de pago) y
Retry-After(Límites). - Webhook registrado para los
202y los análisisasync; deduplica porwebhook-idy responde rápido. - Revisa
storageykeep_resultsen Almacenamiento y privacidad.