GitHub Actions × AWS CDK で、OpenAPI 仕様書を Pull Request ごとに Web 上でプレビュー可能にするワークフローを組んでみた

GitHub Actions × AWS CDK で、OpenAPI 仕様書を Pull Request ごとに Web 上でプレビュー可能にするワークフローを組んでみた

Clock Icon2025.07.21

こんにちは、製造ビジネステクノロジー部の若槻です。

前回のブログのでは、OpenAPI 仕様書を S3 + CloudFront でホスティングする構成を AWS CDK で実装する方法を紹介しました。

https://dev.classmethod.jp/articles/aws-cdk-cloudfront-s3-swagger-ui/

https://dev.classmethod.jp/articles/aws-cdk-cloudfront-s3-open-api-redoc/

さて、上記によりコードで管理している API 仕様書のホスティングを自動デプロイ可能になりましたが、API 仕様書の更新時のレビューはどうするのが良いでしょうか。上記で紹介しているデプロイ処理をそのまま CD ワークフローに乗せた場合は、デフォルトブランチおよび全ての PR(Pull Request)の API 仕様書が同一の URL でホスティングされることになり、最後のデプロイが既存のホスティングを上書きしてしまうため、複数人で開発を行っている場合に仕様書の共有やレビューが難しくなってしまいます。

また別の観点として、レビュワーがエンジニアだけであれば、PR での仕様書の更新はデプロイせずとも、yaml ファイルを直接読んだり、ローカルに pull して VS Code などでプレビューすることもできるでしょう。しかしレビューフローに非エンジニアのメンバーも関わってくる場合は、ホスティングされた Web UI を参照してもらう必要があるでしょう。

そこで今回は、GitHub Actions × AWS CDK で OpenAPI 仕様書を PR ごとに Web 上でプレビュー可能にするワークフローを組んでみたので、紹介します。

実現方法

PR ごとにホスティングする S3 プレフィックスを変更することで、PR ごとに異なる API 仕様書をホスティングし、プレビュー可能とする方法を採用します。具体的には、以下のような流れで実装します。

  • PR によるレビュー開始時に、仕様書をブランチ名をプレフィックスとしてアップロード
  • PR クローズ時に、当該ブランチ名プレフィックスを削除
  • PR マージ時に、仕様書をルートにアップロード

具体的なフローとしては、以下のように、PR のオープン状況に応じて S3 プレフィックスを変更します。

  1. プルリクエストの未オープン時
    • / :デフォルトブランチ
  2. プルリクエスト A(feature/01)がオープンされた状態
    • / :デフォルトブランチ
    • /feature/01 :プルリクエスト A のブランチ
  3. プルリクエスト B(feature/02)がオープンされた
    • / :デフォルトブランチ
    • /feature/01 :プルリクエスト A のブランチ
    • /feature/02 :プルリクエスト B のブランチ
  4. プルリクエスト A のマージ後
    • / :デフォルトブランチ
    • /feature/02 :プルリクエスト B のブランチ
  5. プルリクエスト B のマージ後
    • / :デフォルトブランチ

PR ごとにプレフィックスが異なるため、最後のデプロイが既存のホスティングを上書きしてしまう問題も回避可能となります。

ちなみにこの手法は下記で紹介されている内容を参考にしています。やりたいことは同じですが、より詳細な実装を紹介しています。

https://dev.classmethod.jp/articles/open-api-pull-req-branch-path/

実装

以下の2つの GitHub Actions ワークフローを使って、インフラのデプロイと API 仕様書のアップロードを行います。

  • DEPLOY_INFRA ワークフローで CDK を使って S3 バケットと CloudFront Distribution および IAM ロールをデプロイ
  • PREVIEW_API_DOCS ワークフローで PR ごとに S3 プレフィックスを変更して API 仕様書をアップロードし、プレビュー可能とする

CDK コード

権限の実装

GitHub Actions から AWS リソースにアクセスするためには、GitHub Actions の OIDC トークンを使用して AWS IAM ロールを AssumeRole する必要があります。

https://docs.github.com/ja/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services

AssumeRole をするための IAM ロールに必要な権限は、デプロイとプレビューで異なるため、2つの Construct を作成して分離します。

まずは、デプロイ用の権限を定義する DeployPermissionConstruct を実装します。AWS CDK デプロイで必要な権限を定義しています。

lib/constructs/deploy-permission/index.ts
import { Stack, CfnOutput } from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";

import { commonParameter } from "../../../bin/parameter";

