com.twentyn.reachables.order.Service.java Source code

Java tutorial

Introduction

Here is the source code for com.twentyn.reachables.order.Service.java

Source

/*************************************************************************
*                                                                        *
*  This file is part of the 20n/act project.                             *
*  20n/act enables DNA prediction for synthetic biology/bioengineering.  *
*  Copyright (C) 2017 20n Labs, Inc.                                     *
*                                                                        *
*  Please direct all queries to act@20n.com.                             *
*                                                                        *
*  This program is free software: you can redistribute it and/or modify  *
*  it under the terms of the GNU General Public License as published by  *
*  the Free Software Foundation, either version 3 of the License, or     *
*  (at your option) any later version.                                   *
*                                                                        *
*  This program 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 General Public License for more details.                          *
*                                                                        *
*  You should have received a copy of the GNU General Public License     *
*  along with this program.  If not, see <http://www.gnu.org/licenses/>. *
*                                                                        *
*************************************************************************/

package com.twentyn.reachables.order;

import com.amazonaws.ClientConfiguration;
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSCredentialsProvider;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.sns.AmazonSNSAsync;
import com.amazonaws.services.sns.AmazonSNSAsyncClientBuilder;
import com.amazonaws.services.sns.model.PublishRequest;
import com.amazonaws.services.sns.model.PublishResult;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.twentyn.TargetMolecule;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.commons.daemon.Daemon;
import org.apache.commons.daemon.DaemonContext;
import org.apache.commons.daemon.DaemonInitException;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.validator.routines.EmailValidator;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.server.NCSARequestLog;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.AbstractHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;

public class Service implements Daemon {
    private static final Logger LOGGER = LogManager.getFormatterLogger(Service.class);

    public static final String OPTION_CONFIG_FILE = "c";

    public static final List<Option.Builder> OPTION_BUILDERS = new ArrayList<Option.Builder>() {
        {
            add(Option.builder(OPTION_CONFIG_FILE).argName("config file")
                    .desc("Path to a file containing JSON configuration parameters, used instead of CLI args")
                    .hasArg().required().longOpt("config"));

            // Everybody needs a little help from their friends.
            add(Option.builder("h").argName("help").desc("Prints this help message").longOpt("help"));
        }
    };

    private static final String HELP_MESSAGE = StringUtils.join(new String[] {
            "This class runs a web server that sends emails to request access to pathways for specific molecules." },
            "");
    private static final HelpFormatter HELP_FORMATTER = new HelpFormatter();
    static {
        HELP_FORMATTER.setWidth(100);
    }

    private static final List<TargetMolecule> TARGETS = new ArrayList<>();
    private static final Map<String, TargetMolecule> INCHI_KEY_TO_TARGET = new HashMap<>();

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    // Based on https://en.wikipedia.org/wiki/International_Chemical_Identifier#InChIKey
    private static final Pattern REGEX_INCHI_KEY = Pattern.compile("^[A-Z]{14}-[A-Z]{10}-[A-Z]$");

    private static final String TEMPLATE_NAME_ORDER_FORM = "OrderForm.ftl";
    private static final String TEMPLATE_NAME_ORDER_INVALID = "OrderInvalid.ftl";
    private static final String TEMPLATE_NAME_ORDER_SUBMITTED = "OrderSubmitted.ftl";

    private Server jettyServer;

