Laravel 프로젝트에서 OpenAPI(Swagger) 사용하기

올해 초 라라벨 API 프로젝트 작업할 때, SDK 코드제너레이션 까지는 아니더라도, 적어도 문서화를 위하여 Swagger 사용을 고려했었다. 구글에서 laravel swagger 라는 키워드로 검색후 발견한 L5-Swagger 패키지를 받았었는데, 그 부실한 문서와 (물론, 그 패키지가 래핑하고 있는 swagger-php 패키지의 문서도 그리 좋은 편이 아니어서) 장황하고 지저분한 annotation block 를 보고난 다음, 더우기 annotation 에서, 배열을 위한 square bracket 사용이 불가하다는 내용을 보니, 라라벨과 Swagger 콤보의 조합은 별로 어울리지 않는다는 결론과 함께 충만했던 호기심이 단번에 사라지고 말았다. 결국, API 문서화를 위해서는 API Blueprint 나, Slate, apiDoc 등의 alternative 들을 사용하는 바가 낫겠다는 생각 이었는데… 세월은 흘러, 어느덧 2018 라라콘 비디오가 유튜브에 올라오는 시기가 되었으니… 두둥… TJ Miller 가 이번 라라콘에서 API 에 대하여 발표한 이 20180726 Laracon 2018 TJ Miller 프리젠테이션 을 보면, 다양한 API 핸즈온 경험들을 커버하는데, 여기에, OpenAPI 와 JSON Schema 에 대한 설명과, 그가 행하는 practice 를 이야기하는 부분이 나온다. 위의 그림에서처럼 기정의한 JSON Schema 를 레퍼런스 할 수 있다는 것과, 각각의 스키마들을 /schemas 폴더에 정리한 설명을 들어보니, 어라, 저런식으로 라라벨 프로젝트에서 Swagger(OpenAPI)를 사용하면 되겠구나 하는 아이디어를 얻게 되었다. 뿐만아니라, Speccy 프로젝트를 설명하면서 언급했던 Phil Sturgeon의 블로그를 보면, OpenAPI 와 관련된 더 많은 내용들을 읽어 볼 수도 있었다. 이 포스팅에서는 그 후, 회사 API 프로젝트에 Swagger 를 사용한 경험을 정리해 보기로 한다. 잠깐, Swagger(OpenAPI) 가 뭔데? 아직 마차에 타지 못한 사람들을 위해 짧은 설명을 첨부하자면, OpenAPI 는 JSON Schema 에 기반한 일종의 extension 으로, API 문서화 자동 생성, API 에 대한 다양한 언어를 위한 SDK 코드 자동 생성, Postman API 컬랙션 자동 생성 등의 다양한 API 개발 헬퍼 기능과 API 문서 자동생성 툴을 제공하는 소프트웨어 이다. OpenAPI 스펙은 .json 포맷 이나 .yaml 포맷으로 작성할 수 있는데, PHP 프로젝트에서 주석문 부분에 Doctrine annotation 을 추가하여, 해당 프로젝트용 OpenAPI 스펙을 자동으로 만들어주는 툴이 바로, swagger-php 이다. (참고로, OpenAPI v3 스펙 이전에는 OpenAPI 가 Swagger 로 불리였지만, v3 부터는 OpenAPI 로 이름을 바꿔었으므로, 이 둘을 interchangable 하게 불러도 헷갈려 하지 마시길.) Swagger v2 스펙은 JSON Schema draft v4 를 기반으로 하고, OpenAPI v3 스펙은 JSON Schema draft v5 를 기반하여 정의되었는데, 사실 OpenAPI 와 JSON Schema 는 약간 다른 목적을 두고, 독자적 body 로부터 독자 진화중이라서 그 둘 간 상이한 차이점이 존재하므로, 서로 다른 버젼 및 스펙간의 상호 컨버팅을 할 수 있도록 도와주는 프로젝트가 위에서 언급된 Speccy 이다. 어쨌든, 그럼 JSON Schema 는 도대체 뭐지? JSON Schema 는 JSON 을 위한 메타 데이터를 정의하기 위한 스펙으로 그 목적은 아래와 같이 정리할 수 있다.
  • API 의 데이터 포맷을 설명하는 메타 정보
  • 사람과 기계가 모두 clear 하게 이해할 수 있는 정보
  • JSON 구조의 validation
  • 클라이언트가 보내는 데이터 validation 가능 (*)
