AWS

【AWS SAM】Swaggerファイルを分割・結合で効率的に作業する

ファイルが大きくなりがちなSwaggerファイルを分割して管理する方法をまとめます。
また、分割して管理しつつもAWS CodePipelineで自動デプロイがうまく動作するよう設定していきます。

はじめに

なぜSwaggerファイルの分割が必要か

1つのSwaggerファイルでプロジェクトを管理していると様々な課題に直面してきます。

  • 機能が多くなるとコード量が膨らみメンテナンスが難しくなる
  • 複数人で同じファイルを編集すると競合が発生しやすい
  • 競合が発生するとマージ作業が面倒くさい

こういった理由から、Swaggerファイルを分割して管理することで、複数人でも並行して作業が可能になり、効率がよくなるケースがしばしばあります。ただ、一人または少人数開発の場合は、必ずしも分割する必要はないのでケースバイケースになります。

どのように実現するか

やることは2つ。

  1. Swaggerファイルを分割して作成
  2. 分割して作成されたSwaggerファイルのパーツを結合

参考にした記事をお手本に json-refsgulp を組み合わせてファイルの結合を実現しています。

Swaggerファイル分割

ファイル構成

.
├── app
│   └── hello_world
│       └── index.py                       # Lambda関数のプログラムソース
├── swagger
│   ├── components
│   │   └── response_404_not_found.yaml    # 共通レスポンスとして分割したYAMLファイル
│   ├── paths
│   │   ├── hello_world.yaml               # APIのエンドポイント毎に分割したYAMLファイル
│   │   └── mock
│   │       └── hello_world.yaml           # APIのエンドポイント毎に分割したYAMLファイル(MOCK用)
│   ├── _components.yaml                   # componentsフォルダ配下のファイル管理用のYAMLファイル
│   ├── _paths.yaml                        # pathsフォルダ配下のファイル管理用のYAMLファイル
│   └── base.yaml                          # 読み込み開始の起点となるYAMLファイル
├── gulpfile.js                            # gulpで実行したいJavaScriptのプログラムソース
├── package.json                           # 利用するモジュール一覧
├── template.yaml                          # SAMテンプレート
├── buildspec.yml                          # CodeBuild用のYAMLファイル
├── swagger.yaml                           # swaggerフォルダ配下のファイルを結合したSwaggerファイル(Git による追跡から除外する)
└── .gitignore                             # Git による追跡から除外するファイルを記載

今回は簡単なAWS SAMでのWebアプリケーションの実装を想定しています。
プロジェクトの始め方は以下の記事をご参考ください。

【AWS】CodeStarを使ってWebアプリケーションのCI/CD環境を作成してみた AWS CodeStarとは 公式様からの引用です。 AWS CodeStar を使用すると、アプリケーションを迅速に開発...

次に、各ファイルの中身について記載していきます。

gulp関連ファイル

gulpfile.js

分割して配置したファイルを結合するJavaScriptのプログラムソース

今回の主役。このプログラムを動かして分割したファイルを結合します。
9-11行目でInput/Outputのパスの設定を行っています。

'use strict';

const gulp = require('gulp');
const rename = require('gulp-rename');
const through2 = require('through2');
const yaml = require('js-yaml');
 
// パス設定
const inputFileName = './swagger/base.yaml';
const outputFileName = 'swagger.yaml';
const outputPath = './';
 
gulp.task('compile', cb => {
  return gulp
    .src(`${inputFileName}`)
    .pipe(through2.obj((file, enc, cb) => {
        if (!file.isBuffer()) throw new Error(`[FAILED]. '${inputFileName}' can not load target file.`);
        const root = yaml.safeLoad(file.contents);
        const resolve = require('json-refs').resolveRefs;
        const options = {
          filter : ['relative', 'remote'],
          loaderOptions : {
            processContent : (res, callback) => {
              callback(null, yaml.safeLoad(res.text));
            }
          }
        };
        resolve(root, options).then((results) => {
          file.contents = Buffer.from(yaml.safeDump(results.resolved));
          delete require.cache[require.resolve('json-refs')];
          cb(null, file);
        }).catch((e) => {
          throw new Error(e);
        });
      })
    )
    .pipe(rename(outputFileName))
    .pipe(gulp.dest(outputPath));
});
 
gulp.task(
  'default',
  gulp.series(
    'compile'
  )
);

package.json

利用するモジュール一覧

{
  "name": "swagger",
  "version": "1.0.0",
  "description": "swagger file join",
  "main": "resolve.js",
  "scripts": {
    "start": "gulp",
    "build": "gulp build",
    "start2": "node resolve.js ./src/index.yaml -o yaml > ./test.swagger.yaml",
    "test": "node resolve.js index.yaml > test/resolved.json && node resolve.js -o yaml index.yaml"
  },
  "license": "MIT",
  "dependencies": {
    "commander": "^2.19.0",
    "gulp": "^4.0.2",
    "gulp-rename": "^2.0.0",
    "js-yaml": "^3.12.2",
    "json-refs": "^3.0.12",
    "multi-file-swagger": "^2.3.0",
    "through2": "^4.0.2"
  }
}

