001package ball.spring;
002/*-
003 * ##########################################################################
004 * Reusable Spring Components
005 * %%
006 * Copyright (C) 2018 - 2022 Allen D. Ball
007 * %%
008 * Licensed under the Apache License, Version 2.0 (the "License");
009 * you may not use this file except in compliance with the License.
010 * You may obtain a copy of the License at
011 *
012 *      http://www.apache.org/licenses/LICENSE-2.0
013 *
014 * Unless required by applicable law or agreed to in writing, software
015 * distributed under the License is distributed on an "AS IS" BASIS,
016 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
017 * See the License for the specific language governing permissions and
018 * limitations under the License.
019 * ##########################################################################
020 */
021import ball.spring.dialect.WebJarsDialect;
022import java.util.Map;
023import java.util.NoSuchElementException;
024import java.util.Properties;
025import java.util.concurrent.ConcurrentSkipListMap;
026import java.util.regex.Pattern;
027import javax.annotation.PostConstruct;
028import javax.annotation.PreDestroy;
029import lombok.NoArgsConstructor;
030import lombok.ToString;
031import lombok.extern.log4j.Log4j2;
032import org.springframework.beans.factory.annotation.Autowired;
033import org.springframework.beans.factory.annotation.Value;
034import org.springframework.beans.factory.config.PropertiesFactoryBean;
035import org.springframework.boot.web.servlet.error.ErrorController;
036import org.springframework.context.ApplicationContext;
037import org.springframework.core.io.Resource;
038import org.springframework.ui.Model;
039import org.springframework.validation.support.BindingAwareModelMap;
040import org.springframework.web.bind.annotation.ExceptionHandler;
041import org.springframework.web.bind.annotation.ModelAttribute;
042import org.springframework.web.bind.annotation.RequestMapping;
043import org.springframework.web.bind.annotation.ResponseBody;
044import org.springframework.web.bind.annotation.ResponseStatus;
045import org.thymeleaf.spring5.SpringTemplateEngine;
046import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
047import org.webjars.RequireJS;
048
049import static lombok.AccessLevel.PROTECTED;
050import static org.apache.commons.lang3.StringUtils.appendIfMissing;
051import static org.apache.commons.lang3.StringUtils.prependIfMissing;
052import static org.apache.commons.lang3.StringUtils.removeEnd;
053import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
054import static org.springframework.http.HttpStatus.NOT_FOUND;
055
056/**
057 * Abstract {@link org.springframework.stereotype.Controller} base class.
058 * Implements {@link ErrorController}, implements {@link #getViewName()}
059 * (with
060 * {@code String.join("-", getClass().getPackage().getName().split(Pattern.quote(".")))}),
061 * provides {@link #addDefaultModelAttributesTo(Model)} from corresponding
062 * {@code template.model.properties}, and configures
063 * {@link SpringResourceTemplateResolver} to use decoupled logic.
064 *
065 * {@injected.fields}
066 *
067 * @author {@link.uri mailto:ball@hcf.dev Allen D. Ball}
068 */
069@NoArgsConstructor(access = PROTECTED) @ToString @Log4j2
070public abstract class AbstractController implements ErrorController {
071    @Value("${server.error.path:${error.path:/error}}")
072    private String errorPath = null;
073
074    @Autowired
075    private ApplicationContext context = null;
076
077    @Autowired
078    private SpringTemplateEngine engine = null;
079
080    @Autowired
081    private SpringResourceTemplateResolver resolver = null;
082
083    private ConcurrentSkipListMap<String,Properties> viewDefaultAttributesMap = new ConcurrentSkipListMap<>();
084
085    @PostConstruct
086    public void init() {
087        engine.addDialect(new WebJarsDialect());
088        resolver.setUseDecoupledLogic(true);
089    }
090
091    @PreDestroy
092    public void destroy() { }
093
094    /* org.springframework.web.servlet.RequestToViewNameTranslator */
095    public String getViewName(/* HttpServletRequest request */) {
096        return String.join("-", getClass().getPackage().getName().split(Pattern.quote(".")));
097    }
098
099    @ModelAttribute
100    public void addDefaultModelAttributesTo(Model model) {
101        BindingAwareModelMap defaults = new BindingAwareModelMap();
102        Properties properties =
103            viewDefaultAttributesMap.computeIfAbsent(getViewName(), k -> getDefaultAttributesFor(k));
104
105        for (Map.Entry<Object,Object> entry : properties.entrySet()) {
106            String key = entry.getKey().toString();
107            String value = entry.getValue().toString();
108
109            while (value != null) {
110                String unresolved = value;
111
112                value = context.getEnvironment().resolvePlaceholders(unresolved);
113
114                if (unresolved.equals(value)) {
115                    break;
116                }
117            }
118
119            defaults.put(key, value);
120        }
121
122        model.mergeAttributes(defaults.asMap());
123    }
124
125    private Properties getDefaultAttributesFor(String name) {
126        Properties properties = null;
127
128        try {
129            name = prependIfMissing(name, resolver.getPrefix());
130            name = removeEnd(name, resolver.getSuffix());
131            name = appendIfMissing(name, ".model.properties");
132
133            properties = new PropertiesFactory(context.getResources(name)).getObject();
134        } catch (RuntimeException exception) {
135            throw exception;
136        } catch (Exception exception) {
137            throw new IllegalStateException(exception);
138        }
139
140        return properties;
141    }
142
143    /**
144     * See {@link RequireJS#getSetupJavaScript(String)}.
145     *
146     * @return  The set-up javascript.
147     */
148    @ResponseBody
149    @RequestMapping(value = "/webjarsjs", produces = "application/javascript")
150    public String webjarsjs() {
151        return RequireJS.getSetupJavaScript("/webjars/");
152    }
153
154    @RequestMapping(value = "${server.error.path:${error.path:/error}}")
155    public String error() { return getViewName(); }
156
157    @ExceptionHandler
158    @ResponseStatus(value = NOT_FOUND)
159    public String handleNOT_FOUND(Model model, NoSuchElementException exception) {
160        return handle(model, exception);
161    }
162
163    @ExceptionHandler
164    @ResponseStatus(value = INTERNAL_SERVER_ERROR)
165    public String handle(Model model, Exception exception) {
166        addDefaultModelAttributesTo(model);
167
168        model.addAttribute("exception", exception);
169
170        return getViewName();
171    }
172
173    @ToString
174    private class PropertiesFactory extends PropertiesFactoryBean {
175        public PropertiesFactory(Resource[] resources) {
176            super();
177
178            try {
179                setIgnoreResourceNotFound(true);
180                setLocations(resources);
181                setSingleton(false);
182
183                mergeProperties();
184            } catch (Exception exception) {
185                throw new ExceptionInInitializerError(exception);
186            }
187        }
188    }
189}