export class DeployPermissionConstruct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const { github } = commonParameter;
    const awsAccountId = Stack.of(this).account;
    const region = Stack.of(this).region;
    const CDK_QUALIFIER = "hnb659fds"; // 既定の CDK Bootstrap Stack 識別子

    /**
     * AssumeRole の引受先を制限する信頼ポリシーを定めたロールを作成
     * @see https://github.blog/2021-11-23-secure-deployments-openid-connect-github-actions-generally-available/
     */
    const gitHubActionsOidcRole = new iam.Role(this, "GitHubActionsOidcRole", {
      assumedBy: new iam.FederatedPrincipal(
        `arn:aws:iam::${awsAccountId}:oidc-provider/token.actions.githubusercontent.com`,
        /**
         * GitHub Actions が OIDC トークンを使って AssumeRole する際の条件定義
         */
        {
          StringEquals: {
            "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          },
          StringLike: {
            "token.actions.githubusercontent.com:sub": `repo:${github.organizationName}/${github.repositoryName}:pull_request`,
          },
        },
        "sts:AssumeRoleWithWebIdentity"
      ),
    });
    new CfnOutput(this, "GitHubActionsOidcRoleArnOutput", {
      value: gitHubActionsOidcRole.roleArn,
      description:
        "Configure this role ARN as the ROLE_TO_ASSUME_FOR_DEPLOY variable. It is used by the DEPLOY_INFRA workflow to assume this role.",
    });

    /**
     * AWS CDK のデプロイに必要なポリシーを定義
     */
    const cdkDeployPolicy = new iam.Policy(this, "CdkDeployPolicy", {
      statements: [
        new iam.PolicyStatement({
          actions: ["s3:getBucketLocation", "s3:List*"],
          resources: ["arn:aws:s3:::*"],
        }),
        new iam.PolicyStatement({
          actions: [
            "cloudformation:CreateStack",
            "cloudformation:CreateChangeSet",
            "cloudformation:DeleteChangeSet",
            "cloudformation:DescribeChangeSet",
            "cloudformation:DescribeStacks",
            "cloudformation:DescribeStackEvents",
            "cloudformation:ExecuteChangeSet",
            "cloudformation:GetTemplate",
            "cloudformation:UpdateStack",
          ],
          resources: [
            `arn:aws:cloudformation:${region}:${awsAccountId}:stack/*/*`,
          ],
        }),
        new iam.PolicyStatement({
          actions: ["s3:PutObject", "s3:GetObject"],
          resources: [
            `arn:aws:s3:::cdk-${CDK_QUALIFIER}-assets-${awsAccountId}-${region}/*`,
          ],
        }),
        new iam.PolicyStatement({
          actions: ["ssm:GetParameter"],
          resources: [
            `arn:aws:ssm:${region}:${awsAccountId}:parameter/cdk-bootstrap/${CDK_QUALIFIER}/version`,
          ],
        }),
        new iam.PolicyStatement({
          actions: ["iam:PassRole"],
          resources: [
            `arn:aws:iam::${awsAccountId}:role/cdk-${CDK_QUALIFIER}-cfn-exec-role-${awsAccountId}-${region}`,
          ],
        }),
      ],
    });
    gitHubActionsOidcRole.attachInlinePolicy(cdkDeployPolicy);
  }
}

次に、プレビュー用の権限を定義する PreviewPermissionConstruct を実装します。こちらは、S3 バケットへのアップロードや削除、CloudFront のキャッシュ無効化に必要な権限を定義しています。

lib/constructs/preview-permission/index.ts
import { Stack, CfnOutput } from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as s3 from "aws-cdk-lib/aws-s3";
import { Construct } from "constructs";

import { commonParameter } from "../../../bin/parameter";

interface PreviewPermissionProps {
  hosutingBucket: s3.Bucket;
  hostingDistribution: cloudfront.Distribution;
}

