org.fuin.esc.eshttp.ESHttpEventStore.java Source code

Java tutorial

Introduction

Here is the source code for org.fuin.esc.eshttp.ESHttpEventStore.java

Source

/**
 * Copyright (C) 2015 Michael Schnell. All rights reserved. 
 * http://www.fuin.org/
 *
 * This library is free software; you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation; either version 3 of the License, or (at your option) any
 * later version.
 *
 * This library is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this library. If not, see http://www.gnu.org/licenses/.
 */
package org.fuin.esc.eshttp;

import static org.fuin.esc.api.ExpectedVersion.ANY;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;

import javax.validation.constraints.NotNull;

import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.StatusLine;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
import org.apache.http.impl.nio.client.HttpAsyncClients;
import org.apache.http.util.EntityUtils;
import org.fuin.esc.api.CommonEvent;
import org.fuin.esc.api.EventNotFoundException;
import org.fuin.esc.api.EventStore;
import org.fuin.esc.api.ExpectedVersion;
import org.fuin.esc.api.ProjectionAdminEventStore;
import org.fuin.esc.api.SimpleStreamId;
import org.fuin.esc.api.StreamAlreadyExistsException;
import org.fuin.esc.api.StreamDeletedException;
import org.fuin.esc.api.StreamEventsSlice;
import org.fuin.esc.api.StreamId;
import org.fuin.esc.api.StreamNotFoundException;
import org.fuin.esc.api.StreamReadOnlyException;
import org.fuin.esc.api.StreamState;
import org.fuin.esc.api.TypeName;
import org.fuin.esc.api.WrongExpectedVersionException;
import org.fuin.esc.spi.DeserializerRegistry;
import org.fuin.esc.spi.EnhancedMimeType;
import org.fuin.esc.spi.EscSpiUtils;
import org.fuin.esc.spi.SerializerRegistry;
import org.fuin.objects4j.common.ConstraintViolationException;
import org.fuin.objects4j.common.Contract;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Implementation that connects to the http://www.geteventstore.com via HTTP
 * API.
 */
public final class ESHttpEventStore implements EventStore, ProjectionAdminEventStore {

    private static final Logger LOG = LoggerFactory.getLogger(ESHttpEventStore.class);

    private final ThreadFactory threadFactory;

    private final URL url;

    private final ESEnvelopeType envelopeType;

    private final SerializerRegistry serRegistry;

    private final DeserializerRegistry desRegistry;

    private final CredentialsProvider credentialsProvider;

    private CloseableHttpAsyncClient httpclient;

    private boolean open;

    /**
     * Constructor with all mandatory data.
     * 
     * @param threadFactory
     *            Factory used to create the necessary internal threads.
     * @param url
     *            Event store base URL like "http://127.0.0.1:2113/".
     * @param envelopeType
     *            Envelope type for reading/writing events.
     * @param serRegistry
     *            Registry used to locate serializers.
     * @param desRegistry
     *            Registry used to locate deserializers.
     */
    public ESHttpEventStore(@NotNull final ThreadFactory threadFactory, @NotNull final URL url,
            @NotNull final ESEnvelopeType envelopeType, @NotNull final SerializerRegistry serRegistry,
            @NotNull final DeserializerRegistry desRegistry) {
        this(threadFactory, url, envelopeType, serRegistry, desRegistry, null);
    }

    /**
     * Constructor with credential provider.
     * 
     * @param threadFactory
     *            Factory used to create the necessary internal threads.
     * @param url
     *            Event store base URL like "http://127.0.0.1:2113/".
     * @param envelopeType
     *            Envelope type for reading/writing events.
     * @param serRegistry
     *            Registry used to locate serializers.
     * @param desRegistry
     *            Registry used to locate deserializers.
     * @param credentialsProvider
     *            Provided authentication information.
     */
    public ESHttpEventStore(@NotNull final ThreadFactory threadFactory, @NotNull final URL url,
            @NotNull final ESEnvelopeType envelopeType, @NotNull final SerializerRegistry serRegistry,
            @NotNull final DeserializerRegistry desRegistry, final CredentialsProvider credentialsProvider) {
        super();
        Contract.requireArgNotNull("threadFactory", threadFactory);
        Contract.requireArgNotNull("url", url);
        Contract.requireArgNotNull("envelopeType", envelopeType);
        Contract.requireArgNotNull("serRegistry", serRegistry);
        Contract.requireArgNotNull("desRegistry", desRegistry);
        this.threadFactory = threadFactory;
        this.url = url;
        this.envelopeType = envelopeType;
        this.serRegistry = serRegistry;
        this.desRegistry = desRegistry;
        this.credentialsProvider = credentialsProvider;
        this.open = false;
    }

