🧑‍💻 실무 개발 & 시스템

GitAction을 활용한 OpenAPI Spec 자동 배포 및 Swagger UI 업데이트 과정(1)

🧑‍💻데브비 2024. 11. 8. 10:02
이전을을 읽어야지 이해가 됩니다!

 

Openapi Spec을 사용해서 API 소스코드를 generate하고 API를 사용하는 방법을 배웠고, spec을 가지고 swagger-ui를 만드는 법을 배웠다.

이번에는 gitAction을 사용해서 Spec yaml을 자동배포를 해볼려고한다.

대략적으로 생각해본 순서는아래와 같다.

Step

  1. Git에 yaml을 업로드
  2. 업로드가 되면 gitAction은 최근 commit의 yaml을 찾아서 aws Api Gateway를 호출
  3. Api Gateway는 배포를 담당하는 Lambda를 호출
  4. Lambda는 전달된 yaml을 S3에 업로드
  5. 업로드가 완료되면 swagger-ui app에 업데이트를 호출
  6. 결과물

1. Git에 yaml을 업로드

git에 push하는 방법을 모르는 개발자는 없을테니 파일구조를 한번 보고 가겠다.

workflows는 git Action을 사용하기위해서 필요한 폴더이다.

그외에 sendYamlToApiGateway.js는 Action에서 aws에 요청하기위해서 만든 파일이다.

📦yaml-exam
 ┣ 📂.git
 ┣ 📂.github
 ┃ ┗ 📂workflows
 ┃ ┃ ┗ 📜main.yml
 ┣ 📜package.json
 ┣ 📜post-poenapi.yaml
 ┣ 📜sendYamlToApiGateway.js
 ┗ 📜user-openapi.yaml

2. 업로드가 되면 gitAction은 최근 commit의 yaml을 찾아서

aws Api Gateway를 호출

본격적으로 Action을 생성해보겠다. git action은 yaml문법을 사용한다.

저장소를 생성하고 Actions → set up a workflow yourself 로 들어간다.

그러면 아래와 같은 화면이 나오는데 여기에 yaml파일을 작성하면 된다. 아니면 직접 폴더에서 작업을 해도된다.

 

yaml 정의

https://docs.github.com/ko/actions/writing-workflows/workflow-syntax-for-github-actions

main에 push를 했는데 yaml파일이 있으면 실행하고 싶다. 그러면 아래와 같이 push에 paths를 달아서 *.yaml을 하면 필터를 걸 수 있다. 이렇게하면 yaml이 있으면 Action을 수행하게 된다.

name: Push YAML to AWS API Gateway
on:
  push:
    branches:
      - main
    paths:
      - '*.yaml'  # 루트 경로에 있는 YAML 파일이 푸시될 때만 실행됨

그다음을 jobs을 살펴보면 ubuntu위에서 action을 실행을 하고싶어서 runs-on ubunt로 설정해주고, steps에서 원하는 작업들을 순차적으로 실행한다.

  1. git의 소스를 checkout해주고, Node.js를 사용하기위해 정의해주고 npm install.
  2. AWS cli를 사용한다면 설정.
  3. 최신에 커밋된 yaml파일을 서칭.
  4. 파일을 API에 전달.
jobs:
  send_yaml_to_aws:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0  # 모든 커밋 히스토리를 가져옴
        
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'  # 원하는 Node.js 버전으로 설정

      - name: Install dependencies
        run: npm install  # node_modules 설치

      - name: Set up AWS CLI
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: {aws-access-key-id}
          aws-secret-access-key: {aws-secret-access-key}
          aws-region: ap-northeast-2 # 한국 리전

      - name: Find modified YAML files
        id: find_yaml
        run: |
          # 변경된 YAML 파일 경로를 찾습니다
          CHANGED_YAML_FILES=$(git diff --name-only HEAD~1 HEAD | grep -E '\\.yaml$')
          # 결과를 출력합니다
          echo "Changed YAML files: $CHANGED_YAML_FILES"
          # YAML 파일 경로를 GitHub Action 환경 변수로 설정합니다
          echo "::set-output name=files::$CHANGED_YAML_FILES"

      - name: Send YAML to API Gateway
        if: steps.find_yaml.outputs.files != ''
        run: |
          for YAML_FILE in ${{ steps.find_yaml.outputs.files }}; do
            echo "Processing YAML file: ${YAML_FILE}"
            # Node.js 스크립트 실행하여 API Gateway로 파일 전송
            node sendYamlToApiGateway.js "${YAML_FILE}"
          done

 

참고로 sendYamlToApiGateway.js 소스코드는 아래와 같다. 단순 API를 호출해주는 역할만 한다.

💡postman으로 curl을 만들고 요청을 해보았지만 아래 오류를 잡을 수 없어서 js파일을 만들어서 요청하기로 결정했다.

 

curl --location $API_URL \\\\
--header 'x-api-key: {key}' \\\\
--header 'Content-Type: multipart/form-data;' \\\\
--form "file=@${YAML_FILE}"


{ "errorType": "TypeError", "errorMessage": "The first argument must be of type string 
or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received 
undefined", "trace": [ "TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be 
of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like 
Object. Received undefined", " at new NodeError (node:internal/errors:405:5)", 
" at Function.from (node:buffer:325:9)", " at Runtime.handler (file:///var/task/index.mjs:13:31)", " 
at Runtime.handleOnceNonStreaming (file:///var/runtime/index.mjs:1173:29)" ] }

 

const fs = require('fs');
const axios = require('axios');
const FormData = require('form-data');

// 인자로 전달받은 YAML 파일 경로
const yamlFilePath = process.argv[2];

// API Gateway URL 및 API Key 설정
const apiUrl = "URL";
const apiKey = "KEY";

// 파일이 존재하는지 확인
if (!fs.existsSync(yamlFilePath)) {
    console.log(`File not found: ${yamlFilePath}`);
    process.exit(1);
}

// form-data로 파일 준비
const form = new FormData();
form.append('file', fs.createReadStream(yamlFilePath));

// axios로 POST 요청 보내기
axios.post(apiUrl, form, {
    headers: {
        ...form.getHeaders(),
        'x-api-key': apiKey,
    },
})
    .then(response => {
        console.log('Successfully uploaded YAML file:', response.data);
    })
    .catch(error => {
        console.error('Error uploading YAML file:', error.message);
    });

3. Api Gateway는 배포를 담당하는 Lambda를 호출

git에서 해야될 작업은 완료했다. 이제는 aws에서 작업을 해야된다.

  1. API를 생성
  2. stage를 생성
  3. 리소스 생성
  4. API 키생성
  5. 사용량 계획 생성 및 API 키, stage연결

3-1. API를 생성

Api Gateway에서 REST API를 생성한다.

3-2. stage를 생성

API에 들어가서 stage탭으로 이동 후 stage를 생성해서 dev인지prod인지 등 API의 배포 환경을 설정해 줄 수있다.

 

3-3. 리소스 생성

api를 생성하고 나는 upload를 하기위한 리소스를 생성했다.

  • 이 리소스는 form-data를 받아서 Lambda에 넘겨주는 역할을 할 것이다.

 

3-4. API 키생성

보안을 위해서 API키가 있어야지 요청을 할 수있게 할 것이다.

 

3-5. 사용량 계획 생성 및 API 키, stage연결

사용량 계획 탭으로 들어가서 먼저 사용량 계획을 생성해준다. 생성해준 후에 key와 stage를 열결 할 것이다.

생성한 사용량계획에 들어가서 stage와 key를 연결만 시켜주면 API Gateway에서 작업이 완료 되었다.

 

4. Lambda는 전달된 yaml을 S3에 업로드

Api Gateway를 생성했으니 리소스에 연결될 Lambda를 생성해 줄 차례이다.

  1. Lambda 생성
  2. Lambda 계층 생성 및 Lambda에 연결
  3. 소스코드 Deploy
  4. Api Gateway 연결

4-1. Lambda 생성

Lambda서비스로 들어가서 함수를 생성해주는데 Node를 사용할 것이라서 런타임을 Node로 해주었다. 생성하고 들어가보면 vscode web으로 작업할 수 있는 환경이다.

 

4-2. Lambda 계층 생성 및 Lambda에 연결

💡node_module은 s3에 올린다음에 Lambda Layers에 선언을 했다. Lambda Layers에서는 공통으로 사용되는 라이브러리나 패키지를 별도로 관리할 수 있다.

 

Lambda에서 계층을생성하기전에 S3에 사용할 module을 zip해서 올려두고 s3 링크를 연결해 주면된다.

생성 후 함수로 들어가서 아래로 스크롤 해보면 계층을 연결할 수 있다.

4-3. 소스코드 Deploy

기본적인 작업은 끝났고 이제 소스코드를 작성할 차례이다. aws에서 테스트기능도 사용할 수 있으니 활용하면된다. 원하는 동작은 Post로 form데이터가 왔을때 그데이터를 s3에 올리는 작업이다.

event의 body-json을 통해 전달된 값을 s3에 업로드하는 로직이다.

import AWS from "aws-sdk";
import multipart from "parse-multipart";
import bluebird from "bluebird";
import axios from "axios";

const s3 = new AWS.S3();

// ES 모듈에서의 export 방식
export const handler = async function (event, context) {
    const result = [];

    const bodyBuffer = Buffer.from(event["body-json"].toString(), "base64");

    const boundary = multipart.getBoundary(event.params.header["Content-Type"]);

    const parts = multipart.Parse(bodyBuffer, boundary);

    const files = getFiles(parts);

    await bluebird.map(files, async (file) => {
        console.log(`uploadCall!!!`);
        try {
            const data = await upload(file);
            result.push({ data, file_url: file.uploadFile.full_path });
            console.log(`data=> ${JSON.stringify(data, null, 2)}`);
        } catch (err) {
            console.log(`s3 upload err => ${err}`);
        }
    });
    
    return context.succeed(result);
};

const upload = function (file) {
    console.log(`putObject call!!!!`);
    return s3.upload(file.params).promise();
};

const getFiles = function (parts) {
    const files = [];
    parts.forEach((part) => {
        const buffer = part.data;
        const fileFullName = part.filename;

        const filefullPath = `https://s3.ap-northeast-2.amazonaws.com/${fileFullName}`;

        const params = {
            Bucket: "{bucketName}",
            Key: `${fileFullName}`,
            Body: buffer,
        };
        
        const uploadFile = {
            size: buffer.toString("ascii").length,
            type: part.type,
            name: fileFullName,
            full_path: filefullPath,
        };

        files.push({ params, uploadFile });
    });
    return files;
};

4-4. Api Gateway 연결

Lambda로 돌아와서 트리거를 추가 해주면 된다. Api gateway는 이미 위에서 만들었으니 연결만 시켜주면된다.

5. 업로드가 완료되면 swagger-ui app에 업데이트를 호출

swagger-ui를 사용하기위해서 ec2 인스턴스를 하나 생성해야된다. 프리티어를 사용하고 있어서 ubuntu의 micro로 하나 생성했다.

그리고 node expresss를 사용해서 swagger-ui를 8080으로 실행시켰다. 아래의 소스코드는 s3의 yaml파일을 읽어와서 spec을 생성하고 swagger을 구성한다. update-spec을 요청시에 새로운 yaml이 있으면 spec을 업데이트해준다.

import express from 'express';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3';
import AWS from 'aws-sdk';
import { fromEnv } from '@aws-sdk/credential-providers';
import dotenv from 'dotenv';
dotenv.config();

// aws region 및 자격증명 설정
AWS.config.update({
   accessKeyId: process.env.AWS_ACCESS_KEY_ID,
   secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
   region: 'ap-northeast-2',
});

// 전체 스웨거 스펙을 저장할 변수
let swaggerSpec = {
  openapi: '3.0.0',
  info: {
    title: 'Combined API Docs',
    version: '1.0.0',
    description: 'Combined API documentation for all services'
  },
  paths: {},
  components: {}
};

// S3 클라이언트 초기화
const s3Client = new S3Client({
  region: 'ap-northeast-2',
  credentials: fromEnv(),
});

const streamToString = (stream) =>
  new Promise((resolve, reject) => {
    const chunks = [];
    stream.on('data', (chunk) => chunks.push(chunk));
    stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
    stream.on('error', reject);
  });

const listFilesInFolder = async (bucketName, folderPath) => {
  try {
    const listCommand = new ListObjectsV2Command({
      Bucket: bucketName,
      Prefix: folderPath,
    });
    const listResponse = await s3Client.send(listCommand);

    const fileContents = await Promise.all(
      listResponse.Contents.map(async (file) => {
        const getCommand = new GetObjectCommand({
          Bucket: bucketName,
          Key: file.Key,
        });
        const getResponse = await s3Client.send(getCommand);

        const fileContent = await streamToString(getResponse.Body);
        const parsedContent = YAML.parse(fileContent); // YAML 파싱

        return { fileName: file.Key, content: parsedContent };
      })
    );

    // Swagger 스펙에 paths와 components 병합
    fileContents.forEach((file) => {
      if (file.content === null) {
        console.log(`Skipping file: ${file.fileName} (content is null)`);
        return;
      }

      if (file.content.paths) {
        Object.keys(file.content.paths).forEach((path) => {
          swaggerSpec.paths[path] = file.content.paths[path];
        });
      }

      if (file.content.components) {
        Object.keys(file.content.components).forEach((component) => {
          swaggerSpec.components[component] = file.content.components[component];
        });
      }
    });
  } catch (error) {
    console.error("Error listing or retrieving files:", error);
    throw error;
  }
};        
const app = express();
const port = 8080;
const bucketName = ''; // S3 버킷 이름
const folderPath = ''; // S3 폴더 경로

// 서버 시작 및 YAML 파일을 S3에서 가져오기
const startServer = async () => {
  // S3에서 YAML 파일을 가져와 Swagger 스펙 병합
  await listFilesInFolder(bucketName, folderPath);

  app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
  app.post('/update-spec', async (req, res) => {
    await listFilesInFolder(bucketName, folderPath);
    swaggerUi.setup(swaggerSpec);
    res.send({ message: 'Swagger Spec updated!' });
  });
  app.listen(port, () => {
    console.log(`Swagger UI ${port}`);
  });
};

startServer().catch((error) => {
  console.error('Error starting server:', error);
});

결과물

git에 push하면 아래와 같은 형식으로 프로세스가 진행된다.