从扩展中调用Workfront和Fusion API

NOTE
本文假定您对软件开发工具有一定的了解。

Fusion上下文引用会为您提供登录用户的IMS令牌,因此自然而然的下一步是调用Workfront或Fusion API并显示实际数据。 由于CORS,这是不可能的。 本文介绍如何使用App Builder运行时操作作为服务器端代理来绕过此限制,并包含示例(事件订阅仪表板)。

为什么直接浏览器调用失败(CORS)

您的可见UI在从Adobe CDN (https://<your-app>.adobeio-static.net)提供的<iframe>中运行。 当该页面对​ 其他 ​源上的Workfront或Fusion API执行fetch(...)操作时,浏览器将实施跨源资源共享(CORS)。 除非API为您的CDN源明确返回Access-Control-Allow-Origin,否则浏览器将阻止响应。 这些API不会允许列表任意扩展源,因此来自访客的直接调用会失败。

有关CORS的信息,请参阅CORS

在不使用CORS的情况下调用您自己的运行时操作

此问题的解决方法是在不使用CORS的情况下调用您自己的运行时操作。

App Builder应用程序可以包括运行时操作,这些操作是在Adobe I/O Runtime服务器端运行的小型无服务器函数。 服务器到服务器调用不受浏览器CORS限制。 由于该操作是应用程序的一部分,因此访客可以使用相对URL调用它,该URL是同域的,因此不会被阻止。

  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

该操作从来宾接收用户的IMS令牌并将其转发到上游,因此仍代表用户使用其权限进行调用。

第1步:声明操作

运行时操作在扩展的runtimeManifest下的app.config.yaml中声明。 在扩展旁边添加wf-proxy操作:

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

该操作将在以下位置变为可访问:

/api/v1/web/<package>/<action>     e.g.  /api/v1/web/fusion-uix-guest/wf-proxy

require-adobe-auth: true与false

此注释控制Adobe的网关在操作运行之前是否验证IMS令牌。

  • true​安全默认值。 网关拒绝未经身份验证的调用。 但是,验证器会严格限制它预期的标头,并且可能会拒绝请求或丢弃上游调用所需的自定义标头。 如果发生这种情况,即使您的令牌有效,它也会显示为401
  • false​跳过网关检查。 您的操作随后可公开调用,因此您​ 必须 ​自己强制执行授权。 操作中需要Authorization持有者,如果缺少则拒绝,然后向上游转发该持有者,Workfront和Fusion将验证它。 与步骤2中描述的严格目标允许列表相结合,这是需要传递自定义标头的代理的可靠路径。
TIP
请先尝试true。 如果您看到无法解释的401,因为该令牌有效且在其他位置工作,请切换到false ,保留操作中的持有者检查并允许列表,以便上游仍强制实施安全性。

步骤2:为代理编写操作

创建src/fusion-nav-organization-1/actions/wf-proxy/index.js。 有两个规则可以确保此安全:上游目标的允许列表,以便该操作不能用作开放中继,以及上游转发的必需持有者令牌。

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

getBearerTokenerrorResponsecheckMissingRequestInputs来自生成的actions/utils.js,模板将他们作为基架。 getBearerToken读取params.__ow_headers.authorization,网关将传入的Authorization标头放置在该位置。

步骤3:从来宾中调用操作

从您的UI中,fetch具有相对(同源) URL的操作,并将IMS令牌作为持有者发送。 将上游所需的组织和团队ID作为查询参数进行传递。

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();
}

从上下文获取tokenimsOrgIdfusionOrgIdteamId

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;

有关上下文的信息,请参阅Fusion上下文引用

Fusion v3 API详情

对于https://fusion.adobe.com/api/v3的仪表板适用的内容:

标题/参数
价值
注释
Authorization
Bearer <imsToken>
上下文中的用户的IMS令牌。
x-organization-id
organization.id
Fusion自己的租户ID,而不是IMS组织ID。 Fusion通过它来标识租户。
x-team-id
team.id
当调用属于团队范围时发送。
x-gw-ims-org-id
imsOrgId
网关的Adobe IMS组织ID。

请注意以下注意事项:

  • GET /api/v3/hooks是团队范围: teamId是​必需的查询参数 (/api/v3/hooks?teamId=...)。 没有它,您会获得400。 这意味着挂接仅针对​活动团队返回;覆盖组织、循环团队和合并。
  • GET /api/v3/scenarios​与organizationId (/api/v3/scenarios?organizationId=...)配合使用。
NOTE
官方引用是Adobe的Workfront Fusion API。 标头/身份验证要求因网关而异。 此表反映了演示的实际需求。 如果调用返回401/400,请先重新检查这些标头。

按页显示列表结果

Fusion v3列表端点(挂接、场景)一次返回一个​页面,而不是整个集。 响应如下所示:

{
  "items": [ /* ...this page of records... */ ],
  "_page": { "start": 0, "limit": 100, "total": 342 }
}

记录在​ items ​下,分页元数据在​ _page ​下。 您使用​start(偏移量)和​limit(页面大小)查询参数进行页面。 上述代理同时传递两者,因此在收集所有内容之前,通过循环在访客中翻页:

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;
}

如果您希望从浏览器中发出寻呼,请在运行时操作内执行相同的循环,并在一个响应中返回合并的items数组。 无论如何,请不要假定第一页是整个结果集。 否则,具有多页钩子的团队将看起来缺少场景。

安全核对清单

  • 允许列表上游。 切勿通过原始客户端输入构造目标URL。 将短的target键映射到固定URL,如步骤2中所示。 这样可防止您的操作成为开放式中继。
  • 在操作中需要持有者令牌,并将其向上游转发。 让Workfront和Fusion强制实施用户的权限。
  • 从不记录令牌。 imsToken是凭据。 让LOG_LEVEL留意stringParameters打印的内容。
  • 仅通过HTTPS​转发到受信任的Adobe和Workfront主机。

工作示例:事件订阅仪表板

演示仪表板将连接三个源,以根据Workfront事件订阅显示匹配的Fusion场景是否正常:

  1. fetchSubscriptions()Workfront事件订阅(带有已接收/传递的计数器)。
  2. 活动团队的fetchHooks(teamId)个→ Fusion挂接(通过fetchAllPages分页)。
  3. fetchScenarios(fusionOrgId)组织→Fusion方案(通过fetchAllPages分页)。

join​将它们链结,但有一个需要注意的问题:Workfront订阅和它的Fusion挂接指向​ 不同主机 ​上的实时位置,因此它们的URL字段并非完全相同。 稳定的是webhook URL (最后一个路径段)末尾的​令牌。 匹配该尾随令牌,而不是完整URL。 然后,挂接的scenarioId与方案的id匹配:

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"], "")));

状态派生自连接:

  • 已中断:没有匹配的挂接,或者挂接是gone
  • 正在筛选:已匹配,但passed < received(事件已到达,但在方案运行之前已筛选掉)。
  • 正常:匹配并传递。

由于实际有效负载形状不同,因此客户端会防御性地映射字段,在每个字段中尝试多个候选键,因此API的细微差异不会破坏表:

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;
}

这只是个例子。 相同的代理模式适用于您需要的任何Workfront或Fusion API。

recommendation-more-help
workfront-fusion-help-workfront-fusion