package com.google.sitebricks.routing; import java.io.IOException; import java.lang.annotation.Annotation; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicReference; import javax.validation.ValidationException; import net.jcip.annotations.GuardedBy; import net.jcip.annotations.ThreadSafe; import org.jetbrains.annotations.Nullable; import com.google.common.base.Objects; import com.google.common.base.Preconditions; import com.google.common.collect.HashMultimap; import com.google.common.collect.Lists; import com.google.common.collect.MapMaker; import com.google.common.collect.Maps; import com.google.common.collect.Multimap; import com.google.inject.BindingAnnotation; import com.google.inject.Inject; import com.google.inject.Injector; import com.google.inject.Key; import com.google.inject.Singleton; import com.google.inject.TypeLiteral; import com.google.inject.name.Named; import com.google.sitebricks.ActionDescriptor; import com.google.sitebricks.At; import com.google.sitebricks.Bricks; import com.google.sitebricks.Renderable; import com.google.sitebricks.Show; import com.google.sitebricks.client.Transport; import com.google.sitebricks.conversion.TypeConverter; import com.google.sitebricks.headless.Reply; import com.google.sitebricks.headless.Request; import com.google.sitebricks.headless.Service; import com.google.sitebricks.http.As; import com.google.sitebricks.http.Get; import com.google.sitebricks.http.Head; import com.google.sitebricks.http.Select; import com.google.sitebricks.http.Trace; import com.google.sitebricks.http.negotiate.ContentNegotiator; import com.google.sitebricks.http.negotiate.Negotiation; import com.google.sitebricks.rendering.Strings; import com.google.sitebricks.rendering.control.DecorateWidget; import com.google.sitebricks.transport.Form; /** * contains active uri/widget mappings * * @author Dhanji R. Prasanna (dhanji@gmail.com) */ @ThreadSafe @Singleton public class DefaultPageBook implements PageBook { //multimaps TODO refactor to multimap? @GuardedBy("lock") // All three following fields private final Map<String, List<PageTuple>> pages = Maps.newHashMap(); private final List<PageTuple> universalMatchingPages = Lists.newArrayList(); private final Map<String, PageTuple> pagesByName = Maps.newHashMap(); private final ConcurrentMap<Class<?>, PageTuple> classToPageMap = new MapMaker() .weakKeys() .weakValues() .makeMap(); private final Object lock = new Object(); private final Injector injector; @Inject public DefaultPageBook(Injector injector) { this.injector = injector; } @Override @SuppressWarnings("unchecked") public Collection<List<Page>> getPageMap() { return (Collection) pages.values(); } // Page registration (internal) APIs public Page serviceAt(String uri, Class<?> pageClass) { // Handle subpaths, registering each as a separate instance of the page // tuple. for (Method method : pageClass.getDeclaredMethods()) { if (method.isAnnotationPresent(At.class)) { // This is a subpath expression. At at = method.getAnnotation(At.class); String subpath = at.value(); // Validate subpath if (!subpath.startsWith("/") || subpath.isEmpty() || subpath.length() == 1) { throw new IllegalArgumentException(String.format( "Subpath At(\"%s\") on %s.%s() must begin with a \"/\" and must not be empty", subpath, pageClass.getName(), method.getName())); } subpath = uri + subpath; // Register as headless web service. doAt(subpath, pageClass, true); } } return doAt(uri, pageClass, true); } public PageTuple at(String uri, Class<?> clazz) { return at(uri, clazz, clazz.isAnnotationPresent(Service.class)); } @Override public void at(String uri, List<ActionDescriptor> actionDescriptors, Map<Class<? extends Annotation>, String> methodSet) { Multimap<String, Action> actions = HashMultimap.create(); for (ActionDescriptor actionDescriptor : actionDescriptors) { for (Class<? extends Annotation> method : actionDescriptor.getMethods()) { String methodString = methodSet.get(method); Action action = actionDescriptor.getAction(); if (null == action) { action = injector.getInstance(actionDescriptor.getActionKey()); } else { injector.injectMembers(action); } actions.put(methodString, new SpiAction(action, actionDescriptor)); } } // Register into the book! at(new PageTuple(uri, new PathMatcherChain(uri), null, true, false, injector, actions)); } private void at(PageTuple page) { // Is Universal? synchronized (lock) { String key = firstPathElement(page.getUri()); if (isVariable(key)) { universalMatchingPages.add(page); } else { multiput(pages, key, page); } } // Actions are not backed by classes. if (page.pageClass() != null) classToPageMap.put(page.pageClass(), page); } private PageTuple at(String uri, Class<?> clazz, boolean headless) { // Handle subpaths, registering each as a separate instance of the page // tuple. for (Method method : clazz.getDeclaredMethods()) { if (method.isAnnotationPresent(At.class)) { // This is a subpath expression. At at = method.getAnnotation(At.class); String subpath = at.value(); // Validate subpath if (!subpath.startsWith("/") || subpath.isEmpty() || subpath.length() == 1) { throw new IllegalArgumentException(String.format( "Subpath At(\"%s\") on %s.%s() must begin with a \"/\" and must not be empty", subpath, clazz.getName(), method.getName())); } subpath = uri + subpath; // Register as headless web service. doAt(subpath, clazz, headless); } } return doAt(uri, clazz, headless); } private PageTuple doAt(String uri, Class<?> clazz, boolean headless) { final String key = firstPathElement(uri); final PageTuple pageTuple = new PageTuple(uri, new PathMatcherChain(uri), clazz, injector, headless, false); synchronized (lock) { //is universal? (i.e. first element is a variable) if (isVariable(key)) universalMatchingPages.add(pageTuple); else { multiput(pages, key, pageTuple); } } // Does not need to be inside lock, as it is concurrent. classToPageMap.put(clazz, pageTuple); return pageTuple; } public Page embedAs(Class<?> clazz, String as) { Preconditions.checkArgument(null == clazz.getAnnotation(Service.class), "You cannot embed headless web services!"); PageTuple pageTuple = new PageTuple("", PathMatcherChain.ignoring(), clazz, injector, false, false); synchronized (lock) { pagesByName.put(as.toLowerCase(), pageTuple); } return pageTuple; } public Page decorate(Class<?> pageClass) { Preconditions.checkArgument(null == pageClass.getAnnotation(Service.class), "You cannot extend headless web services!"); PageTuple pageTuple = new PageTuple("", PathMatcherChain.ignoring(), pageClass, injector, false, true); // store page with a special name used by DecorateWidget String name = DecorateWidget.embedNameFor(pageClass); synchronized (lock) { pagesByName.put(name, pageTuple); } return pageTuple; } public Page nonCompilingGet(String uri) { // The regular get is non compiling, in our case. So these methods are identical. return get(uri); } private static void multiput(Map<String, List<PageTuple>> pages, String key, PageTuple page) { List<PageTuple> list = pages.get(key); if (null == list) { list = new ArrayList<PageTuple>(); pages.put(key, list); } list.add(page); } private static boolean isVariable(String key) { return key.length() > 0 && ':' == key.charAt(0); } String firstPathElement(String uri) { String shortUri = uri.substring(1); final int index = shortUri.indexOf("/"); return (index >= 0) ? shortUri.substring(0, index) : shortUri; } @Nullable public Page get(String uri) { final String key = firstPathElement(uri); List<PageTuple> tuple = pages.get(key); //first try static first piece if (null != tuple) { //first try static first piece for (PageTuple pageTuple : tuple) { if (pageTuple.matcher.matches(uri)) return pageTuple; } } //now try dynamic first piece (how can we make this faster?) for (PageTuple pageTuple : universalMatchingPages) { if (pageTuple.matcher.matches(uri)) return pageTuple; } //nothing matched return null; } public Page forName(String name) { return pagesByName.get(name); } @Nullable public Page forInstance(Object instance) { Class<?> aClass = instance.getClass(); PageTuple targetType = classToPageMap.get(aClass); // Do a super crawl to detect the target type. while (null == targetType) { aClass = aClass.getSuperclass(); targetType = classToPageMap.get(aClass); // Stop at the root =D if (Object.class.equals(aClass)) { return null; } } return InstanceBoundPage.delegating(targetType, instance); } public Page forClass(Class<?> pageClass) { return classToPageMap.get(pageClass); } public static class InstanceBoundPage implements Page { private final Page delegate; private final Object instance; private InstanceBoundPage(Page delegate, Object instance) { this.delegate = delegate; this.instance = instance; } public Renderable widget() { return delegate.widget(); } public Object instantiate() { return instance; } public Object doMethod(String httpMethod, Object page, String pathInfo, Request request) throws IOException { return delegate.doMethod(httpMethod, page, pathInfo, request); } public Class<?> pageClass() { return delegate.pageClass(); } public void apply(Renderable widget) { delegate.apply(widget); } public String getUri() { return delegate.getUri(); } public boolean isHeadless() { return delegate.isHeadless(); } @Override public boolean isDecorated() { return delegate.isDecorated(); } public Set<String> getMethod() { return delegate.getMethod(); } public int compareTo(Page page) { return delegate.compareTo(page); } public static InstanceBoundPage delegating(Page delegate, Object instance) { return new InstanceBoundPage(delegate, instance); } @Override public Show getShow() { return delegate.getShow(); } } @Select("") //the default select (hacky!!) public static class PageTuple implements Page { private final String uri; private final PathMatcher matcher; private final AtomicReference<Renderable> pageWidget = new AtomicReference<Renderable>(); private final Class<?> clazz; private final boolean headless; private final boolean extension; private final Injector injector; private final Multimap<String, Action> methods; //dispatcher switch (select on request param by default) private final Select select; private static final Key<Map<String, Class<? extends Annotation>>> HTTP_METHODS_KEY = Key.get(new TypeLiteral<Map<String, Class<? extends Annotation>>>() {}, Bricks.class); // A map of http methods -> annotation types (e.g. "POST" -> @Post) private Map<String, Class<? extends Annotation>> httpMethods; public PageTuple(String uri, PathMatcher matcher, Class<?> clazz, boolean headless, boolean extension, Injector injector, Multimap<String, Action> methods) { this.uri = uri; this.matcher = matcher; this.clazz = clazz; this.headless = headless; this.extension = extension; this.injector = injector; this.methods = methods; this.select = PageTuple.class.getAnnotation(Select.class); this.httpMethods = injector.getInstance(HTTP_METHODS_KEY); } public PageTuple(String uri, PathMatcher matcher, Class<?> clazz, Injector injector, boolean headless, boolean extension) { this.uri = uri; this.matcher = matcher; this.clazz = clazz; this.injector = injector; this.headless = headless; this.extension = extension; this.select = discoverSelect(clazz); this.httpMethods = injector.getInstance(HTTP_METHODS_KEY); this.methods = reflectAndCache(uri, httpMethods); } //the @Select request parameter-based event dispatcher private Select discoverSelect(Class<?> clazz) { final Select select = clazz.getAnnotation(Select.class); if (null != select) return select; else return PageTuple.class.getAnnotation(Select.class); } /** * Returns a map of HTTP-method name to @Annotation-marked methods */ @SuppressWarnings({"JavaDoc"}) private Multimap<String, Action> reflectAndCache(String uri, Map<String, Class<? extends Annotation>> methodMap) { String tail = ""; if (clazz.isAnnotationPresent(At.class)) { int length = clazz.getAnnotation(At.class).value().length(); // It's possible that the uri being registered is shorter than the // class length, this can happen in the case of using the .at() module // directive to override @At() URI path mapping. In this case we treat // this call as a top-level path registration with no tail. Any // encountered subpath @At methods will be ignored for this URI. if (uri != null && length <= uri.length()) tail = uri.substring(length); } Multimap<String, Action> map = HashMultimap.create(); for (Map.Entry<String, Class<? extends Annotation>> entry : methodMap.entrySet()) { Class<? extends Annotation> get = entry.getValue(); // First search any available public methods and store them (including inherited ones) for (Method method : clazz.getMethods()) { if (method.isAnnotationPresent(get)) { if (!method.isAccessible()) method.setAccessible(true); //ugh // Be defensive about subpaths. if (method.isAnnotationPresent(At.class)) { // Skip any at-annotated methods for a top-level path registration. if (tail.isEmpty()) { continue; } // Skip any at-annotated methods that do not exactly match the path. if (!tail.equals(method.getAnnotation(At.class).value())) { continue; } } else if (!tail.isEmpty()) { // If this is the top-level method we're scanning, but their is a tail, i.e. // this is not intended to be served by the top-level method, then skip. continue; } // Otherwise register this method for firing... //remember default value is empty string String value = getValue(get, method); String key = (Strings.empty(value)) ? entry.getKey() : entry.getKey() + value; map.put(key, new MethodTuple(method, injector)); } } // Then search class's declared methods only (these take precedence) for (Method method : clazz.getDeclaredMethods()) { if (method.isAnnotationPresent(get)) { if (!method.isAccessible()) method.setAccessible(true); //ugh // Be defensive about subpaths. if (method.isAnnotationPresent(At.class)) { // Skip any at-annotated methods for a top-level path registration. if (tail.isEmpty()) { continue; } // Skip any at-annotated methods that do not exactly match the path. if (!tail.equals(method.getAnnotation(At.class).value())) { continue; } } else if (!tail.isEmpty()) { // If this is the top-level method we're scanning, but their is a tail, i.e. // this is not intended to be served by the top-level method, then skip. continue; } // Otherwise register this method for firing... //remember default value is empty string String value = getValue(get, method); String key = (Strings.empty(value)) ? entry.getKey() : entry.getKey() + value; map.put(key, new MethodTuple(method, injector)); } } } return map; } private String getValue(Class<? extends Annotation> get, Method method) { return readAnnotationValue(method.getAnnotation(get)); } public Renderable widget() { return pageWidget.get(); } public Object instantiate() { return clazz == null ? Collections.emptyMap() : injector.getInstance(clazz); } public boolean isHeadless() { return headless; } @Override public boolean isDecorated() { return extension; } public Set<String> getMethod() { return methods.keySet(); } public int compareTo(Page page) { return uri.compareTo(page.getUri()); } public Object doMethod(String httpMethod, Object page, String pathInfo, Request request) throws IOException { //nothing to fire if (Strings.empty(httpMethod)) { return null; } // NOTE(dhanji): This slurps the entire Map. It could potentially be optimized... Multimap<String, String> params = request.params(); // Extract injectable pieces of the pathInfo. final Map<String, String> map = matcher.findMatches(pathInfo); // Find method(s) to dispatch to. Collection<String> events = params.get(select.value()); if (null != events) { boolean matched = false; for (String event : events) { String key = httpMethod + event; Collection<Action> tuples = methods.get(key); Object redirect = null; if (null != tuples) { for (Action action : tuples) { if (action.shouldCall(request)) { matched = true; redirect = action.call(request, page, map); break; } } } // Redirects interrupt the event dispatch sequence. Note this might cause inconsistent // behaviour depending on the order of processing for events. if (null != redirect) { return redirect; } } // no matched events. Fire default handler if (!matched) { return callAction(httpMethod, page, map, request); } } else { // Fire default handler (no events defined) return callAction(httpMethod, page, map, request); } //no redirects, render normally return null; } private Object callAction(String httpMethod, Object page, Map<String, String> pathMap, Request request) throws IOException { // There may be more than one default handler Collection<Action> tuple = methods.get(httpMethod); Object redirect = null; if (null != tuple) { for (Action action : tuple) { if (action.shouldCall(request)) { redirect = action.call(request, page, pathMap); break; } } } return redirect; } public Class<?> pageClass() { return clazz; } public void apply(Renderable widget) { this.pageWidget.set(widget); } public String getUri() { return uri; } @Override public Show getShow() { for (String httpMethod: methods.keySet()) { Collection<Action> actions = methods.get(httpMethod); if (actions != null) { for (Action action: actions) { Method method = action.getMethod(); if (method != null) { Show show = action.getMethod().getAnnotation(Show.class); if (show != null) { return show; } } } } } return null; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Page)) return false; Page that = (Page) o; return this.clazz.equals(that.pageClass()) && isDecorated() == that.isDecorated(); } @Override public int hashCode() { return clazz.hashCode(); } @Override public String toString() { return Objects.toStringHelper(PageTuple.class).add("clazz", clazz).add("isDecorated", extension) .add("uri", uri).add("methods", methods).toString(); } } private static class MethodTuple implements Action { private final Method method; private final Injector injector; private final List<Object> args; private final Map<String, String> negotiates; private final ContentNegotiator negotiator; private final TypeConverter converter; private final As returnAs; private MethodTuple(Method method, Injector injector) { this.method = method; this.injector = injector; this.args = reflect(method); this.negotiates = discoverNegotiates(method, injector); this.negotiator = injector.getInstance(ContentNegotiator.class); this.converter = injector.getInstance(TypeConverter.class); this.returnAs = method.getAnnotation(As.class); } private List<Object> reflect(Method method) { final Annotation[][] annotationsGrid = method.getParameterAnnotations(); if (null == annotationsGrid) return Collections.emptyList(); List<Object> args = new ArrayList<Object>(); for (int i = 0; i < annotationsGrid.length; i++) { Annotation[] annotations = annotationsGrid[i]; Annotation bindingAnnotation = null; boolean preInjectableFound = false; for (Annotation annotation : annotations) { if (Named.class.isInstance(annotation)) { Named named = (Named) annotation; args.add(new NamedParameter(named.value(), method.getGenericParameterTypes()[i])); preInjectableFound = true; break; } if (javax.inject.Named.class.isInstance(annotation)) { javax.inject.Named named = (javax.inject.Named) annotation; args.add(new NamedParameter(named.value(), method.getGenericParameterTypes()[i])); preInjectableFound = true; break; } else if (annotation.annotationType().isAnnotationPresent(BindingAnnotation.class)) { bindingAnnotation = annotation; } else if (As.class.isInstance(annotation)) { As as = (As) annotation; if (method.isAnnotationPresent(Get.class) || method.isAnnotationPresent(Head.class) || method.isAnnotationPresent(Trace.class)) { if (! as.value().equals(Form.class)) { throw new IllegalArgumentException("Cannot accept a @As(...) request body from" + " method marked @Get, @Head or @Trace: " + method.getDeclaringClass().getName() + "#" + method.getName() + "()"); } } preInjectableFound = true; args.add(new AsParameter(as.value(), TypeLiteral.get(method.getGenericParameterTypes()[i]))); break; } } if (!preInjectableFound) { Type genericParameterType = method.getGenericParameterTypes()[i]; Key<?> key = (null != bindingAnnotation) ? Key.get(genericParameterType, bindingAnnotation) : Key.get(genericParameterType); args.add(key); if (null == injector.getBindings().get(key)) { throw new InvalidEventHandlerException( "Encountered an argument not annotated with @Named and not a valid injection key" + " in event handler method: " + method + " " + key); } } } return Collections.unmodifiableList(args); } /** * @return true if this method tuple can be validly called against this request. * Used to select for content negotiation. */ @Override public boolean shouldCall(Request request) { return negotiator.shouldCall(negotiates, request); } @Override public Object call(Request request, Object page, Map<String, String> map) throws IOException { List<Object> arguments = new ArrayList<Object>(); for (Object arg : args) { if (arg instanceof AsParameter) { AsParameter as = (AsParameter) arg; arguments.add(request.read(as.type).as(as.transport)); } else if (arg instanceof NamedParameter) { NamedParameter np = (NamedParameter) arg; String text = map.get(np.getName()); Object value = converter.convert(text, np.getType()); arguments.add(value); } else arguments.add(injector.getInstance((Key<?>) arg)); } Object result = call(page, method, arguments.toArray()); if (returnAs != null && result instanceof Reply) { ((Reply) result).as(returnAs.value()); } return result; } @Override public Method getMethod() { return this.method; } private static Object call(Object page, final Method method, Object[] args) { try { return method.invoke(page, args); } catch (IllegalAccessException e) { throw new EventDispatchException( "Could not access event method (appears to be a security problem): " + method, e); } catch (InvocationTargetException e) { Throwable cause = e.getCause(); if (cause instanceof ValidationException) { throw (ValidationException) cause; } StackTraceElement[] stackTrace = cause.getStackTrace(); throw new EventDispatchException(String.format( "Exception [%s - \"%s\"] thrown by event method [%s]\n\nat %s\n" + "(See below for entire trace.)\n", cause.getClass().getSimpleName(), cause.getMessage(), method, stackTrace[0]), e); } } //the @Accept request header-based event dispatcher private Map<String, String> discoverNegotiates(Method method, Injector injector) { // This ugly gunk gets us the map of headers to negotiation annotations Map<String, Class<? extends Annotation>> negotiationsMap = injector.getInstance( Key.get(new TypeLiteral<Map<String, Class<? extends Annotation>>>(){ }, Negotiation.class)); Map<String, String> negotiations = Maps.newHashMap(); // Gather all the negotiation annotations in this class. for (Map.Entry<String, Class<? extends Annotation>> headerAnn : negotiationsMap.entrySet()) { Annotation annotation = method.getAnnotation(headerAnn.getValue()); if (annotation != null) { negotiations.put(headerAnn.getKey(), readAnnotationValue(annotation)); } } return negotiations; } public class NamedParameter { private final String name; private final Type type; public NamedParameter(String name, Type type) { this.name = name; this.type = type; } public String getName() { return name; } public Type getType() { return type; } } public class AsParameter { private final Class<? extends Transport> transport; private final TypeLiteral<?> type; public AsParameter(Class<? extends Transport> transport, TypeLiteral<?> type) { this.transport = transport; this.type = type; } } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; MethodTuple that = (MethodTuple) o; if (!method.equals(that.method)) return false; return true; } @Override public int hashCode() { return method.hashCode(); } @Override public String toString() { return "MethodTuple [method=" + method + ", args=" + args + "]"; } } /** * A simple utility method that reads the String value attribute of any annotation * instance. */ static String readAnnotationValue(Annotation annotation) { try { Method m = annotation.getClass().getMethod("value"); return (String) m.invoke(annotation); } catch (NoSuchMethodException e) { throw new IllegalStateException("Encountered a configured annotation that " + "has no value parameter. This should never happen. " + annotation, e); } catch (InvocationTargetException e) { throw new IllegalStateException("Encountered a configured annotation that " + "could not be read." + annotation, e); } catch (IllegalAccessException e) { throw new IllegalStateException("Encountered a configured annotation that " + "could not be read." + annotation, e); } } }