Skip to content

Connettore: HTTP (API REST per i nodi lookup e action) ​

Parla con un'API REST dentro il grafo: legge con httpLookup in un nodo lookup, scrive con httpAction in un nodo action. I due condividono trasporto, configurazione e regole sugli errori; cambia che cosa sono autorizzati a fare.

Letture — httpLookup ​

Arricchisce i record leggendo da un'API REST, in sola lettura, dentro un nodo lookup. È la versione HTTP di postgresLookup: l'IO resta visibile nel grafo invece che nascosto in una transform. Codice in src/connectors/http/.

FunzioneTipoCosa fa
httpLookupLookupResolverRisolve un batch di chiavi con un'API REST
httpActionActionScrive su un'API REST e prosegue con l'esito
loadHttpRegistryregistryLe API configurate, per istanza
loadHttpConfigconfigUna sola API, da variabili con prefisso (HTTP_)
loadHttpInstanceconfigUna sola istanza del registry, senza le altre
istanzaHttpPerIlPianopianoL'istanza per il piano, con la sua ricaduta
richiestaHttptrasportoUna richiesta JSON, per gli altri connettori REST
rispostaErroreHttptrasportoStatus, header e corpo di una risposta di errore

Configurazione ​

loadHttpRegistry(env?) risolve le istanze: con HTTP_INSTANCES=gestionale ogni API ha le sue HTTP_<ID>_*, senza lista valgono le variabili base come istanza default. Per etl env check, istanzaHttpDichiarata(id) è l'istanza da passare ad ambienteDellaPipeline nell'environment della pipeline, con le credenziali della variante di AUTH che il piano non mostra.

VariabileDefaultNote
HTTP_BASE_URL—obbligatoria; http o https, senza credenziali nell'URL
HTTP_AUTHnonenone / bearer / header / basic
HTTP_TOKEN—con bearer: Authorization: Bearer <token>
HTTP_HEADER_NAME—con header: il nome dell'header, per esempio X-Api-Key
HTTP_API_KEY—con header: il valore
HTTP_USERNAME—con basic
HTTP_PASSWORD—con basic
HTTP_TIMEOUT_MS10000tempo massimo di una richiesta, in millisecondi
HTTP_MAX_RESPONSE_BYTES10485760dimensione massima di una risposta (10 MiB)

Le variabili sono dichiarate in MANIFEST_HTTP: HTTP_AUTH sceglie la variante, e si leggono solo le variabili di quella. Con variabili mancanti il ConfigError le elenca tutte, anche nel campo mancanti. loadHttpInstance(id) carica una sola istanza senza le altre, e istanzaHttpPerIlPiano(id, env) è la stessa istanza per il piano, che ricade sull'esempio da sola.

Gli errori di configurazione nominano le variabili, mai i valori. HTTP_HEADER_NAME deve essere un nome di header valido e diverso da accept, content-type, content-length e host: gli header del protocollo li imposta il connettore.

Due modalità ​

batch: una richiesta per tutto il batch di chiavi del nodo, dove l'API sa cercarne molte insieme. GET con le chiavi in query, oppure POST di ricerca con le chiavi nel body.

ts
const gestionale = loadHttpRegistry().get("gestionale");

.lookup<Riga, RigaConCliente>("righe", "cliente", {
  resolver: httpLookup({
    mode: "batch",
    config: gestionale,
    instanceId: "gestionale",
    method: "GET",
    path: "/clienti",
    request: (keys) => ({ query: { codice: keys } }), // ?codice=A&codice=B
    response: z.object({ clienti: z.array(clienteSchema) }),
    rows: (r) => r.clienti,
    keyOf: (cliente) => cliente.codice,
    normalizeKey: (k) => k.trim().toUpperCase(), // facoltativa, ai due lati
  }),
  key: (r) => r.codiceCliente,
  merge: (rows, r) => ({ ...r, cliente: rows[0] ?? null }),
}, { deadLetter: scarti })

per-key: una GET per chiave, per le API che espongono solo la risorsa singola. Un 404 vale «non trovato»; al più concurrency richieste insieme (default 4).

ts
httpLookup({
  mode: "per-key",
  config: gestionale,
  instanceId: "gestionale",
  path: (id) => `/ordini/${id}`, // la chiave arriva già codificata per l'URL
  response: ordineSchema,
  rows: (ordine) => [ordine], // facoltativa: default la risposta come riga unica
  concurrency: 4,
});

In entrambe le modalità le righe tornano sulla chiave esattamente come è stata passata (contratto di LookupResolver): un servizio che risponde in maiuscolo non trasforma una chiave minuscola in un non-trovato silenzioso, se normalizeKey lo dichiara.

Errori ​

