안녕하세요, 스포카 프로그래머 이강욱입니다.

스포카는 과거 풀 스택 프로그래머가 중심이 되어 한 프로그래머가 프론트엔드와 백엔드를 동시에 개발하는 문화를 가지고 있었습니다. 이런 문화 아래에서, 웹 서버에는 코드 자체의 문서 이외에 별도의 API 명세나 문서는 존재하지 않았습니다.

그러나 최근 스포카는 프론트엔드 프로그래머와 백엔드 프로그래머를 나누어 각자 전문화된 분야에서 개발할 수 있도록 바꾸어나가는 추세입니다. 이러한 변화를 바탕으로, 프론트엔드와 백엔드 프로그래머가 더 잘 소통할 수 있도록 좀 더 고도화된 API 문서가 필요하다고 생각하게 되었습니다.

저는 최근 웹 서버의 API 문서를 작성하는 작업을 하고 있습니다. 어떻게 하면 API 문서를 더욱 효율적으로 작성하고, 공유하고, 읽을 수 있을까 고민하던 중에, OpenAPI라는 명세를 활용하기로 했습니다.

OpenAPI 명세

위키피디아 OpenAPI Specification

OpenAPI Specification은 원래 Swagger Specification이라는 이름이었습니다. Swagger는 RESTful 웹 서비스의 API 문서를 체계화하여 JSON, YAML 파일 등으로 작성할 수 있는 명세를 만들었습니다. 이 명세가 Swagger만의 명세가 아닌, 리눅스 재단 아래에서 OpenAPI Specification, OAS라는 이름으로 다시 태어났습니다.

다음은 OAS 문서의 한 예입니다. 이 문서는 이 글에서 만들어볼 문서이기도 합니다:

openapi: 3.0.2
info:
  title: example-web-server
  version: 0.1.0
components:
  schemas:
    ValidationError:
      type: object
      properties:
        invalidFields:
          description: 검증에 실패한 필드 이름과 에러 목록
          example:
            someField:
              - some error
    PostHelloRequest:
      type: object
      properties:
        mood:
          type: integer
          description: 당신의 현재 기분
          example: 5
        name:
          type: string
          description: 당신의 이름
          example: Thomas
      required:
        - mood
        - name
    PostHelloResponse:
      type: object
      properties:
        title:
          type: string
        message:
          type: string
      required:
        - message
        - title
  securitySchemes:
    access_token:
      type: apiKey
      description: 액세스 토큰
      name: X-Some-Access-Token
      in: header
paths:
  /hello/:
    get:
      description: 간단한 안녕! 을 받을 수 있는 API
      responses:
        '200':
          description: 간단한 안녕!
          content:
            text/plain: {}
      operationId: getHello
      summary: /hello/ getHello
      tags:
        - api
  /secured/hello/:
    get:
      description: 인증과 URL 파라미터가 필요한 안녕! 을 받을 수 있는 API
      parameters:
        - in: query
          name: name
          required: false
          description: '당신의 이름. (기본값: unknown)'
          schema:
            type: string
            default: unknown
            example: Thomas
      responses:
        '200':
          description: 안녕!
          content:
            text/plain: {}
        '400':
          description: 요청 검증에 실패함. `invalidFields` 항목에서 검증에 실패한 필드와 에러 메시지를 확인할 수 있습니다.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '401':
          description: 액세스 토큰이 잘못됨
      security:
        - access_token: []
      operationId: getSecuredHello
      summary: /secured/hello/ getSecuredHello
      tags:
        - api
  /post/hello/:
    post:
      description: 인증과 JSON 요청이 필요한 더 자세한 안녕! 을 받을 수 있는 API
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostHelloRequest'
      responses:
        '200':
          description: 더 자세한 안녕!
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostHelloResponse'
        '400':
          description: 요청 검증에 실패함. `invalidFields` 항목에서 검증에 실패한 필드와 에러 메시지를 확인할 수 있습니다.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '401':
          description: 액세스 토큰이 잘못됨
      security:
        - access_token: []
      operationId: postHello
      summary: /post/hello/ postHello
      tags:
        - api

이렇게 OAS 문서 자체는 당장 사람이 읽을 수 있는 형태는 아닙니다. 그러나 이 문서를 다양한 시각화 도구에 넣어서 문서를 생성할 수 있습니다. 위 문서를 editor.swagger.io 에 입력해보세요.

OAS는 공개, 표준 명세이다 보니 OAS를 따르는 문서화 자동화 툴도 늘어나고, OAS를 시각화해주는 툴도 점점 늘어나고 있습니다. 그래서 저도 OpenAPI 명세를 따르는 API 문서를 작성해보고자 했고, marshmallow와 apispec을 활용해 달성했습니다.

Flask, Marshmallow, Apispec

스포카에서는 파이썬과 flask를 활용한 웹 서버를 주로 사용합니다. flask에서 사용할 수 있는 OAS 자동화 툴을 찾아보다가 marshmallow과 apispec의 조합을 알게 되었습니다.

Marshmallow

Github marshmallow-code/marshmallow

Marshmallow는 파이썬 오브젝트를 직렬화, 역직렬화, 및 검증을 도와주는 유틸리티입니다. 예를 들어 웹 서버로 들어온 POST 요청의 페이로드를 검증할 때, 또는 파이썬 오브젝트를 응답으로 돌려주기 위해 JSON으로 변환할 때 사용할 수 있습니다.

