Skip to content

Instantly share code, notes, and snippets.

@chengpeiquan
Last active July 26, 2023 10:43
Show Gist options
  • Save chengpeiquan/982fc73eda02178e1a1b1eedcfad9450 to your computer and use it in GitHub Desktop.
Save chengpeiquan/982fc73eda02178e1a1b1eedcfad9450 to your computer and use it in GitHub Desktop.

这里以使用 TypeScript 开发,以及使用 Axios 库发起请求的例子。

没接触过 TypeScript 的话可以看下我之前的文章,有一个开发入门介绍: 快速上手 TypeScript

管理 Avata 配置

因为需要切换测试环境和生产环境,所以这里把配置单独保存在 config 里了,类型是:

// src/config/avata.ts
interface AvataConfig {
  // API Key 用于网关鉴权
  apiKey: string

  // API Secret 用于接口服务调用签名
  apiSecret: string

  // API Url 是接口域名和公共前缀
  apiUrl: string
}

具体的配置管理可以自己调整,这里是解释一下下面的 import { avata } from '@/config' 这一句是干什么的。

环境变量可以通过 cross-env 管理,用法这里就不赘述了。

编写签名文件

签名的规范详见 #13 ,为了方便维护,这里保存为单独的文件用来生成签名(这里我把 Log 都移除了,自己在调试的时候可以在里面打印一些关键数据看看处理结果)。

// src/libs/axios/signature.ts
import { SHA256 } from 'crypto-js'
import { avata } from '@/config'
import type { AxiosRequestConfig } from 'axios'

interface Obj {
  [key: string]: any
}

/**
 * 提取 URL 里的信息
 * @param fullUrl - 完整的 URL ,可能包含 ?a=1&b=2 后面的尾巴
 * @returns 一个包含请求路径和 Query 参数对象的对象
 *  `path`: 请求路径,仅截取域名后及 Query 参数前部分,例:`/v1beta1/accounts`
 *  `query`: Query 参数对象,会把 `key1=value1&key2=value2` 转为对象
 */
function extractUrlInfo(fullUrl: string): {
  path: string
  query: Obj
} {
  const [url, queryStr] = decodeURIComponent(fullUrl).split('?')

  // 去掉域名,拿到请求路径
  const path = url.startsWith('http')
    ? `/${url.split('/').slice(3).join('/')}`
    : url

  // 提取 URL 后面的 Query 部分
  const query: Obj = {}
  if (queryStr) {
    const qArr = queryStr.split('&')
    qArr.forEach((q) => {
      const [k, v] = q.split('=')
      query[k] = v
    })
  }

  return { path, query }
}

/**
 * 添加对象的键前缀
 * @param source - 需要处理的对象数据源
 * @param prefix - 需要添加的前缀
 * @param isParams - 是否 config.params 里的数据
 * @returns 处理后的对象
 */
function addKeyPrefix(source: Obj, prefix: string, isParams?: boolean) {
  const result: Obj = {}

  // Params 里的值需要处理为字符串
  // 见 https://github.com/bianjieai/opb-faq/issues/13
  for (const k in source) {
    if (Object.prototype.hasOwnProperty.call(source, k)) {
      result[`${prefix}_${k}`] = isParams ? String(source[k]) : source[k]
    }
  }

  return result
}

/**
 * 对键进行排序
 * @description 签名还需要对键排序,否则会报 authentication failed
 * @see https://github.com/bianjieai/opb-faq/issues/60
 * @param target - 要排序的数据
 * @returns 排序结果
 *  对象:直接按照键排序后的新结果
 *  数组:如果里面是对象,也是会进行排序
 *  其他:原样返回
 */
function sortKeys(target: any): any {
  // 非数组和非对象,直接返回
  if (
    !Array.isArray(target) &&
    Object.prototype.toString.call(target) !== '[object Object]'
  ) {
    return target
  }

  // 处理数组
  if (Array.isArray(target)) {
    return target.map((i) => sortKeys(i))
  }

  // 处理对象
  const keys = Object.keys(target).sort()
  const newObj: Obj = {}
  keys.forEach((k) => {
    newObj[k] = sortKeys(target[k])
  })
  return newObj
}

