diff --git a/extensions/auth/portability-auth-imgur/src/main/java/org.datatransferproject.auth.imgur/ImgurOAuthConfig.java b/extensions/auth/portability-auth-imgur/src/main/java/org.datatransferproject.auth.imgur/ImgurOAuthConfig.java
index 94a761806..9ef692105 100644
--- a/extensions/auth/portability-auth-imgur/src/main/java/org.datatransferproject.auth.imgur/ImgurOAuthConfig.java
+++ b/extensions/auth/portability-auth-imgur/src/main/java/org.datatransferproject.auth.imgur/ImgurOAuthConfig.java
@@ -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
+ *
+ *
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 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.
+ *
+ * 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 readServiceConfig() {
+ try {
+ return TransferServiceConfig.getForService(SERVICE_NAME).getServiceConfig();
+ } catch (IOException e) {
+ return Optional.empty();
+ }
+ }
+
+ private static String configuredOrDefault(
+ Optional 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
diff --git a/extensions/auth/portability-auth-imgur/src/test/java/org/datatransferproject/auth/imgur/ImgurOAuthConfigTest.java b/extensions/auth/portability-auth-imgur/src/test/java/org/datatransferproject/auth/imgur/ImgurOAuthConfigTest.java
new file mode 100644
index 000000000..ec858d5a4
--- /dev/null
+++ b/extensions/auth/portability-auth-imgur/src/test/java/org/datatransferproject/auth/imgur/ImgurOAuthConfigTest.java
@@ -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 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));
+ }
+}
diff --git a/extensions/data-transfer/portability-data-transfer-imgur/src/main/java/org/datatransferproject/datatransfer/imgur/ImgurTransferExtension.java b/extensions/data-transfer/portability-data-transfer-imgur/src/main/java/org/datatransferproject/datatransfer/imgur/ImgurTransferExtension.java
index 1b514e7f0..de250593b 100644
--- a/extensions/data-transfer/portability-data-transfer-imgur/src/main/java/org/datatransferproject/datatransfer/imgur/ImgurTransferExtension.java
+++ b/extensions/data-transfer/portability-data-transfer-imgur/src/main/java/org/datatransferproject/datatransfer/imgur/ImgurTransferExtension.java
@@ -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;
@@ -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;
@@ -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.
+ *
+ * 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 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;
diff --git a/extensions/data-transfer/portability-data-transfer-imgur/src/test/java/org/datatransferproject/datatransfer/imgur/ImgurTransferExtensionTest.java b/extensions/data-transfer/portability-data-transfer-imgur/src/test/java/org/datatransferproject/datatransfer/imgur/ImgurTransferExtensionTest.java
new file mode 100644
index 000000000..bbb505daa
--- /dev/null
+++ b/extensions/data-transfer/portability-data-transfer-imgur/src/test/java/org/datatransferproject/datatransfer/imgur/ImgurTransferExtensionTest.java
@@ -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);
+ }
+}