(역)직렬화 및 검증을 위해서는 필연적으로 스키마(schema)를 선언해야 합니다. 스키마를 미리미리 정의하여 검증을 돕는 동시에, 자동으로 OAS 문서로 만들기도 쉬워지는 것이죠.

apispec

Github marshmallow-code/apispec

apispec은 marshmallow와 같은 개발자들이 만들고 있는 OpenAPI 명세 작성 도구입니다. 플러그인을 통한 모듈화 구조로 되어 있어서 여러 웹 프레임워크에 대응이 가능합니다. apispec-webframeworks라는 패키지에는 flask, bottle, tornado를 위한 플러그인이 미리 정의되어있습니다.

플러그인을 통해서 여러 가지 편리한 기능을 더 많이 붙일 수 있습니다. 이 글에서도 이 플러그인을 많이 활용하게 될 것입니다.

작성해보자!

실제로 웹 서버를 하나 만들며 API 문서를 작성해봅시다.

이 글에서 작성한 예제 코드는 Github spoqa/flask-marshmallow-apispec-example-web-server 에서도 확인하실 수 있습니다.

API들

우리의 웹 서버는 다음과 같은 3개의 간단한 API를 가진다고 생각해봅시다.

  • /hello/: 간단한 GET 요청을 받는 API
  • /secured/hello/: 헤더를 이용한 액세스 토큰과 URL 파라미터를 받는 API
  • /post/hello/: 엑세스 토큰도 받고, POST 요청을 통해 JSON 바디를 받는 API

위 세 API를 다음과 같이 구현해보았습니다:

from flask import Blueprint, Response, jsonify, request

blueprint = Blueprint('api', __name__)


@blueprint.route('/hello/')
def get_hello() -> Response:
    return Response('Hello, world!', 200)


@blueprint.route('/secured/hello/')
@access_token_required
def get_secured_hello() -> Response:
    payload = request.args.to_dict()
    name = payload['name']
    return Response(f'Hello, sneaky {name}!', 200)


@blueprint.route('/post/hello/', methods=['POST'])
def post_hello() -> Response:
    payload = request.get_json()
    name = payload['name']
    mood = payload['mood']
    response = {
        'title': f'Hello, {name}!',
        'message': f'I hope your mood {mood!s} be better.',
    }
    return jsonify(response)

아주 간단합니다. access_token_required 데코레이터에서 액세스 토큰을 검증하는 과정을 거친다고 생각해주세요. 자세한 것은 생략하겠습니다.

Marshmallow 붙이기

Marshmallow를 통해 요청 검증을 해봅시다. 검증을 위해서는 스키마를 정의해야 하는데, 스키마 정의 전에 몇 가지 자동화를 위한 작업을 할 수 있습니다.

CamelCaseSchema

먼저 스포카에서 파이썬 백엔드는 대체로 snake_case 를 사용하지만, 웹이나 앱 프론트엔드에서는 camelCase 를 사용합니다. 만약 스키마가 정의할 때에는 스네이크 케이스를 사용하지만, 실제 응답은 카멜 케이스로 직렬화된다면 매우 간편하겠죠. 이걸 다음과 같이 구현해봅시다:

from marshmallow import Schema, fields


def camelcase(s: str) -> str:
    parts = iter(s.split("_"))
    return next(parts) + "".join(i.title() for i in parts)


class CamelCaseSchema(Schema):
    """Schema that uses camel-case for its external representation
    and snake-case for its internal representation.
    """
    def on_bind_field(self, field_name: str, field_obj: fields.Field):
        field_obj.data_key = camelcase(field_obj.data_key or field_name)

BaseSchema

CamelCaseSchema 를 모든 스키마가 베이스로 사용하게 될 것 같으니 BaseSchema 라는 이름으로 재정의해줍시다.

class BaseSchema(CamelCaseSchema):
    pass

이렇게 정의한 이유는 아래에서 BaseSchema 에 다른 기능을 추가하기 위함입니다.

스키마 정의하기

이제 실제 요청에 쓰이는 스키마를 정의해봅시다.

from marshmallow import fields


class GetSecuredHelloArgsSchema(BaseSchema):
    name = fields.String(
        missing='unknown',
        metadata={
            'description': '당신의 이름. (기본값: unknown)',
            'example': 'Thomas',
        },
    )


class PostHelloRequestSchema(BaseSchema):
    name = fields.String(
        required=True,
        metadata={
            'description': '당신의 이름',
            'example': 'Thomas',
        },
    )
    mood = fields.Integer(
        required=True,
        metadata={
            'description': '당신의 현재 기분',
            'example': 5,
        },
    )


class PostHelloResponseSchema(BaseSchema):
    title = fields.String(required=True)
    message = fields.String(required=True)

GetSecuredHelloArgsSchema 스키마는 name 이라는 문자열 필드를 하나 가지는 스키마가 됩니다. 또한 이 필드는 기본값이 'unknown' 이 됩니다.

PostHelloRequestSchema 스키마는 문자열 name, 숫자 mood 라는 두 필드를 가지고, 둘 다 기본값은 없고 무조건 필요하도록 설정했습니다.

metadata 안에 들어가는 내용은 나중에 OAS 문서를 생성할 때 반영됩니다.

PostHelloResponseSchema 스키마는 post_hello API의 응답을 정의하는 스키마입니다. 문자열 title, message 두 필드가 있고 두 필드 다 항상 존재합니다. 메타데이터는 여기서는 따로 적지 않았으나, 응답을 설명하기 위해 다른 스키마와 똑같이 적어줘도 됩니다.

