All Downloads are FREE. Search and download functionalities are using the official Maven repository.

framework.Application Maven / Gradle / Ivy

package framework;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.PrintWriter;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.sql.DriverManager;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import app.config.Sys;
import app.controller.Main;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import framework.Response.Status;
import framework.Tuple.Tuple3;
import framework.annotation.Config;
import framework.annotation.Content;
import framework.annotation.Job;
import framework.annotation.Letters;
import framework.annotation.Only;
import framework.annotation.Route;
import framework.annotation.Valid;
import framework.annotation.Validator;
import framework.annotation.Validator.Errors;

/**
 * application scoped object
 */
@SuppressWarnings("serial")
public abstract class Application implements Attributes {

    /**
     * Singleton
     */
    static Application CURRENT;
    
    /**
     * Global validators
     */
    public final Set> globalValidators = new LinkedHashSet<>();

    /**
     * Shutdown actions
     */
    protected List shutdowns = Tool
        .list(Log::shutdown, Job.Scheduler::shutdown, Try.r(Db::shutdown, e -> Log.warning("Db shutdown error")), () -> Tool.stream(DriverManager.getDrivers())
            .forEach(Try.c(DriverManager::deregisterDriver)));

    /**
     * routing table{{request method, pattern, bind map}: {class: method}}
     */
    static Map, Pattern, Map>, Tuple, Method>> routing;

    /**
     * @return singleton
     */
    public static Optional current() {
        return Tool.of(CURRENT);
    }

    /**
     * @return context path
     */
    public abstract String getContextPath();

    @Override
    public String toString() {
        return "real path: " + Tool.val(Tool.trim(null, Tool.toURL("framework")
            .get()
            .toString(), "/"), s -> s.substring(0, s.length() - "framework".length())) + ", context path: " + getContextPath();
    }

    /**
     * @return Routes(method, path, action)
     */
    public static Stream routes() {
        return routing.entrySet()
            .stream()
            .map(route -> Tool.array(route.getKey().l.isEmpty() ? "*"
                    : route.getKey().l.stream()
                        .map(Object::toString)
                        .collect(Collectors.joining(", ")), route.getKey().r.l
                            .pattern(), route.getValue().l.getName() + "." + route.getValue().r.getName(), route.getKey().r.r.toString()))
            .sorted(Comparator.comparing(a -> a[1])
                .thenComparing(a -> a[0]));
    }

