embulk-output-mailchimp 0.3.20 → 0.3.21

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c279827f48671c120b7574d8de2c05669014e4fe
4
- data.tar.gz: 96c677aa514f29b5588bc0d901383cda9a84b7c4
3
+ metadata.gz: 504301e8e2482b1d046fb9a880ab63a631dac2ca
4
+ data.tar.gz: 053d271506d3a0dcb9020466ed703ccfe4d17469
5
5
  SHA512:
6
- metadata.gz: ea72fabb9264611f2146d3c64e540f7179ce76298701b29bb830417fb25dfede981353c433fc8731cc81c1b0415aa6ad8dda18bc3cab30602419f7001237b508
7
- data.tar.gz: 1c939eb9fd3558cefa4234d07961b7c25e5aa943f1f6cf59519e8191a7abda2376d72f3453e9d71f0ff79ac7baae27af7ee87c4bac434d2ddd850e17d1c309a4
6
+ metadata.gz: c08f3cfe6e2de7f6c2c35b04dac4f50b99ab1457c285e7ba42d8c6aed854d5922e640b3e1c089039f8453048b22996bd52e0a1fb689e9b7af8cab4e5fd50954c
7
+ data.tar.gz: d5dca1a6c14f1c992fe0f3e34a987052e6aa0ee8682bfa6114c0d510fcb790e5945e9d8e46091b7184afab7279143f08fb7fb5f9690724d612d9ec803f0908ea
data/CHANGELOG.md CHANGED
@@ -1,3 +1,6 @@
1
+ ## 0.3.21 - 2018-03-02
2
+ - Refactor code to improve performance and fix NPE with merge fields that contain case sensitive [#40](https://github.com/treasure-data/embulk-output-mailchimp/pull/40)
3
+
1
4
  ## 0.3.20 - 2017-10-16
2
5
  - Added pagination for interest categories [#39](https://github.com/treasure-data/embulk-output-mailchimp/pull/39)
3
6
  - Refactor check list id to avoid confusing when get 404 error [#38](https://github.com/treasure-data/embulk-output-mailchimp/pull/38)
data/README.md CHANGED
@@ -26,6 +26,7 @@ add e-mail to List in MailChimp.
26
26
  - **language_column**: column name for language (string, optional, default: nil)
27
27
  - **double_optin**: control whether to send an opt-in confirmation email (boolean, default: true)
28
28
  - **max_records_per_request**: The max records per batch request. MailChimp API enables max records is 500 per batch request (int, default: 500)
29
+ - **sleep_between_requests_millis**: The time to sleep between requests to avoid flood MailChimp API (int, default: 3000)
29
30
 
30
31
  ## Example
31
32
 
@@ -51,5 +52,5 @@ out:
51
52
  ## Build
52
53
 
53
54
  ```
54
- $ rake
55
+ $ ./gradlew gem
55
56
  ```
data/build.gradle CHANGED
@@ -18,22 +18,21 @@ configurations {
18
18
  provided
19
19
  }
20
20
 
21
- version = "0.3.20"
21
+ version = "0.3.21"
22
22
 
23
23
  sourceCompatibility = 1.7
24
24
  targetCompatibility = 1.7
25
25
 
26
26
  dependencies {
27
- compile "org.embulk:embulk-core:0.8.15"
28
- provided "org.embulk:embulk-core:0.8.15"
27
+ compile "org.embulk:embulk-core:0.8.25"
28
+ provided "org.embulk:embulk-core:0.8.25"
29
29
  compile "org.embulk.base.restclient:embulk-base-restclient:0.5.3"
30
30
  compile "org.embulk.base.restclient:embulk-util-retryhelper-jetty92:0.5.3"
31
- // compile "YOUR_JAR_DEPENDENCY_GROUP:YOUR_JAR_DEPENDENCY_MODULE:YOUR_JAR_DEPENDENCY_VERSION"
32
31
 
33
32
  testCompile "junit:junit:4.+"
34
- testCompile "org.embulk:embulk-core:0.8.15:tests"
35
- testCompile "org.embulk:embulk-test:0.8.15"
36
- testCompile "org.embulk:embulk-standards:0.8.15"
33
+ testCompile "org.embulk:embulk-core:0.8.25:tests"
34
+ testCompile "org.embulk:embulk-test:0.8.25"
35
+ testCompile "org.embulk:embulk-standards:0.8.25"
37
36
  testCompile "org.mockito:mockito-core:2.+"
38
37
  }
39
38
 
@@ -5,18 +5,17 @@ import com.fasterxml.jackson.core.JsonProcessingException;
5
5
  import com.fasterxml.jackson.databind.DeserializationFeature;
6
6
  import com.fasterxml.jackson.databind.JsonNode;
7
7
  import com.fasterxml.jackson.databind.ObjectMapper;
8
- import com.fasterxml.jackson.databind.node.MissingNode;
9
8
  import com.fasterxml.jackson.databind.node.ObjectNode;
10
9
  import com.google.common.base.Function;
11
- import com.google.common.base.Joiner;
12
10
  import com.google.common.collect.FluentIterable;
13
11
  import com.google.common.collect.ImmutableList;
14
12
  import com.google.common.collect.Maps;
15
13
  import org.eclipse.jetty.client.HttpResponseException;
16
- import org.eclipse.jetty.http.HttpMethod;
14
+ import org.embulk.base.restclient.jackson.StringJsonParser;
17
15
  import org.embulk.config.ConfigException;
18
16
  import org.embulk.output.mailchimp.MailChimpOutputPluginDelegate.PluginTask;
19
17
  import org.embulk.output.mailchimp.helper.MailChimpHelper;
18
+ import org.embulk.output.mailchimp.helper.MailChimpRetryable;
20
19
  import org.embulk.output.mailchimp.model.CategoriesResponse;
21
20
  import org.embulk.output.mailchimp.model.ErrorResponse;
22
21
  import org.embulk.output.mailchimp.model.InterestCategoriesResponse;
@@ -24,7 +23,6 @@ import org.embulk.output.mailchimp.model.InterestResponse;
24
23
  import org.embulk.output.mailchimp.model.InterestsResponse;
25
24
  import org.embulk.output.mailchimp.model.MergeField;
26
25
  import org.embulk.output.mailchimp.model.MergeFields;
27
- import org.embulk.output.mailchimp.model.MetaDataResponse;
28
26
  import org.embulk.output.mailchimp.model.ReportResponse;
29
27
  import org.embulk.spi.DataException;
30
28
  import org.embulk.spi.Exec;
@@ -38,19 +36,16 @@ import java.util.HashMap;
38
36
  import java.util.List;
39
37
  import java.util.Map;
40
38
 
41
- import static org.embulk.output.mailchimp.model.AuthMethod.API_KEY;
42
- import static org.embulk.output.mailchimp.model.AuthMethod.OAUTH;
43
-
44
39
  /**
45
40
  * Created by thangnc on 4/25/17.
46
41
  */
47
42
  public class MailChimpClient
48
43
  {
49
44
  private static final Logger LOG = Exec.getLogger(MailChimpClient.class);
50
- private static final String API_VERSION = "3.0";
51
- private static String mailchimpEndpoint;
52
- private MailChimpHttpClient client;
53
- private final ObjectMapper mapper;
45
+ private final ObjectMapper mapper = new ObjectMapper()
46
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
47
+ .configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, false);
48
+ private StringJsonParser jsonParser = new StringJsonParser();
54
49
 
55
50
  /**
56
51
  * Instantiates a new Mail chimp client.
@@ -59,12 +54,6 @@ public class MailChimpClient
59
54
  */
60
55
  public MailChimpClient(final PluginTask task)
61
56
  {
62
- mailchimpEndpoint = Joiner.on("/").join("https://{0}.api.mailchimp.com", API_VERSION);
63
- this.client = new MailChimpHttpClient(task);
64
- this.mapper = new ObjectMapper()
65
- .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
66
- .configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, false);
67
- extractDataCenter(task);
68
57
  findList(task);
69
58
  }
70
59
 
@@ -75,24 +64,19 @@ public class MailChimpClient
75
64
  * @param node the data
76
65
  * @param task the task
77
66
  * @return the report response
78
- * @throws JsonProcessingException the json processing exception
79
67
  */
80
- ReportResponse push(final ObjectNode node, PluginTask task)
81
- throws JsonProcessingException
68
+ public ReportResponse push(final ObjectNode node, PluginTask task) throws JsonProcessingException
82
69
  {
83
- String endpoint = MessageFormat.format(mailchimpEndpoint + "/lists/{0}",
84
- task.getListId());
85
-
86
- JsonNode response = client.sendRequest(endpoint, HttpMethod.POST, node.toString(), task);
87
- client.avoidFlushAPI("Process next request");
70
+ try (MailChimpRetryable retryable = new MailChimpRetryable(task)) {
71
+ String response = retryable.post(MessageFormat.format("/lists/{0}", task.getListId()),
72
+ "application/json;utf-8",
73
+ node.toString());
74
+ if (response != null && !response.isEmpty()) {
75
+ return mapper.treeToValue(jsonParser.parseJsonObject(response), ReportResponse.class);
76
+ }
88
77
 
89
- if (response instanceof MissingNode) {
90
- ReportResponse reportResponse = new ReportResponse();
91
- reportResponse.setErrors(new ArrayList<ErrorResponse>());
92
- return reportResponse;
78
+ throw new DataException("The json data in response were broken.");
93
79
  }
94
-
95
- return mapper.treeToValue(response, ReportResponse.class);
96
80
  }
97
81
 
98
82
  /**
@@ -100,7 +84,7 @@ public class MailChimpClient
100
84
  *
101
85
  * @param errorResponses the error responses
102
86
  */
103
- void handleErrors(List<ErrorResponse> errorResponses)
87
+ public void handleErrors(List<ErrorResponse> errorResponses)
104
88
  {
105
89
  if (!errorResponses.isEmpty()) {
106
90
  StringBuilder errorMessage = new StringBuilder();
@@ -125,76 +109,77 @@ public class MailChimpClient
125
109
  * @return the map
126
110
  * @throws JsonProcessingException the json processing exception
127
111
  */
128
- Map<String, Map<String, InterestResponse>> extractInterestCategoriesByGroupNames(final PluginTask task)
112
+ public Map<String, Map<String, InterestResponse>> extractInterestCategoriesByGroupNames(final PluginTask task)
129
113
  throws JsonProcessingException
130
114
  {
131
- Map<String, Map<String, InterestResponse>> categories = new HashMap<>();
132
- if (task.getGroupingColumns().isPresent() && !task.getGroupingColumns().get().isEmpty()) {
133
- List<String> interestCategoryNames = task.getGroupingColumns().get();
134
-
135
- int count = 100;
136
- int offset = 0;
137
- int page = 1;
138
- boolean hasMore = true;
139
- JsonNode response;
140
- List<CategoriesResponse> allCategoriesResponse = new ArrayList<>();
141
-
142
- while (hasMore) {
143
- String endpoint = MessageFormat.format(mailchimpEndpoint + "/lists/{0}/interest-categories?count={1}&offset={2}",
115
+ try (MailChimpRetryable retryable = new MailChimpRetryable(task)) {
116
+ Map<String, Map<String, InterestResponse>> categories = new HashMap<>();
117
+ if (task.getGroupingColumns().isPresent() && !task.getGroupingColumns().get().isEmpty()) {
118
+ List<String> interestCategoryNames = task.getGroupingColumns().get();
119
+
120
+ int count = 100;
121
+ int offset = 0;
122
+ int page = 1;
123
+ boolean hasMore = true;
124
+ JsonNode response;
125
+ List<CategoriesResponse> allCategoriesResponse = new ArrayList<>();
126
+
127
+ while (hasMore) {
128
+ String path = MessageFormat.format("/lists/{0}/interest-categories?count={1}&offset={2}",
144
129
  task.getListId(),
145
130
  count,
146
131
  offset);
147
-
148
- response = client.sendRequest(endpoint, HttpMethod.GET, task);
149
- InterestCategoriesResponse interestCategoriesResponse = mapper.treeToValue(response,
150
- InterestCategoriesResponse.class);
151
-
152
- allCategoriesResponse.addAll(interestCategoriesResponse.getCategories());
153
- if (hasMorePage(interestCategoriesResponse.getTotalItems(), count, page)) {
154
- offset = count;
155
- page++;
156
- }
157
- else {
158
- hasMore = false;
132
+ response = jsonParser.parseJsonObject(retryable.get(path));
133
+ InterestCategoriesResponse interestCategoriesResponse = mapper.treeToValue(response,
134
+ InterestCategoriesResponse.class);
135
+
136
+ allCategoriesResponse.addAll(interestCategoriesResponse.getCategories());
137
+ if (hasMorePage(interestCategoriesResponse.getTotalItems(), count, page)) {
138
+ offset = count;
139
+ page++;
140
+ }
141
+ else {
142
+ hasMore = false;
143
+ }
159
144
  }
160
- }
161
145
 
162
- Function<CategoriesResponse, String> function = new Function<CategoriesResponse, String>()
163
- {
164
- @Override
165
- public String apply(CategoriesResponse input)
146
+ Function<CategoriesResponse, String> function = new Function<CategoriesResponse, String>()
166
147
  {
167
- return input.getTitle().toLowerCase();
148
+ @Override
149
+ public String apply(CategoriesResponse input)
150
+ {
151
+ return input.getTitle().toLowerCase();
152
+ }
153
+ };
154
+
155
+ // Transform to a list of available category names and validate with data that user input
156
+ ImmutableList<String> availableCategories = FluentIterable
157
+ .from(allCategoriesResponse)
158
+ .transform(function)
159
+ .toList();
160
+
161
+ for (String category : interestCategoryNames) {
162
+ if (!availableCategories.contains(category)) {
163
+ throw new ConfigException("Invalid interest category name: '" + category + "'");
164
+ }
168
165
  }
169
- };
170
-
171
- // Transform to a list of available category names and validate with data that user input
172
- ImmutableList<String> availableCategories = FluentIterable
173
- .from(allCategoriesResponse)
174
- .transform(function)
175
- .toList();
176
166
 
177
- for (String category : interestCategoryNames) {
178
- if (!availableCategories.contains(category)) {
179
- throw new ConfigException("Invalid interest category name: '" + category + "'");
180
- }
181
- }
182
-
183
- for (CategoriesResponse categoriesResponse : allCategoriesResponse) {
184
- String detailEndpoint = MessageFormat.format(mailchimpEndpoint + "/lists/{0}/interest-categories/{1}/interests",
167
+ for (CategoriesResponse categoriesResponse : allCategoriesResponse) {
168
+ String detailPath = MessageFormat.format("/lists/{0}/interest-categories/{1}/interests",
185
169
  task.getListId(),
186
170
  categoriesResponse.getId());
187
- response = client.sendRequest(detailEndpoint, HttpMethod.GET, task);
171
+ response = jsonParser.parseJsonObject(retryable.get(detailPath));
188
172
 
189
- // Avoid flush MailChimp API
190
- client.avoidFlushAPI("Fetching next category's interests");
191
- InterestsResponse interestsResponse = mapper.treeToValue(response, InterestsResponse.class);
192
- categories.put(categoriesResponse.getTitle().toLowerCase(),
193
- convertInterestCategoryToMap(interestsResponse.getInterests()));
173
+ // Avoid flood MailChimp API
174
+ avoidFloodAPI("Fetching next category's interests", task.getSleepBetweenRequestsMillis());
175
+ InterestsResponse interestsResponse = mapper.treeToValue(response, InterestsResponse.class);
176
+ categories.put(categoriesResponse.getTitle().toLowerCase(),
177
+ convertInterestCategoryToMap(interestsResponse.getInterests()));
178
+ }
194
179
  }
195
- }
196
180
 
197
- return categories;
181
+ return categories;
182
+ }
198
183
  }
199
184
 
200
185
  /**
@@ -205,76 +190,45 @@ public class MailChimpClient
205
190
  * @return the map
206
191
  * @throws JsonProcessingException the json processing exception
207
192
  */
208
- Map<String, MergeField> extractMergeFieldsFromList(PluginTask task) throws JsonProcessingException
193
+ public Map<String, MergeField> extractMergeFieldsFromList(PluginTask task) throws JsonProcessingException
209
194
  {
210
- int count = 100;
211
- int offset = 0;
212
- int page = 1;
213
- boolean hasMore = true;
214
- List<MergeField> allMergeFields = new ArrayList<>();
215
-
216
- while (hasMore) {
217
- String endpoint = MessageFormat.format(mailchimpEndpoint + "/lists/{0}/merge-fields?count={1}&offset={2}",
195
+ try (MailChimpRetryable retryable = new MailChimpRetryable(task)) {
196
+ int count = 100;
197
+ int offset = 0;
198
+ int page = 1;
199
+ boolean hasMore = true;
200
+ List<MergeField> allMergeFields = new ArrayList<>();
201
+
202
+ while (hasMore) {
203
+ String path = MessageFormat.format("/lists/{0}/merge-fields?count={1}&offset={2}",
218
204
  task.getListId(),
219
205
  count,
220
206
  offset);
221
207
 
222
- JsonNode response = client.sendRequest(endpoint, HttpMethod.GET, task);
223
- MergeFields mergeFields = mapper.treeToValue(response,
224
- MergeFields.class);
208
+ JsonNode response = jsonParser.parseJsonObject(retryable.get(path));
209
+ MergeFields mergeFields = mapper.treeToValue(response,
210
+ MergeFields.class);
225
211
 
226
- allMergeFields.addAll(mergeFields.getMergeFields());
212
+ allMergeFields.addAll(mergeFields.getMergeFields());
227
213
 
228
- if (hasMorePage(mergeFields.getTotalItems(), count, page)) {
229
- offset = count;
230
- page++;
231
- }
232
- else {
233
- hasMore = false;
234
- }
235
- }
236
-
237
- return convertMergeFieldToMap(allMergeFields);
238
- }
239
-
240
- private void extractDataCenter(final PluginTask task)
241
- {
242
- if (task.getAuthMethod() == OAUTH) {
243
- // Extract data center from meta data URL
244
- JsonNode response = client.sendRequest("https://login.mailchimp.com/oauth2/metadata", HttpMethod.GET, task);
245
- MetaDataResponse metaDataResponse;
246
- try {
247
- metaDataResponse = mapper.treeToValue(response, MetaDataResponse.class);
248
- mailchimpEndpoint = MessageFormat.format(mailchimpEndpoint, metaDataResponse.getDc());
249
- }
250
- catch (JsonProcessingException e) {
251
- throw new DataException(e);
252
- }
253
- }
254
- else if (task.getAuthMethod() == API_KEY && task.getApikey().isPresent()) {
255
- // Authenticate and return data center
256
- if (!task.getApikey().get().contains("-")) {
257
- throw new ConfigException("API Key format invalid.");
214
+ if (hasMorePage(mergeFields.getTotalItems(), count, page)) {
215
+ offset = count;
216
+ page++;
217
+ }
218
+ else {
219
+ hasMore = false;
220
+ }
258
221
  }
259
222
 
260
- String domain = task.getApikey().get().split("-")[1];
261
- String endpoint = MessageFormat.format(mailchimpEndpoint + "/", domain);
262
- try {
263
- client.sendRequest(endpoint, HttpMethod.GET, task);
264
- mailchimpEndpoint = MessageFormat.format(mailchimpEndpoint, domain);
265
- }
266
- catch (HttpResponseException re) {
267
- throw new ConfigException("Your API key may be invalid, or you've attempted to access the wrong datacenter.");
268
- }
223
+ return convertMergeFieldToMap(allMergeFields);
269
224
  }
270
225
  }
271
226
 
272
227
  private void findList(final PluginTask task)
273
228
  {
274
- String endpoint = MessageFormat.format(mailchimpEndpoint + "/lists/{0}",
275
- task.getListId());
276
- try {
277
- client.sendRequest(endpoint, HttpMethod.GET, task);
229
+ try (MailChimpRetryable retryable = new MailChimpRetryable(task)) {
230
+ jsonParser.parseJsonObject(retryable.get(MessageFormat.format("/lists/{0}",
231
+ task.getListId())));
278
232
  }
279
233
  catch (HttpResponseException hre) {
280
234
  throw new ConfigException("The `list id` could not be found.");
@@ -319,4 +273,15 @@ public class MailChimpClient
319
273
  int totalPage = count / pageSize + (count % pageSize > 0 ? 1 : 0);
320
274
  return page < totalPage;
321
275
  }
276
+
277
+ public void avoidFloodAPI(final String reason, final long millis)
278
+ {
279
+ try {
280
+ LOG.info("{} in {}ms...", reason, millis);
281
+ Thread.sleep(millis);
282
+ }
283
+ catch (InterruptedException e) {
284
+ LOG.warn("Failed to sleep: {}", e.getMessage());
285
+ }
286
+ }
322
287
  }
@@ -107,6 +107,10 @@ public class MailChimpOutputPluginDelegate
107
107
  @Config("max_records_per_request")
108
108
  @ConfigDefault("500")
109
109
  int getMaxRecordsPerRequest();
110
+
111
+ @Config("sleep_between_requests_millis")
112
+ @ConfigDefault("3000")
113
+ int getSleepBetweenRequestsMillis();
110
114
  }
111
115
 
112
116
  /**
@@ -129,6 +133,10 @@ public class MailChimpOutputPluginDelegate
129
133
  if (!task.getApikey().isPresent() || isNullOrEmpty(task.getApikey().get())) {
130
134
  throw new ConfigException("'apikey' is required when auth_method is 'api_key'");
131
135
  }
136
+
137
+ if (!task.getApikey().get().contains("-")) {
138
+ throw new ConfigException("apikey's format invalid.");
139
+ }
132
140
  }
133
141
 
134
142
  if (isNullOrEmpty(task.getListId())) {
@@ -150,7 +150,7 @@ public class MailChimpRecordBuffer
150
150
  @Override
151
151
  public void close()
152
152
  {
153
- // Do not implement
153
+ // // Do not close here
154
154
  }
155
155
 
156
156
  /**
@@ -165,10 +165,14 @@ public class MailChimpRecordBuffer
165
165
  {
166
166
  // Should loop the names and get the id of interest categories.
167
167
  // The reason why we put categories validation here because we can not share data between instance.
168
- categories = mailChimpClient.extractInterestCategoriesByGroupNames(task);
168
+ if (categories == null) {
169
+ categories = mailChimpClient.extractInterestCategoriesByGroupNames(task);
170
+ }
169
171
 
170
172
  // Extract merge fields detail
171
- availableMergeFields = mailChimpClient.extractMergeFieldsFromList(task);
173
+ if (availableMergeFields == null) {
174
+ availableMergeFields = mailChimpClient.extractMergeFieldsFromList(task);
175
+ }
172
176
 
173
177
  // Required merge fields
174
178
  Map<String, String> map = new HashMap<>();
@@ -208,7 +212,7 @@ public class MailChimpRecordBuffer
208
212
  String value = input.hasNonNull(column.getName()) ? input.findValue(column.getName()).asText() : "";
209
213
 
210
214
  // Try to convert to Json from string with the merge field's type is address
211
- if (availableMergeFields.get(column.getName()).getType()
215
+ if (availableMergeFields.get(column.getName().toLowerCase()).getType()
212
216
  .equals(MergeField.MergeFieldType.ADDRESS.getType())) {
213
217
  JsonNode addressNode = toJsonNode(value);
214
218
  if (addressNode instanceof NullNode) {
@@ -243,31 +247,32 @@ public class MailChimpRecordBuffer
243
247
  };
244
248
  }
245
249
 
246
- private ObjectNode buildInterestCategories(final MailChimpOutputPluginDelegate.PluginTask task,
247
- final JsonNode input)
250
+ private ObjectNode buildInterestCategories(final PluginTask task, final JsonNode input)
248
251
  {
249
252
  ObjectNode interests = JsonNodeFactory.instance.objectNode();
250
253
 
251
- for (String category : task.getGroupingColumns().get()) {
252
- String inputValue = input.findValue(category).asText();
253
- List<String> interestValues = fromCommaSeparatedString(inputValue);
254
- Map<String, InterestResponse> availableCategories = categories.get(category);
255
-
256
- // Only update user-predefined categories if replace interests != true
257
- if (!task.getReplaceInterests()) {
258
- for (String interestValue : interestValues) {
259
- if (availableCategories.get(interestValue) != null) {
260
- interests.put(availableCategories.get(interestValue).getId(), true);
261
- }
262
- }
263
- } // Otherwise, force update all categories include user-predefined categories
264
- else if (task.getReplaceInterests()) {
265
- for (String availableCategory : availableCategories.keySet()) {
266
- if (interestValues.contains(availableCategory)) {
267
- interests.put(availableCategories.get(availableCategory).getId(), true);
254
+ if (task.getGroupingColumns().isPresent()) {
255
+ for (String category : task.getGroupingColumns().get()) {
256
+ String inputValue = input.findValue(category).asText();
257
+ List<String> interestValues = fromCommaSeparatedString(inputValue);
258
+ Map<String, InterestResponse> availableCategories = categories.get(category);
259
+
260
+ // Only update user-predefined categories if replace interests != true
261
+ if (!task.getReplaceInterests()) {
262
+ for (String interestValue : interestValues) {
263
+ if (availableCategories.get(interestValue) != null) {
264
+ interests.put(availableCategories.get(interestValue).getId(), true);
265
+ }
268
266
  }
269
- else {
270
- interests.put(availableCategories.get(availableCategory).getId(), false);
267
+ } // Otherwise, force update all categories include user-predefined categories
268
+ else if (task.getReplaceInterests()) {
269
+ for (String availableCategory : availableCategories.keySet()) {
270
+ if (interestValues.contains(availableCategory)) {
271
+ interests.put(availableCategories.get(availableCategory).getId(), true);
272
+ }
273
+ else {
274
+ interests.put(availableCategories.get(availableCategory).getId(), false);
275
+ }
271
276
  }
272
277
  }
273
278
  }
@@ -302,6 +307,8 @@ public class MailChimpRecordBuffer
302
307
  reportResponse.getErrorCount(), System.currentTimeMillis() - startTime);
303
308
  mailChimpClient.handleErrors(reportResponse.getErrors());
304
309
 
310
+ mailChimpClient.avoidFloodAPI("Process next request", task.getSleepBetweenRequestsMillis());
311
+
305
312
  if (duplicatedRecords.size() > 0) {
306
313
  LOG.info("Start to process {} duplicated record(s)", duplicatedRecords.size());
307
314
  for (JsonNode duplicatedRecord : duplicatedRecords) {
@@ -0,0 +1,224 @@
1
+ package org.embulk.output.mailchimp.helper;
2
+
3
+ import com.fasterxml.jackson.core.Base64Variants;
4
+ import com.fasterxml.jackson.databind.node.ObjectNode;
5
+ import com.google.common.base.Charsets;
6
+ import com.google.common.base.Joiner;
7
+ import org.eclipse.jetty.client.HttpClient;
8
+ import org.eclipse.jetty.client.HttpResponseException;
9
+ import org.eclipse.jetty.client.api.ContentResponse;
10
+ import org.eclipse.jetty.client.api.Request;
11
+ import org.eclipse.jetty.client.api.Response;
12
+ import org.eclipse.jetty.client.util.StringContentProvider;
13
+ import org.embulk.base.restclient.jackson.StringJsonParser;
14
+ import org.embulk.config.ConfigException;
15
+ import org.embulk.output.mailchimp.MailChimpOutputPluginDelegate.PluginTask;
16
+ import org.embulk.spi.Exec;
17
+ import org.embulk.util.retryhelper.jetty92.DefaultJetty92ClientCreator;
18
+ import org.embulk.util.retryhelper.jetty92.Jetty92RetryHelper;
19
+ import org.embulk.util.retryhelper.jetty92.Jetty92SingleRequester;
20
+ import org.embulk.util.retryhelper.jetty92.StringJetty92ResponseEntityReader;
21
+ import org.slf4j.Logger;
22
+
23
+ import java.text.MessageFormat;
24
+ import java.util.concurrent.ExecutionException;
25
+ import java.util.concurrent.TimeoutException;
26
+
27
+ import static org.eclipse.jetty.http.HttpHeader.AUTHORIZATION;
28
+ import static org.eclipse.jetty.http.HttpMethod.GET;
29
+ import static org.eclipse.jetty.http.HttpMethod.POST;
30
+ import static org.embulk.output.mailchimp.model.AuthMethod.API_KEY;
31
+ import static org.embulk.output.mailchimp.model.AuthMethod.OAUTH;
32
+
33
+ public class MailChimpRetryable implements AutoCloseable
34
+ {
35
+ private static final Logger LOG = Exec.getLogger(MailChimpRetryable.class);
36
+ private static final int READER_TIMEOUT_MILLIS = 300000;
37
+ private static final String API_VERSION = "3.0";
38
+ private final Jetty92RetryHelper retryHelper;
39
+ private final PluginTask pluginTask;
40
+ private static TokenHolder tokenHolder;
41
+ protected StringJsonParser jsonParser = new StringJsonParser();
42
+ private String authorizationHeader;
43
+
44
+ public MailChimpRetryable(final PluginTask pluginTask)
45
+ {
46
+ this.retryHelper = new Jetty92RetryHelper(pluginTask.getMaximumRetries(),
47
+ pluginTask.getInitialRetryIntervalMillis(),
48
+ pluginTask.getMaximumRetryIntervalMillis(),
49
+ new DefaultJetty92ClientCreator(pluginTask.getTimeoutMillis(),
50
+ pluginTask.getTimeoutMillis()));
51
+ this.pluginTask = pluginTask;
52
+ authorizationHeader = buildAuthorizationHeader(pluginTask);
53
+ }
54
+
55
+ public String get(final String path)
56
+ {
57
+ return sendRequest(path, null);
58
+ }
59
+
60
+ public String post(final String path, String contentType, String body)
61
+ {
62
+ return sendRequest(path, new StringContentProvider(contentType, body, Charsets.UTF_8));
63
+ }
64
+
65
+ private String sendRequest(final String path, final StringContentProvider contentProvider)
66
+ {
67
+ return retryHelper.requestWithRetry(
68
+ new StringJetty92ResponseEntityReader(READER_TIMEOUT_MILLIS),
69
+ new Jetty92SingleRequester()
70
+ {
71
+ @Override
72
+ public void requestOnce(HttpClient client, Response.Listener responseListener)
73
+ {
74
+ createTokenHolder(client);
75
+ Request request = client.newRequest(tokenHolder.getEndpoint() + path)
76
+ .header(AUTHORIZATION, authorizationHeader)
77
+ .method(GET);
78
+ if (contentProvider != null) {
79
+ request = request.method(POST).content(contentProvider);
80
+ }
81
+ request.send(responseListener);
82
+ }
83
+
84
+ @Override
85
+ protected boolean isResponseStatusToRetry(Response response)
86
+ {
87
+ // Retry if it's a server or rate limit exceeded error
88
+ return (response.getStatus() != 500 && response.getStatus() / 100 != 4) || response.getStatus() == 429;
89
+ }
90
+
91
+ @Override
92
+ protected boolean isExceptionToRetry(Exception exception)
93
+ {
94
+ // This check is to make sure if the original exception is retryable, i.e.
95
+ // server not found, internal server error...
96
+ if (exception instanceof ConfigException || exception instanceof ExecutionException) {
97
+ return toRetry((Exception) exception.getCause());
98
+ }
99
+ return exception instanceof TimeoutException || super.isExceptionToRetry(exception);
100
+ }
101
+ });
102
+ }
103
+
104
+ @Override
105
+ public void close()
106
+ {
107
+ if (retryHelper != null) {
108
+ retryHelper.close();
109
+ }
110
+ }
111
+
112
+ /**
113
+ * MailChimp API v3 supports non expires access_token. Then no need refresh_token
114
+ *
115
+ * @param task
116
+ * @return
117
+ */
118
+ private String buildAuthorizationHeader(final PluginTask task)
119
+ {
120
+ switch (task.getAuthMethod()) {
121
+ case OAUTH:
122
+ return "OAuth " + task.getAccessToken().orNull();
123
+ case API_KEY:
124
+ return "Basic " + Base64Variants.MIME_NO_LINEFEEDS
125
+ .encode(("apikey" + ":" + task.getApikey().orNull()).getBytes());
126
+ default:
127
+ throw new ConfigException("Not supported method");
128
+ }
129
+ }
130
+
131
+ private TokenHolder createTokenHolder(final HttpClient client)
132
+ {
133
+ if (tokenHolder != null) {
134
+ return tokenHolder;
135
+ }
136
+
137
+ LOG.info("Create new token holder and extract data center");
138
+
139
+ if (pluginTask.getAuthMethod() == OAUTH) {
140
+ try {
141
+ // Extract data center from meta data URL
142
+ ContentResponse contentResponse = client.newRequest("https://login.mailchimp.com/oauth2/metadata")
143
+ .method(GET)
144
+ .header("Authorization", authorizationHeader)
145
+ .send();
146
+
147
+ if (contentResponse.getStatus() == 200) {
148
+ ObjectNode objectNode = jsonParser.parseJsonObject(contentResponse.getContentAsString());
149
+ String endpoint = MessageFormat.format(Joiner.on("/").join("https://{0}.api.mailchimp.com", API_VERSION),
150
+ objectNode.get("dc").asText());
151
+ tokenHolder = new TokenHolder(pluginTask.getAccessToken().orNull(), null, endpoint);
152
+ return tokenHolder;
153
+ }
154
+
155
+ String message = String.format("%s %d %s",
156
+ contentResponse.getVersion(),
157
+ contentResponse.getStatus(),
158
+ contentResponse.getReason());
159
+ throw new HttpResponseException(message, contentResponse);
160
+ }
161
+ catch (Exception ex) {
162
+ throw new ConfigException("Unable to connect the data center", ex);
163
+ }
164
+ }
165
+ else if (pluginTask.getAuthMethod() == API_KEY) {
166
+ try {
167
+ // Authenticate and return data center
168
+ String domain = pluginTask.getApikey().get().split("-")[1];
169
+ String endpoint = MessageFormat.format(Joiner.on("/").join("https://{0}.api.mailchimp.com", API_VERSION),
170
+ domain);
171
+ ContentResponse contentResponse = client.newRequest(endpoint + "/")
172
+ .method(GET)
173
+ .header("Authorization", "Basic " + Base64Variants.MIME_NO_LINEFEEDS
174
+ .encode(("apikey" + ":" + pluginTask.getApikey().get()).getBytes()))
175
+ .send();
176
+
177
+ if (contentResponse.getStatus() == 200) {
178
+ tokenHolder = new TokenHolder(null, pluginTask.getApikey().orNull(), endpoint);
179
+ return tokenHolder;
180
+ }
181
+
182
+ String message = String.format("%s %d %s",
183
+ contentResponse.getVersion(),
184
+ contentResponse.getStatus(),
185
+ contentResponse.getReason());
186
+ throw new HttpResponseException(message, contentResponse);
187
+ }
188
+ catch (Exception ex) {
189
+ throw new ConfigException("Your API key may be invalid, or you've attempted to access the wrong datacenter.");
190
+ }
191
+ }
192
+
193
+ throw new ConfigException("Not supported auth method");
194
+ }
195
+ }
196
+
197
+ class TokenHolder
198
+ {
199
+ private String accessToken;
200
+ private String apiKey;
201
+ private String endpoint;
202
+
203
+ public TokenHolder(final String accessToken, final String apiKey, final String endpoint)
204
+ {
205
+ this.accessToken = accessToken;
206
+ this.apiKey = apiKey;
207
+ this.endpoint = endpoint;
208
+ }
209
+
210
+ public String getAccessToken()
211
+ {
212
+ return accessToken;
213
+ }
214
+
215
+ public String getApiKey()
216
+ {
217
+ return apiKey;
218
+ }
219
+
220
+ public String getEndpoint()
221
+ {
222
+ return endpoint;
223
+ }
224
+ }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: embulk-output-mailchimp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.20
4
+ version: 0.3.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thang Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-10-16 00:00:00.000000000 Z
11
+ date: 2018-03-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -62,11 +62,11 @@ files:
62
62
  - gradlew.bat
63
63
  - lib/embulk/output/mailchimp.rb
64
64
  - src/main/java/org/embulk/output/mailchimp/MailChimpClient.java
65
- - src/main/java/org/embulk/output/mailchimp/MailChimpHttpClient.java
66
65
  - src/main/java/org/embulk/output/mailchimp/MailChimpOutputPlugin.java
67
66
  - src/main/java/org/embulk/output/mailchimp/MailChimpOutputPluginDelegate.java
68
67
  - src/main/java/org/embulk/output/mailchimp/MailChimpRecordBuffer.java
69
68
  - src/main/java/org/embulk/output/mailchimp/helper/MailChimpHelper.java
69
+ - src/main/java/org/embulk/output/mailchimp/helper/MailChimpRetryable.java
70
70
  - src/main/java/org/embulk/output/mailchimp/model/AddressMergeFieldAttribute.java
71
71
  - src/main/java/org/embulk/output/mailchimp/model/AuthMethod.java
72
72
  - src/main/java/org/embulk/output/mailchimp/model/CategoriesResponse.java
@@ -89,7 +89,7 @@ files:
89
89
  - test/override_assert_raise.rb
90
90
  - test/run-test.rb
91
91
  - classpath/jetty-io-9.2.14.v20151106.jar
92
- - classpath/embulk-output-mailchimp-0.3.20.jar
92
+ - classpath/embulk-output-mailchimp-0.3.21.jar
93
93
  - classpath/jetty-util-9.2.14.v20151106.jar
94
94
  - classpath/jetty-http-9.2.14.v20151106.jar
95
95
  - classpath/jetty-client-9.2.14.v20151106.jar
@@ -1,200 +0,0 @@
1
- package org.embulk.output.mailchimp;
2
-
3
- import com.fasterxml.jackson.core.Base64Variants;
4
- import com.fasterxml.jackson.core.JsonParser;
5
- import com.fasterxml.jackson.databind.DeserializationFeature;
6
- import com.fasterxml.jackson.databind.JsonNode;
7
- import com.fasterxml.jackson.databind.ObjectMapper;
8
- import com.fasterxml.jackson.databind.node.MissingNode;
9
- import com.google.common.base.Throwables;
10
- import org.eclipse.jetty.client.HttpClient;
11
- import org.eclipse.jetty.client.api.Request;
12
- import org.eclipse.jetty.client.api.Response;
13
- import org.eclipse.jetty.client.util.StringContentProvider;
14
- import org.eclipse.jetty.http.HttpMethod;
15
- import org.eclipse.jetty.util.ssl.SslContextFactory;
16
- import org.embulk.config.ConfigException;
17
- import org.embulk.spi.DataException;
18
- import org.embulk.spi.Exec;
19
- import org.embulk.util.retryhelper.jetty92.Jetty92ClientCreator;
20
- import org.embulk.util.retryhelper.jetty92.Jetty92RetryHelper;
21
- import org.embulk.util.retryhelper.jetty92.Jetty92SingleRequester;
22
- import org.embulk.util.retryhelper.jetty92.StringJetty92ResponseEntityReader;
23
- import org.slf4j.Logger;
24
-
25
- import java.io.IOException;
26
- import java.util.concurrent.ExecutionException;
27
- import java.util.concurrent.TimeUnit;
28
- import java.util.concurrent.TimeoutException;
29
-
30
- /**
31
- * Created by thangnc on 4/14/17.
32
- */
33
- public class MailChimpHttpClient
34
- {
35
- private static final Logger LOG = Exec.getLogger(MailChimpHttpClient.class);
36
- private final ObjectMapper jsonMapper = new ObjectMapper()
37
- .configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, false)
38
- .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
39
-
40
- /**
41
- * Instantiates a new Mailchimp http client.
42
- *
43
- * @param task the task
44
- */
45
- public MailChimpHttpClient(MailChimpOutputPluginDelegate.PluginTask task)
46
- {
47
- }
48
-
49
- public JsonNode sendRequest(final String endpoint, final HttpMethod method,
50
- final MailChimpOutputPluginDelegate.PluginTask task)
51
- {
52
- return sendRequest(endpoint, method, "", task);
53
- }
54
-
55
- public JsonNode sendRequest(final String endpoint, final HttpMethod method, final String content,
56
- final MailChimpOutputPluginDelegate.PluginTask task)
57
- {
58
- try (final Jetty92RetryHelper retryHelper = createRetryHelper(task)) {
59
- final String authorizationHeader = getAuthorizationHeader(task);
60
-
61
- String responseBody = retryHelper.requestWithRetry(
62
- new StringJetty92ResponseEntityReader(task.getTimeoutMillis()),
63
- new Jetty92SingleRequester()
64
- {
65
- @Override
66
- public void requestOnce(HttpClient client, Response.Listener responseListener)
67
- {
68
- Request request = client
69
- .newRequest(endpoint)
70
- .timeout(task.getTimeoutMillis(), TimeUnit.MILLISECONDS)
71
- .accept("application/json")
72
- .method(method);
73
- if (method == HttpMethod.POST || method == HttpMethod.PUT) {
74
- request.content(new StringContentProvider(content), "application/json;utf-8");
75
- }
76
-
77
- if (!authorizationHeader.isEmpty()) {
78
- request.header("Authorization", authorizationHeader);
79
- }
80
- request.send(responseListener);
81
- }
82
-
83
- @Override
84
- public boolean isResponseStatusToRetry(Response response)
85
- {
86
- int status = response.getStatus();
87
-
88
- return status == 429 || status / 100 != 4;
89
- }
90
-
91
- @Override
92
- protected boolean isExceptionToRetry(Exception exception)
93
- {
94
- LOG.error("Exception to retry.", exception);
95
- // This check is to make sure the exception is retryable, e.g: server not found, internal server error...
96
- if (exception instanceof ConfigException || exception instanceof ExecutionException) {
97
- return toRetry((Exception) exception.getCause());
98
- }
99
-
100
- return exception instanceof TimeoutException || super.isExceptionToRetry(exception);
101
- }
102
- });
103
-
104
- return responseBody != null && !responseBody.isEmpty() ? parseJson(responseBody) : MissingNode.getInstance();
105
- }
106
- catch (Exception ex) {
107
- LOG.error("Exception occurred while sending request.", ex);
108
- throw Throwables.propagate(ex);
109
- }
110
- }
111
-
112
- private JsonNode parseJson(final String json)
113
- throws DataException
114
- {
115
- try {
116
- return this.jsonMapper.readTree(json);
117
- }
118
- catch (IOException ex) {
119
- // Try to parse invalid json before throwing exception
120
- return parseInvalidJsonString(json);
121
- }
122
- }
123
-
124
- // Sometimes, the MailChimp API returns invalid JSON when we pushed a large of data. ObjectMapper can not read string json.
125
- // So we have to use this method to parse string and build a json string as ReportResponse
126
- // E.g invalid json response from MailChimp https://gist.github.com/thangnc/dc94026e4b13b728b7303f402b458b05
127
- private JsonNode parseInvalidJsonString(final String json)
128
- {
129
- int totalCreatedIndex = json.indexOf("\"total_created\"");
130
- int totalUpdatedIndex = json.indexOf("\"total_updated\"");
131
- int errorCountIndex = json.indexOf("\"error_count\"");
132
- int errorsIndex = json.indexOf("\"errors\"");
133
-
134
- StringBuilder validJson = new StringBuilder();
135
- validJson.append("{").append(json.substring(errorsIndex, totalCreatedIndex - 1)).append(",");
136
- validJson.append(json.substring(totalCreatedIndex, totalCreatedIndex + "\"total_created\"".length() + 2)).append(",");
137
- validJson.append(json.substring(totalUpdatedIndex, totalUpdatedIndex + "\"total_updated\"".length() + 2)).append(",");
138
- validJson.append(json.substring(errorCountIndex, errorCountIndex + "\"error_count\"".length() + 2)).append("}");
139
-
140
- try {
141
- return this.jsonMapper.readTree(validJson.toString());
142
- }
143
- catch (IOException ex) {
144
- throw new DataException(ex);
145
- }
146
- }
147
-
148
- public void avoidFlushAPI(String reason)
149
- {
150
- try {
151
- LOG.info("{} in 5s...", reason);
152
- Thread.sleep(5000);
153
- }
154
- catch (InterruptedException e) {
155
- LOG.warn("Failed to sleep: {}", e.getMessage());
156
- }
157
- }
158
-
159
- /**
160
- * MailChimp API v3 supports non expires access_token. Then no need refresh_token
161
- *
162
- * @param task
163
- * @return
164
- */
165
- private String getAuthorizationHeader(final MailChimpOutputPluginDelegate.PluginTask task)
166
- {
167
- switch (task.getAuthMethod()) {
168
- case OAUTH:
169
- return "OAuth " + task.getAccessToken().orNull();
170
- case API_KEY:
171
- return "Basic " + Base64Variants.MIME_NO_LINEFEEDS
172
- .encode(("apikey" + ":" + task.getApikey().orNull()).getBytes());
173
- default:
174
- throw new ConfigException("Not supported method");
175
- }
176
- }
177
-
178
- private Jetty92RetryHelper createRetryHelper(MailChimpOutputPluginDelegate.PluginTask task)
179
- {
180
- return new Jetty92RetryHelper(
181
- task.getMaximumRetries(),
182
- task.getInitialRetryIntervalMillis(),
183
- task.getMaximumRetryIntervalMillis(),
184
- new Jetty92ClientCreator()
185
- {
186
- @Override
187
- public HttpClient createAndStart()
188
- {
189
- HttpClient client = new HttpClient(new SslContextFactory());
190
- try {
191
- client.start();
192
- return client;
193
- }
194
- catch (Exception e) {
195
- throw Throwables.propagate(e);
196
- }
197
- }
198
- });
199
- }
200
- }