스키마 적용

정의한 스키마를 통해 두 API를 재작성하면 이렇게 됩니다:

get_secured_hello_args_schema = GetSecuredHelloArgsSchema()


@blueprint.route('/secured/hello/')
def get_secured_hello() -> Response:
    payload = get_secured_hello_args_schema.load(request.args.to_dict())
    name = payload['name']
    return Response(f'Hello, sneaky {name}!', 200)


post_hello_request_schema = PostHelloRequestSchema()
post_hello_response_schema = PostHelloResponseSchema()


@blueprint.route('/post/hello/', methods=['POST'])
def post_hello() -> Response:
    payload = post_hello_request_schema.load(request.get_json())
    name = payload['name']
    mood = payload['mood']
    response = {
        'title': f'Hello, {name}!',
        'message': f'I hope your mood {mood!s} be better.',
    }
    return jsonify(post_hello_response_schema.dump(response))

schema.load 메서드를 통해 요청을 검증할 수 있습니다. 스키마에 맞지 않는 요청이 들어오면 ValidationError 예외가 발생합니다.

schema.dump 메서드는 응답을 검증하지는 않습니다. 여기서는 dict 를 바로 dump 하기 때문에 사실 dump 가 필요 없지만, 만약 응답에 사용할 데이터가 dict 가 아니고 어떤 파이썬 오브젝트라면, 즉 data.title, data.message 필드를 가진 오브젝트여도 schema.dump 메서드로 직렬화할 수 있습니다.

ValidationError 핸들러

그러나 지금 상태라면 예외가 발생하면 500 응답을 돌려줄테니 다음과 같이 예외 핸들러를 추가합시다:

from marshmallow import ValidationError


def _handle_validation_error(e: ValidationError) -> Response:
    resp = jsonify({'invalidFields': e.normalized_messages()})
    resp.status_code = 400
    return resp


app.register_error_handler(ValidationError, _handle_validation_error)

marshmallow.ValidationErrornormalized_messages 메서드를 통해 어떤 필드가 어떻게 잘못되어 검증에 실패했는지 간단한 에러 메시지를 생성해줍니다. 이를 응답에 바로 활용할 수 있습니다. 여기서 구현한 검증 오류 응답도 스키마로 작성해본다면 다음과 같은 모양새가 됩니다:

class ValidationErrorSchema(BaseSchema):
    invalid_fields = fields.Mapping(
        keys=fields.String,
        values=fields.List(fields.String),
        description='검증에 실패한 필드 이름과 에러 목록',
        example={'someField': ['some error']},
    )

이 스키마는 당장 응답을 검증하는 데에 쓰이지는 않지만, 나중에 문서를 생성할 때 도움이 됩니다.

apispec 붙이기

이 글에서는 OpenAPI Specification 3.0.2를 사용하고 있습니다. 해당 명세는 Github OAI/OpenAPI-Specification 3.0.2.md 에서 자세히 보실 수 있습니다.

서비스 정의

OAS 문서에는 서비스를 설명하는 Info 객체와 이 문서가 몇 버전의 OAS를 따르는지 명시하는 OpenAPI 객체가 필요합니다. apispec에서 최초로 서비스를 정의하면서 이 두 객체도 같이 정의합니다.

from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from apispec_webframeworks.flask import FlaskPlugin

spec = APISpec(
    title='example-web-server',
    version='0.1.0',
    openapi_version='3.0.2',
    plugins=[
        FlaskPlugin(),
        MarshmallowPlugin(),
    ],
)

이렇게 정의하고 나면 드디어 OpenAPI 명세 문서를 생성할 수 있습니다.

print(spec.to_yaml())

위 코드를 실행하면 다음 문서가 나옵니다.

openapi: 3.0.2
info:
  title: example-web-server
  version: 0.1.0
paths: {}

아무것도 나오지 않습니다… 아쉽게도 apispec은 마법같이 모든 것을 수집해서 자동으로 생성해주지는 않습니다. 프로그래머가 손으로 조금씩 붙여줘야 합니다.

API 엔드포인트 추가

apispec 문서를 읽어보면 path를 직접 등록해줘야 합니다. 문서에는 flask request context가 필요하다고 되어있지만 사실 flask app을 직접 넘겨주면 없어도 됩니다.

spec.path(view=get_hello, app=app)

이렇게 등록하고 다시 문서를 생성해보면…

openapi: 3.0.2
info:
  title: example-web-server
  version: 0.1.0
paths:
  /hello/: {}

path가 추가되긴 했는데, 아무 내용도 없습니다. 이 상태로 editor.swagger.io 등에서 렌더링을 해도 아무런 문서가 생성되지 않습니다. 내용을 추가하려면 API 엔드포인트 함수에 문서를 적어주어야 합니다.

@blueprint.route('/hello/')
def get_hello() -> Response:
    """
    ---
    get:
      description: 간단한 안녕! 을 받을 수 있는 API
      responses:
        200:
          description: 간단한 안녕!
          content:
            text/plain: {}
    """
    return Response('Hello, world!', 200)

OAS에 맞게 문서를 적고 다시 생성하면…

openapi: 3.0.2
info:
  title: example-web-server
  version: 0.1.0