    @Override
    public void open() {
        if (open) {
            // Ignore
            return;
        }
        final HttpAsyncClientBuilder builder = HttpAsyncClients.custom().setThreadFactory(threadFactory);
        if (credentialsProvider != null) {
            builder.setDefaultCredentialsProvider(credentialsProvider);
        }
        httpclient = builder.build();
        httpclient.start();
        this.open = true;
    }

    @Override
    public void close() {
        if (!open) {
            // Ignore
            return;
        }
        try {
            httpclient.close();
        } catch (final IOException ex) {
            throw new RuntimeException("Cannot close http client", ex);
        }
        this.open = false;
    }

    @Override
    public final boolean isSupportsCreateStream() {
        return false;
    }

    @Override
    public final void createStream(final StreamId streamId) throws StreamAlreadyExistsException {
        // Do nothing as the operation is not supported
    }

    @Override
    public final int appendToStream(final StreamId streamId, final CommonEvent... events) {
        return appendToStream(streamId, -2, EscSpiUtils.asList(events));
    }

    @Override
    public final int appendToStream(final StreamId streamId, final int expectedVersion,
            final CommonEvent... events) {
        return appendToStream(streamId, expectedVersion, EscSpiUtils.asList(events));
    }

    @Override
    public final int appendToStream(final StreamId streamId, final List<CommonEvent> events) {
        return appendToStream(streamId, -2, events);
    }

    @Override
    public int appendToStream(final StreamId streamId, final int expectedVersion,
            final List<CommonEvent> commonEvents)
            throws StreamDeletedException, WrongExpectedVersionException, StreamReadOnlyException {

        Contract.requireArgNotNull("streamId", streamId);
        Contract.requireArgMin("expectedVersion", expectedVersion, ExpectedVersion.ANY.getNo());
        Contract.requireArgNotNull("commonEvents", commonEvents);
        ensureOpen();

        if (streamId.isProjection()) {
            throw new StreamReadOnlyException(streamId);
        }

        final ESHttpMarshaller marshaller = envelopeType.getMarshaller();
        final EnhancedMimeType mimeType = EscSpiUtils.mimeType(serRegistry, commonEvents);
        // TODO Get next expected version from event store!
        final int nextExpectedVersion = 0;
        if (mimeType == null) {
            // Not all events have same type
            for (final CommonEvent commonEvent : commonEvents) {
                final List<CommonEvent> list = new ArrayList<>(1);
                list.add(commonEvent);
                final String content = marshaller.marshal(serRegistry, list);
                appendToStream(streamId, expectedVersion, mimeType, content, 1);
            }
        } else {
            // All events are of same type
            final String content = marshaller.marshal(serRegistry, commonEvents);
            appendToStream(streamId, expectedVersion, mimeType, content, commonEvents.size());
        }

        return nextExpectedVersion;
    }

