ExoRouter.java

/*
 * Copyright (C) 2003-2012 eXo Platform SAS.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */
package org.exoplatform.commons.notification.net.router;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Pattern;

import org.exoplatform.commons.notification.net.router.regex.ExoMatcher;
import org.exoplatform.commons.notification.net.router.regex.ExoPattern;
import org.exoplatform.services.log.ExoLogger;
import org.exoplatform.services.log.Log;
import org.picocontainer.Startable;

public class ExoRouter implements Startable {

  /**
   * The Logger.
   */
  private static final Log LOG = ExoLogger.getLogger(ExoRouter.class);
  
  static ExoPattern defaultRoutePattern = ExoPattern.compile("^({path}.*/[^\\s]*)\\s+({action}[^\\s(]+)({params}.+)?(\\s*)$");

  /**
   * All the loaded routes.
   */
  public static List<Route> routes = new CopyOnWriteArrayList<Route>();

  private ExoRouterConfig routerConfig;
  
  public static void reset() {
    routes.clear();
  }
  
  public ExoRouter() {}

  public void addRoutes(ExoRouterConfig routeConfig) {
    this.routerConfig = routeConfig;
    Map<String, String> routeMapping = this.routerConfig.getRouteMapping();
    
    for(Map.Entry<String, String> entry : routeMapping.entrySet()) {
      addRoute(entry.getValue(), entry.getKey());
    }
  }
  
  /**
   * Add new route which loaded from route configuration file.
   * 
   * @param path /{pageID}/ForumService
   * @param action the action which appends to patch after "ForumService"
   *          string.
   */
  public static void addRoute(String path, String action) {
    addRoute(path, action, null);
  }

  /**
   * Add new route which loaded from route configuration file.
   * 
   * @param path /{pageID}/ForumService
   * @param action /{pageID}/ForumService
   * @param params the action which appends to patch after "ForumService" string
   *          ex: /{pageID}/{ForumService|}/{action} {@literal =>} /{pageID}/ForumService/{}
   */
  public static void addRoute(String path, String action, String params) {
    appendRoute(path, action, params);
  }

  public static void appendRoute(String path, String action, String params) {
    int position = routes.size();
    routes.add(position, getRoute(path, action, params));
  }

  public static Route getRoute(String path, String action, String params) {
    return getRoute(path, action, params, null, 0);
  }

  public static Route getRoute(String path, String action) {
    return getRoute(path, action, null, null, 0);
  }

  public static Route getRoute(String path, String action, String params, String sourceFile, int line) {
    Route route = new Route();
    route.path = path.replace("//", "/");
    route.action = action;
    route.routesFile = sourceFile;
    route.routesFileLine = line;
    route.addParams(params);
    route.compute();
    return route;
  }

  /**
   * Add a new route at the beginning of the route list
   */
  public static void prependRoute(String path, String action, String params) {
    routes.add(0, getRoute(path, action, params));
  }

  /**
   * Add a new route at the beginning of the route list
   */
  public static void prependRoute(String path, String action) {
    routes.add(0, getRoute(path, action));
  }

  public static Route route(String path) {
    for (Route route : routes) {
      Map<String, String> args = route.matches(path);
      if (args != null) {
        route.localArgs = args;
        return route;
      }
    }

    return null;
  }

  /**
   * Generates ActionBuilder base on the action name and arguments list.
   * Example: invokes {@code reverse("show.topic", new HashMap<String, Object>{topicId, "topicId321"})} method.
   *           
   * @param action
   * @param args
   * @return
   */
  public static ActionBuilder reverse(String action, Map<String, Object> args) {
    Map<String, Object> argsbackup = new HashMap<String, Object>(args);
    // Add routeArgs
    for (Route route : routes) {
      if (route.actionPattern != null) {
        ExoMatcher matcher = route.actionPattern.matcher(action);
        if (matcher.matches()) {
          for (String group : route.actionArgs) {
            String v = matcher.group(group);
            if (v == null) {
              continue;
            }
            args.put(group, v.toLowerCase());
          }
          List<String> inPathArgs = new ArrayList<String>(16);
          boolean allRequiredArgsAreHere = true;

          for (Route.ParamArg arg : route.args) {
            inPathArgs.add(arg.name);
            Object value = args.get(arg.name);
            if (value != null) {
              if (!value.toString().startsWith(":") && !arg.constraint.matches(value.toString())) {
                allRequiredArgsAreHere = false;
                break;
              }
            }
          }
          if (allRequiredArgsAreHere) {
            StringBuilder queryString = new StringBuilder();
            String path = route.path;
            if (path.endsWith("/?")) {
              path = path.substring(0, path.length() - 2);
            }
            for (Map.Entry<String, Object> entry : args.entrySet()) {
              String key = entry.getKey();
              Object value = entry.getValue();
              if (inPathArgs.contains(key) && value != null) {
                path = path.replaceAll("\\{(<[^>]+>)?" + key + "\\}", value.toString().replace("$", "\\$").replace("%3A", ":").replace("%40", "@"));
              } else if (value != null) {
                try {
                  queryString.append(URLEncoder.encode(key, "UTF-8"));
                  queryString.append("=");
                  if (value.toString().startsWith(":")) {
                    queryString.append(value.toString());
                  } else {
                    queryString.append(URLEncoder.encode(value.toString() + "", "UTF-8"));
                  }
                  queryString.append("&");
                } catch (UnsupportedEncodingException ex) {
                  LOG.debug("Unsupported encoding error: " + ex);
                }

              }
            }
            String qs = queryString.toString();
            if (qs.endsWith("&")) {
              qs = qs.substring(0, qs.length() - 1);
            }
            ActionBuilder actionDefinition = new ActionBuilder();
            actionDefinition.url = qs.length() == 0 ? path : path + "?" + qs;
            actionDefinition.action = action;
            actionDefinition.args = argsbackup;
            return actionDefinition;
          }
        }
      }
    }
    return null;
  }

