embulk-input-zendesk 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -0
  3. data/README.md +32 -2
  4. data/build.gradle +1 -1
  5. data/src/main/java/org/embulk/input/zendesk/RecordImporter.java +134 -0
  6. data/src/main/java/org/embulk/input/zendesk/ZendeskInputPlugin.java +182 -202
  7. data/src/main/java/org/embulk/input/zendesk/clients/ZendeskRestClient.java +54 -52
  8. data/src/main/java/org/embulk/input/zendesk/models/Target.java +3 -3
  9. data/src/main/java/org/embulk/input/zendesk/models/ZendeskException.java +1 -1
  10. data/src/main/java/org/embulk/input/zendesk/services/ZendeskCustomObjectService.java +110 -0
  11. data/src/main/java/org/embulk/input/zendesk/services/ZendeskNPSService.java +30 -0
  12. data/src/main/java/org/embulk/input/zendesk/services/ZendeskNormalServices.java +239 -0
  13. data/src/main/java/org/embulk/input/zendesk/services/ZendeskService.java +14 -0
  14. data/src/main/java/org/embulk/input/zendesk/services/ZendeskSupportAPIService.java +25 -83
  15. data/src/main/java/org/embulk/input/zendesk/services/ZendeskUserEventService.java +158 -0
  16. data/src/main/java/org/embulk/input/zendesk/stream/PagingSpliterator.java +40 -0
  17. data/src/main/java/org/embulk/input/zendesk/stream/paginator/sunshine/CustomObjectSpliterator.java +42 -0
  18. data/src/main/java/org/embulk/input/zendesk/stream/paginator/sunshine/SunshineSpliterator.java +66 -0
  19. data/src/main/java/org/embulk/input/zendesk/stream/paginator/sunshine/UserEventSpliterator.java +35 -0
  20. data/src/main/java/org/embulk/input/zendesk/stream/paginator/support/OrganizationSpliterator.java +13 -0
  21. data/src/main/java/org/embulk/input/zendesk/stream/paginator/support/SupportSpliterator.java +44 -0
  22. data/src/main/java/org/embulk/input/zendesk/stream/paginator/support/UserSpliterator.java +13 -0
  23. data/src/main/java/org/embulk/input/zendesk/utils/ZendeskConstants.java +13 -1
  24. data/src/main/java/org/embulk/input/zendesk/utils/ZendeskDateUtils.java +22 -11
  25. data/src/main/java/org/embulk/input/zendesk/utils/ZendeskUtils.java +52 -114
  26. data/src/test/java/org/embulk/input/zendesk/TestRecordImporter.java +114 -0
  27. data/src/test/java/org/embulk/input/zendesk/TestZendeskInputPlugin.java +184 -99
  28. data/src/test/java/org/embulk/input/zendesk/clients/TestZendeskRestClient.java +6 -20
  29. data/src/test/java/org/embulk/input/zendesk/services/TestZendeskCustomObjectService.java +161 -0
  30. data/src/test/java/org/embulk/input/zendesk/services/TestZendeskNPSService.java +56 -0
  31. data/src/test/java/org/embulk/input/zendesk/services/TestZendeskNormalService.java +189 -0
  32. data/src/test/java/org/embulk/input/zendesk/services/TestZendeskSupportAPIService.java +18 -60
  33. data/src/test/java/org/embulk/input/zendesk/services/TestZendeskUserEventService.java +158 -0
  34. data/src/test/java/org/embulk/input/zendesk/utils/TestZendeskDateUtils.java +50 -2
  35. data/src/test/java/org/embulk/input/zendesk/utils/TestZendeskUtil.java +0 -138
  36. data/src/test/java/org/embulk/input/zendesk/utils/ZendeskTestHelper.java +16 -0
  37. data/src/test/resources/config/nps.yml +29 -0
  38. data/src/test/resources/config/object_records.yml +24 -0
  39. data/src/test/resources/config/relationship_records.yml +23 -0
  40. data/src/test/resources/config/user_events.yml +29 -0
  41. data/src/test/resources/data/duplicate_user.json +0 -0
  42. data/src/test/resources/data/empty_result.json +7 -0
  43. data/src/test/resources/data/expected/user_events_column.json +40 -0
  44. data/src/test/resources/data/object_records.json +30 -0
  45. data/src/test/resources/data/organization.json +39 -0
  46. data/src/test/resources/data/relationship_records.json +57 -0
  47. data/src/test/resources/data/scores.json +21 -0
  48. data/src/test/resources/data/scores_share_same_time_with_next_page.json +35 -0
  49. data/src/test/resources/data/scores_share_same_time_without_next_page.json +35 -0
  50. data/src/test/resources/data/simple_organization.json +23 -0
  51. data/src/test/resources/data/simple_user.json +50 -0
  52. data/src/test/resources/data/simple_user_event.json +19 -0
  53. data/src/test/resources/data/ticket_events_share_same_time_with_next_page.json +279 -0
  54. data/src/test/resources/data/ticket_events_share_same_time_without_next_page.json +279 -0
  55. data/src/test/resources/data/ticket_events_updated_by_system_records.json +279 -0
  56. data/src/test/resources/data/ticket_share_same_time_with_next_page.json +232 -0
  57. data/src/test/resources/data/ticket_share_same_time_without_next_page.json +232 -0
  58. data/src/test/resources/data/ticket_with_updated_by_system_records.json +187 -0
  59. data/src/test/resources/data/user_event.json +19 -0
  60. data/src/test/resources/data/user_event_contain_latter_create_at.json +19 -0
  61. data/src/test/resources/data/user_event_multiple.json +33 -0
  62. metadata +46 -5
  63. data/src/main/java/org/embulk/input/zendesk/utils/ZendeskValidatorUtils.java +0 -79
  64. data/src/test/java/org/embulk/input/zendesk/utils/TestZendeskValidatorUtils.java +0 -130
