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 6d0b506 commit bb691c6
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 24 deletions.
50 changes: 26 additions & 24 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 @@ -2563,29 +2563,29 @@ public void deleteContentTag(ContentTag contentTag) {

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

public Iterable<ContentTag> getContentTags(int pageSize) {
TemplateUri templateUri = tmpl("/guide/content_tags/{id}" +
"?page[size]={pageSize}]" +
"&page[after]={afterCursor}")
.set("pageSize", pageSize);

return complete(submit(req("GET", templateUri),
handleListWithAfterCursorButNoLinks(ContentTag.class, templateUri,"records")));
return getContentTags(pageSize, null);
}

public Iterable<ContentTag> getContentTags(int pageSize, String namePrefix) {
TemplateUri templateUri = tmpl("/guide/content_tags/{id}" +
"?page[size]={pageSize}]" +
"&page[after]={afterCursor}" +
"&filter[name_prefix]={namePrefix}")
.set("pageSize", pageSize)
.set("namePrefix", namePrefix);
Function<String, Uri> afterCursorUriBuilder = (String afterCursor) -> buildContentTagsSearchUrl(pageSize, namePrefix, afterCursor);
return new PagedIterable<>(afterCursorUriBuilder.apply(null),
handleListWithAfterCursorButNoLinks(ContentTag.class, afterCursorUriBuilder, "records"));
}

return complete(submit(req("GET", templateUri),
handleListWithAfterCursorButNoLinks(ContentTag.class, templateUri,"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());
}

//////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -2926,15 +2926,17 @@ public List<ArticleAttachments> onCompleted(Response response) throws Exception
};
}

/** For a resource (e.g. ContentTag) which supports cursor based pagination for multiple results,
/**
* 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 meta.after_cursor node value
* @param <T> The class of the resource
* @param templateUri a template which has an 'afterCursor' variable in it, which can be used to build the next page link
* @param name the name of the Json node that contains the resources entities (e.g. 'records' for ContentTag)
* 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, TemplateUri templateUri, String name) {
Class<T> clazz, Function<String, Uri> afterCursorUriBuilder, String name) {

return new PagedAsyncListCompletionHandler<T>(clazz, name) {
@Override
Expand All @@ -2951,7 +2953,7 @@ public void setPagedProperties(JsonNode responseNode, Class<?> clazz) {
if (afterCursorNode != null) {
JsonNode hasMoreNode = metaNode.get("has_more");
if (hasMoreNode != null && hasMoreNode.asBoolean()) {
nextPage = templateUri.set("afterCursor", afterCursorNode.asText()).toString();
nextPage = afterCursorUriBuilder.apply(afterCursorNode.asText()).toString();
}
}
}
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 bb691c6

Please sign in to comment.