728x90
반응형

Spring Boot | MockMvc with Spring Security and RestDocs :: 티포의개발일지 (tistory.com)

 

Spring Boot | MockMvc with Spring Security and RestDocs

Spring에서 API 문서를 만들 때 Swegger, Restdocs를 쓴다. 가장 큰 차이는 Restdocs는 테스트코드가 필수라는 점이다. TDD가 처음엔 귀찮은 작업일 순 있지만 향후 유지보수를 위해선 오히려 나를 편하게

typo.tistory.com

 

기존 포스트에서 RestDocs에 관하여 글을 썼던 적이 있다. 이 글은 Restdocs만을 위한 최적화된 테스트였다.

 

원래도 RestDocs와 Swagger를 같이 쓰고 싶었지만, Swagger에 관한 코드를 Controller에서 따로 써야하고, RestDocs와 Swagger를 따로 관리해야 하는 불편함이 있었다.

 

독일 기업 epages에서 RestDocs와 Swagger를 연동하여 OpenApi Spec(기존 Swagger Spec)을 만들어주는 Restdocs-api-spec을 만들어서 이 전보다 훨씬 쉽게 연동할 수 있게 되었다.

ePages-de/restdocs-api-spec: Adds API specification support to Spring REST Docs (github.com)

 

GitHub - ePages-de/restdocs-api-spec: Adds API specification support to Spring REST Docs

Adds API specification support to Spring REST Docs - ePages-de/restdocs-api-spec

github.com

 

이 글은 전에 올린 포스트에서의 수정 사항까지 같이 알려주고자 한다.

 

1. build.gradle

// 1. buildscript 추가
buildscript {
    ext {
        restdocsApiSpecVersion = '0.18.4'
    }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.2'
    id 'io.spring.dependency-management' version '1.1.0'
    // 2. 기존 id "org.asciidoctor.jvm.convert" version "3.3.2" 삭제
    // 3. restdocs-api-spec 추가
    id 'com.epages.restdocs-api-spec' version "${restdocsApiSpecVersion}"
}

group = 'com.teepo'
version = '0.0.1-SNAPSHOT'

repositories {
    mavenCentral()
    google()
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
}

jar {
    enabled = false
}

