Skip to content

Commit

Permalink
Implement search for Content Tags
Browse files Browse the repository at this point in the history
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
  • Loading branch information
andy-may-at committed Mar 22, 2023
1 parent 0620db7 commit 8f76412
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 2 deletions.
68 changes: 66 additions & 2 deletions src/main/java/org/zendesk/client/v2/Zendesk.java
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.function.Function;

/**
* @author stephenc
Expand Down Expand Up @@ -2542,7 +2542,7 @@ public ContentTag getContentTag(String contentTagId) {

public ContentTag createContentTag(ContentTag contentTag) {
checkHasName(contentTag);
return complete(submit(req("POST", tmpl("/guide/content_tags"),
return complete(submit(req("POST", cnst("/guide/content_tags"),
JSON, json(Collections.singletonMap("content_tag", contentTag))),
handle(ContentTag.class, "content_tag")));
}
Expand All @@ -2561,6 +2561,33 @@ public void deleteContentTag(ContentTag contentTag) {
handleStatus()));
}

public Iterable<ContentTag> getContentTags() {
int defaultPageSize = 10;
return getContentTags(defaultPageSize, null);
}

public Iterable<ContentTag> getContentTags(int pageSize) {
return getContentTags(pageSize, null);
}

public Iterable<ContentTag> getContentTags(int pageSize, String namePrefix) {
Function<String, Uri> afterCursorUriBuilder = (String afterCursor) -> buildContentTagsSearchUrl(pageSize, namePrefix, afterCursor);
return new PagedIterable<>(afterCursorUriBuilder.apply(null),
handleListWithAfterCursorButNoLinks(ContentTag.class, afterCursorUriBuilder, "records"));
}

private Uri buildContentTagsSearchUrl(int pageSize, String namePrefixFilter, String afterCursor) {
final StringBuilder uriBuilder = new StringBuilder("/guide/content_tags?page[size]=").append(pageSize);

if (namePrefixFilter != null) {
uriBuilder.append("&filter[name_prefix]=").append(encodeUrl(namePrefixFilter));
}
if (afterCursor != null) {
uriBuilder.append("&page[after]=").append(encodeUrl(afterCursor));
}
return cnst(uriBuilder.toString());
}

//////////////////////////////////////////////////////////////////////
// Helper methods
//////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -2714,6 +2741,7 @@ public JobStatus onCompleted(Response response) throws Exception {
private static final String COUNT = "count";
private static final int INCREMENTAL_EXPORT_MAX_COUNT_BY_REQUEST = 1000;


private abstract class PagedAsyncCompletionHandler<T> extends ZendeskAsyncCompletionHandler<T> {
private String nextPage;

Expand Down Expand Up @@ -2898,6 +2926,42 @@ public List<ArticleAttachments> onCompleted(Response response) throws Exception
};
}

/**
* For a resource (e.g. ContentTag) which supports cursor based pagination for multiple results,
* but where the response does not have a `links.next` node (which would hold the URL of the next page)
* So we need to build the next page URL from the original URL and the meta.after_cursor node value
*
* @param <T> The class of the resource
* @param afterCursorUriBuilder a function to build the URL for the next page `fn(after_cursor_value) => URL_of_next_page`
* @param name the name of the Json node that contains the resources entities (e.g. 'records' for ContentTag)
*/
private <T> PagedAsyncCompletionHandler<List<T>> handleListWithAfterCursorButNoLinks(
Class<T> clazz, Function<String, Uri> afterCursorUriBuilder, String name) {

return new PagedAsyncListCompletionHandler<T>(clazz, name) {
@Override
public void setPagedProperties(JsonNode responseNode, Class<?> clazz) {
JsonNode metaNode = responseNode.get("meta");
String nextPage = null;
if (metaNode == null) {
if (logger.isDebugEnabled()) {
logger.debug("meta" + " property not found, pagination not supported" +
(clazz != null ? " for " + clazz.getName() : ""));
}
} else {
JsonNode afterCursorNode = metaNode.get("after_cursor");
if (afterCursorNode != null) {
JsonNode hasMoreNode = metaNode.get("has_more");
if (hasMoreNode != null && hasMoreNode.asBoolean()) {
nextPage = afterCursorUriBuilder.apply(afterCursorNode.asText()).toString();
}
}
}
setNextPage(nextPage);
}
};
}

