All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.serialized.client.aggregate.AggregateClient Maven / Gradle / Ivy

The newest version!
package io.serialized.client.aggregate;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.serialized.client.ApiException;
import io.serialized.client.ConcurrencyException;
import io.serialized.client.InvalidRequestException;
import io.serialized.client.SerializedClientConfig;
import io.serialized.client.SerializedOkHttpClient;
import io.serialized.client.aggregate.cache.StateCache;
import io.serialized.client.aggregate.cache.VersionedState;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Response;
import org.apache.commons.lang3.Validate;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.logging.Logger;

import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
import static com.fasterxml.jackson.databind.SerializationFeature.FAIL_ON_EMPTY_BEANS;
import static io.serialized.client.aggregate.AggregateRequest.MAX_EVENTS_IN_BATCH;
import static io.serialized.client.aggregate.BulkSaveEvents.newBulkSaveEvents;
import static io.serialized.client.aggregate.StateBuilder.stateBuilder;
import static java.lang.String.format;
import static java.util.logging.Level.INFO;

public class AggregateClient {

  private final Logger logger = Logger.getLogger(getClass().getName());

  private final SerializedOkHttpClient client;
  private final HttpUrl apiRoot;
  private final StateBuilder stateBuilder;
  private final String aggregateType;
  private final RetryStrategy retryStrategy;
  private final int limit;

  private AggregateClient(Builder builder) {
    this.client = new SerializedOkHttpClient(builder.httpClient, builder.objectMapper);
    this.apiRoot = builder.apiRoot;
    this.aggregateType = builder.aggregateType;
    this.stateBuilder = builder.stateBuilder;
    this.retryStrategy = builder.retryStrategy;
    this.limit = builder.limit;
  }

  public static  Builder aggregateClient(String aggregateType, Class stateClass, SerializedClientConfig config) {
    return new Builder<>(aggregateType, stateClass, config);
  }

  /**
   * Save or append events to an aggregate according to the given request
   *
   * @param request the request to perform
   */
  public void save(AggregateRequest request) {

    try {
      HttpUrl url = getAggregateUrl(request.aggregateId).addPathSegment("events").build();

      if (request.tenantId().isPresent()) {
        UUID tenantId = request.tenantId().get();
        client.post(url, request.eventBatch(), tenantId);
      } else {
        client.post(url, request.eventBatch());
      }
    } catch (ApiException e) {
      handleConcurrencyException(e);
    }
  }

  public void save(AggregateBulkRequest request) {

    try {
      HttpUrl url = getAggregateTypeUrl().addPathSegment("events").build();

      BulkSaveEvents payload = request.eventBatches();
      if (request.tenantId().isPresent()) {
        UUID tenantId = request.tenantId().get();
        client.post(url, payload, tenantId);
      } else {
        client.post(url, payload);
      }
    } catch (ApiException e) {
      handleConcurrencyException(e);
    }
  }