export class PreviewPermissionConstruct extends Construct {
  constructor(scope: Construct, id: string, props: PreviewPermissionProps) {
    super(scope, id);

    const { hosutingBucket, hostingDistribution } = props;
    const { github } = commonParameter;
    const awsAccountId = Stack.of(this).account;

    /**
     * AssumeRole の引受先を制限する信頼ポリシーを定めたロールを作成
     * @see https://github.blog/2021-11-23-secure-deployments-openid-connect-github-actions-generally-available/
     */
    const gitHubActionsOidcRole = new iam.Role(this, "GitHubActionsOidcRole", {
      assumedBy: new iam.FederatedPrincipal(
        `arn:aws:iam::${awsAccountId}:oidc-provider/token.actions.githubusercontent.com`,
        /**
         * GitHub Actions が OIDC トークンを使って AssumeRole する際の条件定義
         */
        {
          StringEquals: {
            "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          },
          StringLike: {
            "token.actions.githubusercontent.com:sub": `repo:${github.organizationName}/${github.repositoryName}:pull_request`,
          },
        },
        "sts:AssumeRoleWithWebIdentity"
      ),
    });
    new CfnOutput(this, "GitHubActionsOidcRoleArnOutput", {
      value: gitHubActionsOidcRole.roleArn,
      description:
        "Configure this role ARN as the ROLE_TO_ASSUME_FOR_PREVIEW variable. It is used by the PREVIEW_API_DOCS workflow to assume this role.",
    });

    /**
     * プレビュー機能 (S3 アップロード+削除、CloudFront Invalidation) 用ポリシー
     */
    const previewPolicy = new iam.Policy(this, "PreviewPolicy", {
      statements: [
        // バケット本体への一覧取得
        new iam.PolicyStatement({
          actions: ["s3:ListBucket"],
          resources: [hosutingBucket.bucketArn],
        }),
        // ブランチパス以下の Put/Get/Delete
        new iam.PolicyStatement({
          actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
          resources: [hosutingBucket.arnForObjects("*")],
        }),
        // CloudFront Invalidation
        new iam.PolicyStatement({
          actions: ["cloudfront:CreateInvalidation"],
          resources: [hostingDistribution.distributionArn],
        }),
      ],
    });
    gitHubActionsOidcRole.attachInlinePolicy(previewPolicy);
  }
}

S3 バケットと CloudFront Distribution の実装

そして、前述の権限のコンストラクトおよび、ホスティングに必要な S3 バケットと CloudFront Distribution を作成する MainStack を実装します。

lib/main-stack.ts
import * as cdk from "aws-cdk-lib";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as cloudfront_origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as s3 from "aws-cdk-lib/aws-s3";
import { Construct } from "constructs";

import { DeployPermissionConstruct } from "./constructs/deploy-permission";
import { PreviewPermissionConstruct } from "./constructs/preview-permission";

export class MainStack extends cdk.Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    /**
     * DEPLOY_INFRA ワークフロー用の権限を作成
     */
    new DeployPermissionConstruct(this, "DeployPermission");

    /**
     * 静的ウェブサイトホスティング用の S3 バケットを作成
     */
    const bucket = new s3.Bucket(this, "Bucket", {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
    // S3 バケットの名前を出力
    new cdk.CfnOutput(this, "BucketName", {
      value: bucket.bucketName,
      description:
        "Configure this bucket name as the BUCHOSTING_BUCKET_NAMEKET_NAME variable. It is used by the PREVIEW_API_DOCS workflow to upload or delete files.",
    });

    /**
     * CloudFront Distribution を作成
     */
    const distribution = new cloudfront.Distribution(this, "Distribution", {
      defaultBehavior: {
        origin:
          cloudfront_origins.S3BucketOrigin.withOriginAccessControl(bucket),
        /**
         * TODO: CloudFront Function を使用して Basic 認証を実装
         * @see https://dev.classmethod.jp/articles/aws-cdk-cloudfront-s3-swagger-ui/
         */
      },

      // index.html をデフォルトのオブジェクトとして設定
      defaultRootObject: "index.html",
    });
    // Distribution のドメイン名を出力
    new cdk.CfnOutput(this, "DistributionDomainName", {
      value: distribution.distributionDomainName,
      description: "Hosting URL for the static website.",
    });
    // Distribution の ID を出力
    new cdk.CfnOutput(this, "DistributionId", {
      value: distribution.distributionId,
      description:
        "Configure this distribution ID as the HOSTING_DISTRIBUTION_ID variable. It is used by the PREVIEW_API_DOCS workflow to invalidate the cache.",
    });

    /**
     * MEMO: BucketDeployment によるコンテンツのアップロードは PREVIEW_API_DOCS ワークフロー内で行うため、ここでは行わない。
     */

    /**
     * PREVIEW_API_DOCS ワークフロー用の権限を作成
     */
    new PreviewPermissionConstruct(this, "PreviewPermission", {
      hosutingBucket: bucket,
      hostingDistribution: distribution,
    });
  }
}

ポイントとしては、通常は CloudFront + S3 による静的ウェブサイトホスティングを行う際に、CDK の BucketDeployment を使用して S3 バケットにコンテンツをアップロードしますが、今回はコンテンツの管理処理は GitHub Actions ワークフロー内に設けるため、ここでは実装しません。

GitHub Variables の設定

前述の CDK デプロイにより作成されたリソースの情報を、以下の GitHub Variables に設定します。これらの変数は、GitHub Actions ワークフロー内で使用されます。

  • HOSTING_BUCKET_NAME:ホスティング用の S3 バケット名
  • HOSTING_DISTRIBUTION_ID:ホスティング用の CloudFront Distribution ID
  • ROLE_TO_ASSUME_FOR_DEPLOY:DEPLOY_INFRA ワークフローが AssumeRole するための IAM ロール ARN
  • ROLE_TO_ASSUME_FOR_PREVIEW:PREVIEW_API_DOCS ワークフローが AssumeRole するための IAM ロール ARN

GitHub Actions ワークフロー

まずは DEPLOY_INFRA ワークフローです。インフラの更新を検知して npm run deploycdk deploy)により先程の MainStack をデプロイします。そのため、API 仕様書を管理する docs/api ディレクトリの変更は除外しています。