swagger分割ファイル

swagger/base.yaml

読み込み開始の起点となるYAMLファイル

openapi: "3.0.3"

info:
  title: "Sample API"
  description: Sample API
  version: "1.0.0"
 
paths:
  $ref: ./swagger/_paths.yaml
 
components:
  $ref: ./swagger/_components.yaml

swagger/_components.yaml

componentsフォルダ配下のファイル管理用のYAMLファイル

schemas:
  ResponseNotFound:
    $ref: ./components/response_404_not_found.yaml

swagger/_paths.yaml

pathsフォルダ配下のファイル管理用のYAMLファイル

/hello:
  $ref: ./paths/hello_world.yaml

/mock/hello:
  $ref: ./paths/mock/hello_world.yaml

swagger/components/response_404_not_found.yaml

共通レスポンスとして分割したYAMLファイル

description: "404 Not found | 指定リソースがない時のレスポンス"
type: object
properties:
  errors:
    type: array
    items:
      type: object
      properties:
        message:
          type: string
          example: "コンテンツが存在しません"

swagger/paths/hello_world.yaml

APIのエンドポイント毎に分割したYAMLファイル

今回は、サンプルとしてHello World APIのGETメソッドの定義を記載しています。
APIの呼び出しに対し、バックではLambda関数が動作し、処理結果を返す流れになります。

get:
  summary: "Hello World API."
  description: "sample api"
  responses:
    200:
      description: "Lambda実行が成功した時"
      content:
        application/json:
          schema:
            type: object
            properties:
              message:
                description: "メッセージ"
                type: string
            example:
              - message: "Hello World by Mock."
    404:
      description: "指定リソースは存在しません"
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ResponseNotFound'
  x-amazon-apigateway-integration:
    httpMethod: "POST"
    uri:
      Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorld.Arn}/invocations
    responses:
      default:
        statusCode: "200"
    passthroughBehavior: when_no_match
    type: "aws_proxy"

swagger/paths/mock/hello_world.yaml

APIのエンドポイント毎に分割したYAMLファイル(MOCK用)

ウェブアプリケーション開発の場合、フロントエンドとバックエンドの開発が同時で進むことがあります。
その時は、バックエンドの処理結果ではなくMOCK機能を利用してテストデータを返却することでみんながハッピーになります。
(バックエンド開発チームにとってもフロントエンド開発チームからのプレッシャーから解放されるはず)

get:
  summary: "Hello World API Mock."
  description: "sample api mock"
  responses:
    200:
      description: OK
  x-amazon-apigateway-integration:
    type: "mock"
    requestTemplates:
      application/json: |
        {
          "statusCode": 200
        }
    responses:
      default:
        statusCode: "200"
        responseTemplates:
          application/json: |
            { 
              message: "mock message."
            }

その他ファイル

app/hello_world/index.py

Lambda関数のプログラムソース

HelloWorldのサンプルプログラムを使用しています。

import json
import datetime


def handler(event, context):
    data = {
        'output': 'Hello World!!',
        'timestamp': datetime.datetime.utcnow().isoformat()
    }
    return {'statusCode': 200,
            'body': json.dumps(data),
            'headers': {'Content-Type': 'application/json'}}

template.yaml

SAMテンプレート

API Gateway、Lambda関数、IAMロールをそれぞれ定義しています。

AWSTemplateFormatVersion: 2010-09-09
Transform:
- AWS::Serverless-2016-10-31
- AWS::CodeStar

Parameters:
  ProjectId:
    Type: String
    Description: CodeStar projectId used to associate new resources to team members
  CodeDeployRole:
    Type: String
    Description: IAM role to allow AWS CodeDeploy to manage deployment of AWS Lambda functions
  Stage:
    Type: String
    Description: The name for a project pipeline stage, such as Staging or Prod, for which resources are provisioned and deployed.
    Default: 'Devl'

Globals:
  Function:
    Runtime: python3.9
    Timeout: 5

Resources:
  ApiGatewayRestApi:
    Type: AWS::Serverless::Api
    Properties:
      Name: !Sub 'awscodestar-${ProjectId}-api'
      StageName: !Ref Stage
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: swagger.yaml

  HelloWorld:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub 'awscodestar-${ProjectId}-lambda-HelloWorld'
      CodeUri: app/hello_world/
      Handler: index.handler
      Role:
        Fn::GetAtt:
        - LambdaExecutionRole
        - Arn
      Events:
        GetEvent:
          Type: Api
          Properties:
            Path: /hello
            Method: get
            RestApiId: !Ref ApiGatewayRestApi

  LambdaExecutionRole:
    Description: Creating service role in IAM for AWS Lambda
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 'CodeStar-${ProjectId}-Execution${Stage}'
      AssumeRolePolicyDocument:
        Statement:
        - Effect: Allow
          Principal:
            Service: [lambda.amazonaws.com]
          Action: sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - !Sub 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'

buildspec.yml