    public void init(String[] args) throws Exception {
        Options opts = new Options();
        for (Option.Builder b : OPTION_BUILDERS) {
            opts.addOption(b.build());
        }

        CommandLine cl = null;
        try {
            CommandLineParser parser = new DefaultParser();
            cl = parser.parse(opts, args);
        } catch (ParseException e) {
            System.err.format("Argument parsing failed: %s\n", e.getMessage());
            HELP_FORMATTER.printHelp(Service.class.getCanonicalName(), HELP_MESSAGE, opts, null, true);
            System.exit(1);
        }

        if (cl.hasOption("help")) {
            HELP_FORMATTER.printHelp(Service.class.getCanonicalName(), HELP_MESSAGE, opts, null, true);
            return;
        }

        File configFile = new File(cl.getOptionValue(OPTION_CONFIG_FILE));
        if (!configFile.exists() || !configFile.isFile()) {
            System.err.format("Config file at %s could not be read, but is required for startup",
                    configFile.getAbsolutePath());
            HELP_FORMATTER.printHelp(Service.class.getCanonicalName(), HELP_MESSAGE, opts, null, true);
            System.exit(1);
        }

        ServiceConfig config = null;
        try {
            config = OBJECT_MAPPER.readValue(configFile, new TypeReference<ServiceConfig>() {
            });
        } catch (IOException e) {
            System.err.format("Unable to read config file at %s: %s", configFile.getAbsolutePath(), e.getMessage());
            HELP_FORMATTER.printHelp(Service.class.getCanonicalName(), HELP_MESSAGE, opts, null, true);
            System.exit(1);
        }

        LOGGER.info("Initializing freemarker");
        Configuration cfg = new Configuration(Configuration.VERSION_2_3_25);

        cfg.setClassLoaderForTemplateLoading(this.getClass().getClassLoader(),
                "/com/twentyn/reachables/order/templates");
        cfg.setDefaultEncoding("UTF-8");

        cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
        cfg.setLogTemplateExceptions(true);

        LOGGER.info("Loading targets");
        TARGETS.addAll(TargetMolecule.loadTargets(new File(config.getReachablesFile()))); // Ensure TARGETS != null.
        LOGGER.info("Read %d targets from input TSV", TARGETS.size());
        LOGGER.info("Building InChI Key Map");
        TARGETS.forEach(t -> INCHI_KEY_TO_TARGET.put(t.getInchiKey(), t));

        LOGGER.info("Constructing service");
        jettyServer = new Server(config.getPort());
        /* Note: a ContextHandler here can help organize controllers by path, so we could use one to only dispatch /search
         * requests to the controller.  Unfortunately, because context handlers assume responsibility for an entire sub-
         * path, they always require a trailing slash on the URL, which we don't want.  Instead, we'll always send requests
         * to our controller but only accept them if their target is /search.
         */
        Controller controller = new Controller(config, cfg);
        controller.init();
        jettyServer.setHandler(controller);

        // Use Apache-style access logging, because it's The Right Thing To Do.
        NCSARequestLog logger = new NCSARequestLog();
        logger.setAppend(true);
        jettyServer.setRequestLog(logger);
    }

    @Override
    public void init(DaemonContext context) throws DaemonInitException, Exception {
        String args[] = context.getArguments();
        LOGGER.info("Daemon initializing with arguments: %s", StringUtils.join(args, " "));
        init(args);
    }

    @Override
    public void start() throws Exception {
        LOGGER.info("Starting server");
        jettyServer.start();
        LOGGER.info("Server started, waiting for termination");
    }

    @Override
    public void stop() throws Exception {
        jettyServer.stop();
    }

    @Override
    public void destroy() {
        jettyServer.destroy();
    }

    public static void main(String[] args) throws Exception {
        Service service = new Service();
        service.init(args);

        service.start();
        service.jettyServer.join();
    }

    public static class Controller extends AbstractHandler {
        private static final String EXPECTED_TARGET = "/order";
        private static final String PARAM_INCHI_KEY = "inchi_key";
        private static final String PARAM_EMAIL = "email";
        private static final String PARAM_ORDER_ID = "order_id";

        // Wait 5 seconds for an SNS request to complete.
        private static final long SNS_REQUEST_TIMEOUT = 5;
        private static final TimeUnit SNS_REQUEST_TIME_UNIT = TimeUnit.SECONDS;

        String wikiUrlBase;
        String imagesUrlBase;
        ServiceConfig serviceConfig;

        // TODO: make this a proper singleton.
        Configuration cfg;
        AmazonSNSAsync snsClient;

        Cache<UUID, String> orderIdCache = Caffeine.newBuilder().expireAfterWrite(24, TimeUnit.HOURS).build();

