Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,32 +18,84 @@

import static org.datatransferproject.types.common.models.DataVertical.PHOTOS;

import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.datatransferproject.auth.OAuth2Config;
import org.datatransferproject.types.common.models.DataVertical;
import org.datatransferproject.types.transfer.serviceconfig.TransferServiceConfig;

/**
* Class that provides Imgur-specific information for OAuth2
* See https://apidocs.imgur.com/#authorization-and-oauth
*
* <p>The authorization and token endpoints default to Imgur's, and may be overridden from
* {@code config/imgur.yaml} on the classpath -- the same file {@code ImgurTransferExtension} reads
* its {@code baseUrl} from. That exists so a deployer can point the adapter at a staging or test
* double without rebuilding; nothing else changes behaviour.
*/
public class ImgurOAuthConfig implements OAuth2Config {

private static final String SERVICE_NAME = "Imgur";

@VisibleForTesting
static final String DEFAULT_AUTH_URL = "https://api.imgur.com/oauth2/authorize";

@VisibleForTesting
static final String DEFAULT_TOKEN_URL = "https://api.imgur.com/oauth2/token";

private final String authUrl;
private final String tokenUrl;

public ImgurOAuthConfig() {
this(readServiceConfig());
}

@VisibleForTesting
ImgurOAuthConfig(Optional<JsonNode> serviceConfig) {
this.authUrl = configuredOrDefault(serviceConfig, "authUrl", DEFAULT_AUTH_URL);
this.tokenUrl = configuredOrDefault(serviceConfig, "tokenUrl", DEFAULT_TOKEN_URL);
}

/**
* Reads {@code config/imgur.yaml} if one is on the classpath.
*
* <p>Unlike the transfer extension, an {@link
* org.datatransferproject.auth.OAuth2ServiceExtension} is handed no service-scoped {@code
* TransferServiceConfig}, so this reads it directly. A missing or unreadable file is not an
* error -- it just means the defaults apply.
*/
private static Optional<JsonNode> readServiceConfig() {
try {
return TransferServiceConfig.getForService(SERVICE_NAME).getServiceConfig();
} catch (IOException e) {
return Optional.empty();
}
}

private static String configuredOrDefault(
Optional<JsonNode> serviceConfig, String field, String fallback) {
return serviceConfig.map(node -> node.path(field).asText(fallback)).orElse(fallback);
}

@Override
public String getServiceName() {
return "Imgur";
return SERVICE_NAME;
}

@Override
public String getAuthUrl() {
return "https://api.imgur.com/oauth2/authorize";
return authUrl;
}

@Override
public String getTokenUrl() {
return "https://api.imgur.com/oauth2/token";
return tokenUrl;
}

// Imgur doesn't require scopes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2026 The Data Transfer Project Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.datatransferproject.auth.imgur;

import static com.google.common.truth.Truth.assertThat;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.fasterxml.jackson.databind.JsonNode;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Optional;
import org.datatransferproject.types.transfer.serviceconfig.TransferServiceConfig;
import org.junit.jupiter.api.Test;

public class ImgurOAuthConfigTest {

private static Optional<JsonNode> serviceConfig(String yaml) throws IOException {
return TransferServiceConfig.create(new ByteArrayInputStream(yaml.getBytes(UTF_8)))
.getServiceConfig();
}

@Test
public void defaultsToImgursOwnEndpoints() {
ImgurOAuthConfig config = new ImgurOAuthConfig(Optional.empty());

assertThat(config.getAuthUrl()).isEqualTo(ImgurOAuthConfig.DEFAULT_AUTH_URL);
assertThat(config.getTokenUrl()).isEqualTo(ImgurOAuthConfig.DEFAULT_TOKEN_URL);
}

@Test
public void readsBothEndpointsFromServiceConfig() throws IOException {
ImgurOAuthConfig config =
new ImgurOAuthConfig(
serviceConfig(
"serviceConfig:\n"
+ " authUrl: \"https://imgur.example/oauth2/authorize\"\n"
+ " tokenUrl: \"https://imgur.example/oauth2/token\"\n"));

assertThat(config.getAuthUrl()).isEqualTo("https://imgur.example/oauth2/authorize");
assertThat(config.getTokenUrl()).isEqualTo("https://imgur.example/oauth2/token");
}

@Test
public void overridesEachEndpointIndependently() throws IOException {
// Only tokenUrl is strictly load-bearing -- generateAuthData dereferences it,
// while the auth URL is only ever handed to a browser -- so overriding one
// without the other has to leave the other at its default rather than empty.
ImgurOAuthConfig config =
serviceConfigured("serviceConfig:\n tokenUrl: \"https://imgur.example/oauth2/token\"\n");

assertThat(config.getTokenUrl()).isEqualTo("https://imgur.example/oauth2/token");
assertThat(config.getAuthUrl()).isEqualTo(ImgurOAuthConfig.DEFAULT_AUTH_URL);
}

@Test
public void keepsDefaultsWhenTheConfigHasNoServiceSection() throws IOException {
ImgurOAuthConfig config = serviceConfigured("perUserRateLimit: 10");

assertThat(config.getAuthUrl()).isEqualTo(ImgurOAuthConfig.DEFAULT_AUTH_URL);
assertThat(config.getTokenUrl()).isEqualTo(ImgurOAuthConfig.DEFAULT_TOKEN_URL);
}

@Test
public void keepsTheServiceNameTheRegistryKeysOn() {
// PortabilityAuthServiceProviderRegistry does an exact-match lookup on this
// string, so a change here silently breaks every Imgur job at creation time.
assertThat(new ImgurOAuthConfig(Optional.empty()).getServiceName()).isEqualTo("Imgur");
}

private static ImgurOAuthConfig serviceConfigured(String yaml) throws IOException {
return new ImgurOAuthConfig(serviceConfig(yaml));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
import static org.datatransferproject.types.common.models.DataVertical.PHOTOS;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import java.util.Optional;
import okhttp3.OkHttpClient;
import org.datatransferproject.api.launcher.ExtensionContext;
import org.datatransferproject.api.launcher.Monitor;
Expand All @@ -32,11 +35,14 @@
import org.datatransferproject.spi.transfer.extension.TransferExtension;
import org.datatransferproject.spi.transfer.provider.Exporter;
import org.datatransferproject.spi.transfer.provider.Importer;
import org.datatransferproject.types.transfer.serviceconfig.TransferServiceConfig;

/** Extension for transferring Imgur data */
public class ImgurTransferExtension implements TransferExtension {
private static final String SERVICE_ID = "Imgur";
private static final String BASE_URL = "https://api.imgur.com/3";

@VisibleForTesting
static final String DEFAULT_BASE_URL = "https://api.imgur.com/3";

private boolean initialized = false;

Expand All @@ -58,12 +64,28 @@ public void initialize(ExtensionContext context) {
OkHttpClient client = context.getService(OkHttpClient.class);
TemporaryPerJobDataStore jobStore = context.getService(TemporaryPerJobDataStore.class);

exporter = new ImgurPhotosExporter(monitor, client, mapper, jobStore, BASE_URL);
importer = new ImgurPhotosImporter(monitor, client, mapper, jobStore, BASE_URL);
String baseUrl = baseUrl(context.getService(TransferServiceConfig.class));

exporter = new ImgurPhotosExporter(monitor, client, mapper, jobStore, baseUrl);
importer = new ImgurPhotosImporter(monitor, client, mapper, jobStore, baseUrl);

initialized = true;
}

/**
* The API root, from {@code config/imgur.yaml} if one is on the classpath.
*
* <p>Follows the convention Flickr and Deezer already use, so a deployer can point the adapter at
* a staging endpoint or a test double without rebuilding. Defaults to Imgur's own.
*/
@VisibleForTesting
static String baseUrl(TransferServiceConfig serviceConfig) {
Optional<JsonNode> config = serviceConfig.getServiceConfig();
return config
.map(node -> node.path("baseUrl").asText(DEFAULT_BASE_URL))
.orElse(DEFAULT_BASE_URL);
}

@Override
public String getServiceId() {
return SERVICE_ID;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2026 The Data Transfer Project Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.datatransferproject.datatransfer.imgur;

import static com.google.common.truth.Truth.assertThat;
import static java.nio.charset.StandardCharsets.UTF_8;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import org.datatransferproject.types.transfer.serviceconfig.TransferServiceConfig;
import org.junit.jupiter.api.Test;

public class ImgurTransferExtensionTest {

private static TransferServiceConfig configFrom(String yaml) throws IOException {
return TransferServiceConfig.create(new ByteArrayInputStream(yaml.getBytes(UTF_8)));
}

@Test
public void usesImgursOwnApiWhenNothingIsConfigured() {
assertThat(ImgurTransferExtension.baseUrl(TransferServiceConfig.getDefaultInstance()))
.isEqualTo(ImgurTransferExtension.DEFAULT_BASE_URL);
}

@Test
public void usesImgursOwnApiWhenTheConfigHasNoServiceSection() throws IOException {
// A config file that only sets a rate limit is the shape Flickr and Deezer
// ship, so it must not be read as "override the base URL with nothing".
assertThat(ImgurTransferExtension.baseUrl(configFrom("perUserRateLimit: 10")))
.isEqualTo(ImgurTransferExtension.DEFAULT_BASE_URL);
}

@Test
public void readsTheBaseUrlFromServiceConfig() throws IOException {
TransferServiceConfig config =
configFrom("serviceConfig:\n baseUrl: \"https://imgur.example/3\"\n");

assertThat(ImgurTransferExtension.baseUrl(config)).isEqualTo("https://imgur.example/3");
}

@Test
public void ignoresAServiceConfigThatSetsOtherKeys() throws IOException {
TransferServiceConfig config = configFrom("serviceConfig:\n tokenUrl: \"https://x/token\"\n");

assertThat(ImgurTransferExtension.baseUrl(config))
.isEqualTo(ImgurTransferExtension.DEFAULT_BASE_URL);
}
}
Loading