Skip to content

Commit

Permalink
#7243 🎉 Source Shopify: implement OAuth Java part
Browse files Browse the repository at this point in the history
  • Loading branch information
alexandr-shegeda committed Oct 21, 2021
1 parent b18acd6 commit 8d878b6
Show file tree
Hide file tree
Showing 5 changed files with 293 additions and 3 deletions.
3 changes: 3 additions & 0 deletions airbyte-api/src/main/openapi/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3239,6 +3239,9 @@ components:
redirectUrl:
description: The url to redirect to after getting the user consent
type: string
params:
type: object
additionalProperties: true
DestinationOauthConsentRequest:
type: object
required:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,18 @@
"examples": ["2021-01-01"],
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
},
"auth_method": {
"credentials": {
"title": "Shopify Authorization Method",
"type": "object",
"oneOf": [
{
"type": "object",
"title": "OAuth2.0",
"required": ["client_id", "client_secret", "access_token"],
"required": [
"client_id",
"client_secret",
"access_token"
],
"properties": {
"auth_method": {
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.airbyte.config.persistence.ConfigRepository;
import io.airbyte.oauth.flows.AsanaOAuthFlow;
import io.airbyte.oauth.flows.FacebookMarketingOAuthFlow;
import io.airbyte.oauth.flows.ShopifyOAuthFlow;
import io.airbyte.oauth.flows.TrelloOAuthFlow;
import io.airbyte.oauth.flows.google.GoogleAdsOAuthFlow;
import io.airbyte.oauth.flows.google.GoogleAnalyticsOAuthFlow;
Expand All @@ -24,8 +25,10 @@ public OAuthImplementationFactory(final ConfigRepository configRepository) {
.put("airbyte/source-facebook-marketing", new FacebookMarketingOAuthFlow(configRepository))
.put("airbyte/source-google-ads", new GoogleAdsOAuthFlow(configRepository))
.put("airbyte/source-google-analytics-v4", new GoogleAnalyticsOAuthFlow(configRepository))
.put("airbyte/source-google-search-console", new GoogleSearchConsoleOAuthFlow(configRepository))
.put("airbyte/source-google-search-console",
new GoogleSearchConsoleOAuthFlow(configRepository))
.put("airbyte/source-trello", new TrelloOAuthFlow(configRepository))
.put("airbyte/source-shopify", new ShopifyOAuthFlow(configRepository))
.build();
}

Expand Down
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()));
}
}

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

}

0 comments on commit 8d878b6

Please sign in to comment.