Skip to content

Commit c3c3e31

Browse files
midavadimvmaltsev
and
vmaltsev
authored
🎉 intercom added oauth support (#7060)
* Added oauth support * fixed description of access_token field in Oauth authentication * test selenium * add java implementation * remove commented dependencies| remove star import * remove System.out.println * added support for old format of spec.json files. * added support for old format of spec.json files. * added support for old format of spec.json files * added credential with new spec format * bumped connector version * Reverted format of spec.json file * Override extractRefreshToken method * java oauth test fixes, minor updates, cloud spec version update * Fixed docs strings * Added HttpClient argument * revert changes in seed folder to follow new dev flow * Updated image version in seed * Fixed import of IntercomOAuthFlow * updated IntercomOAuthFlow to match base class changes * Change image version to 0.1.8 Co-authored-by: vmaltsev <vitalii.maltsev@globallogic.com>
1 parent aa11238 commit c3c3e31

File tree

15 files changed

+270
-18
lines changed

15 files changed

+270
-18
lines changed

.github/workflows/publish-command.yml

+1
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ jobs:
121121
HUBSPOT_INTEGRATION_TESTS_CREDS_OAUTH: ${{ secrets.HUBSPOT_INTEGRATION_TESTS_CREDS_OAUTH }}
122122
INSTAGRAM_INTEGRATION_TESTS_CREDS: ${{ secrets.INSTAGRAM_INTEGRATION_TESTS_CREDS }}
123123
INTERCOM_INTEGRATION_TEST_CREDS: ${{ secrets.INTERCOM_INTEGRATION_TEST_CREDS }}
124+
INTERCOM_INTEGRATION_OAUTH_TEST_CREDS: ${{ secrets.INTERCOM_INTEGRATION_OAUTH_TEST_CREDS }}
124125
ITERABLE_INTEGRATION_TEST_CREDS: ${{ secrets.ITERABLE_INTEGRATION_TEST_CREDS }}
125126
JIRA_INTEGRATION_TEST_CREDS: ${{ secrets.JIRA_INTEGRATION_TEST_CREDS }}
126127
KLAVIYO_TEST_CREDS: ${{ secrets.KLAVIYO_TEST_CREDS }}

.github/workflows/test-command.yml

+1
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ jobs:
116116
HUBSPOT_INTEGRATION_TESTS_CREDS_OAUTH: ${{ secrets.HUBSPOT_INTEGRATION_TESTS_CREDS_OAUTH }}
117117
INSTAGRAM_INTEGRATION_TESTS_CREDS: ${{ secrets.INSTAGRAM_INTEGRATION_TESTS_CREDS }}
118118
INTERCOM_INTEGRATION_TEST_CREDS: ${{ secrets.INTERCOM_INTEGRATION_TEST_CREDS }}
119+
INTERCOM_INTEGRATION_OAUTH_TEST_CREDS: ${{ secrets.INTERCOM_INTEGRATION_OAUTH_TEST_CREDS }}
119120
ITERABLE_INTEGRATION_TEST_CREDS: ${{ secrets.ITERABLE_INTEGRATION_TEST_CREDS }}
120121
JIRA_INTEGRATION_TEST_CREDS: ${{ secrets.JIRA_INTEGRATION_TEST_CREDS }}
121122
KLAVIYO_TEST_CREDS: ${{ secrets.KLAVIYO_TEST_CREDS }}

airbyte-config/init/src/main/resources/config/STANDARD_SOURCE_DEFINITION/d8313939-3782-41b0-be29-b3ca20d8dd3a.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"sourceDefinitionId": "d8313939-3782-41b0-be29-b3ca20d8dd3a",
33
"name": "Intercom",
44
"dockerRepository": "airbyte/source-intercom",
5-
"dockerImageTag": "0.1.6",
5+
"dockerImageTag": "0.1.7",
66
"documentationUrl": "https://docs.airbyte.io/integrations/sources/intercom",
77
"icon": "intercom.svg"
88
}

airbyte-config/init/src/main/resources/seed/source_definitions.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@
261261
- name: Intercom
262262
sourceDefinitionId: d8313939-3782-41b0-be29-b3ca20d8dd3a
263263
dockerRepository: airbyte/source-intercom
264-
dockerImageTag: 0.1.6
264+
dockerImageTag: 0.1.7
265265
documentationUrl: https://docs.airbyte.io/integrations/sources/intercom
266266
icon: intercom.svg
267267
sourceType: api

airbyte-config/init/src/main/resources/seed/source_specs.yaml

