Aller au contenu principal
Version: 1.0.0

Tutoriel - Tool vers API publique avec le SDK Touba

Ce projet effectue une vraie lecture HTTP sur Données Québec. Il nécessite la distribution Touba, Node.js 24, npm et Internet, sans clé API ni modèle payant. Une panne réseau reste une erreur, pas un résultat de catalogue inventé.

1. Installer le SDK du projet​

Voir l'installation du SDK. Dans un terminal macOS/Linux :

Terminal
mkdir -p public-api-demo
cd public-api-demo
mkdir -p handlers vendor
npm init -y
node --input-type=module -e 'import {execFileSync} from "node:child_process"; import {copyFileSync} from "node:fs"; const v=JSON.parse(execFileSync("touba",["version","--json"],{encoding:"utf8"})); copyFileSync(v.distribution.sdk_archive,"vendor/touba-sdk.tgz");'
npm install --offline --ignore-scripts --save-exact ./vendor/touba-sdk.tgz

2. Déclarer le tool typé​

datasets.tb
module examples.search

type DatasetSearchRequest
query is Text

type DatasetSearchResult
count is Int
first_title is Text

tool SearchDatasets
description "Search the public Données Québec dataset catalogue."
takes request as DatasetSearchRequest
gives DatasetSearchResult
requires network

skill SearchPublicDatasets
description "Return the number of matches and the first dataset title."
takes request as DatasetSearchRequest
gives DatasetSearchResult
uses SearchDatasets
do
SearchDatasets
with request as request
into result
give result

3. Implémenter la requête HTTP​

Le guide officiel de l'API décrit package_search. Le handler fournit q et limite les fiches renvoyées avec rows=1. Le nombre total de correspondances reste distinct de cette limite.

handlers/search_datasets.handler.mjs
import { defineTool, ToubaHandlerError } from "@touba/sdk";

await defineTool({
toolRef: "tool:examples.search/SearchDatasets",
inputSignature: [{ name: "request", type_ref: "type:examples.search/DatasetSearchRequest" }],
outputType: "type:examples.search/DatasetSearchResult",
requiredGrants: ["network"],
run: async ({ request }) => {
const url = new URL("https://www.donneesquebec.ca/recherche/api/3/action/package_search");
url.searchParams.set("q", request.query);
url.searchParams.set("rows", "1");
const response = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!response.ok) throw new ToubaHandlerError("DatasetHttpError", "The catalogue request failed.");
const payload = await response.json();
if (payload.success !== true || !Number.isSafeInteger(payload.result?.count)
|| !Array.isArray(payload.result?.results)) {
throw new ToubaHandlerError("DatasetResponseInvalid", "The catalogue response has an unexpected shape.");
}
const first = payload.result.results[0];
const title = first?.title ?? first?.name ?? "No matching dataset";
if (typeof title !== "string") throw new ToubaHandlerError("DatasetTitleInvalid", "A dataset title must be text.");
return { count: payload.result.count, first_title: title };
},
});

4. Déclarer le binding hôte​

Ce script écrit des manifests complets avec le chemin absolu du Node utilisé. Le premier accorde explicitement le réseau ; le second le refuse pour le test. L'opération lit des données et déclare kind: none, pas une écriture externe.

configure.mjs
import { writeFileSync } from "node:fs";
const manifest = {
manifest_version: "touba-manifest-v1", bindings: {
"tool:examples.search/SearchDatasets": {
handler: { kind: "process", command: process.execPath, args: ["handlers/search_datasets.handler.mjs"], cwd: "." },
grants: ["network"], effect_contract: { kind: "none" },
timeout_ms: 20000, max_payload_bytes: 65536,
payload_constraints: { input: { "request.query": { non_empty: true, max_length: 120 } } },
},
},
};
writeFileSync("touba.manifest.json", JSON.stringify(manifest, null, 2) + "\n");
manifest.bindings["tool:examples.search/SearchDatasets"].grants = [];
writeFileSync("denied.manifest.json", JSON.stringify(manifest, null, 2) + "\n");

touba manifest generate peut aussi fournir un brouillon, mais ne choisit ni grant ni contrat d'effet à votre place. Un effect_contract: null généré n'est pas exécutable. Le runtime ne découvre pas le manifest implicitement.

5. Exécuter et vérifier le refus​

verify.mjs
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const result = JSON.parse(readFileSync("result.json", "utf8"));
assert.equal(result.status, "success");
assert.ok(Number.isSafeInteger(result.result.count) && result.result.count >= 0);
assert.equal(typeof result.result.first_title, "string");
const refused = JSON.parse(readFileSync("refused.json", "utf8"));
assert.equal(refused.reason, "ToolCapabilityNotGranted");
assert.equal(refused.trace.events.filter(event => event.kind === "tool_binding_authorized").length, 0);
console.log(JSON.stringify(result.result));
console.log("PUBLIC_API_READ_AND_GRANT_REFUSAL_OK");
Terminal
node configure.mjs
touba compile datasets.tb --out datasets.air.json
touba run datasets.air.json --entry skill:examples.search/SearchPublicDatasets \
--manifest touba.manifest.json --inputs-json '{"request":{"query":"pompier"}}' \
--output json --trace-out api.trace.json > result.json
if touba run datasets.air.json --entry skill:examples.search/SearchPublicDatasets \
--manifest denied.manifest.json --inputs-json '{"request":{"query":"pompier"}}' \
--output json > refused.json; then
exit 1
else
test "$?" -eq 1
fi
node verify.mjs

Résultat attendu : PUBLIC_API_READ_AND_GRANT_REFUSAL_OK. Le nombre et le premier titre peuvent évoluer avec le catalogue. result.json contient les données métier ; la trace décrit l'exécution sans recopier le corps HTTP. Le run refusé termine avec le code 1 sans autoriser le handler.

Le runtime contrôle schéma d'entrée et contraintes avant dispatch, puis schéma de sortie avant publication de la valeur. Un grant n'est pas une sandbox OS. Pour l'intégrité des artefacts, poursuivre avec le tutoriel supply chain ; un lock ne donne aucun droit.