    /**
     * setup
     *
     * @param factory response factory
     */
    @SuppressFBWarnings({ "LI_LAZY_INIT_STATIC" })
    void setup(Supplier factory) {

        /* check to enabled of method parameters name */
        try {
            if (!"factory".equals(Application.class.getDeclaredMethod("setup", Supplier.class)
                .getParameters()[0].getName())) {
                throw new InternalError("must to enable compile option `-parameters`");
            }
        } catch (NoSuchMethodException | SecurityException e) {
            throw new InternalError(e);
        }

        /* setup log */
        Log.startup();
        Log.info(Application.current()
            .get()::toString);

        /* setup for response creator */
        if (Response.factory == null) {
            Response.factory = factory;
        }

        /* setup routing */
        if (routing == null) {
            routing = new HashMap<>();
            try (Stream> cs = Tool.getClasses(Main.class.getPackage()
                .getName())) {
                cs.flatMap(c -> Stream.of(c.getDeclaredMethods())
                    .map(m -> Tuple.of(m, m.getAnnotation(Route.class)))
                    .filter(pair -> pair.r != null)
                    .map(pair -> Tuple.of(c, pair.l, pair.r)))
                    .collect(() -> routing, (map, trio) -> {
                        Class clazz = trio.l;
                        Method method = trio.r.l;
                        String path = Tool.path("/", Tool.of(clazz.getAnnotation(Route.class))
                            .map(Route::value)
                            .orElse(""), trio.r.r.value())
                            .apply("/");
                        Map renameMap = new HashMap<>();
                        ByteArrayOutputStream out = new ByteArrayOutputStream();
                        PrintWriter writer = new PrintWriter(out);
                        Tool.printReplace(writer, path, (w, to, prefix) -> {
                            String from;
                            if (!to.chars()
                                .allMatch(i -> (Letters.ALPHABETS + Letters.DIGITS).indexOf(i) >= 0)) {
                                from = String.format("HasH%08x", to.hashCode());
                                renameMap.put(from, to);
                            } else {
                                from = to;
                            }
                            writer.print("(?<" + from + ">");
                        }, "(?<", ">");
                        writer.flush();
                        path = out.toString();
                        method.setAccessible(true);
                        map.compute(Tuple.of(Tool.set(trio.r.r.method()), Pattern.compile(out.toString()), renameMap), (k, v) -> {
                            if (v != null) {
                                Log.warning("duplicated route: " + k + " [disabled] " + v.r + " [enabled] " + method);
                            }
                            return Tuple.of(clazz, method);
                        });
                    }, Map::putAll);
            }
            Log.info(() -> Tool.print(writer -> {
                writer.println("---- routing ----");
                routes().forEach(a -> writer.println(a[0] + " " + a[1] + " -> " + a[2] + " " + Tool.trim("{", a[3], "}")));
            }));
        }

        /* start h2 tcp server */
        Sys.h2_tcp_port.ifPresent(port -> {
            try {
                List parameters = Tool.list("-tcpPort", String.valueOf(port));
                if (Sys.h2_tcp_allow_remote) {
                    parameters.add("-tcpAllowOthers");
                }
                if (Sys.h2_tcp_ssl) {
                    parameters.add("-tcpSSL");
                }
                Object tcp = Reflector.invoke("org.h2.tools.Server.createTcpServer", Tool
                    .array(String[].class), new Object[] { parameters.toArray(new String[parameters.size()]) });
                Reflector.invoke(tcp, "start", Tool.array());
                shutdowns.add(Try.r(() -> Reflector.invoke(tcp, "stop", Tool.array()), e -> Log.warning(e, () -> "h2 tcp server stop error")));
                Log.info("h2 tcp server started on port " + port);
            } catch (Exception e) {
                Log.warning(e, () -> "h2 tcp server error");
            }
        });

        /* start H2 web interface */
        Sys.h2_web_port.ifPresent(port -> {
            try {
                File config = new File(Tool.suffix(System.getProperty("java.io.tmpdir"), File.separator) + ".h2.server.properties");
                List lines = new ArrayList<>();
                lines.add("webAllowOthers=" + Sys.h2_web_allow_remote);
                lines.add("webPort=" + port);
                lines.add("webSSL=" + Sys.h2_web_ssl);
                AtomicInteger index = new AtomicInteger(-1);
                Tool.val(Config.Injector.getSource(Sys.class, Session.currentLocale()), properties -> properties.stringPropertyNames()
                    .stream()
                    .sorted(String::compareTo)
                    .map(p -> Tuple.of(p, properties.getProperty(p)))
                    .filter(t -> t.l.startsWith("Sys.Db") && t.r.startsWith("jdbc:"))
                    .map(t -> index.incrementAndGet() + "=" + t.l + "|" + Db.Type.fromUrl(t.r).driver + "|" + t.r.replace(":", "\\:")
                        .replace("=", "\\=")))
                    .forEach(lines::add);
                Files.write(config.toPath(), lines, StandardCharsets.UTF_8);
                config.deleteOnExit();
                Object db = Reflector
                    .invoke("org.h2.tools.Server.createWebServer", Tool.array(String[].class), new Object[] { Tool.array("-properties", config.getParent()) });
                Reflector.invoke(db, "start", Tool.array());
                shutdowns.add(Try.r(() -> Reflector.invoke(db, "stop", Tool.array()), e -> Log.warning(e, () -> "h2 web interface stop error")));
                Log.info("h2 web interface started on port " + port);
            } catch (Exception e) {
                Log.warning(e, () -> "h2 web interface error");
            }
        });

        /* database setup */
        Db.setup(Sys.Db.setup);

        /* load database config */
        Config.Injector.loadDb();

        Log.info(() -> "---- setting ----" + Letters.CRLF + Config.Injector.classes.stream()
            .map(c -> String.join(Letters.CRLF, Config.Injector.dumpConfig(c, true)))
            .collect(Collectors.joining(Letters.CRLF)));

        Log.info(() -> "---- message ----" + Letters.CRLF + String.join(Letters.CRLF, Config.Injector.dumpMessage()));

        /* job scheduler setup */
        List> cs = new ArrayList<>();
        Sys.job_packages.stream()
            .forEach(p -> {
                try (Stream> classes = Tool.getClasses(p)) {
                    classes.forEach(cs::add);
                }
            });
        Job.Scheduler.setup(cs.toArray(new Class[cs.size()]));
        Job.Scheduler.trigger(Job.OnApplicationStart);
    }

