Connection.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;

import com.amilesend.client.connection.auth.AuthManager;
import com.amilesend.client.connection.retry.RetriableCallResponse;
import com.amilesend.client.connection.retry.RetryStrategy;
import com.amilesend.client.parse.GsonFactoryBase;
import com.amilesend.client.parse.parser.GsonParser;
import com.amilesend.client.util.VisibleForTesting;
import com.google.gson.JsonParseException;
import lombok.Getter;
import lombok.NonNull;
import lombok.experimental.SuperBuilder;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.zip.GZIPInputStream;

import static com.amilesend.client.connection.Connection.Headers.ACCEPT;
import static com.amilesend.client.connection.Connection.Headers.ACCEPT_ENCODING;
import static com.amilesend.client.connection.Connection.Headers.CONTENT_ENCODING;
import static com.amilesend.client.connection.Connection.Headers.USER_AGENT;

/** Wraps an {@link OkHttpClient} that manages parsing responses to corresponding POJO types. */
@SuperBuilder
@Getter
@Slf4j
public class Connection<G extends GsonFactoryBase> {
    public static final String FORM_DATA_CONTENT_TYPE = "application/x-www-form-urlencoded";
    public static final MediaType FORM_DATA_MEDIA_TYPE = MediaType.parse(FORM_DATA_CONTENT_TYPE);
    public static final String JSON_CONTENT_TYPE = "application/json; charset=utf-8";
    public static final MediaType JSON_MEDIA_TYPE = MediaType.parse(JSON_CONTENT_TYPE);

    protected static final String THROTTLED_RETRY_AFTER_HEADER = "Retry-After";
    protected static final String GZIP_ENCODING = "gzip";
    protected static final Long DEFAULT_RETRY_AFTER_SECONDS = Long.valueOf(10L);
    protected static final int THROTTLED_RESPONSE_CODE = 429;

    /** The underlying http client. */
    @NonNull
    private final OkHttpClient httpClient;
    /** The Gson factory used to create GSON instance that marshals request and responses to/from JSON. */
    @NonNull
    private final G gsonFactory;
    /** The authorization manager used to authenticate and sign requests. */
    @NonNull
    private final AuthManager<?> authManager;
    /** The base URL for the Graph API. */
    @NonNull
    private final String baseUrl;
    /** The user agent to include in request headers. */
    @NonNull
    private final String userAgent;
    /** Flag indicator that the response is GZIP encoded. */
    private final boolean isGzipContentEncodingEnabled;
    /** The retry strategy to use. */
    @NonNull
    private final RetryStrategy retryStrategy;

    /**
     * Creates a new {@link Request.Builder} with pre-configured headers for request that expect a JSON-formatted
     * response body.
     *
     * @return the request builder
     */
    public Request.Builder newRequestBuilder() {
        final Request.Builder requestBuilder = new Request.Builder()
                .addHeader(USER_AGENT, userAgent)
                .addHeader(ACCEPT, JSON_CONTENT_TYPE);
        if (isGzipContentEncodingEnabled) {
            requestBuilder.addHeader(ACCEPT_ENCODING, GZIP_ENCODING);
        }

        return authManager.addAuthentication(requestBuilder);
    }

    /**
     * Executes the given {@link Request} and parses the JSON-formatted response with given {@link GsonParser}.
     *
     * @param request the request
     * @param parser the parser to decode the response body
     * @return the response as a POJO resource type
     * @param <T> the POJO resource type
     * @throws ConnectionException if an error occurred during the transaction
     */
    public <T> T execute(@NonNull final Request request, @NonNull final GsonParser<T> parser)
            throws ConnectionException {
        try {
            try (final Response response = execute(request)) {
                final InputStream responseBodyInputStream =
                        "gzip".equals(response.header(CONTENT_ENCODING))
                                ? new GZIPInputStream(response.body().byteStream())
                                : response.body().byteStream();
                return parser.parse(gsonFactory.getInstance(this), responseBodyInputStream);
            }
        } catch (final IOException ex) {
            throw new RequestException("Unable to execute request: " + ex.getMessage(), ex);
        } catch (final JsonParseException ex) {
            throw new ResponseParseException("Error parsing response: " + ex.getMessage(), ex);
        }
    }

    /**
     * Executes the given {@link Request} and returns the associated HTTP response code. This is typically used for
     * transactions that do not expect a response in the body.
     *
     * @param request the request
     * @return the HTTP response
     * @throws ConnectionException if an error occurred during the transaction
     */
    public Response execute(@NonNull final Request request) throws ConnectionException {
        final RetriableCallResponse response = retryStrategy.invoke(() -> httpClient.newCall(request).execute());
        if (response.isSuccess()) {
            return response.getResponse();
        }

        final List<Exception> thrownExceptions = response.getExceptions();
        if (thrownExceptions.isEmpty()) {
            throw new ConnectionException("Error executing request (no exception provided)");
        }

        final Exception lastThrownException = response.getExceptions().get(response.getExceptions().size() - 1);
        if (IOException.class.isInstance(lastThrownException)) {
            throw new RequestException(
                    "Unable to execute request: " + lastThrownException.getMessage(),
                    lastThrownException);
        } else if (ConnectionException.class.isInstance(lastThrownException)) {
            throw (ConnectionException) lastThrownException;
        }

        throw new ConnectionException("Error executing request: " + lastThrownException.getCause(), lastThrownException);
    }

    /**
     * Validates the response code.
     *
     * @param response the response to validate
     * @deprecated use {@link RetryStrategy#validateResponseCode(Response)} instead
     */
    @Deprecated
    protected void validateResponseCode(final Response response) {
        retryStrategy.validateResponseCode(response);
    }

    /**
     * Extracts the retry after value from a throttled response header (if it exists).
     *
     * @param response the response
     * @return the retry after value in seconds
     * @deprecated use {@link RetryStrategy#extractRetryAfterHeaderValue(Response)} instead
     */
    @Deprecated
    @VisibleForTesting
    protected Long extractRetryAfterHeaderValue(final Response response) {
        return retryStrategy.extractRetryAfterHeaderValue(response);
    }

    @UtilityClass
    public static class Headers {
        public static final String ACCEPT = "Accept";
        public static final String ACCEPT_ENCODING = "Accept-Encoding";
        public static final String AUTHORIZATION = "Authorization";
        public static final String CONTENT_ENCODING = "Content-Encoding";
        public static final String CONTENT_TYPE = "Content-Type";
        public static final String USER_AGENT = "User-Agent";
    }
}