  public static class ActionBuilder {
    public String url;

    //TODO - what is this? does it include the class and package?
    public String action;

    //TODO - are these the required args in the routing file, or the query string in a request?
    public Map<String, Object> args;

    public ActionBuilder add(String key, Object value) {
      args.put(key, value);
      return reverse(action, args);
    }

    public ActionBuilder remove(String key) {
      args.remove(key);
      return reverse(action, args);
    }

    public ActionBuilder addRef(String fragment) {
      url += "#" + fragment;
      return this;
    }

    @Override
    public String toString() {
      return url;
    }

  }
  
  /**
   * Route class which contains path, action and argument list.
   * 
   * @author thanhvc
   *
   */
  public static class Route {

    public String path;

    public String action;

    ExoPattern actionPattern;

    List<String> actionArgs = new ArrayList<String>(3);

    ExoPattern pattern;

    public String routesFile;

    List<ParamArg> args = new ArrayList<ParamArg>(3);

    Map<String, String> staticArgs = new HashMap<String, String>(3);

    public Map<String, String> localArgs = null;

    public int routesFileLine;

    static ExoPattern customRegexPattern =  ExoPattern.compile("\\{([a-zA-Z_][a-zA-Z_0-9]*)\\}");

    static ExoPattern argsPattern = ExoPattern.compile("\\{<([^>]+)>([a-zA-Z_0-9]+)\\}");

    static ExoPattern paramPattern = ExoPattern.compile("([a-zA-Z_0-9]+):'(.*)'");

    public void compute() {
      String patternString = this.path;
      patternString = customRegexPattern.matcher(patternString).replaceAll("\\{<[^/]+>$1\\}");
      ExoMatcher matcher = argsPattern.matcher(patternString);
      while (matcher.find()) {
        ParamArg arg = new ParamArg();
        arg.name = matcher.group(2);
        arg.constraint = ExoPattern.compile(matcher.group(1));
        args.add(arg);
      }

      patternString = argsPattern.matcher(patternString).replaceAll("({$2}$1)");
      this.pattern = ExoPattern.compile(patternString);
      // Action pattern
      patternString = action;
      patternString = patternString.replace(".", "[.]");
      for (ParamArg arg : args) {
        if (patternString.contains("{" + arg.name + "}")) {
          patternString = patternString.replace("{" + arg.name + "}", "({" + arg.name + "}" + arg.constraint.toString() + ")");
          actionArgs.add(arg.name);
        }
      }
      actionPattern = ExoPattern.compile(patternString, Pattern.CASE_INSENSITIVE);
    }

    public void addParams(String params) {
      if (params == null || params.length() < 1) {
        return;
      }
      params = params.substring(1, params.length() - 1);
      for (String param : params.split(",")) {
        ExoMatcher matcher = paramPattern.matcher(param);
        if (matcher.matches()) {
          staticArgs.put(matcher.group(1), matcher.group(2));
        } else {
          LOG.warn("Ignoring %s (static params must be specified as key:'value',...)");
        }
      }
    }
    /**
     * Base on defined Pattern, when provided URI path, 
     * this method will extract all of parameters path value 
     * in given path which reflects in defined Pattern
     * 
     * Example: 
     * defined Pattern = "/{pageID}/topic/{topicID}"
     * invokes:: matches("1256/topic/topic544343");
     * result: {@code Map<String, String> = {"pageID" -> "1256"}, {"topicID" -> "topic544343"}}
     * 
     * @param path : given URI path
     * @return
     */
    public Map<String, String> matches(String path) {
      ExoMatcher matcher = pattern.matcher(path);
      if (matcher.matches()) {
        Map<String, String> localArgs = new HashMap<String, String>();
        for (ParamArg arg : args) {
          if (arg.defaultValue == null) {
            localArgs.put(arg.name, matcher.group(arg.name));
          }
        }
        return localArgs;
      }

      return null;
    }

    static class ParamArg {
      String name;

      ExoPattern constraint;

      String defaultValue;
    }
  }

  @Override
  public void start() {
    
  }

  @Override
  public void stop() {
    
  }

}