    private void appendToStream(final StreamId streamId, final int expectedVersion, final EnhancedMimeType mimeType,
            final String content, final int count) throws StreamDeletedException, WrongExpectedVersionException {

        final String msg = "appendToStream(" + streamId + ", " + expectedVersion + ", " + mimeType + ", " + count
                + ")";
        try {
            final URI uri = new URIBuilder(url.toURI()).setPath("/streams/" + streamId).build();
            final HttpPost post = createPost(uri, expectedVersion, content);
            try {
                LOG.debug(msg + " POST: {}", post);

                final Future<HttpResponse> future = httpclient.execute(post, null);
                final HttpResponse response = future.get();
                final StatusLine statusLine = response.getStatusLine();
                if (statusLine.getStatusCode() == 201) {
                    // CREATED - Event(s) where added
                    LOG.debug(msg + " RESPONSE: {}", response);
                    return;
                }
                if (statusLine.getStatusCode() == 301) {
                    // FOUND - Event(s) already existed and where not created
                    // again
                    // (Idempotency)
                    LOG.debug(msg + " RESPONSE: {}", response);
                    return;
                }
                if ((statusLine.getStatusCode() == 400)
                        && !statusLine.getReasonPhrase().contains("request body invalid")) {
                    // TODO Add expected version instead of any version if ES
                    // returns this in header
                    LOG.debug(msg + " RESPONSE: {}", response);
                    throw new WrongExpectedVersionException(streamId, expectedVersion, null);
                }
                if (statusLine.getStatusCode() == 410) {
                    // Stream was hard deleted
                    LOG.debug(msg + " RESPONSE: {}", response);
                    throw new StreamDeletedException(streamId);
                }

                LOG.debug(msg + " RESPONSE: {}", response);
                throw new RuntimeException(msg + " [Status=" + statusLine + ", Content=" + content + "]");

            } finally {
                post.reset();
            }
        } catch (final URISyntaxException | InterruptedException | ExecutionException ex) {
            throw new RuntimeException(msg, ex);
        }

    }

    @Override
    public void deleteStream(final StreamId streamId, final int expectedVersion, final boolean hardDelete)
            throws StreamNotFoundException, StreamDeletedException, WrongExpectedVersionException {

        Contract.requireArgNotNull("streamId", streamId);
        Contract.requireArgMin("expectedVersion", expectedVersion, ExpectedVersion.ANY.getNo());
        ensureOpen();

        if (streamId.isProjection()) {
            throw new StreamReadOnlyException(streamId);
        }

        final String msg = "deleteStream(" + streamId + ", " + expectedVersion + ", " + hardDelete + ")";
        try {
            final URI uri = new URIBuilder(url.toURI()).setPath("/streams/" + streamId).build();
            final HttpDelete delete = new HttpDelete(uri);
            try {
                delete.setHeader("ES-HardDelete", "" + hardDelete);
                delete.setHeader("ES-ExpectedVersion", "" + expectedVersion);
                LOG.debug(msg + " DELETE: {}", delete);

                final Future<HttpResponse> future = httpclient.execute(delete, null);
                final HttpResponse response = future.get();
                final StatusLine statusLine = response.getStatusLine();
                if (statusLine.getStatusCode() == 204) {
                    // Stream deleted
                    LOG.debug(msg + " RESPONSE: {}", response);
                    return;
                }
                if (statusLine.getStatusCode() == 400) {
                    // TODO Add expected version instead of any version if ES
                    // returns this in header
                    LOG.debug(msg + " RESPONSE: {}", response);
                    throw new WrongExpectedVersionException(streamId, expectedVersion, null);
                }
                if (statusLine.getStatusCode() == 410) {
                    // 410 GONE - Stream was hard deleted
                    LOG.debug(msg + " RESPONSE: {}", response);
                    throw new StreamDeletedException(streamId);
                }

                LOG.debug(msg + " RESPONSE: {}", response);
                throw new RuntimeException(msg + " [Status=" + statusLine + "]");

            } finally {
                delete.reset();
            }

        } catch (final URISyntaxException | ExecutionException | InterruptedException ex) {
            throw new RuntimeException(msg, ex);
        }

    }

    @Override
    public void deleteStream(final StreamId streamId, final boolean hardDelete)
            throws StreamNotFoundException, StreamDeletedException {
        deleteStream(streamId, ANY.getNo(), hardDelete);
    }

    @Override
    public StreamEventsSlice readEventsForward(final StreamId streamId, final int start, final int count) {

        Contract.requireArgNotNull("streamId", streamId);
        Contract.requireArgMin("start", start, 0);
        Contract.requireArgMin("count", count, 1);
        ensureOpen();

        final String msg = "readEventsForward(" + streamId + ", " + start + ", " + count + ")";
        try {
            final URI uri = new URIBuilder(url.toURI())
                    .setPath("/streams/" + streamName(streamId) + "/" + start + "/forward/" + count).build();
            return readEvents(streamId, true, uri, start, count, msg, false);
        } catch (final IOException | URISyntaxException | InterruptedException | ExecutionException ex) {
            throw new RuntimeException(msg, ex);
        }

    }