        public Controller(ServiceConfig config, Configuration cfg) {
            this.serviceConfig = config;
            this.cfg = cfg;

            // Do some path munging once to ensure we can build clean URLs.
            this.wikiUrlBase = config.getWikiUrlPrefix();
            this.imagesUrlBase = config.getImageUrlPrefix();

            // Add trailing slashes so we can assume they exist later.
            if (!this.wikiUrlBase.endsWith("/")) {
                this.wikiUrlBase = this.wikiUrlBase + "/";
            }

            if (!this.imagesUrlBase.endsWith("/")) {
                this.imagesUrlBase = this.imagesUrlBase + "/";
            }
        }

        public void init() throws IOException {
            AWSCredentials credentials = new BasicAWSCredentials(this.serviceConfig.getAccessKeyId(),
                    this.serviceConfig.getSecretAccessKey());
            AWSCredentialsProvider credentialsProvider = new AWSStaticCredentialsProvider(credentials);
            ClientConfiguration config = new ClientConfiguration();
            snsClient = AmazonSNSAsyncClientBuilder.standard().withCredentials(credentialsProvider)
                    .withRegion(serviceConfig.getRegion()).build();
        }

        @Override
        public void handle(String target, Request baseRequest, HttpServletRequest request,
                HttpServletResponse response) throws IOException, ServletException {
            LOGGER.info("Expected target: '%s', actual target: '%s'", EXPECTED_TARGET, target);
            if (!EXPECTED_TARGET.equals(target)) {
                response.setStatus(HttpServletResponse.SC_NOT_FOUND);
                return;
            }

            if (HttpMethod.GET.asString().equalsIgnoreCase(request.getMethod())) {
                handleGet(request, response);
            } else if (HttpMethod.POST.asString().equalsIgnoreCase(request.getMethod())) {
                LOGGER.info("Handling post request.");
                handlePost(request, response);
            } else {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            }
        }

        void handleGet(HttpServletRequest request, HttpServletResponse response)
                throws IOException, ServletException {
            LOGGER.info("In handle GET");
            Map<String, String[]> params = request.getParameterMap();

            // Basic invariant checks: request must have exactly one InChI Key parameter.
            if (!params.containsKey(PARAM_INCHI_KEY) || params.get(PARAM_INCHI_KEY).length != 1
                    || params.get(PARAM_INCHI_KEY)[0] == null || params.get(PARAM_INCHI_KEY)[0].isEmpty()) {
                makeErrorResponse(ORDER_ERRORS.UNKNOWN_MOL, null, response);
                return;
            }

            String inchiKey = params.get(PARAM_INCHI_KEY)[0];
            // Content-aware invariant checks: InChI Key must be valid and exist in our list of targets.
            if (!REGEX_INCHI_KEY.matcher(inchiKey).matches() || !INCHI_KEY_TO_TARGET.containsKey(inchiKey)) {
                LOGGER.info("Invalid inchi key: '%s', in targets: %s", inchiKey,
                        INCHI_KEY_TO_TARGET.containsKey(inchiKey));
                makeErrorResponse(ORDER_ERRORS.UNKNOWN_MOL, null, response);
                return;
            }

            // Save the order id for lookup later to make sure the POST endpoint can't be easily spammed.
            UUID orderId = UUID.randomUUID();
            orderIdCache.put(orderId, inchiKey);

            makeOrderFormResponse(inchiKey, orderId.toString(), null, response);
        }

