Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pipedrive oAuth support #7968

Merged
merged 8 commits into from
Nov 15, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.6
LABEL io.airbyte.version=0.1.7
LABEL io.airbyte.name=airbyte/source-pipedrive
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"authorization": {
"type": "object",
"title": "Authentication Type",
"description": "Choose one of the possible authorization method",
"oneOf": [
{
"title": "Sign in via Pipedrive (OAuth)",
Expand Down Expand Up @@ -40,12 +41,6 @@
"description": "The client secret of your developer application",
"airbyte_secret": true
},
"access_token": {
"title": "Access Token",
"type": "string",
"description": "An access token generated using the above client ID and secret",
"airbyte_secret": true
},
"refresh_token": {
"title": "Refresh Token",
"type": "string",
Expand Down Expand Up @@ -86,5 +81,13 @@
}
},
"supportsIncremental": true,
"supported_destination_sync_modes": ["append"]
"supported_destination_sync_modes": ["append"],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is a source, is that normal to set destination modes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, missed that, will remove

"authSpecification": {
"auth_type": "oauth2.0",
"oauth2Specification": {
"rootObject": ["authorization", 0],
"oauthFlowInitParameters": [["client_id"], ["client_secret"]],
"oauthFlowOutputParameters": [["refresh_token"]]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import io.airbyte.oauth.flows.GithubOAuthFlow;
import io.airbyte.oauth.flows.HubspotOAuthFlow;
import io.airbyte.oauth.flows.IntercomOAuthFlow;
import io.airbyte.oauth.flows.PipeDriveOAuthFlow;
import io.airbyte.oauth.flows.QuickbooksOAuthFlow;
import io.airbyte.oauth.flows.SalesforceOAuthFlow;
import io.airbyte.oauth.flows.SlackOAuthFlow;
Expand Down Expand Up @@ -45,6 +46,7 @@ public OAuthImplementationFactory(final ConfigRepository configRepository, final
.put("airbyte/source-hubspot", new HubspotOAuthFlow(configRepository, httpClient))
.put("airbyte/source-intercom", new IntercomOAuthFlow(configRepository, httpClient))
.put("airbyte/source-instagram", new InstagramOAuthFlow(configRepository, httpClient))
.put("airbyte/source-pipedrive", new PipeDriveOAuthFlow(configRepository, httpClient))
.put("airbyte/source-quickbooks", new QuickbooksOAuthFlow(configRepository, httpClient))
.put("airbyte/source-salesforce", new SalesforceOAuthFlow(configRepository, httpClient))
.put("airbyte/source-slack", new SlackOAuthFlow(configRepository, httpClient))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
*/

package io.airbyte.oauth.flows;

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.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
import org.apache.http.client.utils.URIBuilder;

/**
* Following docs from https://pipedrive.readme.io/docs/marketplace-oauth-authorization
*/
public class PipeDriveOAuthFlow extends BaseOAuth2Flow {

final String AUTHORIZE_URL = "https://oauth.pipedrive.com/oauth/authorize";
final String ACCESS_TOKEN_URL = "https://oauth.pipedrive.com/oauth/token";

public PipeDriveOAuthFlow(final ConfigRepository configRepository, final HttpClient httpClient) {
super(configRepository, httpClient);
}

@VisibleForTesting
public PipeDriveOAuthFlow(final ConfigRepository configRepository, final HttpClient httpClient, final Supplier<String> stateSupplier) {
super(configRepository, httpClient, stateSupplier);
}

@Override
protected String formatConsentUrl(final UUID definitionId, final String clientId, final String redirectUrl) throws IOException {
try {
return new URIBuilder(AUTHORIZE_URL)
.addParameter("client_id", clientId)
.addParameter("redirect_uri", redirectUrl)
.addParameter("state", getState())
.build().toString();
} catch (final URISyntaxException e) {
throw new IOException("Failed to format Consent URL for OAuth flow", e);
}
}

@Override
protected Map<String, String> getAccessTokenQueryParameters(final String clientId,
final String clientSecret,
final String authCode,
final String redirectUrl) {
return ImmutableMap.<String, String>builder()
.putAll(super.getAccessTokenQueryParameters(clientId, clientSecret, authCode, redirectUrl))
.put("grant_type", "authorization_code")
.build();
}

@Override
protected String getAccessTokenUrl() {
return ACCESS_TOKEN_URL;
}

@Override
protected List<String> getDefaultOAuthOutputPath() {
return List.of("authorization");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
* Following docs from https://marketingapi.snapchat.com/docs/#authentication
*/
public class SnapchatMarketingOAuthFlow extends BaseOAuth2Flow {
// Clickable link for IDE
// https://help.salesforce.com/s/articleView?language=en_US&id=sf.remoteaccess_oauth_web_server_flow.htm

private static final String AUTHORIZE_URL = "https://accounts.snapchat.com/login/oauth2/authorize";
private static final String ACCESS_TOKEN_URL = "https://accounts.snapchat.com/login/oauth2/access_token";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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.Test;

public class PipeDriveOAuthFlowIntegrationTest extends OAuthFlowIntegrationTest {

@Override
protected Path getCredentialsPath() {
return Path.of("secrets/pipedrive.json");
}

@Override
protected String getRedirectUrl() {
return "http://localhost:3000/auth_flow";
}

protected int getServerListeningPort() {
return 3000;
}

@Override
protected OAuthFlowImplementation getFlowImplementation(ConfigRepository configRepository, HttpClient httpClient) {
return new PipeDriveOAuthFlow(configRepository, httpClient);
}

@Test
public void testFullPipeDriveOAuthFlow() throws InterruptedException, ConfigNotFoundException, IOException, JsonValidationException {
final UUID workspaceId = UUID.randomUUID();
final UUID definitionId = UUID.randomUUID();
final String fullConfigAsString = new String(Files.readAllBytes(getCredentialsPath()));
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 = getFlowImplementation(configRepository, httpClient).getSourceConsentUrl(workspaceId, definitionId, getRedirectUrl());
LOGGER.info("Waiting for user consent at: {}", url);
waitForResponse(20);
assertTrue(serverHandler.isSucceeded(), "Failed to get User consent on time");
final Map<String, Object> params = flow.completeSourceOAuth(workspaceId, definitionId,
Map.of("code", serverHandler.getParamValue()), getRedirectUrl());
LOGGER.info("Response from completing OAuth Flow is: {}", params.toString());
assertTrue(params.containsKey("authorization"));
final var creds = (Map<String, String>) params.get("authorization");
assertTrue(creds.get("refresh_token").toString().length() > 0);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ public void setup() throws IOException {
httpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
flow = this.getFlowImplementation(configRepository, httpClient);

System.out.println(getServerListeningPort());
server = HttpServer.create(new InetSocketAddress(getServerListeningPort()), 0);
server.setExecutor(null); // creates a default executor
server.start();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
*/

package io.airbyte.oauth.flows;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

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.validation.json.JsonValidationException;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
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 PipeDriveOAuthFlowTest {
Copy link
Contributor

@ChristopheDuong ChristopheDuong Nov 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, could you update your branch and make the unit test extends from BaseOAuthTestFlow?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, nice refactoring, I love it


private UUID workspaceId;
private UUID definitionId;
private ConfigRepository configRepository;
private PipeDriveOAuthFlow pipedriveOAuthFlow;
private HttpClient httpClient;

private static final String REDIRECT_URL = "https://airbyte.io";

private static String getConstantState() {
return "state";
}

@BeforeEach
public void setup() throws IOException, JsonValidationException {
workspaceId = UUID.randomUUID();
definitionId = UUID.randomUUID();
configRepository = mock(ConfigRepository.class);
httpClient = mock(HttpClient.class);
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", "test_client_id")
.put("client_secret", "test_client_secret")
.build())))));
pipedriveOAuthFlow = new PipeDriveOAuthFlow(configRepository, httpClient, PipeDriveOAuthFlowTest::getConstantState);

}

@Test
public void testGetSourceConsentUrl() throws IOException, InterruptedException, ConfigNotFoundException {
final String consentUrl = pipedriveOAuthFlow.getSourceConsentUrl(workspaceId, definitionId, REDIRECT_URL);
assertEquals(
"https://oauth.pipedrive.com/oauth/authorize?client_id=test_client_id&redirect_uri=https%3A%2F%2Fairbyte.io&state=state",
consentUrl);
}

@Test
public void testCompleteSourceOAuth() throws IOException, JsonValidationException, InterruptedException, ConfigNotFoundException {

final Map<String, String> returnedCredentials = Map.of("refresh_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 =
pipedriveOAuthFlow.completeSourceOAuth(workspaceId, definitionId, queryParams, REDIRECT_URL);
assertEquals(Map.of("authorization", returnedCredentials), actualQueryParams);
}

}
14 changes: 0 additions & 14 deletions airbyte-webapp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions airbyte-webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"lodash.merge": "^4.6.2",
"lodash.pick": "^4.4.0",
"query-string": "^6.13.1",
"react": "17.0.1",
"react-dom": "17.0.1",
Expand Down Expand Up @@ -68,7 +67,6 @@
"@types/lodash.get": "^4.4.6",
"@types/lodash.isequal": "^4.5.5",
"@types/lodash.merge": "^4.6.6",
"@types/lodash.pick": "^4.4.6",
"@types/node": "^12.0.0",
"@types/query-string": "^6.3.0",
"@types/react": "17.0.2",
Expand Down
3 changes: 0 additions & 3 deletions airbyte-webapp/src/core/domain/connector/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ interface ConnectorDefinitionSpecificationBase {
rootObject?: string[];
oauthFlowInitParameters?: string[][];
oauthFlowOutputParameters?: string[][];
oauthFlowInputFields?: string[][];
};
};
}
Expand All @@ -40,12 +39,10 @@ export interface SourceGetConsentPayload {
redirectUrl: string;
sourceDefinitionId: string;
workspaceId: string;
inputParams: Record<string, unknown>;
}

export interface DestinationGetConsentPayload {
redirectUrl: string;
destinationDefinitionId: string;
workspaceId: string;
inputParams: Record<string, unknown>;
}
Loading