    @Override
    public StreamEventsSlice readEventsBackward(final StreamId streamId, final int start, final int count) {

        Contract.requireArgNotNull("streamId", streamId);
        Contract.requireArgMin("start", start, 0);
        Contract.requireArgMin("count", count, 1);
        ensureOpen();

        final String msg = "readEventsBackward(" + streamId + ", " + start + ", " + count + ")";
        try {
            final URI uri = new URIBuilder(url.toURI())
                    .setPath("/streams/" + streamName(streamId) + "/" + start + "/backward/" + count).build();
            return readEvents(streamId, false, uri, start, count, msg, true);
        } catch (final IOException | URISyntaxException | InterruptedException | ExecutionException ex) {
            throw new RuntimeException(msg, ex);
        }
    }

    @Override
    public CommonEvent readEvent(final StreamId streamId, final int eventNumber) {

        Contract.requireArgNotNull("streamId", streamId);
        Contract.requireArgMin("eventNumber", eventNumber, 0);
        ensureOpen();

        final String msg = "readEvent(" + streamId + ", " + eventNumber + ")";
        try {
            final URI uri = new URIBuilder(url.toURI())
                    .setPath("/streams/" + streamName(streamId) + "/" + eventNumber).build();
            return readEvent(uri);
        } catch (final URISyntaxException ex) {
            throw new RuntimeException(msg, ex);
        }
    }

    @Override
    public final boolean streamExists(final StreamId streamId) {

        Contract.requireArgNotNull("streamId", streamId);
        ensureOpen();

        final String msg = "streamExists(" + streamId + ")";
        try {
            final URI uri = new URIBuilder(url.toURI()).setPath("/streams/" + streamName(streamId)).build();
            LOG.debug(uri.toString());
            final HttpGet get = createHttpGet(uri);
            try {
                final Future<HttpResponse> future = httpclient.execute(get, null);
                final HttpResponse response = future.get();
                final StatusLine status = response.getStatusLine();
                if (status.getStatusCode() == 404) {
                    return false;
                }
                if (status.getStatusCode() == 410) {
                    // Stream was hard deleted
                    return false;
                }
                if (status.getStatusCode() == 200) {
                    return true;
                }
                LOG.debug(msg + " RESPONSE: {}", response);
                throw new RuntimeException(msg + " [Status=" + status + "]");
            } finally {
                get.reset();
            }
        } catch (final URISyntaxException | InterruptedException | ExecutionException ex) {
            throw new RuntimeException(msg, ex);
        }

    }

    @Override
    public final StreamState streamState(final StreamId streamId) {
        Contract.requireArgNotNull("streamId", streamId);
        ensureOpen();

        final String msg = "streamState(" + streamId + ")";
        try {
            final URI uri = new URIBuilder(url.toURI()).setPath("/streams/" + streamName(streamId)).build();
            LOG.debug(uri.toString());
            final HttpGet get = createHttpGet(uri);
            try {
                final Future<HttpResponse> future = httpclient.execute(get, null);
                final HttpResponse response = future.get();
                final StatusLine status = response.getStatusLine();
                if (status.getStatusCode() == 200) {
                    LOG.debug(msg + " RESPONSE: {}", response);
                    return StreamState.ACTIVE;
                }
                if (status.getStatusCode() == 404) {
                    // May have never existed or was soft deleted...
                    // TODO Maybe the event store can send something to
                    // distinguish this?
                    LOG.debug(msg + " RESPONSE: {}", response);
                    throw new StreamNotFoundException(streamId);
                }
                if (status.getStatusCode() == 410) {
                    // 410 GONE - Stream was hard deleted
                    LOG.debug(msg + " RESPONSE: {}", response);
                    return StreamState.HARD_DELETED;
                }
                LOG.debug(msg + " RESPONSE: {}", response);
                throw new RuntimeException(msg + " [Status=" + status + "]");
            } finally {
                get.reset();
            }
        } catch (final URISyntaxException | InterruptedException | ExecutionException ex) {
            throw new RuntimeException(msg, ex);
        }
    }

