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

org.atmosphere.interceptor.SimpleRestInterceptor Maven / Gradle / Ivy

There is a newer version: 3.0.13
Show newest version
/*
 * Copyright 2016 Async-IO.org
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
package org.atmosphere.interceptor;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

import javax.servlet.ServletException;

import org.atmosphere.cpr.Action;
import org.atmosphere.cpr.ApplicationConfig;
import org.atmosphere.cpr.AsyncIOInterceptor;
import org.atmosphere.cpr.AsyncIOInterceptorAdapter;
import org.atmosphere.cpr.AsyncIOWriter;
import org.atmosphere.cpr.AtmosphereConfig;
import org.atmosphere.cpr.AtmosphereInterceptorAdapter;
import org.atmosphere.cpr.AtmosphereInterceptorWriter;
import org.atmosphere.cpr.AtmosphereRequest;
import org.atmosphere.cpr.AtmosphereRequestImpl;
import org.atmosphere.cpr.AtmosphereResource;
import org.atmosphere.cpr.AtmosphereResourceEvent;
import org.atmosphere.cpr.AtmosphereResourceEventListenerAdapter;
import org.atmosphere.cpr.AtmosphereResponse;
import org.atmosphere.cpr.Broadcaster;
import org.atmosphere.cpr.CompletionAware;
import org.atmosphere.cpr.DefaultBroadcaster;
import org.atmosphere.cpr.FrameworkConfig;
import org.atmosphere.util.ChunkConcatReaderPool;
import org.atmosphere.util.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * An Atmosphere interceptor to enable a simple rest binding protocol.
 * This protocol is a simplified version of SwaggerSocket with some new features
 * https://github.com/swagger-api/swaggersocket
 *
 * This interceptor currently handles both Websocket and SSE protocols.
 * It was originally developed for enabling atmosphere for kafka-rest at
 * https://github.com/elakito/kafka-rest-atmosphere
 *
 * @author elakito
 */
public class SimpleRestInterceptor extends AtmosphereInterceptorAdapter {
    private static final Logger LOG = LoggerFactory.getLogger(SimpleRestInterceptor.class);
    /**
     * Servlet init property to enable the detached mode in the response
     */
    public final static String PROTOCOL_DETACHED_KEY = "atmosphere.simple-rest.protocol.detached";

    /**
     * Connection request property to enable the detached mode in the response
     */
    public final static String X_ATMOSPHERE_SIMPLE_REST_PROTOCOL_DETACHED = "X-Atmosphere-SimpleRestProtocolDetached";

    protected final static String REQUEST_DISPATCHED = "request.dispatched";
    protected final static String REQUEST_ID = "request.id";

    private final static byte[] RESPONSE_TEMPLATE_HEAD = "{\"id\": \"".getBytes();
    private final static byte[] RESPONSE_TEMPLATE_BELLY = "\", \"data\": ".getBytes();
    private final static byte[] RESPONSE_TEMPLATE_BELLY_CONTINUE = "\", \"continue\":true, \"data\": ".getBytes();
    private final static byte[] RESPONSE_TEMPLATE_BELLY_DETACHED = "\", \"detached\": true".getBytes();
    private final static byte[] RESPONSE_TEMPLATE_BELLY_CONTINUE_DETACHED = "\", \"continue\":true, \"detached\": true".getBytes();
    private final static byte[] RESPONSE_TEMPLATE_TAIL = "}".getBytes();
    private final static byte[] RESPONSE_TEMPLATE_NEWLINE = "\n".getBytes();

    private final static String HEARTBEAT_BROADCASTER_NAME = "/simple-rest.heartbeat";
    private final static String HEARTBEAT_SCHEDULED = "heatbeat.scheduled";
    private final static String HEARTBEAT_TEMPLATE = "{\"heartbeat\": \"%s\", \"time\": %d}";
    private final static long DEFAULT_HEARTBEAT_INTERVAL = 60;

    private Map suspendedResponses = new HashMap();
    private ChunkConcatReaderPool readerPool = new ChunkConcatReaderPool();
    private boolean detached;

    private Broadcaster heartbeat;

