728x90
반응형

참고

24.08.29 기준으로 restdoc-api-spec 라이브러리에 문제가 발견되었는데,

document("{class-name}/{method-name}",
                    preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    pathParameters(parameterWithName("id").description("user Id")),
                    responseFields(
                    	    fieldWithPath("smtpHost").type(STRING)
                                .description("smtpHost"),
                            fieldWithPath("smtpPort").type(NUMBER)
                                .description("smtpPort")
                    ), // document
                    resource(ResourceSnippetParameters
                        .builder()
                        .tag(this.tag)
                        .summary("Notification Channel 조회(ID)")
                        .description("Notification Channel 조회 By ID")
                        .requestSchema(Schema.schema(this.tag + "-Req-Find By ID"))
                        .responseSchema(Schema.schema(this.tag + "-Res-Find By ID"))
                        .responseFields(
                            fieldWithPath("smtpHost").type(STRING)
                                .description("smtpHost"),
                            fieldWithPath("smtpPort").type(NUMBER)
                                .description("smtpPort")
                        ).build()) // resource
                )

 

해당 코드에서 responseFields 가 2번 들어가는 것을 볼 수 있다.

 

document의 인자로 들어가는 responseFields는 snippet을 만들어주며,

resource의 인자로 들어가는 responseFields는 swagger를 켰을 때 해당 schema를 페이지 내부에서 보여준다.

 

둘 중 한 개라도 없을 경우,  restdoc, swagger의 완벽한 문서를 만들 수가 없다.

 

이 때문에 중복되는 FieldsDescriptor[]가 두 번 들어가게 된다. 해당 버그는 아직까지 고쳐지지 않은 상태이다.

question for request/response fields snippet with ResourceSnippetParameters · Issue #206 · ePages-de/restdocs-api-spec (github.com)

 

question for request/response fields snippet with ResourceSnippetParameters · Issue #206 · ePages-de/restdocs-api-spec

Hi. I have a question about generating request/response fields snippets with MockMvcRestDocumentationWrapper. I'd expected documenting with only MockMvcRestDocumentationWrapper and ResourceSnippetP...

github.com

 


 

이를 떠나서 사실 지금 제일 큰 문제가 FieldDescriptor가 많아질 수록 코드가 상당히 길어지고 복잡해지는 것을 볼 수 있다.

    @Test
    public void findById() throws Exception {
        List<FieldDescriptor> responseFields = List.of(
            fieldWithPath("data").type(OBJECT).description("data"),
            fieldWithPath("data.id").type(NUMBER)
                .description("id"),
            fieldWithPath("data.name").type(STRING)
                .description("name"),
            fieldWithPath("data.type").type(STRING)
                .description("type ( EMAIL, TEAMS, KAKAOTALK )"),
            fieldWithPath("data.alarmEnabled").type(BOOLEAN)
                .description("alarmEnabled"),
            fieldWithPath("data.resolveAlarmEnabled").type(BOOLEAN)
                .description("resolveAlarmEnabled"),
            fieldWithPath("data.reminderEnabled").type(BOOLEAN)
                .description("reminderEnabled"),
            fieldWithPath("data.reminder").type(STRING)
                .description("reminder"),
            fieldWithPath("data.smtpHost").type(STRING)
                .description("smtpHost"),
            fieldWithPath("data.smtpPort").type(NUMBER)
                .description("smtpPort"),
            fieldWithPath("data.smtpFrom").type(STRING)
                .description("smtpFrom"),
            fieldWithPath("data.smtpTo").type(STRING)
                .description("smtpTo"),
            fieldWithPath("data.smtpUsername").type(STRING)
                .description("smtpUsername"),
            fieldWithPath("data.smtpPassword").type(STRING)
                .description("smtpPassword"),
            fieldWithPath("data.smtpTlsEnabled").type(BOOLEAN)
                .description("smtpTlsEnabled"),
            fieldWithPath("data.smtpTlsVersion").type(STRING)
                .description("smtpTlsVersion"),
            fieldWithPath("data.teamsWebhookUrl").type(STRING)
                .description("teamsWebhookUrl"),
            fieldWithPath("data.phoneNumbers").type(ARRAY)
                .description("phoneNumbers"),
            fieldWithPath("data.createdAt").type(STRING)
                .description("createdAt"),
            fieldWithPath("data.updatedAt").type(STRING)
                .description("updatedAt"),
            fieldWithPath("data.deletedAt").optional().type(STRING)
                .description("deletedAt"));
        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/channel/{id}", channelId)
                .session(this.getSession())
                .with(user("admin")))
            .andExpect(status().isOk())
            .andDo(
                document("{class-name}/{method-name}",
                    preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    pathParameters(parameterWithName("id").description("user Id")),
                    responseFields(responseFields),
                    resource(ResourceSnippetParameters
                        .builder()
                        .tag(this.tag)
                        .summary("Notification Channel 조회(ID)")
                        .description("Notification Channel 조회 By ID")
                        .requestSchema(Schema.schema(this.tag + "-Req-Find By ID"))
                        .responseSchema(Schema.schema(this.tag + "-Res-Find By ID"))
                        .responseFields(responseFields).build())
                )
            );
    }

 

 

 

만약, 

Response, Request Dto를 FieldDescriptor[]로 만들어주는 메소드가 있다면?

우린 상당히 깔끔한 테스트 코드를 만들 수 있을 것이다.

 

우선 생각해보면 Request는 일반 Dto클래스로 받아오기 때문에 상관이 없지만,

API Response 객체는 대부분 CommonResponse로 제네릭 클래스를 사용한다.

 

Sping Boot | Backend Project | 공통 응답 객체 ( Common Response ) :: 티포의개발일지 (tistory.com)

 

Sping Boot | Backend Project | 공통 응답 객체 ( Common Response )

성공 시 { success : true, result : data } 실패 시 { success : false, result : "에러 사유" } 1. CommonResponse backend/response/CommonResponse package web.backend.response; import lombok.AllArgsConstructor; import lombok.Data; @Data @AllArgsConstr

typo.tistory.com

 

때문에 우린 생각해야 한다. 

 

1. 제네릭 클래스로 받아올 경우 ( Response ) 

2. 일반 클래스로 받아올 경우 ( Request )

 

이를 제외하고도 생각해야 될 부분이 먼저 만들어질 fieldWithPath 메소드를 보면

fieldWithPath("data.deletedAt").optional().type(STRING).description("deletedAt"));

 

Dto의 클래스를 순환하여 위와 같은 fieldWithPath 메소드를 만드는 것이다.

 

3. path 

4. optional

5. type

6. desciption

 

해당 필드들을 만들어주어야 한다.

 

먼저 메소드를 만들기 전 RestDoc에서 제공해주는 FieldDescriptor를 살펴보면

public class FieldDescriptor extends IgnorableDescriptor<FieldDescriptor> {
    private final String path;
    private Object type;
    private boolean optional;

    protected FieldDescriptor(String path) {
        this.path = path;
    }

    public final FieldDescriptor type(Object type) {
        this.type = type;
        return this;
    }

    public final FieldDescriptor optional() {
        this.optional = true;
        return this;
    }

    public final String getPath() {
        return this.path;
    }

    public final Object getType() {
        return this.type;
    }

    public final boolean isOptional() {
        return this.optional;
    }
}

 

여기서 확인할 수 있는 것은

1. FieldDescriptor는 생성자가 protected기 때문에 인스턴스를 만들고 사용할 수 없다.

2. Description은 따로 작성해야한다.

이 때문에 FieldDiscriptor[] 배열을 선언해주고, add() 메소드로 FieldDiscriptor를 description과 함께 삽입해 줄 것이다.

 

1. RestDocsFieldGenerator 생성

@Slf4j
public class RestDocsFieldGenerator {

    /*
       외부에서 호출하는 메소드
     */
    public static FieldDescriptor[] generateForType(Type type) {
        List<FieldDescriptor> fieldDescriptors = new ArrayList<>();
        generateFieldDescriptors("", type, fieldDescriptors);
        return fieldDescriptors.toArray(new FieldDescriptor[0]);
    }