.github/workflows/deploy-infra.yml
name: DEPLOY_INFRA

on:
  pull_request:
    types:
      - opened
      - ready_for_review
      - closed
    paths-ignore:
      # API 仕様書の変更時は、本ワークフローではなく PREVIEW_API_DOCS ワークフローを実行するため、ここでは除外
      - "docs/api/**"

permissions:
  # AssumeRole に必要な権限を設定
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Pull Request オープン時(ドラフトを除く)、ドラフト解除時、またはマージ時にのみ実行
    if: (github.event.action == 'opened' && !github.event.pull_request.draft)
      || github.event.action == 'ready_for_review'
      || github.event.pull_request.merged == true
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Cache Dependencies
        uses: actions/cache@v4
        id: cache-node-modules
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-${{ hashFiles('package-lock.json') }}-node-${{ hashFiles('**/package.json') }}

      - name: Install Dependencies
        if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' }}
        run: npm ci

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.ROLE_TO_ASSUME_FOR_DEPLOY }}
          aws-region: ap-northeast-1

      - name: CDK Deploy
        run: npm run deploy

続いて、 PREVIEW_API_DOCS ワークフローです。PR ごとに S3 プレフィックスを変更して API 仕様書をアップロードし、プレビュー可能とします。AWS CLI コマンドで S3 バケット上のコンテンツのアップロードや削除、CloudFront のキャッシュ無効化を行っています。

.github/workflows/preview-api-docs.yml
name: PREVIEW_API_DOCS

on:
  pull_request:
    types:
      - opened
      - ready_for_review
      - closed
    paths:
      - "docs/api/**"

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Pull Request オープン時(ドラフトを除く)、ドラフト解除時、またはクローズ時にのみ実行
    if: (github.event.action == 'opened' && !github.event.pull_request.draft)
      || github.event.action == 'ready_for_review'
      || github.event.action == 'closed'
    permissions:
      # AssumeRole に必要な権限を設定
      id-token: write
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Cache Dependencies
        uses: actions/cache@v4
        id: cache-node-modules
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-${{ hashFiles('package-lock.json') }}-node-${{ hashFiles('**/package.json') }}

      - name: Install Dependencies
        if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' }}
        run: npm ci

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.ROLE_TO_ASSUME_FOR_PREVIEW }}
          aws-region: ap-northeast-1

      # ────────────────────────────────────────────────
      # S3プレフィックス操作:プルリクエストによるレビュー開始時に、ブランチ名をプレフィックスとして docs/api をアップロード(CloudFront Invalidaton含む)
      # ────────────────────────────────────────────────
      - name: Upload API Docs Preview
        # プルリクエストがオープン時(ドラフトを除く)またはドラフト解除時に実行
        if:
          (github.event.action == 'opened' && !github.event.pull_request.draft)
          || github.event.action == 'ready_for_review'
        run: |
          aws s3 cp docs/api/ s3://${{ vars.HOSTING_BUCKET_NAME }}/${{ github.head_ref }}/ --recursive
          aws cloudfront create-invalidation \
            --distribution-id ${{ vars.HOSTING_DISTRIBUTION_ID }} \
            --paths "/${{ github.head_ref }}/*"

      # ────────────────────────────────────────────────
      # S3プレフィックス操作:プルリクエストのクローズ時に、当該ブランチ名プレフィックスを削除(CloudFront Invalidaton含む)
      # ────────────────────────────────────────────────
      - name: Remove API Docs Preview
        # プルリクエストがクローズされたときに実行
        if: github.event.action == 'closed'
        run: |
          aws s3 rm s3://${{ vars.HOSTING_BUCKET_NAME }}/${{ github.head_ref }}/ --recursive
          aws cloudfront create-invalidation \
            --distribution-id ${{ vars.HOSTING_DISTRIBUTION_ID }} \
            --paths "/${{ github.head_ref }}/*"

      # ────────────────────────────────────────────────
      # S3プレフィックス操作:プルリクエストのマージ時に、docs/api をルートにアップロード(CloudFront Invalidaton含む)
      # ────────────────────────────────────────────────
      - name: Deploy Default-Branch API Docs to Root
        # プルリクエストがマージされたときに実行
        if: github.event.pull_request.merged == true
        run: |
          aws s3 cp docs/api/ s3://${{ vars.HOSTING_BUCKET_NAME }}/ --recursive
          aws cloudfront create-invalidation \
            --distribution-id ${{ vars.HOSTING_DISTRIBUTION_ID }} \
            --paths "/*"

docs/api ディレクトリ

リポジトリ内の docs/api ディレクトリに OpenAPI 仕様書を配置します。ここでは、Petstore の OpenAPI ドキュメントを例として使用します。

