diff --git a/airbyte-config/init/src/main/resources/config/STANDARD_SOURCE_DEFINITION/77225a51-cd15-4a13-af02-65816bd0ecf4.json b/airbyte-config/init/src/main/resources/config/STANDARD_SOURCE_DEFINITION/77225a51-cd15-4a13-af02-65816bd0ecf4.json index 7daaf44dedab..e5fb4122cceb 100644 --- a/airbyte-config/init/src/main/resources/config/STANDARD_SOURCE_DEFINITION/77225a51-cd15-4a13-af02-65816bd0ecf4.json +++ b/airbyte-config/init/src/main/resources/config/STANDARD_SOURCE_DEFINITION/77225a51-cd15-4a13-af02-65816bd0ecf4.json @@ -2,7 +2,7 @@ "sourceDefinitionId": "77225a51-cd15-4a13-af02-65816bd0ecf4", "name": "Square", "dockerRepository": "airbyte/source-square", - "dockerImageTag": "0.1.3", + "dockerImageTag": "0.1.4", "documentationUrl": "https://docs.airbyte.io/integrations/sources/square", "icon": "square.svg" } diff --git a/airbyte-config/init/src/main/resources/seed/source_definitions.yaml b/airbyte-config/init/src/main/resources/seed/source_definitions.yaml index 7160cb6ecc2d..372fbe110101 100644 --- a/airbyte-config/init/src/main/resources/seed/source_definitions.yaml +++ b/airbyte-config/init/src/main/resources/seed/source_definitions.yaml @@ -676,7 +676,7 @@ - name: Square sourceDefinitionId: 77225a51-cd15-4a13-af02-65816bd0ecf4 dockerRepository: airbyte/source-square - dockerImageTag: 0.1.3 + dockerImageTag: 0.1.4 documentationUrl: https://docs.airbyte.io/integrations/sources/square icon: square.svg sourceType: api diff --git a/airbyte-config/init/src/main/resources/seed/source_specs.yaml b/airbyte-config/init/src/main/resources/seed/source_specs.yaml index e8cafd857637..1a6fc9b15260 100644 --- a/airbyte-config/init/src/main/resources/seed/source_specs.yaml +++ b/airbyte-config/init/src/main/resources/seed/source_specs.yaml @@ -7049,7 +7049,7 @@ supportsNormalization: false supportsDBT: false supported_destination_sync_modes: [] -- dockerImage: "airbyte/source-square:0.1.3" +- dockerImage: "airbyte/source-square:0.1.4" spec: documentationUrl: "https://docs.airbyte.io/integrations/sources/square" connectionSpecification: @@ -7057,15 +7057,9 @@ title: "Square Source CDK Specifications" type: "object" required: - - "api_key" - "is_sandbox" - additionalProperties: false + additionalProperties: true properties: - api_key: - type: "string" - description: "The API key for a Square application." - title: "API Key" - airbyte_secret: true is_sandbox: type: "boolean" description: "Determines whether to use the sandbox or production environment." @@ -7073,7 +7067,7 @@ examples: - true - false - default: true + default: false start_date: type: "string" description: "UTC date in the format YYYY-MM-DD. Any data before this date\ @@ -7081,20 +7075,122 @@ title: "Start Date" examples: - "2021-01-01" - default: "1970-01-01" + default: "2021-01-01" pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" include_deleted_objects: type: "boolean" description: "In some streams there is an option to include deleted objects\ \ (Items, Categories, Discounts, Taxes)" - title: "Include Deleded Objects" + title: "Include Deleted Objects" examples: - true - false default: false + credentials: + type: "object" + title: "Credential Type" + oneOf: + - title: "Oauth authentication" + type: "object" + required: + - "auth_type" + - "client_id" + - "client_secret" + - "refresh_token" + properties: + auth_type: + type: "string" + const: "Oauth" + enum: + - "Oauth" + default: "Oauth" + order: 0 + client_id: + title: "Client ID" + type: "string" + description: "The Square-issued ID of your application" + airbyte_secret: true + client_secret: + title: "Client Secret" + type: "string" + description: "The Square-issued application secret for your application" + airbyte_secret: true + refresh_token: + title: "Refresh Token" + type: "string" + description: "A refresh token generated using the above client ID\ + \ and secret" + airbyte_secret: true + - type: "object" + title: "API Key" + required: + - "auth_type" + - "api_key" + properties: + auth_type: + type: "string" + const: "Apikey" + enum: + - "Apikey" + default: "Apikey" + order: 1 + api_key: + title: "API key token" + type: "string" + description: "The API key for a Square application" + airbyte_secret: true supportsNormalization: false supportsDBT: false supported_destination_sync_modes: [] + authSpecification: + auth_type: "oauth2.0" + oauth2Specification: + rootObject: + - "credentials" + - "0" + oauthFlowInitParameters: + - - "client_id" + - - "client_secret" + oauthFlowOutputParameters: + - - "refresh_token" + advanced_auth: + auth_flow_type: "oauth2.0" + predicate_key: + - "credentials" + - "auth_type" + predicate_value: "Oauth" + oauth_config_specification: + complete_oauth_output_specification: + type: "object" + additionalProperties: false + properties: + refresh_token: + type: "string" + path_in_connector_config: + - "credentials" + - "refresh_token" + complete_oauth_server_input_specification: + type: "object" + additionalProperties: false + properties: + client_id: + type: "string" + client_secret: + type: "string" + complete_oauth_server_output_specification: + type: "object" + additionalProperties: false + properties: + client_id: + type: "string" + path_in_connector_config: + - "credentials" + - "client_id" + client_secret: + type: "string" + path_in_connector_config: + - "credentials" + - "client_secret" - dockerImage: "airbyte/source-strava:0.1.2" spec: documentationUrl: "https://docs.airbyte.io/integrations/sources/strava" diff --git a/airbyte-integrations/connectors/source-square/Dockerfile b/airbyte-integrations/connectors/source-square/Dockerfile index ec5e91b1622f..ef116771ded8 100644 --- a/airbyte-integrations/connectors/source-square/Dockerfile +++ b/airbyte-integrations/connectors/source-square/Dockerfile @@ -12,5 +12,5 @@ RUN pip install . ENV AIRBYTE_ENTRYPOINT "python /airbyte/integration_code/main.py" ENTRYPOINT ["python", "/airbyte/integration_code/main.py"] -LABEL io.airbyte.version=0.1.3 +LABEL io.airbyte.version=0.1.4 LABEL io.airbyte.name=airbyte/source-square diff --git a/airbyte-integrations/connectors/source-square/acceptance-test-config.yml b/airbyte-integrations/connectors/source-square/acceptance-test-config.yml index 68414755e1ea..e3cfdf922783 100644 --- a/airbyte-integrations/connectors/source-square/acceptance-test-config.yml +++ b/airbyte-integrations/connectors/source-square/acceptance-test-config.yml @@ -5,13 +5,18 @@ tests: connection: - config_path: "secrets/config.json" status: "succeed" + - config_path: "secrets/config_oauth.json" + status: "succeed" - config_path: "integration_tests/invalid_config.json" status: "failed" discovery: - config_path: "secrets/config.json" + - config_path: "secrets/config_oauth.json" basic_read: - config_path: "secrets/config.json" configured_catalog_path: "integration_tests/configured_catalog.json" + - config_path: "secrets/config_oauth.json" + configured_catalog_path: "integration_tests/configured_catalog_oauth.json" incremental: - config_path: "secrets/config.json" configured_catalog_path: "integration_tests/configured_catalog.json" @@ -19,3 +24,4 @@ tests: full_refresh: - config_path: "secrets/config.json" configured_catalog_path: "integration_tests/configured_catalog.json" + diff --git a/airbyte-integrations/connectors/source-square/integration_tests/configured_catalog_oauth.json b/airbyte-integrations/connectors/source-square/integration_tests/configured_catalog_oauth.json new file mode 100644 index 000000000000..316537a5a67f --- /dev/null +++ b/airbyte-integrations/connectors/source-square/integration_tests/configured_catalog_oauth.json @@ -0,0 +1,40 @@ +{ + "streams": [ + { + "stream": { + "name": "locations", + "json_schema": {}, + "supported_sync_modes": ["full_refresh"], + "source_defined_cursor": true, + "default_cursor_field": ["id"] + }, + "sync_mode": "full_refresh", + "cursor_field": ["id"], + "destination_sync_mode": "overwrite" + }, + { + "stream": { + "name": "team_members", + "json_schema": {}, + "supported_sync_modes": ["full_refresh"], + "source_defined_cursor": true, + "default_cursor_field": ["id"] + }, + "sync_mode": "full_refresh", + "cursor_field": ["id"], + "destination_sync_mode": "overwrite" + }, + { + "stream": { + "name": "team_member_wages", + "json_schema": {}, + "supported_sync_modes": ["full_refresh"], + "source_defined_cursor": true, + "default_cursor_field": ["id"] + }, + "sync_mode": "full_refresh", + "cursor_field": ["id"], + "destination_sync_mode": "overwrite" + } + ] +} diff --git a/airbyte-integrations/connectors/source-square/source_square/source.py b/airbyte-integrations/connectors/source-square/source_square/source.py index 1c697cfce1d7..6d0bd64c638f 100644 --- a/airbyte-integrations/connectors/source-square/source_square/source.py +++ b/airbyte-integrations/connectors/source-square/source_square/source.py @@ -4,15 +4,18 @@ import json from abc import ABC, abstractmethod -from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Tuple +from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Tuple, Union import pendulum import requests +from airbyte_cdk.logger import AirbyteLogger from airbyte_cdk.models import SyncMode from airbyte_cdk.sources import AbstractSource from airbyte_cdk.sources.streams import Stream from airbyte_cdk.sources.streams.http import HttpStream -from airbyte_cdk.sources.streams.http.auth import TokenAuthenticator +from airbyte_cdk.sources.streams.http.auth.core import HttpAuthenticator +from airbyte_cdk.sources.streams.http.requests_native_auth import Oauth2Authenticator, TokenAuthenticator +from requests.auth import AuthBase from source_square.utils import separate_items_by_count @@ -35,8 +38,16 @@ def parse_square_error_response(error: requests.exceptions.HTTPError) -> SquareE class SquareStream(HttpStream, ABC): - def __init__(self, is_sandbox: bool, api_version: str, start_date: str, include_deleted_objects: bool, **kwargs): - super().__init__(**kwargs) + def __init__( + self, + is_sandbox: bool, + api_version: str, + start_date: str, + include_deleted_objects: bool, + authenticator: Union[AuthBase, HttpAuthenticator], + ): + super().__init__(authenticator) + self._authenticator = authenticator self.is_sandbox = is_sandbox self.api_version = api_version # Converting users ISO 8601 format (YYYY-MM-DD) to RFC 3339 (2021-06-14T13:47:56.799Z) @@ -358,16 +369,75 @@ def stream_slices(self, **kwargs) -> Iterable[Optional[Mapping[str, Any]]]: yield {"location_ids": location} +class Oauth2AuthenticatorSquare(Oauth2Authenticator): + def refresh_access_token(self) -> Tuple[str, int]: + """Handle differences in expiration attr: + from API: "expires_at": "2021-11-05T14:26:57Z" + expected: "expires_in": number of seconds + """ + token, expires_at = super().refresh_access_token() + expires_in = pendulum.parse(expires_at) - pendulum.now() + return token, expires_in.seconds + + class SourceSquare(AbstractSource): - api_version = "2021-06-16" # Latest Stable Release + api_version = "2021-09-15" # Latest Stable Release + + @staticmethod + def get_auth(config: Mapping[str, Any]) -> AuthBase: + + credential = config.get("credentials", {}) + auth_type = credential.get("auth_type") + if auth_type == "Oauth": + # scopes needed for all currently supported streams: + scopes = [ + "CUSTOMERS_READ", + "EMPLOYEES_READ", + "ITEMS_READ", + "MERCHANT_PROFILE_READ", + "ORDERS_READ", + "PAYMENTS_READ", + "TIMECARDS_READ", + # OAuth Permissions: + # https://developer.squareup.com/docs/oauth-api/square-permissions + # https://developer.squareup.com/reference/square/enums/OAuthPermission + # "DISPUTES_READ", + # "GIFTCARDS_READ", + # "INVENTORY_READ", + # "INVOICES_READ", + # "TIMECARDS_SETTINGS_READ", + # "LOYALTY_READ", + # "ONLINE_STORE_SITE_READ", + # "ONLINE_STORE_SNIPPETS_READ", + # "SUBSCRIPTIONS_READ", + ] + + auth = Oauth2AuthenticatorSquare( + token_refresh_endpoint="https://connect.squareup.com/oauth2/token", + client_secret=credential.get("client_secret"), + client_id=credential.get("client_id"), + refresh_token=credential.get("refresh_token"), + scopes=scopes, + expires_in_name="expires_at", + ) + elif auth_type == "Apikey": + auth = TokenAuthenticator(token=credential.get("api_key")) + elif not auth_type and config.get("api_key"): + auth = TokenAuthenticator(token=config.get("api_key")) + else: + raise Exception(f"Invalid auth type: {auth_type}") + + return auth - def check_connection(self, logger, config) -> Tuple[bool, any]: + def check_connection(self, logger: AirbyteLogger, config: Mapping[str, Any]) -> Tuple[bool, any]: headers = { "Square-Version": self.api_version, - "Authorization": "Bearer {}".format(config["api_key"]), "Content-Type": "application/json", } + auth = self.get_auth(config) + headers.update(auth.get_auth_header()) + url = "https://connect.squareup{}.com/v2/catalog/info".format("sandbox" if config["is_sandbox"] else "") try: @@ -383,9 +453,8 @@ def check_connection(self, logger, config) -> Tuple[bool, any]: def streams(self, config: Mapping[str, Any]) -> List[Stream]: - auth = TokenAuthenticator(token=config["api_key"]) args = { - "authenticator": auth, + "authenticator": self.get_auth(config), "is_sandbox": config["is_sandbox"], "api_version": self.api_version, "start_date": config["start_date"], diff --git a/airbyte-integrations/connectors/source-square/source_square/spec.json b/airbyte-integrations/connectors/source-square/source_square/spec.json index 6a7acf383ddb..23c77b753f8f 100644 --- a/airbyte-integrations/connectors/source-square/source_square/spec.json +++ b/airbyte-integrations/connectors/source-square/source_square/spec.json @@ -4,37 +4,146 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Square Source CDK Specifications", "type": "object", - "required": ["api_key", "is_sandbox"], - "additionalProperties": false, + "required": ["is_sandbox"], + "additionalProperties": true, "properties": { - "api_key": { - "type": "string", - "description": "The API key for a Square application.", - "title": "API Key", - "airbyte_secret": true - }, "is_sandbox": { "type": "boolean", "description": "Determines whether to use the sandbox or production environment.", "title": "Sandbox", "examples": [true, false], - "default": true + "default": false }, "start_date": { "type": "string", "description": "UTC date in the format YYYY-MM-DD. Any data before this date will not be replicated. If not set, all data will be replicated.", "title": "Start Date", "examples": ["2021-01-01"], - "default": "1970-01-01", + "default": "2021-01-01", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, "include_deleted_objects": { "type": "boolean", "description": "In some streams there is an option to include deleted objects (Items, Categories, Discounts, Taxes)", - "title": "Include Deleded Objects", + "title": "Include Deleted Objects", "examples": [true, false], "default": false + }, + "credentials": { + "type": "object", + "title": "Credential Type", + "oneOf": [ + { + "title": "Oauth authentication", + "type": "object", + "required": [ + "auth_type", + "client_id", + "client_secret", + "refresh_token" + ], + "properties": { + "auth_type": { + "type": "string", + "const": "Oauth", + "enum": ["Oauth"], + "default": "Oauth", + "order": 0 + }, + "client_id": { + "title": "Client ID", + "type": "string", + "description": "The Square-issued ID of your application", + "airbyte_secret": true + }, + "client_secret": { + "title": "Client Secret", + "type": "string", + "description": "The Square-issued application secret for your application", + "airbyte_secret": true + }, + "refresh_token": { + "title": "Refresh Token", + "type": "string", + "description": "A refresh token generated using the above client ID and secret", + "airbyte_secret": true + } + } + }, + { + "type": "object", + "title": "API Key", + "required": ["auth_type", "api_key"], + "properties": { + "auth_type": { + "type": "string", + "const": "Apikey", + "enum": ["Apikey"], + "default": "Apikey", + "order": 1 + }, + "api_key": { + "title": "API key token", + "type": "string", + "description": "The API key for a Square application", + "airbyte_secret": true + } + } + } + ] + } + } + }, + "authSpecification": { + "auth_type": "oauth2.0", + "oauth2Specification": { + "rootObject": ["credentials", 0], + "oauthFlowInitParameters": [["client_id"], ["client_secret"]], + "oauthFlowOutputParameters": [["refresh_token"]] + } + }, + "advanced_auth": { + "auth_flow_type": "oauth2.0", + "predicate_key": ["credentials", "auth_type"], + "predicate_value": "Oauth", + "oauth_config_specification": { + "complete_oauth_output_specification": { + "type": "object", + "additionalProperties": false, + "properties": { + "refresh_token": { + "type": "string", + "path_in_connector_config": ["credentials", "refresh_token"] + } + } + }, + "complete_oauth_server_input_specification": { + "type": "object", + "additionalProperties": false, + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + } + } + }, + "complete_oauth_server_output_specification": { + "type": "object", + "additionalProperties": false, + "properties": { + "client_id": { + "type": "string", + "path_in_connector_config": ["credentials", "client_id"] + }, + "client_secret": { + "type": "string", + "path_in_connector_config": ["credentials", "client_secret"] + } + } } } } + } diff --git a/airbyte-integrations/connectors/source-square/unit_tests/connection_test.py b/airbyte-integrations/connectors/source-square/unit_tests/connection_test.py index f47cb7ca956b..5e028777a7cf 100644 --- a/airbyte-integrations/connectors/source-square/unit_tests/connection_test.py +++ b/airbyte-integrations/connectors/source-square/unit_tests/connection_test.py @@ -8,5 +8,11 @@ def test_source_wrong_credentials(): source = SourceSquare() - status, error = source.check_connection(logger=AirbyteLogger(), config={"api_key": "wrong.api.key", "is_sandbox": True}) + config = { + "credentials": {"auth_type": "Apikey", "api_key": "bla"}, + "is_sandbox": True, + "start_date": "2021-06-01", + "include_deleted_objects": False, + } + status, error = source.check_connection(logger=AirbyteLogger(), config=config) assert not status diff --git a/airbyte-oauth/src/main/java/io/airbyte/oauth/OAuthImplementationFactory.java b/airbyte-oauth/src/main/java/io/airbyte/oauth/OAuthImplementationFactory.java index d9de82bf9ecc..7a549d65a365 100644 --- a/airbyte-oauth/src/main/java/io/airbyte/oauth/OAuthImplementationFactory.java +++ b/airbyte-oauth/src/main/java/io/airbyte/oauth/OAuthImplementationFactory.java @@ -47,6 +47,7 @@ public OAuthImplementationFactory(final ConfigRepository configRepository, final .put("airbyte/source-salesforce", new SalesforceOAuthFlow(configRepository, httpClient)) .put("airbyte/source-slack", new SlackOAuthFlow(configRepository, httpClient)) .put("airbyte/source-snapchat-marketing", new SnapchatMarketingOAuthFlow(configRepository, httpClient)) + .put("airbyte/source-square", new SquareOAuthFlow(configRepository, httpClient)) .put("airbyte/source-strava", new StravaOAuthFlow(configRepository, httpClient)) .put("airbyte/source-surveymonkey", new SurveymonkeyOAuthFlow(configRepository, httpClient)) .put("airbyte/source-trello", new TrelloOAuthFlow(configRepository, httpClient)) diff --git a/airbyte-oauth/src/main/java/io/airbyte/oauth/flows/SquareOAuthFlow.java b/airbyte-oauth/src/main/java/io/airbyte/oauth/flows/SquareOAuthFlow.java new file mode 100644 index 000000000000..1e9d821a67a1 --- /dev/null +++ b/airbyte-oauth/src/main/java/io/airbyte/oauth/flows/SquareOAuthFlow.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2021 Airbyte, Inc., all rights reserved. + */ + +package io.airbyte.oauth.flows; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; +import io.airbyte.config.persistence.ConfigRepository; +import io.airbyte.oauth.BaseOAuth2Flow; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.apache.http.client.utils.URIBuilder; + +public class SquareOAuthFlow extends BaseOAuth2Flow { + + private static final List SCOPES = Arrays.asList( + "CUSTOMERS_READ", + "EMPLOYEES_READ", + "ITEMS_READ", + "MERCHANT_PROFILE_READ", + "ORDERS_READ", + "PAYMENTS_READ", + "TIMECARDS_READ" + // OAuth Permissions: + // https://developer.squareup.com/docs/oauth-api/square-permissions + // https://developer.squareup.com/reference/square/enums/OAuthPermission + // "DISPUTES_READ", + // "GIFTCARDS_READ", + // "INVENTORY_READ", + // "INVOICES_READ", + // "TIMECARDS_SETTINGS_READ", + // "LOYALTY_READ", + // "ONLINE_STORE_SITE_READ", + // "ONLINE_STORE_SNIPPETS_READ", + // "SUBSCRIPTIONS_READ", + ); + private static final String AUTHORIZE_URL = "https://connect.squareup.com/oauth2/authorize"; + private static final String ACCESS_TOKEN_URL = "https://connect.squareup.com/oauth2/token"; + + public SquareOAuthFlow(ConfigRepository configRepository, final HttpClient httpClient) { + super(configRepository, httpClient); + } + + @VisibleForTesting + public SquareOAuthFlow(ConfigRepository configRepository, + HttpClient httpClient, + Supplier stateSupplier) { + super(configRepository, httpClient, stateSupplier); + } + + @Override + protected String formatConsentUrl(final UUID definitionId, + final String clientId, + final String redirectUrl, + final JsonNode inputOAuthConfiguration) + throws IOException { + try { + // Need to have decoded format, otherwise square fails saying that scope is incorrect + return URLDecoder.decode(new URIBuilder(AUTHORIZE_URL) + .addParameter("client_id", clientId) + .addParameter("scope", String.join("+", SCOPES)) + .addParameter("session", "False") + .addParameter("state", getState()) + .build().toString(), StandardCharsets.UTF_8); + } catch (URISyntaxException e) { + throw new IOException("Failed to format Consent URL for OAuth flow", e); + } + } + + @Override + protected String getAccessTokenUrl(final JsonNode inputOAuthConfiguration) { + return ACCESS_TOKEN_URL; + } + + @Override + protected Map getAccessTokenQueryParameters(String clientId, + String clientSecret, + String authCode, + String redirectUrl) { + String scopes = SCOPES.stream() + .map(name -> ('"' + name + '"')) + .collect(Collectors.joining(",")); + scopes = '[' + scopes + ']'; + + return ImmutableMap.builder() + // required + .put("client_id", clientId) + .put("client_secret", clientSecret) + .put("code", authCode) + .put("grant_type", "authorization_code") + .put("scopes", scopes) + .build(); + } + +} diff --git a/airbyte-oauth/src/test-integration/java/io.airbyte.oauth.flows/SquareOAuthFlowIntegrationTest.java b/airbyte-oauth/src/test-integration/java/io.airbyte.oauth.flows/SquareOAuthFlowIntegrationTest.java new file mode 100644 index 000000000000..03da842f2471 --- /dev/null +++ b/airbyte-oauth/src/test-integration/java/io.airbyte.oauth.flows/SquareOAuthFlowIntegrationTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2021 Airbyte, Inc., all rights reserved. + */ + +package io.airbyte.oauth.flows; + +import static org.junit.jupiter.api.Assertions.assertTrue; +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.SourceOAuthParameter; +import io.airbyte.config.persistence.ConfigNotFoundException; +import io.airbyte.config.persistence.ConfigRepository; +import io.airbyte.oauth.OAuthFlowImplementation; +import io.airbyte.validation.json.JsonValidationException; +import java.io.IOException; +import java.net.http.HttpClient; +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; + +public class SquareOAuthFlowIntegrationTest extends OAuthFlowIntegrationTest { + + protected static final Path CREDENTIALS_PATH = Path.of("secrets/square.json"); + protected static final String REDIRECT_URL = "http://localhost:8000/code"; + protected static final int SERVER_LISTENING_PORT = 8000; + + @Override + protected Path getCredentialsPath() { + return CREDENTIALS_PATH; + } + + @Override + protected OAuthFlowImplementation getFlowImplementation(final ConfigRepository configRepository, final HttpClient httpClient) { + return new SquareOAuthFlow(configRepository, httpClient); + } + + @Override + protected int getServerListeningPort() { + return SERVER_LISTENING_PORT; + } + + @Override + @BeforeEach + public void setup() throws IOException { + super.setup(); + } + + @Test + public void testFullSquareOAuthFlow() throws InterruptedException, ConfigNotFoundException, IOException, JsonValidationException { + int limit = 20; + final UUID workspaceId = UUID.randomUUID(); + final UUID definitionId = UUID.randomUUID(); + final String fullConfigAsString = new String(Files.readAllBytes(CREDENTIALS_PATH)); + final JsonNode credentialsJson = Jsons.deserialize(fullConfigAsString); + when(configRepository.listSourceOAuthParam()).thenReturn(List.of(new SourceOAuthParameter() + .withOauthParameterId(UUID.randomUUID()) + .withSourceDefinitionId(definitionId) + .withWorkspaceId(workspaceId) + .withConfiguration(Jsons.jsonNode( + Map.of("authorization", + ImmutableMap.builder() + .put("client_id", credentialsJson.get("client_id").asText()) + .put("client_secret", credentialsJson.get("client_secret").asText()) + .build()))))); + + final String url = flow.getSourceConsentUrl(workspaceId, definitionId, REDIRECT_URL, Jsons.emptyObject(), null); + LOGGER.info("Waiting for user consent at: {}", url); + + // TODO: To automate, start a selenium job to navigate to the Consent URL and click on allowing + // access... + while (!serverHandler.isSucceeded() && limit > 0) { + Thread.sleep(1000); + limit -= 1; + } + assertTrue(serverHandler.isSucceeded(), "Failed to get User consent on time"); + final Map params = flow.completeSourceOAuth(workspaceId, definitionId, + Map.of("code", serverHandler.getParamValue()), REDIRECT_URL); + LOGGER.info("Response from completing OAuth Flow is: {}", params.toString()); + assertTrue(params.containsKey("access_token")); + assertTrue(params.get("access_token").toString().length() > 0); + } + +} diff --git a/airbyte-oauth/src/test/java/io/airbyte/oauth/flows/SquareOAuthFlowTest.java b/airbyte-oauth/src/test/java/io/airbyte/oauth/flows/SquareOAuthFlowTest.java new file mode 100644 index 000000000000..5c54891086a5 --- /dev/null +++ b/airbyte-oauth/src/test/java/io/airbyte/oauth/flows/SquareOAuthFlowTest.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2021 Airbyte, Inc., all rights reserved. + */ + +package io.airbyte.oauth.flows; + +import io.airbyte.oauth.BaseOAuthFlow; + +public class SquareOAuthFlowTest extends BaseOAuthFlowTest { + + @Override + protected BaseOAuthFlow getOAuthFlow() { + return new SquareOAuthFlow(getConfigRepository(), getHttpClient(), this::getConstantState); + } + + @Override + protected String getExpectedConsentUrl() { + return "https://connect.squareup.com/oauth2/authorize?client_id=test_client_id" + + "&scope=CUSTOMERS_READ+EMPLOYEES_READ+ITEMS_READ+MERCHANT_PROFILE_READ+ORDERS_READ+PAYMENTS_READ+TIMECARDS_READ" + + "&session=False&state=state"; + } + +} diff --git a/docs/integrations/sources/square.md b/docs/integrations/sources/square.md index 559b64b60ea9..ee964435d3e5 100644 --- a/docs/integrations/sources/square.md +++ b/docs/integrations/sources/square.md @@ -75,10 +75,11 @@ Some Square API endpoints has different page size limitation ## Changelog -| Version | Date | Pull Request | Subject | -| :--- | :--- | :--- | :--- | -| 0.1.3 | 2021-12-06 | [8425](https://github.com/airbytehq/airbyte/pull/8425) | Update title, description fields in spec | -| 0.1.2 | 2021-11-08 | [7499](https://github.com/airbytehq/airbyte/pull/7499) | Remove base-python dependencies | -| 0.1.1 | 2021-07-09 | [4645](https://github.com/airbytehq/airbyte/pull/4645) | Update \_send\_request method due to Airbyte CDK changes | -| 0.1.0 | 2021-06-30 | [4439](https://github.com/airbytehq/airbyte/pull/4439) | Initial release supporting the Square API | +| Version | Date | Pull Request | Subject | +|:--------|:-----------| :--- |:---------------------------------------------------------| +| 0.1.4 | 2021-12-02 | [6842](https://github.com/airbytehq/airbyte/pull/6842) | Added oauth support | +| 0.1.3 | 2021-12-06 | [8425](https://github.com/airbytehq/airbyte/pull/8425) | Update title, description fields in spec | +| 0.1.2 | 2021-11-08 | [7499](https://github.com/airbytehq/airbyte/pull/7499) | Remove base-python dependencies | +| 0.1.1 | 2021-07-09 | [4645](https://github.com/airbytehq/airbyte/pull/4645) | Update \_send\_request method due to Airbyte CDK changes | +| 0.1.0 | 2021-06-30 | [4439](https://github.com/airbytehq/airbyte/pull/4439) | Initial release supporting the Square API |