DiscogsConnection.java
/*
* discogs-java-client - A Java SDK to access the Discogs API
* 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.discogs.connection;
import com.amilesend.client.connection.Connection;
import com.amilesend.client.connection.ConnectionException;
import com.amilesend.client.connection.RequestException;
import com.amilesend.client.connection.auth.AuthManager;
import com.amilesend.client.connection.file.TransferFileWriter;
import com.amilesend.client.connection.file.TransferProgressCallback;
import com.amilesend.client.parse.parser.GsonParser;
import com.amilesend.client.util.Validate;
import com.amilesend.client.util.VisibleForTesting;
import com.amilesend.discogs.connection.auth.AuthVerifier;
import com.amilesend.discogs.connection.auth.NoOpAuthVerifier;
import com.amilesend.discogs.model.inventory.type.DownloadInformation;
import com.amilesend.discogs.model.inventory.type.UploadInformation;
import com.amilesend.discogs.parse.GsonFactory;
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.experimental.SuperBuilder;
import lombok.extern.slf4j.Slf4j;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
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.USER_AGENT;
/**
* Wraps a {@link OkHttpClient} that manages authentication refresh and parsing responses to corresponding POJO types.
*
* @see AuthManager
*/
@SuperBuilder
@Getter
@Slf4j
public class DiscogsConnection extends Connection<GsonFactory> {
public static final String DEFAULT_BASE_URL = "https://api.discogs.com";
public static final String TEXT_CSV_TYPE = "text/csv; charset=utf-8";
public static final String LOCATION = "Location";
public static final String CONTENT_DISPOSITION = "Content-Disposition";
public static final String CONTENT_LENGTH = "Content-Length";
/** The authorization verifier used to check if API calls require authentication prior to invoking the API. */
@Builder.Default
private final AuthVerifier authVerifier = new NoOpAuthVerifier();
/**
* Creates a new {@link Request.Builder} with pre-configured headers for a download request that expects a
* CSV-formatted response.
*
* @return the request builder
*/
public Request.Builder newRequestBuilderForDownload() {
final Request.Builder requestBuilder = new Request.Builder()
.addHeader(USER_AGENT, getUserAgent())
.addHeader(ACCEPT, TEXT_CSV_TYPE);
if (isGzipContentEncodingEnabled()) {
requestBuilder.addHeader(ACCEPT_ENCODING, GZIP_ENCODING);
}
return getAuthManager().addAuthentication(requestBuilder);
}
@Override
public <T> T execute(@NonNull final Request request, @NonNull final GsonParser<T> parser)
throws ConnectionException {
authVerifier.checkIfAuthenticated(getAuthManager());
return super.execute(request, parser);
}
/**
* Uploads the contents for a given {@code request}.
*
* @param request the request
* @param filename the name of the file that is uploaded
* @return the upload information
* @throws ConnectionException if an error occurred while uploading the content for the request
* @see UploadInformation
*/
public UploadInformation upload(@NonNull final Request request, final String filename) {
try (final Response response = execute(request)) {
return UploadInformation.builder()
.filename(filename)
.location(response.header(LOCATION))
.build();
}
}
@Override
public Response execute(@NonNull final Request request) throws ConnectionException {
authVerifier.checkIfAuthenticated(getAuthManager());
return super.execute(request);
}
/**
* Downloads the contents for the given {@code request} to the specified {@code folderPath}.
*
* @param request the request
* @param folderPath the path of the folder to download the content to
* @param callback the {@link TransferProgressCallback} call to invoke to report download transfer progress
* @return the download information
* @throws ConnectionException if an error occurred while downloading the content for the request
* @see DownloadInformation
*/
public DownloadInformation download(
@NonNull final Request request,
@NonNull final Path folderPath,
@NonNull final TransferProgressCallback callback) throws ConnectionException {
authVerifier.checkIfAuthenticated(getAuthManager());
try (final Response response = getHttpClient().newCall(request).execute()) {
final String fileName = parseFileNameFromContentDisposition(response.header(CONTENT_DISPOSITION));
final long sizeBytes = Optional.of(response)
.map(r -> r.header(CONTENT_LENGTH))
.map(Long::parseLong)
.orElseThrow(() ->
new IllegalStateException("Response does not define a CONTENT_LENGTH header"));
final Path downloadPath = checkFolderAndGetDestinationPath(folderPath, fileName);
final long downloadBytes = processDownloadResponse(response, downloadPath, sizeBytes, callback);
return DownloadInformation.builder()
.fileName(fileName)
.sizeBytes(sizeBytes)
.downloadPath(downloadPath)
.downloadedBytes(downloadBytes)
.build();
} catch (final ConnectionException ex) {
// Response failed validation, notify the callback
callback.onFailure(ex);
throw ex;
} catch (final Exception ex) {
// The underlying TransferFileWriter will record an onFailure to the callback.
throw new RequestException("Unable to execute request: " + ex.getMessage(), ex);
}
}
@VisibleForTesting
String parseFileNameFromContentDisposition(final String headerValue) {
Validate.notBlank(headerValue, "Content-Disposition header value must not be blank");
final String validationErrorMsg = "Content-Disposition header value contains unexpected format: " + headerValue;
Validate.isTrue(headerValue.startsWith("attachment; filename="), validationErrorMsg);
final String[] tokens = headerValue.split("=");
Validate.isTrue(tokens.length == 2, validationErrorMsg);
return tokens[1];
}
@VisibleForTesting
Path checkFolderAndGetDestinationPath(final Path folderPath, final String name) throws IOException {
final Path normalizedFolderPath = folderPath.toAbsolutePath().normalize();
if (Files.exists(normalizedFolderPath) && !Files.isDirectory(normalizedFolderPath)) {
throw new IllegalArgumentException(normalizedFolderPath + " must not already exist as a file");
}
Files.createDirectories(normalizedFolderPath);
return normalizedFolderPath.resolve(name);
}
@VisibleForTesting
long processDownloadResponse(
final Response response,
final Path downloadPath,
final long sizeBytes,
final TransferProgressCallback callback) throws IOException {
validateResponseCode(response);
final long totalBytes = TransferFileWriter.builder()
.output(downloadPath)
.callback(callback)
.build()
.write(response.body().source(), sizeBytes);
if (log.isDebugEnabled()) {
log.debug("Downloaded [{}] bytes to [{}]", totalBytes, downloadPath);
}
return totalBytes;
}
}