/************************************************************************* * * * 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.search.substructure; import chemaxon.formats.MolFormatException; import chemaxon.license.LicenseManager; import chemaxon.sss.search.MolSearch; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.twentyn.TargetMolecule; 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.csv.CSVFormat; 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.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.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class Service implements Daemon { private static final Logger LOGGER = LogManager.getFormatterLogger(Service.class); public static final CSVFormat TSV_FORMAT = CSVFormat.newFormat('\t'). withRecordSeparator('\n').withQuote('"').withIgnoreEmptyLines(true).withHeader(); 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 does substructure matching against a TSV file using a single SMILES query. ", "All matching chemicals are outputted; non-matches are ignored." }, ""); private static final HelpFormatter HELP_FORMATTER = new HelpFormatter(); static { HELP_FORMATTER.setWidth(100); } private static final Long MAX_RESULTS = 100L; private static final List<TargetMolecule> TARGETS = new ArrayList<>(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 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); } LicenseManager.setLicenseFile(config.getLicenseFile()); 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("Constructing service"); jettyServer = new Server(config.getPort()); // TODO: take this as a CLI arg. /* 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. */ jettyServer.setHandler(new Controller(config)); // 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 = "/search"; private static final String PARAM_QUERY = "q"; private static final String PARAM_SEARCH_OPTIONS = "options"; SubstructureSearch substructureSearch = new SubstructureSearch(); String wikiUrlBase; String imagesUrlBase; public Controller(ServiceConfig config) { 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 + "/"; } } @Override public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { // Only handle /search GET queries. if (!EXPECTED_TARGET.equals(target)) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } if(!HttpMethod.GET.asString().equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } Map<String, String[]> parameters = request.getParameterMap(); // Search query is required. if (!parameters.containsKey(PARAM_QUERY) || parameters.get(PARAM_QUERY).length == 0) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } String queryString = parameters.get(PARAM_QUERY)[0]; if (queryString == null || queryString.isEmpty()) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } // TODO: should we do additional validation of the query string here? // Search options are optional. List<String> searchOptions = Collections.emptyList(); if (parameters.containsKey(PARAM_SEARCH_OPTIONS)) { searchOptions = Arrays.asList(parameters.get(PARAM_SEARCH_OPTIONS)); } try { MolSearch search = substructureSearch.constructSearch(queryString, searchOptions); List<TargetMolecule> matches = new ArrayList<>(); for (TargetMolecule targetMol : TARGETS) { if (substructureSearch.matchSubstructure(targetMol.getMolecule(), search)) { matches.add(targetMol); } } List<SearchResult> results = matches.stream(). limit(MAX_RESULTS). map(mol -> new SearchResult( // TODO: parameterize these URLs based on some CLI or configuration parameter. this.imagesUrlBase + mol.getImageName(), mol.getDisplayName(), this.wikiUrlBase + mol.getInchiKey()) ).collect(Collectors.toList()); // TODO: are there constants for these somewhere? response.addHeader("Content-type", "application/json"); response.getWriter().write(OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(results)); response.getWriter().flush(); response.setStatus(HttpServletResponse.SC_OK); } catch (MolFormatException e) { LOGGER.warn("Caught MolFormatException: %s", e.getMessage()); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); } catch (Exception e) { LOGGER.error("Caught unexpected exception: %s", e.getMessage()); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } baseRequest.setHandled(true); } } private static class SearchResult { @JsonProperty("image_name") String imageLink; @JsonProperty("page_name") String pageName; @JsonProperty("link") String link; private SearchResult() { } public SearchResult(String imageLink, String pageName, String link) { this.imageLink = imageLink; this.pageName = pageName; this.link = link; } public String getImageLink() { return imageLink; } public void setImageLink(String imageLink) { this.imageLink = imageLink; } public String getPageName() { return pageName; } public void setPageName(String pageName) { this.pageName = pageName; } public String getLink() { return link; } public void setLink(String link) { this.link = link; } } }