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); + } +}