paths:
  /hello/:
    get:
      description: 간단한 안녕! 을 받을 수 있는 API
      responses:
        '200':
          description: 간단한 안녕!
          content:
            text/plain: {}

드디어 뭔가 문서가 나오기 시작합니다. editor.swagger.io 등에 입력해봐도 괜찮은 문서가 렌더링됩니다.

API 엔드포인트 추가 자동화

근데 spec.path(...) 를 이용해 엔드포인트를 일일이 추가하는 건 매우 번거롭고, 잊어먹기 쉽습니다. 이 과정을 자동화해봅시다. 다음과 같이 flask.Blueprint 를 감싼 새 블루프린트를 만들어줍니다.

class DocumentedBlueprint(Blueprint):
    def __init__(self, name, import_name, **kwargs):
        super().__init__(name, import_name, **kwargs)
        self.view_functions = []

    def add_url_rule(
        self,
        rule,
        endpoint=None,
        view_func=None,
        **kwargs,
    ):
        super().add_url_rule(
            rule,
            endpoint=endpoint,
            view_func=view_func,
            **kwargs,
        )
        self.view_functions.append(view_func)

    def register(self, app, options, first_registration=False):
        super().register(app, options, first_registration=first_registration)
        for view_function in self.view_functions:
            spec.path(view=view_function, app=app)

기존 blueprint를 교체하고…

blueprint = DocumentedBlueprint('api', __name__)

다른 API 엔드포인트에도 간단한 문서를 추가해줍니다.

@blueprint.route('/secured/hello/')
@access_token_required
def get_secured_hello() -> Response:
    """
    ---
    get:
      description: 인증과 URL 파라미터가 필요한 안녕! 을 받을 수 있는 API
      responses:
        200:
          description: 안녕!
          content:
            text/plain: {}
    """
    # 생략


@blueprint.route('/post/hello/', methods=['POST'])
@access_token_required
def post_hello() -> Response:
    """
    ---
    post:
      description: 인증과 JSON 요청이 필요한 더 자세한 안녕! 을 받을 수 있는 API
      responses:
        200:
          description: 더 자세한 안녕!
          content:
            application/json: {}
    """
    # 생략

이제 spec에 path를 일일이 생성해줄 필요 없이, flask app에 blueprint를 등록한 이후에 문서를 렌더링하면 알아서 path가 등록됩니다.

app.register_blueprint(blueprint)
# ...
print(spec.to_yaml())

이제 다른 API 엔드포인트에 대한 문서도 잘 생성이 됩니다.

openapi: 3.0.2
info:
  title: example-web-server
  version: 0.1.0
paths:
  /hello/:
    get:
      description: 간단한 안녕! 을 받을 수 있는 API
      responses:
        '200':
          description: 간단한 안녕!
          content:
            text/plain: {}
  /secured/hello/:
    get:
      description: 인증과 URL 파라미터가 필요한 안녕! 을 받을 수 있는 API
      responses:
        '200':
          description: 안녕!
          content:
            text/plain: {}
  /post/hello/:
    post:
      description: 인증과 JSON 요청이 필요한 더 자세한 안녕! 을 받을 수 있는 API
      responses:
        '200':
          description: 더 자세한 안녕!
          content:
            application/json: {}

스키마 추가

이제 요청/응답을 검증하는 데에 사용한 스키마를 문서에 자동으로 등록해봅시다. 이건 그렇게 어렵지 않습니다. docstring으로 추가한 문서에 스키마 이름을 적으면 apispec의 MarshmallowPlugin이 알아서 추가해줍니다.

@blueprint.route('/secured/hello/')
@access_token_required
def get_secured_hello() -> Response:
    """
    ---
    get:
      description: 인증과 URL 파라미터가 필요한 안녕! 을 받을 수 있는 API
      parameters:
      - in: query
        schema: GetSecuredHelloArgsSchema
      responses:
        200:
          description: 안녕!
          content:
            text/plain: {}
    """
    # 생략


@blueprint.route('/post/hello/', methods=['POST'])
@access_token_required
def post_hello() -> Response:
    """
    ---
    post:
      description: 인증과 JSON 요청이 필요한 더 자세한 안녕! 을 받을 수 있는 API
      requestBody:
        required: true
        content:
          application/json:
            schema: PostHelloRequestSchema
      responses:
        200:
          description: 더 자세한 안녕!
          content:
            application/json:
              schema: PostHelloResponseSchema
    """
    # 생략

이렇게 요청 URL 파라미터, JSON 바디, 응답에 대한 스키마를 명시하고 다시 문서를 생성해보면…

openapi: 3.0.2
info:
  title: example-web-server
  version: 0.1.0
components:
  schemas:
    PostHelloRequest:
      type: object
      properties:
        name:
          type: string
          description: 당신의 이름
          example: Thomas
        mood:
          type: integer
          description: 당신의 현재 기분
          example: 5
      required:
        - mood
        - name
    PostHelloResponse:
      type: object
      properties:
        title:
          type: string
        message:
          type: string
      required:
        - message
        - title
paths:
  /hello/:
    get:
      description: 간단한 안녕! 을 받을 수 있는 API
      responses:
        '200':
          description: 간단한 안녕!
          content:
            text/plain: {}
  /secured/hello/:
    get:
      description: 인증과 URL 파라미터가 필요한 안녕! 을 받을 수 있는 API
      parameters:
        - in: query
          name: name
          required: false
          description: '당신의 이름. (기본값: unknown)'
          schema:
            type: string
            default: unknown
            example: Thomas
      responses:
        '200':
          description: 안녕!
          content:
            text/plain: {}
  /post/hello/:
    post:
      description: 인증과 JSON 요청이 필요한 더 자세한 안녕! 을 받을 수 있는 API
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostHelloRequest'
      responses:
        '200':
          description: 더 자세한 안녕!
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostHelloResponse'

