swagger  OpenApiCustomizer를 사용하여 커스텀 하기
web/Spring

swagger OpenApiCustomizer를 사용하여 커스텀 하기

반응형

회사에서 사용하고 있는 rpc library가 있는데 해당 rpc는 proto를 사용하는것이 아닌 java class로 만든 IDL을 사용하고 있다. 

https://youtu.be/iOoquUhKT5g

출처 2023 우아콘

 

method를 정의하고 method에 사용된 request, response 객체를 사용해서 rpc 호출을 하는 구조이다. 기존에 http로 통신하는 경우에는 spring rest docs를 사용하거나 spring swagger ui를 사용해서 api명세를 편하게 만들어서 외부 사용하는 팀에 전달 할 수 있었다.

 

하지만 java method기반으로 통신 프로토콜을 정의하는 방식에서는 swagger 기존 방식으로는 사용할 수 없기 때문에 이를 custom해줘야했다. 그 과정에서 문서를 만들기 위해서 custom한 과정을 정리한다.

 

RequestBody, ApiResponse에 대한 custom annotation으로의 대체

기존에는 @Schema 애노테이션을 붙여서 RequestBody, ApiResponse로 사용될 객체에 클래스 위, 필드위에 설명을 적용할 수 있었다. 하지만 내부 library에 경우 @Schema를 붙여서 사용하지 않고 특정 custom annotation을 사용하고 있다. 여기서는 그 이름을 CustomSchemeAnnotation이라고 했다.

 

package com.wedul.openapi.annotation;

import io.swagger.v3.oas.annotations.media.Schema;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Schema
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomSchemeAnnotation {

    String description() default "";

    String example() default "";

    String format() default "";

}

 

이 Annotation을 기존 @Scheme와 같이 RequestBody, ApiResponse로 사용될 객체에 명세를 작성해줄 수 있다. 여기서는 @Schema에서 제공하는 필드 중 내부적으로 사용할 필드 세가지만 가지고 왔다.

 

RequestBody (CustomRequest)

package com.wedul.openapi.annotation;

import java.util.List;
import java.util.Map;

@CustomSchemeAnnotation(description = "custom request class")
public class CustomRequest {

    @CustomSchemeAnnotation(description = "custom annotation name", example = "wedul", format = "String")
    private String name;

    @CustomSchemeAnnotation(description = "custom annotation age", example = "10", format = "int")
    private int age;

    @CustomSchemeAnnotation(description = "list of string", example = "[\"classA\", \"classB\"]", format = "array of string")
    private List<String> stringList;

    @CustomSchemeAnnotation(description = "data list example", example = "{\"fieldName\":\"exampleField\"}", format = "array of Data")
    private Data data;

    @CustomSchemeAnnotation(description = "generic string wrapper", example = "{\"value\":\"hello\"}", format = "GenericWrapper<String>")
    private GenericWrapper<String> wrappedString;

    @CustomSchemeAnnotation(description = "generic data wrapper", example = "{\"value\":{\"fieldName\":\"nestedField\"}}", format = "GenericWrapper<Data>")
    private GenericWrapper<Data> wrappedData;

    @CustomSchemeAnnotation(description = "metadata map", example = "{\"key1\": 1, \"key2\": 2}", format = "Map<String, Integer>")
    private Map<String, Integer> metadata;

}



package com.wedul.openapi.annotation;

@CustomSchemeAnnotation(description = "data inner class")
public class Data {
    @CustomSchemeAnnotation(description = "field name in data", example = "exampleField", format = "string")
    private String fieldName;
}


package com.wedul.openapi.annotation;

@CustomSchemeAnnotation(description = "generic wrapper")
public class GenericWrapper<T> {

    @CustomSchemeAnnotation(description = "wrapped value", example = "exampleValue", format = "generic type")
    private T value;
}

 

 

ApiResponse

package com.wedul.openapi.annotation;

@CustomSchemeAnnotation
public class CustomResponse {

    @CustomSchemeAnnotation(description = "result Message", example = "this is result.", format = "String")
    private String resultMessage;

    public CustomResponse(String resultMessage) {
        this.resultMessage = resultMessage;
    }

    public CustomResponse() {

    }
}

 

 

RestController의 명세인 Operation을 대체 할 Custom annotation

http가 아닌 메소드이름 자체가 path가 되어야한다. OpenApiCustomizer에서 path에 대한 작업을 진행하겠지만 path가 될 method를 명시해야하기 때문에 사용하고자 하는 method에 custom annotation CustomMethodAnnotation를 추가했다.

package com.wedul.openapi.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomMethodAnnotation {

    String title();

}

 

package com.wedul.openapi.annotation;

public class CustomMethodImpl implements CustomMethod {

    @Override
    @CustomMethodAnnotation(title = "test method")
    public CustomResponse test(CustomRequest request) {
        return new CustomResponse();
    }
}

 

 

OpenApiCustomizer를 문서 커스텀

기존 open api 문서를 생성하는 방식이 아닌 새롭게 정의한 애노테이션을 사용하도록 해야하고 path를 메소드 이름으로 사용할 수 있도록 커스텀을 진행해야한다. 이를 커스텀 하기 위해서 OpenApiCustomizer를 커스텀을 진행한다.

 

package com.wedul.openapi.annotation.config;

import com.wedul.openapi.annotation.CustomMethodAnnotation;
import com.wedul.openapi.annotation.CustomSchemeAnnotation;
import io.swagger.v3.core.util.PrimitiveType;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.media.*;
import io.swagger.v3.oas.models.parameters.RequestBody;
import io.swagger.v3.oas.models.responses.ApiResponse;
import io.swagger.v3.oas.models.responses.ApiResponses;
import org.reflections.Reflections;
import org.reflections.scanners.Scanners;
import org.reflections.util.ConfigurationBuilder;
import org.springdoc.core.customizers.OpenApiCustomizer;

import java.lang.reflect.*;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class MethodCustomizer implements OpenApiCustomizer {

    @Override
    public void customise(OpenAPI openApi) {
        Reflections reflections = new Reflections(
                new ConfigurationBuilder()
                        .forPackages("com.wedul")
                        .addScanners(Scanners.TypesAnnotated, Scanners.MethodsAnnotated, Scanners.SubTypes)
        );

        Set<Method> annotatedMethods = reflections.getMethodsAnnotatedWith(CustomMethodAnnotation.class);

        for (Method annotatedMethod : annotatedMethods) {
            CustomMethodAnnotation annotation = annotatedMethod.getAnnotation(CustomMethodAnnotation.class);

            Operation operation = new Operation();
            operation.setSummary(annotation.title());
            operation.setOperationId(annotatedMethod.getName());

            Parameter[] parameters = annotatedMethod.getParameters();
            Class<?> requestType = parameters[0].getType();

            Schema<?> schema = buildSchemaFromCustomAnnotation(requestType, openApi);

            RequestBody requestBody = new RequestBody()
                    .content(new Content().addMediaType("application/json",
                            new MediaType().schema(schema)));
            operation.setRequestBody(requestBody);


            Class<?> returnType = annotatedMethod.getReturnType();
            Schema<?> responseSchema = buildSchemaFromCustomAnnotation(returnType, openApi);

            ApiResponse apiResResponse = new ApiResponse()
                    .content(new Content().addMediaType("application/json",
                            new MediaType().schema(responseSchema)));

            operation.setRequestBody(requestBody);
            operation.setResponses(new ApiResponses().addApiResponse("200", apiResResponse));

            PathItem pathItem = new PathItem().get(operation);
            openApi.path(annotatedMethod.getName(), pathItem);
        }
    }

    public Schema<?> buildSchemaFromCustomAnnotation(Class<?> clazz, OpenAPI openApi) {
        Schema<?> schema = new ObjectSchema();

        CustomSchemeAnnotation classAnnotation = clazz.getAnnotation(CustomSchemeAnnotation.class);
        if (classAnnotation != null) {
            applyCustomAnnotationToSchema(schema, classAnnotation);
        }

        for (Field field : clazz.getDeclaredFields()) {
            field.setAccessible(true);
            CustomSchemeAnnotation fieldAnnotation = field.getAnnotation(CustomSchemeAnnotation.class);
            if (fieldAnnotation != null) {
                Schema<?> fieldSchema = resolveFieldSchema(field.getGenericType(), fieldAnnotation, openApi);
                schema.addProperties(field.getName(), fieldSchema);
            }
        }
        openApi.getComponents().addSchemas(clazz.getSimpleName(), schema);
        return schema;
    }

    private Schema<?> resolveFieldSchema(Type type, CustomSchemeAnnotation annotation, OpenAPI openApi) {
        Schema<?> schema;

        if (type instanceof ParameterizedType) {
            ParameterizedType pt = (ParameterizedType) type;
            Type raw = pt.getRawType();
            Type actual = pt.getActualTypeArguments()[0];

            if (raw == Map.class) {
                Type valueType = pt.getActualTypeArguments()[1];
                Schema<?> valueSchema = resolveFieldSchema(valueType, annotation, openApi);

                schema = new MapSchema().additionalProperties(valueSchema);
            } else if (raw == List.class || raw == Set.class) {
                schema = new ArraySchema().items(resolveFieldSchema(actual, annotation, openApi));
            } else {
                schema = resolveFieldSchema(actual, annotation, openApi);
            }
        } else if (type instanceof Class<?>) {
            Class<?> clazz = (Class<?>) type;
            schema = PrimitiveType.createProperty(clazz);
            if (schema == null) {
                buildSchemaFromCustomAnnotation(clazz, openApi);
                schema = new Schema<>().$ref("#/components/schemas/" + clazz.getSimpleName());
            }
        } else {
            schema = new StringSchema();
        }

        applyCustomAnnotationToSchema(schema, annotation);
        return schema;
    }

    private void applyCustomAnnotationToSchema(Schema<?> schema, CustomSchemeAnnotation ann) {
        if (!ann.description().isEmpty()) schema.setDescription(ann.description());
        if (!ann.example().isEmpty()) schema.setExample(ann.example());
        if (!ann.format().isEmpty()) schema.setFormat(ann.format());
    }

}

 

 

void customise(OpenAPI openApi)

-> OpenApiCustomizer 인터페이스에 정의된 문서로 이곳을 통해서 openapi를 커스텀을 진행한다. 우선 com.wedul 로 시작하는 패키지 기준으로 애노테이션이 붙은 클래스들을 스캔한다. Operation의 기준이 될 method 들을 먼저 스캐닝하고 찾은 메소드의 CustomMethodAnnotation의 필드를 꺼내서 Operation객체를 새로 생성한다. 그 다음 해당 메소드의 Parameter와 response를 꺼내서 schema를 등록한다. 과정은 아래 메소드로 나눠서 설명한다.

 

 

Schema<?> buildSchemaFromCustomAnnotation(Class<?> clazz, OpenAPI openApi)

-> class에 annotation이 달려있으면 상위 schema에 대해서 설명을 추가한다. 그 다음 필드를 순회하면서 CustomSchemeAnnotation이 달려있는 정보를 꺼내서 filed별 schema를 생성하고 클래스 schema에 properties로 추가한다.

다 생성 된 후 현재 class의 schema를 componets에 추가해준다. (스키마 재사용을 위해)

 

private Schema<?> resolveFieldSchema(Type type, CustomSchemeAnnotation annotation, OpenAPI openApi)

-> RequestBody, ApiResponse 클래스 내부에 정의된 filed들에 대해서 schema를 만드는 로직이다. 이 로직을 통해서 ParameterizedType의 경우 collection과 제네릭에 대한 처리를 진행 할 수 있다. 그리고 그 이외 Primitive type에 대한 처리와 추가 처리 되지 못한건 string schema 처리를 진행했다.

 

 

최종 화면

 

 

전체 코드는 이곳에서 볼 수 있다.

https://github.com/weduls/custom-openapi/tree/main

반응형