001package ball.spring;
002/*-
003 * ##########################################################################
004 * Reusable Spring Components
005 * %%
006 * Copyright (C) 2018 - 2025 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.apache.commons.lang3.Strings;
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.SpringTemplateEngine;
047import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
048import org.webjars.RequireJS;
049
050import static lombok.AccessLevel.PROTECTED;
051import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
052import static org.springframework.http.HttpStatus.NOT_FOUND;
053
054/**
055 * Abstract {@link org.springframework.stereotype.Controller} base class.
056 * Implements {@link ErrorController}, implements {@link #getViewName()}
057 * (with
058 * {@code String.join("-", getClass().getPackage().getName().split(Pattern.quote(".")))}),
059 * provides {@link #addDefaultModelAttributesTo(Model)} from corresponding
060 * {@code template.model.properties}, and configures
061 * {@link SpringResourceTemplateResolver} to use decoupled logic.
062 *
063 * {@injected.fields}
064 *
065 * @author {@link.uri mailto:ball@hcf.dev Allen D. Ball}
066 */
067@NoArgsConstructor(access = PROTECTED) @ToString @Log4j2
068public abstract class AbstractController implements ErrorController {
069    @Value("${server.error.path:${error.path:/error}}")
070    private String errorPath = null;
071
072    @Autowired
073    private ApplicationContext context = null;
074
075    @Autowired
076    private SpringTemplateEngine engine = null;
077
078    @Autowired
079    private SpringResourceTemplateResolver resolver = null;
080
081    private ConcurrentSkipListMap<String,Properties> viewDefaultAttributesMap = new ConcurrentSkipListMap<>();
082
083    @PostConstruct
084    public void init() {
085        engine.addDialect(new WebJarsDialect());
086        resolver.setUseDecoupledLogic(true);
087    }
088
089    @PreDestroy
090    public void destroy() { }
091
092    /* org.springframework.web.servlet.RequestToViewNameTranslator */
093    public String getViewName(/* HttpServletRequest request */) {
094        return String.join("-", getClass().getPackage().getName().split(Pattern.quote(".")));
095    }
096
097    @ModelAttribute
098    public void addDefaultModelAttributesTo(Model model) {
099        BindingAwareModelMap defaults = new BindingAwareModelMap();
100        Properties properties =
101            viewDefaultAttributesMap.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 = context.getEnvironment().resolvePlaceholders(unresolved);
111
112                if (unresolved.equals(value)) {
113                    break;
114                }
115            }
116
117            defaults.put(key, value);
118        }
119
120        model.mergeAttributes(defaults.asMap());
121    }
122
123    private Properties getDefaultAttributesFor(String name) {
124        Properties properties = null;
125
126        try {
127            name = Strings.CI.prependIfMissing(name, resolver.getPrefix());
128            name = Strings.CI.removeEnd(name, resolver.getSuffix());
129            name = Strings.CI.appendIfMissing(name, ".model.properties");
130
131            properties = new PropertiesFactory(context.getResources(name)).getObject();
132        } catch (RuntimeException exception) {
133            throw exception;
134        } catch (Exception exception) {
135            throw new IllegalStateException(exception);
136        }
137
138        return properties;
139    }
140
141    /**
142     * See {@link RequireJS#getSetupJavaScript(String)}.
143     *
144     * @return  The set-up javascript.
145     */
146    @ResponseBody
147    @RequestMapping(value = "/webjarsjs", produces = "application/javascript")
148    public String webjarsjs() {
149        return RequireJS.getSetupJavaScript("/webjars/");
150    }
151
152    @RequestMapping(value = "${server.error.path:${error.path:/error}}")
153    public String error() { return getViewName(); }
154
155    @ExceptionHandler
156    @ResponseStatus(value = NOT_FOUND)
157    public String handleNOT_FOUND(Model model, NoSuchElementException exception) {
158        return handle(model, exception);
159    }
160
161    @ExceptionHandler
162    @ResponseStatus(value = INTERNAL_SERVER_ERROR)
163    public String handle(Model model, Exception exception) {
164        addDefaultModelAttributesTo(model);
165
166        model.addAttribute("exception", exception);
167
168        return getViewName();
169    }
170
171    @ToString
172    private class PropertiesFactory extends PropertiesFactoryBean {
173        public PropertiesFactory(Resource[] resources) {
174            super();
175
176            try {
177                setIgnoreResourceNotFound(true);
178                setLocations(resources);
179                setSingleton(false);
180
181                mergeProperties();
182            } catch (Exception exception) {
183                throw new ExceptionInInitializerError(exception);
184            }
185        }
186    }
187}