    // REVISIST more appropriate to store this status?
    private boolean heartbeatScheduled;
    private final AsyncIOInterceptor interceptor = new Interceptor();

    public SimpleRestInterceptor() {
    }

    @Override
    public void configure(AtmosphereConfig config) {
        super.configure(config);
        detached = Boolean.parseBoolean(config.getInitParameter(PROTOCOL_DETACHED_KEY));
        //TODO make the heartbeat configurable
        heartbeat = config.getBroadcasterFactory().lookup(DefaultBroadcaster.class, getHeartbeatBroadcasterName());
        if (heartbeat == null) {
            heartbeat = config.getBroadcasterFactory().get(DefaultBroadcaster.class, getHeartbeatBroadcasterName());
        }
    }

    @Override
    public Action inspect(final AtmosphereResource r) {
        if (AtmosphereResource.TRANSPORT.WEBSOCKET != r.transport()
                && AtmosphereResource.TRANSPORT.SSE != r.transport()
                && AtmosphereResource.TRANSPORT.POLLING != r.transport()) {
            LOG.debug("Skipping for non websocket request");
            return Action.CONTINUE;
        }
        if (AtmosphereResource.TRANSPORT.POLLING == r.transport()) {
            final String saruuid = (String)r.getRequest().getAttribute(ApplicationConfig.SUSPENDED_ATMOSPHERE_RESOURCE_UUID);
            final AtmosphereResponse suspendedResponse = suspendedResponses.get(saruuid);
            LOG.debug("Attaching a proxy writer to suspended response");
            r.getResponse().asyncIOWriter(new AtmosphereInterceptorWriter() {
                @Override
                public AsyncIOWriter write(AtmosphereResponse r, String data) throws IOException {
                    suspendedResponse.write(data);
                    suspendedResponse.flushBuffer();
                    return this;
                }

                @Override
                public AsyncIOWriter write(AtmosphereResponse r, byte[] data) throws IOException {
                    suspendedResponse.write(data);
                    suspendedResponse.flushBuffer();
                    return this;
                }

                @Override
                public AsyncIOWriter write(AtmosphereResponse r, byte[] data, int offset, int length) throws IOException {
                    suspendedResponse.write(data, offset, length);
                    suspendedResponse.flushBuffer();
                    return this;
                }

                @Override
                public void close(AtmosphereResponse response) throws IOException {
                }
            });
            // REVISIT we need to keep this response's asyncwriter alive so that data can be written to the
            //   suspended response, but investigate if there is a better alternative.
            r.getResponse().destroyable(false);
            return Action.CONTINUE;
        }

        r.addEventListener(new AtmosphereResourceEventListenerAdapter() {
            @Override
            public void onSuspend(AtmosphereResourceEvent event) {
                final String srid = (String)event.getResource().getRequest().getAttribute(ApplicationConfig.SUSPENDED_ATMOSPHERE_RESOURCE_UUID);
                LOG.debug("Registrering suspended resource: {}", srid);
                suspendedResponses.put(srid, event.getResource().getResponse());

                AsyncIOWriter writer = event.getResource().getResponse().getAsyncIOWriter();
                if (writer == null) {
                    writer = new AtmosphereInterceptorWriter();
                    r.getResponse().asyncIOWriter(writer);
                }
                if (writer instanceof AtmosphereInterceptorWriter) {
                    ((AtmosphereInterceptorWriter)writer).interceptor(interceptor);
                }
            }

            @Override
            public void onDisconnect(AtmosphereResourceEvent event) {
                super.onDisconnect(event);
                final String srid = (String)event.getResource().getRequest().getAttribute(ApplicationConfig.SUSPENDED_ATMOSPHERE_RESOURCE_UUID);
                LOG.debug("Unregistrering suspended resource: {}", srid);
                suspendedResponses.remove(srid);
            }

        });

        AtmosphereRequest request = r.getRequest();
        if (request.getAttribute(REQUEST_DISPATCHED) == null) {
            try {
                //REVISIT use a more efficient approach for the detached mode (i.e.,avoid reading the message into a string)
                // read the message entity and dispatch a service call
                String body = IOUtils.readEntirelyAsString(r).toString();
                LOG.debug("Request message: '{}'", body);
                if (body.length() == 0) {
                    //TODO we might want to move this heartbeat scheduling after the handshake phase (if that is added)
                    if ((AtmosphereResource.TRANSPORT.WEBSOCKET == r.transport() ||
                            AtmosphereResource.TRANSPORT.SSE == r.transport())
                            && request.getAttribute(HEARTBEAT_SCHEDULED) == null) {
                        r.suspend();
                        scheduleHeartbeat(r);
                        request.setAttribute(HEARTBEAT_SCHEDULED, "true");
                        return Action.SUSPEND;
                    }
                    return Action.CANCELLED;
                }

                AtmosphereRequest ar = createAtmosphereRequest(request, body);
                if (ar == null) {
                    return Action.CANCELLED;
                }
                AtmosphereResponse response = r.getResponse();
                ar.localAttributes().put(REQUEST_DISPATCHED, "true");

                request.removeAttribute(FrameworkConfig.INJECTED_ATMOSPHERE_RESOURCE);
                response.request(ar);

                attachWriter(r);

                Action action = r.getAtmosphereConfig().framework().doCometSupport(ar, response);
                if (action.type() == Action.TYPE.SUSPEND) {
                    ar.destroyable(false);
                    response.destroyable(false);
                }
                return Action.CANCELLED;
            } catch (IOException | ServletException e) {
                LOG.error("Failed to process", e);
            }
        }

        return Action.CONTINUE;
    }