JSON Schema 의 재밌는 내용 중 하나인 클라이언트가 보내는 데이터 validation 을 살펴보면 JSON Schema 의 목적들 중 하나를 좀 더 잘 이해할 수 있으리라 본다. 아래는 user 를 정의하는 JSON Schema 파일의 예 이다.
{
  "$id": "http://example.com/schemas/user.json",
  "type": "object",
  "definitions": {},
  "$schema": "http://json-schema.org/draft-07/schema#",
  "properties": {
    "name": {
      "title": "Name",
      "type": "string",
      "description": "Users full name supporting unicode but no emojis.",
      "maxLength": 20
    },
    "email": {
      "title": "Email",
      "description": "Like a postal address but for computers.",
      "type": "string",
      "format": "email"
    },
    "date_of_birth": {
      "title": "Date Of Birth",
      "type": "string",
      "description": "Date of uses birth in the one and only date standard: ISO 8601.",
      "format": "date",
      "example": "1990–12–28"
    }
  },
  "required": [
    "name"
  ]
}
가령 위와 같은 user 스키마가 있다고 가정하면, 사용자가 registration form 을 작성할 때, 서버에 요청후 응답을 기다리는 것이 아니라,
{
  "name": "Lucrezia Nethersole",
  "email":"[email protected]",
  "date_of_birth": "2007–01–23"
}
해당 client 가 아래와 같은 header 를 통해서 user.json 의 JSON Schema 를 다운받은 다음, 굳이 서버에 request 를 보낸 뒤 서버가 validation 을 한 뒤 보내오는 422 에러를 리턴받지 않아도, 사용자 input 값에 대한 validation 수행이 가능하다. 따라서, 전체 form 입력값으로 기대하는 user 인풋값의 valid 한 형태를 client 가 미리 알 수 있기 때문에 불필요한 요청이 발생하지 않아도 되는 flow 가 만들어 질 수 있다.
Link: <http://example.com/schemas/user.json#>; rel="describedby"
OpenAPI 는 JSON Schema 의 이러한 semantic 유효성에 대한 정의 보다, 자동 documenation 생성, mocking proxy 생성, Postman collection 생성, SDK 코드 자동 생성 등에 포커스를 두는 목적을 갖고있다고 이해하면 된다. 여기까지의 설명으로, 그 배경과 차이점을 알았으리라 판단되므로, 이제부터는 회사 프로젝트에 OpenAPI 를 적용한 내용을 살펴보기로 하자. (참고로, 현재 라라벨 프로젝트는 v5.6 을 사용하고 있다.)

사용한 PHP 패키지

오래 전에도, 이 패키지를 본적이 있었는데, 그때만 하더라도 v2 스펙을 위한 @SWG\XXX annotation 들만 존재했었고 v3 스펙을 위한 @OA\XXX annotation 들이 존재하지 않았다. 뿐만아니라, 문서에는 모든 annotation 들이 어떻게 매칭되어 .json 이나 .yaml 파일로 생성되는지도 언급이 없었기에, 해당 패키지가 제공하는 Examples 폴더의 내용을 들여다 볼 수 밖에 없었는데, 이것이 이 패키지의 완성도가 상당히 낮다고 느껴지게 만드는 turn off 요소로 작용했었다. 지금 다시 보니, 많은 내용들을 그 예시들로 이해 할 수 있게 제공되고 있지만, 어쨌든, 아직도 문서가 좀 더 보강되었으면 하는 바램은 여전하다. 불행히도, TJ Miller 의 프리젠테이션으로부터 gotcha moment 을 갖기 전까지, (사실 면밀히 말해서, swagger-php 예제나, Swagger 레퍼런스를 제대로 읽어봤다면 기정의 스펙의 reference 기능을 알 수 있었겠지만), 주석문에 덕지덕지 떡칠된 annotation 에 대한 부담을 없앨 수 있는 대안이 있다는 사실을 알 지 못했었다. 참고로, swagger-php 패키지가 아직은 완벽하지 않은 듯 하여, yaml 파일을 생성하게 되면, Doctrine annotation 중 배열의 [] 을 위한 {} 컨버팅이 아직 완벽하지 않다. 일례로 아래의 annotation 은
     *    security={
     *        {"bearerAuth":{}}
     *    },
.json 형태의 output 생성시, 아래와 같이 정상적으로 생성되는 것에 반해
        "security": [
                {
                        "bearerAuth": []
                }
        ]
.yaml 형태로 output 생성시에는 아래처럼
      security:
        -
          bearerAuth: {  }
출력되고 마는 것을 미루어 보아 말이다. 따라서, yaml 파일 생성할 때, []{ } 로 출력되는 오류를 피하기 위한 workaround 로 sed 명령을 사용하여, 아래와 같이 어리버리한 { } 를 모두 [] 로 변경해주는 편법을 사용해야 한다.
./vendor/bin/openapi ./app | sed -E "s/(\{[ ]+\})/\[\]/" > openapi.yaml
그럼에도 불구하고, PHP 프로젝트 내의 주석문에다가 약간의 annotation 을 추가함으로써, API 문서를 위한 single source of truth 를 갖게 되고, 이를 기반으로 OpenAPI 스펙 생성이 가능하다는 점은 매우 매력적인 장점이라 할 수 있겠다.

