GitLab API 踩坑小结

1. 前言

今年上半年一直在做一个离线作业的调度系统。这个季度为了更好的用户体验,避免用户手动上传和管理文件,做了与公司内部 GitLab 打通的功能。

一方面通过 GitLab 提供的 API,可以很方便地选定某个脚本文件作为数据加工的执行脚本。每次升级的时候选定不同 commit 的版本即可快速地发布任务。

另一种场景下,通过打通 GitLab 的 WebHook 可以在用户向 master 分支推送代码的时候自动触发构建和上传构建好的 artifact,避免用户手动上传几百兆的 JAR 包的等待时间。

2. 实现方案

2.1 文件获取与存储

先说一下文件存储,由于作业调度在分布式环境下执行,所以文件的存储也必须是一个高可用的分布式的文件存储。

作为一个离线作业的调度系统,第一个想到的自然是 HDFS。毕竟如果 HDFS 挂了,基本离线计算任务你也别想跑了。

文件的获取方式分为两种:

第一种是直接从 GitLab 的 Repository 里面拉取文件,这种只适合 SQL、Shell、Python 纯文本的脚本文件(相关接口见:Get file from repository)。

第二种则是面向诸如 Spark 程序的 JAR 包,动辄上百兆。具体做法是与 Jenkins 这种打包构建的工具相结合,master 分支的代码更新后触发 WebHook,由构建工具将源代码打包并通过接口回传。接口可以根据项目的 Project id 和 commit id 组合成一个确定的路径写入 HDFS。

2.2 账号与权限

说到 GitLab API 的调用就不得不提 GitLab 的认证的过程。GitLab 提供了三种验证方式

本着猛糙快的互联网精神,果断选择了第二种方式:使用账户生成一个 TOKEN,通过 HTTP 请求的 Header 参数或者 URL 中的查询参数传递给服务器。简单粗暴!

那么第一个问题来了,这个账号用谁的账号呢?

从代码安全性的角度考虑,个人账号不合适。原因有两点:首先私有项目如果要使用该功能必须把这个账号加入到项目的 Members 中,意味着个人账号可以看到别人(或者别的组)私有项目的内容。本着不粘不背锅的精神,能不碰的就不碰。第二点,GitLab 的账号与企业 LDAP 账号是互通的,一旦离职很可能直接导致 TOKEN 失效,API 无法调用(负责 GitLab 的小姐姐一直强调交接的问题,一种明天就要离职的感觉)。

解决方案是申请一个应用账号,这个账号默认不带有任何项目的权限,用户需要使用这个功能的时候将应用账号加入到 Members 中,赋予 Reporter 角色(至少是 Reporter 角色,否则无法获取文件内容)。

第二个问题:如果共享一个应用账号如防止用户窥探无权限的项目呢?

答案是每次涉及项目信息时通过 GitLab 的 Get project users 接口获取有权限用户的用户名列表,与请求用户的用户名对照。

2.3 交互过程

无论是通过 GitLab 获取文件还是构建好的 artifact,第一步都是先确定一个项目以及其版本信息。搜索 GitLab 项目、选取 Master 分支的特定 commit 流程可以用下图来简单描述一下:

接下来首先说一下 GitLab 文件的上传过程。要唯一的确定 GitLab 中的一个文件需要三个要素:GitLab Project id、Commit id 以及文件在项目中相对路径。因此,后续的交互过程可以用下图来描述:

然后说一下 Git artifact 的上传。使用 WebHook 之后 Jenkins 会把所有 artifact 通过接口回传,我们要做的只是权限和文件的验证即可:

3. 踩过的坑

3.1 分页参数

先从 GitLab 的 API 返回格式说起吧。在过往的工作经历中,后端返回 JSON 一半分为三个部分:

  • Response 元信息,比如返回的状态码、错误提示、请求的唯一标识等等。

  • Response 数据题,真正承载数据的部分。

  • Response 分页信息,主要针对列表查询。

但是 GitLab API 的风格完全不一样,没有响应的元信息和分页信息,直接使用 HTTP 的 Status Code 描述请求的异常。

这倒也罢了,但是类似于列表的接口完全没有分页信息,请求的参数里也没有提到分页参数的设置。一开始还自作聪明地以为 GitLab 的 API 返回了全量的数据,结果在通过关键字搜索 Git 仓库的时候竟然搜不到自己的项目!之后查阅文档才发现 GitLab 的分页信息是写在 Header 里面的(详情请见 Pagination)!