    protected String getHeartbeatBroadcasterName() {
        return HEARTBEAT_BROADCASTER_NAME;
    }

    protected String getHeartbeatTemplate() {
        return HEARTBEAT_TEMPLATE;
    }

    protected Object[] getHeartbeatTemplateArguments() {
        return new Object[]{UUID.randomUUID().toString(), System.currentTimeMillis()};
    }

    protected AtmosphereRequest createAtmosphereRequest(AtmosphereRequest request, String body) throws IOException {
        //REVISIT find a more efficient way to read and extract the message data
        JSONEnvelopeReader jer = new JSONEnvelopeReader(new StringReader(body));

        final String id = jer.getHeader("id");
        if (id != null) {
            request.localAttributes().put(REQUEST_ID, id);
        }

        boolean skip = false;
        final boolean continued = Boolean.valueOf(jer.getHeader("continue"));
        Reader reader = readerPool.getReader(id, false);
        if (reader != null) {
            skip = true;
        } else if (continued) {
            reader = readerPool.getReader(id, true);
        }

        if (skip) {
            // add the request data to the prevously dispatched request and skip dispatching a new one
            final Reader data = jer.getReader();
            if (data != null) {
                readerPool.addChunk(id, data, continued);
            }
            return null;
        } else {
            // prepare a new request for dispatching
            final String method = jer.getHeader("method");
            String path = jer.getHeader("path");
            final String type = jer.getHeader("type");
            final String accept = jer.getHeader("accept");

            AtmosphereRequest.Builder b = new AtmosphereRequestImpl.Builder();
            b.method(method != null ? method : "GET").pathInfo(path != null ? path: "/");
            if (accept != null || type != null) {
                Map headers = new TreeMap(String.CASE_INSENSITIVE_ORDER);
                if (accept != null) {
                    headers.put("Accept", accept);
                }
                if (type != null) {
                    b.contentType(type);
                }
                b.headers(headers);
            }
            final int qpos = path.indexOf('?');
            if (qpos > 0) {
                b.queryString(path.substring(qpos + 1));
                path = path.substring(0, qpos);
            }
            final Reader data = jer.getReader();
            if (data != null) {
                if (reader != null) {
                    b.reader(reader);
                    readerPool.addChunk(id, data, true);
                } else {
                    b.reader(data);
                }
            }
            String requestURL = request.getRequestURL() + path.substring(request.getRequestURI().length());
            b.requestURI(path).requestURL(requestURL).request(request);

            return b.build();
        }
    }

