Skip to content

Commit 8f76412

Browse files
committed
Implement search for Content Tags
ContentTag pagination sadly doesn't conform to Zendesk's standards. It uses cursor pagination, but doesn't include a `links.next` node in the response (which would normally hold the URL of the next page of results). Because of this, we have to build the 'next page URL' ourselves by extracting the `meta.after_cursor` node value & using it to add a `&page[after]=<cursorValue>` parameter to the original query URL
1 parent 0620db7 commit 8f76412

File tree

6 files changed

+250
-2
lines changed

6 files changed

+250
-2
lines changed

src/main/java/org/zendesk/client/v2/Zendesk.java

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
import java.util.Optional;
100100
import java.util.concurrent.ExecutionException;
101101
import java.util.concurrent.TimeUnit;
102-
import java.util.regex.Pattern;
102+
import java.util.function.Function;
103103

104104
/**
105105
* @author stephenc
@@ -2542,7 +2542,7 @@ public ContentTag getContentTag(String contentTagId) {
25422542

25432543
public ContentTag createContentTag(ContentTag contentTag) {
25442544
checkHasName(contentTag);
2545-
return complete(submit(req("POST", tmpl("/guide/content_tags"),
2545+
return complete(submit(req("POST", cnst("/guide/content_tags"),
25462546
JSON, json(Collections.singletonMap("content_tag", contentTag))),
25472547
handle(ContentTag.class, "content_tag")));
25482548
}
@@ -2561,6 +2561,33 @@ public void deleteContentTag(ContentTag contentTag) {
25612561
handleStatus()));
25622562
}
25632563

2564+
public Iterable<ContentTag> getContentTags() {
2565+
int defaultPageSize = 10;
2566+
return getContentTags(defaultPageSize, null);
2567+
}
2568+
2569+
public Iterable<ContentTag> getContentTags(int pageSize) {
2570+
return getContentTags(pageSize, null);
2571+
}
2572+
2573+
public Iterable<ContentTag> getContentTags(int pageSize, String namePrefix) {
2574+
Function<String, Uri> afterCursorUriBuilder = (String afterCursor) -> buildContentTagsSearchUrl(pageSize, namePrefix, afterCursor);
2575+
return new PagedIterable<>(afterCursorUriBuilder.apply(null),
2576+
handleListWithAfterCursorButNoLinks(ContentTag.class, afterCursorUriBuilder, "records"));
2577+
}
2578+
2579+
private Uri buildContentTagsSearchUrl(int pageSize, String namePrefixFilter, String afterCursor) {
2580+
final StringBuilder uriBuilder = new StringBuilder("/guide/content_tags?page[size]=").append(pageSize);
2581+
2582+
if (namePrefixFilter != null) {
2583+
uriBuilder.append("&filter[name_prefix]=").append(encodeUrl(namePrefixFilter));
2584+
}
2585+
if (afterCursor != null) {
2586+
uriBuilder.append("&page[after]=").append(encodeUrl(afterCursor));
2587+
}
2588+
return cnst(uriBuilder.toString());
2589+
}
2590+
25642591
//////////////////////////////////////////////////////////////////////
25652592
// Helper methods
25662593
//////////////////////////////////////////////////////////////////////
@@ -2714,6 +2741,7 @@ public JobStatus onCompleted(Response response) throws Exception {
27142741
private static final String COUNT = "count";
27152742
private static final int INCREMENTAL_EXPORT_MAX_COUNT_BY_REQUEST = 1000;
27162743

2744+
27172745
private abstract class PagedAsyncCompletionHandler<T> extends ZendeskAsyncCompletionHandler<T> {
27182746
private String nextPage;
27192747

@@ -2898,6 +2926,42 @@ public List<ArticleAttachments> onCompleted(Response response) throws Exception
28982926
};
28992927
}
29002928

2929+
/**
2930+
* For a resource (e.g. ContentTag) which supports cursor based pagination for multiple results,
2931+
* but where the response does not have a `links.next` node (which would hold the URL of the next page)
2932+
* So we need to build the next page URL from the original URL and the meta.after_cursor node value
2933+
*
2934+
* @param <T> The class of the resource
2935+
* @param afterCursorUriBuilder a function to build the URL for the next page `fn(after_cursor_value) => URL_of_next_page`
2936+
* @param name the name of the Json node that contains the resources entities (e.g. 'records' for ContentTag)
2937+
*/
2938+
private <T> PagedAsyncCompletionHandler<List<T>> handleListWithAfterCursorButNoLinks(
2939+
Class<T> clazz, Function<String, Uri> afterCursorUriBuilder, String name) {
2940+
2941+
return new PagedAsyncListCompletionHandler<T>(clazz, name) {
2942+
@Override
2943+
public void setPagedProperties(JsonNode responseNode, Class<?> clazz) {
2944+
JsonNode metaNode = responseNode.get("meta");
2945+
String nextPage = null;
2946+
if (metaNode == null) {
2947+
if (logger.isDebugEnabled()) {
2948+
logger.debug("meta" + " property not found, pagination not supported" +
2949+
(clazz != null ? " for " + clazz.getName() : ""));
2950+
}
2951+
} else {
2952+
JsonNode afterCursorNode = metaNode.get("after_cursor");
2953+
if (afterCursorNode != null) {
2954+
JsonNode hasMoreNode = metaNode.get("has_more");
2955+
if (hasMoreNode != null && hasMoreNode.asBoolean()) {
2956+
nextPage = afterCursorUriBuilder.apply(afterCursorNode.asText()).toString();
2957+
}
2958+
}
2959+
}
2960+
setNextPage(nextPage);
2961+
}
2962+
};
2963+
}
2964+
29012965
private TemplateUri tmpl(String template) {
29022966
return new TemplateUri(url + template);
29032967
}