  /**
   * Update the aggregate.
   * 

* The update will be performed using optimistic concurrency check depending on the * {@link AggregateUpdate#useOptimisticConcurrencyOnUpdate} setting. * * @param aggregateId The ID of the aggregate. * @param update Function that executes business logic and returns the resulting domain events. * @return Number of events stored in batch */ public int update(String aggregateId, AggregateUpdate update) { return update(UUID.fromString(aggregateId), update); } /** * Update the aggregate. *

* The update will be performed using optimistic concurrency check depending on the * {@link AggregateUpdate#useOptimisticConcurrencyOnUpdate} setting. * * @param aggregateId The ID of the aggregate. * @param update Function that executes business logic and returns the resulting domain events. * @return Number of events stored in batch */ public int update(UUID aggregateId, AggregateUpdate update) { ConcurrencyException lastException = new ConcurrencyException(409, "Conflict"); for (int i = 0; i <= retryStrategy.getRetryCount(); i++) { try { return updateInternal(aggregateId, update, eventBatch -> storeBatch(aggregateId, update.tenantId(), eventBatch)); } catch (ConcurrencyException concurrencyException) { lastException = concurrencyException; try { Thread.sleep(retryStrategy.getSleepMs()); } catch (InterruptedException ie) { // ignore } } } throw lastException; } public int bulkUpdate(Set aggregateIds, AggregateUpdate update) { ConcurrencyException lastException = new ConcurrencyException(409, "Conflict"); for (int i = 0; i <= retryStrategy.getRetryCount(); i++) { try { List batches = new ArrayList<>(); for (UUID aggregateId : aggregateIds) { updateInternal(aggregateId, update, eventBatch -> { if (!eventBatch.events().isEmpty()) { batches.add(eventBatch.withAggregateId(aggregateId)); } return eventBatch.events().size(); }); } return storeBulk(update.tenantId(), batches); } catch (ConcurrencyException concurrencyException) { lastException = concurrencyException; try { Thread.sleep(retryStrategy.getSleepMs()); } catch (InterruptedException ie) { // ignore } } } throw lastException; } private int updateInternal(UUID aggregateId, AggregateUpdate update, java.util.function.Function onSave) { assertValidUpdateConfig(update); if (update.stateCache().isPresent()) { StateCache stateCache = update.stateCache().get(); Optional> cachedState = stateCache.get(aggregateId); final int currentVersion; final T currentState; if (cachedState.isPresent()) { VersionedState versionedState = cachedState.get(); currentVersion = versionedState.version(); currentState = versionedState.state(); } else { LoadAggregateResponse aggregateResponse = loadState(aggregateId, update.tenantId()); currentVersion = aggregateResponse.aggregateVersion; currentState = stateBuilder.buildState(aggregateResponse.events); } try { List> events = update.apply(currentState); if (events.size() >= MAX_EVENTS_IN_BATCH) { throw new InvalidRequestException(format("Cannot store more than %d events per batch", MAX_EVENTS_IN_BATCH)); } int eventStored = onSave.apply(new EventBatch(events, currentVersion)); if (eventStored > 0) { stateCache.put(aggregateId, new VersionedState<>(stateBuilder.buildState(currentState, events), currentVersion + 1)); } return eventStored; } catch (ConcurrencyException e) { logger.log(INFO, format("Concurrency exception detected - invalidating cached entry with ID [%s]", aggregateId.toString())); stateCache.invalidate(aggregateId); throw e; } } else { LoadAggregateResponse aggregateResponse = loadState(aggregateId, update.tenantId()); T state = stateBuilder.buildState(aggregateResponse.events); Integer expectedVersion = update.useOptimisticConcurrencyOnUpdate() ? aggregateResponse.aggregateVersion : null; List> events = update.apply(state); if (events.size() >= MAX_EVENTS_IN_BATCH) { throw new InvalidRequestException(format("Cannot store more than %d events per batch", MAX_EVENTS_IN_BATCH)); } return onSave.apply(new EventBatch(events, expectedVersion)); } } private void assertValidUpdateConfig(AggregateUpdate update) { if (update.stateCache().isPresent() && !update.useOptimisticConcurrencyOnUpdate()) { throw new IllegalArgumentException("Cannot use stateCache with optimisticConcurrencyOnUpdate disabled"); } } /** * Deletes all aggregate instances for current type or a single aggregate instance by its ID. * * @param delete Request * @return Confirmation for client to confirm. * @see AggregateDeleteConfirmation#confirm() */ public AggregateDeleteConfirmation delete(AggregateDelete delete) { if (delete.aggregateId == null) { return getDeleteToken(getAggregateTypeUrl(), delete.tenantId); } else { return getDeleteToken(getAggregateUrl(delete.aggregateId), delete.tenantId); } } /** * Check if an aggregate exists. * * @param exists Request * @return True if aggregate with ID exists, false if not. */ public boolean exists(AggregateExists exists) { try { HttpUrl url = getAggregateUrl(exists.aggregateId).build(); if (exists.tenantId == null) { return client.head(url, Response::code) == 200; } else { return client.head(url, Response::code, exists.tenantId) == 200; } } catch (ApiException e) { if (e.statusCode() == 404) { return false; } else { throw e; } } } private AggregateDeleteConfirmation getDeleteToken(HttpUrl.Builder urlBuilder, UUID tenantId) { if (tenantId == null) { HttpUrl deleteAggregateUrl = extractDeleteToken(urlBuilder, client.delete(urlBuilder.build(), Map.class)); return new AggregateDeleteConfirmation(client, deleteAggregateUrl); } else { HttpUrl deleteAggregateUrl = extractDeleteToken(urlBuilder, client.delete(urlBuilder.build(), Map.class, tenantId)); return new AggregateDeleteConfirmation(client, deleteAggregateUrl, tenantId); } } private HttpUrl extractDeleteToken(HttpUrl.Builder urlBuilder, Map deleteResponse) { return urlBuilder.addQueryParameter("deleteToken", deleteResponse.get("deleteToken")).build(); } private LoadAggregateResponse loadState(UUID aggregateId, Optional tenantId) { HttpUrl.Builder builder = getAggregateUrl(aggregateId).addQueryParameter("limit", String.valueOf(limit)); int since = 0; LoadAggregateResponse response = new LoadAggregateResponse(); if (tenantId.isPresent()) { do { HttpUrl url = builder.setQueryParameter("since", String.valueOf(since)).build(); response.merge(client.get(url, LoadAggregateResponse.class, tenantId.get())); since += limit; } while (response.hasMore); } else { do { HttpUrl url = builder.setQueryParameter("since", String.valueOf(since)).build(); response.merge(client.get(url, LoadAggregateResponse.class)); since += limit; } while (response.hasMore); } return response; } private int storeBatch(UUID aggregateId, Optional tenantId, EventBatch eventBatch) { int eventCount = eventBatch.events().size(); if (eventCount == 0) return 0; try { HttpUrl url = getAggregateUrl(aggregateId).addPathSegment("events").build(); if (tenantId.isPresent()) { client.post(url, eventBatch, tenantId.get()); } else { client.post(url, eventBatch); } } catch (ApiException e) { handleConcurrencyException(e); } return eventCount; } private int storeBulk(Optional tenantId, List batches) { if (batches.isEmpty()) return 0; try { HttpUrl url = getAggregateTypeUrl().addPathSegment("events").build(); if (tenantId.isPresent()) { client.post(url, newBulkSaveEvents(batches), tenantId.get()); } else { client.post(url, newBulkSaveEvents(batches)); } } catch (ApiException e) { handleConcurrencyException(e); } return batches.stream().map(EventBatch::events).mapToInt(List::size).sum(); } private void handleConcurrencyException(ApiException e) { if (e.statusCode() == 409) { throw new ConcurrencyException(409, e.getMessage()); } else { throw e; } } private HttpUrl.Builder getAggregateTypeUrl() { return apiRoot.newBuilder().addPathSegment("aggregates").addPathSegment(aggregateType); } private HttpUrl.Builder getAggregateUrl(UUID aggregateId) { return getAggregateTypeUrl().addPathSegment(aggregateId.toString()); } public static class Builder { private final ObjectMapper objectMapper = new ObjectMapper() .disable(FAIL_ON_UNKNOWN_PROPERTIES) .disable(FAIL_ON_EMPTY_BEANS) .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) .setSerializationInclusion(NON_NULL); private final HttpUrl apiRoot; private final OkHttpClient httpClient; private final StateBuilder stateBuilder; private final String aggregateType; private final Map eventTypes = new HashMap<>(); private RetryStrategy retryStrategy = RetryStrategy.DEFAULT; private UpdateStrategy updateStrategy = UpdateStrategy.DEFAULT; private int limit = 1000; Builder(String aggregateType, Class stateClass, SerializedClientConfig config) { this.aggregateType = aggregateType; this.apiRoot = config.apiRoot(); this.httpClient = config.newHttpClient(); this.stateBuilder = stateBuilder(stateClass); } /** * Registers which handler should be invoked during aggregate load/hydration. */ public Builder registerHandler(Class eventClass, EventHandler handler) { return registerHandler(eventClass.getSimpleName(), eventClass, handler); } /** * Registers which handler should be invoked during aggregate load/hydration. */ public Builder registerHandler(String eventType, Class eventClass, EventHandler handler) { this.eventTypes.put(eventType, eventClass); stateBuilder.withHandler(eventClass, handler); return this; } public Builder withRetryStrategy(RetryStrategy retryStrategy) { this.retryStrategy = retryStrategy; return this; } public Builder withUpdateStrategy(UpdateStrategy updateStrategy) { this.updateStrategy = updateStrategy; return this; } /** * Limits the number of returned versions (event batches) on aggregate load (during update). * * @param limit The limit. Default is 1000 (maximum number according to the API). */ public Builder withLimit(int limit) { this.limit = limit; return this; } /** * Allows object mapper customization. */ public Builder configureObjectMapper(Consumer consumer) { consumer.accept(objectMapper); return this; } public AggregateClient build() { Validate.notNull(aggregateType, "'aggregateType' must be set"); objectMapper.registerModule(EventDeserializer.module(eventTypes)); stateBuilder.setFailOnMissingHandler(updateStrategy.failOnMissingHandler()); stateBuilder.setIgnoredEventTypes(updateStrategy.ignoredEventTypes()); return new AggregateClient<>(this); } } private static class LoadAggregateResponse { String aggregateId; String aggregateType; int aggregateVersion; List> events; boolean hasMore; public void merge(LoadAggregateResponse response) { this.aggregateId = response.aggregateId; this.aggregateType = response.aggregateType; this.aggregateVersion = response.aggregateVersion; if (events == null) { this.events = new ArrayList<>(response.events); } else { this.events.addAll(response.events); } this.hasMore = response.hasMore; } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy