package com.elibom.jogger.middleware.router.loader; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.lang.reflect.Method; import java.text.ParseException; import java.util.ArrayList; import java.util.List; import java.util.StringTokenizer; import com.elibom.jogger.http.Request; import com.elibom.jogger.http.Response; import com.elibom.jogger.middleware.router.Route; import com.elibom.jogger.middleware.router.RoutesException; import com.elibom.jogger.middleware.router.Route.HttpMethod; /** * <p>Base class for classes that load routes from a file. Concrete implementations need only to * implement the method {@link #getInputStream()}.</p> * * <h3>The format of the file</h3> * * <p>The file can have zero or more lines, which can be a <strong>route</strong>, a comment (starts with #) or a blank * line. A <strong>route</strong> line has the following format:</p> * * <pre><code> * Route := <em>HttpMethod</em> <em>Path</em> <em>Controller#Method</em> * * HttpMethod := (GET | POST | PUT | DELETE) * Path := a valid path starting with / * Controller := a string representing the name of the controller * Method := a string representing the name of the method * </code></pre> * * <p>For example:</p> * * <pre><code> * # users * GET /users com.app.controller.Users#index * POST /users com.app.controller.Users#create * * # orders * GET /orders/{id} com.app.controller.Orders#show * </code></pre> * * <p><em>Note:</em> tokens can be separated by one or more tabs/spaces.</p> * * @author German Escobar */ public abstract class AbstractFileRoutesLoader implements RoutesLoader { private ControllerLoader controllerLoader = new ClassPathControllerLoader(); @Override public List<Route> load() throws ParseException, RoutesException { InputStream inputStream = null; try { inputStream = getInputStream(); } catch (Exception e) { throw new RoutesException("Problem loading the routes.config file: " + e.getMessage(), e); } try { return load(inputStream); } catch (IOException e) { throw new RoutesException("Problem loading the routes.config file: " + e.getMessage(), e); } } /** * Helper method. Loads the routes from the <code>inputStream</code>. * * @param inputStream * * @return * @throws ParseException * @throws IOException */ private List<Route> load(InputStream inputStream) throws ParseException, IOException { int line = 0; // reset line positioning List<Route> routes = new ArrayList<Route>(); // this is what we will fill and return BufferedReader in = null; try { in = new BufferedReader(new InputStreamReader(inputStream)); String input; while ( (input = in.readLine()) != null ) { line++; input = input.trim(); // only parse line if it is not empty and not a comment if (!input.equals("") && !input.startsWith("#")) { Route route = parse(input, line); routes.add(route); } } } finally { closeResource(in); } return routes; } /** * Helper method. Creates a {@link Route} object from the input string. * * @param input the string to parse. * * @return a {@link Route} object. * @throws ParseException if the line has an invalid format. */ private Route parse(String input, int line) throws ParseException { StringTokenizer st = new StringTokenizer(input, " \t"); if (st.countTokens() != 3) { throw new ParseException("Unrecognized format", line); } // retrieve and validate the three arguments String httpMethod = validateHttpMethod( st.nextToken().trim(), line ); String path = validatePath( st.nextToken().trim(), line ); String controllerAndMethod = validateControllerAndMethod( st.nextToken().trim(), line ); // retrieve controller name int hashPos = controllerAndMethod.indexOf('#'); String controllerName = controllerAndMethod.substring(0, hashPos); // retrieve controller method String controllerMethod = controllerAndMethod.substring(hashPos + 1); return buildRoute(httpMethod, path, controllerName, controllerMethod); } /** * Helper method. It validates if the HTTP method is valid (i.e. is a GET, POST, PUT or DELETE). * * @param httpMethod the HTTP method to validate. * * @return the same httpMethod that was received as an argument. * @throws ParseException if the HTTP method is not recognized. */ private String validateHttpMethod(String httpMethod, int line) throws ParseException { if (!httpMethod.equalsIgnoreCase("GET") && !httpMethod.equalsIgnoreCase("POST") && !httpMethod.equalsIgnoreCase("PUT") && !httpMethod.equalsIgnoreCase("DELETE")) { throw new ParseException("Unrecognized HTTP method: " + httpMethod, line); } return httpMethod; } /** * Helper method. It validates if the path is valid. * * @param path the path to be validated * * @return the same path that was received as an argument. * @throws ParseException if the path is not valid. */ private String validatePath(String path, int line) throws ParseException { if (!path.startsWith("/")) { throw new ParseException("Path must start with '/'", line); } boolean openedKey = false; for (int i=0; i < path.length(); i++) { boolean validChar = isValidCharForPath(path.charAt(i), openedKey); if (!validChar) { throw new ParseException(path, i); } if (path.charAt(i) == '{') { openedKey = true; } if (path.charAt(i) == '}') { openedKey = false; } } return path; } /** * Helper method. Tells if a char is valid in a the path of a route line. * * @param c the char that we are validating. * @param openedKey if there is already an opened key ({) char before. * * @return true if the char is valid, false otherwise. */ private boolean isValidCharForPath(char c, boolean openedKey) { char[] invalidChars = { '?', '#', ' ' }; for (char invalidChar : invalidChars) { if (c == invalidChar) { return false; } } if (openedKey) { char[] moreInvalidChars = { '/', '{' }; for (char invalidChar : moreInvalidChars) { if (c == invalidChar) { return false; } } } return true; } /** * Helper method. Validates that the format of the controller and method is valid (i.e. in the form of controller#method). * * @param beanAndMethod the beanAndMethod string to be validated. * * @return the same beanAndMethod that was received as an argument. * @throws ParseException if the format of the controller and method is not valid. */ private String validateControllerAndMethod(String beanAndMethod, int line) throws ParseException { int hashPos = beanAndMethod.indexOf('#'); if (hashPos == -1) { throw new ParseException("Unrecognized format for '" + beanAndMethod + "'", line); } return beanAndMethod; } /** * Helper method. Builds a {@link Route} object from the received arguments instantiating the controller and the * method. It uses the {@link #loadController(String)} method that has to be defined by concrete implementations. * * @param httpMethod the HTTP method to which the route will respond. * @param path the HTTP path to which the route will respond. * @param controllerName the name of the controller that will handle this route. * @param methodName the name of the method that will handle this route. * * @return a {@link Route} object. * @throws RoutesException if there is a problem loading the controller or the method. */ private Route buildRoute(String httpMethod, String path, String controllerName, String methodName) throws RoutesException { Object controller = controllerLoader.load(controllerName); Method method = getMethod(controller, methodName); return new Route(HttpMethod.valueOf(httpMethod.toUpperCase()), path, controller, method); } /** * Helper method. Retrieves the method with the specified <code>methodName</code> and from the specified object. * Notice that the method must received two parameters of types {@link Request} and {@link Response} respectively. * * @param controller the object from which we will retrieve the method. * @param methodName the name of the method to be retrieved. * * @return a <code>java.lang.reflect.Method</code> object. * @throws RoutesException if the method doesn't exists or there is a problem accessing the method. */ private Method getMethod(Object controller, String methodName) throws RoutesException { try { // try to retrieve the method and check if an exception is thrown return controller.getClass().getMethod(methodName, Request.class, Response.class); } catch (Exception e) { throw new RoutesException(e); } } private void closeResource(Reader reader) { if (reader != null) { try { reader.close(); } catch (Exception e) { } } } /** * Used to retrieve the InputStream * * @return * @throws Exception */ protected abstract InputStream getInputStream() throws Exception; /** * Sets the <code>basePackage</code> to use when loading controllers (i.e you don't need to specified all the * package of all controllers in the routes files). This method will set a {@link ClassPathControllerLoader} as the * default mechanism to load controllers with the specified <code>basePackage</code>. * * @param basePackage the base package of all controllers. */ public void setBasePackage(String basePackage) { this.controllerLoader = new ClassPathControllerLoader(basePackage); } public ControllerLoader getControllerLoader() { return controllerLoader; } public void setControllerLoader(ControllerLoader controllerLoader) { this.controllerLoader = controllerLoader; } }