@@ -7,7 +7,6 @@ import com.google.common.collect.ImmutableMap;
7
7
  import com.google.common.collect.ImmutableMap.Builder;
8
8
  import com.google.common.util.concurrent.RateLimiter;
9
9
  import com.google.common.util.concurrent.Uninterruptibles;
10
-
11
10
  import org.apache.http.Header;
12
11
  import org.apache.http.HttpResponse;
13
12
  import org.apache.http.HttpStatus;
@@ -26,23 +25,21 @@ import org.embulk.spi.DataException;
26
25
  import org.embulk.spi.Exec;
27
26
  import org.embulk.spi.util.RetryExecutor;
28
27
  import org.slf4j.Logger;
29
- import static org.apache.http.HttpHeaders.AUTHORIZATION;
30
- import static org.apache.http.protocol.HTTP.CONTENT_TYPE;
31
- import static org.embulk.spi.util.RetryExecutor.retryExecutor;
32
28
 
33
29
  import java.io.IOException;
34
-
35
30
  import java.util.concurrent.TimeUnit;
36
31
 
32
+ import static org.apache.http.HttpHeaders.AUTHORIZATION;
33
+ import static org.apache.http.protocol.HTTP.CONTENT_TYPE;
34
+ import static org.embulk.spi.util.RetryExecutor.retryExecutor;
35
+
37
36
  public class ZendeskRestClient