src/main/java/org/zendesk/client/v2/model/hc/ContentTag.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ public class ContentTag {
2828
@JsonProperty("updated_at")
2929
private Date updatedAt;
3030

31+
public ContentTag() {
32+
}
33+
34+
public ContentTag(String id, String name, Date createdAt, Date updatedAt) {
35+
this.id = id;
36+
this.name = name;
37+
this.createdAt = createdAt;
38+
this.updatedAt = updatedAt;
39+
}
40+
3141
public String getId() {
3242
return id;
3343
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package org.zendesk.client.v2;
2+
3+
import com.github.tomakehurst.wiremock.junit.WireMockClassRule;
4+
import org.apache.commons.text.RandomStringGenerator;
5+
import org.junit.After;
6+
import org.junit.Before;
7+
import org.junit.ClassRule;
8+
import org.junit.Rule;
9+
import org.junit.Test;
10+
import org.zendesk.client.v2.model.hc.ContentTag;
11+
12+
import java.text.SimpleDateFormat;
13+
import java.util.TimeZone;
14+
15+
import static com.github.tomakehurst.wiremock.client.WireMock.*;
16+
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
19+
20+
public class ContentTagsTest {
21+
22+
private static final String MOCK_URL_FORMATTED_STRING = "http://localhost:%d";
23+
public static final RandomStringGenerator RANDOM_STRING_GENERATOR =
24+
new RandomStringGenerator.Builder().withinRange('a', 'z').build();
25+
private static final String MOCK_API_TOKEN = RANDOM_STRING_GENERATOR.generate(15);
26+
private static final String MOCK_USERNAME = RANDOM_STRING_GENERATOR.generate(10).toLowerCase() + "@cloudbees.com";
27+
28+
@ClassRule
29+
public static WireMockClassRule zendeskApiClass = new WireMockClassRule(options()
30+
.dynamicPort()
31+
.dynamicHttpsPort()
32+
.usingFilesUnderClasspath("wiremock")
33+
);
34+
35+
@Rule
36+
public WireMockClassRule zendeskApiMock = zendeskApiClass;
37+
38+
private Zendesk client;
39+
40+
@Before
41+
public void setUp() throws Exception {
42+
int ephemeralPort = zendeskApiMock.port();
43+
44+
String hostname = String.format(MOCK_URL_FORMATTED_STRING, ephemeralPort);
45+
46+
client = new Zendesk.Builder(hostname)
47+
.setUsername(MOCK_USERNAME)
48+
.setToken(MOCK_API_TOKEN)
49+
.build();
50+
}
51+
52+
@After
53+
public void closeClient() {
54+
if (client != null) {
55+
client.close();
56+
}
57+
client = null;
58+
}
59+
60+
@Test
61+
public void getContentTags_willPageOverMultiplePages() throws Exception {
62+
zendeskApiMock.stubFor(
63+
get(
64+
urlPathEqualTo("/api/v2/guide/content_tags"))
65+
.withQueryParam("page%5Bsize%5D", equalTo("2"))
66+
.willReturn(ok()
67+
.withBodyFile("content_tags/content_tag_search_first_page.json")
68+
)
69+
);
70+
zendeskApiMock.stubFor(
71+
get(
72+
urlPathEqualTo("/api/v2/guide/content_tags"))
73+
.withQueryParam("page%5Bsize%5D", equalTo("2"))
74+
.withQueryParam("page%5Bafter%5D", equalTo("first_after_cursor"))
75+
.willReturn(ok()
76+
.withBodyFile("content_tags/content_tag_search_second_page.json")
77+
)
78+
);
79+
zendeskApiMock.stubFor(
80+
get(
81+
urlPathEqualTo("/api/v2/guide/content_tags"))
82+
.withQueryParam("page%5Bsize%5D", equalTo("2"))
83+
.withQueryParam("page%5Bafter%5D", equalTo("second_after_cursor"))
84+
.willReturn(ok()
85+
.withBodyFile("content_tags/content_tag_search_third_page.json")
86+
)
87+
);
88+
89+
Iterable<ContentTag> actualResults = client.getContentTags(2);
90+
91+
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
92+
df.setTimeZone(TimeZone.getTimeZone("UTC"));
93+
94+
assertThat(actualResults).containsExactly(
95+
new ContentTag("11111111111111111111111111", "first name",
96+
df.parse("2023-03-13 10:01:00"),
97+
df.parse("2023-03-13 10:01:01")
98+
),
99+
new ContentTag("22222222222222222222222222", "second name",
100+
df.parse("2023-03-13 10:02:00"),
101+
df.parse("2023-03-13 10:02:02")
102+
),
103+
new ContentTag("33333333333333333333333333", "third name",
104+
df.parse("2023-03-13 10:03:00"),
105+
df.parse("2023-03-13 10:03:03")
106+
),
107+
new ContentTag("44444444444444444444444444", "fourth name",
108+
df.parse("2023-03-13 10:04:00"),
109+
df.parse("2023-03-13 10:04:04")
110+
),
111+
new ContentTag("55555555555555555555555555", "fifth name",
112+
df.parse("2023-03-13 10:05:00"),
113+
df.parse("2023-03-13 10:05:05")
114+
)
115+
);
116+
}
117+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"records": [
3+
{
4+
"id": "11111111111111111111111111",
5+
"name": "first name",
6+
"created_at": "2023-03-13T10:01:00.000Z",
7+
"updated_at": "2023-03-13T10:01:01.000Z"
8+
},
9+
{
10+
"id": "22222222222222222222222222",
11+
"name": "second name",
12+
"created_at": "2023-03-13T10:02:00.000Z",
13+
"updated_at": "2023-03-13T10:02:02.000Z"
14+
}
15+
],
16+
"meta": {
17+
"has_more": true,
18+
"after_cursor": "first_after_cursor",
19+
"before_cursor": "first_before_cursor"
20+
}
21+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"records": [
3+
{
4+
"id": "33333333333333333333333333",
5+
"name": "third name",
6+
"created_at": "2023-03-13T10:03:00.000Z",
7+
"updated_at": "2023-03-13T10:03:03.000Z"
8+
},
9+
{
10+
"id": "44444444444444444444444444",
11+
"name": "fourth name",
12+
"created_at": "2023-03-13T10:04:00.000Z",
13+
"updated_at": "2023-03-13T10:04:04.000Z"
14+
}
15+
],
16+
"meta": {
17+
"has_more": true,
18+
"after_cursor": "second_after_cursor",
19+
"before_cursor": "second_before_cursor"
20+
}
21+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"records": [
3+
{
4+
"id": "55555555555555555555555555",
5+
"name": "fifth name",
6+
"created_at": "2023-03-13T10:05:00.000Z",
7+
"updated_at": "2023-03-13T10:05:05.000Z"
8+
}
9+
],
10+
"meta": {
11+
"has_more": false,
12+
"after_cursor": "third_after_cursor",
13+
"before_cursor": "third_before_cursor"
14+
}
15+
}

0 commit comments

Comments
 (0)