프로젝트 Project

API 명세서 - Swagger + Rest Docs

달래dallae 2024. 8. 6. 16:05

1️⃣ Swagger와 Rest Docs의 특징

Rest API를 개발할 때, 흔히 Swagger 또는 Rest docs를 사용해서 API 명세서를 편리하게 작성합니다.

두 방법은 각각 명확한 장단점을 가지고 있습니다.

Swagger

  • Controller와 Dto에 어노테이션 코드를 작성해서 API 명세서를 작성합니다. API 명세서를 위한 코드와 어플리케이션 코드가 함께 혼합되어있어 가독성이 떨어질 수 있습니다.
  • API를 호출할 수 있는 웹 기반 UI가 제공됩니다.

Rest Docs

  • 컨트롤러 테스트 코드를 작성해 API 명세서를 작성합니다. 자동으로 만들어진 adoc 문서 외에 구조나 목차등은 수동으로 작성해야 합니다.
  • 정적인 문서만 생성하기 때문에 swagger 처럼 API를 호출할 수 있는 웹 기반 UI는 제공되지 않습니다.

이러한 두 방식의 장점을 살려 혼합하여 사용할 수 있는 방법이 있습니다.

Swagger의 openapi 라이브러리는 openapi3(json, yaml 파일) 로 변환시켜 swagger-ui는 이를 기반으로 ui를 구성합니다.

따라서 이 방법은 rest docs의 컨트롤러 테스트 코드를 바탕으로 openapi3 파일을 구성해, swagger ui가 이를 바탕으로 웹 ui를 만들 수 있게 합니다.

 


2️⃣ 구현

✔️설정

buildscript {
	ext {
		restdocsApiSpecVersion = '0.18.2'
	}
}

plugins {
	id 'com.epages.restdocs-api-spec' version "${restdocsApiSpecVersion}"
}

dependencies {
	// Swagger + REST Docs
	implementation 'org.springdoc:springdoc-openapi-ui:1.6.14'
	testImplementation "com.epages:restdocs-api-spec-mockmvc:${restdocsApiSpecVersion}"
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
	swaggerUI 'org.webjars:swagger-ui:4.11.1'
}

clean {
	delete file("static/docs")
}

openapi3 {
	servers = [
			{url = "<http://ip>:port"},
			{url = "<http://localhost:8080>"}
	]
	title = "명세서 이름"
	description = "Rest docs with Swagger UI"
	version = "0.0.1"
	format = "yaml"
}

swaggerSources {
	sample {
		setInputFile(file("${project.buildDir}/api-spec/openapi3.yaml"))
	}
}

tasks.withType(GenerateSwaggerUI) {
	dependsOn 'openapi3'
	doFirst {
			def swaggerUIFile = file("${openapi3.outputDirectory}/openapi3.yaml")
		
			def securitySchemesContent =  "  securitySchemes:\\n" +  \\
		                                    "    APIKey:\\n" +  \\
		                                    "      type: apiKey\\n" +  \\
		                                    "      name: Authorization\\n" +  \\
		                                    "      in: header\\n" + \\
		                                    "security:\\n" +
					  						"  - APIKey: []  # Apply the security scheme here"
		
			swaggerUIFile.append securitySchemesContent
	}
}

tasks.register("copySwaggerUISample") {
		doLast {
				copy {
					from("${generateSwaggerUISample.outputDir}")
					into 'static/docs'
				}
		}
}

bootJar {
		dependsOn generateSwaggerUISample
		finalizedBy copySwaggerUISample
}
  1. openapi3: openapi3 yaml파일에 대한 설정입니다.
  2. plugins: epages의 플러그인을 추가합니다.
  3. dependencies: rest docs와 swagger ui 의존성을 추가합니다.
  4. swagger sources: rest docs로 설정한 테스트 실행 후 만들어진 openapi3 yaml 파일을 어디에 저장할 것인지 설정합니다.
  5. tasks.withType(generateSwaggerUI): openapi3 설정을 토대로 swagger UI 파일을 생성합니다. swaggerUI 파일을 생성하기 전, openapi3.yaml파일에 request header에 Authorization code를 입력하기 위한 설정을 추가해줍니다. Swagger UI에서는 따로 header값을 조작하지 못하기 때문에 필요한 설정입니다.
  6. tasks.register("copySwaggerUISample"): Swagger UI 파일을 설정한 파일로 copy합니다.
  7. bootJar: bootJar 파일을 빌드할 때 swagger ui 파일을 함께 빌드합니다.

✔️코드 구현

위와 같이 기본적인 설정을하고 나면, 실제로 컨트롤러 테스트 코드를 rest docs에서 요구하는 규격(adoc)에 맞게 작성해야 합니다.

일반적 패턴인 mockito를 사용해서 주입하고, given, when, then 패턴을 사용하여 테스트코드를 작성합니다.

 

DocsControllerTest (추상클래스)

@WebMvcTest(value = {컨트롤러.class, 컨트롤러.class...})
@AutoConfigureRestDocs
@AutoConfigureMockMvc
public abstract class DocsControllerTest {
    @Autowired
    protected WebApplicationContext ctx;
    
    protected MockMvc mockMvc;
    
    @MockBean
    protected 서비스 서비스;...
    
    @BeforeAll
    static void setUpAuth() {
        MEMBER_BEARER_HEADER = "BEARER " + BearerAuthHelper.generateToken(MEMBER.getId());
    }

    @BeforeEach
    void setUp(final RestDocumentationContextProvider restDocumentation) {
        mockMvc = MockMvcBuilders.webAppContextSetup(ctx)
                .apply(documentationConfiguration(restDocumentation))
                .addFilters(new CharacterEncodingFilter("UTF-8", true))
                .alwaysDo(print())
                .build();
    }
}