    /*
        FieldDescriptors 생성 및 fieldDescriptors 리스트에 추가
     */
    private static void generateFieldDescriptors(
        String prefix,
        Type type,
        List<FieldDescriptor> fieldDescriptors
    ) {
        Class<?> clazz = extractClass(type);
        if (clazz == null) {
            return;
        }

        Field[] fields = clazz.getDeclaredFields();

        for (Field field : fields) {
            if (shouldIgnoreField(field)) {
                continue;
            }

            String fieldPath = buildFieldPath(prefix, field);
            Type fieldType = resolveFieldType(type, field);
            String fieldDescription = resolveFieldDescription(field);
            JsonFieldType jsonFieldType = determineJsonFieldType(fieldType);

            FieldDescriptor fieldDescriptor = createFieldDescriptor(fieldPath, jsonFieldType,
                fieldDescription, field);
            fieldDescriptors.add(fieldDescriptor);

            if (field.isAnnotationPresent(RestDocIgnore.class)) {
                continue;
            }

            // Object or List 경우 재귀 함수로 다시 실행
            if (jsonFieldType == JsonFieldType.ARRAY) {
                handleArrayField(fieldPath, fieldType, fieldDescriptors);
            } else if (jsonFieldType == JsonFieldType.OBJECT) {
                generateFieldDescriptors(fieldPath, fieldType, fieldDescriptors);
            }
        }
    }

    /*
        JsonIgnore 어노테이션이 존재하는지 판단
     */
    private static boolean shouldIgnoreField(Field field) {
        return field.isAnnotationPresent(JsonIgnore.class);
    }

    /*
        Field Path 반환
     */
    private static String buildFieldPath(String prefix, Field field) {
        return prefix.isEmpty() ? field.getName() : prefix + "." + field.getName();
    }

    /*
        Field Type 반환 (제네릭 클래스 판단)
     */
    private static Type resolveFieldType(Type type, Field field) {
        Type fieldType = field.getGenericType();
        if (type instanceof ParameterizedType parameterizedType &&
            Objects.equals(fieldType.getTypeName(), "T") // 필드 타입이 제네릭 일 경우
        ) {
            fieldType = parameterizedType.getActualTypeArguments()[0];
        }
        return fieldType;
    }

    /*
        Field Description 반환 (어노테이션 없을 경우 Field Name 반환)
     */
    private static String resolveFieldDescription(Field field) {
        Description descriptionAnnotation = field.getAnnotation(Description.class);
        return descriptionAnnotation != null ? descriptionAnnotation.value() : field.getName();
    }

    /*
        FieldDescriptor 반환 (Nullable 어노테이션 있을 경우 .optional() 추가)
     */
    private static FieldDescriptor createFieldDescriptor(
        String fieldPath,
        JsonFieldType jsonFieldType,
        String description,
        Field field
    ) {
        FieldDescriptor fieldDescriptor = fieldWithPath(fieldPath).type(jsonFieldType)
            .description(description);
        if (field.isAnnotationPresent(Nullable.class)) {
            fieldDescriptor.optional();
        }
        return fieldDescriptor;
    }

    /*
        JsonFieldType 반환 (FieldDescriptor 사용)
     */
    private static JsonFieldType determineJsonFieldType(Type type) {
        Class<?> clazz = extractClass(type);
        if (clazz == null) {
            return JsonFieldType.OBJECT;
        }

        if (clazz.equals(String.class) || clazz.isEnum() || clazz.equals(LocalDateTime.class)
            || clazz.equals(LocalDate.class) || clazz.equals(LocalTime.class) || clazz.equals(
            DayOfWeek.class) || clazz.isEnum()) {
            return JsonFieldType.STRING;
        } else if (Number.class.isAssignableFrom(clazz) || clazz.isPrimitive()) {
            return JsonFieldType.NUMBER;
        } else if (clazz.equals(Boolean.class) || clazz.equals(boolean.class)) {
            return JsonFieldType.BOOLEAN;
        } else if (List.class.isAssignableFrom(clazz)) {
            return JsonFieldType.ARRAY;
        } else {
            return JsonFieldType.OBJECT;
        }
    }

    /*
        List Type Field Handler
     */
    private static void handleArrayField(
        String fieldPath,
        Type fieldType,
        List<FieldDescriptor> fieldDescriptors
    ) {
        Class<?> listType = extractListGenericType(fieldType);
        if (listType != null && !isSimpleType(listType)) {
            generateFieldDescriptors(fieldPath + "[]", listType, fieldDescriptors);
        }
    }

    /*
        List Generic Type 지정
     */
    private static Class<?> extractListGenericType(Type fieldType) {
        if (fieldType instanceof ParameterizedType parameterizedType) {
            Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
            if (actualTypeArguments.length > 0) {
                return extractClass(actualTypeArguments[0]);
            }
        }
        return null;
    }

    /*
        클래스를 추출 (Generic Class 판단)
     */
    public static Class<?> extractClass(Type type) {
        if (type instanceof ParameterizedType parameterizedType) {
            return (Class<?>) parameterizedType.getRawType();
        } else if (type instanceof Class<?>) {
            return (Class<?>) type;
        } else {
            return null;
        }
    }

    /*
        Simple Type 여부 판단
     */
    private static boolean isSimpleType(Class<?> clazz) {
        return clazz.isPrimitive() || clazz.equals(String.class) ||
            Number.class.isAssignableFrom(clazz) || clazz.equals(Boolean.class);
    }
}

 

주석으로 메소드에 대한 설명은 적어놨고, 질문은 댓글이나 이메일로 받겠습니다.

2. 테스트 코드에서의 사용

    @Test
    public void findById() throws Exception {
        Type responseType = new TypeToken<CommonResponse<NotificationChannelListResponseDto>>() {
        }.getType();
        FieldDescriptor[] responseFields = RestDocsFieldGenerator.generateForType(responseType);
        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/channel/{id}", channelId)
                .session(this.getSession())
                .with(user("admin")))
            .andExpect(status().isOk())
            .andDo(
                document("{class-name}/{method-name}",
                    preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    pathParameters(parameterWithName("id").description("user Id")),
                    responseFields(responseFields),
                    resource(ResourceSnippetParameters
                        .builder()
                        .tag(this.tag)
                        .summary("Notification Channel 조회(ID)")
                        .description("Notification Channel 조회 By ID")
                        .requestSchema(Schema.schema(this.tag + "-Req-Find By ID"))
                        .responseSchema(Schema.schema(this.tag + "-Res-Find By ID"))
                        .responseFields(responseFields).build())
                )
            );
    }

위의 코드와 같이 필요한 곳에 사용하면 된다.

 

3. 필드 어노테이션

물론 각 프로젝트마다의 조건이 다르겠지만, 필자가 진행하는 프로젝트에서의 조건만 달아둔 상태이며,

필요 시 코드에 추가하면 된다. 해당 어노테이션은 Request, Response Dto 필드에 추가하면 된다. 아래는 예시 Dto이다.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class NotificationChannelListResponseDto {

    @RestDocIgnore
    private List<String> phoneNumbers;
    @JsonIgnore
    private String password;
    @Description("이름을 나타내는 필드")
    private String name;
    @Nullable
    private LocalDateTime deletedAt;
}

 

1. @RestDocIgnore

예를 들어 아래와 같은 필드가 있을 때,

    private TreeMap<Integer, Integer> cycleMap;

 

            if (jsonFieldType == JsonFieldType.ARRAY) {
                handleArrayField(fieldPath, field, fieldDescriptors);
            } else if (jsonFieldType == JsonFieldType.OBJECT) {
                // Object일 경우 재귀 함수로 다시 실행
                generateFields(fieldPath, fieldType, fieldDescriptors);
            }

필드가 Object로 판단되어 재귀함수로 들어가는데, 때문에 내가 원하지 않는 하위 필드들이 FieldDescriptor에 추가된다.

이를 방지하기 위한 어노테이션이다.

 

2. @JsonIgnore

