-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
#7243 🎉 Source Shopify: implement OAuth Java part
- Loading branch information
1 parent
b18acd6
commit 8d878b6
Showing
5 changed files
with
293 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
133 changes: 133 additions & 0 deletions
133
airbyte-oauth/src/main/java/io/airbyte/oauth/flows/ShopifyOAuthFlow.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
/* | ||
* Copyright (c) 2021 Airbyte, Inc., all rights reserved. | ||
*/ | ||
|
||
package io.airbyte.oauth.flows; | ||
|
||
import com.fasterxml.jackson.databind.JsonNode; | ||
import com.google.api.client.util.Preconditions; | ||
import com.google.common.annotations.VisibleForTesting; | ||
import com.google.common.collect.ImmutableMap; | ||
import io.airbyte.config.persistence.ConfigRepository; | ||
import io.airbyte.oauth.BaseOAuthFlow; | ||
import java.io.IOException; | ||
import java.net.URISyntaxException; | ||
import java.net.http.HttpClient; | ||
import java.util.Arrays; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.UUID; | ||
import java.util.function.Supplier; | ||
import org.apache.http.client.utils.URIBuilder; | ||
|
||
public class ShopifyOAuthFlow extends BaseOAuthFlow { | ||
|
||
private static final String ACCESS_TOKEN_URL = "https://airbyte-integration-test.myshopify.com/admin/oauth/access_token"; | ||
private static final List<String> SCOPES = Arrays.asList( | ||
"read_themes", | ||
"read_orders", | ||
"read_all_orders", | ||
"read_assigned_fulfillment_orders", | ||
"read_checkouts", | ||
"read_content", | ||
"read_customers", | ||
"read_discounts", | ||
"read_draft_orders", | ||
"read_fulfillments", | ||
"read_locales", | ||
"read_locations", | ||
"read_price_rules", | ||
"read_products", | ||
"read_product_listings", | ||
"read_shopify_payments_payouts"); | ||
|
||
private String authPrefix = "airbyte-integration-test"; | ||
|
||
public ShopifyOAuthFlow(ConfigRepository configRepository) { | ||
super(configRepository); | ||
} | ||
|
||
public String getScopes() { | ||
return String.join(",", SCOPES); | ||
} | ||
|
||
public void setAuthPrefix(String authPrefix) { | ||
this.authPrefix = authPrefix; | ||
} | ||
|
||
@VisibleForTesting | ||
ShopifyOAuthFlow(ConfigRepository configRepository, HttpClient httpClient, | ||
Supplier<String> stateSupplier) { | ||
super(configRepository, httpClient, stateSupplier); | ||
} | ||
|
||
@Override | ||
protected String formatConsentUrl(UUID definitionId, String clientId, String redirectUrl) | ||
throws IOException { | ||
String host = authPrefix + ".myshopify.com"; | ||
final URIBuilder builder = new URIBuilder() | ||
.setScheme("https") | ||
.setHost(host) | ||
.setPath("admin/oauth/authorize") | ||
.addParameter("client_id", clientId) | ||
.addParameter("redirect_uri", redirectUrl) | ||
.addParameter("state", getState()) | ||
.addParameter("grant_options[]", "value") | ||
.addParameter("scope", getScopes()); | ||
try { | ||
return builder.build().toString(); | ||
} catch (URISyntaxException e) { | ||
throw new IOException("Failed to format Consent URL for OAuth flow", e); | ||
} | ||
} | ||
|
||
@Override | ||
protected String extractCodeParameter(Map<String, Object> queryParams) throws IOException { | ||
if (queryParams.containsKey("code")) { | ||
return (String) queryParams.get("code"); | ||
} else { | ||
throw new IOException("Undefined 'code' from consent redirected url."); | ||
} | ||
} | ||
|
||
@Override | ||
protected String getClientIdUnsafe(JsonNode config) { | ||
// the config object containing client ID is nested inside the "credentials" object | ||
Preconditions.checkArgument(config.hasNonNull("credentials")); | ||
return super.getClientIdUnsafe(config.get("credentials")); | ||
} | ||
|
||
@Override | ||
protected String getClientSecretUnsafe(JsonNode config) { | ||
// the config object containing client SECRET is nested inside the "credentials" object | ||
Preconditions.checkArgument(config.hasNonNull("credentials")); | ||
return super.getClientSecretUnsafe(config.get("credentials")); | ||
} | ||
|
||
@Override | ||
protected String getAccessTokenUrl() { | ||
return ACCESS_TOKEN_URL; | ||
} | ||
|
||
@Override | ||
protected Map<String, String> getAccessTokenQueryParameters(String clientId, String clientSecret, | ||
String authCode, String redirectUrl) { | ||
return ImmutableMap.<String, String>builder() | ||
.put("client_id", clientId) | ||
.put("client_secret", clientSecret) | ||
.put("code", authCode) | ||
.build(); | ||
} | ||
|
||
@Override | ||
protected Map<String, Object> extractRefreshToken(JsonNode data) throws IOException { | ||
// Shopify does not have refresh token but calls it "long lived access token" instead: | ||
if (data.has("access_token")) { | ||
return Map.of("credentials", Map.of("access_token", data.get("access_token").asText())); | ||
} else { | ||
throw new IOException( | ||
String.format("Missing 'access_token' in query params from %s", getAccessTokenUrl())); | ||
} | ||
} | ||
|
||
} |
147 changes: 147 additions & 0 deletions
147
airbyte-oauth/src/test/java/io/airbyte/oauth/flows/ShopifyOAuthFlowTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
/* | ||
* Copyright (c) 2021 Airbyte, Inc., all rights reserved. | ||
*/ | ||
|
||
package io.airbyte.oauth.flows; | ||
|
||
import static org.junit.jupiter.api.Assertions.assertEquals; | ||
import static org.junit.jupiter.api.Assertions.assertThrows; | ||
import static org.mockito.ArgumentMatchers.any; | ||
import static org.mockito.Mockito.mock; | ||
import static org.mockito.Mockito.when; | ||
|
||
import com.fasterxml.jackson.databind.JsonNode; | ||
import com.google.common.collect.ImmutableMap; | ||
import io.airbyte.commons.json.Jsons; | ||
import io.airbyte.config.DestinationOAuthParameter; | ||
import io.airbyte.config.SourceOAuthParameter; | ||
import io.airbyte.config.persistence.ConfigNotFoundException; | ||
import io.airbyte.config.persistence.ConfigRepository; | ||
import io.airbyte.validation.json.JsonValidationException; | ||
import java.io.IOException; | ||
import java.net.http.HttpClient; | ||
import java.net.http.HttpResponse; | ||
import java.nio.file.Files; | ||
import java.nio.file.Path; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.UUID; | ||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.Test; | ||
import org.slf4j.Logger; | ||
import org.slf4j.LoggerFactory; | ||
|
||
public class ShopifyOAuthFlowTest { | ||
|
||
private static final Logger LOGGER = LoggerFactory.getLogger(ShopifyOAuthFlowTest.class); | ||
private static final Path CREDENTIALS_PATH = Path.of("secrets/credentials.json"); | ||
private static final String REDIRECT_URL = "https://airbyte.io"; | ||
private static final String EXPECTED_REDIRECT_URL = "https%3A%2F%2Fairbyte.io"; | ||
private static final String EXPECTED_OPTIONS = | ||
"grant_options%5B%5D=value&scope=read_themes%2Cread_orders%2Cread_all_orders%2Cread_assigned_fulfillment_orders%2Cread_checkouts%2Cread_content%2Cread_customers%2Cread_discounts%2Cread_draft_orders%2Cread_fulfillments%2Cread_locales%2Cread_locations%2Cread_price_rules%2Cread_products%2Cread_product_listings%2Cread_shopify_payments_payouts"; | ||
|
||
private HttpClient httpClient; | ||
private ConfigRepository configRepository; | ||
private ShopifyOAuthFlow shopifyOAuthFlow; | ||
|
||
private UUID workspaceId; | ||
private UUID definitionId; | ||
|
||
@BeforeEach | ||
public void setup() { | ||
httpClient = mock(HttpClient.class); | ||
configRepository = mock(ConfigRepository.class); | ||
shopifyOAuthFlow = new ShopifyOAuthFlow(configRepository, httpClient, | ||
ShopifyOAuthFlowTest::getConstantState); | ||
|
||
workspaceId = UUID.randomUUID(); | ||
definitionId = UUID.randomUUID(); | ||
} | ||
|
||
@Test | ||
public void testGetSourceConsentUrl() | ||
throws IOException, ConfigNotFoundException, JsonValidationException { | ||
when(configRepository.listSourceOAuthParam()).thenReturn(List.of(new SourceOAuthParameter() | ||
.withOauthParameterId(UUID.randomUUID()) | ||
.withSourceDefinitionId(definitionId) | ||
.withWorkspaceId(workspaceId) | ||
.withConfiguration(Jsons.jsonNode(Map.of("credentials", ImmutableMap.builder() | ||
.put("client_id", getClientId()) | ||
.put("client_secret", "test_client_secret") | ||
.build()))))); | ||
final String actualSourceUrl = shopifyOAuthFlow.getSourceConsentUrl(workspaceId, definitionId, | ||
REDIRECT_URL); | ||
final String expectedSourceUrl = String.format( | ||
"https://airbyte-integration-test.myshopify.com/admin/oauth/authorize?client_id=%s&redirect_uri=%s&state=%s&%s", | ||
getClientId(), | ||
EXPECTED_REDIRECT_URL, | ||
getConstantState(), | ||
EXPECTED_OPTIONS); | ||
LOGGER.info(expectedSourceUrl); | ||
assertEquals(expectedSourceUrl, actualSourceUrl); | ||
} | ||
|
||
@Test | ||
public void testCompleteDestinationOAuth() | ||
throws IOException, ConfigNotFoundException, JsonValidationException, InterruptedException { | ||
when(configRepository.listDestinationOAuthParam()).thenReturn( | ||
List.of(new DestinationOAuthParameter() | ||
.withOauthParameterId(UUID.randomUUID()) | ||
.withDestinationDefinitionId(definitionId) | ||
.withWorkspaceId(workspaceId) | ||
.withConfiguration(Jsons.jsonNode(Map.of("credentials", ImmutableMap.builder() | ||
.put("client_id", "test_client_id") | ||
.put("client_secret", "test_client_secret") | ||
.build()))))); | ||
|
||
final Map<String, String> returnedCredentials = Map.of("access_token", | ||
"refresh_token_response"); | ||
final HttpResponse response = mock(HttpResponse.class); | ||
when(response.body()).thenReturn(Jsons.serialize(returnedCredentials)); | ||
when(httpClient.send(any(), any())).thenReturn(response); | ||
final Map<String, Object> queryParams = Map.of("code", "test_code"); | ||
final Map<String, Object> actualQueryParams = shopifyOAuthFlow.completeDestinationOAuth( | ||
workspaceId, definitionId, queryParams, REDIRECT_URL); | ||
|
||
assertEquals(Jsons.serialize(Map.of("credentials", returnedCredentials)), | ||
Jsons.serialize(actualQueryParams)); | ||
} | ||
|
||
@Test | ||
public void testGetClientIdUnsafe() { | ||
final String clientId = "123"; | ||
final Map<String, String> clientIdMap = Map.of("client_id", clientId); | ||
final Map<String, Map<String, String>> nestedConfig = Map.of("credentials", clientIdMap); | ||
|
||
assertThrows(IllegalArgumentException.class, | ||
() -> shopifyOAuthFlow.getClientIdUnsafe(Jsons.jsonNode(clientIdMap))); | ||
assertEquals(clientId, shopifyOAuthFlow.getClientIdUnsafe(Jsons.jsonNode(nestedConfig))); | ||
} | ||
|
||
@Test | ||
public void testGetClientSecretUnsafe() { | ||
final String clientSecret = "secret"; | ||
final Map<String, String> clientIdMap = Map.of("client_secret", clientSecret); | ||
final Map<String, Map<String, String>> nestedConfig = Map.of("credentials", clientIdMap); | ||
|
||
assertThrows(IllegalArgumentException.class, | ||
() -> shopifyOAuthFlow.getClientSecretUnsafe(Jsons.jsonNode(clientIdMap))); | ||
assertEquals(clientSecret, | ||
shopifyOAuthFlow.getClientSecretUnsafe(Jsons.jsonNode(nestedConfig))); | ||
} | ||
|
||
private static String getConstantState() { | ||
return "state"; | ||
} | ||
|
||
private String getClientId() throws IOException { | ||
if (!Files.exists(CREDENTIALS_PATH)) { | ||
return "test_client_id"; | ||
} else { | ||
final String fullConfigAsString = new String(Files.readAllBytes(CREDENTIALS_PATH)); | ||
final JsonNode credentialsJson = Jsons.deserialize(fullConfigAsString); | ||
return credentialsJson.get("credentials").get("client_id").asText(); | ||
} | ||
} | ||
|
||
} |