38
37
  {
39
38
  private static final int CONNECTION_TIME_OUT = 240000;
40
39
 
41
40
  private static final Logger logger = Exec.getLogger(ZendeskRestClient.class);
42
-
43
- private static RateLimiter rateLimiter;
44
-
45
41
  private static final ObjectMapper objectMapper = new ObjectMapper();
42
+ private static RateLimiter rateLimiter;
46
43
 
47
44
  static {
48
45
  objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
@@ -52,35 +49,37 @@ public class ZendeskRestClient
52
49
  {
53
50
  }
54
51
 
55
- public String doGet(String url, PluginTask task, boolean isPreview)
52
+ public String doGet(final String url, final PluginTask task, final boolean isPreview)
56
53
  {
57
54
  try {
58
55
  return retryExecutor().withRetryLimit(task.getRetryLimit())
59
56
  .withInitialRetryWait(task.getRetryInitialWaitSec() * 1000)
60
57
  .withMaxRetryWait(task.getMaxRetryWaitSec() * 1000)
61
- .runInterruptible(new RetryExecutor.Retryable<String>() {
58
+ .runInterruptible(new RetryExecutor.Retryable<String>()
59
+ {
62
60
  @Override
63
- public String call() throws Exception
61
+ public String call()
62
+ throws Exception
64
63
  {
65
64
  return sendGetRequest(url, task);
66
65
  }
67
66
 
68
67
  @Override
69
- public boolean isRetryableException(Exception exception)
68
+ public boolean isRetryableException(final Exception exception)
70
69
  {
71
70
  if (exception instanceof ZendeskException) {
72
- int statusCode = ((ZendeskException) exception).getStatusCode();
71
+ final int statusCode = ((ZendeskException) exception).getStatusCode();
73
72
  return isResponseStatusToRetry(statusCode, exception.getMessage(), ((ZendeskException) exception).getRetryAfter(), isPreview);
74
73
  }
75
74
  return false;
76
75
  }
77
76
 
78
77
  @Override
79
- public void onRetry(Exception exception, int retryCount, int retryLimit, int retryWait)
78
+ public void onRetry(final Exception exception, final int retryCount, final int retryLimit, final int retryWait)
80
79
  {
81
80
  if (exception instanceof ZendeskException) {
82
- int retryAfter = ((ZendeskException) exception).getRetryAfter();
83
- String message;
81
+ final int retryAfter = ((ZendeskException) exception).getRetryAfter();
82
+ final String message;
84
83
  if (retryAfter > 0 && retryAfter > (retryWait / 1000)) {
85
84
  message = String
86
85
  .format("Retrying '%d'/'%d' after '%d' seconds. HTTP status code: '%s'",
@@ -100,7 +99,7 @@ public class ZendeskRestClient
100
99
  }
101
100
  }
102
101
  else {
103
- String message = String
102
+ final String message = String
104
103
  .format("Retrying '%d'/'%d' after '%d' seconds. Message: '%s'",
105
104
  retryCount, retryLimit,
106
105
  retryWait / 1000,
@@ -110,15 +109,15 @@ public class ZendeskRestClient
110
109
  }
111
110
 
112
111
  @Override
113
- public void onGiveup(Exception firstException, Exception lastException)
112
+ public void onGiveup(final Exception firstException, final Exception lastException)
114
113
  {
115
114
  }
116
115
  });
117
116
  }
118
- catch (RetryExecutor.RetryGiveupException | InterruptedException e) {
117
+ catch (final RetryExecutor.RetryGiveupException | InterruptedException e) {
119
118
  if (e instanceof RetryExecutor.RetryGiveupException && e.getCause() != null && e.getCause() instanceof ZendeskException) {
120
119
  throw new ConfigException("Status: '" + ((ZendeskException) (e.getCause())).getStatusCode() + "', error message: '" + e.getCause().getMessage() + "'",
121
- e.getCause());
120
+ e.getCause());
122
121
  }
123
122
  throw new ConfigException(e);
124
123
  }
@@ -127,27 +126,34 @@ public class ZendeskRestClient
127
126
  @VisibleForTesting
128
127
  protected HttpClient createHttpClient()
129
128
  {
130
- RequestConfig config = RequestConfig.custom()
129
+ final RequestConfig config = RequestConfig.custom()
131
130
  .setConnectTimeout(CONNECTION_TIME_OUT)
132
131
  .setConnectionRequestTimeout(CONNECTION_TIME_OUT)
133
132
  .build();
134
133
  return HttpClientBuilder.create().setDefaultRequestConfig(config).build();
135
134
  }
136
135
 
137
- private String sendGetRequest(final String url, final PluginTask task) throws ZendeskException
136
+ private String sendGetRequest(final String url, final PluginTask task)
137
+ throws ZendeskException
138
138
  {
139
139
  try {
140
- HttpClient client = createHttpClient();
141
- HttpRequestBase request = createGetRequest(url, task);
140
+ final HttpClient client = createHttpClient();
141
+ final HttpRequestBase request = createGetRequest(url, task);
142
+
143
+ if (rateLimiter != null) {
144
+ rateLimiter.acquire();
145
+ }
142
146
  logger.info(">>> {}{}", request.getURI().getPath(),
143
147
  request.getURI().getQuery() != null ? "?" + request.getURI().getQuery() : "");
144
- HttpResponse response = client.execute(request);
145
- getRateLimiter(response).acquire();
146
- int statusCode = response.getStatusLine().getStatusCode();
148
+ final HttpResponse response = client.execute(request);
149
+ if (rateLimiter == null) {
150
+ initRateLimiter(response);
151
+ }
152
+ final int statusCode = response.getStatusLine().getStatusCode();
147
153
  if (statusCode != HttpStatus.SC_OK) {
148
154
  if (statusCode == ZendeskConstants.HttpStatus.TOO_MANY_REQUEST || statusCode == HttpStatus.SC_INTERNAL_SERVER_ERROR
149
155
  || statusCode == HttpStatus.SC_SERVICE_UNAVAILABLE) {
150
- Header retryHeader = response.getFirstHeader("Retry-After");
156
+ final Header retryHeader = response.getFirstHeader("Retry-After");
151
157
  if (retryHeader != null) {
152
158
  throw new ZendeskException(statusCode, EntityUtils.toString(response.getEntity()), Integer.parseInt(retryHeader.getValue()));
153
159
  }
@@ -156,12 +162,12 @@ public class ZendeskRestClient
156
162
  }
157
163
  return EntityUtils.toString(response.getEntity());
158
164
  }
159
- catch (IOException ex) {
165
+ catch (final IOException ex) {
160
166
  throw new ZendeskException(-1, ex.getMessage(), 0);
161
167
  }
162
168
  }
163
169
 
164
- private boolean isResponseStatusToRetry(final int status, final String message, int retryAfter, final boolean isPreview)
170
+ private boolean isResponseStatusToRetry(final int status, final String message, final int retryAfter, final boolean isPreview)
165
171
  {
166
172
  if (status == HttpStatus.SC_NOT_FOUND) {
167
173
  // 404 would be returned e.g. ticket comments are empty (on fetchRelatedObjects method)
@@ -204,23 +210,23 @@ public class ZendeskRestClient
204
210
  return true;
205
211
  }
206
212
 
207
- private HttpRequestBase createGetRequest(String url, PluginTask task)
213
+ private HttpRequestBase createGetRequest(final String url, final PluginTask task)
208
214
  {
209
- HttpGet request = new HttpGet(url);
210
- ImmutableMap<String, String> headers = buildAuthHeader(task);
215
+ final HttpGet request = new HttpGet(url);
216
+ final ImmutableMap<String, String> headers = buildAuthHeader(task);
211
217
  headers.forEach(request::setHeader);
212
218
  return request;
213
219
  }
214
220
 
215
- private ImmutableMap<String, String> buildAuthHeader(PluginTask task)
221
+ private ImmutableMap<String, String> buildAuthHeader(final PluginTask task)
216
222
  {
217
- Builder<String, String> builder = new Builder<>();
223
+ final Builder<String, String> builder = new Builder<>();
218
224
  builder.put(AUTHORIZATION, buildCredential(task));
219
225
  addCommonHeader(builder, task);
220
226
  return builder.build();
221
227
  }
222
228
 
223
- private String buildCredential(PluginTask task)
229
+ private String buildCredential(final PluginTask task)
224
230
  {
225
231
  switch (task.getAuthenticationMethod()) {
226
232
  case BASIC:
@@ -233,7 +239,7 @@ public class ZendeskRestClient
233
239
  return "";
234
240
  }
235
241
 
236
- private void addCommonHeader(final Builder<String, String> builder, PluginTask task)
242
+ private void addCommonHeader(final Builder<String, String> builder, final PluginTask task)
237
243
  {
238
244
  task.getAppMarketPlaceIntegrationName().ifPresent(s -> builder.put(ZendeskConstants.Header.ZENDESK_MARKETPLACE_NAME, s));
239
245
  task.getAppMarketPlaceAppId().ifPresent(s -> builder.put(ZendeskConstants.Header.ZENDESK_MARKETPLACE_APP_ID, s));
@@ -242,28 +248,24 @@ public class ZendeskRestClient
242
248
  builder.put(CONTENT_TYPE, ZendeskConstants.Header.APPLICATION_JSON);
243
249
  }
244
250
 
245
- private RateLimiter getRateLimiter(final HttpResponse response)
251
+ private synchronized void initRateLimiter(final HttpResponse response)
246
252
  {
247
- if (rateLimiter == null) {
248
- rateLimiter = initRateLimiter(response);
249
- }
250
- return rateLimiter;
251
- }
252
-
253
- private static synchronized RateLimiter initRateLimiter(final HttpResponse response)
254
- {
255
- double permits = 0.0;
256
253
  if (response.containsHeader("x-rate-limit")) {
257
- String rateLimit = response.getFirstHeader("x-rate-limit").getValue();
254
+ double permits = 0.0;
255
+ final String rateLimit = response.getFirstHeader("x-rate-limit").getValue();
258
256
  try {
259
257
  permits = Double.parseDouble(rateLimit);
260
258
  }
261
- catch (NumberFormatException e) {
259
+ catch (final NumberFormatException e) {
262
260
  throw new DataException("Error when parse x-rate-limit: '" + response.getFirstHeader("x-rate-limit").getValue() + "'");
263
261
  }
264
- permits = permits / 60;
262
+
263
+ if (permits > 0) {
264
+ permits = permits / 60;
265
+ logger.info("Permits per second " + permits);
266
+
267
+ rateLimiter = RateLimiter.create(permits);
268
+ }
265
269
  }
266
- logger.info("Permits per second " + permits);
267
- return RateLimiter.create(permits);
268
270
  }
269
271
  }
@@ -10,9 +10,9 @@ public enum Target
10
10
  /** For ticket_metrics - we fetch by using include metric_sets with ticket target
11
11
  * so the jsonName is different comparing to the target name
12
12
  */
13
- TICKETS("tickets"), USERS("users"), ORGANIZATIONS("organizations"),
14
- TICKET_EVENTS("ticket_events"), TICKET_METRICS("metric_sets"),
15
- TICKET_FIELDS("ticket_fields"), TICKET_FORMS("ticket_forms");
13
+ TICKETS("tickets"), USERS("users"), ORGANIZATIONS("organizations"), TICKET_EVENTS("ticket_events"),
14
+ TICKET_METRICS("metric_sets"), TICKET_FIELDS("ticket_fields"), TICKET_FORMS("ticket_forms"),
15
+ RECIPIENTS("recipients"), SCORES("responses"), OBJECT_RECORDS("data"), RELATIONSHIP_RECORDS("data"), USER_EVENTS("data");
16
16
 
17
17
  String jsonName;
18
18
 
@@ -6,7 +6,7 @@ public class ZendeskException extends Exception
6
6
  private final int statusCode;
7
7
  private final int retryAfter;
8
8
 
9
- public ZendeskException(int statusCode, String message, int retryAfter)
9
+ public ZendeskException(final int statusCode, final String message, final int retryAfter)
10
10
  {
11
11
  super(message);
12
12
  this.statusCode = statusCode;
@@ -0,0 +1,110 @@
1
+ package org.embulk.input.zendesk.services;
2
+
3
+ import com.fasterxml.jackson.databind.JsonNode;
4
+ import com.fasterxml.jackson.databind.ObjectMapper;
5
+ import com.google.common.annotations.VisibleForTesting;
6
+ import com.google.common.base.Preconditions;
7
+ import org.apache.http.HttpStatus;
8
+ import org.embulk.config.ConfigException;
9
+ import org.embulk.config.TaskReport;
10
+ import org.embulk.input.zendesk.RecordImporter;
11
+ import org.embulk.input.zendesk.ZendeskInputPlugin;
12
+ import org.embulk.input.zendesk.clients.ZendeskRestClient;
13
+ import org.embulk.input.zendesk.models.Target;
14
+ import org.embulk.input.zendesk.models.ZendeskException;
15
+ import org.embulk.input.zendesk.stream.paginator.sunshine.CustomObjectSpliterator;
16
+ import org.embulk.input.zendesk.utils.ZendeskConstants;
17
+ import org.embulk.input.zendesk.utils.ZendeskUtils;
18
+ import org.embulk.spi.Exec;
19
+
20
+ import java.util.Collections;
21
+ import java.util.List;
22
+ import java.util.Optional;
23
+ import java.util.stream.Collectors;
24
+ import java.util.stream.StreamSupport;
25
+
26
+ public class ZendeskCustomObjectService implements ZendeskService
27
+ {
28
+ protected ZendeskInputPlugin.PluginTask task;
29
+
30
+ private ZendeskRestClient zendeskRestClient;
31
+
32
+ public ZendeskCustomObjectService(final ZendeskInputPlugin.PluginTask task)
33
+ {
34
+ this.task = task;
35
+ }
36
+
37
+ public boolean isSupportIncremental()
38
+ {
39
+ return false;
40
+ }
41
+
42
+ @Override
43
+ public TaskReport addRecordToImporter(final int taskIndex, final RecordImporter recordImporter)
44
+ {
45
+ final List<String> paths = getListPathByTarget();
46
+
47
+ paths.parallelStream().forEach(path -> StreamSupport.stream(new CustomObjectSpliterator(path, getZendeskRestClient(), task, Exec.isPreview()), !Exec.isPreview())
48
+ .forEach(recordImporter::addRecord));
49
+
50
+ return Exec.newTaskReport();
51
+ }
52
+
53
+ @Override
54
+ public JsonNode getDataFromPath(final String path, final int page, final boolean isPreview, final long startTime)
55
+ {
56
+ Preconditions.checkArgument(isPreview, "IsPreview should be true to use this method");
57
+
58
+ Optional<String> response = Optional.empty();
59
+ final List<String> paths = path.isEmpty()
60
+ ? getListPathByTarget()
61
+ : Collections.singletonList(path);
62
+
63
+ for (final String temp : paths) {
64
+ try {
65
+ response = Optional.ofNullable(getZendeskRestClient().doGet(temp, task, true));
66
+
67
+ if (response.isPresent()) {
68
+ break;
69
+ }
70
+ }
71
+ catch (final ConfigException e) {
72
+ // Sometimes we get 404 when having invalid endpoint, so ignore when we get 404
73
+ if (!(e.getCause() instanceof ZendeskException && ((ZendeskException) e.getCause()).getStatusCode() == HttpStatus.SC_NOT_FOUND)) {
74
+ throw e;
75
+ }
76
+ }
77
+ }
78
+
79
+ return response.map(ZendeskUtils::parseJsonObject).orElse(new ObjectMapper().createObjectNode());
80
+ }
81
+
82
+ @VisibleForTesting
83
+ protected ZendeskRestClient getZendeskRestClient()
84
+ {
85
+ if (zendeskRestClient == null) {
86
+ zendeskRestClient = new ZendeskRestClient();
87
+ }
88
+ return zendeskRestClient;
89
+ }
90
+
91
+ private List<String> getListPathByTarget()
92
+ {
93
+ return task.getTarget().equals(Target.OBJECT_RECORDS)
94
+ ? task.getObjectTypes().stream().map(this::buildPath).collect(Collectors.toList())
95
+ : task.getRelationshipTypes().stream().map(this::buildPath).collect(Collectors.toList());
96
+ }
97
+
98
+ private String buildPath(final String value)
99
+ {
100
+ final String perPage = Exec.isPreview() ? "1" : "1000";
101
+
102
+ return ZendeskUtils.getURIBuilder(task.getLoginUrl())
103
+ .setPath(task.getTarget().equals(Target.OBJECT_RECORDS)
104
+ ? ZendeskConstants.Url.API_OBJECT_RECORD
105
+ : ZendeskConstants.Url.API_RELATIONSHIP_RECORD)
106
+ .setParameter("type", value)
107
+ .setParameter("per_page", perPage)
108
+ .toString();
109
+ }
110
+ }
@@ -0,0 +1,30 @@
1
+ package org.embulk.input.zendesk.services;
2
+
3
+ import org.embulk.input.zendesk.ZendeskInputPlugin;
4
+ import org.embulk.input.zendesk.utils.ZendeskConstants;
5
+ import org.embulk.input.zendesk.utils.ZendeskUtils;
6
+
7
+ public class ZendeskNPSService extends ZendeskNormalServices
8
+ {
9
+ public ZendeskNPSService(final ZendeskInputPlugin.PluginTask task)
10
+ {
11
+ super(task);
12
+ }
13
+
14
+ public boolean isSupportIncremental()
15
+ {
16
+ return true;
17
+ }
18
+
19
+ @Override
20
+ protected String buildURI(final int page, final long startTime)
21
+ {
22
+ return ZendeskUtils.getURIBuilder(task.getLoginUrl())
23
+ .setPath(ZendeskConstants.Url.API_NPS_INCREMENTAL
24
+ + "/"
25
+ + task.getTarget().getJsonName()
26
+ + ".json")
27
+ .setParameter(ZendeskConstants.Field.START_TIME, String.valueOf(startTime))
28
+ .toString();
29
+ }
30
+ }
@@ -0,0 +1,239 @@
1
+ package org.embulk.input.zendesk.services;
2
+
3
+ import com.fasterxml.jackson.databind.JsonNode;
4
+ import com.fasterxml.jackson.databind.node.ObjectNode;
5
+ import com.google.common.annotations.VisibleForTesting;
6
+ import com.google.common.base.Throwables;
7
+ import org.apache.http.HttpStatus;
8
+ import org.apache.http.client.utils.URIBuilder;
9
+ import org.embulk.config.ConfigException;
10
+ import org.embulk.config.TaskReport;
11
+ import org.embulk.input.zendesk.RecordImporter;
12
+ import org.embulk.input.zendesk.ZendeskInputPlugin;
13
+ import org.embulk.input.zendesk.clients.ZendeskRestClient;
14
+ import org.embulk.input.zendesk.models.ZendeskException;
15
+ import org.embulk.input.zendesk.utils.ZendeskConstants;
16
+ import org.embulk.input.zendesk.utils.ZendeskDateUtils;
17
+ import org.embulk.input.zendesk.utils.ZendeskUtils;
18
+ import org.embulk.spi.Exec;
19
+ import org.slf4j.Logger;
20
+
21
+ import java.time.Instant;
22
+
23
+ import java.util.Iterator;
24
+ import java.util.Set;
25
+ import java.util.concurrent.ConcurrentHashMap;
26
+ import java.util.concurrent.LinkedBlockingQueue;
27
+ import java.util.concurrent.ThreadPoolExecutor;
28
+ import java.util.concurrent.TimeUnit;
29
+
30
+ public abstract class ZendeskNormalServices implements ZendeskService
31
+ {
32
+ private static final Logger logger = Exec.getLogger(ZendeskNormalServices.class);
33
+
34
+ protected ZendeskInputPlugin.PluginTask task;
35
+
36
+ private ZendeskRestClient zendeskRestClient;
37
+
38
+ protected ZendeskNormalServices(final ZendeskInputPlugin.PluginTask task)
39
+ {
40
+ this.task = task;
41
+ }
42
+
43
+ public TaskReport addRecordToImporter(final int taskIndex, final RecordImporter recordImporter)
44
+ {
45
+ TaskReport taskReport = Exec.newTaskReport();
46
+
47
+ if (isSupportIncremental()) {
48
+ importDataForIncremental(task, recordImporter, taskReport);
49
+ }
50
+ else {
51
+ importDataForNonIncremental(task, taskIndex, recordImporter);
52
+ }
53
+
54
+ return taskReport;
55
+ }
56
+
57
+ public JsonNode getDataFromPath(String path, final int page, final boolean isPreview, final long startTime)
58
+ {
59
+ if (path.isEmpty()) {
60
+ path = buildURI(page, startTime);
61
+ }
62
+
63
+ final String response = getZendeskRestClient().doGet(path, task, isPreview);
64
+ return ZendeskUtils.parseJsonObject(response);
65
+ }
66
+
67
+ protected abstract String buildURI(int page, long startTime);
68
+
69
+ @VisibleForTesting
70
+ protected ZendeskRestClient getZendeskRestClient()
71
+ {
72
+ if (zendeskRestClient == null) {
73
+ zendeskRestClient = new ZendeskRestClient();
74
+ }
75
+ return zendeskRestClient;
76
+ }
77
+
78
+ private void importDataForIncremental(final ZendeskInputPlugin.PluginTask task, final RecordImporter recordImporter, final TaskReport taskReport)
79
+ {
80
+ long startTime = 0;
81
+
82
+ if (task.getStartTime().isPresent()) {
83
+ startTime = ZendeskDateUtils.getStartTime(task.getStartTime().get());
84
+ }
85
+
86
+ // For incremental target, we will run in one task but split in multiple threads inside for data deduplication.
87
+ // Run with incremental will contain duplicated data.
88
+ ThreadPoolExecutor pool = null;
89
+ try {
90
+ final Set<String> knownIds = ConcurrentHashMap.newKeySet();
91
+ pool = new ThreadPoolExecutor(
92
+ 10, 100, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()
93
+ );
94
+
95
+ while (true) {
96
+ int recordCount = 0;
97
+
98
+ // Page argument isn't used in incremental API so we just set it to 0
99
+ final JsonNode result = getDataFromPath("", 0, false, startTime);
100
+ final Iterator<JsonNode> iterator = ZendeskUtils.getListRecords(result, task.getTarget().getJsonName());
101
+
102
+ int numberOfRecords = 0;
103
+ if (result.has(ZendeskConstants.Field.COUNT)) {
104
+ numberOfRecords = result.get(ZendeskConstants.Field.COUNT).asInt();
105
+ }
106
+
107
+ while (iterator.hasNext()) {
108
+ final JsonNode recordJsonNode = iterator.next();
109
+
110
+ if (isUpdatedBySystem(recordJsonNode, startTime)) {
111
+ continue;
112
+ }
113
+
114
+ if (task.getDedup()) {
115
+ final String recordID = recordJsonNode.get(ZendeskConstants.Field.ID).asText();
116
+
117
+ // add success -> no duplicate
118
+ if (!knownIds.add(recordID)) {
119
+ continue;
120
+ }
121
+ }
122
+
123
+ pool.submit(() -> fetchSubResourceAndAddToImporter(recordJsonNode, task, recordImporter));
124
+ recordCount++;
125
+ if (Exec.isPreview()) {
126
+ return;
127
+ }
128
+ }
129
+
130
+ logger.info("Fetched '{}' records from start_time '{}'", recordCount, startTime);
131
+
132
+ // https://developer.zendesk.com/rest_api/docs/support/incremental_export#pagination
133
+ // When there are more than 1000 records share the same time stamp, the count > 1000
134
+ long apiEndTime = result.get(ZendeskConstants.Field.END_TIME).asLong();
135
+ startTime = startTime == apiEndTime
136
+ ? apiEndTime + 1
137
+ : apiEndTime;
138
+
139
+ if (numberOfRecords < ZendeskConstants.Misc.MAXIMUM_RECORDS_INCREMENTAL) {
140
+ break;
141
+ }
142
+ }
143
+
144
+ if (!Exec.isPreview()) {
145
+ storeStartTimeForConfigDiff(taskReport, startTime);
146
+ }
147
+ }
148
+ finally {
149
+ if (pool != null) {
150
+ pool.shutdown();
151
+ try {
152
+ pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
153
+ }
154
+ catch (final InterruptedException e) {
155
+ logger.warn("Error when wait pool to finish");
156
+ throw Throwables.propagate(e);
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ private void storeStartTimeForConfigDiff(final TaskReport taskReport, final long resultEndTime)
163
+ {
164
+ if (task.getIncremental()) {
165
+ // resultEndTime = 0 mean no records, we should store now time for next run
166
+ if (resultEndTime == 0) {
167
+ taskReport.set(ZendeskConstants.Field.START_TIME, Instant.now().getEpochSecond());
168
+ }
169
+ else {
170
+ // NOTE: start_time compared as "=>", not ">".
171
+ // If we will use end_time for next start_time, we got the same records that are fetched
172
+ // end_time + 1 is workaround for that
173
+ taskReport.set(ZendeskConstants.Field.START_TIME, resultEndTime + 1);
174
+ }
175
+ }
176
+ }
177
+
178
+ private void fetchSubResourceAndAddToImporter(final JsonNode jsonNode, final ZendeskInputPlugin.PluginTask task, final RecordImporter recordImporter)
179
+ {
180
+ task.getIncludes().forEach(include -> {
181
+ final String relatedObjectName = include.trim();
182
+
183
+ final URIBuilder uriBuilder = ZendeskUtils.getURIBuilder(task.getLoginUrl())
184
+ .setPath(ZendeskConstants.Url.API
185
+ + "/" + task.getTarget().toString()
186
+ + "/" + jsonNode.get(ZendeskConstants.Field.ID).asText()
187
+ + "/" + relatedObjectName + ".json");
188
+ try {
189
+ final JsonNode result = getDataFromPath(uriBuilder.toString(), 0, false, 0);
190
+ if (result != null && result.has(relatedObjectName)) {
191
+ ((ObjectNode) jsonNode).set(include, result.get(relatedObjectName));
192
+ }
193
+ }
194
+ catch (final ConfigException e) {
195
+ // Sometimes we get 404 when having invalid endpoint, so ignore when we get 404 InvalidEndpoint
196
+ if (!(e.getCause() instanceof ZendeskException && ((ZendeskException) e.getCause()).getStatusCode() == HttpStatus.SC_NOT_FOUND)) {
197
+ throw e;
198
+ }
199
+ }
200
+ });
201
+
202
+ recordImporter.addRecord(jsonNode);
203
+ }
204
+
205
+ private boolean isUpdatedBySystem(final JsonNode recordJsonNode, final long startTime)
206
+ {
207
+ /*
208
+ * https://developer.zendesk.com/rest_api/docs/core/incremental_export#excluding-system-updates
209
+ * "generated_timestamp" will be updated when Zendesk internal changing
210
+ * "updated_at" will be updated when ticket data was changed
211
+ * start_time for query parameter will be processed on Zendesk with generated_timestamp,
212
+ * but it was calculated by record' updated_at time.
213
+ * So the doesn't changed record from previous import would be appear by Zendesk internal changes.
214
+ */
215
+ if (recordJsonNode.has(ZendeskConstants.Field.GENERATED_TIMESTAMP) && recordJsonNode.has(ZendeskConstants.Field.UPDATED_AT)) {
216
+ final String recordUpdatedAtTime = recordJsonNode.get(ZendeskConstants.Field.UPDATED_AT).asText();
217
+ final long recordUpdatedAtToEpochSecond = ZendeskDateUtils.isoToEpochSecond(recordUpdatedAtTime);
218
+
219
+ return recordUpdatedAtToEpochSecond <= startTime;
220
+ }
221
+
222
+ return false;
223
+ }
224
+
225
+ private void importDataForNonIncremental(final ZendeskInputPlugin.PluginTask task, final int taskIndex, RecordImporter recordImporter)
226
+ {
227
+ // Page start from 1 => page = taskIndex + 1
228
+ final JsonNode result = getDataFromPath("", taskIndex + 1, false, 0);
229
+ final Iterator<JsonNode> iterator = ZendeskUtils.getListRecords(result, task.getTarget().getJsonName());
230
+
231
+ while (iterator.hasNext()) {
232
+ fetchSubResourceAndAddToImporter(iterator.next(), task, recordImporter);
233
+
234
+ if (Exec.isPreview()) {
235
+ break;
236
+ }
237
+ }
238
+ }
239
+ }