io.apicurio.hub.editing.EditApiDesignEndpoint.java Source code

Java tutorial

Introduction

Here is the source code for io.apicurio.hub.editing.EditApiDesignEndpoint.java

Source

/*
 * Copyright 2017 JBoss Inc
 *
 * 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 io.apicurio.hub.editing;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.websocket.CloseReason;
import javax.websocket.CloseReason.CloseCodes;
import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import io.apicurio.hub.core.beans.ApiContentType;
import io.apicurio.hub.core.beans.ApiDesign;
import io.apicurio.hub.core.beans.ApiDesignCommand;
import io.apicurio.hub.core.beans.ApiDesignCommandAck;
import io.apicurio.hub.core.beans.ApiDesignContent;
import io.apicurio.hub.core.beans.ApiDesignResourceInfo;
import io.apicurio.hub.core.editing.ApiDesignEditingSession;
import io.apicurio.hub.core.editing.IEditingSessionManager;
import io.apicurio.hub.core.exceptions.NotFoundException;
import io.apicurio.hub.core.exceptions.ServerError;
import io.apicurio.hub.core.js.OaiCommandException;
import io.apicurio.hub.core.js.OaiCommandExecutor;
import io.apicurio.hub.core.storage.IStorage;
import io.apicurio.hub.core.storage.StorageException;
import io.apicurio.hub.editing.metrics.IEditingMetrics;

/**
 * @author eric.wittmann@gmail.com
 */
@ServerEndpoint(value = "/designs/{designId}", encoders = { MessageEncoder.class }, decoders = {
        MessageDecoder.class })
@ApplicationScoped
public class EditApiDesignEndpoint {

    private static Logger logger = LoggerFactory.getLogger(EditApiDesignEndpoint.class);
    private static final ObjectMapper mapper = new ObjectMapper();

    @Inject
    private IEditingSessionManager editingSessionManager;
    @Inject
    private IStorage storage;
    @Inject
    private OaiCommandExecutor oaiCommandExecutor;
    @Inject
    private IEditingMetrics metrics;

    /**
     * Called when a web socket connection is made.  The format for the web socket URL endpoint is:
     * 
     *   /designs/{designId}?uuid={uuid}&user={user}&secret={secret}
     *   
     * The uuid, user, and secret query parameters must be present for a connection to be 
     * successfully made.
     * 
     * @param session
     */
    @OnOpen
    public void onOpenSession(Session session) {
        String designId = session.getPathParameters().get("designId");
        logger.debug("WebSocket opened: {}", session.getId());
        logger.debug("\tdesignId: {}", designId);

        String queryString = session.getQueryString();
        Map<String, String> queryParams = parseQueryString(queryString);
        String uuid = queryParams.get("uuid");
        String userId = queryParams.get("user");
        String secret = queryParams.get("secret");

        this.metrics.socketConnected(designId, userId);

        logger.debug("\tuuid: {}", uuid);
        logger.debug("\tuser: {}", userId);

        ApiDesignEditingSession editingSession = null;

        try {
            long contentVersion = editingSessionManager.validateSessionUuid(uuid, designId, userId, secret);

            // Join the editing session (or create a new one) for the API Design
            editingSession = this.editingSessionManager.getOrCreateEditingSession(designId);
            Set<Session> otherSessions = editingSession.getSessions();
            if (editingSession.isEmpty()) {
                this.metrics.editingSessionCreated(designId);
            }
            editingSession.join(session, userId);

            // Send "join" messages for each user already in the session
            for (Session otherSession : otherSessions) {
                String otherUser = editingSession.getUser(otherSession);
                editingSession.sendJoinTo(session, otherUser, otherSession.getId());
            }

            // Send any commands that have been created since the user asked to join the editing session.
            List<ApiDesignCommand> commands = this.storage.listContentCommands(userId, designId, contentVersion);
            for (ApiDesignCommand command : commands) {
                String cmdData = command.getCommand();

                StringBuilder builder = new StringBuilder();
                builder.append("{");
                builder.append("\"contentVersion\": ");
                builder.append(command.getContentVersion());
                builder.append(", ");
                builder.append("\"type\": \"command\", ");
                builder.append("\"command\": ");
                builder.append(cmdData);
                builder.append("}");

                logger.debug("Sending command to client (onOpenSession): {}", builder.toString());

                session.getBasicRemote().sendText(builder.toString());
            }

            editingSession.sendJoinToOthers(session, userId);
        } catch (ServerError | StorageException | IOException e) {
            if (editingSession != null) {
                editingSession.leave(session);
            }
            logger.error("Error validating editing session UUID for API Design ID: " + designId, e);
            try {
                session.close(new CloseReason(CloseCodes.CANNOT_ACCEPT,
                        "Error opening editing session: " + e.getMessage()));
            } catch (IOException e1) {
                logger.error(
                        "Error closing web socket session (attempted to close due to error validating editing session UUID).",
                        e1);
            }
        }
    }

