Skip to content

Commit c47ef63

Browse files
Merge pull request #151 from cloudsufi/feature/proxy-support
[PLUGIN-1959] Add proxy support for ServiceNow plugins
2 parents fadd5db + 23fbe88 commit c47ef63

19 files changed

Lines changed: 773 additions & 12 deletions

docs/ServiceNow-batchsink.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ You also can use the macro function ${conn(connection-name)}.
2424

2525
**Password**: The password for ServiceNow Instance.
2626

27+
**Proxy URL**: Proxy URL through which all the ServiceNow API calls are routed. For example,
28+
`http://proxy.example.com:8080`. If no scheme is specified (for example, `proxy.example.com:8080`),
29+
it defaults to `http`. Specify `https://` explicitly
30+
when using an HTTPS proxy. Leave it empty to connect to ServiceNow directly.
31+
32+
**Proxy Username**: The username to authenticate with the proxy server, if the proxy requires authentication.
33+
34+
**Proxy Password**: The password to authenticate with the proxy server, if the proxy requires authentication.
35+
2736
**Reference Name**: Name used to uniquely identify this source for lineage, annotating metadata, etc.
2837

2938
**Table Name**: The name of the ServiceNow table into which data is to be pushed.

docs/ServiceNow-batchsource.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ You also can use the macro function ${conn(connection-name)}.
2727

2828
**Password**: The password for ServiceNow Instance.
2929

30+
**Proxy URL**: Proxy URL through which all the ServiceNow API calls are routed. For example,
31+
`http://proxy.example.com:8080`. If no scheme is specified (for example, `proxy.example.com:8080`),
32+
it defaults to `http`. Specify `https://` explicitly
33+
when using an HTTPS proxy. Leave it empty to connect to ServiceNow directly.
34+
35+
**Proxy Username**: The username to authenticate with the proxy server, if the proxy requires authentication.
36+
37+
**Proxy Password**: The password to authenticate with the proxy server, if the proxy requires authentication.
38+
3039
**Reference Name**: Name used to uniquely identify this source for lineage, annotating metadata, etc.
3140

3241
**Mode**: Mode of query. The mode can be one of two values:

docs/ServiceNow-connector.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ Properties
2020

2121
**Password**: The password for ServiceNow Instance.
2222

23+
**Proxy URL**: Proxy URL through which all the ServiceNow API calls are routed. For example,
24+
`http://proxy.example.com:8080`. If no scheme is specified (for example, `proxy.example.com:8080`),
25+
it defaults to `http`. Specify `https://` explicitly
26+
when using an HTTPS proxy. Leave it empty to connect to ServiceNow directly.
27+
28+
**Proxy Username**: The username to authenticate with the proxy server, if the proxy requires authentication.
29+
30+
**Proxy Password**: The password to authenticate with the proxy server, if the proxy requires authentication.
31+
2332

2433
Path of the connection
2534
----------------------

docs/ServiceNowMultiSource-batchsource.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ Properties
2222

2323
**Password**: The password for ServiceNow Instance.
2424

25+
**Proxy URL**: Proxy URL through which all the ServiceNow API calls are routed. For example,
26+
`http://proxy.example.com:8080`. If no scheme is specified (for example, `proxy.example.com:8080`),
27+
it defaults to `http`. Specify `https://` explicitly
28+
when using an HTTPS proxy. Leave it empty to connect to ServiceNow directly.
29+
30+
**Proxy Username**: The username to authenticate with the proxy server, if the proxy requires authentication.
31+
32+
**Proxy Password**: The password to authenticate with the proxy server, if the proxy requires authentication.
33+
2534
**Reference Name**: Name used to uniquely identify this source for lineage, annotating metadata, etc.
2635

2736
**Table Names**: The name of the ServiceNow table(s) from which data to be fetched.

pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@
105105
<version>${hadoop.version}</version>
106106
<scope>provided</scope>
107107
<exclusions>
108+
<exclusion>
109+
<groupId>commons-codec</groupId>
110+
<artifactId>commons-codec</artifactId>
111+
</exclusion>
108112
<exclusion>
109113
<groupId>commons-logging</groupId>
110114
<artifactId>commons-logging</artifactId>

src/e2e-test/resources/errorMessage.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ validationSuccessMessage=No errors found.
55
invalid.property.tablename=ServiceNow API returned an unexpected result or the specified table may not exist. Cause: Http call to ServiceNow instance returned status code 400. Ensure specified table exists in the datasource.
66
invalid.property.startdate=Invalid format for Start date. Correct Format: yyyy-MM-dd
77
invalid.property.enddate=Invalid format for End date. Correct Format: yyyy-MM-dd
8-
invalid.property.credentials=Unable to connect to ServiceNow Instance. Ensure properties like Client ID, Client Secret, API Endpoint, User Name, Password are correct.
8+
invalid.property.credentials=Unable to connect to ServiceNow Instance. Ensure properties like Client ID, Client Secret, API Endpoint, User Name, Password and Proxy Properties are correct.
99