컨트롤러에 추가한 annotations 들

그럼, 이번 API 프로젝트 내에서, 추가한 주요 annotations 들을 살펴보자. 일단, 모든 프로젝트에서 필수적으로 요구되는 info 메타 데이터부를 추가해야한다.
  • info 메타 데이터
    /**
     * @OA\Info(
     *     title="Search API",
     *     version="global-v2",
     *     x={
     *         "logo": {
     *             "url": "https://examples.com/logo.jpg",
     *             "altText": "Company Logo"
     *
     *        }
     *     }
     * )
     * @OA\Server(
     *     url="http://search.test",
     *     description="local"
     * )
     * @OA\SecurityScheme(
     *     type="http",
     *     scheme="bearer",
     *     in="header",
     *     securityScheme="bearerAuth"
     * )
     */
    public function __invoke()
    {
        return response()->json([
                    :
        ]);
    }
  • 각각의 API end-point 별 컨트롤러 주석부에 추가한 annotations
info 메타 데이터가 추가 되었다면, API 각각의 end-point 별로, 아래와 같은 형태의 annotation 을 코드내에 추가하여, swagger-php 가 픽업할 수 있도록 한다. 결국 이 annotation 들이, 하나의 API end-point 에 대한 상응하는 OpenAPI 스펙으로 변환된다. 아래의 예에서 보는 것과 같이, 기정의 스키마들은 response 를 위한 3개의 .json 파일과, 1 개의 dto 를 위한 .json 파일을 지정하여, annotation hell 을 막을 수 있었고, 이런 식으로 적용하여, 보다 깔끔한 스펙작성과 배열 [] 이 {} 로 컨버팅되는 문제를 막을 수 있었다. (참고로, 이 예제에서 보이는 application 에서는 Elasticsearch 을 사용하여 검색을 수행하고, 그때 필요한 검색조건을 DTO object 로 전달하는 내용이 있기 때문에, 모델 정의 .json 을 지정하지 않고, 필요한 검색 DTO 정의 .json 스펙을 사용했다.)
    /**
     * @OA\Post(
     *     path="/api/v2/type",
     *     tags={"Match"},
     *     summary="맞춤소개",
     *     description="설명부분 어쩌구 저쩌구",
     *     security={
     *         {"bearerAuth": {}}
     *     },
     *     operationId="matchType",
     *     @OA\Response(response=200,
     *         ref="schemas/responses/match-type.json"
     *     ),
     *     @OA\Response(response="401",
     *         ref="schemas/responses/error-auth-invalid.json"
     *     ),
     *     @OA\Response(response="422",
     *         ref="schemas/responses/error-unprocessable-entity.json"
     *     ),
     *     @OA\RequestBody(
     *         required=true,
     *         @OA\MediaType(
     *             mediaType="application/json",
     *             @OA\Schema(
     *                 @OA\Property(
     *                     description="MatchDto",
     *                     property="match_dto",
     *                     ref="schemas/match-dto.json"
     *                 ),
     *                 @OA\Property(
     *                     description="맞춤소개 타입",
     *                     property="match_type",
     *                     type="string",
     *                     enum={
     *                         "ugly",
     *                         "good-looking"
     *                     }
     *                 ),
     *                 type="object",
     *             )
     *         )
     *     )
     * )
     */
    public function match(MatchTypeRequest $request, Responder $responder)
    {
        :
    }
  • 기정의된 schemas\responses\match-type.json 의 내용
{
  "description": "성공 응답",
  "content": {
    "application/json": {
      "schema": {
        "properties": {
          "status": {
            "type": "integer"
          },
          "success": {
            "type": "boolean"
          },
          "data": {
            "type": "object"
          }
        },
        "type": "object",
        "example": {
          "status": 200,
          "success": true,
          "data": {
            "list": {
              "1100011": "CustomType",
              "1200021": "CustomType",
            },
            "updated_at": "2018-10-01 09:00:00"
          }
        }
      }
    }
  }
}
  • 기정의된 schemas\responses\error-auth-invalid.json 의 내용
{
  "description": "실패 응답 (인증실패)",
  "content": {
    "application/json": {
      "schema": {
        "properties": {
          "status": {
            "type": "integer"
          },
          "success": {
            "type": "boolean"
          },
          "error": {
            "type": "object"
          }
        },
        "type": "object",
        "example": {
          "status": 401,
          "success": false,
          "error": {
            "code": "auth_invalid",
            "message": "Unauthorized request."
          }
        }
      }
    }
  }
}
  • 기정의된 schemas\responses\error-unprocessable-entity.json 의 내용
{
  "description": "실패 응답 (처리불가)",
  "content": {
    "application/json": {
      "schema": {
        "properties": {
          "status": {
            "type": "integer"
          },
          "success": {
            "type": "boolean"
          },
          "error": {
            "type": "object"
          }
        },
        "type": "object",
        "example": {
          "status": 422,
          "success": false,
          "error": {
            "code": "unprocessable_entity",
            "message": "insufficient search result"
          }
        }
      }
    }
  }
}
  • 기정의된 schema\match-dto.json 의 내용 (상세 구현 내용은 중략)
{
  "properties": {
    "id": {
      "description": "아이디",
      "format": "int64",
      "type": "integer"
    },
    "age": {
      "description": "나이",
      "format": "int32",
      "type": "integer"
    },
    "timezone": {
      "description": "타임존",
      "type": "string",
      "nullable": true
    },
    "gender": {
      "description": "성별",
      "type": "string"
    },
    "gender_preference": {
      "description": "선호성별 리스트",
      "type": "array",
      "items": {
        "type": "string"
      }
    },
        :
    "priorities": {
      "description": "우선순위 리스트",
      "type": "array",
      "items": {
        "type": "string"
      }
    }
  },
  "required": [
    "id",
    "age",
        :
  ],
  "type": "object",
  "example": {
    "id": 1155,
    "age": 32,
    "timezone": "Asia/Seoul",
    "gender": "M",
    "gender_preference": [ "F" ],
        :
    "priorities": []
  }
}
위 와 같은 annotation 들을 API end-point 를 담당하는 controller 마다 추가하였다면, 문서를 생성할 준비가 끝나게 된다.

