WpErrorInterceptor.java

package io.github.evisentin.wordpress.rest.client.adapter.okhttp.interceptors;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.evisentin.wordpress.rest.client.adapter.okhttp.http.HttpStatusCodes;
import io.github.evisentin.wordpress.rest.client.domain.exception.WpBadRequestException;
import io.github.evisentin.wordpress.rest.client.domain.exception.WpForbiddenException;
import io.github.evisentin.wordpress.rest.client.domain.exception.WpNotFoundException;
import io.github.evisentin.wordpress.rest.client.domain.exception.WpUnauthorizedException;
import io.github.evisentin.wordpress.rest.client.domain.model.WpError;
import okhttp3.*;
import org.jetbrains.annotations.NotNull;

import java.io.IOException;

import static org.apache.commons.lang3.StringUtils.isBlank;

/**
 * OkHttp {@link Interceptor} that translates non-successful HTTP responses from the WordPress REST API into
 * domain-specific exceptions.
 *
 * <p>This interceptor inspects the HTTP response returned by the server. If the
 * response is not successful (i.e., not in the 2xx range), it attempts to parse the response body into a
 * {@link WpError} object and maps the HTTP status code to a corresponding {@code WpException} subtype.
 *
 * <p>Supported mappings include:
 * <ul>
 *     <li>400 → {@link WpBadRequestException}</li>
 *     <li>401 → {@link WpUnauthorizedException}</li>
 *     <li>403 → {@link WpForbiddenException}</li>
 *     <li>404 → {@link WpNotFoundException}</li>
 * </ul>
 *
 * <p>If the response body cannot be parsed or the status code is not explicitly
 * handled, a generic {@link RuntimeException} is thrown.
 *
 * <p>This interceptor should typically be registered with the OkHttp client
 * used by the WordPress client.
 */
public final class WpErrorInterceptor implements Interceptor {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    /**
     * Intercepts the HTTP request/response chain and converts error responses into domain-specific exceptions.
     *
     * <p>If the response is successful, it is returned unchanged. Otherwise,
     * the response body is read and parsed into a {@link WpError} object (if possible), and an appropriate exception is
     * thrown based on the HTTP status code.
     *
     * <p><strong>Note:</strong> The response body is consumed during processing,
     * so it is rebuilt before throwing the exception to preserve the original content.
     *
     * @param chain
     *         the OkHttp interceptor chain
     *
     * @return the original {@link Response} if successful
     *
     * @throws IOException
     *         if an I/O error occurs during request execution
     * @throws RuntimeException
     *         mapped exception corresponding to the HTTP error response
     */
    @Override
    @NotNull
    public Response intercept(final Chain chain) throws IOException {
        final Request request = chain.request();
        final Response response = chain.proceed(request);

        if (response.isSuccessful()) {
            return response;
        }

        final ResponseBody originalBody = response.body();
        final MediaType contentType = originalBody.contentType();
        final String rawBody = originalBody.string();

        final WpError wpError = parseWpError(rawBody);

        final Response rebuiltResponse = response.newBuilder()
                                                 .body(ResponseBody.create(rawBody, contentType))
                                                 .build();

        throw mapException(rebuiltResponse, wpError, rawBody);
    }

    private static String buildBodySuffix(final String rawBody) {
        if (isBlank(rawBody))
            return "";

        return ", body=" + rawBody;
    }

    private static RuntimeException mapException(final Response response,
                                                 final WpError wpError,
                                                 final String rawBody) {
        return switch (response.code()) {
            case HttpStatusCodes.BAD_REQUEST -> new WpBadRequestException(wpError);
            case HttpStatusCodes.NOT_FOUND -> new WpNotFoundException(wpError);
            case HttpStatusCodes.UNAUTHORIZED -> new WpUnauthorizedException(wpError);
            case HttpStatusCodes.FORBIDDEN -> new WpForbiddenException(wpError);
            default -> new RuntimeException("Unexpected HTTP status " + response.code() + buildBodySuffix(rawBody));
        };
    }

    private static WpError parseWpError(final String rawBody) {
        if (isBlank(rawBody))
            return null;

        try {
            return MAPPER.readValue(rawBody, WpError.class);
        } catch (Exception ignored) {
            return null;
        }
    }
}