Petstore OpenAPI ドキュメント
docs/api/petstore.yaml
openapi: 3.0.0
servers:
  - url: "http://petstore.swagger.io/v2"
info:
  description: >-
    This is a sample server Petstore server. For this sample, you can use the api key
    `special-key` to test the authorization filters.
  version: 1.0.0
  title: OpenAPI Petstore
  license:
    name: Apache-2.0
    url: "https://www.apache.org/licenses/LICENSE-2.0.html"
tags:
  - name: pet
    description: Everything about your Pets
  - name: store
    description: Access to Petstore orders
  - name: user
    description: Operations about user
paths:
  /pet:
    post:
      tags:
        - pet
      summary: Add a new pet to the store
      description: ""
      operationId: addPet
      responses:
        "200":
          description: successful operation
          content:
            application/xml:
              schema:
                $ref: "#/components/schemas/Pet"
            application/json:
              schema:
                $ref: "#/components/schemas/Pet"
        "405":
          description: Invalid input
      security:
        - petstore_auth:
            - "write:pets"
            - "read:pets"
      requestBody:
        $ref: "#/components/requestBodies/Pet"
    put:
      tags:
        - pet
      summary: Update an existing pet
      description: ""
      operationId: updatePet
      externalDocs:
        url: "http://petstore.swagger.io/v2/doc/updatePet"
        description: "API documentation for the updatePet operation"
      responses:
        "200":
          description: successful operation
          content:
            application/xml:
              schema:
                $ref: "#/components/schemas/Pet"
            application/json:
              schema:
                $ref: "#/components/schemas/Pet"
        "400":
          description: Invalid ID supplied
        "404":
          description: Pet not found
        "405":
          description: Validation exception
      security:
        - petstore_auth:
            - "write:pets"
            - "read:pets"
      requestBody:
        $ref: "#/components/requestBodies/Pet"
  /pet/findByStatus:
    get:
      tags:
        - pet
      summary: Finds Pets by status
      description: Multiple status values can be provided with comma separated strings
      operationId: findPetsByStatus
      parameters:
        - name: status
          in: query
          description: Status values that need to be considered for filter
          required: true
          style: form
          explode: false
          deprecated: true
          schema:
            type: array
            items:
              type: string
              enum:
                - available
                - pending
                - sold
              default: available
      responses:
        "200":
          description: successful operation
          content:
            application/xml:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Pet"
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Pet"
        "400":
          description: Invalid status value
      security:
        - petstore_auth:
            - "read:pets"
  /pet/findByTags:
    get:
      tags:
        - pet
      summary: Finds Pets by tags
      description: >-
        Multiple tags can be provided with comma separated strings. Use tag1,
        tag2, tag3 for testing.
      operationId: findPetsByTags
      parameters:
        - name: tags
          in: query
          description: Tags to filter by
          required: true
          style: form
          explode: false
          schema:
            type: array
            items:
              type: string
      responses:
        "200":
          description: successful operation
          content:
            application/xml:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Pet"
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Pet"
        "400":
          description: Invalid tag value
      security:
        - petstore_auth:
            - "read:pets"
      deprecated: true
  "/pet/{petId}":
    get:
      tags:
        - pet
      summary: Find pet by ID
      description: Returns a single pet
      operationId: getPetById
      parameters:
        - name: petId
          in: path
          description: ID of pet to return
          required: true
          schema:
            type: integer
            format: int64
      responses:
        "200":
          description: successful operation
          content:
            application/xml:
              schema:
                $ref: "#/components/schemas/Pet"
            application/json:
              schema:
                $ref: "#/components/schemas/Pet"
        "400":
          description: Invalid ID supplied
        "404":
          description: Pet not found
      security:
        - api_key: []
    post:
      tags:
        - pet
      summary: Updates a pet in the store with form data
      description: ""
      operationId: updatePetWithForm
      parameters:
        - name: petId
          in: path
          description: ID of pet that needs to be updated
          required: true
          schema:
            type: integer
            format: int64
      responses:
        "405":
          description: Invalid input
      security:
        - petstore_auth:
            - "write:pets"
            - "read:pets"
      requestBody:
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                name:
                  description: Updated name of the pet
                  type: string
                status:
                  description: Updated status of the pet
                  type: string
    delete:
      tags:
        - pet
      summary: Deletes a pet
      description: ""
      operationId: deletePet
      parameters:
        - name: api_key
          in: header
          required: false
          schema:
            type: string
        - name: petId
          in: path
          description: Pet id to delete
          required: true
          schema:
            type: integer
            format: int64
      responses:
        "400":
          description: Invalid pet value
      security:
        - petstore_auth:
            - "write:pets"
            - "read:pets"
  "/pet/{petId}/uploadImage":
    post:
      tags:
        - pet
      summary: uploads an image
      description: ""
      operationId: uploadFile
      parameters:
        - name: petId
          in: path
          description: ID of pet to update
          required: true
          schema:
            type: integer
            format: int64
      responses:
        "200":
          description: successful operation
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
      security:
        - petstore_auth:
            - "write:pets"
            - "read:pets"
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                additionalMetadata:
                  description: Additional data to pass to server
                  type: string
                file:
                  description: file to upload
                  type: string
                  format: binary
  /store/inventory:
    get:
      tags:
        - store
      summary: Returns pet inventories by status
      description: Returns a map of status codes to quantities
      operationId: getInventory
      responses:
        "200":
          description: successful operation
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: integer
                  format: int32
      security:
        - api_key: []
  /store/order:
    post:
      tags:
        - store
      summary: Place an order for a pet
      description: ""
      operationId: placeOrder
      responses:
        "200":
          description: successful operation
          content:
            application/xml:
              schema:
                $ref: "#/components/schemas/Order"
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "400":
          description: Invalid Order
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Order"
        description: order placed for purchasing the pet
        required: true
  "/store/order/{orderId}":
    get:
      tags:
        - store
      summary: Find purchase order by ID
      description: >-
        For valid response try integer IDs with value <= 5 or > 10. Other values
        will generate exceptions
      operationId: getOrderById
      parameters:
        - name: orderId
          in: path
          description: ID of pet that needs to be fetched
          required: true
          schema:
            type: integer
            format: int64
            minimum: 1
            maximum: 5
      responses:
        "200":
          description: successful operation
          content:
            application/xml:
              schema:
                $ref: "#/components/schemas/Order"
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "400":
          description: Invalid ID supplied
        "404":
          description: Order not found
    delete:
      tags:
        - store
      summary: Delete purchase order by ID
      description: >-
        For valid response try integer IDs with value < 1000. Anything above
        1000 or nonintegers will generate API errors
      operationId: deleteOrder
      parameters:
        - name: orderId
          in: path
          description: ID of the order that needs to be deleted
          required: true
          schema:
            type: string
      responses:
        "400":
          description: Invalid ID supplied
        "404":
          description: Order not found
  /user:
    post:
      tags:
        - user
      summary: Create user
      description: This can only be done by the logged in user.
      operationId: createUser
      responses:
        default:
          description: successful operation
      security:
        - api_key: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/User"
        description: Created user object
        required: true
  /user/createWithArray:
    post:
      tags:
        - user
      summary: Creates list of users with given input array
      description: ""
      operationId: createUsersWithArrayInput
      responses:
        default:
          description: successful operation
      security:
        - api_key: []
      requestBody:
        $ref: "#/components/requestBodies/UserArray"
  /user/createWithList:
    post:
      tags:
        - user
      summary: Creates list of users with given input array
      description: ""
      operationId: createUsersWithListInput
      responses:
        default:
          description: successful operation
      security:
        - api_key: []
      requestBody:
        $ref: "#/components/requestBodies/UserArray"
  /user/login:
    get:
      tags:
        - user
      summary: Logs user into the system
      description: ""
      operationId: loginUser
      parameters:
        - name: username
          in: query
          description: The user name for login
          required: true
          schema:
            type: string
            pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$'
        - name: password
          in: query
          description: The password for login in clear text
          required: true
          schema:
            type: string
      responses:
        "200":
          description: successful operation
          headers:
            Set-Cookie:
              description: >-
                Cookie authentication key for use with the `api_key`
                apiKey authentication.
              schema:
                type: string
                example: AUTH_KEY=abcde12345; Path=/; HttpOnly
            X-Rate-Limit:
              description: calls per hour allowed by the user
              schema:
                type: integer
                format: int32
            X-Expires-After:
              description: date in UTC when token expires
              schema:
                type: string
                format: date-time
          content:
            application/xml:
              schema:
                type: string
            application/json:
              schema:
                type: string
        "400":
          description: Invalid username/password supplied
  /user/logout:
    get:
      tags:
        - user
      summary: Logs out current logged in user session
      description: ""
      operationId: logoutUser
      responses:
        default:
          description: successful operation
      security:
        - api_key: []
  "/user/{username}":
    get:
      tags:
        - user
      summary: Get user by user name
      description: ""
      operationId: getUserByName
      parameters:
        - name: username
          in: path
          description: The name that needs to be fetched. Use user1 for testing.
          required: true
          schema:
            type: string
      responses:
        "200":
          description: successful operation
          content:
            application/xml:
              schema:
                $ref: "#/components/schemas/User"
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          description: Invalid username supplied
        "404":
          description: User not found
    put:
      tags:
        - user
      summary: Updated user
      description: This can only be done by the logged in user.
      operationId: updateUser
      parameters:
        - name: username
          in: path
          description: name that need to be deleted
          required: true
          schema:
            type: string
      responses:
        "400":
          description: Invalid user supplied
        "404":
          description: User not found
      security:
        - api_key: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/User"
        description: Updated user object
        required: true
    delete:
      tags:
        - user
      summary: Delete user
      description: This can only be done by the logged in user.
      operationId: deleteUser
      parameters:
        - name: username
          in: path
          description: The name that needs to be deleted
          required: true
          schema:
            type: string
      responses:
        "400":
          description: Invalid username supplied
        "404":
          description: User not found
      security:
        - api_key: []