+6-6
Original file line numberDiff line numberDiff line change
@@ -1132,7 +1132,7 @@
11321132
supportsNormalization: false
11331133
supportsDBT: false
11341134
supported_destination_sync_modes: []
1135-
- dockerImage: "airbyte/source-facebook-marketing:0.2.22"
1135+
- dockerImage: "airbyte/source-facebook-marketing:0.2.24"
11361136
spec:
11371137
documentationUrl: "https://docs.airbyte.io/integrations/sources/facebook-marketing"
11381138
changelogUrl: "https://docs.airbyte.io/integrations/sources/facebook-marketing"
@@ -2535,7 +2535,7 @@
25352535
oauthFlowInitParameters: []
25362536
oauthFlowOutputParameters:
25372537
- - "access_token"
2538-
- dockerImage: "airbyte/source-intercom:0.1.6"
2538+
- dockerImage: "airbyte/source-intercom:0.1.7"
25392539
spec:
25402540
documentationUrl: "https://docs.airbyte.io/integrations/sources/intercom"
25412541
connectionSpecification:
@@ -4968,7 +4968,7 @@
49684968
supportsNormalization: false
49694969
supportsDBT: false
49704970
supported_destination_sync_modes: []
4971-
- dockerImage: "airbyte/source-salesforce:0.1.3"
4971+
- dockerImage: "airbyte/source-salesforce:0.1.4"
49724972
spec:
49734973
documentationUrl: "https://docs.airbyte.io/integrations/sources/salesforce"
49744974
connectionSpecification:
@@ -5000,9 +5000,9 @@
50005000
airbyte_secret: true
50015001
start_date:
50025002
description: "UTC date and time in the format 2017-01-25T00:00:00Z. Any\
5003-
\ data before this date will not be replicated. Priority for filtering\
5004-
\ by `updated` fields, and only then by `created` fields if they are available\
5005-
\ for stream."
5003+
\ data before this date will not be replicated. This field uses the \"\
5004+
updated\" field if available, otherwise the \"created\" fields if they\
5005+
\ are available for a stream."
50065006
type: "string"
50075007
pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
50085008
examples:

airbyte-integrations/connectors/source-intercom/Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@ COPY source_intercom ./source_intercom
3535
ENV AIRBYTE_ENTRYPOINT "python /airbyte/integration_code/main.py"
3636
ENTRYPOINT ["python", "/airbyte/integration_code/main.py"]
3737

38-
LABEL io.airbyte.version=0.1.6
38+
LABEL io.airbyte.version=0.1.8
3939
LABEL io.airbyte.name=airbyte/source-intercom

airbyte-integrations/connectors/source-intercom/source_intercom/source.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,8 @@ def check_connection(self, logger, config) -> Tuple[bool, any]:
309309
return False, e
310310

311311
def streams(self, config: Mapping[str, Any]) -> List[Stream]:
312-
AirbyteLogger().log("INFO", f"Using start_date: {config['start_date']}")
313-
314312
config["start_date"] = datetime.strptime(config["start_date"], "%Y-%m-%dT%H:%M:%SZ").timestamp()
313+
AirbyteLogger().log("INFO", f"Using start_date: {config['start_date']}")
315314

316315
auth = TokenAuthenticator(token=config["access_token"])
317316
return [

airbyte-integrations/connectors/source-intercom/source_intercom/spec.json

+16-7
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,29 @@
44
"$schema": "http://json-schema.org/draft-07/schema#",
55
"title": "Source Intercom Spec",
66
"type": "object",
7-
"required": ["access_token", "start_date"],
8-
"additionalProperties": false,
7+
"required": ["start_date", "access_token"],
8+
"additionalProperties": true,
99
"properties": {
10-
"access_token": {
11-
"type": "string",
12-
"description": "Intercom Access Token. See the <a href=\"https://docs.airbyte.io/integrations/sources/intercom\">docs</a> for more information on how to obtain this key.",
13-
"airbyte_secret": true
14-
},
1510
"start_date": {
1611
"type": "string",
1712
"description": "The date from which you'd like to replicate data for Intercom API, in the format YYYY-MM-DDT00:00:00Z. All data generated after this date will be replicated.",
1813
"examples": ["2020-11-16T00:00:00Z"],
1914
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
15+
},
16+
"access_token": {
17+
"title": "Access Token",
18+
"type": "string",
19+
"description": "Access token generated either from an oauth flow or from the Intercom Developer dashboard. See the <a href=\"https://docs.airbyte.io/integrations/sources/intercom\">docs</a> for more information on how to obtain this key manually.",
20+
"airbyte_secret": true
2021
}
2122
}
23+
},
24+
"authSpecification": {
25+
"auth_type": "oauth2.0",
26+
"oauth2Specification": {
27+
"rootObject": [],
28+
"oauthFlowInitParameters": [],
29+
"oauthFlowOutputParameters": [["access_token"]]
30+
}
2231
}
2332
}

