From 0feb3124eeab57f3a29e3f2adf6f49946fb61f94 Mon Sep 17 00:00:00 2001 From: Baz Date: Wed, 16 Mar 2022 15:02:07 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20=20Source=20Zendesk-Support:=20I?= =?UTF-8?q?mplement=20`OAuth2.0`=20(#11162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/seed/source_definitions.yaml | 2 +- .../src/main/resources/seed/source_specs.yaml | 107 ++++++++++++----- .../source-zendesk-support/Dockerfile | 2 +- .../source-zendesk-support/README.md | 3 +- .../acceptance-test-config.yml | 1 + .../integration_tests/invalid_config.json | 3 +- .../source_zendesk_support/source.py | 20 +++- .../source_zendesk_support/spec.json | 111 ++++++++++++++---- .../source_zendesk_support/streams.py | 16 +-- .../oauth/OAuthImplementationFactory.java | 1 + .../oauth/flows/ZendeskSupportOAuthFlow.java | 99 ++++++++++++++++ docs/integrations/sources/zendesk-support.md | 1 + 12 files changed, 297 insertions(+), 69 deletions(-) create mode 100644 airbyte-oauth/src/main/java/io/airbyte/oauth/flows/ZendeskSupportOAuthFlow.java 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 5fb861cf50d2d..b62a7b9982bc5 100644 --- a/airbyte-config/init/src/main/resources/seed/source_definitions.yaml +++ b/airbyte-config/init/src/main/resources/seed/source_definitions.yaml @@ -836,7 +836,7 @@ - name: Zendesk Support sourceDefinitionId: 79c1aa37-dae3-42ae-b333-d1c105477715 dockerRepository: airbyte/source-zendesk-support - dockerImageTag: 0.2.0 + dockerImageTag: 0.2.1 documentationUrl: https://docs.airbyte.io/integrations/sources/zendesk-support icon: zendesk.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 26bcc6645cb79..2e6a198f87412 100644 --- a/airbyte-config/init/src/main/resources/seed/source_specs.yaml +++ b/airbyte-config/init/src/main/resources/seed/source_specs.yaml @@ -8882,7 +8882,7 @@ path_in_connector_config: - "credentials" - "client_secret" -- dockerImage: "airbyte/source-zendesk-support:0.2.0" +- dockerImage: "airbyte/source-zendesk-support:0.2.1" spec: documentationUrl: "https://docs.airbyte.io/integrations/sources/zendesk-support" connectionSpecification: @@ -8892,8 +8892,7 @@ required: - "start_date" - "subdomain" - - "auth_method" - additionalProperties: false + additionalProperties: true properties: start_date: type: "string" @@ -8906,25 +8905,49 @@ pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" subdomain: type: "string" - description: "The subdomain for your Zendesk Support" - auth_method: + title: "Subdomain" + description: "Identifier of your Zendesk Subdomain, like: https://{MY_SUBDOMAIN}.zendesk.com/,\ + \ where MY_SUBDOMAIN is the value of your subdomain" + credentials: title: "Authorization Method" type: "object" - default: "api_token" - description: "Zendesk service provides 2 auth method: API token and OAuth2.\ - \ Now only the first one is available. Another one will be added in the\ - \ future." + description: "Zendesk service provides two authentication methods. Choose\ + \ between: `OAuth2.0` or `API token`." oneOf: + - title: "OAuth2.0" + type: "object" + required: + - "access_token" + additionalProperties: true + properties: + credentials: + type: "string" + const: "oauth2.0" + enum: + - "oauth2.0" + default: "oauth2.0" + order: 0 + access_token: + type: "string" + title: "Access Token" + description: "The value of the API token generated. See the docs\ + \ for more information." + airbyte_secret: true - title: "API Token" type: "object" required: - "email" - "api_token" - additionalProperties: false + additionalProperties: true properties: - auth_method: + credentials: type: "string" const: "api_token" + enum: + - "api_token" + default: "api_token" + order: 0 email: title: "Email" type: "string" @@ -8936,25 +8959,55 @@ https://docs.airbyte.io/integrations/sources/zendesk-support\">docs\ \ for more information." airbyte_secret: true - - title: "OAuth2.0" - type: "object" - required: - - "access_token" - additionalProperties: false - properties: - auth_method: - type: "string" - const: "access_token" - access_token: - title: "Access Token" - type: "string" - description: "The value of the Access token generated. See the docs for more information." - airbyte_secret: true supportsNormalization: false supportsDBT: false supported_destination_sync_modes: [] + advanced_auth: + auth_flow_type: "oauth2.0" + predicate_key: + - "credentials" + - "credentials" + predicate_value: "oauth2.0" + oauth_config_specification: + oauth_user_input_from_connector_config_specification: + type: "object" + additionalProperties: false + properties: + subdomain: + type: "string" + path_in_connector_config: + - "subdomain" + complete_oauth_output_specification: + type: "object" + additionalProperties: false + properties: + access_token: + type: "string" + path_in_connector_config: + - "credentials" + - "access_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-zendesk-talk:0.1.3" spec: documentationUrl: "https://docs.airbyte.io/integrations/sources/zendesk-talk" diff --git a/airbyte-integrations/connectors/source-zendesk-support/Dockerfile b/airbyte-integrations/connectors/source-zendesk-support/Dockerfile index 41daded4afeab..b542ce4cb4e99 100644 --- a/airbyte-integrations/connectors/source-zendesk-support/Dockerfile +++ b/airbyte-integrations/connectors/source-zendesk-support/Dockerfile @@ -25,5 +25,5 @@ COPY source_zendesk_support ./source_zendesk_support ENV AIRBYTE_ENTRYPOINT "python /airbyte/integration_code/main.py" ENTRYPOINT ["python", "/airbyte/integration_code/main.py"] -LABEL io.airbyte.version=0.2.0 +LABEL io.airbyte.version=0.2.1 LABEL io.airbyte.name=airbyte/source-zendesk-support diff --git a/airbyte-integrations/connectors/source-zendesk-support/README.md b/airbyte-integrations/connectors/source-zendesk-support/README.md index d4cfd188c9161..96c6104b28617 100644 --- a/airbyte-integrations/connectors/source-zendesk-support/README.md +++ b/airbyte-integrations/connectors/source-zendesk-support/README.md @@ -101,7 +101,8 @@ Customize `acceptance-test-config.yml` file to configure tests. See [Source Acce If your connector requires to create or destroy resources for use during acceptance tests create fixtures for it and place them inside integration_tests/acceptance.py. To run your integration tests with acceptance tests, from the connector root, run ``` -python -m pytest integration_tests -p integration_tests.acceptance +docker build . --no-cache -t airbyte/source-zendesk-support:dev \ +&& python -m pytest -p source_acceptance_test.plugin ``` To run your integration tests with docker diff --git a/airbyte-integrations/connectors/source-zendesk-support/acceptance-test-config.yml b/airbyte-integrations/connectors/source-zendesk-support/acceptance-test-config.yml index 565b1a47d614d..6bdcc2ef1a109 100644 --- a/airbyte-integrations/connectors/source-zendesk-support/acceptance-test-config.yml +++ b/airbyte-integrations/connectors/source-zendesk-support/acceptance-test-config.yml @@ -13,6 +13,7 @@ tests: 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" diff --git a/airbyte-integrations/connectors/source-zendesk-support/integration_tests/invalid_config.json b/airbyte-integrations/connectors/source-zendesk-support/integration_tests/invalid_config.json index c1562ac3660e6..0eb9ad451f4f1 100644 --- a/airbyte-integrations/connectors/source-zendesk-support/integration_tests/invalid_config.json +++ b/airbyte-integrations/connectors/source-zendesk-support/integration_tests/invalid_config.json @@ -1,5 +1,6 @@ { - "auth_method": { + "credentials": { + "credentials": "api_token", "api_token": "", "email": "broken.email@invalid.config" }, diff --git a/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/source.py b/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/source.py index f45d9aa5eab8b..dcb475a0d0c53 100644 --- a/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/source.py +++ b/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/source.py @@ -51,11 +51,21 @@ class SourceZendeskSupport(AbstractSource): @classmethod def get_authenticator(cls, config: Mapping[str, Any]) -> BasicApiTokenAuthenticator: - if config["auth_method"]["auth_method"] == "access_token": - return TokenAuthenticator(token=config["auth_method"]["access_token"]) - elif config["auth_method"]["auth_method"] == "api_token": - return BasicApiTokenAuthenticator(config["auth_method"]["email"], config["auth_method"]["api_token"]) - raise SourceZendeskException(f"Not implemented authorization method: {config['auth_method']}") + + # old authentication flow support + auth_old = config.get("auth_method") + if auth_old: + if auth_old.get("auth_method") == "api_token": + return BasicApiTokenAuthenticator(config["auth_method"]["email"], config["auth_method"]["api_token"]) + # new authentication flow + auth = config.get("credentials") + if auth: + if auth.get("credentials") == "oauth2.0": + return TokenAuthenticator(token=config["credentials"]["access_token"]) + elif auth.get("credentials") == "api_token": + return BasicApiTokenAuthenticator(config["credentials"]["email"], config["credentials"]["api_token"]) + else: + raise SourceZendeskException(f"Not implemented authorization method: {config['credentials']}") def check_connection(self, logger, config) -> Tuple[bool, any]: """Connection check to validate that the user-provided config can be used to connect to the underlying API diff --git a/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/spec.json b/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/spec.json index c4ea2fd36e88f..5e01fc832fa46 100644 --- a/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/spec.json +++ b/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/spec.json @@ -4,8 +4,8 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Source Zendesk Support Spec", "type": "object", - "required": ["start_date", "subdomain", "auth_method"], - "additionalProperties": false, + "required": ["start_date", "subdomain"], + "additionalProperties": true, "properties": { "start_date": { "type": "string", @@ -16,51 +16,57 @@ }, "subdomain": { "type": "string", - "description": "The subdomain for your Zendesk Support" + "title": "Subdomain", + "description": "Identifier of your Zendesk Subdomain, like: https://{MY_SUBDOMAIN}.zendesk.com/, where MY_SUBDOMAIN is the value of your subdomain" }, - "auth_method": { + "credentials": { "title": "Authorization Method", "type": "object", - "default": "api_token", - "description": "Zendesk service provides 2 auth method: API token and OAuth2. Now only the first one is available. Another one will be added in the future.", + "description": "Zendesk service provides two authentication methods. Choose between: `OAuth2.0` or `API token`.", "oneOf": [ { - "title": "API Token", + "title": "OAuth2.0", "type": "object", - "required": ["email", "api_token"], - "additionalProperties": false, + "required": ["access_token"], + "additionalProperties": true, "properties": { - "auth_method": { + "credentials": { "type": "string", - "const": "api_token" + "const": "oauth2.0", + "enum": ["oauth2.0"], + "default": "oauth2.0", + "order": 0 }, - "email": { - "title": "Email", - "type": "string", - "description": "The user email for your Zendesk account." - }, - "api_token": { - "title": "API Token", + "access_token": { "type": "string", + "title": "Access Token", "description": "The value of the API token generated. See the docs for more information.", "airbyte_secret": true } } }, { - "title": "OAuth2.0", + "title": "API Token", "type": "object", - "required": ["access_token"], - "additionalProperties": false, + "required": ["email", "api_token"], + "additionalProperties": true, "properties": { - "auth_method": { + "credentials": { "type": "string", - "const": "access_token" + "const": "api_token", + "enum": ["api_token"], + "default": "api_token", + "order": 0 }, - "access_token": { - "title": "Access Token", + "email": { + "title": "Email", "type": "string", - "description": "The value of the Access token generated. See the docs for more information.", + "description": "The user email for your Zendesk account." + }, + "api_token": { + "title": "API Token", + "type": "string", + "description": "The value of the API token generated. See the docs for more information.", "airbyte_secret": true } } @@ -68,5 +74,58 @@ ] } } + }, + "advanced_auth": { + "auth_flow_type": "oauth2.0", + "predicate_key": ["credentials", "credentials"], + "predicate_value": "oauth2.0", + "oauth_config_specification": { + "complete_oauth_output_specification": { + "type": "object", + "additionalProperties": false, + "properties": { + "access_token": { + "type": "string", + "path_in_connector_config": ["credentials", "access_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"] + } + } + }, + "oauth_user_input_from_connector_config_specification": { + "type": "object", + "additionalProperties": false, + "properties": { + "subdomain": { + "type": "string", + "path_in_connector_config": ["subdomain"] + } + } + } + } } } diff --git a/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/streams.py b/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/streams.py index c98d5bd1cfbc0..47509ac5b4447 100644 --- a/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/streams.py +++ b/airbyte-integrations/connectors/source-zendesk-support/source_zendesk_support/streams.py @@ -290,6 +290,11 @@ def next_page_token(self, *args, **kwargs): class SourceZendeskSupportFullRefreshStream(BaseSourceZendeskSupportStream): + """ + # endpoints don't provide the updated_at/created_at fields + # thus we can't implement an incremental logic for them + """ + primary_key = "id" response_list_name: str = None @@ -327,6 +332,10 @@ def request_params(self, next_page_token: Mapping[str, Any] = None, **kwargs) -> class SourceZendeskSupportCursorPaginationStream(SourceZendeskSupportFullRefreshStream): + """ + # endpoints provide a cursor pagination and sorting mechanism + """ + next_page_field = "next_page" prev_start_time = None @@ -463,9 +472,6 @@ class Macros(SourceZendeskSupportStream): """Macros stream: https://developer.zendesk.com/api-reference/ticketing/business-rules/macros/""" -# endpoints provide a cursor pagination and sorting mechanism - - class TicketAudits(SourceZendeskSupportCursorPaginationStream): """TicketAudits stream: https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_audits/""" @@ -490,10 +496,6 @@ def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, return response.json().get("before_cursor") -# endpoints don't provide the updated_at/created_at fields -# thus we can't implement an incremental logic for them - - class Tags(SourceZendeskSupportFullRefreshStream): """Tags stream: https://developer.zendesk.com/api-reference/ticketing/ticket-management/tags/""" 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 aaf390ef3d582..2f01f2bc9d6a4 100644 --- a/airbyte-oauth/src/main/java/io/airbyte/oauth/OAuthImplementationFactory.java +++ b/airbyte-oauth/src/main/java/io/airbyte/oauth/OAuthImplementationFactory.java @@ -55,6 +55,7 @@ public OAuthImplementationFactory(final ConfigRepository configRepository, final .put("airbyte/source-youtube-analytics", new YouTubeAnalyticsOAuthFlow(configRepository, httpClient)) .put("airbyte/source-drift", new DriftOAuthFlow(configRepository, httpClient)) .put("airbyte/source-zendesk-chat", new ZendeskChatOAuthFlow(configRepository, httpClient)) + .put("airbyte/source-zendesk-support", new ZendeskSupportOAuthFlow(configRepository, httpClient)) .put("airbyte/source-monday", new MondayOAuthFlow(configRepository, httpClient)) .put("airbyte/source-zendesk-sunshine", new ZendeskSunshineOAuthFlow(configRepository, httpClient)) .put("airbyte/source-mailchimp", new MailchimpOAuthFlow(configRepository, httpClient)) diff --git a/airbyte-oauth/src/main/java/io/airbyte/oauth/flows/ZendeskSupportOAuthFlow.java b/airbyte-oauth/src/main/java/io/airbyte/oauth/flows/ZendeskSupportOAuthFlow.java new file mode 100644 index 0000000000000..71481301bb3ad --- /dev/null +++ b/airbyte-oauth/src/main/java/io/airbyte/oauth/flows/ZendeskSupportOAuthFlow.java @@ -0,0 +1,99 @@ +/* + * 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.http.HttpClient; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; +import org.apache.http.client.utils.URIBuilder; + +/** + * Following docs from + * https://support.zendesk.com/hc/en-us/articles/4408845965210-Using-OAuth-authentication-with-your-application + */ +public class ZendeskSupportOAuthFlow extends BaseOAuth2Flow { + + public ZendeskSupportOAuthFlow(final ConfigRepository configRepository, final HttpClient httpClient) { + super(configRepository, httpClient); + } + + @VisibleForTesting + public ZendeskSupportOAuthFlow(final ConfigRepository configRepository, final HttpClient httpClient, final Supplier stateSupplier) { + super(configRepository, httpClient, stateSupplier); + } + + @Override + protected String formatConsentUrl(final UUID definitionId, + final String clientId, + final String redirectUrl, + final JsonNode inputOAuthConfiguration) + throws IOException { + + // getting subdomain value from user's config + final String subdomain = getConfigValueUnsafe(inputOAuthConfiguration, "subdomain"); + + final URIBuilder builder = new URIBuilder() + .setScheme("https") + .setHost(subdomain + ".zendesk.com") + .setPath("oauth/authorizations/new") + // required + .addParameter("client_id", clientId) + .addParameter("redirect_uri", redirectUrl) + .addParameter("response_type", "code") + .addParameter("scope", "read") + .addParameter("state", getState()); + + try { + return builder.build().toString(); + } catch (URISyntaxException e) { + throw new IOException("Failed to format Consent URL for OAuth flow", e); + } + } + + @Override + protected Map getAccessTokenQueryParameters(String clientId, + String clientSecret, + String authCode, + String redirectUrl) { + return ImmutableMap.builder() + // required + .put("grant_type", "authorization_code") + .put("code", authCode) + .put("client_id", clientId) + .put("client_secret", clientSecret) + .put("redirect_uri", redirectUrl) + .put("scope", "read") + .build(); + } + + @Override + protected String getAccessTokenUrl(final JsonNode inputOAuthConfiguration) { + // getting subdomain value from user's config + final String subdomain = getConfigValueUnsafe(inputOAuthConfiguration, "subdomain"); + return "https://" + subdomain + ".zendesk.com/oauth/tokens"; + } + + @Override + protected Map extractOAuthOutput(final JsonNode data, final String accessTokenUrl) throws IOException { + final Map result = new HashMap<>(); + // getting out access_token + if (data.has("access_token")) { + result.put("access_token", data.get("access_token").asText()); + } else { + throw new IOException(String.format("Missing 'access_token' in query params from %s", accessTokenUrl)); + } + return result; + } + +} diff --git a/docs/integrations/sources/zendesk-support.md b/docs/integrations/sources/zendesk-support.md index 83cdf21b65f5a..22fbe7a7a4cd9 100644 --- a/docs/integrations/sources/zendesk-support.md +++ b/docs/integrations/sources/zendesk-support.md @@ -97,6 +97,7 @@ We recommend creating a restricted, read-only key specifically for Airbyte acces | Version | Date | Pull Request | Subject | |:---------|:-----------| :----- |:-------------------------------------------------------| +| `0.2.1` | 2022-03-15 | [11162](https://github.com/airbytehq/airbyte/pull/11162) | Added support of OAuth2.0 authentication method | `0.2.0` | 2022-03-01 | [9456](https://github.com/airbytehq/airbyte/pull/9456) | Update source to use future requests | | `0.1.12` | 2022-01-25 | [9785](https://github.com/airbytehq/airbyte/pull/9785) | Add log message | | `0.1.11` | 2021-12-21 | [8987](https://github.com/airbytehq/airbyte/pull/8987) | Update connector fields title/description |