이제 정말 훌륭한 문서가 생성되었습니다. 렌더러에서 렌더링해보면 이 정도면 충분한 API 문서라고 부를 수 있는 물건이 되었습니다.

하지만 저는 여기에 만족하지 않고 이것저것 플러그인을 더 만들어 붙여보았습니다.

이것저것 플러그인 붙이기

액세스 토큰 플러그인

이 간단한 웹 서비스의 두 API에서는 액세스 토큰이 필요하다는 사실을 기억하시나요? OAS에서도 이러한 인증 수단을 명시할 수 있는 명세가 존재합니다. 이 명세를 이용해 액세스 토큰을 문서화하고, 나아가 액세스 토큰이 필요한 API 엔드포인트에 자동으로 관련 문서를 붙여봅시다.

먼저 액세스 토큰 플러그인을 다음과 같이 작성합니다.

from apispec import APISpec, BasePlugin


class AccessTokenPlugin(BasePlugin):
    def init_spec(self, spec: APISpec):
        spec.components.security_scheme(
            'access_token',
            {
                'type': 'apiKey',
                'description': '액세스 토큰',
                'name': 'X-Some-Access-Token',
                'in': 'header',
            },
        )

    def path_helper(self, operations: dict, *, view, **kwargs):
        if getattr(view, '__access_token_required', False):
            for operation in operations.values():
                operation.setdefault('security', []).append({
                    'access_token': [],
                })
                operation.setdefault('responses', {}).update({
                    401: {'description': '액세스 토큰이 잘못됨'},
                })

init_spec 메서드는 플러그인이 spec에 처음 등록될 때 호출됩니다. 여기서 OAS의 SecurityScheme 이라는 객체에 access_token 에 대한 문서를 추가합니다.

path_helper 메서드는 spec에 path가 추가될 때마다 호출됩니다. operations 인자가 OAS의 Operation 객체의 모음을 나타내고, 여기에 지금까지 만들어진 문서가 담겨있고, 여기에 내용을 추가하면 결과물에 반영됩니다. 나머지 인자들은 spec.path(...) 를 호출할 때 넘겨준 인자가 들어옵니다. 처음 spec에 path를 추가할 때나 DocumentedBlueprint 에서 추가할 때 view=view_function 이라는 인자로 API 엔드포인트 함수를 넘겨준 것을 기억하시나요? 그 view 인자가 여기로 들어오는 것입니다.

path_helper 메서드에서 만약 이 API 엔드포인트에서 액세스 토큰이 필요하다면, access_token security를 추가하고, 401 응답 문서도 추가합니다.

그런데 어떤 API 엔드포인트가 액세스 토큰이 있어야 하는지 알 수 있을까요? 간단합니다. access_token_required 데코레이터에 다음 내용을 추가합시다.

def access_token_required(f):
    f.__access_token_required = True

    @functools.wraps(f)
    def decorated(*args, **kwargs):
        # 생략

이렇게 하면 AccessTokenPlugin 에서 getattr(view, '__access_token_required', False) 으로 이 엔드포인트가 액세스 토큰을 사용하는지 알 수 있습니다.

이 플러그인을 spec에 등록합니다.

spec = APISpec(
    title='example-web-server',
    version='0.1.0',
    openapi_version='3.0.2',
    plugins=[
        FlaskPlugin(),
        MarshmallowPlugin(),
        AccessTokenPlugin(),
    ],
)

그리고 다시 문서를 생성해보면…

openapi: 3.0.2
info:
  title: example-web-server
  version: 0.1.0
components:
  schemas:
    PostHelloRequest:
      type: object
      properties:
        name:
          type: string
          description: 당신의 이름
          example: Thomas
        mood:
          type: integer
          description: 당신의 현재 기분
          example: 5
      required:
        - mood
        - name
    PostHelloResponse:
      type: object
      properties:
        message:
          type: string
        title:
          type: string
      required:
        - message
        - title
  securitySchemes:
    access_token:
      type: apiKey
      description: 액세스 토큰
      name: X-Some-Access-Token
      in: header
paths:
  /hello/:
    get:
      description: 간단한 안녕! 을 받을 수 있는 API
      responses:
        '200':
          description: 간단한 안녕!
          content:
            text/plain: {}
  /secured/hello/:
    get:
      description: 인증과 URL 파라미터가 필요한 안녕! 을 받을 수 있는 API
      parameters:
        - in: query
          name: name
          required: false
          description: '당신의 이름. (기본값: unknown)'
          schema:
            type: string
            default: unknown
            example: Thomas
      responses:
        '200':
          description: 안녕!
          content:
            text/plain: {}
        '401':
          description: 액세스 토큰이 잘못됨
      security:
        - access_token: []
  /post/hello/:
    post:
      description: 인증과 JSON 요청이 필요한 더 자세한 안녕! 을 받을 수 있는 API
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostHelloRequest'
      responses:
        '200':
          description: 더 자세한 안녕!
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostHelloResponse'
        '401':
          description: 액세스 토큰이 잘못됨
      security:
        - access_token: []

