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