    /**
     * Called when a message is received on a web socket connection.  All messages must
     * be of the following (JSON) format:
     * 
     * <pre>
     * {
     *    "type": "command|...",
     *    "command": {
     *       &lt;marshalled OAI command goes here>
     *    }
     * }
     * </pre>
     * 
     * @param session
     * @param message
     */
    @OnMessage
    public void onMessage(Session session, JsonNode message) {
        String designId = session.getPathParameters().get("designId");
        ApiDesignEditingSession editingSession = editingSessionManager.getEditingSession(designId);
        String msgType = message.get("type").asText();

        logger.debug("Received a \"{}\" message from a client.", msgType);
        logger.debug("\tdesignId: {}", designId);

        if (msgType.equals("command")) {
            String user = editingSession.getUser(session);
            long localCommandId = -1;
            if (message.has("commandId")) {
                localCommandId = message.get("commandId").asLong();
            }
            String content;
            long cmdContentVersion;

            this.metrics.contentCommand(designId);

            logger.debug("\tuser:" + user);
            try {
                content = mapper.writeValueAsString(message.get("command"));
            } catch (JsonProcessingException e) {
                logger.error("Error writing command as string.", e);
                // TODO do something sensible here - send a msg to the client?
                return;
            }
            try {
                cmdContentVersion = storage.addContent(user, designId, ApiContentType.Command, content);
            } catch (StorageException e) {
                logger.error("Error storing the command.", e);
                // TODO do something sensible here - send a msg to the client?
                return;
            }

            // Send an ack message back to the user
            ApiDesignCommandAck ack = new ApiDesignCommandAck();
            ack.setCommandId(localCommandId);
            ack.setContentVersion(cmdContentVersion);
            editingSession.sendAckTo(session, ack);
            logger.debug("ACK sent back to client.");

            // Now propagate the command to all other clients
            ApiDesignCommand command = new ApiDesignCommand();
            command.setCommand(content);
            command.setContentVersion(cmdContentVersion);
            editingSession.sendCommandToOthers(session, user, command);
            logger.debug("Command propagated to 'other' clients.");

            return;
        } else if (msgType.equals("selection")) {
            String user = editingSession.getUser(session);
            String selection = null;
            if (message.has("selection")) {
                JsonNode node = message.get("selection");
                if (node != null) {
                    selection = node.asText();
                }
            }
            logger.debug("\tuser:" + user);
            logger.debug("\tselection:" + selection);
            editingSession.sendUserSelectionToOthers(session, user, selection);
            logger.debug("User selection propagated to 'other' clients.");
            return;
        } else if (msgType.equals("ping")) {
            logger.debug("PING message received.");
            return;
        }
        logger.error("Unknown message type: {}", msgType);
        // TODO something went wrong if we got here - report an error of some kind
    }

    @OnClose
    public void onCloseSession(Session session, CloseReason reason) {
        String designId = session.getPathParameters().get("designId");
        logger.debug("Closing a WebSocket due to: {}", reason.getReasonPhrase());
        logger.debug("\tdesignId: {}", designId);

        // Call 'leave' on the concurrent editing session for this user
        ApiDesignEditingSession editingSession = editingSessionManager.getEditingSession(designId);
        String userId = editingSession.getUser(session);
        editingSession.leave(session);
        if (editingSession.isEmpty()) {
            // TODO race condition - the session may no longer be empty here!
            editingSessionManager.closeEditingSession(editingSession);

            try {
                rollupCommands(userId, designId);
            } catch (NotFoundException | StorageException | OaiCommandException e) {
                logger.error("Failed to rollup commands for API with id: " + designId, "Rollup error: ", e);
            }
        } else {
            editingSession.sendLeaveToOthers(session, userId);
        }
    }

    /**
     * Finds all commands executed since the last full content rollup and applies
     * them to the API design.  This produces a "latest" version of the API
     * and stores that as a new content entry in the storage.
     * @param userId
     * @param designId
     * @throws StorageException 
     * @throws NotFoundException 
     * @throws OaiCommandException 
     */
    private void rollupCommands(String userId, String designId)
            throws NotFoundException, StorageException, OaiCommandException {
        logger.debug("Rolling up commands for API with ID: {}", designId);
        ApiDesignContent designContent = this.storage.getLatestContentDocument(userId, designId);
        List<ApiDesignCommand> apiCommands = this.storage.listContentCommands(userId, designId,
                designContent.getContentVersion());
        if (apiCommands.isEmpty()) {
            logger.debug("No hanging commands found, rollup of API {} canceled.", designId);
            return;
        }
        List<String> commands = new ArrayList<>(apiCommands.size());
        for (ApiDesignCommand apiCommand : apiCommands) {
            commands.add(apiCommand.getCommand());
        }
        String content = this.oaiCommandExecutor.executeCommands(designContent.getOaiDocument(), commands);
        long contentVersion = this.storage.addContent(userId, designId, ApiContentType.Document, content);
        logger.debug("Rollup of {} commands complete with new content version: {}", commands.size(),
                contentVersion);

        try {
            logger.debug("Updating meta-data for API design {} if necessary.", designId);
            ApiDesign design = this.storage.getApiDesign(userId, designId);
            ApiDesignResourceInfo info = ApiDesignResourceInfo.fromContent(content);
            boolean dirty = false;
            if (design.getName() == null || !design.getName().equals(info.getName())) {
                design.setName(info.getName());
                dirty = true;
            }
            if (design.getDescription() == null || !design.getDescription().equals(info.getDescription())) {
                design.setDescription(info.getDescription());
                dirty = true;
            }
            if (design.getTags() == null || !design.getTags().equals(info.getTags())) {
                design.setTags(info.getTags());
                dirty = true;
            }
            if (dirty) {
                logger.debug("API design {} meta-data changed, updating in storage.", designId);
                this.storage.updateApiDesign(userId, design);
            }
        } catch (Exception e) {
            // Not the end of the world if we fail to update the API's meta-data
            logger.error(e.getMessage(), e);
        }
    }

    /**
     * Parses the query string into a map.
     * @param queryString
     */
    protected static Map<String, String> parseQueryString(String queryString) {
        Map<String, String> rval = new HashMap<>();
        List<NameValuePair> list = URLEncodedUtils.parse(queryString, StandardCharsets.UTF_8);
        for (NameValuePair nameValuePair : list) {
            rval.put(nameValuePair.getName(), nameValuePair.getValue());
        }
        return rval;
    }

}