Password와 같이 비즈니스 로직에서는 쓰이지만 Response Dto로 사용하면 안되는 필드는 보통 @JsonIgnore로 Client에 전송하는 것을 막는데, 당연히 RestDoc에서도 제거를 해야된다.

            if (shouldIgnoreField(field)) {
                continue;
            }

해당 어노테이션이 존재하는 필드일 경우, 이 부분에서 건너뛰게 되어있다.

 

3. @Description

필자는 FieldDescriptor의 기본 값을 Field의 이름(field.getName())으로 해둔 상태이며, 해당 어노테이션이 존재하는 필드는 어노테이션의 value로 FieldDescriptor가 만들어지게 설계했다.

 

4. @Nullable

Response로 보내는 Dto 중 예를들어 Null이 될 수도, String이 될 수도 있는 값인데, Test에서 Null로 보내는 상태면 

type(STRING) <- 이 메소드쪽에서 에러가 난다. type은 필수로 지정되어야 하므로 추가해주고,

해당 어노테이션이 존재하는 필드는 .optional()이 추가되게 설계했다. 

728x90
반응형
728x90
반응형

개발 환경이 Windows라면, 도커 컨테이너는 wsl 기반이기 때문에 바인딩을 해주어야 한다.

참고로 windows와 wsl 환경에서 동시에 쓰일 수 없다.

 

1. usbipd 설치

먼저 Windows PowerShell을 관리자 모드로 키고 usbipd를 다운로드 받는다.

$ winget install usbipd

 

그 다음 USB를 연결하고 아래 명령어로 연결된 장치를 확인한다.

$ usbipd list

여기서 USB 직렬 장치(COM5) <- 장비를 쓸 예정인데, 지금 State를 보면 Not shared인 것을 확인할 수 있다.

이 State를 Shared로 바꿔야 wsl에서 인식할 수 있다. 

 

먼저 바인딩을 해준다. ( 위 사진의 BUSID 참고 )

$ usbipd bind --busid 2-4

 

그 다음 wsl attach를 해준다.

$ usbipd attach --wsl -b 2-4

 

우분투 shell에서 확인해보면, 

/# lsusb

 

이렇게 04d8:000a 주소가 잘 뜨는 것을 확인할 수 있다.

 

이제 Docker Container Spring boot에서 해당 데이터를 송 수신 해보자.

 

2. build.gradle 추가

dependencies {
	...
    implementation 'com.fazecast:jSerialComm:2.11.0'
    ...
}

 

3. 메서드 구현

read() 메서드를 @Scheduled를 사용한 스케줄링으로도 사용이 가능하다.

@Getter
@Slf4j
@Component
@Scope(scopeName = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class UA10SerialImpl implements ThMeterSerial {

    private final static byte[] WRITE_DATA_BYTE_ARRAY = new byte[]{65, 84, 67, 68, 13, 10};
    @Setter
    @Getter
    public DeviceDto deviceDto;

    private SerialPort serialPort;

    public void connect() {
        try {
            if (serialPort == null) {
                serialPort = SerialPort.getCommPort(this.deviceDto.getSerialPort());
            }

            serialPort.setComPortParameters(9600, 8, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY);
            serialPort.setComPortTimeouts(SerialPort.TIMEOUT_SCANNER, 0, 0);
            if (!serialPort.openPort()) {
                disconnect();
            }
            this.deviceDto.setIsConnected(true);
        } catch (Exception e) {
            log.error("[ Serial Connect Exception ] message : {}", e.getMessage());
        }
    }

    public void disconnect() {
        if (!serialPort.closePort()) {
            log.info("Failed to close port.");
        }
        serialPort.setComPortTimeouts(SerialPort.TIMEOUT_NONBLOCKING, 0, 0);
        serialPort = null;
        this.deviceDto.setIsConnected(false);
    }

    public synchronized void read() {
        if (!deviceDto.getIsConnected()) {
            return;
        }
        if (serialPort == null || serialPort.bytesAvailable() < 0) {
            connect();
            return;
        }
        serialPort.writeBytes(WRITE_DATA_BYTE_ARRAY,
            WRITE_DATA_BYTE_ARRAY.length);

        byte[] readBuffer = new byte[serialPort.bytesAvailable()];
        int numRead = serialPort.readBytes(readBuffer, readBuffer.length);

        if (numRead == 0) {
            return;
        }

        String receivedData = new String(readBuffer, 0, numRead);

        log.info("Received data: {}", receivedData);

    }
}

 

728x90
반응형
728x90
반응형

사용하는 Dto Class에 Enum Type이 존재하고, 타입 별로 필드를 검증하고 싶을 때 Annotation과 ConstraintValidator 인터페이스를 활용하여 Validaton을 구현할 수 있다.

 

1. ConditinalValidator Class

Conditional Class는 2번에서 만들 Annotation을 의미한다.

isValid 메소드에서 Required Value가 null이거나 ""일 때 에러를 반환해준다.

@Slf4j
public class ConditionalValidator implements ConstraintValidator<Conditional, Object> {

    private String selected;
    private String[] required;
    private String message;
    private String[] values;

    @Override
    public void initialize(Conditional requiredIfChecked) {
        selected = requiredIfChecked.selected();
        required = requiredIfChecked.required();
        message = requiredIfChecked.message();
        values = requiredIfChecked.values();
    }

    @Override
    public boolean isValid(Object objectToValidate, ConstraintValidatorContext context) {
        Boolean valid = true;
        try {
            Object actualValue = BeanUtils.getProperty(objectToValidate, selected);
            if (Arrays.asList(values).contains(actualValue)) {
                for (String propName : required) {
                    Object requiredValue = BeanUtils.getProperty(objectToValidate, propName);
                    // value가 null이거나 ""인지 판별한다.
                    valid = requiredValue != null && !isEmpty(requiredValue);
                    if (!valid) {
                        context.disableDefaultConstraintViolation();
                        context.buildConstraintViolationWithTemplate(message).addPropertyNode(propName).addConstraintViolation();
                    }
                }
            }
        } catch (IllegalAccessException e) {
            log.error("Accessor method is not available for class : {}, exception : {}", objectToValidate.getClass().getName(), e);
            e.printStackTrace();
            return false;
        } catch (NoSuchMethodException e) {
            log.error("Field or method is not present on class : {}, exception : {}", objectToValidate.getClass().getName(), e);
            e.printStackTrace();
            return false;
        } catch (InvocationTargetException e) {
            log.error("An exception occurred while accessing class : {}, exception : {}", objectToValidate.getClass().getName(), e);
            e.printStackTrace();
            return false;
        }
        return valid;
    }
}

 

2. Conditional Annotaton

Dto에 쓰이며 위에서 만든 ConditionalValidator Class를 활용하는 Annotaton이다.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ConditionalValidator.class})
public @interface Conditional {

    String message() default "This field is required.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String selected();
    String[] required();
    String[] values();
}

 

여기서 아래와 같이 @Repeatable 어노테이션으로 여러 개의 Conditianl Annotation을 같이 선언할 수도 있다.

@Repeatable(Conditionals.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ConditionalValidator.class})
public @interface Conditional {

    String message() default "This field is required.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String selected();
    String[] required();
    String[] values();
}

 

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface Conditionals {
    Conditional[] value();
}

 

3. DTO

사용이 필요한 DTO에 어노테이션을 추가해준다.

@Builder
@Getter
@Setter
@Conditional(selected = "locationOption", values = {"SFTP", "FTP"}, required = {"host", "user", "password", "port"})
@Conditional(selected = "locationOption", values = {"EMAIL"}, required = {"emailAddress"})
public class ExportLocationDTO {

    @NotEmpty
    private String name;
    @NotNull
    private ExportLocationOption locationOption;
    private String emailAddress;
    private String host;
    @Min(1)
    private Integer port;
    @NotBlank
    @Length(min = 1,max = 255)
    private String user;
    @NotBlank
    @Length(min = 1,max = 100)
    private String password;
}

 

 

2번에서 알려준 @Conditionals를 활용하면 다음과 같이 쓸 수 있다.

@Builder
@Getter
@Setter
@Conditionals(
	{
		@Conditional(selected = "locationOption", values = {"SFTP", "FTP"}, required = {"host", "user", "password", "port"}),
		@Conditional(selected = "locationOption", values = {"EMAIL"}, required = {"emailAddress"})
	}
)
public class ExportLocationDTO {

    @NotEmpty
    private String name;
    @NotNull
    private ExportLocationOption locationOption;
    private String emailAddress;
    private String host;
    private Integer port;
    private String user;
    private String password;
}

 

4. @Valid 적용

마지막으로 해당 DTO가 사용되는 곳에 @Valid 어노테이션을 추가해준다.

    @PutMapping
    public CommonResponse<Boolean> exportLocation(
        @Valid @RequestBody ExportLocationDTO dto
    ) {
        service.exportLocation(dto);
        return new CommonResponse<>(true);
    }

 

 

5. Test Code

@Valid 유닛 테스트 코드는 다음과 같이 작성할 수 있다.

Assertions.assertFalse(violations.isEmpty()); 메소드를 사용하면 @Valid 검증이 실패할 경우 테스트가 통과된다.

import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;

public class ExportLocationDTOTest {

    private Validator validator;

    private ExportLocationDTO dto;

    @Before
    public void setUp() throws Exception {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        validator = validatorFactory.getValidator();

        dto = ExportLocationDTO.builder()
                .name("validName")
                .build();
    }

    @Test
    public void ShouldFailOnMissingEmailAddressField() {
        dto.setLocationOption(ExportLocationOption.EMAIL);
        Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
        assertEquals(1, violations.size());
    }

    @Test
    public void ShouldFailOnMissingFTPCredentials() {
        dto.setLocationOption(ExportLocationOption.FTP);
        Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
        assertEquals(4, violations.size());
    }

    @Test
    public void ShouldFailOnMissingSFTPCredentials() {
        dto.setLocationOption(ExportLocationOption.SFTP);
        Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
        assertEquals(4, violations.size());
    }
}

Assertions.assertFalse(violations.isEmpty());

 

 

아래는 Conditional 어노테이션 및 ConditionalValidator 클래스의 동작을 테스트한다.

LocaltionOption이 EMAIL일 경우 emailAddress 필드 값이 null이거나 빈 값이면 안되기 때문에 False를 검증하는 테스트이다.

public class ExportLocationDTOTest {

    private final ConstraintValidatorContext constraintValidatorContext = mock(
        ConstraintValidatorContext.class);

    private ExportLocationDTO dto;
    
    private conditionalValidator = new ConditionalValidator();

    @BeforeEach
    public void setUp(){

        dto = ExportLocationDTO.builder()
            .name("validName")
            .build();

        ConstraintAnnotationDescriptor.Builder<Conditional> descriptorBuilder = new ConstraintAnnotationDescriptor.Builder<>(
            Conditional.class);
        descriptorBuilder.setAttribute("selected", "locationOption");
        descriptorBuilder.setAttribute("values", new String[]{"EMAIL"});
        descriptorBuilder.setAttribute("required", new String[]{"emailAddress"});
        Conditional conditional = descriptorBuilder.build()
            .getAnnotation();
        conditionalValidator.initialize(conditional);
    }

    @Test
    public void ShouldFailOnMissingEmailAddressField() {
        dto.setLocationOption(ExportLocationOption.EMAIL);
        boolean valid = conditionalValidator.isValid(dto,
            constraintValidatorContext);
        assertFalse(valid);
    }
}
728x90
반응형
728x90
반응형

이번엔 로그인 뿐만 아니라 TOPT, Email을 사용한 복합 인증에 관하여 알아보자.

TOPT는 시간으로 OPT를 인증받는 방식으로, 구글, 마이크로 소프트 등 여러 앱으로 인증이 가능하다.

 

1. build.gradle 패키지 추가

implementation 'org.jboss.aerogear:aerogear-otp-java:1.0.0'

 

2. User Entity

  • isUsingMfa: 복합인증을 사용하는 유저인지 판단
  • mfaTypes: 어떤 복합인증을 사용하는 지 ( [ { mfaType: "EMAIL", registeredAt: "등록 시간" } ] )
  • secret: 복합인증에 사용되는 Base32 인코딩 값
    @Column(name = "is_using_mfa")
    private boolean isUsingMfa;

    @Column
    @Convert(converter = MfaTypesConverter.class)
    private List<MfaTypesDto> mfaTypes;

    @Column
    private String secret;

 

User가 만들어질 때 secret을 지정해준다.

    public long create(UserCreateDto createDto) {
        repository.findByUsernameAndDeletedAtIsNull(createDto.getUsername())
            .ifPresent(u -> {
                log.error("[This username already exists] Username: {}", createDto.getUsername());
                throw new IllegalArgumentException("This username already exists.");
            });
        if (createDto.getUsername().isBlank() | createDto.getPassword().isBlank()) {
            log.error("[ID or PW was entered incorrectly] Username: {}", createDto.getUsername());
            throw new IllegalArgumentException("ID or PW was entered incorrectly.");
        }

        String random = Base32.random();
        while (repository.findBySecretAndDeletedAtIsNull(random).isPresent()) {
            random = Base32.random();
        }

        createDto.setSecret(random);
        createDto.setPassword(passwordEncoder.encode(createDto.getPassword()));
        createDto.setGroups(setUserGroup(createDto.getGroupIds()).stream().toList());
        return repository.save(new User(createDto)).getId();
    }

 

3. MfaTypesDto

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MfaTypesDto {

    private MfaType mfaType;
    private LocalDateTime registeredAt;
}

 

4. MfaType Enum

public enum MfaType {
    OTP,
    EMAIL
}

 

5. MfaTypesConverter

@Converter
public class MfaTypesConverter implements AttributeConverter<List<MfaTypesDto>, String> {

    private final ObjectMapper mapper;

    public MfaTypesConverter(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public String convertToDatabaseColumn(List<MfaTypesDto> attribute) {
        try {
            return mapper.writeValueAsString(attribute);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public List<MfaTypesDto> convertToEntityAttribute(String dbData) {
        try {
            return mapper.readValue(dbData, new TypeReference<List<MfaTypesDto>>() {
            });
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}

 

여기까지 엔티티에 대한 구성을 완료 하였고, 이제 부터 필요한 것은 

  • 복합 인증 사용자가 로그인을 할 경우 쿠키 및 세션을 제공하지 않는다.
  • 복합 인증 사용자는 인증을 거쳐야 쿠키 및 세션이 등록된다.

일단 필요한 Dto들을 먼저 보자면,

 

6. MfaEmailSendRequestDto

이메일 전송 시 Request

@Data
@AllArgsConstructor
@NoArgsConstructor
@EventLoggingDto
public class MfaEmailSendRequestDto {

    @NotNull
    private String email;
}

 

7. MfaEmailVerifyRequestDto

이메일 검증 시 Request

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MfaEmailVerifyRequestDto {

    @NotNull
    private String email;
    
    @NotNull
    private String code;

}

 

8. MfaOtpVerifyDto

Otp 검증 시 Request

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MfaOtpVerifyDto {

    @NotNull
    private String email;

    private String code;

}

 

 

9. ResponseLoginDto

로그인 성공 시 Response

@Data
@AllArgsConstructor
public class ResponseLoginDto {

    private boolean isUsingMfa;
    private List<MfaType> mfaTypes;
    private MfaType currentMfaType;
    private Long id;
    private String name;
    private String firstName;
    private String lastName;
    private List<String> groupNames;
    private List<String> roles;
    private Long exp;

    public ResponseLoginDto(
        boolean isUsingMfa,
        List<MfaType> mfaTypes,
        Long id,
        String name,
        String firstName,
        String lastName,
        List<String> groupNames,
        List<String> roles,
        Long exp
    ) {
        MfaType currentMfaType = null;
        if (mfaTypes.contains(MfaType.OTP)) {
            currentMfaType = MfaType.OTP;
        } else if (mfaTypes.contains(MfaType.E_MAIL)) {
            currentMfaType = MfaType.E_MAIL;
        }
        this.isUsingMfa = isUsingMfa;
        this.mfaTypes = mfaTypes;
        this.currentMfaType = currentMfaType;
        this.id = id;
        this.name = name;
        this.firstName = firstName;
        this.lastName = lastName;
        this.groupNames = groupNames;
        this.roles = roles;
        this.exp = exp;

    }
}

 

10. UserService

  1. registerMfaTypes: 유저의 MfaTypes 정보를 새로 등록한다.
  2. deleteMfaTypes: 유저의 MfaTypes 중 원하는 것을 제거한다.
  3. registerAuthenticator: OTP 인증 중 secret 값을 만들고 유저에 등록하며 클라이언트로 보내주는 로직
  4. verifyAuthenticatorCode: TOPT 검증하는 로직
  5. sendAuthenticationCodeByEmail: 이메일 전송 로직 ( Spring Boot | Custom 메일 보내기 ( with JavaMailSender ) :: 티포의개발일지 (tistory.com) ) 참고
  6. certEmail: 이메일 검증 로직
  7. emailCertNumberMap: 이메일 검증 시에 쓰이며, Dto에는 code와 createdAt이 있다. 검증 로직에서 아래 상수로 시간이 지나면 검증이 실패하게끔 만들어준다. 이 포스트에서는 인메모리로 다루어봤다. 
  8. VERIFY_TIMEOUT: 서버에서의 이메일 검증 만료 시간
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class UserService {

    public static final int VERIFY_TIMEOUT = 35;
    private final UserRepository repository;
    private final EmailService emailService;
    private final UserGroupRepository userGroupRepository;

    private final PasswordEncoder passwordEncoder;

    @Getter
    public Map<String, MfaEmailVerifyMapDto> emailCertNumberMap = new HashMap<>();

	...

    public void registerMfaTypes(MfaRegisterDto mfaRegisterDto) {
        User user = repository.findByUsernameAndDeletedAtIsNull(mfaRegisterDto.getId())
            .orElseThrow();

        List<MfaTypesDto> mfaTypes = user.getMfaTypes();

        List<MfaType> mfaTypeList = mfaTypes
            .stream()
            .map(MfaTypesDto::getMfaType)
            .toList();

        if (mfaTypeList.contains(mfaRegisterDto.getMfaType())) {
            throw new IllegalArgumentException("이미 등록된 인증 입니다.");
        }

        mfaTypes.add(new MfaTypesDto(mfaRegisterDto.getMfaType(), LocalDateTime.now()));
        user.updateIsUsingMfa(true);
        user.updateMfaTypes(mfaTypes);
    }

    public void deleteMfaTypes(MfaDeleteDto deleteDto) {
        User user = repository.findByUsernameAndDeletedAtIsNull(deleteDto.getId())
            .orElseThrow();

        List<MfaTypesDto> mfaTypes = user.getMfaTypes();

        List<MfaType> mfaTypeList = mfaTypes
            .stream()
            .map(MfaTypesDto::getMfaType)
            .toList();

        if (!mfaTypeList.contains(deleteDto.getMfaType())) {
            throw new IllegalArgumentException("등록되지 않은 인증 입니다.");
        }
        mfaTypes.removeIf(dto -> dto.getMfaType() == deleteDto.getMfaType());
        if (mfaTypes.size() == 0) {
            user.updateIsUsingMfa(false);
        }
        user.updateMfaTypes(mfaTypes);
    }

    public String registerAuthenticator(MfaOtpRegisterDto dto) {
        return repository
            .findByUsernameAndDeletedAtIsNull(dto.getId())
            .orElseThrow()
            .getSecret();
    }

    public void verifyAuthenticatorCode(MfaOtpVerifyDto dto) {
        String name = dto.getEmail();
        User user = repository.findByUsernameAndDeletedAtIsNull(name).orElseThrow();

        Totp totp = new Totp(user.getSecret());
        if (!isValidLong(dto.getCode()) || !totp.verify(dto.getCode())) {
            throw new AuthorizationException("Code Invalid");
        }
    }

    private boolean isValidLong(String code) {
        try {
            Long.parseLong(code);
        } catch (NumberFormatException e) {
            return false;
        }
        return true;
    }

    public void sendAuthenticationCodeByEmail(String userId) {
        repository.findByUsernameAndDeletedAtIsNull(userId).orElseThrow();

        Random num = new Random();
        StringBuilder randomNumber = new StringBuilder();
        for (int i = 0; i < 6; i++) {
            randomNumber.append(num.nextInt(10));
        }
        EmailSendDto emailSendDto = new EmailSendDto(
            AUTH,
            "이메일 서버",
            포트,
            "이메일 주소",
            userId,
            "",
            "",
            true,
            TLS_V_1_2,
            "인증번호",
            "인증번호는 " + randomNumber + " 입니다.",
            null
        );
        emailService.sendMail(emailSendDto);
        this.emailCertNumberMap.put(
            userId,
            new MfaEmailVerifyMapDto(
                randomNumber.toString(),
                LocalDateTime.now()
            )
        );
    }

    public void certEmail(MfaEmailVerifyRequestDto mfaEmailVerifyRequestDto) {
        String name = mfaEmailVerifyRequestDto.getEmail();
        MfaEmailVerifyMapDto mapDto = this.emailCertNumberMap.get(name);
        Duration between = Duration.between(LocalDateTime.now(), mapDto.getCreatedAt());
        if (Math.abs(between.getSeconds()) > VERIFY_TIMEOUT) {
            throw new AuthorizationException("Code Expired");
        }
        if (!Objects.equals(mapDto.getCode(), mfaEmailVerifyRequestDto.getCode())) {
            throw new AuthorizationException("Code Invalid");
        }
    }


}

 

11. AuthService

@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class AuthService {

    private final UserRepository userRepository;

    private final AuthenticationManager authenticationManager;
    private final PasswordEncoder passwordEncoder;

    @Getter
    public Map<String, MfaEmailVerifyMapDto> emailCertNumberMap = new HashMap<>();


    public ResponseLoginDto login(RequestLoginDto loginDto) {
        User user = userRepository.findByUsernameAndDeletedAtIsNull(loginDto.getId()).orElseThrow();
        if (!passwordEncoder.matches(loginDto.getPassword(), user.getPassword())) {
            log.error("[Password does not match] Username: {}", user.getUsername());
            throw new TokenProblemException("Password does not match");
        }
        if (user.getIsBlocked()) {
            log.error("[User login is blocked] Username: {}", user.getUsername());
            throw new AccessDeniedException("Blocked user.");
        }

        Authentication authentication = this.authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(loginDto.getId(), loginDto.getPassword()));

        SecurityContextHolder.getContext().setAuthentication(authentication);
        UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();

        List<String> roles = userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .toList();

        List<String> groupNames = new ArrayList<>();
        for (UserGroup group : user.getGroups()) {
            groupNames.add(group.getName());
        }

        return new ResponseLoginDto(
            user.getIsUsingMfa(),
            user.getMfaTypes(),
            user.getId(),
            user.getUsername(),
            user.getFirstName(),
            user.getLastName(),
            groupNames,
            roles,
            null
        );
    }

    @Retry(value = 2)
    public ResponseLoginDto refreshToken(String username) {
        User user = userRepository.findByUsernameAndDeletedAtIsNull(username).orElseThrow();
        Set<String> roles = new HashSet<>();
        List<String> groupNames = new ArrayList<>();
        for (UserGroup group : user.getGroups()) {
            roles.addAll(List.of(group.getRolesList()));
            groupNames.add(group.getName());
        }

        return new ResponseLoginDto(
            user.getIsUsingMfa(),
            user.getMfaTypes(),
            user.getId(),
            user.getUsername(),
            user.getFirstName(),
            user.getLastName(),
            groupNames,
            roles.stream().toList(),
            null);
    }

    public long getExpiredTime(Cookie jwtCookie) {
        return Timestamp.valueOf(LocalDateTime.now().plusSeconds(jwtCookie.getMaxAge()))
            .getTime();
    }

}

 

 

 

최종적으로 API를 만들기 위한 단계이다. API는 로그인 시 필요한 AuthController, 사용자 별 복합 인증 관리에 필요한 UserController 두 곳에서 사용할 것이다.

 

12. AuthController

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/auth")
public class AuthController {

    private static final int CLIENT_VERIFY_TIMEOUT = 30;

    private final AuthService service;
    private final UserService userService;

    private final JwtUtil jwtUtil;

    @PostMapping("/login")
    @EventLogging(code = SecurityEventCode.USER_LOGIN)
    public CommonResponse<ResponseLoginDto> login(
        HttpServletResponse response,
        HttpSession session,
        @Valid @RequestBody RequestLoginDto loginDto
    ) {
        ResponseLoginDto responseLoginDto = service.login(loginDto);

        if (!responseLoginDto.isUsingMfa()) {
            long exp = addJwtCookie(response, loginDto.getId());
            responseLoginDto.setExp(exp);
            return new CommonResponse<>(responseLoginDto);
        }

        return new CommonResponse<>(responseLoginDto);
    }

    @PostMapping("/mfa/authenticator/verify")
    public CommonResponse<Long> verifyAuthenticatorCode(
        HttpServletResponse response,
        HttpSession session,
        @Valid @RequestBody MfaOtpVerifyDto dto
    ) {
        verifySessionMfaType(session, OTP);
        userService.verifyAuthenticatorCode(dto);

        long exp = addJwtCookie(
            response,
            dto.getEmail()
        );

        return new CommonResponse<>(exp);
    }

    @PostMapping("/mfa/email/send")
    public CommonResponse<Integer> sendAuthenticationCodeByEmail(
        @RequestBody MfaEmailSendRequestDto mfaEmailSendRequestDto
    ) {
        userService.sendAuthenticationCodeByEmail(mfaEmailSendRequestDto.getEmail());
        return new CommonResponse<>(CLIENT_VERIFY_TIMEOUT);
    }

    @PostMapping("/mfa/email/verify")
    public CommonResponse<Long> certEmail(
        HttpServletResponse response,
        HttpSession session,
        @RequestBody MfaEmailVerifyRequestDto dto
    ) {
        verifySessionMfaType(session, EMAIL);
        userService.certEmail(dto);

        long exp = addJwtCookie(
            response,
            dto.getEmail()
        );

        return new CommonResponse<>(exp);
    }

    @PostMapping("/logout")
    @EventLogging(code = SecurityEventCode.USER_LOGOUT)
    public CommonResponse<Boolean> logout(
        HttpServletResponse response,
        HttpSession session
    ) {
        ResponseCookie jwtCookie = jwtUtil.getCleanJwtCookie();
        response.addHeader(HttpHeaders.SET_COOKIE, jwtCookie.toString());
        return new CommonResponse<>(true);
    }

    @PostMapping("/refresh")
    public CommonResponse<ResponseLoginDto> refresh(
        HttpServletResponse response,
        HttpServletRequest request
    ) {
        String token = this.jwtUtil.getJwtFromCookies(request);
        if ((token == null) || (token.length() < 1)) {
            log.error("[Invalid token] token: {}", token);
            throw new TokenProblemException("Invalid token");
        }
        if (!jwtUtil.validateJwtToken(token)) {
            log.error("[The token has expired] token: {}", token);
            throw new TokenProblemException("The token has expired.");
        }
        String username = jwtUtil.getUsernameFromJwtToken(token);
        Cookie jwtCookie = jwtUtil.generateJwtCookie(username);
        response.addCookie(jwtCookie);

        var exp = service.getExpiredTime(jwtCookie);
        ResponseLoginDto responseLoginDto = service.refreshToken(username);
        responseLoginDto.setExp(exp);

        return new CommonResponse<>(responseLoginDto);
    }

    private void verifySessionMfaType(HttpSession session, MfaType mfaType) {
        MfaType isUsingMfa = sessionUtil.getIsUsingMfa(session);
        if (isUsingMfa != null && isUsingMfa != mfaType) {
            throw new IllegalArgumentException("이미 다른 복합 인증이 사용중입니다.");
        }
        sessionUtil.setIsUsingMfa(session, mfaType);
    }

    private long addJwtCookie(HttpServletResponse response, String name) {
        Cookie jwtCookie = jwtUtil.generateJwtCookie(name);
        response.addCookie(jwtCookie);
        return service.getExpiredTime(jwtCookie);
    }

}

 

13. UserController

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/user")
@PreAuthorize("hasRole('ROLE_USER_READ') || hasRole('ROLE_USER_ADMIN')")
public class UserController {

    private final UserService service;

    @GetMapping
    public CommonResponse<List<ResponseUserDto>> findAll() {
        return new CommonResponse<>(service.findAll());
    }

    @GetMapping("/{id}")
    public CommonResponse<ResponseUserDto> findByIndexId(
        @PathVariable(value = "id") long userId) {
        return new CommonResponse<>(service.findByIndexId(userId));
    }

    @GetMapping("/name/{username}")
    public CommonResponse<Boolean> findByUsername(
        @PathVariable(value = "username") String username) {
        return new CommonResponse<>(service.isUserEmptyByUsername(username));
    }

    @PreAuthorize("hasRole('ROLE_USER_ADMIN')")
    @PostMapping
    @EventLogging(code = SecurityEventCode.USER_USER_CREATE)
    public CommonResponse<Long> create(
        @RequestBody @Valid UserCreateDto createDto) {
        Long id = service.create(createDto);
        createDto.setPassword("");
        return new CommonResponse<>(id);
    }

    @PreAuthorize("hasRole('ROLE_USER_ADMIN')")
    @PutMapping
    @EventLogging(code = SecurityEventCode.USER_USER_UPDATE)
    public CommonResponse<Boolean> updateUser(
        @RequestBody @Valid UserUpdateDto updateDto) {
        service.updateUser(updateDto);
        return new CommonResponse<>(true);
    }

    @PreAuthorize("hasRole('ROLE_USER_ADMIN')")
    @PutMapping("/password")
    @EventLogging(code = SecurityEventCode.USER_USER_PASSWORD_CHANGE)
    public CommonResponse<Boolean> updatePasswordByUser(
        @RequestBody @Valid UserUpdatePasswordDto updateDto) {
        service.updatePasswordByUser(updateDto);
        return new CommonResponse<>(true);
    }

    @PreAuthorize("hasRole('ROLE_USER_ADMIN')")
    @PutMapping("/me")
    @EventLogging(code = SecurityEventCode.USER_USER_UPDATE_OWN_USER)
    public CommonResponse<Boolean> updateOwnUser(
        @RequestBody @Valid OwnUserUpdateDto updateDto) {
        service.updateOwnUser(updateDto);
        return new CommonResponse<>(true);
    }

    @PostMapping("/mfa/register")
    public CommonResponse<Boolean> registerMfaTypes(
        @Valid @RequestBody MfaRegisterDto registerDto
    ) {
        service.registerMfaTypes(registerDto);
        return new CommonResponse<>(true);
    }

    @PostMapping("/mfa/authenticator/verify")
    public CommonResponse<Boolean> verifyAuthenticatorCode(
        @Valid @RequestBody MfaOtpVerifyDto dto
    ) {
        service.verifyAuthenticatorCode(dto);
        return new CommonResponse<>(true);
    }

    @PostMapping("/mfa/delete")
    public CommonResponse<Boolean> deleteMfaTypes(
        @Valid @RequestBody MfaDeleteDto deleteDto
    ) {
        service.deleteMfaTypes(deleteDto);
        return new CommonResponse<>(true);
    }

    @PostMapping("/mfa/authenticator/register")
    public CommonResponse<String> registerAuthenticator(
        @Valid @RequestBody MfaOtpRegisterDto otpRegisterDto
    ) {
        return new CommonResponse<>(service.registerAuthenticator(otpRegisterDto));
    }

    @PostMapping("/mfa/email/send")
    public CommonResponse<Boolean> sendAuthenticationCodeByEmail(
        @Valid @RequestBody MfaEmailSendRequestDto mfaEmailSendRequestDto
    ) {
        service.sendAuthenticationCodeByEmail(mfaEmailSendRequestDto.getEmail());
        return new CommonResponse<>(true);
    }

    @PostMapping("/mfa/email/verify")
    public CommonResponse<Boolean> certEmail(
        @Valid @RequestBody MfaEmailVerifyRequestDto dto
    ) {
        service.certEmail(dto);
        return new CommonResponse<>(true);
    }

    @PreAuthorize("hasRole('ROLE_USER_ADMIN')")
    @PutMapping("/admin")
    @EventLogging(code = SecurityEventCode.USER_USER_PASSWORD_CHANGE_BY_ADMIN)
    public CommonResponse<Boolean> updatePasswordByAdmin(
        @RequestBody @Valid UserUpdatePasswordDto updateDto) {
        service.updatePasswordByAdmin(updateDto);
        return new CommonResponse<>(true);
    }

    @PreAuthorize("hasRole('ROLE_USER_ADMIN')")
    @DeleteMapping
    @EventLogging(code = SecurityEventCode.USER_USER_DELETE)
    public CommonResponse<Boolean> deleteUsers(
        @Valid @RequestBody CommonIdListDto listDto) {
        service.deleteUsers(listDto);
        return new CommonResponse<>(true);
    }


}

 

클라이언트 QR code 예시

`otpauth://totp/typosblog:${username}?secret=${secretQrCode}&issuer=typosblog`
728x90
반응형
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
반응형
728x90
반응형

회사마다 다르겠지만, 본인은 아래와 같은 방법으로 생각을 해봤다.

  1. 이슈 등록
  2. 작업 후 PR 등록 ( 빌드 / 테스트 자동화, 라벨링 자동화 )
  3. Main에 Merge
  4. 적절한 시기에 release/0.0.1 같은 브랜치 생성
  5. release 브랜치에 main 병합 ( 릴리즈 자동화로 버전 관리)
  6. 적절한 시기에 배포 ( 배포 자동화 )

 

다른 자동화 workflow는 이미 이전 포스트에 등록을 했으니, 이번엔 릴리즈 자동화를 하고자 한다.

아래는 이번 포스트에 쓰일 Release Drafter action이다.

 

Release Drafter · Actions · GitHub Marketplace

 

Release Drafter - GitHub Marketplace

Drafts your next release notes as pull requests are merged into master

github.com

 

1. /.github/workflows/release_drafter.yml 생성

name: Auto Labeling

on:
  pull_request:
    branches:
      - main

permissions:
  contents: write
  pull-requests: write
  packages: write

jobs:
  update_release_draft:
    runs-on: self-hosted
    steps:
      - uses: release-drafter/release-drafter@v6
        with:
          config-name: release_drafter_config.yml
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

 

2. /.github/release_drafter_config.yml 생성

name-template: 'v$RESOLVED_VERSION 🌈'
tag-template: 'v$RESOLVED_VERSION'
categories:
  - title: '🚀 Features'
    labels:
      - 'feature'
      - 'enhancement'
  - title: '🐛 Bug Fixes'
    labels:
      - 'fix'
      - 'bugfix'
      - 'bug'
  - title: '🧰 Maintenance'
    label: 'chore'
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
version-resolver:
  major:
    labels:
      - 'major'
  minor:
    labels:
      - 'minor'
  patch:
    labels:
      - 'patch'
  default: patch
template: |
  ## Changes

  $CHANGES

 

참고로 config 파일은 해당 브랜치에 병합이 되어야 인식할 수 있다.

자세한 설명은 위 사이트에서 확인 가능하다.

728x90
반응형
728x90
반응형

workflow를 사용하는 방법은 매우 간단하다

프로젝트/.github/workflows/ 경로 아래에 yml 파일을 만들면 인식해서 실행시켜준다.

 

1. /.github/workflows/auto_labeling.yml 생성

name: Auto Labeling

on:
  pull_request:
    types: [ opened, reopened, synchronize ]

permissions:
  contents: write
  pull-requests: write
  packages: write

jobs:
  update_release_draft:
    runs-on: self-hosted
    steps:
      - uses: release-drafter/release-drafter@v6
        with:
          commitish: main
          config-name: auto_labeling_config.yml
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

 

pull_request가 실행 될 때마다 작동하게 설계했으며, config 파일의 경로는 .github/ 이어야 한다.

권한 또한 중요하니 잊지 않도록 한다.

 

runs-on은 환경에 맞는 코드를 쓰면 된다.

 

2. /.github/auto_labeling_config.yml 작성

template: |
  ## What’s Changed

  $CHANGES
autolabeler:
  - label: 'Component: Client'
    files:
      - 'client/**'
  - label: 'Type: Bug'
    title:
      - '/^fix(\([a-zA-Z][a-zA-Z]\))?:/i'

template 은 필수 코드 이므로 추가하고, 경로와 PR title을 사용한 규칙을 지정하도록 한다.

사실상 template은 해당 작업에서 사용되진 않으며, 릴리즈 자동화에서 쓰일 예정이다.

title의 정규 표현식은 원하는대로 바꿀 수도 있다.

728x90
반응형
728x90
반응형

Pull Request를 올릴 때 자동으로 Label을 달아주는 workflow를 만들어보자.

 

먼저 .github/workflows에 workflow를 만들어준다.

name: Auto Labeling

on:
  pull_request:
    types: [ opened, reopened, synchronize ]

permissions:
  contents: write
  pull-requests: write
  packages: write

jobs:
  update_release_draft:
    runs-on: self-hosted
    steps:
      - uses: release-drafter/release-drafter@v6
        with:
          commitish: main
          config-name: auto_labeling.yml
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

 

그 다음 config 파일인 auto_labeling.yml 파일을 .github/ 경로에 만들어준다.

config 파일은 main 브랜치에 병합이 된 후 인식이 가능하니 알아두도록 하자.

 

template: |
  ## What’s Changed

  $CHANGES
autolabeler:
  - label: 'Component: Client'
    files:
      - 'client/**'
  - label: 'Component: DB'
    files:
      - 'docker/postgres/**'
  - label: 'Component: Server'
    files:
      - 'server/**'
  - label: 'Type: Bug'
    title:
      - '/^fix(\([a-zA-Z][a-zA-Z]\))?:/i'
  - label: 'Type: Build/CI/CD'
    title:
      - '/^build(\([a-zA-Z][a-zA-Z]\))?:/i'
      - '/^ci(\([a-zA-Z][a-zA-Z]\))?:/i'
      - '/^cd(\([a-zA-Z][a-zA-Z]\))?:/i'
  - label: 'Type: Change'
    title:
      - '/^change(\([a-zA-Z][a-zA-Z]\))?:/i'
  - label: 'Type: Chore'
    title:
      - '/^chore(\([a-zA-Z][a-zA-Z]\))?:/i'
  - label: 'Type: Documentation'
    title:
      - '/^docs(\([a-zA-Z][a-zA-Z]\))?:/i'
  - label: 'Type: Feature'
    title:
      - '/^feat(\([a-zA-Z][a-zA-Z]\))?:/i'
  - label: 'Type: Style'
    title:
      - '/^style(\([a-zA-Z][a-zA-Z]\))?:/i'
  - label: 'Type: Test'
    title:
      - '/^test(\([a-zA-Z][a-zA-Z]\))?:/i'
728x90
반응형
728x90
반응형

 

1. 액세스 토큰 생성

Personal Access Tokens (Classic) (github.com)  에서 패키지에 대한 권한을 가진 토큰을 생성한다.

생성되는 토큰 값을 저장한 뒤 원하는 곳에 txt 파일로 만들어두고, 아래 명령어 중 하나로 로그인 가능하다.

$ docker login https://ghcr.io -u outsideris // 입력 후 패스워드 토큰값 입력
$ cat TOKEN.txt | docker login https://ghcr.io -u outsideris --password-stdin

 

2. self_host runner를 만들고 등록해준다.

About self-hosted runners - GitHub Docs

 

About self-hosted runners - GitHub Docs

You can host your own runners and customize the environment used to run jobs in your GitHub Actions workflows.

docs.github.com

 

3. workflow를 만들어준다.

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created
# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle

name: Workflow

permissions:
  contents: read
  packages: write
  checks: write

# 어떤 이벤트가 발생하면 workflow 실행할 지 명시
on:
  # main 브랜치에 pull_request 발생 시
  pull_request:
    branches:
      - main


# 위 이벤트 발생 시 실행될 작업들
jobs:
  # 빌드 후 Container Registry에 image 등록
  push_to_registry:
    # VM의실행 환경 지정 => self-hosted
    runs-on: self-hosted

    # 실행될 jobs 순서 대로 명시
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Postgres Start
        env:
          ENV_FILE: ${{ secrets.ENV_FILE }}
        run: echo "$ENV_FILE" > ./.env &&
          docker image prune &&
          docker rm -f $(docker ps -qa) &&
          docker compose up db -d

      # JDK 17 설치
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      # Gradle Build를 위한 권한 부여
      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      # Gradle Build
      - name: Build with Gradle
        run: ./gradlew clean build

      # 테스트 결과를 PR에 코멘트로 달아줌
      - name: Publish test result
        uses: EnricoMi/publish-unit-test-result-action@v2.15.1
        if: always()
        with:
          files: '**/build/test-results/test/*.xml'

      # 테스트 실패 시 해당 코드 라인에 코멘트 달아줌.
      - name: When test fail, a comment is registered in the error code line.
        uses: mikepenz/action-junit-report@v4.1.0
        if: always()
        with:
          report_paths: '**/build/test-results/test/*.xml'

      # GitHub Container Registry 로그인
      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ secrets.GHUB_USERNAME }}
          password: ${{ secrets.GHUB_TOKEN }}

      # Docker image 빌드 및 push
      - name: Server Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./server/Dockerfile.prod
          push: true
          tags: ghcr.io/${{ secrets.GHUB_USERNAME }}/server:${{ secrets.RELEASE_VERSION }}

      - name: Client Build and push
        uses: docker/build-push-action@v5
        with:
          context: ./client
          file: ./client/Dockerfile.prod
          push: true
          tags: ghcr.io/${{ secrets.GHUB_USERNAME }}/client:${{ secrets.RELEASE_VERSION }}

      - name: Nginx Build and push
        uses: docker/build-push-action@v5
        with:
          context: ./docker/nginx
          push: true
          build-args: BUILD_ENV=prod
          tags: ghcr.io/${{ secrets.GHUB_USERNAME }}/nginx:${{ secrets.RELEASE_VERSION }}

      - name: DB Build and push
        uses: docker/build-push-action@v5
        with:
          context: ./docker/postgres
          push: true
          tags: ghcr.io/${{ secrets.GHUB_USERNAME }}/db:${{ secrets.RELEASE_VERSION }}

  # 서버에 배포
  deploy_via_ssh:
    needs: push_to_registry
    runs-on: self-hosted

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # Env 추가
      - name: Add Env
        env:
          ENV_FILE: ${{ secrets.ENV_FILE }}
          GHUB_USERNAME: ${{ secrets.GHUB_USERNAME }}
          RELEASE_VERSION: ${{ secrets.RELEASE_VERSION }}
        run: echo "$ENV_FILE" > ./.env &&
          echo -e "GHUB_USERNAME=$GHUB_USERNAME \nRELEASE_VERSION=$RELEASE_VERSION" >> ./.env

      # .env, docker-compose.prod.yml 파일 scp로 서버에 전송
      - name: copy files to target server via scp
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USERNAME }}
          key: ${{ secrets.DEPLOY_KEY }}
          port: ${{ secrets.DEPLOY_PORT }}
          source: ./docker-compose.prod.yml, ./.env
          target: /home

      #  원격으로 docker compose up 실행
      - name: remote docker-compose up via ssh
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USERNAME }}
          key: ${{ secrets.DEPLOY_KEY }}
          port: ${{ secrets.DEPLOY_PORT }}
          envs: APPTOKEN,USERNAME
          script: |
            echo ${{ secrets.GHUB_TOKEN }} | sudo docker login ghcr.io --username ${{ secrets.GHUB_USERNAME }} --password-stdin &&
            cd /home &&
            sudo docker pull ghcr.io/${{ secrets.GHUB_USERNAME }}/server:${{ secrets.RELEASE_VERSION }} &&
            sudo docker pull ghcr.io/${{ secrets.GHUB_USERNAME }}/client:${{ secrets.RELEASE_VERSION }} &&
            sudo docker pull ghcr.io/${{ secrets.GHUB_USERNAME }}/db:${{ secrets.RELEASE_VERSION }} &&
            sudo docker pull ghcr.io/${{ secrets.GHUB_USERNAME }}/nginx:${{ secrets.RELEASE_VERSION }}

 

 

4. Dockefile

ghcr(컨테이너 레지스트리)에서 image를 pull 하므로 배포용 Docker 파일을 다음과같이 바꿔야 한다.

    app:
        image: ghcr.io/${IMAGE_REPO}:${RELEASE_VERSION}

 

5. secrets

github repository -> Settings -> Secrets and variables에서 secrets 항목들을 추가해준다.

참고로 github username은 반드시 소문자로 해야된다.


self-hosted 로 사용할 경우 서버의 사용자에 Docker 권한을 부여해주어야 한다.

 

How to fix docker: Got permission denied issue - Stack Overflow

 

How to fix docker: Got permission denied issue

I installed Docker in my machine where I have Ubuntu OS. When I run: sudo docker run hello-world All is ok, but I want to hide the sudo command to make the command shorter. If I write the command

stackoverflow.com

 

 

참고

GitHub Action Docker Compose deployments via SSH (servicestack.net)

 

728x90
반응형
728x90
반응형

이번 포스트에서는 Dockerfile의 환경 ( dev or prod )에 따라 Nginx를 다르게 빌드하는 법을 알아보겠다.

 

1. dev.default.conf 생성

upstream client{
  server client:3000;
}

upstream server{
  server server:8080;
}

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    #access_log  /var/log/nginx/host.access.log  main;
    location /api {
        proxy_pass http://server;
        proxy_redirect off;
        rewrite ^/api/(.*)$ /$1 break;
    }

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
        proxy_pass http://client;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

 

2,  prod.default.conf 생성

upstream client{
  server client:3000;
}

upstream server{
  server server:8080;
}

server {
    listen       80;
    listen  [::]:80;
    server_name  [도메인이름];

    location ^~ /.well-known/acme-challenge/ {
      default_type "text/plain";
      root /var/www/certbot;
    }

    location / {
        return 301 https://[도메인이름]$request_uri;
    }
}

server {

        listen 443 ssl default_server;
        listen [::]:443 ssl default_server;
        server_name  [도메인이름];

        ssl_certificate /etc/nginx/ssl/live/[도메인이름]/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/live/[도메인이름]/privkey.pem;

        #access_log  /var/log/nginx/host.access.log  main;
        location /api {
            proxy_pass http://server;
            proxy_redirect off;
            rewrite ^/api/(.*)$ /$1 break;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
        }

        location / {
            proxy_pass http://client;
            root   /usr/share/nginx/html;
            index  index.html index.htm;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }

        #allow deny ip
        #allow 12.0.0.0;
        #deny all;
}

 

3. .env 변수 추가

# Build 환경 변수 설정
BUILD_ENV=dev

 

4. docker compose 파일 수정

services:
  nginx:
    build:
      context: ./docker/nginx
      args:
        BUILD_ENV: ${BUILD_ENV} // 이 부분 추가
    ports:
      - "80:80"
      - "443:443"
    restart: always
    environment:
      TZ: Asia/Seoul
      BUILD_ENV: ${BUILD_ENV}
    volumes:
      - ${CERTBOT_WWW_DIR}:/var/www/certbot/:ro
      - ${CERTBOT_CONF_DIR}:/etc/nginx/ssl/:ro
    depends_on:
      - client
      - server

 

5. Dockerfile 수정

FROM nginx:latest

ARG BUILD_ENV=BUILD_ENV

COPY ./$BUILD_ENV.default.conf /etc/nginx/conf.d/default.conf

CMD ["nginx", "-g", "daemon off;"]
728x90
반응형

+ Recent posts