HttpJsonLoggingInterceptor.java

/*
 * okhttp-client-extensions - A set of helpful extensions to support okhttp clients
 * Copyright © 2025 Andy Miles (andy.miles@amilesend.com)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */
package com.amilesend.client.connection.http;

import com.amilesend.client.util.Pair;
import com.amilesend.client.util.VisibleForTesting;
import com.google.gson.Gson;
import com.google.gson.JsonParser;
import lombok.Builder;
import lombok.NonNull;
import lombok.Singular;
import okhttp3.Headers;
import okhttp3.HttpUrl;
import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
import okio.GzipSource;
import okio.Okio;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;

import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.Set;
import java.util.StringJoiner;

import static com.amilesend.client.connection.Connection.Headers.CONTENT_ENCODING;

/**
 * A logging interceptor to aid in debugging.
 *
 * @see Interceptor
 */
@Builder
public class HttpJsonLoggingInterceptor implements Interceptor {
    private static final String REDACTED = " **********";
    private static final String REDACTED_QUERY_PARAM_VALUE = "REDACTED";
    private static final String GZIP_ENCODING = "gzip";

    /** The set of HTTP headers to redact in the logging statements. */
    @Singular
    private final Set<String> redactedHeaders;
    /** The set of HTTP URL query parameters to redact in the logging statements. */
    @Singular
    private final Set<String> redactedQueryParams;
    /** The configured GSON instance. Note: Recommended to set the pretty-print flag. */
    @NonNull
    private final Gson gson;
    /** Log requests flag. */
    @Builder.Default
    private final boolean isRequestLogged = true;
    /** Log response flag. */
    @Builder.Default
    private final boolean isResponseLogged = true;
    /** The logger instance. */
    @Builder.Default
    private final Logger log = LoggerFactory.getLogger(HttpJsonLoggingInterceptor.class);;
    /** The logging level of the statements. */
    @Builder.Default
    private final Level loggingLevel = Level.INFO;

    @Override
    public Response intercept(@NonNull final Chain chain) throws IOException {
        final long startTime = System.currentTimeMillis();
        final Request request = chain.request();
        if (isRequestLogged) {
            log.atLevel(loggingLevel)
                    .log("\nRequest\n  URL: {}\n HEADERS: {}\n Body: {}",
                            redactUrl(request.url()),
                            redactHeaders(request.headers()),
                            getBodyAsString(request));
        }
        final Response response = chain.proceed(request);

        final long responseTimeMs = System.currentTimeMillis() - startTime;
        if (isResponseLogged) {
            final Pair<String, Response> bodyResponsePair = extractResponseBodyAsString(response);
            final String responseCode = new StringBuilder("\nHTTP Response (")
                    .append(response.protocol()
                            .name()
                            .replaceFirst("_", "/")
                            .replace('_', '.'))
                    .append(" ")
                    .append(response.code())
                    .append(") in ")
                    .append(responseTimeMs)
                    .append(" ms")
                    .toString();
            log.atLevel(loggingLevel)
                    .log("{}\n HEADERS:{}\n BODY:\n{}",
                            responseCode,
                            redactHeaders(response.headers()),
                            bodyResponsePair.getLeft());
            return bodyResponsePair.getRight();
        }

        return response;
    }

    @VisibleForTesting
    String getBodyAsString(final Request request) throws IOException {
        if (Objects.isNull(request) || Objects.isNull(request.body())) {
            return "[No Body]";
        }

        final RequestBody body = request.body();
        final MediaType mediaType = body.contentType();
        final String type = mediaType.type();
        final boolean isApplicationType = "application".equals(type);
        if (!"text".equals(type) && !isApplicationType) {
            return "[Unsupported content type: " + mediaType + "]";
        }

        final String subType = mediaType.subtype();
        final boolean isSubTypeJson = "json".equals(subType);
        if (isApplicationType && !isSubTypeJson) {
            return "[Unsupported content type: " + mediaType + "]";
        }

        final Buffer buffer = newBuffer();
        body.writeTo(buffer);

        final String bodyContent = buffer.readUtf8();
        return isSubTypeJson ? gsonify(bodyContent) : bodyContent;
    }

    @VisibleForTesting
    Buffer newBuffer() {
        return new Buffer();
    }

    @VisibleForTesting
    Pair<String, Response> extractResponseBodyAsString(final Response response) throws IOException {
        if (Objects.isNull(response) || Objects.isNull(response.body())) {
            return Pair.of("[No Body]", response);
        }

        final ResponseBody body = response.body();
        final MediaType mediaType = body.contentType();
        final String type = mediaType.type();
        final boolean isApplicationType = "application".equals(type);
        if (!"text".equals(type) && !isApplicationType) {
            return Pair.of("[Unsupported content type: " + mediaType + "]", response);
        }

        final String subType = mediaType.subtype();
        final boolean isSubTypeJson = "json".equals(subType);
        if (isApplicationType && !isSubTypeJson) {
            return Pair.of("[Unsupported content type: " + mediaType + "]", response);
        }

        if (GZIP_ENCODING.equals(response.header(CONTENT_ENCODING))) {
            final String bodyContent = newBufferedSource(body).readUtf8();
            final Response wrappedResponse = response.newBuilder()
                    .removeHeader(CONTENT_ENCODING)
                    .body(ResponseBody.create(bodyContent, mediaType))
                    .build();
            final String formattedBodyContent = isSubTypeJson ? gsonify(bodyContent) : bodyContent;
            return Pair.of(formattedBodyContent, wrappedResponse);
        }

        final String bodyContent = body.string();
        final Response wrappedResponse = response.newBuilder()
                .body(ResponseBody.create(bodyContent, mediaType))
                .build();
        final String formattedBodyContent = isSubTypeJson ? gsonify(bodyContent) : bodyContent;
        return Pair.of(formattedBodyContent, wrappedResponse);
    }

    @VisibleForTesting
    BufferedSource newBufferedSource(final ResponseBody body) {
        return Okio.buffer(new GzipSource(body.source()));
    }

    @VisibleForTesting
    String gsonify(final String value) {
        return gson.toJson(JsonParser.parseString(value));
    }

    @VisibleForTesting
    String redactHeaders(final Headers headers) {
        if (Objects.isNull(headers) || headers.size() < 1) {
            return "  No Headers";
        }

        final StringJoiner sj = new StringJoiner("\n  ");
        headers.forEach(p -> {
            if (redactedHeaders.contains(p.getFirst().trim())) {
                sj.add(p.getFirst().trim() + ":" + REDACTED);
            } else {
                sj.add(p.getFirst() + ": " + p.getSecond());
            }
        });

        return "\n  " + sj;
    }

    @VisibleForTesting
    String redactUrl(@NonNull final HttpUrl url) {
        if (url.querySize() == 0 || redactedQueryParams.isEmpty()) {
            return url.toString();
        }

        final HttpUrl.Builder urlBuilder = url.newBuilder().query(null);
        for (int i = 0; i < url.querySize(); ++i) {
            final String paramName = url.queryParameterName(i);
            final String paramValue = redactedQueryParams.contains(paramName)
                    ? REDACTED_QUERY_PARAM_VALUE
                    : url.queryParameterValue(i);

            urlBuilder.addQueryParameter(paramName, paramValue);
        }

        return urlBuilder.build().toString();
    }
}