
GitHub Actions × AWS CDK で、OpenAPI 仕様書を Pull Request ごとに Web 上でプレビュー可能にするワークフローを組んでみた
こんにちは、製造ビジネステクノロジー部の若槻です。
前回のブログのでは、OpenAPI 仕様書を S3 + CloudFront でホスティングする構成を AWS CDK で実装する方法を紹介しました。
さて、上記によりコードで管理している 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 プレフィックスを変更します。
- プルリクエストの未オープン時
/
:デフォルトブランチ
- プルリクエスト A(feature/01)がオープンされた状態
/
:デフォルトブランチ/feature/01
:プルリクエスト A のブランチ
- プルリクエスト B(feature/02)がオープンされた
/
:デフォルトブランチ/feature/01
:プルリクエスト A のブランチ/feature/02
:プルリクエスト B のブランチ
- プルリクエスト A のマージ後
/
:デフォルトブランチ/feature/02
:プルリクエスト B のブランチ
- プルリクエスト B のマージ後
/
:デフォルトブランチ
PR ごとにプレフィックスが異なるため、最後のデプロイが既存のホスティングを上書きしてしまう問題も回避可能となります。
ちなみにこの手法は下記で紹介されている内容を参考にしています。やりたいことは同じですが、より詳細な実装を紹介しています。
実装
以下の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 する必要があります。
AssumeRole をするための IAM ロールに必要な権限は、デプロイとプレビューで異なるため、2つの Construct を作成して分離します。
まずは、デプロイ用の権限を定義する DeployPermissionConstruct
を実装します。AWS CDK デプロイで必要な権限を定義しています。
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 のキャッシュ無効化に必要な権限を定義しています。
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
を実装します。
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 IDROLE_TO_ASSUME_FOR_DEPLOY
:DEPLOY_INFRA ワークフローが AssumeRole するための IAM ロール ARNROLE_TO_ASSUME_FOR_PREVIEW
:PREVIEW_API_DOCS ワークフローが AssumeRole するための IAM ロール ARN
GitHub Actions ワークフロー
まずは DEPLOY_INFRA
ワークフローです。インフラの更新を検知して npm run deploy
(cdk deploy
)により先程の MainStack
をデプロイします。そのため、API 仕様書を管理する docs/api
ディレクトリの変更は除外しています。
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 のキャッシュ無効化を行っています。
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 ドキュメント
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
<!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>
配置内容の詳細は下記ブログに記載しているので、参考にしてください。
動作確認
実際にプルリクエストを作成して、以下のフローを確認します。
- プルリクエストの未オープン時
/
:デフォルトブランチ
- プルリクエスト A(feature/hoge-01)がオープンされた状態
/
:デフォルトブランチ/feature/hoge-01
:プルリクエスト A のブランチ
- プルリクエスト 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
を追加することができます。下記でその方法を紹介しているので、参考にしてください。
セキュリティ
今回は実装しませんでしたが、ホスティングに対して Basic 認証や AWS WAF を使用した IP アドレス制限を行うことを検討してください。Basic 認証は下記を参考にしてください。
また AWS WAF Web ACL IP アドレス制限を行うことも検討してください。
おわりに
GitHub Actions × AWS CDK で、OpenAPI 仕様書を Pull Request ごとに Web 上でプレビュー可能にするワークフローを組んでみたので、紹介しました。
この構成により、API 仕様書の変更を PR ごとに確認できるようになり、仕様書のレビューや共有を円滑に行うことが可能になります。特に、API の変更が多いプロジェクトで役に立つのではないでしょうか。
以上