ReDoc-cli 을 사용한 문서 생성

ReDoc 은 yaml OpenAPI 스펙을 읽어서, 깔끔한 HTML 문서를 생성시켜주는 npm 프로젝트이며, 아래의 명령으로 redoc-cli 를 전역 설치한다.
npm i -g redoc-cli
위에서도 잠시 언급했지만, .yaml 파일을 생성할때는, swagger-php 를 이용하여, Laravel 프로젝트 root 에서, /app 폴더 하위에 기록된 모든 annotation 들을 파싱해서 OpenAPI 스펙인 openapi.yaml 파일을 만들도록 ./vendor/bin/openapi --output openapi.yaml ./app 이라는 명령을 주면 되지만, 배열 [] 이 {} 로 출력되는 오류로 인해, 아래처럼 workaround 를 사용했다.
./vendor/bin/openapi ./app | sed -E "s/(\{[ ]+\})/\[\]/" > openapi.yaml
openapi.yaml 파일이 생성된 후에는, redoc-cli 를 이용해서, 아래 명령으로 문서를 생성하고, 확인할 수 있다.
redoc-cli serve openapi.yaml --watch
위에서 처럼, –watch 플랙을 지정하면 openapi.yaml 파일이 바뀔 때마다, 다시 랜더링이 된다. 그런 다음 브라우저를 열고 localhost:8080 에 접속하면, 아래와 같이 예쁜 문서를 볼 수 있다.

Swagger UI 사용법

OpenAPI 스펙이 만들어졌다면, 이번엔 전통적인 Swagger-UI 프로젝트를 통해서도, 문서화된 내용을 만들어 볼 수 있는데, 여기서는 .json 로 생성한 스펙파일을 사용해보겠다. 우선 swagger-php cli 명령으로, 아래와 같이 openapi.json 스펙을 생성한다.
# json 파일 생성
./vendor/bin/openapi --output openapi.json ./app
이젠 docker 를 사용하여, swagger-ui 를 실행할 수 있는데, 다만, 아래의 ~/Code/search 의 경우, 현재 사용하는 PC 내의 로컬 폴더 path 이므로, 자신의 폴더 path 로 변경이 필요하겠다.
docker run -p 8081:8080 \
           -e SWAGGER_JSON=/search/openapi.json \
           -v ~/Code/search:/search \
           -v ~/Code/search/schemas:/usr/share/nginx/html/schemas \
           swaggerapi/swagger-ui
그런 다음 브라우저를 열고, localhost:8081 접속하면, Postman 처럼 실제로 Curl 호출을 해볼 수 있는 인터페이스까지 포함된, 문서를 얻을 수 있다. 이번에, 사내 스터디에서 발표할 자료를 만들면서 정리한 블로그 포스팅은 여기까지 이며, 비슷한 고민을 하던, php 코더들에게 도움이 되었으면 하는 바램이다. Happy Coding!

Leave a Reply