sourceSets {
    main {
        resources {
            srcDirs = ['src/main/resources']
        }
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    // 4. 기존 asciidoctorExt 삭제
}

dependencies {
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
    annotationProcessor 'org.projectlombok:lombok'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'

    compileOnly 'org.projectlombok:lombok'

    developmentOnly 'org.springframework.boot:spring-boot-devtools'

    implementation 'org.yaml:snakeyaml:2.2'
    implementation 'software.amazon.awssdk:route53domains:2.25.40'
    implementation 'com.bucket4j:bucket4j-core:8.3.0'
    implementation 'com.google.guava:guava:33.1.0-jre'
    implementation 'com.intelligt.modbus:jlibmodbus:1.2.9.10'
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    implementation 'io.hypersistence:hypersistence-utils-hibernate-60:3.3.2'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'org.apache.commons:commons-csv:1.10.0'
    implementation 'org.apache.commons:commons-lang3:3.14.0'
    implementation 'org.flywaydb:flyway-core:9.8.1'
    implementation 'org.hibernate:hibernate-core:6.1.6.Final'
    implementation 'org.mapstruct:mapstruct:1.5.3.Final'
    implementation 'org.springframework.boot:spring-boot-configuration-processor'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-mail'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.session:spring-session-core:3.1.2'
    implementation 'org.springframework.session:spring-session-jdbc:3.1.2'
    implementation 'org.springframework.kafka:spring-kafka'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.xhtmlrenderer:flying-saucer-pdf:9.7.2'
    implementation 'org.graalvm.buildtools:native-gradle-plugin:0.9.27'
    implementation 'net.java.dev.jna:jna:5.7.0'
    implementation 'org.apache.httpcomponents:httpclient:4.5.14'

    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    runtimeOnly 'org.postgresql:postgresql'

    testAnnotationProcessor 'org.projectlombok:lombok'

    testCompileOnly 'org.projectlombok:lombok'

    testImplementation 'junit:junit:4.13.1'
    testImplementation 'org.springframework.kafka:spring-kafka-test'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    // 5. 기존 asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' 삭제
    // 6. 기존 spring-restdocs-mockmvc 유지
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    // 7. openAPI3 추가
    testImplementation 'com.epages:restdocs-api-spec-mockmvc:' + restdocsApiSpecVersion
}

tasks.named('test') {
    useJUnitPlatform()
    // 8. 기존 outputs.dir snippetsDir 삭제
}


// 9. OpenApi3 서버 실행
openapi3 {
    setServer("http://localhost:8080")
    title = 'Post Service API'
    description = 'Post Service API description'
    version = '1.0.0'
    format = 'yaml'
}

// 10. 그 밖에 기존 대비 삭제 되는 것들
//ext {
//    set('snippetsDir', file("build/generated-snippets"))
//}
//
//task copyDocument(type: Copy) {
//    dependsOn asciidoctor
//    from file("build/docs/asciidoc")
//    into file("src/main/resources/static/docs")
//}
//
//tasks.named('asciidoctor') {
//    inputs.dir snippetsDir
//    dependsOn test
//}
// asciidoctor {//RestDoc
//    inputs.dir snippetsDir
//    configurations 'asciidoctorExt'
//    dependsOn test
//}

 

 

2. RestDocs 테스트 코드 작성

RestDocs 테스트 코드 기반으로  진행되어야 한다. 이는 전 포스트의 내용과 거의 동일하기 때문에 복사한 뒤 변경 사항을 알려주겠다.

 

여기서 중요한 점은 

2.1 기존 MockMvcRestDocumentation.document 사용 시 

MockMvcRestDocumentation.document 이런식으로 사용되던 것들을

MockMvcRestDocumentationWrapper.document 이렇게 바꿔야 한다.

 

import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation ->

import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper 

 

import 부분을 바꾸면 된다.

 

2.2 기존 MockMvcRequestBuilders.post 사용 시 (httpMethod 전부 동일)

예를 들어, mockMvc.perform(MockMvcRequestBuilders.delete("/group")  이런 것들을

this.mockMvc.perform(RestDocumentationRequestBuilders.delete("/group")

이렇게 바꿔야 한다.

 

2.3 기존 RestDocumentationResultHandler restdocs 사용 금지 

아직 이유를 찾지 못했지만 restdocs.document 이런 식으로 사용하던 메소드는 

queryParameters를 인식하지 못한다.

 

 

 

테스트 코드를 작성해보자. 

 

 

andDo(document()) 부분을 삭제하는 코드를 만들어준다.

이 코드는 모든 테스트에서 extends 될 것이다.

@SpringBootTest
@AutoConfigureRestDocs
@AutoConfigureMockMvc
@Disabled
@Import(RestDocsConfig.class)
@ExtendWith(RestDocumentationExtension.class)
public class RestDocsTestSupport {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    protected JwtUtil jwtUtil;

    @BeforeEach
    void setUp(final WebApplicationContext context,
        final RestDocumentationContextProvider provider) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .apply(
                MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
            .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함 
            .addFilters(new CharacterEncodingFilter("UTF-8", true)) // 한글 깨짐 방지
            .build();
    }
}

 

여기서 만약 Spring Security를 적용하면 다음과같이 써야한다.

@BeforeEach
    void setUp(final WebApplicationContext context,
        final RestDocumentationContextProvider provider) throws Exception {

        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();

        delegateProxyFilter.init(
            new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN));

        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .apply(
                MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
            .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함
            .addFilters(
                new CharacterEncodingFilter("UTF-8", true),
                delegateProxyFilter
            )
            .build();
    }

 

Session을 사용한다면, 다음과 같은 방법을 사용할 수도 있다.

@BeforeEach
    void setUp(final WebApplicationContext context,
        final RestDocumentationContextProvider provider) throws Exception {

        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();

        delegateProxyFilter.init(
            new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN));

        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .apply(
                MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
            .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함
            .addFilters(
                new CharacterEncodingFilter("UTF-8", true),
                delegateProxyFilter
            )
            .build();
            
        User user = new User(
            new UserCreateDto(
                "test",
                passwordEncoder.encode("test1234")
            )
        );
        em.persist(user);
        em.flush();
        em.clear();
        
        this.login();
    }
    
    private void login() throws Exception {
        var loginDto = new RequestLoginDto();
        loginDto.setId("admin");
        loginDto.setPassword("root1234");
        var login = mockMvc.perform(RestDocumentationRequestBuilders.post("/auth/login")
            .content(objectMapper.writeValueAsString(loginDto))
            .contentType(MediaType.APPLICATION_JSON));
        this.session = (MockHttpSession) login.andReturn().getRequest().getSession();
    }

    protected MockHttpSession getSession() {
        return this.session;
    }

 

아래는 예시 코드이다.

class MemberControllerTest extends RestDocsTestSupport {

    @Test
    void findAll() throws Exception {
        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/user")
                .session(this.getSession())
                .with(user("admin")
                    .roles("USER_ADMIN")))
            .andExpect(status().isOk())
            .andDo(document("{class-name}/{method-name}",
            		preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    responseFields(
                        fieldWithPath("data").type(ARRAY).description("data"),
                        fieldWithPath("data[].id").type(NUMBER)
                            .description("id"),
                        fieldWithPath("data[].username").type(STRING)
                            .description("username"),
                        fieldWithPath("data[].createdAt").type(STRING)
                            .description("createdAt"),
                        fieldWithPath("data[].updatedAt").type(STRING)
                            .description("updatedAt")
                    )
                )
            );
    }
}

 

7. Request, Response Fields 커스터마이징

/test/resources/org/springframework/restdocs/templates/asciidoctor/ 경로에 snippet 파일을 추가하면 default 표를 만들 수 있다.

/default-request-fields.snippet

|===
|필드|타입|필수값|설명|제한

{{#fields}}
|{{path}}
|{{type}}
|{{^optional}}true{{/optional}}
|{{description}}
|{{#constraint}}{{constraint}}{{/constraint}}

{{/fields}}
|===

constraints 같은 경우는 다음과 같이 추가할 수 있다.

fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하"))

 

/default-response-fields.snippet

|===
|필드|타입|설명

{{#fields}}
|{{path}}
|{{type}}
|{{description}}

{{/fields}}
|===

/default-path-parameters.snippet, /default-query-parameters.snippet

|===
|파라미터|설명

{{#parameters}}
|{{name}}
|{{description}}

{{/parameters}}
|===

 

8. Enum 코드 문서화

Enum 타입을 사용하기 위해선 로직에 사용하던 enum을 다음 interface에 implement 해주어야 한다.

RestDocsEnumType.java

public interface RestDocsEnumType {

    String getName();

    String getDescription();

}

 

아래는 예시 코드.

MemberStatus.java

@AllArgsConstructor
public enum MemberStatus implements RestDocsEnumType {
    LOCK("일시 정지"),
    NORMAL("정상"),
    BAN("영구 정지");

    private final String description;

    @Override
    public String getDescription() {
        return this.description;
    }

    @Override
    public String getName() {
        return this.name();
    }
}

 

위는 참고했던 사이트에서 만든 파일들이고,

내가 직접 만들었던 파일들이다.

 

Enum에 관련한 snippet인 custom-response-fields.snippet을 만들어준다.

|===
|필드|타입|설명

{{#fields}}
|{{path}}
|{{type}}
|{{description}}

{{/fields}}
|===

 

위 패키지 경로대로 하나씩 클래스를 만들어준다.

 

utils/consts/CustomResponseFieldsSnippet

public class CustomResponseFieldsSnippet extends AbstractFieldsSnippet {

    public CustomResponseFieldsSnippet(String type,
        PayloadSubsectionExtractor<?> subsectionExtractor,
        List<FieldDescriptor> descriptors, Map<String, Object> attributes,
        boolean ignoreUndocumentedFields) {
        super(type, descriptors, attributes, ignoreUndocumentedFields,
            subsectionExtractor);
    }

    @Override
    protected MediaType getContentType(Operation operation) {
        return operation.getResponse().getHeaders().getContentType();
    }

    @Override
    protected byte[] getContent(Operation operation) throws IOException {
        return operation.getResponse().getContent();
    }
}

 

utils/consts/ApiResponseDto

@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {

    private T data;

    private ApiResponseDto(T data){
        this.data=data;
    }

    public static <T> ApiResponseDto<T> of(T data) {
        return new ApiResponseDto<>(data);
    }
}

 

utils/consts/EnumDocs

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnumDocs {
    // 문서화하고 싶은 모든 enum값을 명시
    Map<String,String> Sex;
    Map<String,String> memberStatus;
}

 

utils/consts/CommonDocController

@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {

    private T data;

    private ApiResponseDto(T data){
        this.data=data;
    }

    public static <T> ApiResponseDto<T> of(T data) {
        return new ApiResponseDto<>(data);
    }
}

 

utils/consts/CommonDocControllerTest

// restdocs의 get 이 아님을 주의!!
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;


class CommonDocControllerTest extends RestDocsTestSupport {

    @Test
    public void enums() throws Exception {
        // 요청
        ResultActions result = this.mockMvc.perform(
                get("/test/enums")
                        .contentType(MediaType.APPLICATION_JSON)
        );

        // 결과값
        MvcResult mvcResult = result.andReturn();

        // 데이터 파싱
        EnumDocs enumDocs = getData(mvcResult);

        // 문서화 진행
        result.andExpect(status().isOk())
                .andDo(restDocs.document(
                        customResponseFields("custom-response", beneathPath("data.memberStatus").withSubsectionId("memberStatus"), // (1)
                                attributes(key("title").value("memberStatus")),
                                enumConvertFieldDescriptor((enumDocs.getMemberStatus()))
                        ),
                        customResponseFields("custom-response", beneathPath("data.sex").withSubsectionId("sex"), 
                                attributes(key("title").value("sex")),
                                enumConvertFieldDescriptor((enumDocs.getSex()))
                        )
                ));
    }

    // 커스텀 템플릿 사용을 위한 함수
    public static CustomResponseFieldsSnippet customResponseFields
                                (String type,
                                 PayloadSubsectionExtractor<?> subsectionExtractor,
                                 Map<String, Object> attributes, FieldDescriptor... descriptors) {
        return new CustomResponseFieldsSnippet(type, subsectionExtractor, Arrays.asList(descriptors), attributes
                , true);
    }

    // Map으로 넘어온 enumValue를 fieldWithPath로 변경하여 리턴
    private static FieldDescriptor[] enumConvertFieldDescriptor(Map<String, String> enumValues) {
        return enumValues.entrySet().stream()
                .map(x -> fieldWithPath(x.getKey()).description(x.getValue()))
                .toArray(FieldDescriptor[]::new);
    }

    // mvc result 데이터 파싱
    private EnumDocs getData(MvcResult result) throws IOException {
        ApiResponseDto<EnumDocs> apiResponseDto = objectMapper
                                                .readValue(result.getResponse().getContentAsByteArray(),
                                                new TypeReference<ApiResponseDto<EnumDocs>>() {}
                                                );
        return apiResponseDto.getData();
    }
}

 

이렇게 만들고 빌드를 하면 아래와 같은 adoc 파일이 생기는데

 

Enum을 클릭해서 새 창을 띄우도록 하려면 아래와 같이 사용할 수 있다.

queryParameters(
                        parameterWithName("memberStatus").description(
                            "link:#enum-memberStatus[memberStatus,window=\"_blank\"]"),

 

 

3. Swagger 접목하기

swagger를 위해 build/api-spec/openapi3.json 파일이 필요하다.

build를 실행할 때의 테스트가 실행되어야 하며, 통과 됐을 경우 아래와 같이 json 파일이 만들어진다.

{
  "openapi" : "3.0.1",
  "info" : {
    "title" : "Post Service API",
    "description" : "Post Service API description",
    "version" : "1.0.0"
  },
  "servers" : [ {
    "url" : "http://localhost:8080"
  }, {
    "url" : "http://production-api-server-url.com"
  } ],
  "tags" : [ ],
  "paths" : {
    "/posts" : {
      "get" : {
        "tags" : [ "posts" ],
        "operationId" : "get-posts",
        "parameters" : [ {
          "name" : "page",
          "in" : "query",
          "description" : "페이지 번호",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        }, {
          "name" : "size",
          "in" : "query",
          "description" : "한 페이지의 데이터 개수",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        }, {
          "name" : "sort",
          "in" : "query",
          "description" : "정렬 파라미터,오름차순 또는 내림차순 +\nex) +\ncreatedDate,asc(작성일 오름차순) +\ncreatedDate,desc(작성일 내림차순)",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        } ],
        ...

 

 

이제 docker-compose에 다음과 같이 추가한 뒤 실행하면 http://localhost:8090에 Swagger가 뜨는 것을 확인할 수 있다.

  server-swagger:
    image: swaggerapi/swagger-ui
    ports:
      - "8090:8080"
    environment:
      SWAGGER_JSON: /tmp/openapi3.json
    volumes:
      - .\server\build\api-spec\:/tmp
    depends_on:
      - server
      - db

 

또한 아래와 같이 document에서 Swagger에 설명을 추가할 수 있다.

        List<ParameterDescriptor> queryParameters = List.of(
            parameterWithName("pageNumber").description("pageNumber"),
            parameterWithName("postNumber").description("postNumber")
        );
        List<FieldDescriptor> responseFields = List.of(
            fieldWithPath("data").type(OBJECT).description("data")
        );
        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/channel")
                .param("pageNumber", pageNumber)
                .param("postNumber", postNumber)
                .session(this.getSession())
                .with(user("admin")))
            .andExpect(status().isOk())
            .andDo(
                document("{class-name}/{method-name}",
                    preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    queryParameters(queryParameters),
                    responseFields(responseFields),
                    resource(ResourceSnippetParameters
                        .builder()
                        .tag(this.tag)
                        .summary("Notification Channel List 조회")
                        .description("Notification Channel List 조회")
                        .responseFields(responseFields)
                        .requestSchema(Schema.schema(this.tag + "-Req-List By Pagination"))
                        .responseSchema(Schema.schema(this.tag + "-Res-List By Pagination"))
                        .build())
                )
            );

requestFields, responseFields는 document, resource 둘 다 있어야 한다.

(restdocs - fields 표, swagger - schema)

경험상 Deswcriptor들은 파일을 분리하는게 보기 깔끔하고 좋다.

 

4. CORS 설정

@Configuration
@Import({
    AopConfig.class,
})
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("http://localhost:8090", "http://localhost:3001")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

 

5. AuthTokenFilter

Spring Security에서 해당 필터를 사용중이라면, 다음 조건을 걸어주자.

@Slf4j
public class AuthTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private UserDetailServiceImpl userDetailsService;

    private String parseJwt(HttpServletRequest request) {
        return jwtUtil.getJwtFromCookies(request);
    }

    //매 인증시 JWT 필터 로직
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        try {
            String jwt = parseJwt(request);
            if (Objects.equals(request.getHeader("origin"), "http://localhost:8090")) {
                String username = "admin";

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities());

                authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            if (jwt != null && jwtUtil.validateJwtToken(jwt)) {
                String username = jwtUtil.getUsernameFromJwtToken(jwt);

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities());

                authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            log.error("Cannot set user authentication: {}", e.getMessage());
        }

        filterChain.doFilter(request, response);
    }
}
728x90
반응형

+ Recent posts