externalDocs:
  description: Find out more about Swagger
  url: "http://swagger.io"
components:
  requestBodies:
    UserArray:
      content:
        application/json:
          schema:
            type: array
            items:
              $ref: "#/components/schemas/User"
      description: List of user object
      required: true
    Pet:
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Pet"
        application/xml:
          schema:
            $ref: "#/components/schemas/Pet"
      description: Pet object that needs to be added to the store
      required: true
  securitySchemes:
    petstore_auth:
      type: oauth2
      flows:
        implicit:
          authorizationUrl: "http://petstore.swagger.io/api/oauth/dialog"
          scopes:
            "write:pets": modify pets in your account
            "read:pets": read your pets
    api_key:
      type: apiKey
      name: api_key
      in: header
  schemas:
    Order:
      title: Pet Order
      description: An order for a pets from the pet store
      type: object
      properties:
        id:
          type: integer
          format: int64
        petId:
          type: integer
          format: int64
        quantity:
          type: integer
          format: int32
        shipDate:
          type: string
          format: date-time
        status:
          type: string
          description: Order Status
          enum:
            - placed
            - approved
            - delivered
        complete:
          type: boolean
          default: false
      xml:
        name: Order
    Category:
      title: Pet category
      description: A category for a pet
      type: object
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$'
      xml:
        name: Category
    User:
      title: a User
      description: A User who is purchasing from the pet store
      type: object
      properties:
        id:
          type: integer
          format: int64
        username:
          type: string
        firstName:
          type: string
        lastName:
          type: string
        email:
          type: string
        password:
          type: string
        phone:
          type: string
        userStatus:
          type: integer
          format: int32
          description: User Status
      xml:
        name: User
    Tag:
      title: Pet Tag
      description: A tag for a pet
      type: object
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
      xml:
        name: Tag
    Pet:
      title: a Pet
      description: A pet for sale in the pet store
      type: object
      required:
        - name
        - photoUrls
      properties:
        id:
          type: integer
          format: int64
        category:
          $ref: "#/components/schemas/Category"
        name:
          type: string
          example: doggie
        photoUrls:
          type: array
          xml:
            name: photoUrl
            wrapped: true
          items:
            type: string
        tags:
          type: array
          xml:
            name: tag
            wrapped: true
          items:
            $ref: "#/components/schemas/Tag"
        status:
          type: string
          description: pet status in the store
          deprecated: true
          enum:
            - available
            - pending
            - sold
      xml:
        name: Pet
    ApiResponse:
      title: An uploaded response
      description: Describes the result of uploading an image resource
      type: object
      properties:
        code:
          type: integer
          format: int32
        type:
          type: string
        message:
          type: string