액세스 토큰과 관련한 내용이 추가된 것을 확인할 수 있습니다.

ValidationError 플러그인

marshmallow의 스키마를 사용하는 엔드포인트는 항상 ValidationError가 발생할 수 있습니다. 위에서 register_error_handler 를 통해 400 에러를 발생하게 해놨으니, 이 400 에러가 발생한다는 사실도 자동으로 문서화할 수 있을 것 같습니다.

다음과 같이 플러그인을 작성합니다.

def document_validation_error(f):
    f.__validation_error_documentation_needed = True
    return f


class ValidationErrorPlugin(BasePlugin):
    def path_helper(self, operations: dict, *, view, **kwargs):
        for operation in operations.values():
            responses = response = operation.setdefault('responses', {})
            if getattr(view, '__validation_error_documentation_needed', False):
                response = responses.setdefault(400, {})
                response.setdefault(
                    'description',
                    '요청 검증에 실패함. `invalidFields` 항목에서 검증에 실패한 '
                    '필드와 에러 메시지를 확인할 수 있습니다.',
                )
                response \
                    .setdefault('content', {}) \
                    .setdefault('application/json', {}) \
                    .setdefault('schema', 'ValidationErrorSchema')

액세스 토큰 플러그인과 마찬가지로 데코레이터를 통해 ValidationError가 발생할 수 있다는 것을 지정할 수 있습니다. 엔드포인트 함수에 데코레이터를 붙여줍시다.

@blueprint.route('/secured/hello/')
@document_validation_error
@access_token_required
def get_secured_hello() -> Response:
    # 생략


@blueprint.route('/post/hello/', methods=['POST'])
@document_validation_error
@access_token_required
def post_hello() -> Response:
    # 생략

플러그인을 등록하고…

spec = APISpec(
    title='example-web-server',
    version='0.1.0',
    openapi_version='3.0.2',
    plugins=[
        FlaskPlugin(),
        MarshmallowPlugin(),
        AccessTokenPlugin(),
        ValidationErrorPlugin(),
    ],
)

이제 문서를 생성해보면…

openapi: 3.0.2
info:
  title: example-web-server
  version: 0.1.0
components:
  schemas:
    ValidationError:
      type: object
      properties:
        invalidFields:
          description: 검증에 실패한 필드 이름과 에러 목록
          example:
            someField:
              - some error
    PostHelloRequest:
      type: object
      properties:
        name:
          type: string
          description: 당신의 이름
          example: Thomas
        mood:
          type: integer
          description: 당신의 현재 기분
          example: 5
      required:
        - mood
        - name
    PostHelloResponse:
      type: object
      properties:
        title:
          type: string
        message:
          type: string
      required:
        - message
        - title
  securitySchemes:
    access_token:
      type: apiKey
      description: 액세스 토큰
      name: X-Some-Access-Token
      in: header
paths:
  /hello/:
    get:
      description: 간단한 안녕! 을 받을 수 있는 API
      responses:
        '200':
          description: 간단한 안녕!
          content:
            text/plain: {}
  /secured/hello/:
    get:
      description: 인증과 URL 파라미터가 필요한 안녕! 을 받을 수 있는 API
      parameters:
        - in: query
          name: name
          required: false
          description: '당신의 이름. (기본값: unknown)'
          schema:
            type: string
            default: unknown
            example: Thomas
      responses:
        '200':
          description: 안녕!
          content:
            text/plain: {}
        '400':
          description: 요청 검증에 실패함. `invalidFields` 항목에서 검증에 실패한 필드와 에러 메시지를 확인할 수 있습니다.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '401':
          description: 액세스 토큰이 잘못됨
      security:
        - access_token: []
  /post/hello/:
    post:
      description: 인증과 JSON 요청이 필요한 더 자세한 안녕! 을 받을 수 있는 API
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostHelloRequest'
      responses:
        '200':
          description: 더 자세한 안녕!
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostHelloResponse'
        '400':
          description: 요청 검증에 실패함. `invalidFields` 항목에서 검증에 실패한 필드와 에러 메시지를 확인할 수 있습니다.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '401':
          description: 액세스 토큰이 잘못됨
      security:
        - access_token: []

ValidationErrorSchema가 잘 등록되어있고, 지정한 API에 관련 문서도 붙어있는 것을 볼 수 있습니다.

기본 정보 플러그인

이렇게 파이썬 코드를 이용한 자동화가 쉬운 만큼, API를 서술하는 여러 가지 기본적인 정보들도 생성할 수 있습니다.

먼저 DocumentedBlueprint 클래스의 register 메서드를 살짝 수정합니다.

class DocumentedBlueprint(Blueprint):
    # 생략

    def register(self, app, options, first_registration=False):
        super().register(app, options, first_registration=first_registration)
        for view_function in self.view_functions:
            spec.path(view=view_function, app=app, blueprint_name=self.name)

blueprint_name 이 추가되었습니다. 이걸 이용해 API를 blueprint 별로 자동으로 분류할 수 있습니다.

다음과 같이 플러그인을 작성합니다.

class BasicInfoPlugin(BasePlugin):
    def path_helper(
        self,
        operations: dict,
        *,
        view: typing.Callable,
        path: str,
        blueprint_name: str,
        **kwargs,
    ):
        for operation in operations.values():
            operation_id = camelcase(view.__name__)
            summary = path + ' ' + operation_id
            operation.setdefault('operationId', operation_id)
            operation.setdefault('summary', summary)
            operation.setdefault('tags', []).append(blueprint_name)

