Appel des API Workfront et Fusion à partir de votre extension
La référence de contexte Fusion vous donne le jeton IMS de l’utilisateur connecté. L’étape suivante naturelle consiste donc à appeler les API Workfront ou Fusion et à afficher les données réelles. Ceci n’est pas possible en raison de la norme CORS. Cet article explique comment contourner cette limitation à l’aide d’une action d’exécution App Builder en tant que proxy côté serveur. Il comprend un exemple (le tableau de bord des abonnements aux événements).
Pourquoi un appel direct au navigateur échoue (CORS)
Votre interface utilisateur visible s’exécute dans un <iframe> diffusé à partir du réseau CDN d’Adobe (https://<your-app>.adobeio-static.net). Lorsque cette page fetch(...) à une API Workfront ou Fusion d’une origine différente, le navigateur applique le partage de ressources entre origines multiples (CORS). À moins que l’API ne renvoie explicitement le Access-Control-Allow-Origin pour l’origine du réseau CDN, le navigateur bloque la réponse. Ces API ne placent sur la liste autorisée pas les origines d’extension arbitraires. Par conséquent, les appels directs depuis l’invité échouent.
Pour plus d’informations sur CORS, voir CORS.
Appeler votre propre action d’exécution sans CORS
Le correctif pour cela est d’appeler votre propre action d’exécution sans CORS.
Les applications App Builder peuvent inclure des actions d’exécution, qui sont de petites fonctions sans serveur exécutées sur Adobe I/O Runtime côté serveur. Les appels serveur à serveur ne sont pas soumis à la norme CORS du navigateur. Et comme l’action fait partie de votre application, l’invité peut l’appeler avec une URL relative, qui est de même origine et donc non bloquée.
Guest UI (browser, adobeio-static.net)
│ fetch('/api/v1/web/<app>/wf-proxy?...') ← relative = same-origin, no CORS
▼
Your runtime action (Adobe I/O Runtime, server-side)
│ fetch('https://fusion.adobe.com/api/v3/...') ← server-to-server, no CORS
▼
Workfront / Fusion API
L’action reçoit le jeton IMS de l’utilisateur de l’invité et le transfère en amont, de sorte que les appels sont toujours effectués au nom de l’utilisateur avec ses autorisations.
Étape 1 : déclarer l’action
Les actions d’exécution sont déclarées en app.config.yaml sous la runtimeManifest de l’extension. Ajoutez une action wf-proxy en regard de votre extension :
extensions:
fusion/nav-organization/1:
$include: src/fusion-nav-organization-1/ext.config.yaml
runtimeManifest:
packages:
fusion-uix-guest: # ← your package name; part of the action URL
license: Apache-2.0
actions:
wf-proxy:
function: src/fusion-nav-organization-1/actions/wf-proxy/index.js
web: 'yes' # exposes it at /api/v1/web/<package>/wf-proxy
runtime: nodejs:22
inputs:
LOG_LEVEL: debug
annotations:
require-adobe-auth: false # see note below
final: true
L’action devient accessible à l’adresse :
/api/v1/web/<package>/<action> e.g. /api/v1/web/fusion-uix-guest/wf-proxy
require-adobe-auth : true ou false
Cette annotation contrôle si la passerelle Adobe valide un jeton IMS avant l’exécution de votre action.
true: valeur par défaut sécurisée. La passerelle rejette les appels non authentifiés. Cependant, le programme de validation détermine strictement les en-têtes attendus et peut rejeter les requêtes ou supprimer les en-têtes personnalisés dont votre appel en amont a besoin. Si cela se produit, il s’affiche sous la forme d’un401, même si votre jeton est valide.false: ignore la vérification de la passerelle. Votre action est alors invocable publiquement, vous devez donc appliquer l’autorisation vous-même. Demandez un porteur deAuthorizationdans l’action et rejetez-le s’il est manquant, puis transmettez-le en amont, où Workfront et Fusion le valident. Associé à une place sur la liste autorisée cible stricte, décrite à l’étape 2, il s’agit du chemin fiable pour un proxy qui doit transmettre des en-têtes personnalisés.
true d'abord. Si vous voyez une 401 que vous ne pouvez pas expliquer car le jeton est valide et fonctionne ailleurs, passez à false et vérifiez et la sécurité du porteur dans votre action afin que celle-ci soit toujours appliquée en amont.Étape 2 : écrire l’action pour un proxy
Créez des src/fusion-nav-organization-1/actions/wf-proxy/index.js. Deux règles assurent la sécurité : une liste autorisée des cibles en amont afin que l’action ne puisse pas être utilisée comme relais ouvert et un jeton porteur requis qui est transféré en amont.
const fetch = require('node-fetch')
const { Core } = require('@adobe/aio-sdk')
const { errorResponse, getBearerToken, checkMissingRequestInputs } = require('../utils')
// Page-through query params (see "Paginate list results" below).
const pageQuery = (p) => {
const q = new URLSearchParams()
if (p.start != null) q.set('start', p.start)
if (p.limit != null) q.set('limit', p.limit)
return q
}
// Only these upstreams may be reached. Never build the URL from arbitrary input.
const TARGETS = {
subscriptions: {
method: 'GET',
url: () => 'https://<your-wf-host>/attask/eventsubscription/api/v1/subscriptions',
},
hooks: {
method: 'GET',
// Fusion hooks are team-scoped: teamId is a REQUIRED query param (see below).
url: (p) => {
const q = pageQuery(p)
if (p.teamId) q.set('teamId', p.teamId)
return `https://fusion.adobe.com/api/v3/hooks?${q.toString()}`
},
},
scenarios: {
method: 'GET',
url: (p) => {
const q = pageQuery(p)
if (p.fusionOrgId) q.set('organizationId', p.fusionOrgId)
return `https://fusion.adobe.com/api/v3/scenarios?${q.toString()}`
},
},
}
async function main (params) {
const logger = Core.Logger('main', { level: params.LOG_LEVEL || 'info' })
try {
const missing = checkMissingRequestInputs(params, ['target'], ['Authorization'])
if (missing) return errorResponse(400, missing, logger)
const target = TARGETS[params.target]
if (!target) return errorResponse(400, `unknown target '${params.target}'`, logger)
const token = getBearerToken(params) // reads params.__ow_headers.authorization
const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json' }
if (params.orgId) headers['x-gw-ims-org-id'] = params.orgId // Adobe IMS org id
if (params.fusionOrgId) headers['x-organization-id'] = params.fusionOrgId // Fusion tenant id
if (params.teamId) headers['x-team-id'] = params.teamId // Fusion team id
const res = await fetch(target.url(params), { method: target.method, headers })
const text = await res.text()
let body
try { body = JSON.parse(text) } catch (e) { body = text }
if (!res.ok) {
return { statusCode: res.status, body: { error: `upstream ${res.status}`, target: params.target, details: body } }
}
return { statusCode: 200, body }
} catch (error) {
logger.error(error)
return errorResponse(500, 'server error: ' + error.message, logger)
}
}
exports.main = main
getBearerToken, errorResponse et checkMissingRequestInputs proviennent des actions/utils.js générés, où le modèle les utilise comme modèles automatiques. getBearerToken lit params.__ow_headers.authorization, où la passerelle place l’en-tête Authorization entrant.
Étape 3 : appeler l’action de l’invité
Dans l’interface utilisateur, fetch l’action avec une URL relative (même origine) et envoyez le jeton IMS en tant que porteur. Transmettez les identifiants d’organisation et d’équipe dont les éléments en amont ont besoin en tant que paramètres de requête.
const PROXY_URL = "/api/v1/web/fusion-uix-guest/wf-proxy";
async function callProxy(target, token, { imsOrgId, fusionOrgId, teamId, start, limit } = {}) {
const params = new URLSearchParams({ target });
if (imsOrgId) params.set("orgId", imsOrgId); // → x-gw-ims-org-id
if (fusionOrgId) params.set("fusionOrgId", fusionOrgId); // → x-organization-id
if (teamId) params.set("teamId", teamId); // → x-team-id
if (start != null) params.set("start", start); // pagination offset
if (limit != null) params.set("limit", limit); // pagination page size
const res = await fetch(`${PROXY_URL}?${params.toString()}`, {
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`${target} request failed: ${res.status}`);
return res.json();
}
Tirez token, imsOrgId, fusionOrgId et teamId du contexte :
const token = connection.sharedContext.get("imsToken");
const imsOrgId = connection.sharedContext.get("imsOrgId");
const fusionOrgId = connection.sharedContext.get("organization")?.id; // Fusion tenant id
const teamId = connection.sharedContext.get("team")?.id;
Pour plus d’informations sur le contexte, voir Référence du contexte de Fusion.
Spécificités de l’API Fusion v3
Ce qui a fonctionné pour le tableau de bord par rapport à https://fusion.adobe.com/api/v3 :
AuthorizationBearer <imsToken>x-organization-idorganization.idx-team-idteam.idx-gw-ims-org-idimsOrgIdNotez les avertissements suivants :
GET /api/v3/hooksa une portée d’équipe :teamIdest un paramètre de requête obligatoire (/api/v3/hooks?teamId=...). Sans cela, on obtient un400. Cela signifie que les hooks reviennent pour l’équipe active uniquement ; pour couvrir une organisation, boucler les équipes et fusionner.GET /api/v3/scenariosfonctionne avecorganizationId(/api/v3/scenarios?organizationId=...).
401/400, revérifiez d’abord ces en-têtes.Paginer les résultats de la liste
Les points d’entrée de liste Fusion v3 (hooks, scénarios) renvoient une page à la fois, et non l’ensemble. Une réponse ressemble à ceci :
{
"items": [ /* ...this page of records... */ ],
"_page": { "start": 0, "limit": 100, "total": 342 }
}
Les enregistrements sont sous items et les métadonnées de pagination sont sous _page. La page contient les paramètres de requête start (décalage) et limit (taille de la page). Le proxy ci-dessus passe par les deux. Vous devez donc paginer dans l’invité en bouclant jusqu’à ce que vous ayez tout collecté :
const PAGE_LIMIT = 100;
async function fetchAllPages(target, token, opts = {}) {
const all = [];
let start = 0;
// Stop when a page returns fewer than PAGE_LIMIT items, or when _page.total is reached.
for (;;) {
const res = await callProxy(target, token, { ...opts, start, limit: PAGE_LIMIT });
const items = res.items ?? [];
all.push(...items);
const total = res._page?.total;
const done = items.length < PAGE_LIMIT || (total != null && all.length >= total);
if (done) break;
start += PAGE_LIMIT;
}
return all;
}
Si vous préférez conserver la pagination en dehors du navigateur, effectuez la même boucle dans l’action d’exécution et renvoyez le tableau de items fusionné dans une seule réponse. Dans les deux cas, ne supposez pas que la première page correspond à l’ensemble des résultats. Une équipe comportant plusieurs pages de points d’extension ressemblerait à des scénarios manquants.
Liste de contrôle de sécurité
- Placer sur la liste autorisée en amont. Ne construisez jamais l’URL cible à partir d’une entrée client brute. Mappez une clé
targetcourte à une URL fixe, comme à l’étape 2. Cela empêche votre action de devenir un relais ouvert. - Exiger le jeton porteur dans l’action et le transférer en amont. Laissez Workfront et Fusion appliquer les autorisations de l’utilisateur.
- Ne jamais enregistrer le jeton.
imsTokens’agit d’informations d’identification. GardezLOG_LEVELà l’esprit ce questringParametersimprime. - Transférer uniquement via HTTPS aux hôtes Adobe et Workfront approuvés.
Exemple travaillé : le tableau de bord des abonnements aux événements
Le tableau de bord de démonstration associe trois sources pour montrer, par abonnement à un événement Workfront, si un scénario Fusion correspondant est sain :
fetchSubscriptions()→ abonnements aux événements Workfront (avec les compteurs reçus/transmis).fetchHooks(teamId)→ hooks Fusion pour l’équipe active (paginée avecfetchAllPages).fetchScenarios(fusionOrgId)→ Scénarios Fusion pour l’organisation (paginé avecfetchAllPages).
Le join les enchaîne, mais il y a un hic à signaler : un abonnement Workfront et le hook Fusion qu’il pointe en direct sur différents hôtes, donc leurs champs d’URL ne sont pas égaux octet pour octet. Ce qui est stable est le jeton à la fin de l’URL du webhook (le dernier segment de chemin d’accès). Correspond à ce jeton de fin, et non à l’URL complète. Le scenarioId du hook correspond ensuite au id d’un scénario :
subscription.targetUrl ──(trailing token)──▶ hook.url
hook.scenarioId ──▶ scenario.id
// Reduce a webhook URL to its trailing token so hosts/bases can differ.
function hookKey(url) {
if (!url) return "";
const path = String(url).trim().toLowerCase().split(/[?#]/)[0].replace(/\/+$/, "");
const i = path.lastIndexOf("/");
return i >= 0 ? path.slice(i + 1) : path;
}
// Index hooks by token, then look each subscription up by the same token.
const hooksByToken = new Map(hooks.map((h) => [hookKey(pick(h, ["url", "address", "targetUrl"], "")), h]));
const hook = hooksByToken.get(hookKey(pick(sub, ["url", "endpointUrl", "targetUrl", "target.url", "callbackUrl"], "")));
Le statut est dérivé de la jointure :
- Cassé : aucun crochet correspondant ou le crochet est
gone. - Filtrage : apparié, mais
passed < received(les événements arrivent mais sont filtrés avant l’exécution du scénario). - Sain : apparié et réussi.
Comme les formes réelles de payload varient, le client mappe les champs de manière défensive, en essayant plusieurs clés candidates par champ, de sorte qu’une différence d’API mineure ne rompt pas la table :
function pick(obj, keys, fallback) {
for (const key of keys) {
const value = key.split(".").reduce((acc, part) => (acc == null ? acc : acc[part]), obj);
if (value != null) return value;
}
return fallback;
}
Ce n’est qu’un exemple parmi d’autres. Le même modèle de proxy fonctionne pour toute API Workfront ou Fusion dont vous avez besoin.