自写API反向代理Github API

自写API反向代理Github API


在2024年8月11日,我使用Fronti这个开源的Astro主题构建了你们正在浏览的网站。当时非常匆忙,有两篇博文要写,首页就先保持初始状态了。

8月18日,博文已经没有要写的了,就开始对首页进行大的整改,期间这几条Github统计数据可是花了我好多头发,但当时写的匆忙能跑不就行了,不知不觉明明知道给自己挖了一个坑

问题分析

我为了防止用户撞到Github API速率限制墙而无法加载我的Github统计数据,直接把我Github账号的PAT(Personal access tokens)令牌硬编码进请求头里让用户去请求数据,但这样就出现了几个大问题:

  1. 每位用户都要去找Github请求数据,访问的人多起来或遭到DDos攻击非常容易导致我的Github账号受到速率限制。
  2. 原因同上,有些用户无法访问Github,他们也就无法正常加载我的Github统计数据。
  3. 虽然公开的是毫无权限的令牌,但有心之人可以拿我的令牌去不断请求Github API,使我的Github账号受到速率限制,导致正常用户无法加载我的Github统计数据,我也会跟着遭殃。

如何实现

总的来说,我需要让服务端向Github请求数据,Github把数据返回给服务端,服务端做一个缓存,然后把请求到的数据返回给用户,缓存未失效之前再有人访问时服务端会直接把数据发送给客户端,无需再向Github请求数据。

这,听起来反向代理挺适合的,但我不能完全代理api.github.com,这样用户直接把我的反向代理当作镜像站来请求,这不就完犊子了!

那就给Astro加API端点吧!我这么想着,看了一会文档,开始尝试,构建失败7次,运行失败6次,我放弃了,开始寻找别的方法。

github-readme-stats的启示

在那几条Github统计数据完工前,我首页贴着的是两张github-readme-stats.vercel.app提供的卡片,这是一个开源项目,我便去他们的Github仓库翻了翻代码,找到了灵感。

最终的解决方案是使用Vercel的Serverless服务,实现一套带缓存的,只能访问特定的Github API地址的API端点。

实现过程

知道如何实现还不行,现在我对相关代码的编写还是一头雾水,还得寻找更多的资料。

首先,我在网络上搜集相关文档,翻到了Serverless functions with Vercel这篇文章,了解了基本语法,顺便写了个Hello, world!万事都从Hello, world!开始

TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
export default async (
  _: any,
  res: {
    json: (data: any) => void;
    setHeader: (key: string, value: string) => void;
  },
) => {
  res.setHeader("Content-Type", "application/json");
  res.json({ "Hello, ": "world!" });
};

部署完后,使用curl访问刚部署的API地址,得到了以下结果

JSON
1
{ "Hello, ": "world!" }

接下来,就开始编写API吧!

获取项目信息的API

先把要用到的常量保存在src/consts.ts文件中

TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const GITHUB_TOKEN = "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
export const GITHUB_NAME = "Buer-Nahida";

export const method = "POST";
export const headers = {
  "Content-Type": "application/json",
  Authorization: `Bearer ${GITHUB_TOKEN}`,
};

export const PROJECT_BODY = JSON.stringify({
  query: `{
    r0: repository(owner: "${GITHUB_NAME}", name: "antonym.nvim") {
      stargazerCount
      watchers { totalCount }
      forkCount
    }
    r1: repository(owner: "AkashaTerm", name: "AkashaTerminal") {
      stargazerCount
      watchers { totalCount }
      forkCount
    }
    r2: repository(owner: "${GITHUB_NAME}", name: "fcitx5-switch.nvim") {
      stargazerCount
      watchers { totalCount }
      forkCount
    }
  }`,
});

再创建api/project.ts文件,编写获取项目信息的API:

TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { PROJECT_BODY, headers, method } from "../src/consts";

export default async (
  _: any,
  res: {
    json: (data: any) => void;
    setHeader: (key: string, value: string) => void;
  },
) => {
  res.setHeader("Content-Type", "application/json");
  res.json(
    await fetch("https://api.github.com/graphql", {
      headers,
      method,
      body: PROJECT_BODY,
    }).then((r) => r.json()),
  );
};

推送到Vercel上部署,使用curl访问刚创建的API,得到结果:

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
  "data": {
    "r0": {
      "stargazerCount": 1,
      "watchers": { "totalCount": 1 },
      "forkCount": 0
    },
    "r1": {
      "stargazerCount": 0,
      "watchers": { "totalCount": 0 },
      "forkCount": 0
    },
    "r2": {
      "stargazerCount": 1,
      "watchers": { "totalCount": 1 },
      "forkCount": 0
    }
  }
}

解决跨域问题

好,看起来没问题,那就修改下我的网站的代码试试看:

x

果然没有那么简单,遇到了跨域问题,但难不倒我,经过一番搜索,寻找到了解决方法:

先向src/consts.ts文件中添加下面这一行:

TYPESCRIPT
1
export const SIDE = "https://nahida.im";

再修改api/project.ts文件:

TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { SIDE, PROJECT_BODY, headers, method } from "../src/consts";

export default async (
  _: any,
  res: {
    json: (data: any) => void;
    setHeader: (key: string, value: string) => void;
  },
) => {
  res.setHeader("Content-Type", "application/json");
  //+++++++++++++++++++++++++++++++++++++++++++++++//
  res.setHeader("Access-Control-Allow-Origin", SIDE);
  //+++++++++++++++++++++++++++++++++++++++++++++++//
  res.json(
    await fetch("https://api.github.com/graphql", {
      headers,
      method,
      body: PROJECT_BODY,
    }).then((r) => r.json()),
  );
};