        void handlePost(HttpServletRequest request, HttpServletResponse response)
                throws IOException, ServletException {
            Map<String, String[]> params = request.getParameterMap();
            // Basic invariant checks: request must have exactly one InChI Key parameter.
            if (!params.containsKey(PARAM_INCHI_KEY) || params.get(PARAM_INCHI_KEY).length != 1
                    || params.get(PARAM_INCHI_KEY)[0] == null || params.get(PARAM_INCHI_KEY)[0].isEmpty()) {
                makeErrorResponse(ORDER_ERRORS.UNKNOWN_MOL, null, response);
                return;
            }

            String inchiKey = params.get(PARAM_INCHI_KEY)[0];
            // Content-aware invariant checks: InChI Key must be valid and exist in our list of targets.
            if (!REGEX_INCHI_KEY.matcher(inchiKey).matches() || !INCHI_KEY_TO_TARGET.containsKey(inchiKey)) {
                LOGGER.info("Invalid inchi key: '%s', %s", inchiKey, INCHI_KEY_TO_TARGET.containsKey(inchiKey));
                makeErrorResponse(ORDER_ERRORS.UNKNOWN_MOL, null, response);
                return;
            }

            // Basic invariant checks: request must have exactly one order ID parameter.
            UUID orderId;
            if (!params.containsKey(PARAM_ORDER_ID) || params.get(PARAM_ORDER_ID).length != 1
                    || params.get(PARAM_ORDER_ID)[0] == null || params.get(PARAM_ORDER_ID)[0].isEmpty()) {
                makeErrorResponse(ORDER_ERRORS.UNKNOWN_ID, inchiKey, response);
                return;
            }

            try {
                orderId = UUID.fromString(params.get(PARAM_ORDER_ID)[0]);
            } catch (IllegalArgumentException e) {
                LOGGER.error("Parsing order id resulted in illegal argument exception: %s", e.getMessage());
                makeErrorResponse(ORDER_ERRORS.UNKNOWN_ID, inchiKey, response);
                return;
            }

            // Basic invariant checks: exactly one contact email address must be specified.
            if (!params.containsKey(PARAM_EMAIL) || params.get(PARAM_EMAIL).length != 1
                    || params.get(PARAM_EMAIL)[0] == null || params.get(PARAM_EMAIL)[0].isEmpty()) {
                makeOrderFormResponse(inchiKey, orderId.toString(), "A contact email address must be specified.",
                        response);
                return;
            }

            String email = params.get(PARAM_EMAIL)[0];
            // Content-aware invariant checks: ensure email is valid.
            if (!EmailValidator.getInstance().isValid(email)) {
                makeOrderFormResponse(inchiKey, orderId.toString(), "The specified email address is invalid.",
                        response);
                return;
            }

            // Verify that our order token hasn't timed out or already been used.
            String expectedInchiKey = orderIdCache.getIfPresent(orderId);
            if (expectedInchiKey == null) {
                LOGGER.warn("Couldn't find InChI Key for order id %s w/ user supplied InChI Key",
                        orderId.toString(), inchiKey);
                makeErrorResponse(ORDER_ERRORS.UNKNOWN_ID, inchiKey, response);
                return;
            }

            if (!expectedInchiKey.equals(inchiKey)) {
                LOGGER.warn("Expected and actual InChI Keys don't match for order id %s (%s vs %s)",
                        orderId.toString(), expectedInchiKey, inchiKey);
                makeErrorResponse(ORDER_ERRORS.UNKNOWN_ID, inchiKey, response); // Take them back to the molecule they specified.
                return;
            }
            // Mark this order id as claimed by removing it from the cache.
            orderIdCache.invalidate(orderId);

            OrderRequest orderRequest = new OrderRequest(inchiKey, serviceConfig.getClientKeyword(), email,
                    orderId.toString());
            PublishRequest snsRequest = new PublishRequest(serviceConfig.getSnsTopic(),
                    OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(orderRequest),
                    "New reachables order request");
            // Perform an async SNS request so we can time out if it takes too long to complete.
            Future<PublishResult> snsResultFuture = snsClient.publishAsync(snsRequest);

            try {
                PublishResult result = snsResultFuture.get(SNS_REQUEST_TIMEOUT, SNS_REQUEST_TIME_UNIT);
                LOGGER.info("Received SNS message id: %s", result.getMessageId());
            } catch (TimeoutException e) {
                // TODO: render an error page instead?
                LOGGER.error("Got timeout exception when sending SNS message: %s", e.getMessage());
                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                return;
            } catch (InterruptedException e) {
                LOGGER.error("Got interrupted exception when sending SNS message: %s", e.getMessage());
                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                return;
            } catch (ExecutionException e) {
                LOGGER.error("Got execution exception when sending SNS message: %s", e.getMessage());
                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                return;
            }

            LOGGER.info("Got order request for %s, assigned uuid %s", inchiKey, orderId);
            makeOrderSubmittedResponse(inchiKey, orderId.toString(), response);
        }