1010
#Logs error message
1111
invalid.tablename.logsmessage=ServiceNow API returned an unexpected result or the specified table may not exist.

src/main/java/io/cdap/plugin/servicenow/ServiceNowBaseConfig.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,16 @@ public void validateServiceNowConnection(FailureCollector collector) {
8989
restApi.getAccessToken();
9090
} catch (Exception e) {
9191
collector.addFailure("Unable to connect to ServiceNow Instance.",
92-
"Ensure properties like Client ID, Client Secret, API Endpoint, User Name, Password " +
93-
"are correct.")
92+
"Ensure properties like Client ID, Client Secret, API Endpoint, User Name, " +
93+
"Password and Proxy Properties are correct.")
9494
.withConfigProperty(ServiceNowConstants.PROPERTY_CLIENT_ID)
9595
.withConfigProperty(ServiceNowConstants.PROPERTY_CLIENT_SECRET)
9696
.withConfigProperty(ServiceNowConstants.PROPERTY_API_ENDPOINT)
9797
.withConfigProperty(ServiceNowConstants.PROPERTY_USER)
9898
.withConfigProperty(ServiceNowConstants.PROPERTY_PASSWORD)
99+
.withConfigProperty(ServiceNowConstants.PROPERTY_PROXY_URL)
100+
.withConfigProperty(ServiceNowConstants.PROPERTY_PROXY_USERNAME)
101+
.withConfigProperty(ServiceNowConstants.PROPERTY_PROXY_PASSWORD)
99102
.withStacktrace(e.getStackTrace());
100103
}
101104
}
@@ -108,7 +111,10 @@ public boolean shouldConnect() {
108111
!containsMacro(ServiceNowConstants.PROPERTY_CLIENT_SECRET) &&
109112
!containsMacro(ServiceNowConstants.PROPERTY_API_ENDPOINT) &&
110113
!containsMacro(ServiceNowConstants.PROPERTY_USER) &&
111-
!containsMacro(ServiceNowConstants.PROPERTY_PASSWORD);
114+
!containsMacro(ServiceNowConstants.PROPERTY_PASSWORD) &&
115+
!containsMacro(ServiceNowConstants.PROPERTY_PROXY_URL) &&
116+
!containsMacro(ServiceNowConstants.PROPERTY_PROXY_USERNAME) &&
117+
!containsMacro(ServiceNowConstants.PROPERTY_PROXY_PASSWORD);
112118
}
113119