    @Override
    public boolean projectionExists(final StreamId projectionId) {

        Contract.requireArgNotNull("projectionId", projectionId);
        requireProjection(projectionId);
        ensureOpen();

        final String msg = "projectionExists(" + projectionId + ")";
        try {
            final URI uri = new URIBuilder(url.toURI()).setPath("/projection/" + projectionId.getName() + "/state")
                    .build();
            LOG.debug(uri.toString());
            final HttpGet get = new HttpGet(uri);
            get.setHeader("Accept", ESEnvelopeType.JSON.getMetaType());
            try {
                final Future<HttpResponse> future = httpclient.execute(get, null);
                final HttpResponse response = future.get();
                final StatusLine status = response.getStatusLine();
                if (status.getStatusCode() == 404) {
                    return false;
                }
                if (status.getStatusCode() == 200) {
                    return true;
                }
                LOG.debug(msg + " RESPONSE: {}", response);
                throw new RuntimeException(msg + " [Status=" + status + "]");
            } finally {
                get.reset();
            }
        } catch (final URISyntaxException | InterruptedException | ExecutionException ex) {
            throw new RuntimeException(msg, ex);
        }

    }

    @Override
    public final void enableProjection(final StreamId projectionId) throws StreamNotFoundException {
        enableDisable(projectionId, "enable");
    }

    @Override
    public final void disableProjection(final StreamId projectionId) throws StreamNotFoundException {
        enableDisable(projectionId, "disable");
    }

    private void enableDisable(final StreamId projectionId, final String action) {

        Contract.requireArgNotNull("projectionId", projectionId);
        requireProjection(projectionId);
        ensureOpen();

        final String msg = action + "Projection(" + projectionId + ")";
        try {
            final URI uri = new URIBuilder(url.toURI())
                    .setPath("/projection/" + projectionId.getName() + "/command/" + action).build();
            LOG.debug("{}", uri);
            final HttpPost post = createPost(uri, "", ESEnvelopeType.JSON);
            try {
                LOG.debug(msg + " POST: {}", post);
                final Future<HttpResponse> future = httpclient.execute(post, null);
                final HttpResponse response = future.get();
                final StatusLine status = response.getStatusLine();
                LOG.debug(msg + " RESPONSE: {}", response);
                if (status.getStatusCode() == 200) {
                    return;
                }
                if (status.getStatusCode() == 404) {
                    // 404 Not Found
                    throw new StreamNotFoundException(projectionId);
                }
                throw new RuntimeException(msg + " [Status=" + status + "]");
            } finally {
                post.reset();
            }
        } catch (final URISyntaxException | InterruptedException | ExecutionException ex) {
            throw new RuntimeException(msg, ex);
        }
    }

    @Override
    public final void createProjection(final StreamId projectionId, final boolean enable,
            final TypeName... eventType) throws StreamAlreadyExistsException {

        Contract.requireArgNotNull("eventType", eventType);
        createProjection(projectionId, enable, Arrays.asList(eventType));

    }

    @Override
    public final void createProjection(final StreamId projectionId, final boolean enable,
            final List<TypeName> eventTypes) throws StreamAlreadyExistsException {

        Contract.requireArgNotNull("projectionId", projectionId);
        Contract.requireArgNotNull("eventTypes", eventTypes);
        requireProjection(projectionId);
        ensureOpen();

        final String msg = "createProjection(" + projectionId + "," + enable + type2str(eventTypes) + ")";
        try {
            final URI uri = new URIBuilder(url.toURI()).setPath("/projections/continuous")
                    .addParameter("name", projectionId.getName()).addParameter("emit", "yes")
                    .addParameter("checkpoints", "yes").addParameter("enabled", ESHttpUtils.yesNo(enable)).build();
            final String javascript = new ProjectionJavaScriptBuilder(projectionId).types(eventTypes).build();
            LOG.debug("{}: {}", uri, javascript);
            final HttpPost post = createPost(uri, javascript, ESEnvelopeType.JSON);
            try {
                LOG.debug(msg + " POST: {}", post);
                final Future<HttpResponse> future = httpclient.execute(post, null);
                final HttpResponse response = future.get();
                final StatusLine status = response.getStatusLine();
                LOG.debug(msg + " RESPONSE: {}", response);
                if (status.getStatusCode() == 201) {
                    // CREATED
                    return;
                }
                throw new RuntimeException(msg + " [Status=" + status + "]");
            } finally {
                post.reset();
            }
        } catch (final URISyntaxException | InterruptedException | ExecutionException ex) {
            throw new RuntimeException(msg, ex);
        }

    }