docs/api/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="description" content="SwaggerUI" />
    <title>SwaggerUI</title>
    <link
      rel="stylesheet"
      href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css"
    />
  </head>
  <body>
    <div id="swagger-ui"></div>
    <script
      src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"
      crossorigin
    ></script>
    <script
      src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js"
      crossorigin
    ></script>
    <script>
      window.onload = () => {
        window.ui = SwaggerUIBundle({
          // 複数の OpenAPI ドキュメントを読み込む
          urls: [
            {
              url: "./petstore.yaml",
              name: "Petstore API",
            },
          ],
          dom_id: "#swagger-ui",
          presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
          layout: "StandaloneLayout",
          validatorUrl: null, // バリデーションバッジを非表示
        });
      };
    </script>
  </body>
</html>

配置内容の詳細は下記ブログに記載しているので、参考にしてください。

https://dev.classmethod.jp/articles/aws-cdk-cloudfront-s3-swagger-ui/

動作確認

実際にプルリクエストを作成して、以下のフローを確認します。

  1. プルリクエストの未オープン時
    • / :デフォルトブランチ
  2. プルリクエスト A(feature/hoge-01)がオープンされた状態
    • / :デフォルトブランチ
    • /feature/hoge-01 :プルリクエスト A のブランチ
  3. プルリクエスト A のマージ後
    • / :デフォルトブランチ

1. プルリクエストの未オープン時

この時点での S3 バケットの内容は以下の通りです。ルートにのみ OpenAPI 仕様書が配置されています。

$ aws s3api list-objects-v2 \
  --bucket ${HOSTING_BUCKET_NAME} \
  --query "Contents[].Key" \
  --output text
favicon.ico
index.html
petstore.yaml

ルートにアクセスした場合、デフォルトブランチの OpenAPI 仕様書が表示されます。

2. プルリクエスト A(feature/hoge-01)がオープンされた状態

OpenAPI 仕様書の変更を行い、プルリクエスト A を作成します。

$ git diff
diff --git a/docs/api/petstore.yaml b/docs/api/petstore.yaml
index 9a97ce6..bde1bbc 100644
--- a/docs/api/petstore.yaml
+++ b/docs/api/petstore.yaml
@@ -6,7 +6,7 @@ info:
     This is a sample server Petstore server. For this sample, you can use the api key
     `special-key` to test the authorization filters.
   version: 1.0.0
-  title: OpenAPI Petstore
+  title: OpenAPI Petstore hoge
   license:
     name: Apache-2.0
     url: "https://www.apache.org/licenses/LICENSE-2.0.html"

プルリクエストがオープンされると、PREVIEW_API_DOCS ワークフローが実行されます。

ワークフローの Upload API Docs Preview ステップで、更新した docs/api がブランチ名のプレフィクスにアップロードされ、CloudFront のキャッシュが無効化されます。一方で、Remove API Docs Preview および Deploy Default-Branch API Docs to Root ステップはスキップされます。

ワークフロー実行後の S3 バケットの内容は以下の通りです。feature/hoge-01 プレフィックスが追加され、プルリクエスト A の OpenAPI 仕様書がアップロードされています。

$ aws s3api list-objects-v2 \
  --bucket ${HOSTING_BUCKET_NAME} \
  --query "Contents[].Key" \
  --output text
favicon.ico
index.html
petstore.yaml
feature/hoge-01/index.html
feature/hoge-01/petstore.yaml

PR のブランチのパス /feature/hoge-01 にアクセスした場合は、変更された OpenAPI 仕様書が表示されます。

一方で、ルートにアクセスした場合、引き続きデフォルトブランチの OpenAPI 仕様書が表示されます。

3. プルリクエスト A のマージ後

先ほどのプルリクエスト A をマージします。

gh pr merge feature/hoge-01

マージ後、PREVIEW_API_DOCS ワークフローの Remove API Docs Preview および Deploy Default-Branch API Docs to Root ステップが実行され、feature/hoge-01 プレフィックスが削除され、デフォルトブランチの OpenAPI 仕様書がルートにアップロードされます。

この時点での S3 バケットの内容は以下の通りです。ルートにのみ OpenAPI 仕様書が配置されています。

$ aws s3api list-objects-v2 \
  --bucket ${HOSTING_BUCKET_NAME} \
  --query "Contents[].Key" \
  --output text
favicon.ico
index.html
petstore.yaml

PR のブランチのパス /feature/hoge-01 にアクセスした場合は AccessDenied エラーが表示され、ちゃんと削除されていることが確認できます。

ルートにアクセスした場合、プルリクエスト A の更新がマージ後のデフォルトブランチの OpenAPI 仕様書が表示されます。

その他

インフラデプロイとプレビューに依存関係が無い点に注意

今回、DEPLOY_INFRA ワークフローと PREVIEW_API_DOCS ワークフローは、互いに依存関係を持たないように設計されています。なので例えば、ホスティング用の S3 バケットの再作成と、API 仕様書の更新を同じプルリクエストで行った場合、アップロード後に CDK デプロイが走るなどして、仕様書の更新が反映されないことがあります。そのようなプルリクエストを作ることは無いと思いますが、ちゅういするようにしましょう。

index.html を付けずにプレビューにアクセス可能としたい場合

今回の構成では、API 仕様書のプレビューにアクセスする際に、index.html を URL に付ける必要があります。もし index.html を付けずにアクセス可能としたい場合は、CloudFront Functions を使用してリクエスト URL に index.html を追加することができます。下記でその方法を紹介しているので、参考にしてください。

https://dev.classmethod.jp/articles/aws-cdk-cloudfront-functions-request-url-index-html-addition-in-nextjs-application/

セキュリティ

今回は実装しませんでしたが、ホスティングに対して Basic 認証や AWS WAF を使用した IP アドレス制限を行うことを検討してください。Basic 認証は下記を参考にしてください。

https://dev.classmethod.jp/articles/aws-cdk-cloudfront-functions-keyvaluestore/

また AWS WAF Web ACL IP アドレス制限を行うことも検討してください。

おわりに

GitHub Actions × AWS CDK で、OpenAPI 仕様書を Pull Request ごとに Web 上でプレビュー可能にするワークフローを組んでみたので、紹介しました。

この構成により、API 仕様書の変更を PR ごとに確認できるようになり、仕様書のレビューや共有を円滑に行うことが可能になります。特に、API の変更が多いプロジェクトで役に立つのではないでしょうか。

以上

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.