114120
public boolean shouldGetSchema() {

src/main/java/io/cdap/plugin/servicenow/apiclient/ServiceNowTableAPIClientImpl.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
import org.apache.http.client.methods.CloseableHttpResponse;
5050
import org.apache.http.client.methods.HttpDelete;
5151
import org.apache.http.impl.client.CloseableHttpClient;
52-
import org.apache.http.impl.client.HttpClients;
5352
import org.apache.oltu.oauth2.common.exception.OAuthProblemException;
5453
import org.apache.oltu.oauth2.common.exception.OAuthSystemException;
5554
import org.slf4j.Logger;
@@ -86,6 +85,7 @@ public class ServiceNowTableAPIClientImpl extends RestAPIClient {
8685
public static JsonArray serviceNowJsonResultArray;
8786

8887
public ServiceNowTableAPIClientImpl(ServiceNowConnectorConfig conf, Boolean useConnection) {
88+
super(conf.getProxyUrl(), conf.getProxyUsername(), conf.getProxyPassword());
8989
this.conf = conf;
9090
this.schemaType = getSchemaTypeBasedOnUseConnection(useConnection);
9191
}
@@ -561,7 +561,7 @@ public void deleteRecordFromServiceNowTable(String tableName, String sysId)
561561
deleteRequest.setHeader("Authorization", "Bearer " + accessToken);
562562
deleteRequest.setHeader("Accept", "application/json");
563563

564-
try (CloseableHttpClient httpClient = HttpClients.createDefault();
564+
try (CloseableHttpClient httpClient = getHttpClientBuilder().build();
565565
CloseableHttpResponse response = httpClient.execute(deleteRequest)) {
566566

567567
int statusCode = response.getStatusLine().getStatusCode();

src/main/java/io/cdap/plugin/servicenow/connector/ServiceNowConnectorConfig.java

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import io.cdap.plugin.servicenow.apiclient.ServiceNowTableAPIClientImpl;
2424
import io.cdap.plugin.servicenow.util.ServiceNowConstants;
2525
import io.cdap.plugin.servicenow.util.Util;
26+
import org.apache.http.HttpHost;
2627

2728
import javax.annotation.Nullable;
2829

@@ -62,13 +63,41 @@ public class ServiceNowConnectorConfig extends PluginConfig {
6263
@Description("The password for ServiceNow Instance.")
6364
private final String password;
6465

66+
@Name(ServiceNowConstants.PROPERTY_PROXY_URL)
67+
@Macro
68+
@Nullable
69+
@Description("Proxy URL through which all the ServiceNow API calls are routed. " +
70+
"Must contain a protocol, address and port. For example, http://proxy.example.com:8080")
71+
private final String proxyUrl;
72+
73+
@Name(ServiceNowConstants.PROPERTY_PROXY_USERNAME)
74+
@Macro
75+
@Nullable
76+
@Description("The username to authenticate with the proxy server, if the proxy requires authentication.")
77+
private final String proxyUsername;
78+
79+
@Name(ServiceNowConstants.PROPERTY_PROXY_PASSWORD)
80+
@Macro
81+
@Nullable
82+
@Description("The password to authenticate with the proxy server, if the proxy requires authentication.")
83+
private final String proxyPassword;
84+
6585
public ServiceNowConnectorConfig(String clientId, String clientSecret, String restApiEndpoint,
6686
String user, String password) {
87+
this(clientId, clientSecret, restApiEndpoint, user, password, null, null, null);
88+
}
89+
90+
public ServiceNowConnectorConfig(String clientId, String clientSecret, String restApiEndpoint,
91+
String user, String password, String proxyUrl, String proxyUsername,
92+
String proxyPassword) {
6793
this.clientId = clientId;
6894
this.clientSecret = clientSecret;
6995
this.restApiEndpoint = restApiEndpoint;
7096
this.user = user;
7197
this.password = password;
98+
this.proxyUrl = proxyUrl;
99+
this.proxyUsername = proxyUsername;
100+
this.proxyPassword = proxyPassword;
72101
}
73102

74103
public String getClientId() {
@@ -91,6 +120,28 @@ public String getPassword() {
91120
return password;
92121
}
93122

123+
@Nullable
124+
public String getProxyUrl() {
125+
return proxyUrl;
126+
}
127+
128+
@Nullable
129+
public String getProxyUsername() {
130+
return proxyUsername;
131+
}
132+
133+
@Nullable
134+
public String getProxyPassword() {
135+
return proxyPassword;
136+
}
137+
138+
/**
139+
* Returns true if the proxy properties do not contain macros and a proxy URL has been configured.
140+
*/
141+
public boolean hasProxyConfigured() {
142+
return !containsMacro(ServiceNowConstants.PROPERTY_PROXY_URL) && !Util.isNullOrEmpty(proxyUrl);
143+
}
144+
94145

95146
/**
96147
* validates all the fields which are mandatory for the connection.
@@ -120,6 +171,43 @@ public void validateCredentialsFields(FailureCollector collector) {
120171
collector.addFailure("Password must be specified.", null)
121172
.withConfigProperty(ServiceNowConstants.PROPERTY_PASSWORD);
122173
}
174+
175+
validateProxyFields(collector);
176+
}
177+
178+
/**
179+
* Validates the optional proxy fields. The proxy URL must be a valid host with a protocol and port, and a proxy
180+
* username and password must be provided together.
181+
*/
182+
public void validateProxyFields(FailureCollector collector) {
183+
if (!containsMacro(ServiceNowConstants.PROPERTY_PROXY_URL) && !Util.isNullOrEmpty(proxyUrl)) {
184+
try {
185+
HttpHost host = HttpHost.create(proxyUrl);
186+
if (host.getPort() == -1) {
187+
throw new IllegalArgumentException();
188+
}
189+
} catch (IllegalArgumentException e) {
190+
collector.addFailure("Proxy URL is not valid.",
191+
"Ensure the Proxy URL contains a protocol, address and port. " +
192+
"For example, http://proxy.example.com:8080.")
193+
.withConfigProperty(ServiceNowConstants.PROPERTY_PROXY_URL);
194+
}
195+
}
196+
197+
if (containsMacro(ServiceNowConstants.PROPERTY_PROXY_USERNAME)
198+
|| containsMacro(ServiceNowConstants.PROPERTY_PROXY_PASSWORD)) {
199+
return;
200+
}
201+
202+
boolean hasUsername = !Util.isNullOrEmpty(proxyUsername);
203+
boolean hasPassword = !Util.isNullOrEmpty(proxyPassword);
204+
if (hasUsername && !hasPassword) {
205+
collector.addFailure("Proxy password must be specified when a proxy username is provided.", null)
206+
.withConfigProperty(ServiceNowConstants.PROPERTY_PROXY_PASSWORD);
207+
} else if (!hasUsername && hasPassword) {
208+
collector.addFailure("Proxy username must be specified when a proxy password is provided.", null)
209+
.withConfigProperty(ServiceNowConstants.PROPERTY_PROXY_USERNAME);
210+
}
123211
}
124212

125213
public void validateConnection(FailureCollector collector) {
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright © 2026 Cask Data, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
17+
package io.cdap.plugin.servicenow.restapi;
18+
19+
import org.apache.http.Header;
20+
import org.apache.http.client.methods.CloseableHttpResponse;
21+
import org.apache.http.client.methods.HttpGet;
22+
import org.apache.http.client.methods.HttpPost;
23+
import org.apache.http.client.methods.HttpRequestBase;
24+
import org.apache.http.entity.StringEntity;
25+
import org.apache.http.impl.client.CloseableHttpClient;
26+
import org.apache.http.impl.client.HttpClientBuilder;
27+
import org.apache.http.util.EntityUtils;
28+
import org.apache.oltu.oauth2.client.HttpClient;
29+
import org.apache.oltu.oauth2.client.request.OAuthClientRequest;
30+
import org.apache.oltu.oauth2.client.response.OAuthClientResponse;
31+
import org.apache.oltu.oauth2.client.response.OAuthClientResponseFactory;
32+
import org.apache.oltu.oauth2.common.OAuth;
33+
import org.apache.oltu.oauth2.common.exception.OAuthProblemException;
34+
import org.apache.oltu.oauth2.common.exception.OAuthSystemException;
35+
36+
import java.io.IOException;
37+
import java.nio.charset.StandardCharsets;
38+
import java.util.ArrayList;
39+
import java.util.Collections;
40+
import java.util.List;
41+
import java.util.Map;
42+
43+
/**
44+
* An implementation of Oltu's {@link HttpClient} backed by Apache {@link CloseableHttpClient}. It is used so that the
45+
* OAuth token request is executed through the same proxy-aware HTTP client as the rest of the ServiceNow API calls.
46+
*/
47+
public class ProxyOltuHttpClient implements HttpClient {
48+
49+
private final HttpClientBuilder httpClientBuilder;
50+
51+
public ProxyOltuHttpClient(HttpClientBuilder httpClientBuilder) {
52+
this.httpClientBuilder = httpClientBuilder;
53+
}
54+
55+
@Override
56+
public <T extends OAuthClientResponse> T execute(OAuthClientRequest request, Map<String, String> headers,
57+
String requestMethod, Class<T> responseClass)
58+
throws OAuthSystemException, OAuthProblemException {
59+
60+
HttpRequestBase httpRequest;
61+
if (OAuth.HttpMethod.POST.equalsIgnoreCase(requestMethod) || OAuth.HttpMethod.PUT.equalsIgnoreCase(requestMethod)) {
62+
HttpPost httpPost = new HttpPost(request.getLocationUri());
63+
if (request.getBody() != null) {
64+
httpPost.setEntity(new StringEntity(request.getBody(), StandardCharsets.UTF_8));
65+
}
66+
httpRequest = httpPost;
67+
} else {
68+
httpRequest = new HttpGet(request.getLocationUri());
69+
}
70+
71+
if (request.getHeaders() != null) {
72+
request.getHeaders().forEach(httpRequest::setHeader);
73+
}
74+
if (headers != null) {
75+
headers.forEach(httpRequest::setHeader);
76+
}
77+
if (httpRequest.getFirstHeader(OAuth.HeaderType.CONTENT_TYPE) == null) {
78+
httpRequest.setHeader(OAuth.HeaderType.CONTENT_TYPE, OAuth.ContentType.URL_ENCODED);
79+
}
80+
81+
try (CloseableHttpClient httpClient = httpClientBuilder.build();
82+
CloseableHttpResponse response = httpClient.execute(httpRequest)) {
83+
int responseCode = response.getStatusLine().getStatusCode();
84+
String contentType = response.getEntity() != null && response.getEntity().getContentType() != null
85+
? response.getEntity().getContentType().getValue() : null;
86+
String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : "";
87+
return OAuthClientResponseFactory.createCustomResponse(body, contentType, responseCode,
88+
extractHeaders(response), responseClass);
89+
} catch (IOException e) {
90+
throw new OAuthSystemException(e);
91+
}
92+
}
93+
94+
@Override
95+
public void shutdown() {
96+
// No persistent resources are held; a fresh client is created per request and closed in execute().
97+
}
98+
99+
private Map<String, List<String>> extractHeaders(CloseableHttpResponse response) {
100+
Map<String, List<String>> responseHeaders = new java.util.HashMap<>();
101+
for (Header header : response.getAllHeaders()) {
102+
List<String> values = responseHeaders.computeIfAbsent(header.getName(), k -> new ArrayList<>());
103+
values.add(header.getValue());
104+
}
105+
return Collections.unmodifiableMap(responseHeaders);
106+
}
107+
}

0 commit comments

Comments
 (0)