    @Override
    public final void deleteProjection(final StreamId projectionId) throws StreamNotFoundException {

        Contract.requireArgNotNull("projectionId", projectionId);
        requireProjection(projectionId);
        ensureOpen();

        final String msg = "deleteProjection(" + projectionId + ")";
        try {
            final URI uri = new URIBuilder(url.toURI()).setPath("/projection/" + projectionId.getName())
                    .addParameter("deleteCheckpointStream", "yes").addParameter("deleteStateStream", "yes").build();
            final HttpDelete delete = new HttpDelete(uri);
            try {
                LOG.debug(msg + " DELETE: {}", delete);
                final Future<HttpResponse> future = httpclient.execute(delete, null);
                final HttpResponse response = future.get();
                final StatusLine statusLine = response.getStatusLine();
                LOG.debug(msg + " RESPONSE: {}", response);
                if (statusLine.getStatusCode() == 204) {
                    // Also delete the event stream
                    deleteStream(new SimpleStreamId(projectionId.getName()), false);
                    return;
                }
                if (statusLine.getStatusCode() == 404) {
                    throw new StreamNotFoundException(projectionId);
                }
                throw new RuntimeException(msg + " [Status=" + statusLine + "]");

            } finally {
                delete.reset();
            }

        } catch (final URISyntaxException | ExecutionException | InterruptedException ex) {
            throw new RuntimeException(msg, ex);
        }

    }

    private void ensureOpen() {
        if (!open) {
            open();
        }
    }

    private StreamEventsSlice readEvents(final StreamId streamId, final boolean forward, final URI uri,
            final int start, final int count, final String msg, final boolean reverseOrder)
            throws InterruptedException, ExecutionException, IOException {
        LOG.debug(uri.toString());
        final HttpGet get = createHttpGet(uri);
        try {
            final Future<HttpResponse> future = httpclient.execute(get, null);
            final HttpResponse response = future.get();
            final StatusLine statusLine = response.getStatusLine();
            if (statusLine.getStatusCode() == 200) {
                final HttpEntity entity = response.getEntity();
                try {
                    final InputStream in = entity.getContent();
                    try {
                        final AtomFeedReader atomFeedReader = envelopeType.getAtomFeedReader();
                        final List<URI> uris = atomFeedReader.readAtomFeed(in);
                        return readEvents(forward, start, count, uris, reverseOrder);
                    } finally {
                        in.close();
                    }
                } finally {
                    EntityUtils.consume(entity);
                }
            }
            if (statusLine.getStatusCode() == 404) {
                // 404 Not Found
                LOG.debug(msg + " RESPONSE: {}", response);
                throw new StreamNotFoundException(streamId);
            }
            if (statusLine.getStatusCode() == 410) {
                // Stream was hard deleted
                LOG.debug(msg + " RESPONSE: {}", response);
                throw new StreamDeletedException(streamId);
            }
            throw new RuntimeException(msg + " [Status=" + statusLine + "]");
        } finally {
            get.reset();
        }
    }

    private StreamEventsSlice readEvents(final boolean forward, final int fromEventNumber, final int count,
            final List<URI> uris, final boolean reverseOrder) {
        final List<CommonEvent> events = new ArrayList<>();
        if (reverseOrder) {
            for (int i = 0; i < uris.size(); i++) {
                final URI uri = uris.get(i);
                events.add(readEvent(uri));
            }
        } else {
            for (int i = uris.size() - 1; i >= 0; i--) {
                final URI uri = uris.get(i);
                events.add(readEvent(uri));
            }
        }
        final int nextEventNumber;
        final boolean endOfStream;
        if (forward) {
            nextEventNumber = fromEventNumber + events.size();
            endOfStream = count > events.size();
        } else {
            nextEventNumber = ((fromEventNumber - count < 0)) ? 0 : fromEventNumber - count;
            endOfStream = (fromEventNumber - count < 0);
        }
        return new StreamEventsSlice(fromEventNumber, events, nextEventNumber, endOfStream);
    }

