728x90
반응형

프로젝트를 진행하다가, DTO 필드들을 파싱하는 함수를 만들게 되었다.

어노테이션을 필드에 선언하여 원하는 로직을 구성할 수도 있다.

 

예를 들어, 아래 DTO가 있다고 하자

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReadDto {

    public static final int MODEL_LENGTH = 35;

    private Integer dcv;
    private Integer dca;
    private Integer dcw;
}

 

 

여기서 만약, 필드 별 필요한 데이터의 타입 등이 있을 경우 아래와 같이 어노테이션을 만들어서 Dto 필드에 넣어준다.

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ModbusValue {

    int offset();

    ModbusValueType type() default UINT_16;

}

 

바뀐  Dto

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReadDto {

    public static final int MODEL_LENGTH = 35;

    @ModbusValue(offset = 0, type = INT_16)
    private Integer dcv;

    @ModbusValue(offset = 1, type = INT_16)
    private Integer dca;

    @ModbusValue(offset = 2, type = INT_16)
    private Integer dcw;
}

 

특정한 Dto가 아닌, 제네릭으로 받아서 Dto마다의 결과값을 반환하기 위해 제네릭 메소드로 생성한다.

    public <T> T getDataObject(T object) {
        try {
            // Dto의 static 필드값이 필요한 경우
            int quantity = object.getClass().getField(MODEL_LENGTH)
                .getInt(object);
            Class<?> modbusEntity = object.getClass();
            Field[] columns = modbusEntity.getDeclaredFields();
            
            // 필드 별 로직 구성
            Arrays.stream(columns)
                .parallel()
                .forEach(column -> {
                    try {
                        ModbusValue annotation = column.getAnnotation(ModbusValue.class);
                        if (annotation == null) {
                            return;
                        }

                        int offset = annotation.offset();
                        var type = annotation.type();
                        Object value = getModbusValue(type, offset);
                        column.setAccessible(true);
                        column.set(object, castType(value, column.getType().getSimpleName()));

                    } catch (IllegalAccessException e) {
                        throw new RuntimeException(e);
                    }
                });
            return object; // 최종 DTO 객체 반환
        } catch (Exception e) {
            log.error("[ getDtoWithModbusRawData Error ] message: {}", e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }

 

이렇게 하면, 원하는 Dto의 필드들에 값을 주입하고 다시 Dto를 반환하게 할 수 있다.

 

아래와 같이 원하는 변수로 선언 후 사용할 수 있다.

ReadDto readDto = getDataObject(new ReadDto());
728x90
반응형
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
반응형

회사에서 API 게이트웨이 서버를 만들게 되었다.

검색을 해도 중구난방 잘 안되어있어서 경험하면서 기본적인 틀을 기록하고자 한다.

 

1. build.gradle에 추가해준다.

ext {
    set('snippetsDir', file("build/generated-snippets"))
    set('springCloudVersion', "2022.0.4")
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

dependencies {

	...
    
    implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'org.postgresql:postgresql'
    runtimeOnly 'org.postgresql:r2dbc-postgresql'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
}

 

2. application-properties.yml 파일 수정

server:
  port: 8070

spring:
  jackson:
    timezone: Asia/Seoul
  data:
    r2dbc:
      repositories:
        enabled: true
  datasource:
    url: r2dbc:postgresql://localhost:5433/postgres
    username: postgres
    password: postgres
    driver-class-name: org.postgresql.Driver
  r2dbc:
    url: r2dbc:postgresql://localhost:5433/postgres
    username: postgres
    password: postgres
  cloud:
    gateway:
      default-filters: # Gateway 공통 필터
        - name: GlobalFilter
          args:
            baseMessage: hello world
      routes:
        - id: router-1
          uri: http://localhost:3000
          predicates:
            - Path=/**

 

비동기 서버를 위한 r2dbc를 사용해야 한다.

 

3. GlobalFilter 생성

@Slf4j
@Component
public class GlobalFilter extends AbstractGatewayFilterFactory<FilterDto> {

    public GlobalFilter() {
        super(FilterDto.class);
    }

    @Override
    public GatewayFilter apply(FilterDto dto) {
        return (exchange, chain) -> {
            log.info("GlobalFilter baseMessage: {}", dto.getMessage());
            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                log.info("GlobalFilter End: {}", exchange.getResponse());
            }));
        };
    }
}

 

4. FilterDto 생성

@Getter
public class FilterDto {

    private String message;
}

 

5. ApiRoute Entity 생성

@Entity
@Getter
@Table(name = "api_route")
@TableGenerator(name = "api_route", allocationSize = 1)
public class ApiRoute {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO, generator = "api_route_seq")
    @SequenceGenerator(name = "api_route_seq", sequenceName = "api_route_seq", allocationSize = 1)

    @Column(name = "id")
    private String id;

    @Column
    private String routeIdentifier;
    @Column
    private String uri;
    @Column
    private String method;
    @Column
    private String path;
}

 

6. ApiRouteRepository 생성

@Repository
public interface ApiRouteRepository extends R2dbcRepository<ApiRoute, String> {

}

 

7. ApiRouteService 생성

Service Interface는 아래의 Override 한 메소드만 추가해주면 된다.

@Service
@RequiredArgsConstructor
public class ApiRouteServiceImpl implements ApiRouteService {

    private final ApiRouteRepository apiRouteRepository;

    @Override
    public Flux<ApiRoute> getAll() {
        return this.apiRouteRepository.findAll();
    }

    public Mono<ApiRoute> create(ApiRoute apiRoute) {
        return this.apiRouteRepository.save(apiRoute);
    }

    public Mono<ApiRoute> getById(String id) {
        return this.apiRouteRepository.findById(id);
    }
}

 

8. ApiPathRouteLocatorImpl 생성

@AllArgsConstructor
public class ApiPathRouteLocatorImpl implements RouteLocator {

    private final ApiRouteService apiRouteService;
    private final RouteLocatorBuilder routeLocatorBuilder;

    @Override
    public Flux<Route> getRoutes() {
        RouteLocatorBuilder.Builder routesBuilder = routeLocatorBuilder.routes();
        return apiRouteService.getAll()
            .map(apiRoute -> routesBuilder.route(String.valueOf(apiRoute.getRouteIdentifier()),
                predicateSpec -> setPredicateSpec(apiRoute, predicateSpec)))
            .collectList()
            .flatMapMany(builders -> routesBuilder.build()
                .getRoutes());
    }

    private Buildable<Route> setPredicateSpec(ApiRoute apiRoute, PredicateSpec predicateSpec) {
        BooleanSpec booleanSpec = predicateSpec.path(apiRoute.getPath());
        if (!StringUtils.isEmpty(apiRoute.getMethod())) {
            booleanSpec.and()
                .method(apiRoute.getMethod());
        }
        return booleanSpec.uri(apiRoute.getUri());
    }

    @Override
    public Flux<Route> getRoutesByMetadata(Map<String, Object> metadata) {
        return RouteLocator.super.getRoutesByMetadata(metadata);
    }
}

 

9. GatewayConfig 생성

@Configuration
@Slf4j
public class GatewayConfig {

    @Bean
    public RouteLocator routeLocator(ApiRouteService routeService,
        RouteLocatorBuilder routeLocationBuilder) {
        return new ApiPathRouteLocatorImpl(routeService, routeLocationBuilder);
    }

}

 

여기까지가 기본적인 프록시를 위한 라우터이다.

다음 부터는 API로 route를 CRUD 하기 위한 작업이다.

 

10. ApiRouteRouter Configuration 생성

@Configuration
public class ApiRouteRouter {

    @Bean
    public RouterFunction<ServerResponse> route(ApiRouteHandler apiRouteHandler) {
        return RouterFunctions.route(POST("/routes")
                .and(accept(MediaType.APPLICATION_JSON)), apiRouteHandler::create)
            .andRoute(GET("/routes/{routeId}")
                .and(accept(MediaType.APPLICATION_JSON)), apiRouteHandler::getById)
            .andRoute(GET("/routes/refresh-routes")
                .and(accept(MediaType.APPLICATION_JSON)), apiRouteHandler::refreshRoutes);
    }
}

 

11. ApiROuteHandler 생성

@RequiredArgsConstructor
@Component
@Slf4j
public class ApiRouteHandler {

    private final ApiRouteService routeService;

    private final GatewayRoutesRefresher gatewayRoutesRefresher;

    public Mono<ServerResponse> create(ServerRequest serverRequest) {
        Mono<ApiRoute> apiRoute = serverRequest.bodyToMono(ApiRoute.class);
        return apiRoute.flatMap(route ->
            ServerResponse.status(HttpStatus.OK)
                .contentType(MediaType.APPLICATION_JSON)
                .body(routeService.create(route), ApiRoute.class));
    }

    public Mono<ServerResponse> getById(ServerRequest serverRequest) {
        log.info("serverRequest.pathVariable(\"routeId\") = {}",
            serverRequest.pathVariable("routeId"));
        final String apiId = serverRequest.pathVariable("routeId");
        Mono<ApiRoute> apiRoute = routeService.getById(apiId);
        return apiRoute.flatMap(route -> ServerResponse.ok()
                .body(fromValue(route)))
            .switchIfEmpty(ServerResponse.notFound()
                .build());
    }

    public Mono<ServerResponse> refreshRoutes(ServerRequest serverRequest) {
        gatewayRoutesRefresher.refreshRoutes();
        return ServerResponse.ok().body(BodyInserters.fromObject("Routes reloaded successfully"));
    }
}

 

12. GatewayRoutesRefresher 생성

@Component
public class GatewayRoutesRefresher implements ApplicationEventPublisherAware {

    private ApplicationEventPublisher applicationEventPublisher;

    @Override
    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
        this.applicationEventPublisher = applicationEventPublisher;
    }

    /**
     * Refresh the routes to load from data store
     */
    public void refreshRoutes() {
        applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this));
    }
}

 

 


만약, domain Hostname에 따라 Proxy되는 서버의 주소를 변경하고자 하면 다음과 같은 방법을 쓸 수 있다.

@Override
    public GatewayFilter apply(HostNameFilterDto hostNameFilterdto) {

        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();

            request.getHeaders();

            String[] split = request.getURI().getHost().split("\\.");
            String siteId = split[0];

            Mono<Site> siteMono = siteService.findById(siteId);
            Site site = siteMono.share().block();

            String uri = Objects.requireNonNull(site).getConnectHost();
            int port = site.getConnectPort();
            if (port != 80) {
                uri += ":" + port;
            }

            Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);
            Route newRoute = Route.async()
                .id(site.getId())
                .uri(uri)
                .predicate(serverWebExchange -> false)
                .order(Objects.requireNonNull(route).getOrder())
                .filters(route.getFilters())
                .build();
            exchange.getAttributes().put(GATEWAY_ROUTE_ATTR, newRoute);

            return chain.filter(exchange);
        };
    }
728x90
반응형
728x90
반응형

Dockerfile로 필요한 confluent 패키지를 다운로드 받고, add_connector.sh 와 connect-distributed.properties 파일을 사용한다.

 

- add_connector.sh 에선 Kafka 연결을 확인하고 connect-mqtt-source.json파일대로 curl을 실행한다.

- connect-distributed.properties 파일로 connect 기본 설정을 한다.

 

1. Kafka, Kafka-Connect Docker Compose 생성

  kafka:
    image: bitnami/kafka:3.4
    container_name: kafka
    ports:
      - "9092:9092"
    environment:
      ALLOW_PLAINTEXT_LISTENER: yes
      KAFKA_BROKER_ID: 1
      KAFKA_CFG_PROCESS_ROLES: broker,controller
      KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_CFG_INTER_BROKER_LISTENER_NAME: CLIENT
      KAFKA_CFG_LISTENERS: CONTROLLER://:9093,CLIENT://:9092
      KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,CLIENT:PLAINTEXT
      KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: true
      KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_CFG_ADVERTISED_LISTENERS: CLIENT://kafka:9092
      KAFKA_CFG_DELETE_TOPIC_ENABLE: true
      KAFKA_CFG_BROKER_ID: 1
      KAFKA_CFG_NODE_ID: 1
      KRAFT_MODE: true
      KAFKA_ENABLE_KRAFT: yes
      TZ: Asia/Seoul
    volumes:
      - ${KAFKA_DATA_DIR}:/bitnami/kafka/data

  kafka-connect:
    build: ./docker/kafka-connect
    container_name: kafka-connect
    ports:
      - "8083:8083"
    depends_on:
      - kafka
    volumes:
      - ${KAFKA_CONNECT_DATA_DIR}:/var/lib/kafka-connect

 

2. Kafka Connect Dockerfile 생성

프로젝트/docker/kafka-connect

FROM bitnami/kafka:3.4

ARG MODULE=kafka-connect
ENV MODULE=${MODULE}

USER root

WORKDIR /connectors

RUN apt-get update && apt-get upgrade -y && apt-get install unzip -y

RUN curl -O https://client.hub.confluent.io/confluent-hub-client-latest.tar.gz
RUN tar -zxvf confluent-hub-client-latest.tar.gz
RUN rm -f confluent-hub-client-latest.tar.gz
RUN mkdir component && mkdir config && cd config && touch worker.properties
RUN cd /connectors/bin && ./confluent-hub install --no-prompt confluentinc/kafka-connect-mqtt:1.7.1 \
    --component-dir /connectors/component \
    --worker-configs /connectors/config/worker.properties
RUN cp -p /connectors/component/confluentinc-kafka-connect-mqtt/lib/* /opt/bitnami/kafka/libs

WORKDIR /tmp
COPY . .
CMD /tmp/add_connector.sh & /opt/bitnami/kafka/bin/connect-distributed.sh /tmp/connect-distributed.properties

 

3. connect-distributed.properties 생성

bootstrap.servers=kafka:9092
group.id=connect-cluster
key.converter=org.apache.kafka.connect.json.JsonConverter
value.converter=org.apache.kafka.connect.json.JsonConverter
key.converter.schemas.enable=true
value.converter.schemas.enable=true
offset.storage.topic=connect-offsets
offset.storage.replication.factor=1
config.storage.topic=connect-configs
config.storage.replication.factor=1
status.storage.topic=connect-status
status.storage.replication.factor=1
converter.encoding=UTF-8
offset.flush.interval.ms=10000
topic.prefix=test
tasks.max=5
plugin.path=/connectors

 

4. connect-mqtt-source.json 생성

{
  "name": "mqtt-source-1",
  "config": {
    "bootstrap.servers": "kafka:9092",
    "connector.class": "io.confluent.connect.mqtt.MqttSourceConnector",
    "tasks.max": "1",
    "mqtt.server.uri": "tcp://host.docker.internal:9000",
    "mqtt.topics": "data",
    "kafka.topic": "mqtt.data",
    "key.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "confluent.topic.bootstrap.servers": "kafka:9092",
    "confluent.topic.replication.factor": 1
  }
}

 

5. add_connector.sh 생성

# Wait for Kafka Connect to start
while true
do
  status=$(curl -s -o /dev/null -w %{http_code} http://localhost:8083/connectors)
  if [ "$status" -eq 200 ]; then
    echo "Kafka Connect has started."
    break
  else
    echo "Waiting for Kafka Connect to start..."
    sleep 3
  fi
done


echo "Start to add kafka-connect"

# ADD Mqtt Source Connectors
curl -d @/tmp/connect-mqtt-source.json -H "Content-Type: application/json" -X POST http://localhost:8083/connectors

echo "Complete to add kafka-connect"

 

 

6. Kafka consumer (Spring Boot) 사용법은 아래를 참고한다.

Spring boot | Spring Apache Kafka 사용법 ( with Docker Container ) | Consumer :: 티포의개발일지 (tistory.com)

 

Spring boot | Spring Apache Kafka 사용법 ( with Docker Container ) | Consumer

이번엔 Consumer 서버를 만들어보고 Producer 서버에서 생성한 토픽을 구독하여 읽어보기로 하자. 1. application.properties server.port=8081 spring.kafka.consumer.bootstrap-servers=localhost:9092 spring.kafka.consumer.auto-offset-

typo.tistory.com

 

7. docker compose 실행 후 mqtt에서 보낸 데이터를 확인한다.

 

728x90
반응형
728x90
반응형

1. Mosquitto Docker compose 설정

  mosquitto:
    container_name: lpms-mosquitto
    restart: always
    image: eclipse-mosquitto
    ports:
      - "9000:1883"
      - "9001:9001"
    volumes:
      - ${MOSQUITTO_DIR}/config/mosquitto.conf:/mosquitto/config/mosquitto.conf
      - ${MOSQUITTO_DIR}/data:/mosquitto/data
      - ${MOSQUITTO_DIR}/log:/mosquitto/log

- mosquitto.conf

allow_anonymous true
connection_messages true
log_type all
listener 1883

 

2. Spring boot build.gradle 추가

    implementation 'org.springframework.boot:spring-boot-starter-integration'
    implementation 'org.springframework.integration:spring-integration-mqtt'

 

3. MqttProperties 클래스 생성

@ConfigurationProperties(prefix = "mqtt")
@Component
@Data
@Validated
public class MqttProperties {

    private String name;
    private String password;
    private String url;
    private Integer qos;
    private String topic;

}

 

4. MqttConfig 클래스 생성

본 프로젝트에서는 outBound만 사용 할 예정이다.

@Configuration
@RequiredArgsConstructor
public class MqttConfig {

    private static final String MQTT_CLIENT_ID = MqttAsyncClient.generateClientId();
    private final MqttProperties properties;

    /**
     * DefaultMqttPahoClientFactory를 통해 MQTT 클라이언트를 등록
     */
    @Bean
    public DefaultMqttPahoClientFactory defaultMqttPahoClientFactory() {
        DefaultMqttPahoClientFactory clientFactory = new DefaultMqttPahoClientFactory();
        MqttConnectOptions options = new MqttConnectOptions();
        options.setCleanSession(true);
        options.setServerURIs(new String[]{properties.getUrl()});
        options.setUserName(properties.getName());
        options.setPassword(properties.getPassword().toCharArray());
        clientFactory.setConnectionOptions(options);
        return clientFactory;
    }

    /**
     * MQTT 클라이언트를 통해 메시지를 구독하기 위하여 MqttPahoMessageDrivenChannelAdapter를 통해 메시지 수신을 위한 채널을 구성
     */
//    @Bean
//    public MessageChannel mqttInputChannel() {
//        return new DirectChannel();
//    }
//
//    @Bean
//    public MessageProducer inboundChannel() {
//        MqttPahoMessageDrivenChannelAdapter adapter =
//            new MqttPahoMessageDrivenChannelAdapter(
//                properties.getUrl(),
//                MQTT_CLIENT_ID,
//                properties.getTopic());
//        adapter.setCompletionTimeout(5000);
//        adapter.setConverter(new DefaultPahoMessageConverter());
//        adapter.setQos(1);
//        adapter.setOutputChannel(mqttInputChannel());
//        return adapter;
//    }
//
//    @Bean
//    @ServiceActivator(inputChannel = "mqttInputChannel")
//    public MessageHandler inboundMessageHandler() {
//        return message -> {
//            String topic = (String) message.getHeaders().get(MqttHeaders.RECEIVED_TOPIC);
//            System.out.println("Topic:" + topic);
//            System.out.println("Payload" + message.getPayload());
//        };
//    }

    /**
     * Message outbound를 위한 채널 구성
     */

    @Bean
    public MessageChannel mqttOutboundChannel() {
        return new DirectChannel();
    }

    @Bean
    @ServiceActivator(inputChannel = "mqttOutboundChannel")
    public MessageHandler mqttOutbound(DefaultMqttPahoClientFactory clientFactory) {
        MqttPahoMessageHandler messageHandler =
            new MqttPahoMessageHandler(MQTT_CLIENT_ID, clientFactory);
        messageHandler.setAsync(true);
        messageHandler.setDefaultQos(1);
        return messageHandler;
    }

    @MessagingGateway(defaultRequestChannel = "mqttOutboundChannel")
    public interface MyGateway {

        void sendToMqtt(String data, @Header(MqttHeaders.TOPIC) String topic);

    }

}

 

5. MqttService 클래스 생성

@Service
@RequiredArgsConstructor
public class MqttService {

    private final MyGateway myGateway;

    public void send() {
        myGateway.sendToMqtt("12345", "/a/b/q");
    }
}

 

6. 도커 컨테이너 만들고, Spring 서버 킨 다음 필요한 곳에서 사용

728x90
반응형
728x90
반응형

Spring Security를 사용할 때 UserDetail 객체를 사용하는데, 예를들어

@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {

@Getter
private Long id;

위와 같은 애들을 Request로 받아올 수 있다.

 

아래와 같이 사용하면 된다.

 

@PostMapping("/logout")
public CommonResponse<Boolean> logout(
@AuthenticationPrincipal UserDetailsImpl userDetails,
HttpServletResponse response
) {
728x90
반응형

+ Recent posts