content: /*************************************************************************
  *                                                                        *
  *  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;
      }
    }
  }
  