GitHub 上の OpenAPI Spec を SwaggerUI でURL共有できるツールを作ります🌟

[petstore.yaml]:https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml
[GitHub]:https://github.com/
[SwaggerUI]:https://swagger.io/tools/swagger-ui/
[volta]:https://volta.sh/
[Personal Access Token]:https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
[Next.js]:https://nextjs.org/
[Node.js]:https://nodejs.org/
[Vercel]:https://vercel.com/
[AWS Amplify]:https://aws.amazon.com/jp/amplify/
[React]:https://react.dev/
[swagger-ui-react]:https://www.npmjs.com/package/swagger-ui-react
[Route Handler]:https://nextjs.org/docs/app/building-your-application/routing/route-handlers


:::note
本記事は [リンクアンドモチベーション Advent Calendar 2023](https://qiita.com/advent-calendar/2023/lmi) の7日目です🎄
:::

こんにちは！ リンクアンドモチベーションの川津です🌟

今日のWebサービス開発をしている現場では [OpenAPI Specification (旧Swagger)](https://swagger.io/specification/) を使って API 仕様を書いてる所は多いかと思います。

弊社でも使ってまして、先日 API 仕様のレビュー依頼が来たのですが、見るまでの手順 ↓ が煩雑で「**単に URL 共有してくれると嬉しいのにな〜**」と思ったのが本記事の発端です😅

1. レビュー対象の Branch を pull する
1. VSCode で Specification ファイル (.yaml / .json) を開く
1. [OpenAPI (Swagger) Editor](https://marketplace.visualstudio.com/items?itemName=42Crunch.vscode-openapi) でプレビュー表示する

# 完成品

という事でツールを作ってみました。

以下の例では OAI 公式の GitHub で公開されているサンプル [petstore.yaml] を [SwaggerUI] を用いて描画しています。

https://swagger-github.vercel.app/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml

GitHub repository:

https://github.com/megmogmog1965/swagger-github

:::note warn
このツールそのものは認証等のセキュリティ対策は無いので、基本的に利用の際は **インターネットに公開してはいけません！**
プライベートネットワーク中にホストするか、別途IPアドレス制限等をします。
:::

# 使い方

## URL

URL で GitHub 上の OpenAPI Specification ファイル (`.yaml` / `.json`) を指定できます。

```
https://swagger-github.vercel.app/{owner}/{repo}/{branch}/{path_to_spec}
```

この URL のパス部分は GitHub Raw ファイル URL [raw.githubusercontent.com](https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml) と同じです。

https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml

<img width="400" alt="Pasted_Image_2023_12_04_20_10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/5610bbf0-1a3d-b2ea-f6b3-93a8c0220c9a.png">

## GitHub (Private Repo) 認証

サーバーサイドの環境変数もしくは `.env` ファイルで GitHub トークンを指定する様にしました。

```shell
# Your Authorization Bearer token. (e.g. GitHub "personal access token")
BEARER_TOKEN=
```

> 前述のサンプルで公開しているケースでは `BEARER_TOKEN` は未指定にしています。

# 仕組み

## 要素技術

* [Node.js] → 20.10.0
* [Next.js] → 単に楽なので使いました (FE/BEまとめて書ける & デプロイ環境)
* デプロイ先
    * [Vercel] → 楽だけど、課金しないと IP 制限できなさそう
    * [AWS Amplify] → CloudFront (WAF) もつければ IP 制限できる

## シーケンス

特別な事は何もなく、良く思いつくであろう構成です。

1. Top-Level Navigation アクセスでは、[SwaggerUI] 等のシングルページを返す
1. URLパスを元に、[SwaggerUI] が OpenAPI Spec ファイル (`.yaml` / `.json`) を Ajax で要求
1. (セキュリティ認証の為に) GitHub へのアクセスをサーバーがプロキシして結果を返す

```mermaid
sequenceDiagram
  participant Browser as Browser<br/>(SwaggerUI)
  participant Next as Next.js
  participant GF as GitHub<br/>(raw.githubusercontent.com)

  Browser->>+Next: http://{host}/

  Next-->>-Browser: Static (SwaggerUI)

  Browser->>+Next: http://{host}/api/github/...
  Next->>+GF: with/without Access Token

  GF-->>-Next: YAML / JSON file.
  Next-->>-Browser: YAML / JSON file.

  Browser->>+Browser: Render
```

## ページ/API

Next.js なので [React] でフロントコードを書きます。

SwaggerUI 自体は [swagger-ui-react] Package を使って数行で描画できます。

https://github.com/megmogmog1965/swagger-github/blob/1.0.0/app/%5B...path%5D/page.tsx

```react
'use client'

import dynamic from 'next/dynamic'
const SwaggerUI = dynamic(() => import('swagger-ui-react'), { ssr: false })
import 'swagger-ui-react/swagger-ui.css'

export default function Home({ params }: { params: { path: string[] } }) {
  const githubPath = params.path.join('/')

  return (
    <main className="">
      <SwaggerUI url={`/api/github/${githubPath}`} deepLinking={true} />
    </main>
  )
}
```

↑ で `url={"..."}` 指定した URL に Ajax リクエストが来ます。

Next.js の [Route Handler] で OpenAPI Spec (`.yaml` / `.json`) を返す API を用意します。

https://github.com/megmogmog1965/swagger-github/blob/1.0.0/app/api/github/%5B...path%5D/route.ts

```typescript
export async function GET(
  request: Request,
  { params }: { params: { path: string[] } },
) {
  // 認証が必要なら、環境変数から取って来る。
  const token = process.env.BEARER_TOKEN
  const options = token ? {
    headers: { Authorization: `Bearer ${token}` },
  } : undefined

  // raw.githubusercontent.com にアクセス.
  const githubPath = params.path.join('/')
  const url = new URL(githubPath, 'https://raw.githubusercontent.com')
  const res = await fetch(url, options)

  // ...(省略)...

  return new Response(res.blob(), { status: res.status })
}
```

実際、書いたコードはこれだけです😳

## ※一部、力技で実装した箇所が...

OpenAPI Specification では `$ref` 参照を使って、ファイルを分割管理する事ができます。

```yaml:root.yaml
paths:
  /oauth2/token:
    post:
      $ref: "./child.yml"
  ...
```

```yaml:child.yaml
operationId: postOAuth2
tags:
  - OAuth2
summary: アクセストークンを...
requestBody:
  content:
    ...
```

しかし SwaggerUI を使って表示すると、本来のカテゴリ箇所 (tag) ではなく、全て `default` カテゴリに表示されてしまいました。

<img width="800" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/59afed3e-7ad7-f0bc-7ad9-dbc08b706e75.png">

これはどうやら、子供のファイル `child.yaml` の方に tags を記述している事が原因のようです。

SwaggerUI はカテゴリ `POST /oauth2/token` をクリックして開閉するまで、子供のファイル `child.yaml` を取得しにいかない為、中身がまだ分からないのですね。

GPT に「どうしたらいいの？」と聞くと「サーバーサイドで全部くっつけたらいいよ」とかなり力技の提案をされたので、その通りにしてみました...😅

https://github.com/megmogmog1965/swagger-github/blob/1.0.0/app/api/github/%5B...path%5D/route.ts#L37

# デプロイ

デプロイ先は次の３パターンを考えました。

重視した観点は ①「なるべく保守をしたくない」、②「インターネットに公開しない」です。

1. プライベートネットワーク上のサーバー (EC2 とか)
1. Vercel
1. AWS Amplify + CloudFront(WAF)

## 1. プライベートネットワーク上のサーバー (EC2 とか)

ネットワーク上、外部からアクセスできないので正直イチオシの方法です...笑

どこかのサーバー (野良EC2とか) に入って、Next.js サーバーを起動するだけです。

```bash
npm run build
npm run start
```

ブラウザで [http://{server-ip-address}:3000](http://localhost:3000) URL にアクセスします。

## 2. Vercel

Next.js で書いたのなら、[Vercel] が最も手っ取り早いデプロイオプションでしょう。

:::note
画面の指示に従うだけで簡単にできちゃうので、手順は説明しません。
:::

しかしながら、残念な事に「IPアドレス制限」(Trusted IPs) は Enterprise プランでないと使えないようです。
> 一応アプリケーションコードで弾く方法もありますが、少し怖いので不採用としました。

<img width="1200" alt="Pasted_Image_2023_12_04_22_20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/af1277d2-0f90-834a-e203-b5a397a0ff2b.png">

## 3. AWS Amplify + CloudFront(WAF)

この方法は正直、初期構築は一番手間なのですが、その後の保守はわりと楽なので社内にデプロイしたのはこの方法を採用しました。

* Next.js コードは [AWS Amplify] でサーバレスデプロイできます
* 更に CloudFront (WAF) を使うことで、IPアドレス制限ができる

```mermaid
sequenceDiagram
  participant Browser as Browser<br/>(SwaggerUI)
  participant CF as CloudFront<br/>(WAF)
  participant AMP as AWS Amplify<br/>(Next.js)
  participant GF as GitHub<br/>(raw.githubusercontent.com)

  Browser->>+CF: http://{host}/
  CF->>+AMP: Basic Authentication

  AMP-->>-CF: Static (SwaggerUI)
  CF-->>-Browser: Static (SwaggerUI)

  Browser->>+CF: http://{host}/api/github/...
  CF->>+AMP: Basic Authentication
  AMP->>+GF: with/without Access Token

  GF-->>-AMP: YAML / JSON file.
  AMP-->>-CF: YAML / JSON file.
  CF-->>-Browser: YAML / JSON file.

  Browser->>+Browser: Render
```

:::note warn
CloudFront(WAF) → AWS Amplify 間は Basic 認証はあれど、実際にはインターネット通信なので、唯一そこが心配...
:::

AWS公式様のこの記事 ↓ が大変わかりやすいので、本記事では手順の説明は割愛します。

1. [Amplify を使った Next.js SSR アプリケーションのデプロイ](https://docs.aws.amazon.com/ja_jp/amplify/latest/userguide/deploy-nextjs-app.html)
1. [AWS Amplify がホストするウェブアプリケーションの AWS WAF を有効にする](https://docs.aws.amazon.com/ja_jp/prescriptive-guidance/latest/patterns/enable-aws-waf-for-web-applications-hosted-by-aws-amplify.html)

一つハマったのは AWS Amplify で実行時に「環境変数が適用されない」問題です。
AWS Console (Amplify) で環境変数を設定しても何故か反映されません。

理由は公式のページ ↓ に書いてありました。どうやら実行時の環境変数は `.env` ファイルで指定する必要があるみたいです。

https://docs.aws.amazon.com/ja_jp/amplify/latest/userguide/environment-variables.html#access-env-vars

最終的に AWS Amplify の Build settings `amplify.yml` はこうなります。

```yaml
version: 1
frontend:
  phases:
    preBuild:
      commands:
        - nvm use 18
        - node -v
        - npm ci
    build:
      commands:
        - echo "BEARER_TOKEN=$BEARER_TOKEN" >> .env
        - echo "PROXY_DOMAIN=$PROXY_DOMAIN" >> .env
        - echo "RESOLVE_ALL_REFS=$RESOLVE_ALL_REFS" >> .env
        - echo "RESOLVE_ALL_REFS_DEPTH_LIMIT=$RESOLVE_ALL_REFS_DEPTH_LIMIT" >> .env
        - npm run build
  artifacts:
    baseDirectory: .next
    files:
      - '**/*'
  cache:
    paths:
```

https://github.com/megmogmog1965/swagger-github/blob/1.0.0/amplify.yml

> 本当は node 20 にしたいけど、現状 Amplify では 18 までしか使えなそう。

# 最後に

ここまで読んで下さってありがとうございます。
結構ニッチな記事なのですが、どの現場でも一律「これ使えば解決するよね？」という策があまりなさそうだったので、記事化してみました。

この記事が、もし同じ事で悩んでおられる方の役に立てれば幸いです。