/**
 * 合并参数
 * @param path - 通过 extractUrlInfo 拿到的 Path 请求路径
 * @param query - 通过 extractUrlInfo 拿到的 Query 参数对象
 * @param params - 传入 config.params
 * @param data - 传入 config.data
 * @returns 对键进行了排序的合并结果
 */
function mergeParams(
  path: string,
  query: Obj = {},
  params: Obj = {},
  data: Obj = {}

): Obj {
  // 合并处理了键前缀的结果
  const originResult: Obj = {
    path_url: path,
    ...addKeyPrefix(query, 'query'),
    ...addKeyPrefix(params, 'query', true),
    ...addKeyPrefix(data, 'body'),
  }

  // 对键进行排序
  const result = sortKeys(originResult)
  console.log('[libs/axios/signature]', JSON.stringify(result))

  return result
}

/**
 * 获取 Avata API 的签名
 * @param config - Axios 的请求配置
 * @returns 按照 SHA256(Params+Timestamp+ApiSecret) 的算法生成的 API 签名
 */
export function getAvataSignature(config: AxiosRequestConfig): string {
  const { url, headers, params, data } = config
  const { path, query } = extractUrlInfo(url)
  const sha256Data = mergeParams(path, query, params, data)
  const timestamp = headers['X-Timestamp'] || Date.now()
  const signature = String(
    SHA256(`${JSON.stringify(sha256Data)}${timestamp}${avata.apiSecret}`)
  )

  return signature
}

编写请求拦截器

Axios 支持请求拦截,拦截器我也是一个独立文件维护:

// src/libs/axios/request.ts
import { avata } from '@/config'
import { getAvataSignature } from './signature'
import type { AxiosInstance } from 'axios'

/**
 * 请求拦截
 * 添加一些全局要带上的东西
 */
export function useRequest(instance: AxiosInstance) {
  instance.interceptors.request.use(
    // 正常拦截
    (config) => {
      // 处理 Avata API 配置,请求以 `/avata/xxx` 开头的都是 Avata API
      if (config.url.startsWith('/avata')) {
        // 根据命令行的指定环境处理为完整的请求 URL
        config.url = config.url.replace('/avata', avata.apiUrl)

        // 添加请求头
        const timestamp = Date.now()
        config.headers['X-Api-Key'] = avata.apiKey
        config.headers['X-Timestamp'] = timestamp
        config.headers['X-Signature'] = getAvataSignature(config)
      }

      // 返回处理后的配置
      return Promise.resolve(config)
    },

    // 异常拦截
    (err) => Promise.reject(err)
  )
}

启用请求拦截器

入口文件创建 Axios 实例,然后对这个实例启用拦截器(其他的响应拦截器就不展示了,我还封装了一些判断是否请求成功的方法,所以统一用了命名导出)。

import axios from 'axios'
import { axiosConfig } from './config'
import { useRequest } from './request'

/**
 * 创建一个独立的 Axios 实例
 * 把常用的公共请求配置放这里添加
 */
const instance = axios.create(axiosConfig)
useRequest(instance)

export { instance as axios }

请求 Avata API

这里请求一个不存在的接口作为测试,你可以在上面的文件里,把一些关键参数打印出来看看。

import { axios } from '@/libs/axios'

async function example(): Promise<void> {
  const res = await axios({
    method: 'post',
    url: '/avata/example?a=1&b=2&c=3',
    params: {
      aa: 1,
      bb: 2,
      cc: 'a%20a',
    },
    data: {
      aaa: 'Hello',
      bbb: 'World',
      ccc: 123,
    },
  })

  console.log(res)
}
@monkey-hxz
Copy link

monkey-hxz commented Jul 26, 2023

文昌返回的分页参数可能携带"=",这会导致extractUrlInfo方法里的const [k, v] = q.split('=')处理时丢失数据,建议改成new URL(fullUrl).searchParams来遍历并处理参数

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment