diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b479a15..274c167 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,20 +33,21 @@ jobs: - name: Run tests and generate reports run: ./gradlew testAndReport - - name: Run Sonar analysis - if: matrix.Java == '17' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew sonar -x test --no-watch-fs - - name: Upload Artifact uses: actions/upload-artifact@v4 + if: always() with: name: report-java-${{ matrix.Java }} path: build/reports/** retention-days: 5 + - name: Run Sonar analysis + if: matrix.Java == '17' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./gradlew sonar -x test --no-watch-fs + build: runs-on: ubuntu-latest needs: [test] diff --git a/README.md b/README.md index 75cb050..ecd54d6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Library providing basic generic functionality required by any HTTP web service. ``` // https://mvnrepository.com/artifact/ee.bitweb/spring-core -implementation group: 'ee.bitweb', name: 'spring-core', version: '3.2.0' +implementation group: 'ee.bitweb', name: 'spring-core', version: '4.0.0' ``` Review available versions in [Maven Central](https://mvnrepository.com/artifact/ee.bitweb/spring-core). diff --git a/build.gradle b/build.gradle index 4d8023f..15c3a2e 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ plugins { } group 'ee.bitweb' -version '3.4.0' +version '4.0.0' java { sourceCompatibility = '17' } diff --git a/src/main/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapter.java b/src/main/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapter.java index 1facf18..d56bd7b 100644 --- a/src/main/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapter.java +++ b/src/main/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapter.java @@ -41,6 +41,8 @@ public void write(Map container) { if (currentContext != null) { MDC.setContextMap(currentContext); + } else { + MDC.clear(); } } diff --git a/src/main/java/ee/bitweb/core/retrofit/ConditionalOnEnabledRetrofitMapper.java b/src/main/java/ee/bitweb/core/retrofit/ConditionalOnEnabledRetrofitMapper.java new file mode 100644 index 0000000..a4b2a9e --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/ConditionalOnEnabledRetrofitMapper.java @@ -0,0 +1,49 @@ +package ee.bitweb.core.retrofit; + +import ee.bitweb.core.exception.CoreException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.core.type.AnnotatedTypeMetadata; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Map; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Conditional(value = ConditionalOnEnabledRetrofitMapper.MapperEnabled.class) +public @interface ConditionalOnEnabledRetrofitMapper { + + String mapper(); + + class MapperEnabled implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + Map attributes = metadata.getAnnotationAttributes( + ConditionalOnEnabledRetrofitMapper.class.getName() + ); + if (attributes == null) return false; + + Object mapperKeyOb = attributes.get("mapper"); + + if (mapperKeyOb == null) return false; + + String mapperKey = (String) mapperKeyOb; + + RetrofitProperties config = Binder.get( + context.getEnvironment() + ).bind( + RetrofitProperties.PREFIX, RetrofitProperties.class + ).orElseThrow( + () -> new CoreException("Error occurred while trying to bind environment to RetrofitProperties") + ); + + return config.getLogging().getMappers().contains(mapperKey); + } + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/RetrofitAutoConfiguration.java b/src/main/java/ee/bitweb/core/retrofit/RetrofitAutoConfiguration.java index 9c3f3b7..4d91160 100644 --- a/src/main/java/ee/bitweb/core/retrofit/RetrofitAutoConfiguration.java +++ b/src/main/java/ee/bitweb/core/retrofit/RetrofitAutoConfiguration.java @@ -1,20 +1,29 @@ package ee.bitweb.core.retrofit; import com.fasterxml.jackson.databind.ObjectMapper; +import ee.bitweb.core.retrofit.builder.LoggingLevel; import ee.bitweb.core.retrofit.interceptor.auth.AuthTokenInjectInterceptor; import ee.bitweb.core.retrofit.interceptor.auth.TokenProvider; import ee.bitweb.core.retrofit.interceptor.auth.criteria.AuthTokenCriteria; import ee.bitweb.core.retrofit.interceptor.auth.criteria.WhitelistCriteria; +import ee.bitweb.core.retrofit.logging.NoopRetrofitLoggingInterceptor; +import ee.bitweb.core.retrofit.logging.RetrofitLoggingInterceptor; +import ee.bitweb.core.retrofit.logging.RetrofitLoggingInterceptorImplementation; +import ee.bitweb.core.retrofit.logging.mappers.*; +import ee.bitweb.core.retrofit.logging.writers.RetrofitLogLoggerWriterAdapter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import retrofit2.Converter; import retrofit2.converter.jackson.JacksonConverterFactory; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.regex.Pattern; @@ -22,6 +31,7 @@ @Configuration @RequiredArgsConstructor @ConditionalOnProperty(value = RetrofitProperties.PREFIX + ".auto-configuration", havingValue = "true") +@EnableConfigurationProperties({RetrofitProperties.class}) public class RetrofitAutoConfiguration { @Bean @@ -64,4 +74,105 @@ public AuthTokenCriteria defaultCriteria(RetrofitProperties properties) { return criteria; } + + @Bean("defaultRetrofitLoggingInterceptor") + @Primary + public RetrofitLoggingInterceptor defaultRetrofitLoggingInterceptor( + List mappers, + RetrofitProperties properties + ) { + if (properties.getLogging().getLevel() == LoggingLevel.NONE) { + log.info("Create Default Retrofit Logging Interceptor for logging level NONE"); + + return new NoopRetrofitLoggingInterceptor(); + } + + log.info( + "Create Default Retrofit Logging Interceptor with writer {}", + RetrofitLogLoggerWriterAdapter.class.getSimpleName() + ); + + for (RetrofitLoggingMapper mapper : mappers) { + log.info("Applying Retrofit Log Data Mapper: {}", mapper.getClass()); + } + + return new RetrofitLoggingInterceptorImplementation(mappers, new RetrofitLogLoggerWriterAdapter()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledRetrofitMapper(mapper = RetrofitRequestMethodMapper.KEY) + public RetrofitRequestMethodMapper retrofitRequestMethodMapper() { + return new RetrofitRequestMethodMapper(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledRetrofitMapper(mapper = RetrofitRequestUrlMapper.KEY) + public RetrofitRequestUrlMapper retrofitRequestUrlMapper() { + return new RetrofitRequestUrlMapper(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledRetrofitMapper(mapper = RetrofitRequestHeadersMapper.KEY) + public RetrofitRequestHeadersMapper retrofitRequestHeadersMapper( + RetrofitProperties retrofitProperties + ) { + return new RetrofitRequestHeadersMapper(new HashSet<>(retrofitProperties.getLogging().getSuppressedHeaders())); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledRetrofitMapper(mapper = RetrofitRequestBodySizeMapper.KEY) + public RetrofitRequestBodySizeMapper retrofitRequestBodySizeMapper() { + return new RetrofitRequestBodySizeMapper(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledRetrofitMapper(mapper = RetrofitRequestBodyMapper.KEY) + public RetrofitRequestBodyMapper retrofitRequestBodyMapper( + RetrofitProperties retrofitProperties + ) { + return new RetrofitRequestBodyMapper( + retrofitProperties.getLogging().getMaxLoggableRequestBodySize().intValue(), + new HashSet<>(retrofitProperties.getLogging().getRedactedBodyUrls()) + ); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledRetrofitMapper(mapper = RetrofitResponseStatusCodeMapper.KEY) + public RetrofitResponseStatusCodeMapper retrofitResponseStatusCodeMapper() { + return new RetrofitResponseStatusCodeMapper(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledRetrofitMapper(mapper = RetrofitResponseHeadersMapper.KEY) + public RetrofitResponseHeadersMapper retrofitResponseHeadersMapper( + RetrofitProperties retrofitProperties + ) { + return new RetrofitResponseHeadersMapper(new HashSet<>(retrofitProperties.getLogging().getSuppressedHeaders())); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledRetrofitMapper(mapper = RetrofitResponseBodySizeMapper.KEY) + public RetrofitResponseBodySizeMapper retrofitResponseBodySizeMapper() { + return new RetrofitResponseBodySizeMapper(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledRetrofitMapper(mapper = RetrofitResponseBodyMapper.KEY) + public RetrofitResponseBodyMapper responseBodyMapper( + RetrofitProperties retrofitProperties + ) { + return new RetrofitResponseBodyMapper( + new HashSet<>(retrofitProperties.getLogging().getRedactedBodyUrls()), + retrofitProperties.getLogging().getMaxLoggableResponseBodySize().intValue() + ); + } } diff --git a/src/main/java/ee/bitweb/core/retrofit/RetrofitProperties.java b/src/main/java/ee/bitweb/core/retrofit/RetrofitProperties.java index cb78661..92addd5 100644 --- a/src/main/java/ee/bitweb/core/retrofit/RetrofitProperties.java +++ b/src/main/java/ee/bitweb/core/retrofit/RetrofitProperties.java @@ -1,25 +1,22 @@ package ee.bitweb.core.retrofit; import ee.bitweb.core.retrofit.builder.LoggingLevel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.http.HttpHeaders; -import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.validation.annotation.Validated; -import jakarta.validation.Valid; -import jakarta.validation.constraints.AssertTrue; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import static ee.bitweb.core.retrofit.RetrofitProperties.PREFIX; -@Component @Setter @Getter @Validated @@ -47,7 +44,28 @@ public static class Logging { @NotNull private LoggingLevel level = LoggingLevel.BASIC; + + @NotNull + private Long maxLoggableRequestBodySize = 1024 * 10L; + + @NotNull + private Long maxLoggableResponseBodySize = 1024 * 10L; + private List<@NotBlank String> suppressedHeaders = new ArrayList<>(); + private List<@NotBlank String> redactedBodyUrls = new ArrayList<>(); + + private List<@NotBlank String> mappers = new ArrayList<>(); + + public List<@NotBlank String> getMappers() { + if (level == LoggingLevel.CUSTOM) { + return mappers; + } else { + Set enabledMappers = new HashSet<>(level.getMappers()); + enabledMappers.addAll(mappers); + + return enabledMappers.stream().toList(); + } + } } @Getter diff --git a/src/main/java/ee/bitweb/core/retrofit/RetrofitRequestExecutor.java b/src/main/java/ee/bitweb/core/retrofit/RetrofitRequestExecutor.java index 074a443..3b00c9a 100644 --- a/src/main/java/ee/bitweb/core/retrofit/RetrofitRequestExecutor.java +++ b/src/main/java/ee/bitweb/core/retrofit/RetrofitRequestExecutor.java @@ -16,7 +16,7 @@ public class RetrofitRequestExecutor { public static T execute(Call> request) { retrofit2.Response> response = doRequest(request); - if (response.body().getData() == null) { + if (response.body() == null || response.body().getData() == null) { throw RetrofitException.of(EMPTY_RESPONSE_BODY_ERROR, request, response); } diff --git a/src/main/java/ee/bitweb/core/retrofit/builder/LoggingLevel.java b/src/main/java/ee/bitweb/core/retrofit/builder/LoggingLevel.java index c4fd0ee..a9f8332 100644 --- a/src/main/java/ee/bitweb/core/retrofit/builder/LoggingLevel.java +++ b/src/main/java/ee/bitweb/core/retrofit/builder/LoggingLevel.java @@ -1,18 +1,52 @@ package ee.bitweb.core.retrofit.builder; +import ee.bitweb.core.retrofit.logging.mappers.*; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; -import okhttp3.logging.HttpLoggingInterceptor; +import java.util.ArrayList; +import java.util.List; + +@Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public enum LoggingLevel { - NONE(HttpLoggingInterceptor.Level.NONE), - BASIC(HttpLoggingInterceptor.Level.BASIC), - HEADERS(HttpLoggingInterceptor.Level.HEADERS), - BODY(HttpLoggingInterceptor.Level.BODY); + NONE(List.of()), + BASIC(basicMappers()), + HEADERS(headerMappers()), + BODY(bodyMappers()), + CUSTOM(List.of()); + + private final List mappers; + + private static List basicMappers() { + return new ArrayList<>(List.of( + RetrofitRequestMethodMapper.KEY, + RetrofitRequestUrlMapper.KEY, + RetrofitRequestBodySizeMapper.KEY, + RetrofitResponseStatusCodeMapper.KEY, + RetrofitResponseBodySizeMapper.KEY + )); + } + + private static List headerMappers() { + List mappers = basicMappers(); + mappers.addAll(List.of( + RetrofitRequestHeadersMapper.KEY, + RetrofitResponseHeadersMapper.KEY + )); + + return mappers; + } + + private static List bodyMappers() { + List mappers = headerMappers(); + mappers.addAll(List.of( + RetrofitRequestBodyMapper.KEY, + RetrofitResponseBodyMapper.KEY + )); - @Getter(AccessLevel.PACKAGE) - private HttpLoggingInterceptor.Level level; + return mappers; + } } diff --git a/src/main/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilder.java b/src/main/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilder.java index b26e041..2ea11df 100644 --- a/src/main/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilder.java +++ b/src/main/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilder.java @@ -3,10 +3,11 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import ee.bitweb.core.exception.CoreException; +import ee.bitweb.core.retrofit.logging.RetrofitLoggingInterceptor; import lombok.extern.slf4j.Slf4j; import okhttp3.Interceptor; import okhttp3.OkHttpClient; -import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Converter; import retrofit2.Retrofit; import retrofit2.converter.jackson.JacksonConverterFactory; @@ -20,7 +21,6 @@ public class RetrofitApiBuilder { public static final ObjectMapper DEFAULT_OBJECT_MAPPER = new ObjectMapper(); - public static final LoggingLevel DEFAULT_LOGGING_LEVEL = LoggingLevel.BASIC; static { DEFAULT_OBJECT_MAPPER.registerModule(new JavaTimeModule()) @@ -33,24 +33,21 @@ public class RetrofitApiBuilder { private final String url; private final Class definition; - private final HttpLoggingInterceptor loggingInterceptor; private Converter.Factory converterFactory; private OkHttpClient.Builder clientBuilder; - public static RetrofitApiBuilder create(String baseUrl, Class definition) { + public static RetrofitApiBuilder create(String baseUrl, Class definition, RetrofitLoggingInterceptor loggingInterceptor) { return new RetrofitApiBuilder<>( baseUrl, definition, - new HttpLoggingInterceptor() - .setLevel(DEFAULT_LOGGING_LEVEL.getLevel()) + loggingInterceptor ); } - private RetrofitApiBuilder(String url, Class definition, HttpLoggingInterceptor loggingInterceptor) { + private RetrofitApiBuilder(String url, Class definition, RetrofitLoggingInterceptor loggingInterceptor) { this.url = url; this.definition = definition; - this.loggingInterceptor = loggingInterceptor; clientBuilder = createDefaultBuilder(loggingInterceptor); } @@ -90,22 +87,8 @@ public RetrofitApiBuilder removeAll(Class definition) } public RetrofitApiBuilder replaceAllOfType(Interceptor interceptor) { - removeAll(interceptor.getClass()); - add(interceptor); - - return this; - } - - public RetrofitApiBuilder loggingLevel(LoggingLevel level) { - loggingInterceptor.setLevel(level.getLevel()); - - return this; - } - - public RetrofitApiBuilder suppressedHeaders(List headers) { - headers.forEach(loggingInterceptor::redactHeader); - - return this; + return removeAll(interceptor.getClass()) + .add(interceptor); } public RetrofitApiBuilder addAll(Collection interceptors) { @@ -153,6 +136,20 @@ public RetrofitApiBuilder writeTimeout(long timeout) { public T build() { Converter.Factory factory = converterFactory != null ? converterFactory : DEFAULT_CONVERTER_FACTORY; + if (clientBuilder.interceptors().stream().filter(RetrofitLoggingInterceptor.class::isInstance).toList().size() > 1) { + throw new CoreException("Multiple logging interceptors detected at build for definition %s".formatted(definition.getName())); + } + + clientBuilder.interceptors().sort((i1, i2) -> { + if (i1 instanceof RetrofitLoggingInterceptor) { + return -1; + } else if (i2 instanceof RetrofitLoggingInterceptor) { + return 1; + } else { + return 0; + } + }); + log.info( "Built Retrofit API for host {} with definition {}, interceptors {} and converter factory {}", url, definition.getName(), clientBuilder.interceptors(), factory @@ -166,9 +163,12 @@ public T build() { .build().create(definition); } - private OkHttpClient.Builder createDefaultBuilder(HttpLoggingInterceptor loggingInterceptor) { + private OkHttpClient.Builder createDefaultBuilder(RetrofitLoggingInterceptor loggingInterceptor) { var httpClientBuilder = new OkHttpClient.Builder(); - httpClientBuilder.interceptors().add(loggingInterceptor); + + if (loggingInterceptor != null) { + httpClientBuilder.interceptors().add(loggingInterceptor); + } return httpClientBuilder; } diff --git a/src/main/java/ee/bitweb/core/retrofit/builder/SpringAwareRetrofitBuilder.java b/src/main/java/ee/bitweb/core/retrofit/builder/SpringAwareRetrofitBuilder.java index 53674ea..ff199e8 100644 --- a/src/main/java/ee/bitweb/core/retrofit/builder/SpringAwareRetrofitBuilder.java +++ b/src/main/java/ee/bitweb/core/retrofit/builder/SpringAwareRetrofitBuilder.java @@ -2,7 +2,9 @@ import ee.bitweb.core.retrofit.RetrofitProperties; import ee.bitweb.core.retrofit.interceptor.InterceptorBean; +import ee.bitweb.core.retrofit.logging.RetrofitLoggingInterceptor; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import retrofit2.Converter; @@ -10,6 +12,7 @@ import java.util.ArrayList; import java.util.List; +@Slf4j @Component @RequiredArgsConstructor @ConditionalOnProperty(value = "ee.bitweb.core.retrofit.auto-configuration", havingValue = "true") @@ -18,13 +21,18 @@ public class SpringAwareRetrofitBuilder { private final List defaultInterceptors; private final Converter.Factory defaultConverterFactory; private final RetrofitProperties properties; + private final RetrofitLoggingInterceptor defaultLoggingInterceptor; public RetrofitApiBuilder create(String baseUrl, Class definition) { + return configure(RetrofitApiBuilder.create(baseUrl, definition, defaultLoggingInterceptor)); + } + + public RetrofitApiBuilder create(String baseUrl, Class definition, RetrofitLoggingInterceptor loggingInterceptor) { + return configure(RetrofitApiBuilder.create(baseUrl, definition, loggingInterceptor)); + } - return RetrofitApiBuilder.create(baseUrl, definition) - .addAll(new ArrayList<>(defaultInterceptors)) - .loggingLevel(properties.getLogging().getLevel()) - .suppressedHeaders(properties.getLogging().getSuppressedHeaders()) + private RetrofitApiBuilder configure(RetrofitApiBuilder api) { + return api.addAll(new ArrayList<>(defaultInterceptors)) .callTimeout(properties.getTimeout().getCall()) .connectTimeout(properties.getTimeout().getConnect()) .readTimeout(properties.getTimeout().getRead()) diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/NoopRetrofitLoggingInterceptor.java b/src/main/java/ee/bitweb/core/retrofit/logging/NoopRetrofitLoggingInterceptor.java new file mode 100644 index 0000000..7199b31 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/NoopRetrofitLoggingInterceptor.java @@ -0,0 +1,15 @@ +package ee.bitweb.core.retrofit.logging; + +import okhttp3.Response; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; + +public class NoopRetrofitLoggingInterceptor implements RetrofitLoggingInterceptor { + + @NotNull + @Override + public Response intercept(@NotNull Chain chain) throws IOException { + return chain.proceed(chain.request()); + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/RetrofitLoggingInterceptor.java b/src/main/java/ee/bitweb/core/retrofit/logging/RetrofitLoggingInterceptor.java new file mode 100644 index 0000000..716e2e0 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/RetrofitLoggingInterceptor.java @@ -0,0 +1,6 @@ +package ee.bitweb.core.retrofit.logging; + +import okhttp3.Interceptor; + +public interface RetrofitLoggingInterceptor extends Interceptor { +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/RetrofitLoggingInterceptorImplementation.java b/src/main/java/ee/bitweb/core/retrofit/logging/RetrofitLoggingInterceptorImplementation.java new file mode 100644 index 0000000..340474c --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/RetrofitLoggingInterceptorImplementation.java @@ -0,0 +1,53 @@ +package ee.bitweb.core.retrofit.logging; + +import ee.bitweb.core.retrofit.logging.mappers.RetrofitLoggingMapper; +import ee.bitweb.core.retrofit.logging.writers.RetrofitLogWriteAdapter; +import lombok.RequiredArgsConstructor; +import okhttp3.Request; +import okhttp3.Response; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RequiredArgsConstructor +public class RetrofitLoggingInterceptorImplementation implements RetrofitLoggingInterceptor { + + public static final String DURATION_KEY = "duration"; + + private final List mappers; + private final RetrofitLogWriteAdapter writer; + + @NotNull + @Override + public Response intercept(@NotNull Chain chain) throws IOException { + Map container = new HashMap<>(); + + var start = System.currentTimeMillis(); + + Response response = null; + try { + response = chain.proceed(chain.request()); + container.put(DURATION_KEY, String.valueOf(System.currentTimeMillis() - start)); + } finally { + Response finalResponse = response; + + Request request = finalResponse != null ? finalResponse.request() : chain.request(); + mappers.forEach(mapper -> mapper.map(request, finalResponse, container)); + + writer.write(container); + } + + return response; + } + + @Override + public String toString() { + return "RetrofitLoggingInterceptorImplementation{" + + "mappers=" + mappers + + ", writer=" + writer + + '}'; + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitBodyMapperHelper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitBodyMapperHelper.java new file mode 100644 index 0000000..eba1e8b --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitBodyMapperHelper.java @@ -0,0 +1,49 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import okhttp3.Headers; +import okio.Buffer; + +import java.io.IOException; +import java.util.Set; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RetrofitBodyMapperHelper { + + public static boolean isProbablyUtf8(Buffer buffer) throws IOException { + var prefix = new Buffer(); + var byteCount = buffer.size() > 64 ? 64 : buffer.size(); + + buffer.copyTo(prefix, 0, byteCount); + + for (int i = 0; i < 16; i++) { + if (prefix.exhausted()) { + break; + } + + var codePoint = prefix.readUtf8CodePoint(); + + if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) { + return false; + } + } + + return true; + } + + public static boolean bodyHasUnknownEncoding(Headers headers) { + var contentEncoding = headers.get("Content-Encoding"); + + if (contentEncoding == null) { + return false; + } + + return !contentEncoding.equalsIgnoreCase("identity") && + !contentEncoding.equalsIgnoreCase("gzip"); + } + + public static boolean isRedactBodyUrl(Set redactBodyUrls, String url) { + return redactBodyUrls.contains(url); + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitHeadersMapperHelper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitHeadersMapperHelper.java new file mode 100644 index 0000000..b0c0bc5 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitHeadersMapperHelper.java @@ -0,0 +1,55 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import okhttp3.Headers; +import okhttp3.MediaType; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RetrofitHeadersMapperHelper { + + public static final String CONTENT_TYPE = "Content-Type"; + public static final String CONTENT_LENGTH = "Content-Length"; + + public static String writeHeadersMapAsString(Map headersMap) { + return headersMap.entrySet().stream() + .map(entry -> "%s: %s".formatted(entry.getKey(), entry.getValue())) + .collect(Collectors.joining("; ")); + } + + public static void addHeaderToResult(Set redactHeaders, Map result, String name, String value) { + result.put(name, redactHeaders.contains(name.toLowerCase()) ? " " : value); + } + + public static String getContentTypeValue(Headers headers, MediaType mediaType) { + var headerValue = headers.get(CONTENT_TYPE); + + if (headerValue != null) { + return headerValue; + } + + if (mediaType == null) { + return null; + } + + return mediaType.type(); + } + + public static String getContentLengthValue(Headers headers, Long contentLength) { + var headerValue = headers.get(CONTENT_LENGTH); + + if (headerValue != null) { + return headerValue; + } + + if (contentLength != null && contentLength != -1) { + return String.valueOf(contentLength); + } + + return null; + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitLoggingMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitLoggingMapper.java new file mode 100644 index 0000000..dbee240 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitLoggingMapper.java @@ -0,0 +1,22 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import ee.bitweb.core.exception.CoreException; +import okhttp3.Request; +import okhttp3.Response; + +import java.util.Map; + +public interface RetrofitLoggingMapper { + + String getValue(Request request, Response response); + + String getKey(); + + default void map(Request request, Response response, Map container) { + if (container.containsKey(getKey())) { + throw new CoreException(String.format("Retrofit log container already contains value for key %s", getKey())); + } + + container.put(getKey(), getValue(request, response)); + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodyMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodyMapper.java new file mode 100644 index 0000000..c89a5c7 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodyMapper.java @@ -0,0 +1,91 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.Buffer; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Set; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@Slf4j +@RequiredArgsConstructor +public class RetrofitRequestBodyMapper implements RetrofitLoggingMapper { + + public static final String KEY = "request_body"; + + private final int maxLoggableRequestSize; + private final Set redactBodyUrls; + + @Override + public String getValue(Request request, Response response) { + try { + return getRequestBody(request); + } catch (IOException e) { + log.error("Failed to parse request body.", e); + return "Parse error"; + } + } + + @Override + public String getKey() { + return KEY; + } + + /** + * Stub method to be able to add custom sanitization of body. For example removing passwords and other sensitive data. + * + * @param body String representation of raw request body + * @return sanitized body + */ + protected String sanitizeBody(String body) { + return body; + } + + String getRequestBody(Request request) throws IOException { + var body = request.body(); + + if (body == null) { + return "null"; + } else if (RetrofitBodyMapperHelper.isRedactBodyUrl(redactBodyUrls, request.url().url().toExternalForm())) { + return "(body redacted)"; + } else if (RetrofitBodyMapperHelper.bodyHasUnknownEncoding(request.headers())) { + return "(encoded body omitted)"; + } else if (body.isDuplex()) { + return "(duplex request body omitted)"; + } else if (body.isOneShot()) { + return "(one-shot body omitted)"; + } else { + return getBodyString(body); + } + } + + String getBodyString(RequestBody body) throws IOException { + var buffer = new Buffer(); + body.writeTo(buffer); + + var contentType = body.contentType(); + Charset charSet = contentType != null ? contentType.charset(UTF_8) : UTF_8; + + if (RetrofitBodyMapperHelper.isProbablyUtf8(buffer)) { + assert charSet != null; + var bodyString = buffer.readString(charSet); + + if (body.contentLength() == -1 || body.contentLength() > maxLoggableRequestSize) { + return "%s ... Content size: %s characters".formatted( + bodyString.substring(0, maxLoggableRequestSize), + body.contentLength() + ); + } + + return sanitizeBody(bodyString); + } else { + return "(binary body omitted)"; + } + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodySizeMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodySizeMapper.java new file mode 100644 index 0000000..1c567e8 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodySizeMapper.java @@ -0,0 +1,33 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class RetrofitRequestBodySizeMapper implements RetrofitLoggingMapper { + + public static final String KEY = "request_body_size"; + + @Override + public String getValue(Request request, Response response) { + try { + RequestBody body = request.body(); + + return body != null ? String.valueOf(body.contentLength()) : "-"; + } catch (IOException e) { + log.error("Failed to parse request content length.", e); + return "Parse error."; + } + } + + @Override + public String getKey() { + return KEY; + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestHeadersMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestHeadersMapper.java new file mode 100644 index 0000000..9ea787b --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestHeadersMapper.java @@ -0,0 +1,72 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static ee.bitweb.core.retrofit.logging.mappers.RetrofitHeadersMapperHelper.*; + +@Slf4j +@RequiredArgsConstructor +public class RetrofitRequestHeadersMapper implements RetrofitLoggingMapper { + + public static final String KEY = "request_headers"; + + private final Set redactHeaders; + + @Override + public String getValue(Request request, Response response) { + try { + return getRequestHeadersString(request); + } catch (IOException e) { + log.error("Failed to parse request headers.", e); + return "Parse error"; + } + } + + @Override + public String getKey() { + return KEY; + } + + protected String getRequestHeadersString(Request request) throws IOException { + Map result = new HashMap<>(); + + var requestHeaders = request.headers(); + var requestBody = request.body(); + + String contentType = getRequestContentTypeValue(requestHeaders, requestBody); + String contentLength = getRequestContentLengthValue(requestHeaders, requestBody); + + if (contentType != null) { + RetrofitHeadersMapperHelper.addHeaderToResult(redactHeaders, result, CONTENT_TYPE, contentType); + } + + if (contentLength != null) { + RetrofitHeadersMapperHelper.addHeaderToResult(redactHeaders, result, CONTENT_LENGTH, contentLength); + } + + for (int i = 0; i < requestHeaders.size(); i++) { + var name = requestHeaders.name(i); + if (CONTENT_TYPE.equalsIgnoreCase(name) || CONTENT_LENGTH.equalsIgnoreCase(name)) { + continue; + } + addHeaderToResult(redactHeaders, result, name, requestHeaders.value(i)); + } + + return writeHeadersMapAsString(result); + } + + protected String getRequestContentTypeValue(Headers headers, RequestBody body) { + return RetrofitHeadersMapperHelper.getContentTypeValue(headers, body != null ? body.contentType() : null); + } + + protected String getRequestContentLengthValue(Headers headers, RequestBody body) throws IOException { + return RetrofitHeadersMapperHelper.getContentLengthValue(headers, body != null ? body.contentLength() : null); + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestMethodMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestMethodMapper.java new file mode 100644 index 0000000..2febfad --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestMethodMapper.java @@ -0,0 +1,21 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.RequiredArgsConstructor; +import okhttp3.Request; +import okhttp3.Response; + +@RequiredArgsConstructor +public class RetrofitRequestMethodMapper implements RetrofitLoggingMapper { + + public static final String KEY = "request_method"; + + @Override + public String getKey() { + return KEY; + } + + @Override + public String getValue(Request request, Response response) { + return request.method(); + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestUrlMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestUrlMapper.java new file mode 100644 index 0000000..1afac97 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestUrlMapper.java @@ -0,0 +1,21 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.RequiredArgsConstructor; +import okhttp3.Request; +import okhttp3.Response; + +@RequiredArgsConstructor +public class RetrofitRequestUrlMapper implements RetrofitLoggingMapper { + + public static final String KEY = "request_url"; + + @Override + public String getValue(Request request, Response response) { + return request.url().toString(); + } + + @Override + public String getKey() { + return KEY; + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapper.java new file mode 100644 index 0000000..2fd9274 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapper.java @@ -0,0 +1,99 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import okhttp3.Response; +import okio.Buffer; +import okio.GzipSource; + +import java.io.IOException; +import java.util.Set; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static okhttp3.internal.http.HttpHeaders.promisesBody; + +@Slf4j +@RequiredArgsConstructor +public class RetrofitResponseBodyMapper implements RetrofitLoggingMapper { + + public static final String KEY = "response_body"; + + private final Set redactBodyUrls; + private final int maxLoggableResponseSize; + + @Override + public String getValue(Request request, Response response) { + try { + return getResponseBody(response); + } catch (IOException e) { + log.error("Failed to parse response body.", e); + return "Parse error"; + } + } + + @Override + public String getKey() { + return KEY; + } + + protected String getResponseBody(Response response) throws IOException { + if (RetrofitBodyMapperHelper.isRedactBodyUrl(redactBodyUrls, response.request().url().toString())) { + return "(body redacted)"; + } else if (!promisesBody(response)) { + return ""; + } else if (RetrofitBodyMapperHelper.bodyHasUnknownEncoding(response.headers())) { + return "(encoded body omitted)"; + } else if (response.body() == null) { + return "(body missing)"; + } else { + return parseBody(response); + } + } + + /** + * Stub method to be able to add custom sanitization of body. For example removing passwords and other sensitive data. + * + * @param body String representation of raw response body + * @return sanitized body + */ + protected String sanitizeBody(String body) { + return body; + } + + private String parseBody(Response response) throws IOException { + var responseBody = response.body(); + var contentType = responseBody.contentType(); + var contentLength = responseBody.contentLength(); + var charset = contentType != null ? contentType.charset(UTF_8) : UTF_8; + + var source = responseBody.source(); + source.request(Long.MAX_VALUE); + var buffer = source.getBuffer(); + + if ("gzip".equalsIgnoreCase(response.header("Content-Encoding"))) { + long gzippedLength = buffer.size(); + var gzipSource = new GzipSource(buffer.clone()); + buffer = new Buffer(); + gzipSource.read(buffer, gzippedLength); + } + + if (!RetrofitBodyMapperHelper.isProbablyUtf8(buffer)) { + return "(binary %s-byte body omitted)".formatted(buffer.size()); + } + + if (contentLength != 0L) { + var bodyString = buffer.clone().readString(charset); + if (contentLength > maxLoggableResponseSize) { + return "%s ... Content size: %s characters".formatted( + bodyString.substring(0, maxLoggableResponseSize), + contentLength + ); + } + + return sanitizeBody(bodyString); + } else { + return ""; + } + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodySizeMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodySizeMapper.java new file mode 100644 index 0000000..f2d3503 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodySizeMapper.java @@ -0,0 +1,24 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.RequiredArgsConstructor; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +@RequiredArgsConstructor +public class RetrofitResponseBodySizeMapper implements RetrofitLoggingMapper { + + public static final String KEY = "response_body_size"; + + @Override + public String getValue(Request request, Response response) { + ResponseBody body = response.body(); + + return body != null ? String.valueOf(body.contentLength()) : "-"; + } + + @Override + public String getKey() { + return KEY; + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseHeadersMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseHeadersMapper.java new file mode 100644 index 0000000..2dc3b63 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseHeadersMapper.java @@ -0,0 +1,65 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.RequiredArgsConstructor; +import okhttp3.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static ee.bitweb.core.retrofit.logging.mappers.RetrofitHeadersMapperHelper.CONTENT_LENGTH; +import static ee.bitweb.core.retrofit.logging.mappers.RetrofitHeadersMapperHelper.CONTENT_TYPE; + +@RequiredArgsConstructor +public class RetrofitResponseHeadersMapper implements RetrofitLoggingMapper { + + public static final String KEY = "response_headers"; + + private final Set redactHeaders; + + @Override + public String getValue(Request request, Response response) { + return getResponseHeadersString(response); + } + + @Override + public String getKey() { + return KEY; + } + + protected String getResponseHeadersString(Response response) { + Map result = new HashMap<>(); + + var responseHeaders = response.headers(); + var responseBody = response.body(); + + String contentType = getResponseContentTypeValue(responseHeaders, responseBody); + String contentLength = getResponseContentLengthValue(responseHeaders, responseBody); + + if (contentType != null) { + RetrofitHeadersMapperHelper.addHeaderToResult(redactHeaders, result, CONTENT_TYPE, contentType); + } + + if (contentLength != null) { + RetrofitHeadersMapperHelper.addHeaderToResult(redactHeaders, result, CONTENT_LENGTH, contentLength); + } + + for (int i = 0; i < responseHeaders.size(); i++) { + var name = responseHeaders.name(i); + if (CONTENT_TYPE.equalsIgnoreCase(name) || CONTENT_LENGTH.equalsIgnoreCase(name)) { + continue; + } + RetrofitHeadersMapperHelper.addHeaderToResult(redactHeaders, result, name, responseHeaders.value(i)); + } + + return RetrofitHeadersMapperHelper.writeHeadersMapAsString(result); + } + + protected String getResponseContentTypeValue(Headers headers, ResponseBody body) { + return RetrofitHeadersMapperHelper.getContentTypeValue(headers, body != null ? body.contentType() : null); + } + + protected String getResponseContentLengthValue(Headers headers, ResponseBody body) { + return RetrofitHeadersMapperHelper.getContentLengthValue(headers, body != null ? body.contentLength() : null); + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseStatusCodeMapper.java b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseStatusCodeMapper.java new file mode 100644 index 0000000..36528f6 --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseStatusCodeMapper.java @@ -0,0 +1,21 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import lombok.RequiredArgsConstructor; +import okhttp3.Request; +import okhttp3.Response; + +@RequiredArgsConstructor +public class RetrofitResponseStatusCodeMapper implements RetrofitLoggingMapper { + + public static final String KEY = "response_code"; + + @Override + public String getValue(Request request, Response response) { + return String.valueOf(response.code()); + } + + @Override + public String getKey() { + return KEY; + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/writers/RetrofitLogLoggerWriterAdapter.java b/src/main/java/ee/bitweb/core/retrofit/logging/writers/RetrofitLogLoggerWriterAdapter.java new file mode 100644 index 0000000..df463ed --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/writers/RetrofitLogLoggerWriterAdapter.java @@ -0,0 +1,74 @@ +package ee.bitweb.core.retrofit.logging.writers; + +import ee.bitweb.core.exception.CoreException; +import ee.bitweb.core.retrofit.logging.RetrofitLoggingInterceptorImplementation; +import ee.bitweb.core.retrofit.logging.mappers.RetrofitRequestMethodMapper; +import ee.bitweb.core.retrofit.logging.mappers.RetrofitRequestUrlMapper; +import ee.bitweb.core.retrofit.logging.mappers.RetrofitResponseBodySizeMapper; +import ee.bitweb.core.retrofit.logging.mappers.RetrofitResponseStatusCodeMapper; +import lombok.RequiredArgsConstructor; +import org.slf4j.MDC; + +import java.util.Map; + +@RequiredArgsConstructor +public class RetrofitLogLoggerWriterAdapter implements RetrofitLogWriteAdapter { + + public static final String LOGGER_NAME = "RetrofitLogger"; + + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(LOGGER_NAME); + + @Override + public void write(Map container) { + Map currentContext = MDC.getCopyOfContextMap(); + + try { + log(container); + } finally { + if (currentContext != null) { + MDC.setContextMap(currentContext); + } else { + MDC.clear(); + } + } + } + + protected void log(Map container) { + container.forEach(MDC::put); + + if (!log.isInfoEnabled()) { + logOrThrowError(); + } else { + log.info( + "{} {} {} {}bytes {}ms", + get(container, RetrofitRequestMethodMapper.KEY), + get(container, RetrofitRequestUrlMapper.KEY), + get(container, RetrofitResponseStatusCodeMapper.KEY), + get(container, RetrofitResponseBodySizeMapper.KEY), + get(container, RetrofitLoggingInterceptorImplementation.DURATION_KEY) + ); + } + } + + protected void logOrThrowError() { + String message = ( + "Retrofit interceptor has been enabled, but %s cannot write as log level does not permit INFO entries. This behaviour is strongly " + + "discouraged as the interceptor consumes resources for no real result. Please set property " + + "ee.bitweb.core.retrofit.logging-level=NONE if you wish to avoid this logging." + ).formatted(RetrofitLogLoggerWriterAdapter.class.getSimpleName()); + + if (log.isErrorEnabled()) { + log.error(message); + } else { + throw new CoreException(message); + } + } + + protected String get(Map container, String key) { + if (container.containsKey(key)) { + return container.get(key); + } + + return "-"; + } +} diff --git a/src/main/java/ee/bitweb/core/retrofit/logging/writers/RetrofitLogWriteAdapter.java b/src/main/java/ee/bitweb/core/retrofit/logging/writers/RetrofitLogWriteAdapter.java new file mode 100644 index 0000000..ec5b99a --- /dev/null +++ b/src/main/java/ee/bitweb/core/retrofit/logging/writers/RetrofitLogWriteAdapter.java @@ -0,0 +1,8 @@ +package ee.bitweb.core.retrofit.logging.writers; + +import java.util.Map; + +public interface RetrofitLogWriteAdapter { + + void write(Map container); +} diff --git a/src/test/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapterUnitTests.java b/src/test/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapterUnitTests.java index f088097..f4df90e 100644 --- a/src/test/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapterUnitTests.java +++ b/src/test/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapterUnitTests.java @@ -4,21 +4,24 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.spi.ILoggingEvent; -import ee.bitweb.core.audit.AuditLogFilter; -import ee.bitweb.core.audit.mappers.*; import ee.bitweb.core.utils.MemoryAppender; -import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.http.HttpStatus; import java.util.HashMap; import java.util.List; import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") @ExtendWith(MockitoExtension.class) class AuditLogLoggerWriterAdapterUnitTests { @@ -37,6 +40,11 @@ void beforeEach() { memoryAppender.start(); } + @AfterEach + void tearDown() { + MDC.clear(); + } + @Test void createASingleLogWithNoSecondaryFields() { @@ -44,26 +52,30 @@ void createASingleLogWithNoSecondaryFields() { List events = memoryAppender.getLoggedEvents(); - Assertions.assertEquals(1, events.size()); - Assertions.assertEquals( + assertEquals(1, events.size()); + assertEquals( "Method(POST), URL(/some-url) Status(200 OK) ResponseSize(9) Duration(123 ms)", events.get(0).getFormattedMessage() ); - Assertions.assertEquals(8, events.get(0).getMDCPropertyMap().size()); + assertEquals(8, events.get(0).getMDCPropertyMap().size()); + assertNull(MDC.getCopyOfContextMap()); } @Test void createASingleLogWithSecondaryFieldsAndDebugDisabled() { + MDC.put("key", "value"); adapter.write(createDefaultMap()); List events = memoryAppender.getLoggedEvents(); - Assertions.assertEquals(1, events.size()); - Assertions.assertEquals( + assertEquals(1, events.size()); + assertEquals( "Method(POST), URL(/some-url) Status(200 OK) ResponseSize(9) Duration(123 ms)", events.get(0).getFormattedMessage() ); - Assertions.assertEquals(8, events.get(0).getMDCPropertyMap().size()); + assertEquals(8, events.get(0).getMDCPropertyMap().size()); + assertEquals(1, MDC.getCopyOfContextMap().size()); + assertEquals("value", MDC.get("key")); } @Test @@ -73,31 +85,31 @@ void createASecondaryLogWithSecondaryFieldsAndDebugEnabled() { List events = memoryAppender.getLoggedEvents(); - Assertions.assertEquals(2, events.size()); - Assertions.assertEquals( + assertEquals(2, events.size()); + assertEquals( "Method(POST), URL(/some-url) Status(200 OK) ResponseSize(9) Duration(123 ms)", events.get(0).getFormattedMessage() ); - Assertions.assertEquals( + assertEquals( "Debug audit log", events.get(1).getFormattedMessage() ); - Assertions.assertEquals(8, events.get(0).getMDCPropertyMap().size()); - Assertions.assertEquals(4, events.get(1).getMDCPropertyMap().size()); - + assertEquals(8, events.get(0).getMDCPropertyMap().size()); + assertEquals(4, events.get(1).getMDCPropertyMap().size()); + assertNull(MDC.getCopyOfContextMap()); } public Map createDefaultMap() { Map map = new HashMap<>(); - map.put(TraceIdMapper.KEY, "some-trace-id"); - map.put(ResponseStatusMapper.KEY, HttpStatus.OK.toString()); - map.put(RequestUrlDataMapper.KEY, "/some-url"); - map.put(AuditLogFilter.DURATION_KEY, "123"); - map.put(RequestMethodMapper.KEY, "POST"); - map.put(RequestBodyMapper.KEY, "Some payload"); - map.put(ResponseBodyMapper.KEY, "Some body"); + map.put("trace_id", "some-trace-id"); + map.put("response_status", HttpStatus.OK.toString()); + map.put("url", "/some-url"); + map.put("duration", "123"); + map.put("method", "POST"); + map.put("request_body", "Some payload"); + map.put("response_body", "Some body"); return map; } diff --git a/src/test/java/ee/bitweb/core/retrofit/RetrofitAutoConfigurationTests.java b/src/test/java/ee/bitweb/core/retrofit/RetrofitAutoConfigurationTests.java index d9f5f8c..cfe4a35 100644 --- a/src/test/java/ee/bitweb/core/retrofit/RetrofitAutoConfigurationTests.java +++ b/src/test/java/ee/bitweb/core/retrofit/RetrofitAutoConfigurationTests.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import retrofit2.Converter; import retrofit2.converter.jackson.JacksonConverterFactory; @@ -27,6 +28,7 @@ "ee.bitweb.core.retrofit.auth-token-injector.whitelist-urls[0]=^http?:\\\\/\\\\/localhost:\\\\d{3,5}\\\\/.*" } ) +@ActiveProfiles("retrofit") class RetrofitAutoConfigurationTests { @Autowired diff --git a/src/test/java/ee/bitweb/core/retrofit/RetrofitExecutorTests.java b/src/test/java/ee/bitweb/core/retrofit/RetrofitExecutorTests.java index dab4b5d..2ac63d0 100644 --- a/src/test/java/ee/bitweb/core/retrofit/RetrofitExecutorTests.java +++ b/src/test/java/ee/bitweb/core/retrofit/RetrofitExecutorTests.java @@ -5,7 +5,10 @@ import ee.bitweb.http.server.mock.MockServer; import io.netty.handler.codec.http.HttpMethod; import org.json.JSONObject; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; import org.mockito.junit.jupiter.MockitoExtension; @@ -24,7 +27,7 @@ class RetrofitExecutorTests { @BeforeAll public static void beforeAll() { - api = RetrofitApiBuilder.create(BASE_URL + server.getPort(), ExternalServiceApi.class).build(); + api = RetrofitApiBuilder.create(BASE_URL + server.getPort(), ExternalServiceApi.class, null).build(); } @Test @@ -135,7 +138,8 @@ void onServiceErrorWithRawRequestShouldThrowRetrofitException() { void onConnectionExceptionShouldThrowRetrofitException() { ExternalServiceApi api = RetrofitApiBuilder.create( "http://some-random-url", - ExternalServiceApi.class + ExternalServiceApi.class, + null ).build(); RetrofitException exception = Assertions.assertThrows( diff --git a/src/test/java/ee/bitweb/core/retrofit/RetrofitPropertiesTest.java b/src/test/java/ee/bitweb/core/retrofit/RetrofitPropertiesTest.java new file mode 100644 index 0000000..2dafb8c --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/RetrofitPropertiesTest.java @@ -0,0 +1,43 @@ +package ee.bitweb.core.retrofit; + +import ee.bitweb.core.retrofit.builder.LoggingLevel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +class RetrofitPropertiesTest { + + @Test + @DisplayName("Must return correct mappers with default config") + void testLoggingReturnsCorrectMappersWithDefaultConfiguration() { + RetrofitProperties.Logging properties = new RetrofitProperties.Logging(); + + assertEquals(5, properties.getMappers().size()); + } + + @Test + @DisplayName("Must return only one custom mapper with level CUSTOM") + void testLoggingReturnsConfiguredMappersWithCustomLevel() { + RetrofitProperties.Logging properties = new RetrofitProperties.Logging(); + properties.setLevel(LoggingLevel.CUSTOM); + properties.getMappers().add("custom-mapper"); + + assertEquals(1, properties.getMappers().size()); + assertEquals("custom-mapper", properties.getMappers().get(0)); + } + + @Test + @DisplayName("Must add custom mappers to default mappers") + void testLoggingReturnMergedMappersWhenAdditionalMappersAreProvided() { + RetrofitProperties.Logging properties = new RetrofitProperties.Logging(); + properties.setMappers(List.of("custom-mapper")); + + assertEquals(6, properties.getMappers().size()); + assertTrue(properties.getMappers().contains("custom-mapper")); + } +} diff --git a/src/test/java/ee/bitweb/core/retrofit/builder/LoggingLevelTest.java b/src/test/java/ee/bitweb/core/retrofit/builder/LoggingLevelTest.java new file mode 100644 index 0000000..1ae38f7 --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/builder/LoggingLevelTest.java @@ -0,0 +1,76 @@ +package ee.bitweb.core.retrofit.builder; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +class LoggingLevelTest { + + @Test + @DisplayName("Ensure correct default mappers for level NONE") + void testLevelNone() { + assertTrue(LoggingLevel.NONE.getMappers().isEmpty()); + } + + @Test + @DisplayName("Ensure correct default mappers for level CUSTOM") + void testLevelCustom() { + assertTrue(LoggingLevel.CUSTOM.getMappers().isEmpty()); + } + + @Test + @DisplayName("Ensure correct default mappers for level BASIC") + void testLevelBasic() { + List mappers = LoggingLevel.BASIC.getMappers(); + + assertAll( + () -> assertEquals(5, mappers.size()), + () -> assertTrue(mappers.contains("request_method")), + () -> assertTrue(mappers.contains("request_url")), + () -> assertTrue(mappers.contains("request_body_size")), + () -> assertTrue(mappers.contains("response_code")), + () -> assertTrue(mappers.contains("response_body_size")) + ); + } + + @Test + @DisplayName("Ensure correct default mappers for level HEADERS") + void testLevelHeaders() { + List mappers = LoggingLevel.HEADERS.getMappers(); + + assertAll( + () -> assertEquals(7, mappers.size()), + () -> assertTrue(mappers.contains("request_method")), + () -> assertTrue(mappers.contains("request_url")), + () -> assertTrue(mappers.contains("request_body_size")), + () -> assertTrue(mappers.contains("response_code")), + () -> assertTrue(mappers.contains("response_body_size")), + () -> assertTrue(mappers.contains("request_headers")), + () -> assertTrue(mappers.contains("response_headers")) + ); + } + + @Test + @DisplayName("Ensure correct default mappers for level BODY") + void testLevelBody() { + List mappers = LoggingLevel.BODY.getMappers(); + + assertAll( + () -> assertEquals(9, mappers.size()), + () -> assertTrue(mappers.contains("request_method")), + () -> assertTrue(mappers.contains("request_url")), + () -> assertTrue(mappers.contains("request_body_size")), + () -> assertTrue(mappers.contains("response_code")), + () -> assertTrue(mappers.contains("response_body_size")), + () -> assertTrue(mappers.contains("request_headers")), + () -> assertTrue(mappers.contains("response_headers")), + () -> assertTrue(mappers.contains("request_body")), + () -> assertTrue(mappers.contains("response_body")) + ); + } +} diff --git a/src/test/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilderTests.java b/src/test/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilderTests.java index a2441ec..06b0270 100644 --- a/src/test/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilderTests.java +++ b/src/test/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilderTests.java @@ -8,7 +8,9 @@ import io.netty.handler.codec.http.HttpMethod; import okhttp3.OkHttpClient; import org.json.JSONObject; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; import org.mockito.Mockito; @@ -32,8 +34,9 @@ class RetrofitApiBuilderTests { void defaultBuilderWorksAsExpected() throws Exception { mockServerGet("message", "1"); ExternalServiceApi api = RetrofitApiBuilder.create( - BASE_URL + server.getPort(), - ExternalServiceApi.class + BASE_URL + server.getPort(), + ExternalServiceApi.class, + null ).build(); ExternalServiceApi.Payload response = api.get().execute().body(); @@ -49,8 +52,9 @@ void addedInterceptorShouldBeUsedOnRequest() throws Exception { RequestCountInterceptor customInterceptor = new RequestCountInterceptor(); ExternalServiceApi api = RetrofitApiBuilder.create( - BASE_URL + server.getPort(), - ExternalServiceApi.class + BASE_URL + server.getPort(), + ExternalServiceApi.class, + null ).add( customInterceptor ).build(); @@ -70,8 +74,9 @@ void addedMultipleInterceptorsShouldAllBeUsedOnRequest() throws Exception { RequestCountInterceptor customInterceptor2 = new RequestCountInterceptor(); ExternalServiceApi api = RetrofitApiBuilder.create( - BASE_URL + server.getPort(), - ExternalServiceApi.class + BASE_URL + server.getPort(), + ExternalServiceApi.class, + null ).addAll( List.of( customInterceptor1, @@ -96,8 +101,9 @@ void onAddedCustomClientIsApplied() throws Exception { OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder().addInterceptor(interceptor); ExternalServiceApi api = RetrofitApiBuilder.create( - BASE_URL + server.getPort(), - ExternalServiceApi.class + BASE_URL + server.getPort(), + ExternalServiceApi.class, + null ).clientBuilder( clientBuilder ).build(); @@ -118,8 +124,9 @@ void clearInterceptorsRemovesInterceptors() throws Exception { OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder().addInterceptor(interceptor); ExternalServiceApi api = RetrofitApiBuilder.create( - BASE_URL + server.getPort(), - ExternalServiceApi.class + BASE_URL + server.getPort(), + ExternalServiceApi.class, + null ).clientBuilder( clientBuilder ).emptyInterceptors().build(); @@ -140,8 +147,9 @@ void removeInterceptorRemovesProvidedInterceptorOnly() throws Exception { RequestCountInterceptor customInterceptor2 = new RequestCountInterceptor(); ExternalServiceApi api = RetrofitApiBuilder.create( - BASE_URL + server.getPort(), - ExternalServiceApi.class + BASE_URL + server.getPort(), + ExternalServiceApi.class, + null ).addAll( List.of( customInterceptor1, @@ -170,7 +178,7 @@ void callTimeoutIsApplied() { }).when(clientBuilder).build(); ExternalServiceApi api = RetrofitApiBuilder - .create(BASE_URL + server.getPort(), ExternalServiceApi.class) + .create(BASE_URL + server.getPort(), ExternalServiceApi.class, null) .clientBuilder(clientBuilder) .callTimeout(999) .build(); @@ -190,7 +198,7 @@ void connectTimeoutIsApplied() { }).when(clientBuilder).build(); ExternalServiceApi api = RetrofitApiBuilder - .create(BASE_URL + server.getPort(), ExternalServiceApi.class) + .create(BASE_URL + server.getPort(), ExternalServiceApi.class, null) .clientBuilder(clientBuilder) .connectTimeout(999) .build(); @@ -210,7 +218,7 @@ void readTimeoutIsApplied() { }).when(clientBuilder).build(); ExternalServiceApi api = RetrofitApiBuilder - .create(BASE_URL + server.getPort(), ExternalServiceApi.class) + .create(BASE_URL + server.getPort(), ExternalServiceApi.class, null) .clientBuilder(clientBuilder) .readTimeout(999) .build(); @@ -230,7 +238,7 @@ void writeTimeoutIsApplied() { }).when(clientBuilder).build(); ExternalServiceApi api = RetrofitApiBuilder - .create(BASE_URL + server.getPort(), ExternalServiceApi.class) + .create(BASE_URL + server.getPort(), ExternalServiceApi.class, null) .clientBuilder(clientBuilder) .writeTimeout(999) .build(); @@ -247,8 +255,9 @@ void addedCustomConverterIsApplied() throws Exception { Converter.Factory converter = JacksonConverterFactory.create(mapper); ExternalServiceApi api = RetrofitApiBuilder.create( - BASE_URL + server.getPort(), - ExternalServiceApi.class + BASE_URL + server.getPort(), + ExternalServiceApi.class, + null ).converter( converter ).build(); diff --git a/src/test/java/ee/bitweb/core/retrofit/builder/SpringAwareRetrofitBuilderTests.java b/src/test/java/ee/bitweb/core/retrofit/builder/SpringAwareRetrofitBuilderTests.java index 924ea82..27d2f41 100644 --- a/src/test/java/ee/bitweb/core/retrofit/builder/SpringAwareRetrofitBuilderTests.java +++ b/src/test/java/ee/bitweb/core/retrofit/builder/SpringAwareRetrofitBuilderTests.java @@ -34,8 +34,6 @@ class SpringAwareRetrofitBuilderTests { @RegisterExtension private static final MockServer server = new MockServer(HttpMethod.GET, "/request"); - - @Autowired private SpringAwareRetrofitBuilder builder; diff --git a/src/test/java/ee/bitweb/core/retrofit/helpers/ExternalServiceApi.java b/src/test/java/ee/bitweb/core/retrofit/helpers/ExternalServiceApi.java index bae4423..da4ae99 100644 --- a/src/test/java/ee/bitweb/core/retrofit/helpers/ExternalServiceApi.java +++ b/src/test/java/ee/bitweb/core/retrofit/helpers/ExternalServiceApi.java @@ -1,11 +1,14 @@ package ee.bitweb.core.retrofit.helpers; import ee.bitweb.core.retrofit.Response; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import retrofit2.Call; +import retrofit2.http.Body; import retrofit2.http.GET; +import retrofit2.http.POST; public interface ExternalServiceApi { @@ -15,9 +18,13 @@ public interface ExternalServiceApi { @GET("/data-request") Call> getWrappedInResponse(); + @POST("/data-post") + Call postData(@Body Payload payload); + @Setter @Getter @NoArgsConstructor + @AllArgsConstructor class Payload { private String message; private Integer value; diff --git a/src/test/java/ee/bitweb/core/retrofit/helpers/RetrofitConfiguration.java b/src/test/java/ee/bitweb/core/retrofit/helpers/RetrofitConfiguration.java index 31bd614..be1584d 100644 --- a/src/test/java/ee/bitweb/core/retrofit/helpers/RetrofitConfiguration.java +++ b/src/test/java/ee/bitweb/core/retrofit/helpers/RetrofitConfiguration.java @@ -3,10 +3,10 @@ import ee.bitweb.core.retrofit.interceptor.auth.TokenProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.test.context.ActiveProfiles; +import org.springframework.context.annotation.Profile; @Configuration -@ActiveProfiles("retrofit") +@Profile("retrofit") public class RetrofitConfiguration { @Bean("interceptor1") diff --git a/src/test/java/ee/bitweb/core/retrofit/helpers/RetrofitLogTestWriter.java b/src/test/java/ee/bitweb/core/retrofit/helpers/RetrofitLogTestWriter.java new file mode 100644 index 0000000..61af209 --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/helpers/RetrofitLogTestWriter.java @@ -0,0 +1,25 @@ +package ee.bitweb.core.retrofit.helpers; + +import ee.bitweb.core.retrofit.logging.writers.RetrofitLogWriteAdapter; +import lombok.Getter; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@Profile("retrofit-log-test-writer") +public class RetrofitLogTestWriter implements RetrofitLogWriteAdapter { + + @Getter + private Map container = null; + + @Override + public void write(Map container) { + this.container = container; + } + + public void clear() { + container = null; + } +} diff --git a/src/test/java/ee/bitweb/core/retrofit/interceptor/TraceIdInterceptorTests.java b/src/test/java/ee/bitweb/core/retrofit/interceptor/TraceIdInterceptorTests.java index 7d08566..4cebb48 100644 --- a/src/test/java/ee/bitweb/core/retrofit/interceptor/TraceIdInterceptorTests.java +++ b/src/test/java/ee/bitweb/core/retrofit/interceptor/TraceIdInterceptorTests.java @@ -7,7 +7,9 @@ import ee.bitweb.http.server.mock.MockServer; import io.netty.handler.codec.http.HttpMethod; import org.json.JSONObject; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; import org.mockito.Mock; @@ -60,8 +62,9 @@ void onMissingTraceIdInContextShouldThrowException() { private ExternalServiceApi createApi() { return RetrofitApiBuilder.create( - BASE_URL + server.getPort(), - ExternalServiceApi.class + BASE_URL + server.getPort(), + ExternalServiceApi.class, + null ).add( new TraceIdInterceptor(config, context) ).build(); diff --git a/src/test/java/ee/bitweb/core/retrofit/interceptor/auth/AuthTokenInjectInterceptorTests.java b/src/test/java/ee/bitweb/core/retrofit/interceptor/auth/AuthTokenInjectInterceptorTests.java index 4757339..727b8d8 100644 --- a/src/test/java/ee/bitweb/core/retrofit/interceptor/auth/AuthTokenInjectInterceptorTests.java +++ b/src/test/java/ee/bitweb/core/retrofit/interceptor/auth/AuthTokenInjectInterceptorTests.java @@ -7,7 +7,9 @@ import ee.bitweb.http.server.mock.MockServer; import io.netty.handler.codec.http.HttpMethod; import org.json.JSONObject; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; import org.mockito.Mock; @@ -167,7 +169,8 @@ void onMatchingBlacklistCriteriaShouldNotAddHeader() { private RetrofitApiBuilder createBuilder() { return RetrofitApiBuilder.create( BASE_URL + server.getPort(), - ExternalServiceApi.class + ExternalServiceApi.class, + null ); } diff --git a/src/test/java/ee/bitweb/core/retrofit/logging/NoopRetrofitLoggingInterceptorTest.java b/src/test/java/ee/bitweb/core/retrofit/logging/NoopRetrofitLoggingInterceptorTest.java new file mode 100644 index 0000000..621845e --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/logging/NoopRetrofitLoggingInterceptorTest.java @@ -0,0 +1,42 @@ +package ee.bitweb.core.retrofit.logging; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +class NoopRetrofitLoggingInterceptorTest { + + @Mock + Interceptor.Chain chain; + + @Mock + Response response; + + @Mock + Request request; + + @Test + @DisplayName("Chain must proceed") + void testChainProceeds() throws IOException { + Mockito.when(chain.request()).thenReturn(request); + Mockito.when(chain.proceed(request)).thenReturn(response); + + NoopRetrofitLoggingInterceptor interceptor = new NoopRetrofitLoggingInterceptor(); + interceptor.intercept(chain); + + Mockito.verify(chain, Mockito.times(1)).proceed(request); + Mockito.verifyNoMoreInteractions(chain); + Mockito.verifyNoInteractions(response, request); + } +} diff --git a/src/test/java/ee/bitweb/core/retrofit/logging/RetrofitLoggingInterceptorTest.java b/src/test/java/ee/bitweb/core/retrofit/logging/RetrofitLoggingInterceptorTest.java new file mode 100644 index 0000000..a8d1f4c --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/logging/RetrofitLoggingInterceptorTest.java @@ -0,0 +1,162 @@ +package ee.bitweb.core.retrofit.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ee.bitweb.core.TestSpringApplication; +import ee.bitweb.core.retrofit.RetrofitProperties; +import ee.bitweb.core.retrofit.builder.LoggingLevel; +import ee.bitweb.core.retrofit.builder.SpringAwareRetrofitBuilder; +import ee.bitweb.core.retrofit.helpers.ExternalServiceApi; +import ee.bitweb.core.trace.context.TraceIdContext; +import ee.bitweb.core.utils.MemoryAppender; +import ee.bitweb.http.server.mock.MockServer; +import io.netty.handler.codec.http.HttpMethod; +import org.json.JSONObject; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("integration") +@SpringBootTest( + classes = TestSpringApplication.class, + properties = { + "ee.bitweb.core.trace.auto-configuration=true", + "ee.bitweb.core.retrofit.auto-configuration=true", + "ee.bitweb.core.retrofit.logging.level=body" + } +) +@ActiveProfiles("retrofit") +class RetrofitLoggingInterceptorTest { + + private static final String BASE_URL = "http://localhost:"; + + @RegisterExtension + private static final MockServer server = new MockServer(HttpMethod.POST, "/data-post"); + + @Autowired + private SpringAwareRetrofitBuilder builder; + + @Autowired + private TraceIdContext context; + + @Autowired + private RetrofitProperties retrofitProperties; + + Logger logger; + MemoryAppender memoryAppender; + + @BeforeEach + void beforeEach() { + context.clear(); + context.set("TEST"); + + logger = (Logger) LoggerFactory.getLogger("RetrofitLogger"); + memoryAppender = new MemoryAppender(); + memoryAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()); + logger.setLevel(Level.DEBUG); + logger.addAppender(memoryAppender); + memoryAppender.start(); + } + + @AfterEach + void afterEach() { + context.clear(); + + memoryAppender.stop(); + + retrofitProperties.getLogging().setSuppressedHeaders(new ArrayList<>()); + retrofitProperties.getLogging().getRedactedBodyUrls().clear(); + retrofitProperties.getLogging().setMaxLoggableRequestBodySize(1024 * 10L); + retrofitProperties.getLogging().setMaxLoggableResponseBodySize(1024 * 10L); + } + + @Test + @DisplayName("Logging level BODY") + void loggingLevelBody() throws Exception { + retrofitProperties.getLogging().setLevel(LoggingLevel.BODY); + + executeRetrofitRequest(); + + assertEquals(1, memoryAppender.getSize()); + + assertLogLevel(); + assertLogMessage(); + + assertAll( + () -> assertEquals(11, memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().size()), + () -> assertEquals("TEST", memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("trace_id")), + () -> assertEquals("200", memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("response_code")), + () -> assertEquals("POST", memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("request_method")), + () -> assertTrue( + Pattern.compile("http:\\/\\/localhost:[0-9]*\\/data-post") + .matcher(memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("request_url")).find() + ), + () -> assertTrue( + Pattern.compile("[0-9]*") + .matcher(memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("duration")).find() + ), + () -> assertEquals("34", memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("request_body_size")), + () -> assertEquals("32", memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("response_body_size")), + () -> assertEquals( + "Content-Length: 34; Content-Type: application; X-Trace-ID: TEST", + memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("request_headers") + ), + () -> assertEquals( + "connection: keep-alive; Content-Length: 32; Content-Type: application/json; charset=utf-8", + memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("response_headers") + ), + () -> assertEquals( + "{\"message\":\"message123\",\"value\":1}", + memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("request_body") + ), + () -> assertEquals( + "{\"message\":\"message2\",\"value\":2}", + memoryAppender.getLoggedEvents().get(0).getMDCPropertyMap().get("response_body") + ) + ); + } + + private void assertLogLevel() { + assertEquals(Level.INFO, memoryAppender.getLoggedEvents().get(0).getLevel()); + } + + private void assertLogMessage() { + assertTrue(Pattern.compile( + "POST http:\\/\\/localhost:[0-9]*\\/data-post 200 [0-9]*bytes [0-9]*ms" + ).matcher(memoryAppender.getLoggedEvents().get(0).getFormattedMessage()).find()); + } + + private void executeRetrofitRequest() throws IOException { + ExternalServiceApi api = builder.create(BASE_URL + server.getPort(), ExternalServiceApi.class).build(); + + mockServerRequest(); + + api.postData(new ExternalServiceApi.Payload("message123", 1)).execute(); + } + + private static void mockServerRequest() { + server.mock( + server.requestBuilder(), + server.responseBuilder(200, createPayload()) + ); + } + + private static JSONObject createPayload() { + JSONObject payload = new JSONObject(); + + payload.put("message", "message2"); + payload.put("value", 2); + + return payload; + } +} diff --git a/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodyMapperTest.java b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodyMapperTest.java new file mode 100644 index 0000000..f92cda8 --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodyMapperTest.java @@ -0,0 +1,273 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import okhttp3.*; +import okio.Buffer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +class RetrofitRequestBodyMapperTest { + + @Mock + RequestBody requestBody; + + @Mock + Request request; + + @Mock + HttpUrl httpUrl; + + @Mock + Headers headers; + + @Mock + MediaType mediaType; + + private static Object setStringToBuffer(InvocationOnMock invocation) throws IOException { + Buffer buffer = invocation.getArgument(0); + + buffer.readFrom(new ByteArrayInputStream("some data that may or may not be over limit".getBytes())); + + return null; + } + + @Test + @DisplayName("Body is null") + void nullBody() { + Mockito.when(request.body()).thenReturn(null); + + var mapper = new RetrofitRequestBodyMapper(2, new HashSet<>()); + + assertEquals("null", mapper.getValue(request, null)); + + Mockito.verify(request, Mockito.only()).body(); + Mockito.verifyNoMoreInteractions(request); + Mockito.verifyNoInteractions(requestBody, httpUrl); + } + + @Test + @DisplayName("Body must be redacted") + void redactBody() throws MalformedURLException { + Mockito.when(httpUrl.url()).thenReturn(new URL("http://localhost:6542/")); + Mockito.when(request.url()).thenReturn(httpUrl); + Mockito.when(request.body()).thenReturn(requestBody); + + Set redactBodyUrls = Set.of("http://localhost:6542/"); + var mapper = new RetrofitRequestBodyMapper(0, redactBodyUrls); + + try (MockedStatic mockedStatic = Mockito.mockStatic(RetrofitBodyMapperHelper.class)) { + mockedStatic.when(() -> RetrofitBodyMapperHelper.isRedactBodyUrl(redactBodyUrls, "http://localhost:6542/")).thenReturn(true); + + assertEquals("(body redacted)", mapper.getValue(request, null)); + } + + Mockito.verify(request, Mockito.times(1)).body(); + Mockito.verify(request, Mockito.times(1)).url(); + Mockito.verify(httpUrl, Mockito.only()).url(); + Mockito.verifyNoMoreInteractions(request, httpUrl); + Mockito.verifyNoInteractions(requestBody); + } + + @Test + @DisplayName("Body has unknown encoding") + void unknownEncoding() throws MalformedURLException { + Mockito.when(httpUrl.url()).thenReturn(new URL("http://localhost:6542/")); + Mockito.when(request.url()).thenReturn(httpUrl); + Mockito.when(request.body()).thenReturn(requestBody); + Mockito.when(request.headers()).thenReturn(headers); + + Set redactBodyUrls = Set.of(); + var mapper = new RetrofitRequestBodyMapper(0, redactBodyUrls); + + try (MockedStatic mockedStatic = Mockito.mockStatic(RetrofitBodyMapperHelper.class)) { + mockedStatic.when(() -> RetrofitBodyMapperHelper.isRedactBodyUrl(redactBodyUrls, "http://localhost:6542/")).thenReturn(false); + mockedStatic.when(() -> RetrofitBodyMapperHelper.bodyHasUnknownEncoding(headers)).thenReturn(true); + + assertEquals("(encoded body omitted)", mapper.getValue(request, null)); + } + + Mockito.verify(request, Mockito.times(1)).body(); + Mockito.verify(request, Mockito.times(1)).url(); + Mockito.verify(request, Mockito.times(1)).headers(); + Mockito.verify(httpUrl, Mockito.only()).url(); + Mockito.verifyNoMoreInteractions(request, httpUrl); + Mockito.verifyNoInteractions(requestBody); + } + + @Test + @DisplayName("Duplex body") + void duplexBody() throws MalformedURLException { + Mockito.when(httpUrl.url()).thenReturn(new URL("http://localhost:6542/")); + Mockito.when(request.url()).thenReturn(httpUrl); + Mockito.when(request.body()).thenReturn(requestBody); + Mockito.when(request.headers()).thenReturn(headers); + Mockito.when(requestBody.isDuplex()).thenReturn(true); + + Set redactBodyUrls = Set.of(); + var mapper = new RetrofitRequestBodyMapper(0, redactBodyUrls); + + try (MockedStatic mockedStatic = Mockito.mockStatic(RetrofitBodyMapperHelper.class)) { + mockedStatic.when(() -> RetrofitBodyMapperHelper.isRedactBodyUrl(redactBodyUrls, "http://localhost:6542/")).thenReturn(false); + mockedStatic.when(() -> RetrofitBodyMapperHelper.bodyHasUnknownEncoding(headers)).thenReturn(false); + + assertEquals("(duplex request body omitted)", mapper.getValue(request, null)); + } + + Mockito.verify(request, Mockito.times(1)).body(); + Mockito.verify(request, Mockito.times(1)).url(); + Mockito.verify(request, Mockito.times(1)).headers(); + Mockito.verify(requestBody, Mockito.times(1)).isDuplex(); + Mockito.verify(httpUrl, Mockito.only()).url(); + Mockito.verifyNoMoreInteractions(request, httpUrl, requestBody); + } + + @Test + @DisplayName("One-shot body") + void oneShotBody() throws MalformedURLException { + Mockito.when(httpUrl.url()).thenReturn(new URL("http://localhost:6542/")); + Mockito.when(request.url()).thenReturn(httpUrl); + Mockito.when(request.body()).thenReturn(requestBody); + Mockito.when(request.headers()).thenReturn(headers); + Mockito.when(requestBody.isDuplex()).thenReturn(false); + Mockito.when(requestBody.isOneShot()).thenReturn(true); + + Set redactBodyUrls = Set.of(); + var mapper = new RetrofitRequestBodyMapper(0, redactBodyUrls); + + try (MockedStatic mockedStatic = Mockito.mockStatic(RetrofitBodyMapperHelper.class)) { + mockedStatic.when(() -> RetrofitBodyMapperHelper.isRedactBodyUrl(redactBodyUrls, "http://localhost:6542/")).thenReturn(false); + mockedStatic.when(() -> RetrofitBodyMapperHelper.bodyHasUnknownEncoding(headers)).thenReturn(false); + + assertEquals("(one-shot body omitted)", mapper.getValue(request, null)); + } + + Mockito.verify(request, Mockito.times(1)).body(); + Mockito.verify(request, Mockito.times(1)).url(); + Mockito.verify(request, Mockito.times(1)).headers(); + Mockito.verify(requestBody, Mockito.times(1)).isDuplex(); + Mockito.verify(requestBody, Mockito.times(1)).isOneShot(); + Mockito.verify(httpUrl, Mockito.only()).url(); + Mockito.verifyNoMoreInteractions(request, httpUrl, requestBody); + } + + @Test + @DisplayName("Request body is not UTF-8") + void requestBodyIsNotUtf8() throws IOException { + Mockito.doNothing().when(requestBody).writeTo(Mockito.isA(Buffer.class)); + Mockito.when(requestBody.contentType()).thenReturn(MediaType.get("application/octet-stream")); + var mapper = new RetrofitRequestBodyMapper(0, Set.of()); + + try (MockedStatic mockedStatic = Mockito.mockStatic(RetrofitBodyMapperHelper.class)) { + mockedStatic.when(() -> RetrofitBodyMapperHelper.isProbablyUtf8(Mockito.isA(Buffer.class))).thenReturn(false); + + assertEquals("(binary body omitted)", mapper.getBodyString(requestBody)); + } + + Mockito.verify(requestBody, Mockito.times(1)).writeTo(Mockito.isA(Buffer.class)); + Mockito.verifyNoMoreInteractions(requestBody); + Mockito.verifyNoInteractions(request, httpUrl, headers); + } + + @Test + @DisplayName("Request body is returned even when content type is not available") + void contentTypeIsNotAvailable() throws IOException { + Mockito.doAnswer(RetrofitRequestBodyMapperTest::setStringToBuffer).when(requestBody).writeTo(Mockito.isA(Buffer.class)); + Mockito.when(requestBody.contentType()).thenReturn(null); + Mockito.when(requestBody.contentLength()).thenReturn(43L); + var mapper = new RetrofitRequestBodyMapper(100, Set.of()); + + try (MockedStatic mockedStatic = Mockito.mockStatic(RetrofitBodyMapperHelper.class)) { + mockedStatic.when(() -> RetrofitBodyMapperHelper.isProbablyUtf8(Mockito.isA(Buffer.class))).thenReturn(true); + + assertEquals("some data that may or may not be over limit", mapper.getBodyString(requestBody)); + } + + Mockito.verify(requestBody, Mockito.times(1)).writeTo(Mockito.isA(Buffer.class)); + Mockito.verify(requestBody, Mockito.times(1)).contentType(); + Mockito.verifyNoMoreInteractions(requestBody); + Mockito.verifyNoInteractions(request, httpUrl, headers); + } + + @Test + @DisplayName("Exception is thrown when charset is null") + void charsetIsNull() throws IOException { + Mockito.doNothing().when(requestBody).writeTo(Mockito.isA(Buffer.class)); + Mockito.when(mediaType.charset(Mockito.any())).thenReturn(null); + Mockito.when(requestBody.contentType()).thenReturn(mediaType); + var mapper = new RetrofitRequestBodyMapper(100, Set.of()); + + try (MockedStatic mockedStatic = Mockito.mockStatic(RetrofitBodyMapperHelper.class)) { + mockedStatic.when(() -> RetrofitBodyMapperHelper.isProbablyUtf8(Mockito.isA(Buffer.class))).thenReturn(true); + + assertThrows(AssertionError.class, () -> mapper.getBodyString(requestBody)); + } + + Mockito.verify(requestBody, Mockito.times(1)).writeTo(Mockito.isA(Buffer.class)); + Mockito.verify(requestBody, Mockito.times(1)).contentType(); + Mockito.verifyNoMoreInteractions(requestBody); + Mockito.verifyNoInteractions(request, httpUrl, headers); + } + + @Test + @DisplayName("Request content length is negative") + void requestContentLengthIsNegative() throws IOException { + Mockito.doAnswer(RetrofitRequestBodyMapperTest::setStringToBuffer).when(requestBody).writeTo(Mockito.isA(Buffer.class)); + Mockito.when(requestBody.contentType()).thenReturn(MediaType.get("text/plain")); + Mockito.when(requestBody.contentLength()).thenReturn(-1L); + var mapper = new RetrofitRequestBodyMapper(11, Set.of()); + + try (MockedStatic mockedStatic = Mockito.mockStatic(RetrofitBodyMapperHelper.class)) { + mockedStatic.when(() -> RetrofitBodyMapperHelper.isProbablyUtf8(Mockito.isA(Buffer.class))).thenReturn(true); + + assertEquals("some data t ... Content size: -1 characters", mapper.getBodyString(requestBody)); + } + + Mockito.verify(requestBody, Mockito.times(1)).writeTo(Mockito.isA(Buffer.class)); + Mockito.verifyNoMoreInteractions(requestBody); + Mockito.verifyNoInteractions(request, httpUrl, headers); + } + + @Test + @DisplayName("Request content is limited") + void requestContentIsLimited() throws IOException { + var customBody = RequestBody.create("some data".getBytes(), MediaType.parse("text/plain")); + var mapper = new RetrofitRequestBodyMapper(2, Set.of()); + + assertEquals("so ... Content size: 9 characters", mapper.getBodyString(customBody)); + } + + @Test + @DisplayName("Request content is returned in full") + void requestContentIsReturnedInFull() throws MalformedURLException { + var customBody = RequestBody.create("some data".getBytes(), MediaType.parse("text/plain")); + Mockito.when(httpUrl.url()).thenReturn(new URL("http://localhost:6542/")); + Mockito.when(request.url()).thenReturn(httpUrl); + Mockito.when(request.headers()).thenReturn(headers); + Mockito.when(request.body()).thenReturn(customBody); + + var mapper = new RetrofitRequestBodyMapper(9, Set.of()); + + assertEquals("some data", mapper.getValue(request, null)); + + Mockito.verify(request, Mockito.times(1)).url(); + Mockito.verify(request, Mockito.times(1)).headers(); + Mockito.verifyNoInteractions(requestBody); + } +} diff --git a/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodySizeMapperTest.java b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodySizeMapperTest.java new file mode 100644 index 0000000..4ac0aa5 --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitRequestBodySizeMapperTest.java @@ -0,0 +1,67 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import okhttp3.Request; +import okhttp3.RequestBody; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +class RetrofitRequestBodySizeMapperTest { + + @Mock + Request request; + + @Mock + RequestBody requestBody; + + @Test + @DisplayName("Returns correct request size") + void testRequestBodyLengthIsSuccessfullyRetrieved() throws IOException { + Mockito.when(request.body()).thenReturn(requestBody); + Mockito.when(requestBody.contentLength()).thenReturn(123L); + RetrofitRequestBodySizeMapper mapper = new RetrofitRequestBodySizeMapper(); + + assertEquals("123", mapper.getValue(request, null)); + + Mockito.verify(request, Mockito.times(1)).body(); + Mockito.verify(requestBody, Mockito.times(1)).contentLength(); + Mockito.verifyNoMoreInteractions(request, requestBody); + } + + @Test + @DisplayName("When request body is not available, should return '-'") + void testRequestBodyIsNotAvailable() { + Mockito.when(request.body()).thenReturn(null); + RetrofitRequestBodySizeMapper mapper = new RetrofitRequestBodySizeMapper(); + + assertEquals("-", mapper.getValue(request, null)); + + Mockito.verify(request, Mockito.times(1)).body(); + Mockito.verifyNoMoreInteractions(request); + Mockito.verifyNoInteractions(requestBody); + } + + @Test + @DisplayName("When request body content size throws exception, should return 'Parse error.'") + void testRequestBodySizeThrowsException() throws IOException { + Mockito.when(request.body()).thenReturn(requestBody); + Mockito.when(requestBody.contentLength()).thenThrow(IOException.class); + RetrofitRequestBodySizeMapper mapper = new RetrofitRequestBodySizeMapper(); + + assertEquals("Parse error.", mapper.getValue(request, null)); + + Mockito.verify(request, Mockito.times(1)).body(); + Mockito.verify(requestBody, Mockito.times(1)).contentLength(); + Mockito.verifyNoMoreInteractions(request, requestBody); + } +} diff --git a/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapperTest.java b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapperTest.java new file mode 100644 index 0000000..8dc80a6 --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapperTest.java @@ -0,0 +1,238 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import okhttp3.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +class RetrofitResponseBodyMapperTest { + + @Test + @DisplayName("Is redact url") + void isRedactUrl() { + var mapper = new RetrofitResponseBodyMapper(Set.of("https://www.google.com/"), 0); + + var response = new Response( + request("GET"), + Protocol.HTTP_1_0, + "message", + 200, + null, + new Headers.Builder().build(), + ResponseBody.create("123".getBytes(), MediaType.get("application/text")), + null, + null, + null, + 1, + 2, + null + ); + + var value = mapper.getValue(null, response); + + assertEquals("(body redacted)", value); + } + + @Test + @DisplayName("Promises body") + void promisesBody() { + var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 0); + + var response = new Response( + request("HEAD"), + Protocol.HTTP_1_0, + "message", + 201, + null, + new Headers.Builder().build(), + ResponseBody.create("123".getBytes(), MediaType.get("application/text")), + null, + null, + null, + 1, + 2, + null + ); + + var value = mapper.getValue(null, response); + + assertEquals("", value); + } + + @Test + @DisplayName("Body has unknown encoding") + void bodyHasUnknownEncoding() { + var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 0); + + var response = new Response( + request("GET"), + Protocol.HTTP_1_0, + "message", + 201, + null, + new Headers.Builder().add("Content-Encoding", "unknownEncoding").build(), + ResponseBody.create("123".getBytes(), MediaType.get("application/text")), + null, + null, + null, + 1, + 2, + null + ); + + var value = mapper.getValue(null, response); + + assertEquals("(encoded body omitted)", value); + } + + @Test + @DisplayName("Body missing") + void bodyMissing() { + var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 0); + + var response = new Response( + request("GET"), + Protocol.HTTP_1_0, + "message", + 201, + null, + new Headers.Builder().build(), + null, + null, + null, + null, + 1, + 2, + null + ); + + var value = mapper.getValue(null, response); + + assertEquals("(body missing)", value); + } + + @Test + @DisplayName("Response body is correctly returned") + void bodyAvailable() { + var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 4096); + var response = new Response( + request("GET"), + Protocol.HTTP_2, + "OK", + 200, + null, + new Headers.Builder().build(), + ResponseBody.create("123".getBytes(), MediaType.get("application/text")), + null, + null, + null, + 1, + 2, + null + ); + + assertEquals("123", mapper.getValue(null, response)); + } + + @Test + @DisplayName("Response body is correctly shortened") + void bodyIsShortened() { + var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 2); + var response = new Response( + request("GET"), + Protocol.HTTP_2, + "OK", + 200, + null, + new Headers.Builder().build(), + ResponseBody.create("123".getBytes(), MediaType.get("application/text")), + null, + null, + null, + 1, + 2, + null + ); + + assertEquals("12 ... Content size: 3 characters", mapper.getValue(null, response)); + } + + @Test + @DisplayName("Response body is correctly returned when empty") + void bodyIsEmpty() { + var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 4096); + var response = new Response( + request("GET"), + Protocol.HTTP_2, + "OK", + 200, + null, + new Headers.Builder().build(), + ResponseBody.create(new byte[]{}, MediaType.get("application/text")), + null, + null, + null, + 1, + 2, + null + ); + + assertEquals("", mapper.getValue(null, response)); + } + + @Test + @DisplayName("Response body is correctly returned when response is gzipped") + void bodyIsGzipped() throws IOException { + var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 4096); + var response = new Response( + request("GET"), + Protocol.HTTP_2, + "OK", + 200, + null, + new Headers.Builder().add("Content-Encoding", "gzip").build(), + ResponseBody.create(gzip("some amount of data"), MediaType.get("application/text")), + null, + null, + null, + 1, + 2, + null + ); + + assertEquals("some amount of data", mapper.getValue(null, response)); + } + + private Request request(String method) { + return new Request( + new HttpUrl.Builder() + .scheme("https") + .host("www.google.com") + .build(), + method, + new Headers.Builder().build(), + RequestBody.create("123".getBytes(), MediaType.get("application/text")), + new HashMap<>() + ); + } + + private byte[] gzip(String data) throws IOException { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + GZIPOutputStream gzipOs = new GZIPOutputStream(os); + byte[] buffer = data.getBytes(); + gzipOs.write(buffer, 0, buffer.length); + gzipOs.close(); + + return os.toByteArray(); + } +} diff --git a/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodySizeMapperTest.java b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodySizeMapperTest.java new file mode 100644 index 0000000..4262a57 --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodySizeMapperTest.java @@ -0,0 +1,51 @@ +package ee.bitweb.core.retrofit.logging.mappers; + +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +class RetrofitResponseBodySizeMapperTest { + + @Mock + Response response; + + @Mock + ResponseBody responseBody; + + @Test + @DisplayName("Returns correct response size") + void testResponseBodyLengthIsSuccessfullyRetrieved() { + Mockito.when(response.body()).thenReturn(responseBody); + Mockito.when(responseBody.contentLength()).thenReturn(1234L); + RetrofitResponseBodySizeMapper mapper = new RetrofitResponseBodySizeMapper(); + + assertEquals("1234", mapper.getValue(null, response)); + + Mockito.verify(response, Mockito.times(1)).body(); + Mockito.verify(responseBody, Mockito.times(1)).contentLength(); + Mockito.verifyNoMoreInteractions(response, responseBody); + } + + @Test + @DisplayName("When response body is not available, should return '-'") + void testResponseBodyIsNotAvailable() { + Mockito.when(response.body()).thenReturn(null); + RetrofitResponseBodySizeMapper mapper = new RetrofitResponseBodySizeMapper(); + + assertEquals("-", mapper.getValue(null, response)); + + Mockito.verify(response, Mockito.times(1)).body(); + Mockito.verifyNoMoreInteractions(response); + Mockito.verifyNoInteractions(responseBody); + } +} diff --git a/src/test/java/ee/bitweb/core/retrofit/logging/writers/RetrofitLogLoggerWriterAdapterTest.java b/src/test/java/ee/bitweb/core/retrofit/logging/writers/RetrofitLogLoggerWriterAdapterTest.java new file mode 100644 index 0000000..fc5c91b --- /dev/null +++ b/src/test/java/ee/bitweb/core/retrofit/logging/writers/RetrofitLogLoggerWriterAdapterTest.java @@ -0,0 +1,153 @@ +package ee.bitweb.core.retrofit.logging.writers; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ee.bitweb.core.exception.CoreException; +import ee.bitweb.core.utils.MemoryAppender; +import org.junit.jupiter.api.*; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +class RetrofitLogLoggerWriterAdapterTest { + + Logger logger; + MemoryAppender memoryAppender; + + @BeforeEach + void setUp() { + logger = (Logger) LoggerFactory.getLogger("RetrofitLogger"); + memoryAppender = new MemoryAppender(); + memoryAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()); + logger.setLevel(Level.INFO); + logger.addAppender(memoryAppender); + memoryAppender.start(); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("Event is logged with correct MDC and MDC is restored") + void testMessageIsWrittenToLoggerWithCorrectContext() { + // given + MDC.put("current", "1"); + Map container = Map.of( + "request_method", "GET", + "request_url", "https://localhost:3000/api?data=true&test", + "response_code", "404", + "duration", "14" + ); + RetrofitLogLoggerWriterAdapter writer = new RetrofitLogLoggerWriterAdapter(); + + // when + writer.write(container); + + // then validate logging event + assertEquals(1, memoryAppender.getSize()); + ILoggingEvent loggingEvent = memoryAppender.getLoggedEvents().get(0); + assertEquals(Level.INFO, loggingEvent.getLevel()); + assertEquals("GET https://localhost:3000/api?data=true&test 404 -bytes 14ms", loggingEvent.getFormattedMessage()); + + // then validate logging event MDC + assertAll( + () -> assertEquals(5, loggingEvent.getMDCPropertyMap().size()), + () -> assertEquals("1", loggingEvent.getMDCPropertyMap().get("current")), + () -> assertEquals("404", loggingEvent.getMDCPropertyMap().get("response_code")), + () -> assertEquals("GET", loggingEvent.getMDCPropertyMap().get("request_method")), + () -> assertEquals("https://localhost:3000/api?data=true&test", loggingEvent.getMDCPropertyMap().get("request_url")), + () -> assertEquals("14", loggingEvent.getMDCPropertyMap().get("duration")), + () -> assertNull(loggingEvent.getMDCPropertyMap().get("response_body_size")) + ); + + // then validate current MDC + assertAll( + () -> assertEquals(1, MDC.getCopyOfContextMap().size()), + () -> assertEquals("1", MDC.getCopyOfContextMap().get("current")) + ); + } + + @Test + @DisplayName("Event is logged with correct MDC and an empty MDC is restored") + void validateMdcIsRestoredWhenEmpty() { + // given + Map container = Map.of( + "RequestMethod", "GET", + "RequestUrl", "https://localhost:3000/api?data=true&test", + "ResponseCode", "404", + "ResponseBodySize", "0", + "Duration", "14" + ); + RetrofitLogLoggerWriterAdapter writer = new RetrofitLogLoggerWriterAdapter(); + + // when + writer.write(container); + + // then validate current MDC + assertAll( + () -> assertNull(MDC.getCopyOfContextMap()) + ); + } + + @Test + @DisplayName("Error should be logged when logging level less than INFO") + void validateErrorIsLoggedWhenLoggingLevelIsLessThanInfo() { + // given + Map container = Map.of( + "RequestMethod", "GET", + "RequestUrl", "https://localhost:3000/api?data=true&test", + "ResponseCode", "404", + "ResponseBodySize", "0", + "Duration", "14" + ); + RetrofitLogLoggerWriterAdapter writer = new RetrofitLogLoggerWriterAdapter(); + logger.setLevel(Level.WARN); + + // when + writer.write(container); + + // then + assertEquals(1, memoryAppender.getSize()); + ILoggingEvent loggingEvent = memoryAppender.getLoggedEvents().get(0); + assertEquals(Level.ERROR, loggingEvent.getLevel()); + assertEquals( + "Retrofit interceptor has been enabled, but RetrofitLogLoggerWriterAdapter cannot write as log level does not permit INFO entries. " + + "This behaviour is strongly discouraged as the interceptor consumes resources for no real result. Please set property " + + "ee.bitweb.core.retrofit.logging-level=NONE if you wish to avoid this logging.", + loggingEvent.getFormattedMessage() + ); + } + + @Test + void validateExceptionIsThrownWhenLoggingLevelIsLessThanError() { + // given + Map container = Map.of( + "RequestMethod", "GET", + "RequestUrl", "https://localhost:3000/api?data=true&test", + "ResponseCode", "404", + "ResponseBodySize", "0", + "Duration", "14" + ); + RetrofitLogLoggerWriterAdapter writer = new RetrofitLogLoggerWriterAdapter(); + logger.setLevel(Level.OFF); + + // when + CoreException ex = assertThrows(CoreException.class, () -> writer.write(container)); + + // then + assertEquals( + "Retrofit interceptor has been enabled, but RetrofitLogLoggerWriterAdapter cannot write as log level does not permit INFO entries. " + + "This behaviour is strongly discouraged as the interceptor consumes resources for no real result. Please set property " + + "ee.bitweb.core.retrofit.logging-level=NONE if you wish to avoid this logging.", + ex.getMessage() + ); + } +}