컨텍스트 생성 횟수를 최소화하기 위해 테스트에서 추상클래스로 구현합니다. 글로벌하게 사용되는 설정들을 미리 세팅해주면 됩니다.

 

Controller 테스트 코드 예시

public class BoardFindApiDocsControllerTest extends DocsControllerTest {
    @Test
    void 게시판_조회_요청() throws Exception {

        // given
        final List<Board> boards = List.of(BOARD1_CAT1_MEM1, BOARD2_CAT1_MEM2, BOARD3_CAT1_MEM3);
        final BoardSearchRequest boardSearchRequest = BoardSearchRequest.of(0, "subject", "999");
        final Pageable pageable = PageRequest.of(boardSearchRequest.pageNo(), boardSearchRequest.pageSize(), Sort.by(boardSearchRequest.sortBy()).descending());

        when(boardRepository.searchBoards(pageable, boardSearchRequest.searchCondition(), boardSearchRequest.searchKeyword()))
                .thenReturn(new PageImpl<>(boards));
        when(boardFindService.findAllBoards(BoardSearchDto.from(boardSearchRequest)))
                .thenReturn(boards.stream().map(BoardFindResultDto::from).toList());

        // when, then
        mockMvc.perform(RestDocumentationRequestBuilders
                        .get("/api/boards")
                        .queryParam("page-no", "0")
                        .queryParam("search", "subject")
                        .queryParam("keyword", "999")
                )
                .andDo(document("boards/find-boards",
                        preprocessRequest(prettyPrint()),
                        preprocessResponse(prettyPrint()),
                        resource(
                                ResourceSnippetParameters.builder()
                                        .tag("게시판 API")
                                        .description("모든 게시글 페이징 조회")
                                        .queryParameters(
                                                ResourceDocumentation.parameterWithName("page-no").description("페이지 번호"),
                                                ResourceDocumentation.parameterWithName("search").description("검색 조건"),
                                                ResourceDocumentation.parameterWithName("keyword").description("검색어"))
                                        .responseFields(
                                                fieldWithPath("boards[].subject").description("게시글 제목"),
                                                fieldWithPath("boards[].content").description("게시글 내용"),
                                                fieldWithPath("boards[].category").description("카테고리 이름"),
                                                fieldWithPath("boards[].tagNames").description("태그이름"),
                                                fieldWithPath("boards[].readCount").description("조회수"),
                                                fieldWithPath("boards[].likesCount").description("좋아요 개수")
                                        )
                                        .responseSchema(Schema.schema("BoardsFindResponse"))
                                        .build()
                        )))
                .andExpect(status().isOk());
    }
    ...
 }

 


3️⃣ 생성된 리소스 파일 확인

이런식으로 컨트롤러 테스트 코드를 모두 작성한 뒤, gradle을 build하거나 openapi3 또는 generateSwaggerUISample을 빌드하면 만들어진 파일을 확인할 수 있습니다.

 


4️⃣ 번외 : 배포 시 서버 분리

어플리케이션 서버(spring)와 swagger 서버는 분리해서 관리하는 것이 바람직한 형태라고 생각되는데요,

이때 docker를 사용하면 swagger ui 이미지를 사용하여 쉽게 빌드할 수 있습니다.

swagger 서버에서 도커 컨테이너를 실행할 때, 어플리케이션의 api명세서인 openapi3.yaml 파일에 대한 정보만 설정해주면 됩니다.

 

💡참고) swagger-ui가 리소스를 만드는 방법

더보기

swagger-intialize.js에 있는 내용입니다.

window.onload = function() {

      //
      window.ui = SwaggerUIBundle({
        url: "<https://petstore.swagger.io/v2/swagger.json>",
        "dom_id": "#swagger-ui",
        deepLinking: true,
        presets: [
          SwaggerUIBundle.presets.apis,
          SwaggerUIStandalonePreset
        ],
        plugins: [
          SwaggerUIBundle.plugins.DownloadUrl
        ],
        layout: "StandaloneLayout",
        queryConfigEnabled: false,
        urls: [{ url: '/config/openapi3.yaml', name: '파일직접설정' }],
        "urls.primaryName": "agent",
        supportedSubmitMethods: ['get'],
      })

      //

};

url 설정이 중요합니다.

 

✔️openapi3 파일 지정 방법

파일을 직접 설정

docker container의 볼륨 사용해서, 컨테이너와 호스트간 파일을 공유하는 방법입니다.

하지만 어플리케이션 서버와 분리되어 있는 경우, openapi3.yaml 파일을 수동으로 관리해주어야하기 때문에 번거로울 수 있습니다.

 

파일의 주소를 설정

어플리케이션 서버의 openapi3.yaml 주소를 설정해줍니다.

✔️구현

docker-compose.yml (swagger)

version: '3'
services:
  swagger:
    image: swaggerapi/swagger-ui
    container_name: 컨테이너명지정
    ports:
      - 8000:8080
    restart: on-failure
    volumes:
      - /home/ubuntu/spring/compose/openapi3.yaml:/config/openapi3.yaml
    environment:
      URLS : "[{url: '/config/openapi3.yaml', name: '파일직접설정'},
				       {url: '<http://서버>:포트/openapi3.yaml위치', name: '주소설정'} ]"

이 때 주의할 점은, 어플리케이션 서버에서 Swagger 서버가 접속할 수 있도록 cors설정을 해주어야 합니다.

 

WebMvcConfig

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
								.allowedOrigins("<http://localhost:3000>")
                .allowedMethods("OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true);
    }
}

Spring Security를 사용하지 않은 프로젝트이므로 WebMvcConfigurer에서 cors설정을 해주었습니다. Security를 사용하는 경우에는 Security 설정에서 구현해주면 됩니다.