    protected byte[] createResponse(AtmosphereResponse response, byte[] payload) {
        if (LOG.isDebugEnabled()) {
            LOG.debug("createResponse for payload {}", new String(payload));
        }
        AtmosphereRequest request = response.request();
        String id = (String)request.getAttribute(REQUEST_ID);
        if (id == null) {
            // control response such as heartbeat or plain responses
            return payload;
        }
        //TODO find a nicer way to build the response entity
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        if (id != null) {
            try {
                baos.write(RESPONSE_TEMPLATE_HEAD);
                baos.write(id.getBytes());
                if (isDetached(request)) {
                    // {"id":...,  "detached": true}\n
                    // 
                    if (isLastResponse(request, response)) {
                        baos.write(RESPONSE_TEMPLATE_BELLY_DETACHED);
                    } else {
                        baos.write(RESPONSE_TEMPLATE_BELLY_CONTINUE_DETACHED);
                    }
                    baos.write(RESPONSE_TEMPLATE_TAIL);
                    baos.write(RESPONSE_TEMPLATE_NEWLINE);
                    baos.write(payload);
                } else {
                    // {"id":..., "data": }
                    boolean isobj = isJSONObject(payload);
                    if (isLastResponse(request, response)) {
                        baos.write(RESPONSE_TEMPLATE_BELLY);
                    } else {
                        baos.write(RESPONSE_TEMPLATE_BELLY_CONTINUE);
                    }
                    if (!isobj) {
                        baos.write(quote(payload));
                    } else {
                        baos.write(payload);
                    }
                    baos.write(RESPONSE_TEMPLATE_TAIL);
                }

            } catch (IOException e) {
                //ignore as it can't happen
            }
        }
        return baos.toByteArray();
    }

    private void scheduleHeartbeat(AtmosphereResource r) {
        //REVISIT make the schedule configurable
        heartbeat.addAtmosphereResource(r);
        if (!heartbeatScheduled) {
            heartbeat.scheduleFixedBroadcast(String.format(getHeartbeatTemplate(), getHeartbeatTemplateArguments()),
                    DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_HEARTBEAT_INTERVAL, TimeUnit.SECONDS);
            heartbeatScheduled = true;
        }
    }

    protected static boolean isLastResponse(AtmosphereRequest request, AtmosphereResponse response) {
        return (response instanceof CompletionAware && ((CompletionAware)response).completed())
                || Boolean.TRUE != request.getAttribute(ApplicationConfig.RESPONSE_COMPLETION_AWARE);
    }

    protected boolean isDetached(AtmosphereRequest request) {
        // the default detached setting configured by the init property can be overrriden by the connection property
        final String prop = request.getHeader(X_ATMOSPHERE_SIMPLE_REST_PROTOCOL_DETACHED);
        return (detached && prop == null) || Boolean.valueOf(prop);
    }

    private void attachWriter(final AtmosphereResource r) {
        AtmosphereResponse res = r.getResponse();
        AsyncIOWriter writer = res.getAsyncIOWriter();

        if (writer instanceof AtmosphereInterceptorWriter) {
            ((AtmosphereInterceptorWriter)writer).interceptor(interceptor, 0);
        }
    }

    private final class Interceptor extends AsyncIOInterceptorAdapter {
        @Override
        public byte[] transformPayload(AtmosphereResponse response, byte[] responseDraft, byte[] data) throws IOException {
            return createResponse(response, responseDraft);
        }
    }

    protected static boolean isJSONObject(byte[] b) {
        return b.length > 0 && (b[0] == (byte)'[' || b[0] == (byte)'{');
    }
    protected static byte[] quote(byte[] b) {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        baos.write('"');
        for (byte c : b) {
            if (c == '"') {
                baos.write('\\');
            }
            baos.write(c);
        }
        baos.write('"');
        return baos.toByteArray();
    }

    /*
     * A custom json envelope reader to parse a character sequence by extracting the key value pairs
     * but leaves the data value unparsed so that it can be subsequently consumed directly as a chracter seqeunce.
     */
    static class JSONEnvelopeReader {
        private Reader reader;
        private Map headers;
        private boolean datap;
        private boolean detachedp;
        private int peek = -1;

        public JSONEnvelopeReader(Reader reader) throws IOException {
            this.reader = reader;
            this.headers = new HashMap();

            prepare();
        }