이 플러그인은 엔드포인트 함수, 블루프린트 이름 등의 정보에서 operation_id, summary, tags 를 생성합니다.

operationId 는 각 API 엔드포인트를 유니크하게 구분하는 아이디입니다. 다른 부분에서 이 아이디를 활용하는 것도 가능합니다. 자세한 것은 OpenAPI 명세를 참고해주세요.

summary 는 엔드포인트를 한 줄 정도로 나타내는 요약입니다. 자연어인 것도 좋지만, 여기에서는 엔드포인트 함수의 이름과 URL 경로를 이용해 생성해보았습니다.

tags 로는 엔드포인트를 분류할 수 있습니다. 여기서는 블루프린트의 이름을 이용했습니다.

이 플러그인도 spec에 등록합니다.

spec = APISpec(
    title='example-web-server',
    version='0.1.0',
    openapi_version='3.0.2',
    plugins=[
        FlaskPlugin(),
        MarshmallowPlugin(),
        AccessTokenPlugin(),
        ValidationErrorPlugin(),
        BasicInfoPlugin(),
    ],
)

문서를 생성해봅시다.

openapi: 3.0.2
info:
  title: example-web-server
  version: 0.1.0
components:
  schemas:
    ValidationError:
      type: object
      properties:
        invalidFields:
          description: 검증에 실패한 필드 이름과 에러 목록
          example:
            someField:
              - some error
    PostHelloRequest:
      type: object
      properties:
        mood:
          type: integer
          description: 당신의 현재 기분
          example: 5
        name:
          type: string
          description: 당신의 이름
          example: Thomas
      required:
        - mood
        - name
    PostHelloResponse:
      type: object
      properties:
        title:
          type: string
        message:
          type: string
      required:
        - message
        - title
  securitySchemes:
    access_token:
      type: apiKey
      description: 액세스 토큰
      name: X-Some-Access-Token
      in: header
paths:
  /hello/:
    get:
      description: 간단한 안녕! 을 받을 수 있는 API
      responses:
        '200':
          description: 간단한 안녕!
          content:
            text/plain: {}
      operationId: getHello
      summary: /hello/ getHello
      tags:
        - api
  /secured/hello/:
    get:
      description: 인증과 URL 파라미터가 필요한 안녕! 을 받을 수 있는 API
      parameters:
        - in: query
          name: name
          required: false
          description: '당신의 이름. (기본값: unknown)'
          schema:
            type: string
            default: unknown
            example: Thomas
      responses:
        '200':
          description: 안녕!
          content:
            text/plain: {}
        '400':
          description: 요청 검증에 실패함. `invalidFields` 항목에서 검증에 실패한 필드와 에러 메시지를 확인할 수 있습니다.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '401':
          description: 액세스 토큰이 잘못됨
      security:
        - access_token: []
      operationId: getSecuredHello
      summary: /secured/hello/ getSecuredHello
      tags:
        - api
  /post/hello/:
    post:
      description: 인증과 JSON 요청이 필요한 더 자세한 안녕! 을 받을 수 있는 API
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostHelloRequest'
      responses:
        '200':
          description: 더 자세한 안녕!
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostHelloResponse'
        '400':
          description: 요청 검증에 실패함. `invalidFields` 항목에서 검증에 실패한 필드와 에러 메시지를 확인할 수 있습니다.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '401':
          description: 액세스 토큰이 잘못됨
      security:
        - access_token: []
      operationId: postHello
      summary: /post/hello/ postHello
      tags:
        - api

각 API에 operationId, summary, tags가 생성된 것을 볼 수 있습니다. BasicInfoPlugin 은 이런 것이 가능하다 정도로 봐주시면 됩니다. summary나 tags 같은 경우 다른 방법으로 생성하는 게 더 실용적일 수도 있겠죠.

이렇게 이 글 맨 위에서 소개한 예제 문서와 동일한 문서가 생성되었습니다. 여기서 멈추지 않고 문서에 당장 반영되지는 않는 플러그인을 몇 가지 더 추가해봅시다.

문서 강제 플러그인

API 엔드포인트가 늘어나다 보면 문서를 작성하는 것을 깜빡할 수 있습니다. 특히 apispec은 API 엔드포인트 함수에 문서를 어느 정도 작성해야 문서가 생성되기 때문에 빠뜨리면 문서가 불완전해집니다. 그래서 테스트 도중에 문서가 빠진 엔드포인트가 있으면 예외를 발생시키는 플러그인을 작성할 수 있습니다.

다음과 같이 플러그인을 작성합니다.

_NO_DOCUMENT_ERROR_ENABLED = False


class NoDocumentErrorPlugin(BasePlugin):
    def path_helper(self, operations: dict, *, view, path, **kwargs):
        if _NO_DOCUMENT_ERROR_ENABLED and len(operations) == 0:
            raise Exception(f'No API documentation found for path `{path}`')

path_helper 가 호출되었다는 건 spec.path, 즉 블루프린트에 엔드포인트가 등록되었다는 것입니다. 그런데 operations 가 비어있다는 건 아무런 문서를 작성하지 않았다는 뜻입니다. ‘API 엔드포인트 추가’ 문단의 초기와 같은 상태라고 할 수 있죠. 이런 엔드포인트가 존재하면 Exception을 발생시킵니다.