然后获取项目信息的API就完成了!

获取除了Commit数量外的其它信息的API

还是一样,先向src/consts.ts文件中添加代码:

TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const STATS_BODY = JSON.stringify({
  query: `{
    user(login: "${GITHUB_NAME}") {
      pullRequests(first: 1) { totalCount }
      openIssues: issues(states: OPEN) { totalCount }
      closedIssues: issues(states: CLOSED) { totalCount }
      contributionsCollection { totalCommitContributions }
      repositories(first: 100) {
        nodes { stargazerCount }
        pageInfo { hasNextPage, endCursor }
      }
    }
  }`,
});

再创建api/stats/index.ts文件:

TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { SIDE, STATS_BODY, headers, method } from "../../src/consts";

export default async (
  _: any,
  res: {
    json: (data: any) => void;
    setHeader: (key: string, value: string) => void;
  },
) => {
  res.setHeader("Content-Type", "application/json");
  res.setHeader("Access-Control-Allow-Origin", SIDE);
  res.json(
    await fetch("https://api.github.com/graphql", {
      headers,
      method,
      body: STATS_BODY,
    }).then((r) => r.json()),
  );
};

获取Commit数量的API

创建api/stats/total-commit-count.ts文件:

TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { SIDE, CACHE, GITHUB_NAME, headers } from "../../src/consts";

export default async (
  _: any,
  res: {
    json: (data: any) => void;
    setHeader: (key: string, value: string) => void;
  },
) => {
  res.setHeader("Content-Type", "application/json");
  res.setHeader("Access-Control-Allow-Origin", SIDE);
  res.json(
    await fetch(
      `https://api.github.com/search/commits?q=author:${GITHUB_NAME}&per_page=1`,
      { headers },
    )
      .then((r) => r.json())
      .then((v) => ({ total_count: v.total_count })),
  );
};

缓存功能

前面提到过,我不光要加快api.github.com的访问速度,还要做一个缓存,在一番寻找后,我选择了lru-cache这个库来实现缓存:

安装这个库

BASH
1
npm install lru-cache --save

修改相关代码 & 最终预览

src/consts.ts
TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { LRUCache } from "lru-cache";

export const SIDE = "https://nahida.im";

export const CACHE = new LRUCache({
  max: 10,
  ttl: 1000 * 60 * 5,
});

const GITHUB_TOKEN = "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
export const GITHUB_NAME = "Buer-Nahida";

export const method = "POST";
export const headers = {
  "Content-Type": "application/json",
  Authorization: `Bearer ${GITHUB_TOKEN}`,
};

export const STATS_BODY = JSON.stringify({
  query: `{
    user(login: "${GITHUB_NAME}") {
      pullRequests(first: 1) { totalCount }
      openIssues: issues(states: OPEN) { totalCount }
      closedIssues: issues(states: CLOSED) { totalCount }
      contributionsCollection { totalCommitContributions }
      repositories(first: 100) {
        nodes { stargazerCount }
        pageInfo { hasNextPage, endCursor }
      }
    }
  }`,
});
export const PROJECT_BODY = JSON.stringify({
  query: `{
    r0: repository(owner: "${GITHUB_NAME}", name: "antonym.nvim") {
      stargazerCount
      watchers { totalCount }
      forkCount
    }
    r1: repository(owner: "AkashaTerm", name: "AkashaTerminal") {
      stargazerCount
      watchers { totalCount }
      forkCount
    }
    r2: repository(owner: "${GITHUB_NAME}", name: "fcitx5-switch.nvim") {
      stargazerCount
      watchers { totalCount }
      forkCount
    }
  }`,
});
api/project.ts
TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { SIDE, CACHE, PROJECT_BODY, headers, method } from "../src/consts";

export default async (
  _: any,
  res: {
    json: (data: any) => void;
    setHeader: (key: string, value: string) => void;
  },
) => {
  const k = 0;
  let v = CACHE.get(k);
  if (!v) {
    v = await fetch("https://api.github.com/graphql", {
      headers,
      method,
      body: PROJECT_BODY,
    }).then((r) => r.json());
    CACHE.set(k, v);
  }
  res.setHeader("Content-Type", "application/json");
  res.setHeader("Access-Control-Allow-Origin", SIDE);
  res.json(v);
};
api/stats/index.ts
TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { SIDE, CACHE, PROJECT_BODY, headers, method } from "../src/consts";

export default async (
  _: any,
  res: {
    json: (data: any) => void;
    setHeader: (key: string, value: string) => void;
  },
) => {
  const k = 0;
  let v = CACHE.get(k);
  if (!v) {
    v = await fetch("https://api.github.com/graphql", {
      headers,
      method,
      body: PROJECT_BODY,
    }).then((r) => r.json());
    CACHE.set(k, v);
  }
  res.setHeader("Content-Type", "application/json");
  res.setHeader("Access-Control-Allow-Origin", SIDE);
  res.json(v);
};
api/stats/total-commit-count.ts
TYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { SIDE, CACHE, GITHUB_NAME, headers } from "../../src/consts";

export default async (
  _: any,
  res: {
    json: (data: any) => void;
    setHeader: (key: string, value: string) => void;
  },
) => {
  const k = 3;
  let v = CACHE.get(k);
  if (!v) {
    v = await fetch(
      `https://api.github.com/search/commits?q=author:${GITHUB_NAME}&per_page=1`,
      { headers },
    )
      .then((r) => r.json())
      .then((v) => ({ total_count: v.total_count }));
    CACHE.set(k, v);
  }
  res.setHeader("Content-Type", "application/json");
  res.setHeader("Access-Control-Allow-Origin", SIDE);
  res.json(v);
};

全文完。