airbyte-oauth/build.gradle

+2
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ dependencies {
88
implementation project(':airbyte-config:persistence')
99
implementation project(':airbyte-json-validation')
1010
testImplementation project(':airbyte-oauth')
11+
12+
implementation group: 'org.seleniumhq.selenium', name: 'selenium-java', version: '3.141.59'
1113
}

airbyte-oauth/src/main/java/io/airbyte/oauth/OAuthImplementationFactory.java

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import io.airbyte.oauth.flows.AsanaOAuthFlow;
1212
import io.airbyte.oauth.flows.GithubOAuthFlow;
1313
import io.airbyte.oauth.flows.HubspotOAuthFlow;
14+
import io.airbyte.oauth.flows.IntercomOAuthFlow;
1415
import io.airbyte.oauth.flows.SalesforceOAuthFlow;
1516
import io.airbyte.oauth.flows.SlackOAuthFlow;
1617
import io.airbyte.oauth.flows.SurveymonkeyOAuthFlow;
@@ -41,6 +42,7 @@ public OAuthImplementationFactory(final ConfigRepository configRepository, final
4142
.put("airbyte/source-google-search-console", new GoogleSearchConsoleOAuthFlow(configRepository, httpClient))
4243
.put("airbyte/source-google-sheets", new GoogleSheetsOAuthFlow(configRepository, httpClient))
4344
.put("airbyte/source-hubspot", new HubspotOAuthFlow(configRepository, httpClient))
45+
.put("airbyte/source-intercom", new IntercomOAuthFlow(configRepository, httpClient))
4446
.put("airbyte/source-instagram", new InstagramOAuthFlow(configRepository, httpClient))
4547
.put("airbyte/source-salesforce", new SalesforceOAuthFlow(configRepository, httpClient))
4648
.put("airbyte/source-slack", new SlackOAuthFlow(configRepository, httpClient))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
*/
4+
5+
package io.airbyte.oauth.flows;
6+
7+
import com.fasterxml.jackson.databind.JsonNode;
8+
import com.google.common.annotations.VisibleForTesting;
9+
import com.google.common.base.Preconditions;
10+
import io.airbyte.config.persistence.ConfigRepository;
11+
import io.airbyte.oauth.BaseOAuth2Flow;
12+
import java.io.IOException;
13+
import java.net.URISyntaxException;
14+
import java.net.http.HttpClient;
15+
import java.util.List;
16+
import java.util.Map;
17+
import java.util.UUID;
18+
import java.util.function.Supplier;
19+
import org.apache.http.client.utils.URIBuilder;
20+
21+
public class IntercomOAuthFlow extends BaseOAuth2Flow {
22+
23+
private static final String AUTHORIZE_URL = "https://app.intercom.com/a/oauth/connect";
24+
private static final String ACCESS_TOKEN_URL = "https://api.intercom.io/auth/eagle/token";
25+
26+
public IntercomOAuthFlow(ConfigRepository configRepository, HttpClient httpClient) {
27+
super(configRepository, httpClient);
28+
}
29+
30+
@VisibleForTesting
31+
public IntercomOAuthFlow(ConfigRepository configRepository, final HttpClient httpClient, Supplier<String> stateSupplier) {
32+
super(configRepository, httpClient, stateSupplier);
33+
}
34+
35+
@Override
36+
protected String formatConsentUrl(UUID definitionId, String clientId, String redirectUrl) throws IOException {
37+
try {
38+
return new URIBuilder(AUTHORIZE_URL)
39+
.addParameter("client_id", clientId)
40+
.addParameter("redirect_uri", redirectUrl)
41+
.addParameter("response_type", "code")
42+
.addParameter("state", getState())
43+
.build().toString();
44+
} catch (URISyntaxException e) {
45+
throw new IOException("Failed to format Consent URL for OAuth flow", e);
46+
}
47+
}
48+
49+
@Override
50+
protected String getAccessTokenUrl() {
51+
return ACCESS_TOKEN_URL;
52+
}
53+
54+
@Override
55+
protected Map<String, Object> extractOAuthOutput(final JsonNode data, final String accessTokenUrl) {
56+
// Intercom does not have refresh token but calls it "long lived access token" instead:
57+
// see https://developers.intercom.com/building-apps/docs/setting-up-oauth
58+
Preconditions.checkArgument(data.has("access_token"), "Missing 'access_token' in query params from %s", ACCESS_TOKEN_URL);
59+
return Map.of("access_token", data.get("access_token").asText());
60+
}
61+
62+
@Override
63+
protected List<String> getDefaultOAuthOutputPath() {
64+
return List.of();
65+
}
66+
67+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
*/
4+
5+
package io.airbyte.oauth.flows;
6+
7+
import static org.junit.jupiter.api.Assertions.assertTrue;
8+
import static org.mockito.Mockito.when;
9+
10+
import com.fasterxml.jackson.databind.JsonNode;
11+
import com.google.common.collect.ImmutableMap;
12+
import io.airbyte.commons.json.Jsons;
13+
import io.airbyte.config.SourceOAuthParameter;
14+
import io.airbyte.config.persistence.ConfigNotFoundException;
15+
import io.airbyte.config.persistence.ConfigRepository;
16+
import io.airbyte.oauth.OAuthFlowImplementation;
17+
import io.airbyte.validation.json.JsonValidationException;
18+
import java.io.IOException;
19+
import java.net.http.HttpClient;
20+
import java.nio.file.Files;
21+
import java.nio.file.Path;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.UUID;
25+
import org.junit.jupiter.api.BeforeEach;
26+
import org.junit.jupiter.api.Test;
27+
28+
public class IntercomOAuthFlowIntegrationTest extends OAuthFlowIntegrationTest {
29+
30+
protected static final Path CREDENTIALS_PATH = Path.of("secrets/intercom.json");
31+
protected static final String REDIRECT_URL = "http://localhost:8000/code";
32+
protected static final int SERVER_LISTENING_PORT = 8000;
33+
34+
@Override
35+
protected Path getCredentialsPath() {
36+
return CREDENTIALS_PATH;
37+
}
38+
39+
@Override
40+
protected OAuthFlowImplementation getFlowImplementation(ConfigRepository configRepository, HttpClient httpClient) {
41+
return new IntercomOAuthFlow(configRepository, httpClient);
42+
}
43+
44+
@Override
45+
protected int getServerListeningPort() {
46+
return SERVER_LISTENING_PORT;
47+
}
48+
49+
@BeforeEach
50+
public void setup() throws IOException {
51+
super.setup();
52+
}
53+
54+
@Test
55+
public void testFullIntercomOAuthFlow() throws InterruptedException, ConfigNotFoundException, IOException, JsonValidationException {
56+
int limit = 20;
57+
final UUID workspaceId = UUID.randomUUID();
58+
final UUID definitionId = UUID.randomUUID();
59+
final String fullConfigAsString = new String(Files.readAllBytes(CREDENTIALS_PATH));
60+
final JsonNode credentialsJson = Jsons.deserialize(fullConfigAsString);
61+
when(configRepository.listSourceOAuthParam()).thenReturn(List.of(new SourceOAuthParameter()
62+
.withOauthParameterId(UUID.randomUUID())
63+
.withSourceDefinitionId(definitionId)
64+
.withWorkspaceId(workspaceId)
65+
.withConfiguration(Jsons.jsonNode(
66+
Map.of("authorization",
67+
ImmutableMap.builder()
68+
.put("client_id", credentialsJson.get("client_id").asText())
69+
.put("client_secret", credentialsJson.get("client_secret").asText())
70+
.build())))));
71+
72+
final String url = flow.getSourceConsentUrl(workspaceId, definitionId, REDIRECT_URL);
73+
LOGGER.info("Waiting for user consent at: {}", url);
74+
75+
// TODO: To automate, start a selenium job to navigate to the Consent URL and click on allowing
76+
// access...
77+
while (!serverHandler.isSucceeded() && limit > 0) {
78+
Thread.sleep(1000);
79+
limit -= 1;
80+
}
81+
assertTrue(serverHandler.isSucceeded(), "Failed to get User consent on time");
82+
final Map<String, Object> params = flow.completeSourceOAuth(workspaceId, definitionId,
83+
Map.of("code", serverHandler.getParamValue()), REDIRECT_URL);
84+
LOGGER.info("Response from completing OAuth Flow is: {}", params.toString());
85+
assertTrue(params.containsKey("access_token"));
86+
assertTrue(params.get("access_token").toString().length() > 0);
87+
}
88+
89+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
*/
4+
5+
package io.airbyte.oauth.flows;
6+
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
import static org.mockito.ArgumentMatchers.any;
9+
import static org.mockito.Mockito.mock;
10+
import static org.mockito.Mockito.when;
11+
12+
import com.google.common.collect.ImmutableMap;
13+
import io.airbyte.commons.json.Jsons;
14+
import io.airbyte.config.SourceOAuthParameter;
15+
import io.airbyte.config.persistence.ConfigNotFoundException;
16+
import io.airbyte.config.persistence.ConfigRepository;
17+
import io.airbyte.validation.json.JsonValidationException;
18+
import java.io.IOException;
19+
import java.net.http.HttpClient;
20+
import java.net.http.HttpResponse;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.UUID;
24+
import org.junit.jupiter.api.BeforeEach;
25+
import org.junit.jupiter.api.Test;
26+
27+
public class IntercomOAuthFlowTest {
28+
29+
private UUID workspaceId;
30+
private UUID definitionId;
31+
private IntercomOAuthFlow intercomoAuthFlow;
32+
private HttpClient httpClient;
33+
34+
private static final String REDIRECT_URL = "https://airbyte.io";
35+
36+
private static String getConstantState() {
37+
return "state";
38+
}
39+
40+
@BeforeEach
41+
public void setup() throws IOException, JsonValidationException {
42+
workspaceId = UUID.randomUUID();
43+
definitionId = UUID.randomUUID();
44+
ConfigRepository configRepository = mock(ConfigRepository.class);
45+
httpClient = mock(HttpClient.class);
46+
when(configRepository.listSourceOAuthParam()).thenReturn(List.of(new SourceOAuthParameter()
47+
.withOauthParameterId(UUID.randomUUID())
48+
.withSourceDefinitionId(definitionId)
49+
.withWorkspaceId(workspaceId)
50+
.withConfiguration(Jsons.jsonNode(
51+
ImmutableMap.builder()
52+
.put("client_id", "test_client_id")
53+
.put("client_secret", "test_client_secret")
54+
.build()))));
55+
intercomoAuthFlow = new IntercomOAuthFlow(configRepository, httpClient, IntercomOAuthFlowTest::getConstantState);
56+
57+
}
58+
59+
@Test
60+
public void testGetSourceConcentUrl() throws IOException, ConfigNotFoundException {
61+
final String concentUrl =
62+
intercomoAuthFlow.getSourceConsentUrl(workspaceId, definitionId, REDIRECT_URL);
63+
assertEquals(concentUrl,
64+
"https://app.intercom.com/a/oauth/connect?client_id=test_client_id&redirect_uri=https%3A%2F%2Fairbyte.io&response_type=code&state=state");
65+
}
66+
67+
@Test
68+
public void testCompleteSourceOAuth() throws IOException, InterruptedException, ConfigNotFoundException {
69+
70+
Map<String, String> returnedCredentials = Map.of("access_token", "refresh_token_response");
71+
final HttpResponse response = mock(HttpResponse.class);
72+
when(response.body()).thenReturn(Jsons.serialize(returnedCredentials));
73+
when(httpClient.send(any(), any())).thenReturn(response);
74+
final Map<String, Object> queryParams = Map.of("code", "test_code");
75+
final Map<String, Object> actualQueryParams =
76+
intercomoAuthFlow.completeSourceOAuth(workspaceId, definitionId, queryParams, REDIRECT_URL);
77+
assertEquals(Jsons.serialize(returnedCredentials), Jsons.serialize(actualQueryParams));
78+
}
79+
80+
}

docs/integrations/sources/intercom.md

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Please read [How to get your Access Token](https://developers.intercom.com/build
5555

5656
| Version | Date | Pull Request | Subject |
5757
| :--- | :--- | :--- | :--- |
58+
| 0.1.8 | 2021-09-28 | [7060](https://github.com/airbytehq/airbyte/pull/7060) | Added oauth support |
5859
| 0.1.6 | 2021-10-07 | [6879](https://github.com/airbytehq/airbyte/pull/6879) | Corrected pagination for contacts |
5960
| 0.1.5 | 2021-09-28 | [6082](https://github.com/airbytehq/airbyte/pull/6082) | Corrected android\_last\_seen\_at field data type in schemas |
6061
| 0.1.4 | 2021-09-20 | [6087](https://github.com/airbytehq/airbyte/pull/6087) | Corrected updated\_at field data type in schemas |

0 commit comments

Comments
 (0)