CoverallsClient.java

/*
 * The MIT License (MIT)
 *
 * Copyright (c) 2013-2026 The Coveralls Maven Plugin Project Contributors:
 *     https://github.com/hazendaz/coveralls-maven-plugin/graphs/contributors
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package org.eluder.coveralls.maven.plugin.httpclient;

import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.Provider;
import java.security.Security;
import java.time.Duration;

import org.eluder.coveralls.maven.plugin.ProcessingException;
import org.eluder.coveralls.maven.plugin.domain.CoverallsResponse;

/**
 * The Class CoverallsClient.
 */
public class CoverallsClient {

    static {
        for (final Provider provider : Security.getProviders()) {
            if (provider.getName().startsWith("SunPKCS11")) {
                Security.removeProvider(provider.getName());
            }
        }
    }

    /** The Constant DEFAULT_SOCKET_TIMEOUT. */
    private static final Duration DEFAULT_SOCKET_TIMEOUT = Duration.ofSeconds(60);

    /** The Constant FILE_NAME. */
    private static final String FILE_NAME = "coveralls.json";

    /** The Constant USER_AGENT_STRING. */
    private static final String USER_AGENT_STRING = "coveralls-maven-plugin";

    /** The coveralls url. */
    private final String coverallsUrl;

    /** The http client. */
    private final HttpClient httpClient;

    /** The object mapper. */
    private final ObjectMapper objectMapper;

    /**
     * Instantiates a new Coveralls Client.
     *
     * @param coverallsUrl
     *            The base url for the Coveralls API. This should generally be set to
     *
     *            <pre>
     * https://coveralls.io/api/v1/jobs
     *            </pre>
     */
    public CoverallsClient(final String coverallsUrl) {
        this(coverallsUrl, new HttpClientFactory(coverallsUrl).create(), new ObjectMapper());
    }

    /**
     * Instantiates a new Coveralls Client.
     *
     * @param coverallsUrl
     *            The base url for the Coveralls API. This should generally be set to
     *
     *            <pre>
     * https://coveralls.io/api/v1/jobs
     *            </pre>
     *
     * @param httpClient
     *            An implementation of {@link HttpClient}
     * @param objectMapper
     *            A Jackson {@link ObjectMapper}
     */
    public CoverallsClient(final String coverallsUrl, final HttpClient httpClient, final ObjectMapper objectMapper) {
        this.coverallsUrl = coverallsUrl;
        this.httpClient = httpClient;
        this.objectMapper = objectMapper;
    }

    /**
     * Submit a coveralls json file to the API.
     *
     * @param file
     *            A coveralls report that can be submitted to the jobs API
     *
     * @return An API response body deserialized to a {@link CoverallsResponse}
     *
     * @throws ProcessingException
     *             the processing exception
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     * @throws InterruptedException
     *             the interrupted exception
     */
    public CoverallsResponse submit(final File file) throws ProcessingException, IOException, InterruptedException {
        final var filePath = file.toPath();

        // Generate a unique boundary
        final String boundary = "----CoverallsBoundary" + System.currentTimeMillis();
        final String CRLF = "\r\n";

        // Build multipart body
        final var byteStream = new ByteArrayOutputStream();
        byteStream.write(("--" + boundary + CRLF).getBytes(StandardCharsets.UTF_8));
        byteStream.write(("Content-Disposition: form-data; name=\"json_file\"; filename=\"" + FILE_NAME + "\"" + CRLF)
                .getBytes(StandardCharsets.UTF_8));
        byteStream
                .write(("Content-Type: application/json;charset=UTF-8" + CRLF + CRLF).getBytes(StandardCharsets.UTF_8));
        byteStream.write(Files.readAllBytes(filePath));
        byteStream.write((CRLF + "--" + boundary + "--" + CRLF).getBytes(StandardCharsets.UTF_8));
        final byte[] multipartBody = byteStream.toByteArray();

        final var request = HttpRequest.newBuilder().version(HttpClient.Version.HTTP_1_1)
                .uri(URI.create(this.coverallsUrl)).timeout(CoverallsClient.DEFAULT_SOCKET_TIMEOUT)
                .header("User-Agent", CoverallsClient.USER_AGENT_STRING)
                .header("Content-Type", "multipart/form-data; boundary=" + boundary)
                .POST(HttpRequest.BodyPublishers.ofByteArray(multipartBody)).build();

        final HttpResponse<InputStream> response = this.httpClient.send(request,
                HttpResponse.BodyHandlers.ofInputStream());
        return this.parseResponse(response);
    }

    /**
     * Parses the response.
     *
     * @param response
     *            the response
     *
     * @return the coveralls response
     *
     * @throws ProcessingException
     *             the processing exception
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    private CoverallsResponse parseResponse(final HttpResponse<InputStream> response)
            throws ProcessingException, IOException {
        if (response.statusCode() >= 500) {
            throw new IOException(this.getResponseErrorMessage(response, "Coveralls API internal error"));
        }

        try (var reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
            final var cr = this.objectMapper.readValue(reader, CoverallsResponse.class);
            if (cr.isError()) {
                throw new ProcessingException(this.getResponseErrorMessage(response, cr.getMessage()));
            }
            return cr;
        } catch (final IOException e) {
            throw new ProcessingException(this.getResponseErrorMessage(response, e.getMessage()), e);
        }
    }

    /**
     * Gets the response error message.
     *
     * @param response
     *            the response
     * @param message
     *            the message
     *
     * @return the response error message
     */
    private String getResponseErrorMessage(final HttpResponse<InputStream> response, final String message) {
        final var errorMessage = new StringBuilder("Report submission to Coveralls API failed with HTTP status ")
                .append(response.statusCode());
        if (message != null && !message.isBlank()) {
            errorMessage.append(": ").append(message);
        }
        return errorMessage.toString();
    }
}