        void makeOrderFormResponse(String inchiKey, String orderId, String errorMessage,
                HttpServletResponse response) throws IOException, ServletException {
            TargetMolecule target = INCHI_KEY_TO_TARGET.get(inchiKey);

            // Make the order form for this molecule.
            Template t = cfg.getTemplate(TEMPLATE_NAME_ORDER_FORM);
            Map<String, String> model = new HashMap<String, String>() {
                {
                    put("adminEmail", serviceConfig.getAdminEmail());
                    put("imageLink", imagesUrlBase + target.getImageName());
                    put("name", target.getDisplayName());
                    put("inchiKey", target.getInchiKey());
                    put("orderId", orderId);
                    if (errorMessage != null && !errorMessage.isEmpty()) {
                        put("errorMsg", errorMessage);
                    }
                }
            };
            processTemplate(t, model, response);
            response.setStatus(HttpServletResponse.SC_OK);
        }

        void makeOrderSubmittedResponse(String inchiKey, String orderId, HttpServletResponse response)
                throws IOException, ServletException {
            TargetMolecule target = INCHI_KEY_TO_TARGET.get(inchiKey);

            Template t = cfg.getTemplate(TEMPLATE_NAME_ORDER_SUBMITTED);
            Map<String, String> model = new HashMap<String, String>() {
                {
                    put("adminEmail", serviceConfig.getAdminEmail());
                    put("inchiKey", target.getInchiKey());
                    put("orderId", orderId);
                    put("returnUrl", wikiUrlBase + target.getInchiKey());
                }
            };
            processTemplate(t, model, response);
            response.setStatus(HttpServletResponse.SC_OK);
        }

        void makeErrorResponse(ORDER_ERRORS error, String inchiKey, HttpServletResponse response)
                throws IOException, ServletException {
            Template t = cfg.getTemplate(TEMPLATE_NAME_ORDER_INVALID);
            Map<String, String> model = new HashMap<String, String>() {
                {
                    put("adminEmail", serviceConfig.getAdminEmail());
                    put(error.getTemplateKey(), Boolean.TRUE.toString()); // Just a flag.
                    if (inchiKey != null) {
                        put("sourcePageLink",
                                String.format("%s?%s=%s", EXPECTED_TARGET, PARAM_INCHI_KEY, inchiKey));
                    }
                }
            };
            processTemplate(t, model, response);
        }

        void processTemplate(Template template, Object model, HttpServletResponse response)
                throws IOException, ServletException {
            try {
                template.process(model, response.getWriter());
                response.getWriter().flush();
            } catch (TemplateException e) {
                LOGGER.error("Caught exception when trying to render invalid order template: %s", e.getMessage());
                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            }
            response.setStatus(HttpServletResponse.SC_OK);
        }
    }

    private enum ORDER_ERRORS {
        UNKNOWN_MOL("molNotRecognized"), UNKNOWN_ID("orderIdInvalid"),;

        String templateKey;

        ORDER_ERRORS(String templateKey) {
            this.templateKey = templateKey;
        }

        public String getTemplateKey() {
            return this.templateKey;
        }
    }

    // Container class for easy JSON serialization of orders.
    private static class OrderRequest {
        @JsonProperty("inchi_key")
        String inchiKey;

        @JsonProperty("client_keyword")
        String clientKeyword;

        @JsonProperty("email")
        String email;

        @JsonProperty("order_id")
        String orderId;

        private OrderRequest() {

        }

        public OrderRequest(String inchiKey, String clientKeyword, String email, String orderId) {
            this.inchiKey = inchiKey;
            this.clientKeyword = clientKeyword;
            this.email = email;
            this.orderId = orderId;
        }

        public String getInchiKey() {
            return inchiKey;
        }

        public void setInchiKey(String inchiKey) {
            this.inchiKey = inchiKey;
        }

        public String getClientKeyword() {
            return clientKeyword;
        }

        public void setClientKeyword(String clientKeyword) {
            this.clientKeyword = clientKeyword;
        }

        public String getEmail() {
            return email;
        }

        public void setEmail(String email) {
            this.email = email;
        }

        public String getOrderId() {
            return orderId;
        }

        public void setOrderId(String orderId) {
            this.orderId = orderId;
        }
    }
}