diff --git a/Generator/JavaClientWriter.cs b/Generator/JavaClientWriter.cs index 0c0d5af..1aefc5d 100644 --- a/Generator/JavaClientWriter.cs +++ b/Generator/JavaClientWriter.cs @@ -1,6 +1,8 @@ using Generator.Extensions; +using Newtonsoft.Json; using Relewise.Client.Requests; using System.CodeDom.Compiler; +using System.Reflection; namespace Generator; @@ -35,6 +37,7 @@ public void GenerateClientClass(Type clientType, string[] clientMethodNames) .Select(derivedType => ( methodName: info.Name.ToCamelCase(), parameterType: javaWriter.TypeName(derivedType), + parameterClrType: derivedType, parameterName: info.GetParameters().First().Name!, returnType: info.ReturnType)) : new[] @@ -42,11 +45,17 @@ public void GenerateClientClass(Type clientType, string[] clientMethodNames) ( methodName: info.Name.ToCamelCase(), parameterType: javaWriter.TypeName(info.GetParameters().First().ParameterType), + parameterClrType: info.GetParameters().First().ParameterType, parameterName: info.GetParameters().First().Name!, returnType: info.ReturnType) }) .ToArray(); + var requiresBatchingImports = clientMethods.Any(method => + (method.parameterClrType.GetProperty("Requests") + ?? method.parameterClrType.GetProperty("Items")) is { } property + && GetCollectionElementType(property.PropertyType) is not null); + int timeout = 5; if (clientType.GetConstructor(new[] { typeof(Guid), typeof(string), typeof(int) }) is { } constructor @@ -62,7 +71,7 @@ public void GenerateClientClass(Type clientType, string[] clientMethodNames) import {Constants.Namespace}.{Constants.GenerationFolderPath}.*; import {Constants.Namespace}.infrastructure.*; import java.io.IOException; - +{(requiresBatchingImports ? "import java.util.*;\n" : "")} """); writer.WriteLine($"public class {clientType.Name} extends RelewiseClient"); @@ -76,7 +85,14 @@ public void GenerateClientClass(Type clientType, string[] clientMethodNames) writer.WriteLine(""); writer.WriteLine($"public {(method.returnType == typeof(void) ? "void" : javaWriter.TypeName(method.returnType))} {method.methodName}({method.parameterType} {method.parameterName}) throws IOException, InterruptedException, ClientException {{"); writer.Indent++; - if (method.returnType == typeof(void)) + var collectionProperty = method.parameterClrType.GetProperty("Requests") + ?? method.parameterClrType.GetProperty("Items"); + + if (collectionProperty is not null && GetCollectionElementType(collectionProperty.PropertyType) is { } collectionElementType) + { + WriteBatchedMethod(writer, method.parameterClrType, method.parameterType, method.parameterName, method.returnType, collectionProperty, collectionElementType); + } + else if (method.returnType == typeof(void)) { writer.WriteLine($"makeRequestAndValidate(\"{method.parameterType}\", {method.parameterName}, {javaWriter.TypeName(method.returnType)}.class);"); } @@ -90,4 +106,120 @@ public void GenerateClientClass(Type clientType, string[] clientMethodNames) writer.Indent--; writer.WriteLine("}"); } -} \ No newline at end of file + + private void WriteBatchedMethod( + IndentedTextWriter writer, + Type parameterClrType, + string parameterType, + string parameterName, + Type returnType, + PropertyInfo collectionProperty, + Type collectionElementType) + { + var collectionGetter = $"{parameterName}.get{collectionProperty.Name}()"; + var emptyCheck = collectionProperty.PropertyType.IsArray + ? $"{collectionGetter}.length == 0" + : $"{collectionGetter}.isEmpty()"; + + writer.WriteLine($"if ({collectionGetter} == null || {emptyCheck}) {{"); + writer.Indent++; + writer.WriteLine(returnType == typeof(void) ? "return;" : "return null;"); + writer.Indent--; + writer.WriteLine("}"); + + if (returnType != typeof(void)) + { + writer.WriteLine($"{javaWriter.TypeName(returnType)} aggregatedResponse = null;"); + } + + writer.WriteLine($"for (var batch : createBatches({collectionGetter})) {{"); + writer.Indent++; + writer.WriteLine($"var chunkedRequest = new {parameterType}();"); + foreach (var property in GetCopyableProperties(parameterClrType, collectionProperty)) + { + writer.WriteLine($"chunkedRequest.set{property.Name}({CopyPropertyValue(parameterName, property)});"); + } + writer.WriteLine($"chunkedRequest.set{collectionProperty.Name}(batch.toArray(new {javaWriter.TypeName(collectionElementType)}[0]));"); + + if (returnType == typeof(void)) + { + writer.WriteLine($"makeRequestAndValidate(\"{parameterType}\", chunkedRequest, Void.class);"); + } + else + { + writer.WriteLine($"var chunkResponse = makeRequestAndValidate(\"{parameterType}\", chunkedRequest, {javaWriter.TypeName(returnType)}.class);"); + writer.WriteLine("if (chunkResponse == null) {"); + writer.Indent++; + writer.WriteLine("continue;"); + writer.Indent--; + writer.WriteLine("}"); + writer.WriteLine("if (aggregatedResponse == null) {"); + writer.Indent++; + writer.WriteLine("aggregatedResponse = chunkResponse;"); + writer.Indent--; + writer.WriteLine("}"); + writer.WriteLine("else {"); + writer.Indent++; + WriteResponseAggregation(writer, returnType); + writer.Indent--; + writer.WriteLine("}"); + } + + writer.Indent--; + writer.WriteLine("}"); + + if (returnType != typeof(void)) + { + writer.WriteLine("return aggregatedResponse;"); + } + } + + private void WriteResponseAggregation(IndentedTextWriter writer, Type returnType) + { + var responsesProperty = returnType.GetProperty("Responses"); + if (responsesProperty is null || GetCollectionElementType(responsesProperty.PropertyType) is not { } responseElementType) + { + return; + } + + var responseElementTypeName = javaWriter.TypeName(responseElementType); + writer.WriteLine("if (chunkResponse.getResponses() != null) {"); + writer.Indent++; + writer.WriteLine("var responses = aggregatedResponse.getResponses() == null"); + writer.Indent++; + writer.WriteLine($"? new ArrayList<{responseElementTypeName}>()"); + writer.WriteLine(": new ArrayList<>(Arrays.asList(aggregatedResponse.getResponses()));"); + writer.Indent--; + writer.WriteLine("responses.addAll(Arrays.asList(chunkResponse.getResponses()));"); + writer.WriteLine($"aggregatedResponse.setResponses(responses.toArray(new {responseElementTypeName}[0]));"); + writer.Indent--; + writer.WriteLine("}"); + } + + private string CopyPropertyValue(string parameterName, PropertyInfo property) + { + var getter = $"{parameterName}.get{property.Name}()"; + return property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(List<>) + && property.PropertyType.GenericTypeArguments is [var elementType] + ? $"{getter} == null ? null : {getter}.toArray(new {javaWriter.TypeName(elementType)}[0])" + : getter; + } + + private static PropertyInfo[] GetCopyableProperties(Type parameterType, PropertyInfo collectionProperty) => parameterType + .GetProperties() + .Where(info => info.Name != collectionProperty.Name + && info.MemberType is MemberTypes.Property + && info.GetIndexParameters().Length is 0 + && info.GetMethod is { IsAbstract: false, IsPublic: true, IsStatic: false } + && info.SetMethod is { IsAbstract: false, IsPublic: true, IsStatic: false } + && !Attribute.IsDefined(info, typeof(JsonIgnoreAttribute)) + && info.Name != "Custom") + .ToArray(); + + private static Type? GetCollectionElementType(Type type) => type.IsArray + ? type.GetElementType() + : type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>) + ? type.GenericTypeArguments[0] + : null; +} diff --git a/src/src/main/java/com/relewise/client/Recommender.java b/src/src/main/java/com/relewise/client/Recommender.java index 71c927c..a40c0c4 100644 --- a/src/src/main/java/com/relewise/client/Recommender.java +++ b/src/src/main/java/com/relewise/client/Recommender.java @@ -3,6 +3,7 @@ import com.relewise.client.model.*; import com.relewise.client.infrastructure.*; import java.io.IOException; +import java.util.*; public class Recommender extends RelewiseClient { @@ -126,11 +127,61 @@ public BrandRecommendationResponse recommend(BrandRecommendationRequest request) } public ProductRecommendationResponseCollection recommend(ProductRecommendationRequestCollection request) throws IOException, InterruptedException, ClientException { - return makeRequestAndValidate("ProductRecommendationRequestCollection", request, ProductRecommendationResponseCollection.class); + if (request.getRequests() == null || request.getRequests().isEmpty()) { + return null; + } + ProductRecommendationResponseCollection aggregatedResponse = null; + for (var batch : createBatches(request.getRequests())) { + var chunkedRequest = new ProductRecommendationRequestCollection(); + chunkedRequest.setRequireDistinctProductsAcrossResults(request.getRequireDistinctProductsAcrossResults()); + chunkedRequest.setRequests(batch.toArray(new ProductRecommendationRequest[0])); + var chunkResponse = makeRequestAndValidate("ProductRecommendationRequestCollection", chunkedRequest, ProductRecommendationResponseCollection.class); + if (chunkResponse == null) { + continue; + } + if (aggregatedResponse == null) { + aggregatedResponse = chunkResponse; + } + else { + if (chunkResponse.getResponses() != null) { + var responses = aggregatedResponse.getResponses() == null + ? new ArrayList() + : new ArrayList<>(Arrays.asList(aggregatedResponse.getResponses())); + responses.addAll(Arrays.asList(chunkResponse.getResponses())); + aggregatedResponse.setResponses(responses.toArray(new ProductRecommendationResponse[0])); + } + } + } + return aggregatedResponse; } public ContentRecommendationResponseCollection recommend(ContentRecommendationRequestCollection request) throws IOException, InterruptedException, ClientException { - return makeRequestAndValidate("ContentRecommendationRequestCollection", request, ContentRecommendationResponseCollection.class); + if (request.getRequests() == null || request.getRequests().isEmpty()) { + return null; + } + ContentRecommendationResponseCollection aggregatedResponse = null; + for (var batch : createBatches(request.getRequests())) { + var chunkedRequest = new ContentRecommendationRequestCollection(); + chunkedRequest.setRequireDistinctContentsAcrossResults(request.getRequireDistinctContentsAcrossResults()); + chunkedRequest.setRequests(batch.toArray(new ContentRecommendationRequest[0])); + var chunkResponse = makeRequestAndValidate("ContentRecommendationRequestCollection", chunkedRequest, ContentRecommendationResponseCollection.class); + if (chunkResponse == null) { + continue; + } + if (aggregatedResponse == null) { + aggregatedResponse = chunkResponse; + } + else { + if (chunkResponse.getResponses() != null) { + var responses = aggregatedResponse.getResponses() == null + ? new ArrayList() + : new ArrayList<>(Arrays.asList(aggregatedResponse.getResponses())); + responses.addAll(Arrays.asList(chunkResponse.getResponses())); + aggregatedResponse.setResponses(responses.toArray(new ContentRecommendationResponse[0])); + } + } + } + return aggregatedResponse; } public ProductRecommendationResponse recommend(ProductRecommendationRequest request) throws IOException, InterruptedException, ClientException { diff --git a/src/src/main/java/com/relewise/client/Searcher.java b/src/src/main/java/com/relewise/client/Searcher.java index b5f4e6b..7723675 100644 --- a/src/src/main/java/com/relewise/client/Searcher.java +++ b/src/src/main/java/com/relewise/client/Searcher.java @@ -3,6 +3,7 @@ import com.relewise.client.model.*; import com.relewise.client.infrastructure.*; import java.io.IOException; +import java.util.*; public class Searcher extends RelewiseClient { @@ -30,6 +31,39 @@ public SearchTermPredictionResponse predict(SearchTermPredictionRequest request) } public SearchResponseCollection batch(SearchRequestCollection request) throws IOException, InterruptedException, ClientException { - return makeRequestAndValidate("SearchRequestCollection", request, SearchResponseCollection.class); + if (request.getRequests() == null || request.getRequests().isEmpty()) { + return null; + } + SearchResponseCollection aggregatedResponse = null; + for (var batch : createBatches(request.getRequests())) { + var chunkedRequest = new SearchRequestCollection(); + chunkedRequest.setLanguage(request.getLanguage()); + chunkedRequest.setCurrency(request.getCurrency()); + chunkedRequest.setUser(request.getUser()); + chunkedRequest.setDisplayedAtLocation(request.getDisplayedAtLocation()); + chunkedRequest.setRelevanceModifiers(request.getRelevanceModifiers()); + chunkedRequest.setFilters(request.getFilters()); + chunkedRequest.setIndexSelector(request.getIndexSelector()); + chunkedRequest.setPostFilters(request.getPostFilters()); + chunkedRequest.setChannel(request.getChannel()); + chunkedRequest.setRequests(batch.toArray(new SearchRequest[0])); + var chunkResponse = makeRequestAndValidate("SearchRequestCollection", chunkedRequest, SearchResponseCollection.class); + if (chunkResponse == null) { + continue; + } + if (aggregatedResponse == null) { + aggregatedResponse = chunkResponse; + } + else { + if (chunkResponse.getResponses() != null) { + var responses = aggregatedResponse.getResponses() == null + ? new ArrayList() + : new ArrayList<>(Arrays.asList(aggregatedResponse.getResponses())); + responses.addAll(Arrays.asList(chunkResponse.getResponses())); + aggregatedResponse.setResponses(responses.toArray(new SearchResponse[0])); + } + } + } + return aggregatedResponse; } } diff --git a/src/src/main/java/com/relewise/client/Tracker.java b/src/src/main/java/com/relewise/client/Tracker.java index ab5cb3a..e0d81c4 100644 --- a/src/src/main/java/com/relewise/client/Tracker.java +++ b/src/src/main/java/com/relewise/client/Tracker.java @@ -3,6 +3,7 @@ import com.relewise.client.model.*; import com.relewise.client.infrastructure.*; import java.io.IOException; +import java.util.*; public class Tracker extends RelewiseClient { @@ -10,7 +11,14 @@ public class Tracker extends RelewiseClient public Tracker(String datasetId, String apiKey, String serverUrl, int timeout) { super(datasetId, apiKey, serverUrl, timeout); } public void track(BatchedTrackingRequest trackingRequest) throws IOException, InterruptedException, ClientException { - makeRequestAndValidate("BatchedTrackingRequest", trackingRequest, Void.class); + if (trackingRequest.getItems() == null || trackingRequest.getItems().length == 0) { + return; + } + for (var batch : createBatches(trackingRequest.getItems())) { + var chunkedRequest = new BatchedTrackingRequest(); + chunkedRequest.setItems(batch.toArray(new Trackable[0])); + makeRequestAndValidate("BatchedTrackingRequest", chunkedRequest, Void.class); + } } public void track(TrackBrandAdministrativeActionRequest trackingRequest) throws IOException, InterruptedException, ClientException { diff --git a/src/src/main/java/com/relewise/client/infrastructure/RelewiseClient.java b/src/src/main/java/com/relewise/client/infrastructure/RelewiseClient.java index 9935779..9069509 100644 --- a/src/src/main/java/com/relewise/client/infrastructure/RelewiseClient.java +++ b/src/src/main/java/com/relewise/client/infrastructure/RelewiseClient.java @@ -12,8 +12,11 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Properties; -import java.util.function.Supplier; public class RelewiseClient { private static final String apiKeyNotDefinedMessage = "apiKey must not be empty."; @@ -26,6 +29,7 @@ public class RelewiseClient { private final String serverUrl; private final int timeout; private final ObjectMapper objectMapper; + private int batchSize = 1000; public RelewiseClient(String datasetId, String apiKey, String serverUrl, int timeout) { if (apiKey.isBlank()) { @@ -51,6 +55,36 @@ public RelewiseClient(String datasetId, String apiKey, String serverUrl, int tim } } + /** Returns the maximum number of items sent in one batch request. */ + public int getBatchSize() { + return batchSize; + } + + /** Configures the maximum number of items sent in one batch request. */ + public void setBatchSize(int batchSize) { + if (batchSize < 1) { + throw new IllegalArgumentException("batchSize must be greater than 0."); + } + this.batchSize = batchSize; + } + + protected List> createBatches(T[] items) { + return items == null ? Collections.emptyList() : createBatches(Arrays.asList(items)); + } + + protected List> createBatches(List items) { + if (items == null || items.isEmpty()) { + return Collections.emptyList(); + } + + var configuredBatchSize = batchSize; + var batches = new ArrayList>(((items.size() - 1) / configuredBatchSize) + 1); + for (int offset = 0; offset < items.size(); offset += configuredBatchSize) { + batches.add(new ArrayList<>(items.subList(offset, Math.min(offset + configuredBatchSize, items.size())))); + } + return batches; + } + public HttpResponse makeRequestAsync(String endpoint, LicensedRequest requestBody) throws IOException, InterruptedException { var stringRequestBody = objectMapper.writeValueAsString(requestBody); diff --git a/src/src/test/java/com/relewise/client/BatchingTest.java b/src/src/test/java/com/relewise/client/BatchingTest.java new file mode 100644 index 0000000..0769dea --- /dev/null +++ b/src/src/test/java/com/relewise/client/BatchingTest.java @@ -0,0 +1,192 @@ +package com.relewise.client; + +import com.relewise.client.model.*; +import org.junit.jupiter.api.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class BatchingTest { + @Test + public void trackerSplitsItemsIntoConfiguredBatchSize() throws Exception { + var tracker = new RecordingTracker(); + tracker.setBatchSize(2); + + var items = new Trackable[] { + new TestTrackable(), + new TestTrackable(), + new TestTrackable(), + new TestTrackable(), + new TestTrackable() + }; + var request = BatchedTrackingRequest.create(items); + + tracker.track(request); + + assertEquals(3, tracker.requests.size()); + assertArrayEquals(Arrays.copyOfRange(items, 0, 2), tracker.requests.get(0).getItems()); + assertArrayEquals(Arrays.copyOfRange(items, 2, 4), tracker.requests.get(1).getItems()); + assertArrayEquals(Arrays.copyOfRange(items, 4, 5), tracker.requests.get(2).getItems()); + assertArrayEquals(items, request.getItems(), "The original request must remain unchanged"); + assertNotSame(request, tracker.requests.get(0)); + assertNotSame(tracker.requests.get(0), tracker.requests.get(1)); + } + + @Test + public void searcherAggregatesResponsesInRequestOrder() throws Exception { + var searcher = new RecordingSearcher(); + searcher.setBatchSize(2); + + var requests = new SearchRequest[] { + new TestSearchRequest(), + new TestSearchRequest(), + new TestSearchRequest(), + new TestSearchRequest(), + new TestSearchRequest() + }; + var responses = new SearchResponse[] { + new TestSearchResponse(), + new TestSearchResponse(), + new TestSearchResponse(), + new TestSearchResponse(), + new TestSearchResponse() + }; + var request = SearchRequestCollection.create(requests).setDisplayedAtLocation("batching-test"); + searcher.queue(SearchResponseCollection.create().setResponses(Arrays.copyOfRange(responses, 0, 2))); + searcher.queue(SearchResponseCollection.create().setResponses(Arrays.copyOfRange(responses, 2, 4))); + searcher.queue(SearchResponseCollection.create().setResponses(Arrays.copyOfRange(responses, 4, 5))); + + var result = searcher.batch(request); + + assertNotNull(result); + assertArrayEquals(responses, result.getResponses()); + assertEquals(3, searcher.requests.size()); + assertEquals(2, searcher.requests.get(0).getRequests().size()); + assertEquals(2, searcher.requests.get(1).getRequests().size()); + assertEquals(1, searcher.requests.get(2).getRequests().size()); + assertEquals("batching-test", searcher.requests.get(0).getDisplayedAtLocation()); + assertEquals(5, request.getRequests().size(), "The original request must remain unchanged"); + } + + @Test + public void recommenderAggregatesResponsesAndCopiesCollectionSettings() throws Exception { + var recommender = new RecordingRecommender(); + recommender.setBatchSize(2); + + var requests = new ProductRecommendationRequest[] { + new TestProductRecommendationRequest(), + new TestProductRecommendationRequest(), + new TestProductRecommendationRequest() + }; + var responses = new ProductRecommendationResponse[] { + new ProductRecommendationResponse(), + new ProductRecommendationResponse(), + new ProductRecommendationResponse() + }; + var request = ProductRecommendationRequestCollection.create(true, requests); + recommender.queue(ProductRecommendationResponseCollection.create(Arrays.copyOfRange(responses, 0, 2))); + recommender.queue(ProductRecommendationResponseCollection.create(Arrays.copyOfRange(responses, 2, 3))); + + var result = recommender.recommend(request); + + assertNotNull(result); + assertArrayEquals(responses, result.getResponses()); + assertEquals(2, recommender.requests.size()); + assertTrue(recommender.requests.get(0).getRequireDistinctProductsAcrossResults()); + assertTrue(recommender.requests.get(1).getRequireDistinctProductsAcrossResults()); + assertEquals(3, request.getRequests().size(), "The original request must remain unchanged"); + } + + @Test + public void emptyCollectionsDoNotSendRequests() throws Exception { + var tracker = new RecordingTracker(); + var searcher = new RecordingSearcher(); + + tracker.track(new BatchedTrackingRequest()); + var response = searcher.batch(new SearchRequestCollection()); + + assertTrue(tracker.requests.isEmpty()); + assertTrue(searcher.requests.isEmpty()); + assertNull(response); + } + + @Test + public void batchSizeMustBeGreaterThanZero() { + var tracker = new RecordingTracker(); + + assertEquals(1000, tracker.getBatchSize()); + assertThrows(IllegalArgumentException.class, () -> tracker.setBatchSize(0)); + assertThrows(IllegalArgumentException.class, () -> tracker.setBatchSize(-1)); + } + + private static class RecordingTracker extends Tracker { + private final List requests = new ArrayList<>(); + + private RecordingTracker() { + super("dataset-id", "api-key", "https://example.com"); + } + + @Override + public T makeRequestAndValidate(String endpoint, LicensedRequest requestBody, Class responseClass) { + assertEquals("BatchedTrackingRequest", endpoint); + requests.add((BatchedTrackingRequest) requestBody); + return null; + } + } + + private static class RecordingSearcher extends Searcher { + private final List requests = new ArrayList<>(); + private final ArrayDeque responses = new ArrayDeque<>(); + + private RecordingSearcher() { + super("dataset-id", "api-key", "https://example.com"); + } + + private void queue(SearchResponseCollection response) { + responses.add(response); + } + + @Override + public T makeRequestAndValidate(String endpoint, LicensedRequest requestBody, Class responseClass) { + assertEquals("SearchRequestCollection", endpoint); + requests.add((SearchRequestCollection) requestBody); + return responseClass.cast(responses.remove()); + } + } + + private static class RecordingRecommender extends Recommender { + private final List requests = new ArrayList<>(); + private final ArrayDeque responses = new ArrayDeque<>(); + + private RecordingRecommender() { + super("dataset-id", "api-key", "https://example.com"); + } + + private void queue(ProductRecommendationResponseCollection response) { + responses.add(response); + } + + @Override + public T makeRequestAndValidate(String endpoint, LicensedRequest requestBody, Class responseClass) { + assertEquals("ProductRecommendationRequestCollection", endpoint); + requests.add((ProductRecommendationRequestCollection) requestBody); + return responseClass.cast(responses.remove()); + } + } + + private static class TestTrackable extends Trackable { + } + + private static class TestSearchRequest extends SearchRequest { + } + + private static class TestSearchResponse extends SearchResponse { + } + + private static class TestProductRecommendationRequest extends ProductRecommendationRequest { + } +}