        public String getHeader(String name) {
            return headers.get(name);
        }

        public Map getHeaders() {
            return headers;
        }

        public Reader getReader() {
            if (!datap && !detachedp) {
                return null;
            }

            return new Reader() {
                private int b;
                @Override
                public int read(char[] cbuf, int off, int len) throws IOException {
                    int n = reader.read(cbuf, off, len);
                    if (n > 0) {
                        boolean escaping = false;
                        char quot = 0;
                        for (int i = off; i < n; i++) {
                            char c = cbuf[i];
                            if (c == '{' && !escaping) {
                                b++;
                            } else if (c == '}' && !escaping) {
                                b--;
                                if (b < 0) {
                                    // past the logical eof
                                    n--;
                                }
                            } else if ((c == '"' || c == '\'') && !escaping) {
                                if (c == quot) {
                                    quot = 0;
                                } else {
                                    quot = c;
                                }
                            } else if (c == '\\' && quot != 0 && !escaping) {
                                escaping = true;
                            } else if (escaping) {
                                escaping = false;
                            }
                        }
                    }
                    return n;
                }

                @Override
                public void close() throws IOException {
                    reader.close();
                }

                @Override
                public boolean ready() throws IOException {
                    return reader.ready();
                }
            };
        }


        private void prepare() throws IOException {
            int c = next(true);
            if (c == '{') {
                for (;;) {
                    String name = nextName();
                    c = next(true);
                    if (c == ':') {
                        if ("data".equals(name)) {
                            datap = true;
                            break;
                        } else if ("detached".equals(name)) {
                            if (Boolean.valueOf(nextValue())) {
                                detachedp = true;
                            }
                        } else {
                            headers.put(name, nextValue());
                        }
                    } else {
                        throw new IOException("invalid value: missing name-separator ':'");
                    }
                    c = next(true);
                    if (c != ',') {
                        if (c == '}' && detachedp) {
                            while (c != -1) {
                                c = next(false);
                                if (c == '\n') {
                                    break;
                                }
                            }
                        } else {
                            unread(c);
                        }
                        break;
                    }
                }
            } else {
                throw new IOException("invalid object: missing being-object '{'");
            }
        }

        private String nextName() throws IOException {
            int c = next(true);
            if (c == '"' || c == '\'') {
                return nextQuoted(c);
            }
            throw new IOException("invalid name: missing quote '\"'");
        }

        private String nextValue() throws IOException {
            int c = next(true);
            if (c == '"' || c == '\'') {
                // quoted string
                return nextQuoted(c);
            } else if (c == 't' || c == 'f' || ('0' <= c && c <= '9')) {
                // true, false, or number
                unread(c);
                return nextNonQuoted();
            }
            throw new IOException("invalid value: unquoted non literals");
        }

        private String nextQuoted(int quot) throws IOException {
            StringBuilder sb = new StringBuilder();
            boolean escaping = false;
            int c;
            while ((c = next(false)) != -1) {
                if (c == '\\' && !escaping) {
                    escaping = true;
                } else if (c == quot && !escaping) {
                    break;
                } else {
                    sb.append((char) c);
                    if (escaping) {
                        escaping = false;
                    }
                }
            }
            if (c != -1) {
                return sb.toString();
            }
            throw new IOException("invalid quoted string: missing quotation");
        }

        private String nextNonQuoted() throws IOException {
            StringBuilder sb = new StringBuilder();
            int c;
            while ((c = next(false)) != -1) {
                if (c == '}' || c == ',' || isWS(c)) {
                    unread(c);
                    break;
                } else {
                    sb.append((char) c);
                }
            }
            if (c != -1) {
                return sb.toString();
            }
            throw new IOException("invalid value: non-terminated");
        }

        private int next(boolean skipws) throws IOException {
            int c;
            if (peek != -1) {
                c = peek;
                peek = -1;
            } else {
                while ((c = reader.read()) != -1 && skipws && isWS(c));
            }
            return c;
        }

        private void unread(int c) {
            peek = c;
        }

        private boolean isWS(int c) {
            return c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d;
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy