使用 GraphQL API 构建基于 Issues 的博客

身边有很多人把 GitHub 的 Issues 用作个人博客,它具有写作方便、免于部署的优点,但是由于需要从仓库的 Issues 访问,定制化程度很低。

GitHub 提供了访问仓库的 Issues 的 API,我们可以自己开发定制前端页面,发布到 GitHub Pages,前端调用 API 读取 Issues 动态渲染页面。这样我们依旧能够在 GitHub 上新建/修改文章,同时不用再去进行发布操作。

简单实现

Github 当前提供了 2 种版本的 API:

它们都能满足我们的需求,但是 REST 版本返回的数据较多,里面有很多我们不需要的内容,所以这里我们选择可定制性更强的 GraphQL 版本。

查询语句的构建可以在 GraphQL API Explorer 进行。

查询语句:

{
  search(type: ISSUE, query: "label:tip repo:hanai/blog_source", first: 10) {
    nodes {
      ... on Issue {
        title
        body
        bodyHTML
        createdAt
        labels(first: 8) {
          nodes {
            name
          }
        }
        lastEditedAt
      }
    }
  }
}

前端页面请求 API:

fetch("https://api.github.com/graphql", {
  method: "POST",
  headers: {
    authorization: "bearer {{token}}"
  },
  body: JSON.stringify({
    query: query
  })
})
  .then(res => res.json())
  .then(console.log);

安全实现

直接将自己 GitHub 的 access token 暴露在公网环境是非常危险的(本例中 access token 对公开仓库具有写权限),实际使用中可以借助云服务商提供的函数计算服务作为网关来请求 GitHub 的 API:

以阿里云为例,代码如下:

const process = require('process');
const https = require("https");

module.exports.handler = function (req, resp, context) {
    const { queries, headers } = req;
    const { origin } = headers;

    const ak = process.env.ak;

    if (/\.ihanai\.com/.test(origin)) {
        const postData = JSON.stringify({
            'query': `{
  search(type: ISSUE, query: "label:tip repo:hanai/blog_source", first: 10) {
    nodes {
      ... on Issue {
        title
        body
        bodyHTML
        createdAt
        labels(first: 8) {
          nodes {
            name
          }
        }
        lastEditedAt
      }
    }
  }
}`
        });
        const httpReq = https.request({
            host: 'api.github.com',
            path: '/graphql',
            method: 'POST',
            headers: {
                authorization: "bearer " + ak,
                'Content-Length': Buffer.byteLength(postData),
                'user-agent': "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.100 Safari/537.36"
            }
        }, res => {
            res.setEncoding('utf8');
            const chunks = [];
            res.on('data', (chunk) => {
                chunks.push(chunk);
            });
            res.on('end', () => {
                resp.setStatusCode(200);
                resp.setHeader('content-type', 'application/json');
                resp.setHeader('Access-Control-Allow-Origin', origin);
                const json = JSON.parse(chunks.join(''));
                resp.send(JSON.stringify(json.data.search));
            });
        });
        httpReq.write(postData);
        httpReq.end();
    } else {
        resp.setHeader('Access-Control-Allow-Origin', 'https://blog.ihanai.com');
        resp.send('');
    }
}