3.2 URL 参数转义

Get file from repository 这个接口的参数定义中,file_path 的说明是 Url encoded full path to new file. Ex. lib%2Fclass%2Erb。意思是说 lib/class.rub 这种文件的相对路径要进行转义,在使用 RestTemplate 的时候猜到了 URL 参数转义的坑。

首先看如下代码:

  @Test
  public void testForUriTemplateWithRawPathParam() {
    String url = "https://gitlab.example.com/api/v4/projects/{project_id}/repository/files/{file_path}"
        + "?private_token={token}";
    UriTemplate uriTemplate = new UriTemplate(url);
    URI expand = uriTemplate.expand(ImmutableMap.of("project_id", 1,
                                                    "file_path", "lib/class.rb",
                                                    "token", "abc"));
    System.out.println("expand = " + expand.toString());
  }

标准输出流的结果是:

expand = https://gitlab.example.com/api/v4/projects/1/repository/files/lib/class.rb?private_token=abc

看来 UriTemplate(org.springframework.web.util.UriTemplate) 并不会主动为参数进行转义,那么我们手动为参数进行转义试试:

  @Test
  public void testForUriTemplateWithEncodedPathParam() throws UnsupportedEncodingException {
    String url = "https://gitlab.example.com/api/v4/projects/{project_id}/repository/files/{file_path}"
        + "?private_token={token}";
    UriTemplate uriTemplate = new UriTemplate(url);

    String encode = UriUtils.encode("lib/class.rb", "UTF-8");
    System.out.println("encode = " + encode);
    URI expand = uriTemplate.expand(ImmutableMap.of("project_id", 1, "file_path", encode, "token", "abc"));
    System.out.println("expand = " + expand.toString());
  }

输出的结果为:

encode = lib%2Fclass.rb
expand = https://gitlab.example.com/api/v4/projects/1/repository/files/lib%252Fclass.rb?private_token=abc

看来 UriTemplate 把我们手动转义的参数中的 % 又进行了一次转义变成了 %25

看来 UriTemplate 要么不转义,要么把结果再给转一次,反正是没法用了。

那么到底如何才能得到正确的参数呢?

我们需要使用 UriComponentsBuilder(org.springframework.web.util.UriComponentsBuilder)

虽然 UriTemplate 底层也是使用的 UriComponentsBuilder,但是我们需要更加精细的控制:

 @Test
  public void testForUriComponentsBuilder() throws UnsupportedEncodingException {

    URI    uri;
    String filePath = "lib/class.rb";

    // Illegal for path, it should use pathSegment
    //uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
    //    .path(String.valueOf(1)).path("repository").path("files").path("lib/class.rb")
    //    .queryParam("private_token", "abc").build(false).toUri();
    //System.out.println("uri = " + uri.toString());
    // result: uri = https://gitlab.example.com/api/v4/projects/1repositoryfileslib/class.rb?private_token=abc
    //
    //uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
    //    .path(String.valueOf(1)).path("repository").path("files").path("lib/class.rb")
    //    .queryParam("private_token", "abc").build(true).toUri();
    //System.out.println("uri = " + uri.toString());
    // result: uri = https://gitlab.example.com/api/v4/projects/1repositoryfileslib/class.rb?private_token=abc

    // exception for build true parameter because 'lib/class.rb' contains '/'
    //uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
    //    .pathSegment(String.valueOf(1), "repository", "files", "lib/class.rb")
    //    .queryParam("private_token", "abc").build(true).toUri();
    //System.out.println("uri = " + uri.toString());

    String encode = UriUtils.encode(filePath, "UTF-8");
    System.out.println("encode = " + encode);
    String encodePath = UriUtils.encodePath(filePath, "UTF-8");
    System.out.println("encodePath = " + encodePath);
    String encodePathSegment = UriUtils.encodePathSegment(filePath, "UTF-8");
    System.out.println("encodePathSegment = " + encodePathSegment);

    uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
        .pathSegment(String.valueOf(1), "repository", "files", encodePathSegment)
        .queryParam("private_token", "abc").build(true).toUri();
    System.out.println("uri = " + uri.toString());
  }

使用上述单元测试里的代码即可构造出访问 GitLab 所需要的 URI 了!

4. 小结

本文所有的参考资料全部来源于 GitLab 的 API 文档,涉及到的 API 有:

以上。

发表评论

电子邮件地址不会被公开。 必填项已用*标注