CasoEsito
2xx con JSON conforme allo schemarighe
404 in per-keynon trovato (onNotFound del nodo decide)
429, 502, 503, 504LookupError transitorio, Retry-After
altro 4xx, 5xx, 404 in batchLookupError con status
timeout, servizio non raggiungibileLookupError transitorio
corpo non JSON o non conforme allo schemaLookupError con i percorsi dei campi errati
risposta oltre HTTP_MAX_RESPONSE_BYTESLookupError
chiave . o .. in per-keyLookupError, nessuna richiesta
abort della runl'abort si propaga, non è un LookupError

Un LookupError segue la semantica del nodo: dead-letter se c'è, ramo catch sotto un tryCatch, altrimenti fail-fast. In per-key un errore fa fallire tutto il batch del nodo, come un round-trip SQL: con batchSize: 1 l'errore resta sul singolo record.

In per-key, al primo errore le richieste ancora in volo vengono annullate e le chiavi rimaste non partono.

Retry e rate limit. Gli errori transitori si ritentano con la policy retry del nodo lookup, che rifà il round-trip; un Retry-After (secondi o data) allunga l'attesa fino a maxMs. httpLookup accetta anche un rateLimit: { perSecond } proprio, che conta le singole richieste: in per-key un round-trip ne fa molte, e il rateLimit del nodo non basterebbe.

Niente dati negli errori. Il messaggio porta metodo, endpoint con {chiave} al posto della chiave e status: mai la chiave, il corpo della risposta o i valori rifiutati dallo schema, perché finiscono nel report e una chiave può essere un'email o un codice fiscale.

Descriptor ​

Kind http-lookup, con l'istanza (connection), la base (origine e path, senza query), la richiesta (GET /ordini/{chiave}), la modalità e in fondo la credenziale. Mai token, header di autenticazione o password.

La base porta la variabile da cui arriva (HTTP_BASE_URL) e la sua origine. La credenziale dice la variante di HTTP_AUTH e il suo stato, mai il valore né il nome dell'header:

HTTP_AUTHRiga credenziale
assente o nonenessuna
bearertoken · impostato
headerAPI key · impostata
basicpassword · impostata
incompletala variante e le variabili che mancano, origine mancante
sconosciutaHTTP_AUTH non valido (attesi: …)

Limiti ​

  • Niente paginazione in batch: il batchSize del nodo deve stare in una risposta.
  • In batch con GET le chiavi vanno nell'URL: con il batchSize di default (200) un servizio può rispondere 414 (URL troppo lungo). Meglio un POST di ricerca, o un batchSize più piccolo.
  • In per-key, se lo schema della risposta è un array, un array vuoto è comunque una riga («trovato»): per contare come non trovato va estratto con rows: (lista) => lista.
  • Il path in per-key riceve la chiave codificata e non può uscire dal prefisso della base: un path che, risolto, esce dal prefisso è un LookupError.
  • httpLookup resta di sola lettura: una scrittura è httpAction (sotto).

Scritture — httpAction ​

Aggiorna un sistema esterno e prosegue con la sua risposta: confermare un pagamento su un gestionale, creare una pratica, chiudere una lavorazione. È un nodo action, quindi la scrittura è visibile nella topologia e nel DAG invece di sparire in una fetch dentro una pipeline.

ts
.action("verificato", "aggiorna", httpAction({
  config: registry.get("gestionale"),
  instanceId: "gestionale",
  method: "POST",
  path: "/aggiorna-ordine",
  request: (r) => ({ body: { numeroOrdine: r.numero, importoPagato: r.importo } }),
  response: z.object({ esito: z.enum(["OK", "KO"]), messaggio: z.string().optional() }),
  merge: (r, esito) => ({ ...r, esito }),
}))

method accetta solo i verbi di scrittura (POST, PUT, PATCH, DELETE): per leggere c'è httpLookup, che mette in comune le chiavi di un batch e ha una cache per run. path è configurazione e non dipende dal record — è ciò che rende l'endpoint leggibile nel DAG e sicuro nei messaggi d'errore.

L'esito è un dato ​

merge riceve un'unione discriminata:

ts
{ ok: true, status, body }              // risposta 2xx, validata con lo schema
{ ok: false, status?, motivo }          // fallita; `status` manca se non è mai arrivata

Un esito.ok === true con body.esito === "KO" non è un errore: è il gestionale che risponde «non ci riesco». La distinzione fra «non ho potuto parlargli» e «mi ha detto di no» sta tutta qui, e di solito il grafo la usa con un route.

⚠️ Un ok: false con status valorizzato non vuol dire che la scrittura non sia avvenuta. Lo status c'è solo quando il servizio ha risposto: un 500 dopo aver aggiornato, o un 200 con un corpo fuori contratto, sono fallimenti per noi ma possono essere scritture andate a buon fine dall'altra parte. Senza status la richiesta non è arrivata (rete, timeout, DNS) e la scrittura quasi certamente non c'è stata. Chi progetta il ramo di recupero deve trattare i due casi in modo diverso: il primo va a un umano, il secondo si può rimettere in coda.