    private CommonEvent readEvent(final URI uri) {
        LOG.debug(uri.toString());
        final String msg = "readEvent(" + uri + ")";
        try {
            final HttpGet get = createHttpGet(uri);
            try {
                final Future<HttpResponse> future = httpclient.execute(get, null);
                final HttpResponse response = future.get();
                final StatusLine statusLine = response.getStatusLine();
                if (statusLine.getStatusCode() == 200) {
                    final HttpEntity entity = response.getEntity();
                    try {
                        final InputStream in = entity.getContent();
                        try {
                            return envelopeType.getAtomFeedReader().readEvent(desRegistry, in);
                        } finally {
                            in.close();
                        }
                    } finally {
                        EntityUtils.consume(entity);
                    }
                }
                if (statusLine.getStatusCode() == 404) {
                    // 404 Not Found
                    LOG.debug(msg + " RESPONSE: {}", response);
                    final StreamId streamId = streamId(uri);
                    final int eventNumber = eventNumber(uri);
                    throw new EventNotFoundException(streamId, eventNumber);
                }
                throw new RuntimeException(msg + " [Status=" + statusLine + "]");
            } finally {
                get.reset();
            }
        } catch (final InterruptedException | ExecutionException | UnsupportedOperationException | IOException ex) {
            throw new RuntimeException("Failed to read " + uri, ex);
        }
    }

    private StreamId streamId(final URI uri) {
        // http://127.0.0.1:2113/streams/append_diff_and_read_stream/2
        final String url = uri.toString();
        final int p1 = url.indexOf("/streams/");
        if (p1 == -1) {
            throw new IllegalStateException("Failed to extract '/streams/': " + uri);
        }
        final int p2 = url.lastIndexOf('/');
        if (p2 == -1) {
            throw new IllegalStateException("Failed to extract last '/': " + uri);
        }
        final String str = url.substring(p1 + 9, p2);
        return new SimpleStreamId(str);
    }

    private int eventNumber(final URI uri) {
        // http://127.0.0.1:2113/streams/append_diff_and_read_stream/2
        final String url = uri.toString();
        final int p = url.lastIndexOf('/');
        if (p == -1) {
            throw new IllegalStateException("Failed to extract event number: " + uri);
        }
        final String str = url.substring(p + 1);
        return Integer.valueOf(str);
    }

    private String streamName(final StreamId streamId) {
        if (streamId.equals(StreamId.ALL)) {
            return "$all";
        }
        return streamId.getName();
    }

    private HttpGet createHttpGet(final URI uri) {
        return createHttpGet(uri, envelopeType);
    }

    private static HttpGet createHttpGet(final URI uri, final ESEnvelopeType envelopeType) {
        final HttpGet request = new HttpGet(uri);
        request.setHeader("Accept", envelopeType.getReadContentType());
        return request;
    }

    private HttpPost createPost(final URI uri, final int expectedVersion, final String content) {
        return createPost(uri, expectedVersion, content, envelopeType);
    }

    private static HttpPost createPost(final URI uri, final int expectedVersion, final String content,
            final ESEnvelopeType envelopeType) {
        final HttpPost post = createPost(uri, content, envelopeType);
        post.setHeader("ES-ExpectedVersion", "" + expectedVersion);
        return post;
    }

    private static HttpPost createPost(final URI uri, final String content, final ESEnvelopeType envelopeType) {
        final HttpPost post = new HttpPost(uri);
        post.setHeader("Content-Type",
                envelopeType.getWriteContentType() + "; charset=" + envelopeType.getMetaCharset());
        final ContentType contentType = ContentType.create(envelopeType.getMetaType(),
                envelopeType.getMetaCharset());
        post.setEntity(new StringEntity(content, contentType));
        return post;
    }

    private static String type2str(final List<TypeName> eventTypes) {
        final StringBuilder sb = new StringBuilder();
        for (final TypeName eventType : eventTypes) {
            sb.append(",");
            sb.append(eventType.asBaseType());
        }
        return sb.toString();
    }

    private static void requireProjection(final StreamId projectionId) {
        if (!projectionId.isProjection()) {
            throw new ConstraintViolationException("The stream identifier is not a projection id");
        }
    }

}