Lime logo 洛明Lime

使用 Deno 创建一个 Webhook 代理

Strapi 的 webhook 并不支持自定义 payload,不能按照预期触发在 Coding 设置的构建计划,所以使用 Deno 设置一个代理来正确触发。

Webhook

Webhook 允许订阅软件系统中发生的事件,并在发生这些事件时自动接收传送到服务器的数据。

在 Wikipedia 中,Webhook 被描述成用户定义的 HTTP 回调(Webhooks are "user-defined HTTP callbacks"),简单来说,Webhook 可以通过提前配置一个 URL 并在指定事件发生时调用该 URL,这样 URL 的目标服务器收到某事件的通知可以进行后续的处理。

Webhook 可以用于各种应用场景,包括:

配置 Webhook

在 Strapi 管理后台中,设置的 Webhooks 由创建、修改、发布记录(Entry)等操作触发。触发粒度比较粗,不能根据某个集合设定,所属集合、记录内容以及触发事件通过 Payload 携带,且不能自定义 Payload,但是可以自定义 Header。

所以最终的触发流程应该是:Strapi -> Webhooks Agent -> Coding CI

在 Coding 获取触发 URL 和 token

要获取触发 CI 的 URL,可以在 Coding 项目中持续集成 > 构建计划 > 触发规则 > API 触发很容易获取到,通常还需要配合一个 token,可以点击图中标记的按钮生成一段附加 token 的 curl 命令,token 也可以在开发者选项中自行配置。

在 Coding 中生成好 token 后,将 token 和 URL 先记下来,我们稍后就会用到。

使用 Deno 写一个代理服务

在 Coding 中取到的 URL 类似这样:https://<your_team>.coding.net/api/cci/job/<job_id>/trigger,token 是一组 username 和 password。

使用Deno.serve() 可以很容易的启动一个 HTTP 服务器,我们只需要监听一个端点(endpoint),所以最简单的 serve 代码是这样的:

Deno.serve(_req => new Response('Hello, world'))

当服务器收到 Strapi Webhook 触发的请求时,我们再向 Coding 发送请求,这里的请求就可以自定义携带的 Payload 了。在调用 Coding 持续集成 API 时所使用的认证方式为Basic Auth,需要携带上方获取到的 token,在 curl 中以 -u login:password 选项的形式携带,curl 会自动通过 base64 编码转换 login:password 并添加到 Authorization: Basic [token] 请求头中。Strapi Webhook 可以配置 Header,所以我这里把 token 交给 strapi 管理,通过 Header 传递给代理服务器,然后在代理服务器中通过 req.headers 来获取 token。

虽然这里标头使用了”X-”前缀,但是实际上 HTTP 规范建议避免使用”X-”前缀,可以查看这篇讨论

现在,URL 和 token 都已经有了,在 Deno 中,我们可以不借助第三方库直接使用 fetch 发送 HTTP 请求。

Deno.serve({
  port: 6336,
  hostname: 'localhost',
}, async (req) => {
  const uname = req.headers.get('X-Coding-Uname')
  const token = req.headers.get('X-Coding-Token')

  const coding_res = await fetch(
    'https://yourteam.coding.net/api/cci/job/jobid/trigger',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Basic ${btoa(`${uname}:${token}`)}`,
      },
      body: JSON.stringify({
        ref: 'master',
      }),
    },
  ).then(res => res.json())

  return new Response(JSON.stringify(coding_res))
})

但是正如上文所说,Strapi Webhook 触发粒度有点粗,所属集合、记录内容是通过 Payload 携带的,所以我们可以通过 req.json() 读取请求体以获取更多信息。例如,我只想在更新 post 集合时触发 Coding CI,其他情况下不做操作,可以这样做:

Deno.serve({
  port: 6336,
  hostname: 'localhost',
}, async (req) => {
  const uname = req.headers.get('X-Coding-Uname')
  const token = req.headers.get('X-Coding-Token')

  const body: EventPayload = await req.json()

  if (body.model !== 'post')
    return new Response()

  const coding_res = await fetch(
    'https://yourteam.coding.net/api/cci/job/jobid/trigger',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Basic ${btoa(`${uname}:${token}`)}`,
      },
      body: JSON.stringify({
        ref: 'master',
      }),
    },
  ).then(res => res.json())

  return new Response(JSON.stringify(coding_res))
})

至此,我们已经实现了我们最开始的需求,接收来自 Strapi 的请求,经过处理后向 Coding 发送请求,最后触发 CI 流程。

记录日志

最后,如果你希望的话,我们可以添加一个 Logger 库用来记录日志。在 Deno 中导入第三方库需要到 deno.land 获取他的包链接,随后在代码中引入;如果引用第三方库很多需要集中管理版本等,可以采用 import maps 这种形式。

import { Logger } from 'https://deno.land/x/[email protected]/mod.ts'

安装文档说明,我们可以简单地配置一个滚动文件日志:

import { Logger } from 'https://deno.land/x/[email protected]/mod.ts'

interface EventPayload {
  event: string
  createdAt: string
  model?: string
  entry?: Record<string, unknown>
}

const logger = new Logger()

await logger.initFileLogger('./trigger-log', {
  rotate: true,
  maxBytes: 1024 * 10,
})
// logger.disableConsole()

Deno.serve({
  port: 6336,
  hostname: 'localhost',
}, async (req) => {
  const uname = req.headers.get('X-Coding-Uname')
  const token = req.headers.get('X-Coding-Token')

  const body: EventPayload = await req.json()

  logger.info(body)
  if (body.model !== 'post')
    return new Response()

  const coding_res = await fetch(
    'https://yourteam.coding.net/api/cci/job/jobid/trigger',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Basic ${btoa(`${uname}:${token}`)}`,
      },
      body: JSON.stringify({
        ref: 'master',
      }),
    },
  ).then(res => res.json())

  logger.info(coding_res)

  return new Response(JSON.stringify(coding_res))
})

Final

这次我们通过 Deno 启动一个 HTTP 服务器,接收 Strapi 的 Webhook 事件,并灵活处理后重新发送给 Coding。Deno 的标准库给的非常丰富,某些逻辑简单的小脚本可以试着使用 Deno 写一写。嗯,就这样!