CodeBuild用のYAMLファイル

CodeBuildで使用するビルド環境に yarn と必要なモジュールをインストールし、分割したswaggerファイルを一つのswaggerファイルに結合します。

version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.9
    commands:
      - npm install -g yarn
      - yarn install

  pre_build:
    commands:
      - yarn start

  build:
    commands:

      # Use AWS SAM to package the application by using AWS CloudFormation
      - aws cloudformation package --template template.yml --s3-bucket $S3_BUCKET --output-template template-export.yml

      # Do not remove this statement. This command is required for AWS CodeStar projects.
      # Update the AWS Partition, AWS Region, account ID and project ID in the project ARN on template-configuration.json file so AWS CloudFormation can tag project resources.
      - sed -i.bak 's/\$PARTITION\$/'${PARTITION}'/g;s/\$AWS_REGION\$/'${AWS_REGION}'/g;s/\$ACCOUNT_ID\$/'${ACCOUNT_ID}'/g;s/\$PROJECT_ID\$/'${PROJECT_ID}'/g' template-configuration.json

artifacts:
  files:
    - template-export.yml
    - template-configuration.json

.gitignore

Git による追跡から除外するファイルを記載

sswaggerファイルのGitへアップロードは競合する可能性が高いので Git による追跡から除外しています。 buildspec.yml により、ビルド環境で結合する設定になっているため、swaggerフォルダ配下の部品だけアップロードしたらOKです。

node_modules, yarn.lockはプロジェクト自体には必要ないので除外に設定しています。

swagger.yaml
node_modules
yarn.lock

Swaggerファイル結合

準備

以下のコマンドを実行して、必要なモジュールをインストールします。

# yarn インストール (既にインストール済みの場合は不要)
$ npm install -g yarn

# 必要モジュールのインストール
$ yarn install

初回の一度だけ実行したらよいです。

実行

yarn start コマンドを実行すると gulpfile.js 内のプログラムが起動します。

$ yarn start
yarn run v1.22.17
$ gulp
[06:34:17] Using gulpfile ~/environment/nullpo-project/gulpfile.js
[06:34:17] Starting 'default'...
[06:34:17] Starting 'compile'...
[06:34:17] Finished 'compile' after 215 ms
[06:34:17] Finished 'default' after 219 ms
Done in 0.72s.

このように表示されたら処理成功です。

結合結果

swaggerフォルダ配下のファイルが結合されて swagger.yaml が出力されました。
既に同じ名前のファイルが存在する場合は上書きされます。

openapi: 3.0.3
info:
  title: SAMPLE API
  Description: SAMPLE API
  version: 1.0.0
paths:
  /hello:
    get:
      summary: Hello World API.
      description: sample api
      responses:
        '200':
          description: Lambda実行が成功した時
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    description: メッセージ
                    type: string
                example:
                  - message: Hello World by Mock.
        '404':
          description: 指定リソースは存在しません
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResponseNotFound'
      x-amazon-apigateway-integration:
        httpMethod: POST
        uri:
          'Fn::Sub': >-
            arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorld.Arn}/invocations
        responses:
          default:
            statusCode: '200'
        passthroughBehavior: when_no_match
        type: aws_proxy
  /mock/hello:
    get:
      summary: Hello World API Mock.
      description: sample api mock
      responses:
        '200':
          description: OK
      x-amazon-apigateway-integration:
        type: mock
        requestTemplates:
          application/json: |
            {
              "statusCode": 200
            }
        responses:
          default:
            statusCode: '200'
            responseTemplates:
              application/json: |
                { 
                  message: "mock message."
                }
components:
  schemas:
    ResponseNotFound:
      description: 404 Not found | 指定リソースがない時のレスポンス
      type: object
      properties:
        errors:
          type: array
          items:
            type: object
            properties:
              message:
                type: string
                example: コンテンツが存在しません

CodeCommit(Git) へ登録

今回のファイル一式をCodeCommit(Git)へPushし、CodePipelineで自動デプロイを行います。
その際に swagger.yaml はビルド環境で結合されます。

.gitignore ファイルにて、ローカルで生成される swagger.yaml は追跡対象外にしているので基本的には競合は発生しづらいはずです。それでも競合が発生した場合は手作業で修正してください。

まとめ

Swaggerファイルを分割することでメンテナンスがしやすくなりました。
複数人で開発するプロジェクトでもコンフリクトの恐怖に怯えることはなくなりそうです。

AWS SAMを利用していて、Swaggerファイルのメンテナンスに困っている方の助けになれば幸いです。

ABOUT ME
湖山 貴裕
はじめまして。 二児のお父さんプログラマーです。最近キャンプにも興味あり。夏には庭でキャンプしようともくろみ中。ボドゲ好き。チョコ好き。茶道経験者。 2012年大学卒業→IT企業就職 Java,VB.NET, C#, javascript等の企業向けシステム開発/主にバックエンドを担当/AWSを少しかじる→2020年フリーランスエンジニアへ転身 広島でAWS案件にて楽しく活動中