이 동작은 테스트 도중에만 실행되어도 괜찮을 거로 생각했습니다. 그래서 _NO_DOCUMENT_ERROR_ENABLED 라는 플래그를 추가하고 이게 테스트 도중에만 활성화되도록 합니다.

tests/conftest.py 파일에 다음과 같이 추가합니다.

from example_web_server import spec


def pytest_configure():
    spec._NO_DOCUMENT_ERROR_ENABLED = True

그리고 당연히 spec에 등록합니다.

spec = APISpec(
    title='example-web-server',
    version='0.1.0',
    openapi_version='3.0.2',
    plugins=[
        FlaskPlugin(),
        MarshmallowPlugin(),
        AccessTokenPlugin(),
        ValidationErrorPlugin(),
        BasicInfoPlugin(),
        NoDocumentErrorPlugin(),
    ],
)

이제 문서가 없는 엔드포인트를 방지할 수 있습니다.

물론 이렇게 하면 pytest를 사용할 때에만 동작합니다. 다른 테스트 프레임워크를 사용하신다면 해당 프레임워크에 맞게 수정해주세요.

응답 검증 스키마

apispec 플러그인은 아니지만, marshmallow 쪽에도 의미 있는 플러그인을 추가할 수 있습니다.

marshmallow는 기본적으로 dump 할 땐 validation을 수행하지 않습니다. 매번 응답 직전에 검증을 수행하는 것은 성능에도 영향을 미칩니다. 그래서 저는 NoDocumentErrorPlugin 과 마찬가지로 테스트 도중에는 응답을 항상 검증하게 하는 스키마를 만들어보았습니다.

다음과 같이 스키마를 작성합니다.

_VALIDATION_ON_DUMP_ENABLED = False


class ValidateOnDumpSchema(Schema):
    def dump(self, obj: typing.Any, *, many: typing.Optional[bool] = None):
        dumped = super().dump(obj, many=many)
        if _VALIDATION_ON_DUMP_ENABLED:
            errors = self.validate(dumped, many=many)
            if errors:
                raise Exception(errors)
        return dumped

BaseSchema 를 업데이트합니다.

class BaseSchema(CamelCaseSchema, ValidateOnDumpSchema):
    pass

conftest.py 도 업데이트합니다.

from example_web_server import schema, spec


def pytest_configure():
    schema._VALIDATION_ON_DUMP_ENABLED = True
    spec._NO_DOCUMENT_ERROR_ENABLED = True

이제 테스트 도중에는 응답도 작성한 스키마와 일치하는지 검증하게 됩니다.

렌더링

이렇게 작성한 YAML 파일은 위에서 언급한 editor.swagger.io 를 통해서 렌더링할 수 있지만, 남들에게 전달하기에는 조금 불편합니다. 저는 redoc 을 이용해 렌더링하고 공유했습니다. YAML 파일과 redoc-cli 가 있으면 하나의 HTML 파일로 렌더링이 가능합니다.

yarn global add redoc-cli
./build_apispec.py > spec.yaml
redoc-cli bundle spec.yaml -o index.html

이렇게 렌더링한 문서는 Github Pages spoqa/flask-marshmallow-apispec-example-web-server 에서 확인하실 수 있습니다.

문제점?

marshmallow + apispec 조합을 사용한 지 그렇게 오랜 시간이 지난 것이 아니라서 당장 큰 문제점을 발견하지는 못했습니다.

한가지 문제점이라면, spec.to_yaml() 을 통해 빌드하면 모든 유니코드가 escape된 형태로 출력된다는 것입니다. json.dumps(spec.to_dict(), ensure_ascii=False) 를 통해 JSON으로 출력하면 같은 문제가 발생하지 않습니다.

yaml.dump 를 할 때도 allow_unicode=True 옵션을 주면 되는데, 현재 apispecspec.to_yaml 메소드에서 yaml.dump 함수의 옵션을 조정할 방법이 없습니다. 이것을 가능하게 하려고 apispec에 PR을 하나 올려두었습니다. marshmallow-code/apispec #648

물론 이 문제는 PyYAML 을 직접 사용하면 회피할 수 있긴 합니다.

from apispec.yaml_utils import YAMLDumper
from yaml import dump as yaml_dump


print(yaml.dump(spec.to_dict(), Dumper=YAMLDumper, allow_unicode=True))

결론

marshmallow를 통해 요청/응답의 형태를 미리 정의하고, 검증할 수 있습니다. 이 스키마를 apispec을 통해 OpenAPI 명세 문서 생성이 가능하기 때문에, 요청 검증과 문서화라는 두 마리 토끼를 다 잡을 수 있게 되었습니다.

OpenAPI 명세가 널리 사용되어 자동화 도구가 더욱더 발전하여 퀄리티 높은 문서를 간편하게 작성할 수 있게 되면 좋겠습니다.

제가 제시한 marshmallow + apispec 조합의 더 나은 사용법을 알고 계신다면 Github spoqa/flask-marshmallow-apispec-example-web-server 리포에 언제든지 풀 리퀘스트를 날려주세요.

긴 글 읽어주셔서 감사합니다.

스포카에서는 “식자재 시장을 디지털화한다” 라는 슬로건 아래, 매장과 식자재 유통사에 도움되는 여러 솔루션들을 개발하고 있습니다.
더 나은 제품으로 세상을 바꾸는 성장의 과정에 동참 하실 분들은 채용 정보 페이지를 확인해주세요!