From 8f764129b879252cfb84adcc8c324a49cfd9b36e Mon Sep 17 00:00:00 2001 From: Andy May Date: Wed, 15 Mar 2023 17:08:02 +0000 Subject: [PATCH] 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]=` parameter to the original query URL --- .../java/org/zendesk/client/v2/Zendesk.java | 68 +++++++++- .../client/v2/model/hc/ContentTag.java | 10 ++ .../zendesk/client/v2/ContentTagsTest.java | 117 ++++++++++++++++++ .../content_tag_search_first_page.json | 21 ++++ .../content_tag_search_second_page.json | 21 ++++ .../content_tag_search_third_page.json | 15 +++ 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/zendesk/client/v2/ContentTagsTest.java create mode 100644 src/test/resources/wiremock/__files/content_tags/content_tag_search_first_page.json create mode 100644 src/test/resources/wiremock/__files/content_tags/content_tag_search_second_page.json create mode 100644 src/test/resources/wiremock/__files/content_tags/content_tag_search_third_page.json diff --git a/src/main/java/org/zendesk/client/v2/Zendesk.java b/src/main/java/org/zendesk/client/v2/Zendesk.java index 9a83f1d9..5914b125 100644 --- a/src/main/java/org/zendesk/client/v2/Zendesk.java +++ b/src/main/java/org/zendesk/client/v2/Zendesk.java @@ -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 @@ -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"))); } @@ -2561,6 +2561,33 @@ public void deleteContentTag(ContentTag contentTag) { handleStatus())); } + public Iterable getContentTags() { + int defaultPageSize = 10; + return getContentTags(defaultPageSize, null); + } + + public Iterable getContentTags(int pageSize) { + return getContentTags(pageSize, null); + } + + public Iterable getContentTags(int pageSize, String namePrefix) { + Function 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 ////////////////////////////////////////////////////////////////////// @@ -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 extends ZendeskAsyncCompletionHandler { private String nextPage; @@ -2898,6 +2926,42 @@ public List 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 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 PagedAsyncCompletionHandler> handleListWithAfterCursorButNoLinks( + Class clazz, Function afterCursorUriBuilder, String name) { + + return new PagedAsyncListCompletionHandler(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); } diff --git a/src/main/java/org/zendesk/client/v2/model/hc/ContentTag.java b/src/main/java/org/zendesk/client/v2/model/hc/ContentTag.java index cf432ea2..56290fd0 100644 --- a/src/main/java/org/zendesk/client/v2/model/hc/ContentTag.java +++ b/src/main/java/org/zendesk/client/v2/model/hc/ContentTag.java @@ -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; } diff --git a/src/test/java/org/zendesk/client/v2/ContentTagsTest.java b/src/test/java/org/zendesk/client/v2/ContentTagsTest.java new file mode 100644 index 00000000..f641734d --- /dev/null +++ b/src/test/java/org/zendesk/client/v2/ContentTagsTest.java @@ -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 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") + ) + ); + } +} diff --git a/src/test/resources/wiremock/__files/content_tags/content_tag_search_first_page.json b/src/test/resources/wiremock/__files/content_tags/content_tag_search_first_page.json new file mode 100644 index 00000000..7cd64670 --- /dev/null +++ b/src/test/resources/wiremock/__files/content_tags/content_tag_search_first_page.json @@ -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" + } +} \ No newline at end of file diff --git a/src/test/resources/wiremock/__files/content_tags/content_tag_search_second_page.json b/src/test/resources/wiremock/__files/content_tags/content_tag_search_second_page.json new file mode 100644 index 00000000..2e341833 --- /dev/null +++ b/src/test/resources/wiremock/__files/content_tags/content_tag_search_second_page.json @@ -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" + } +} \ No newline at end of file diff --git a/src/test/resources/wiremock/__files/content_tags/content_tag_search_third_page.json b/src/test/resources/wiremock/__files/content_tags/content_tag_search_third_page.json new file mode 100644 index 00000000..61ba654e --- /dev/null +++ b/src/test/resources/wiremock/__files/content_tags/content_tag_search_third_page.json @@ -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" + } +} \ No newline at end of file