    /**
     * shutdown
     */
    void shutdown() {
        Job.Scheduler.trigger(Job.OnApplicationEnd);
        Collections.reverse(shutdowns);
        shutdowns.forEach(action -> action.run());
    }

    /**
     * request handle
     *
     * @param request request
     * @param session session
     */
    void handle(Request request, Session session) {
        Log.info(request::toString);

        final String path = request.getPath();

        /* no slash root access */
        if (path == null) {
            Response.redirect(getContextPath(), Status.Moved_Permamently)
                .flush();
            return;
        }

        final Optional mime = Tool.string(Tool.getExtension(path))
                .map(Tool::getContentType);

        /* Pre-request event */
        Object result = Job.Scheduler.trigger(Job.OnRequest);
        if(result != null) {
            Consumer setContentType = r -> {
                Tool.ifPresentOr(mime, m -> r.contentType(m, Tool.isTextContent(path) ? StandardCharsets.UTF_8 : null), () -> {});
            };
            if (result instanceof Response) {
                Tool.peek((Response) result, r -> {
                    if (r.headers == null || !r.headers.containsKey("Content-Type")) {
                        setContentType.accept(r);
                    }
                })
                    .flush();
            } else {
                Tool.peek(Response.of(result), setContentType::accept)
                    .flush();
            }
            return;
        }

        /* action */
        Map> parameters = new HashMap<>(request.getParameters());
        final String normalizedPath = Tool.prefix(Tool.trim(null, path, "/"), "/");
        final Tuple, Method> pair = routing.entrySet()
            .stream()
            .filter(e -> e.getKey().l.isEmpty() || e.getKey().l.contains(request.getMethod()))
            .map(e -> Tuple.of(e.getKey().r.l.matcher(normalizedPath), e.getKey().r.r, e.getValue()))
            .filter(p -> p.l.matches())
            .sorted(Comparator.comparing(c -> c.getValue()
                .getValue()
                .getValue()
                .getAnnotation(Route.class)
                .priority(), Comparator.reverseOrder()))
            .findFirst()
            .map(p -> {
                Reflector.>invoke(p.l.pattern(), "namedGroups", Tool.array())
                    .forEach((k, v) -> Tool.setValue(parameters, p.r.l.getOrDefault(k, k), p.l.group(v), ArrayList::new));
                return p.r.r;
            })
            .orElse(null);
        if (pair != null && Tool.of(pair.r.getAnnotation(Content.class))
            .map(Content::value)
            .map(i -> Tool.list(i)
                .contains(mime.orElse("")))
            .orElse(true)) {
            do {
                Method method = pair.r;
                Only only = Tool.or(method.getAnnotation(Only.class), () -> method.getDeclaringClass()
                    .getAnnotation(Only.class))
                    .orElse(null);

                /* go login page if not logged in */
                if (only != null && Sys.redirect_if_not_login.filter(i -> !i.equals(path))
                    .isPresent() && !session.isLoggedIn()) {
                    String host = Tool.getFirst(request.getHeaders(), "Host")
                        .orElse("localhost");
                    String referer = Tool.getFirst(request.getHeaders(), "Referer")
                        .orElse("");
                    if (referer.contains(host) && !session.containsKey("alert")) {
                        session.put("alert", Sys.Alert.timeout);
                    }
                    if(Tool.getFirst(request.getHeaders(), "X-requested-with").filter(i -> i.equals("XMLHttpRequest")).isPresent()) {
                        Response.error(Status.Unauthorized).flush();
                        return;
                    }
                    Response.redirect(Tool.path(getContextPath(), Sys.redirect_if_not_login.get())
                        .apply("/"))
                        .flush();
                    return;
                }

                /* forbidden check */
                boolean forbidden = only != null && !session.isLoggedIn();
                if (!forbidden && only != null && only.value().length > 0) {
                    forbidden = !session.getAccount()
                        .hasAnyRole(only.value());
                }
                if (forbidden) {
                    session.setAttr("alert", Sys.Alert.forbidden);
                    Response.template("error.html")
                        .flush();
                    return;
                }

                try (Lazy db = new Lazy<>(Db::connect)) {
                    try {
                        Log.config("[invoke method] " + method.getDeclaringClass()
                            .getName() + "." + method.getName());
                        Binder binder = new Binder(parameters).files(request.getFiles());
                        Object[] args = Stream.of(method.getParameters())
                            .map(p -> {
                                Class type = p.getType();
                                if (Request.class.isAssignableFrom(type)) {
                                    return request;
                                }
                                if (Session.class.isAssignableFrom(type)) {
                                    return session;
                                }
                                if (Application.class.isAssignableFrom(type)) {
                                    return this;
                                }
                                if (Db.class.isAssignableFrom(type)) {
                                    return db.get();
                                }
                                if (Errors.class.isAssignableFrom(type)) {
                                    return binder.errors;
                                }
                                String name = p.getName();
                                Valid valid = p.getAnnotation(Valid.class);
                                if(valid != null) {
                                	Validator.Manager.validateClass(valid.value(), p.getType(), name, binder.parameters, binder);
                                	binder.validator(null);
                                } else {
                                	binder.validator((n, value) -> Stream.concat(globalValidators.stream(), //
											Stream.of(p.getAnnotations())//
													.map(a -> Validator.Manager.instance(a).orElse(null))//
													.filter(Objects::nonNull))//
											.forEach(v -> v.validate(Valid.All.class, n, value, binder)));
                                }
								return binder.bind(name, type, Reflector.getGenericParameters(p));
	                        }).toArray();
                        Object response = method.invoke(Modifier.isStatic(method.getModifiers()) ? null : Reflector.instance(pair.l), args);
                        Consumer setContentType = r -> {
                            Content content = method.getAnnotation(Content.class);
                            String[] accept = request.getHeaders()
                                .getOrDefault("accept", Collections.emptyList())
                                .stream()
                                .flatMap(i -> Stream.of(i.split("\\s*,\\s*"))
                                    .map(j -> j.replaceAll(";.*$", "")
                                        .trim()
                                        .toLowerCase(Locale.ENGLISH)))
                                .toArray(String[]::new);
                            Tool.ifPresentOr(Tool.or(mime, () -> Stream.of(accept)
                                .filter(i -> content == null || Stream.of(content.value())
                                    .anyMatch(i::equals))
                                .findFirst()), m -> r.contentType(m, Tool.isTextContent(path) ? StandardCharsets.UTF_8 : null), () -> {
                                    throw new RuntimeException("not accept mime type: " + Arrays.toString(accept));
                                });
                        };
                        if (response instanceof Response) {
                            Tool.peek((Response) response, r -> {
                                if (r.headers == null || !r.headers.containsKey("Content-Type")) {
                                    setContentType.accept(r);
                                }
                            })
                                .flush();
                        } else {
                            Tool.peek(Response.of(response), setContentType::accept)
                                .flush();
                        }
                        return;
                    } catch (InvocationTargetException e) {
                        db.ifGot(Db::rollback);
                        Throwable t = e.getCause();
                        if (t instanceof RuntimeException) {
                            throw (RuntimeException) t;
                        }
                        throw new RuntimeException(t);
                    } catch (IllegalAccessException e) {
                        db.ifGot(Db::rollback);
                        throw new RuntimeException(e);
                    } catch (RuntimeException e) {
                        db.ifGot(Db::rollback);
                        throw e;
                    }
                } catch(Exception e) {
                    Throwable t = e;
                    while(t.getCause() != null) {
                    	t = t.getCause();
                    }
                    if (t != null && ("ClientAbortException".equals(t.getClass().getSimpleName())
                            || (t.getMessage() != null && (t.getMessage().startsWith("確立された接続")
                            || t.getMessage().startsWith("An established connection"))))) {
                        Log.warning(t.toString());
                        return;
                    } else {
                        throw e;
                    }
                }
            } while (false);
        }

        /* static file */
        try {
            Response.file(path).flush();
        } catch(Exception e) {
            Throwable t = e;
            while(t.getCause() != null) {
            	t = t.getCause();
            }
            if (t != null && "ClientAbortException".equals(t.getClass().getSimpleName())) {
                Log.warning(t.toString());
            } else {
                throw e;
            }
        }
    }

	/**
	 * @param annotation Annotation
	 */
	public void addGlobalValidator(Annotation annotation) {
		globalValidators.add(Validator.Manager.instance(annotation).get());
	}
}