private TemplateUri tmpl(String template) {
return new TemplateUri(url + template);
}
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/org/zendesk/client/v2/model/hc/ContentTag.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ public class ContentTag {
@JsonProperty("updated_at")
private Date updatedAt;

public ContentTag() {
}

public ContentTag(String id, String name, Date createdAt, Date updatedAt) {
this.id = id;
this.name = name;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}

public String getId() {
return id;
}
Expand Down
117 changes: 117 additions & 0 deletions src/test/java/org/zendesk/client/v2/ContentTagsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package org.zendesk.client.v2;

import com.github.tomakehurst.wiremock.junit.WireMockClassRule;
import org.apache.commons.text.RandomStringGenerator;
import org.junit.After;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.zendesk.client.v2.model.hc.ContentTag;

import java.text.SimpleDateFormat;
import java.util.TimeZone;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
import static org.assertj.core.api.Assertions.assertThat;


public class ContentTagsTest {

private static final String MOCK_URL_FORMATTED_STRING = "http://localhost:%d";
public static final RandomStringGenerator RANDOM_STRING_GENERATOR =
new RandomStringGenerator.Builder().withinRange('a', 'z').build();
private static final String MOCK_API_TOKEN = RANDOM_STRING_GENERATOR.generate(15);
private static final String MOCK_USERNAME = RANDOM_STRING_GENERATOR.generate(10).toLowerCase() + "@cloudbees.com";

@ClassRule
public static WireMockClassRule zendeskApiClass = new WireMockClassRule(options()
.dynamicPort()
.dynamicHttpsPort()
.usingFilesUnderClasspath("wiremock")
);

@Rule
public WireMockClassRule zendeskApiMock = zendeskApiClass;

private Zendesk client;

@Before
public void setUp() throws Exception {
int ephemeralPort = zendeskApiMock.port();

String hostname = String.format(MOCK_URL_FORMATTED_STRING, ephemeralPort);

client = new Zendesk.Builder(hostname)
.setUsername(MOCK_USERNAME)
.setToken(MOCK_API_TOKEN)
.build();
}

@After
public void closeClient() {
if (client != null) {
client.close();
}
client = null;
}

@Test
public void getContentTags_willPageOverMultiplePages() throws Exception {
zendeskApiMock.stubFor(
get(
urlPathEqualTo("/api/v2/guide/content_tags"))
.withQueryParam("page%5Bsize%5D", equalTo("2"))
.willReturn(ok()
.withBodyFile("content_tags/content_tag_search_first_page.json")
)
);
zendeskApiMock.stubFor(
get(
urlPathEqualTo("/api/v2/guide/content_tags"))
.withQueryParam("page%5Bsize%5D", equalTo("2"))
.withQueryParam("page%5Bafter%5D", equalTo("first_after_cursor"))
.willReturn(ok()
.withBodyFile("content_tags/content_tag_search_second_page.json")
)
);
zendeskApiMock.stubFor(
get(
urlPathEqualTo("/api/v2/guide/content_tags"))
.withQueryParam("page%5Bsize%5D", equalTo("2"))
.withQueryParam("page%5Bafter%5D", equalTo("second_after_cursor"))
.willReturn(ok()
.withBodyFile("content_tags/content_tag_search_third_page.json")
)
);

Iterable<ContentTag> actualResults = client.getContentTags(2);

SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
df.setTimeZone(TimeZone.getTimeZone("UTC"));

assertThat(actualResults).containsExactly(
new ContentTag("11111111111111111111111111", "first name",
df.parse("2023-03-13 10:01:00"),
df.parse("2023-03-13 10:01:01")
),
new ContentTag("22222222222222222222222222", "second name",
df.parse("2023-03-13 10:02:00"),
df.parse("2023-03-13 10:02:02")
),
new ContentTag("33333333333333333333333333", "third name",
df.parse("2023-03-13 10:03:00"),
df.parse("2023-03-13 10:03:03")
),
new ContentTag("44444444444444444444444444", "fourth name",
df.parse("2023-03-13 10:04:00"),
df.parse("2023-03-13 10:04:04")
),
new ContentTag("55555555555555555555555555", "fifth name",
df.parse("2023-03-13 10:05:00"),
df.parse("2023-03-13 10:05:05")
)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"records": [
{
"id": "11111111111111111111111111",
"name": "first name",
"created_at": "2023-03-13T10:01:00.000Z",
"updated_at": "2023-03-13T10:01:01.000Z"
},
{
"id": "22222222222222222222222222",
"name": "second name",
"created_at": "2023-03-13T10:02:00.000Z",
"updated_at": "2023-03-13T10:02:02.000Z"
}
],
"meta": {
"has_more": true,
"after_cursor": "first_after_cursor",
"before_cursor": "first_before_cursor"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"records": [
{
"id": "33333333333333333333333333",
"name": "third name",
"created_at": "2023-03-13T10:03:00.000Z",
"updated_at": "2023-03-13T10:03:03.000Z"
},
{
"id": "44444444444444444444444444",
"name": "fourth name",
"created_at": "2023-03-13T10:04:00.000Z",
"updated_at": "2023-03-13T10:04:04.000Z"
}
],
"meta": {
"has_more": true,
"after_cursor": "second_after_cursor",
"before_cursor": "second_before_cursor"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"records": [
{
"id": "55555555555555555555555555",
"name": "fifth name",
"created_at": "2023-03-13T10:05:00.000Z",
"updated_at": "2023-03-13T10:05:05.000Z"
}
],
"meta": {
"has_more": false,
"after_cursor": "third_after_cursor",
"before_cursor": "third_before_cursor"
}
}

0 comments on commit 8f76412

Please sign in to comment.