Un corpo vuoto (204 No Content, tipico dopo un DELETE) vale null: lo accetta uno schema z.null(), lo rifiuta uno z.object come qualsiasi altra risposta fuori contratto.

Su errore: fermarsi o proseguire ​

onErrorCosa succede
"fallisci"Default. Lancia un ActionError: dead-letter del nodo, o ramo catch.
"continua"L'esito ok: false arriva a merge e il grafo prosegue.

⚠️ Con "continua" il ramo che riceve un esito fallito deve fare qualcosa di visibile — notificare, contare, instradare in revisione. Altrimenti un servizio spento diventa silenzio, che è peggio di una run rossa.

Retry: solo se lo dichiari ​

Ritentare una scrittura la ripete. Il framework non può sapere se il servizio la riconosce come già fatta, quindi non lo indovina: una retry senza idempotente: true è un ConfigError alla costruzione del grafo, non un problema che salta fuori in produzione. È la stessa regola di Sink.retrySafe.

Dichiara idempotente: true solo quando è vero: il servizio deduplica su una chiave naturale, o la richiesta è un PUT che sostituisce una risorsa intera. Con l'opt-in si ritentano solo gli errori transitori (429, 502, 503, 504, timeout, rete), mai un 400 o un 422.

Non esiste un Idempotency-Key generato dal connettore: la deduplicazione è del servizio, sulla chiave naturale che gli mandi. Per questo request viene valutata una volta sola, prima del primo tentativo, e ogni retry rimanda byte per byte lo stesso corpo: se generasse un id o un timestamp a ogni giro, il servizio vedrebbe richieste diverse e idempotente non proteggerebbe da niente.

Errori e descriptor ​

Valgono le regole delle letture: il messaggio porta metodo, endpoint e status, mai il corpo del record o della risposta. Il descriptor ha kind http-action, con istanza, base, richiesta (POST /aggiorna-ordine), comportamento su errore, se la scrittura è dichiarata idempotente e la stessa riga credenziale delle letture — mai token, header di autenticazione o password.

Trasporto condiviso — richiestaHttp ​

httpLookup e httpAction passano per lo stesso trasporto, ed è esportato dal barrel perché lo usino anche i connettori di dominio che parlano REST, a partire da quello di Jira: invece di riscriversi timeout, tetto sui byte, abort, validazione Zod e classificazione degli errori transitori, chiamano richiestaHttp. Le tabelle degli errori sopra valgono uguali.

ts
import { richiestaHttp, rispostaErroreHttp } from "../http/index.js";

const risposta = await richiestaHttp(
  config, // un HttpConfig, dal registry dell'istanza
  {
    method: "POST",
    path: "/rest/api/3/search/jql",
    endpoint: "/rest/api/3/search/jql", // senza dati: finisce nei messaggi
    body: { jql, fields },
    headers: { "X-Atlassian-Token": "no-check" },
  },
  paginaSchema,
  ctx,
  { chiamante: "lookup" }, // o "action": decide LookupError / ActionError
);
risposta.headers["x-ratelimit-remaining"]; // anche con kind "not-found"

Rispetto a ciò che vedono httpLookup e httpAction aggiunge tre cose:

  • Header per richiesta (headers). Quelli del protocollo — accept, content-type, content-length, host — e authorization restano del trasporto, come l'header di autenticazione dell'istanza (HTTP_HEADER_NAME). Chiederne uno, o un header non valido, è un ConfigError che nomina l'header e mai il valore, e la richiesta non parte.
  • Header della risposta nell'esito, con i nomi in minuscolo: servono per leggere i limiti del servizio (X-RateLimit-*, Retry-After) anche sulle risposte andate bene.
  • Risposta degli errori: status, header e corpo, se è JSON e sta sotto HTTP_MAX_RESPONSE_BYTES. Si legge solo da codice, con rispostaErroreHttp(err), che segue la catena delle cause; un corpo vuoto, non JSON o troppo grande lascia corpo assente, e l'errore resta quello di status. httpLookup e httpAction non la leggono: scartano il corpo dell'errore senza leggerlo, come hanno sempre fatto.

La risposta non è nell'errore. Il messaggio resta metodo, endpoint e status, e details non cambia: la risposta sta fuori dall'oggetto errore, quindi né il report, né il log, né un JSON.stringify la portano. Il connettore che la legge ne prende ciò che gli serve — per Jira i nomi dei campi rifiutati, le chiavi di errors — e decide dove metterlo: i testi del servizio vanno solo nell'esito dato a merge, mai in un messaggio. Va letta dove si cattura l'errore del trasporto: gli errori che il runner ricostruisce (in una dead-letter, per esempio) non la portano.

Noeva è un marchio registrato di 4D S.R.L.