Fri, 18 Dec 2020 15:54:39 +0100
adds possibility to specify multiple additional CSS files
1 /*
2 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
3 *
4 * Copyright 2018 Mike Becker. All rights reserved.
5 *
6 * Redistribution and use in source and binary forms, with or without
7 * modification, are permitted provided that the following conditions are met:
8 *
9 * 1. Redistributions of source code must retain the above copyright
10 * notice, this list of conditions and the following disclaimer.
11 *
12 * 2. Redistributions in binary form must reproduce the above copyright
13 * notice, this list of conditions and the following disclaimer in the
14 * documentation and/or other materials provided with the distribution.
15 *
16 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
20 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26 * POSSIBILITY OF SUCH DAMAGE.
27 *
28 */
29 package de.uapcore.lightpit;
31 import de.uapcore.lightpit.dao.DaoProvider;
32 import de.uapcore.lightpit.dao.postgres.PGDaoProvider;
33 import org.slf4j.Logger;
34 import org.slf4j.LoggerFactory;
36 import javax.servlet.ServletException;
37 import javax.servlet.http.HttpServlet;
38 import javax.servlet.http.HttpServletRequest;
39 import javax.servlet.http.HttpServletResponse;
40 import javax.servlet.http.HttpSession;
41 import java.io.IOException;
42 import java.lang.reflect.*;
43 import java.sql.Connection;
44 import java.sql.SQLException;
45 import java.util.*;
46 import java.util.function.Function;
47 import java.util.stream.Collectors;
49 /**
50 * A special implementation of a HTTPServlet which is focused on implementing
51 * the necessary functionality for LightPIT pages.
52 */
53 public abstract class AbstractLightPITServlet extends HttpServlet {
55 private static final Logger LOG = LoggerFactory.getLogger(AbstractLightPITServlet.class);
57 private static final String SITE_JSP = jspPath("site");
60 @FunctionalInterface
61 protected interface SQLFindFunction<K, T> {
62 T apply(K key) throws SQLException;
64 default <V> SQLFindFunction<V, T> compose(Function<? super V, ? extends K> before) throws SQLException {
65 Objects.requireNonNull(before);
66 return (v) -> this.apply(before.apply(v));
67 }
69 default <V> SQLFindFunction<K, V> andThen(Function<? super T, ? extends V> after) throws SQLException {
70 Objects.requireNonNull(after);
71 return (t) -> after.apply(this.apply(t));
72 }
74 static <K> Function<K, K> identity() {
75 return (t) -> t;
76 }
77 }
79 /**
80 * Invocation mapping gathered from the {@link RequestMapping} annotations.
81 * <p>
82 * Paths in this map must always start with a leading slash, although
83 * the specification in the annotation must not start with a leading slash.
84 * <p>
85 * The reason for this is the different handling of empty paths in
86 * {@link HttpServletRequest#getPathInfo()}.
87 */
88 private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>();
90 /**
91 * Returns the name of the resource bundle associated with this servlet.
92 *
93 * @return the resource bundle base name
94 */
95 protected abstract String getResourceBundleName();
98 /**
99 * Creates a set of data access objects for the specified connection.
100 *
101 * @param connection the SQL connection
102 * @return a set of data access objects
103 */
104 private DaoProvider createDataAccessObjects(Connection connection) throws SQLException {
105 final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME());
106 if (df.getDialect() == DataSourceProvider.Dialect.Postgres) {
107 return new PGDaoProvider(connection);
108 }
109 throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug.");
110 }
112 private void invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DaoProvider dao) throws IOException {
113 final var pathPattern = mapping.getKey();
114 final var method = mapping.getValue();
115 try {
116 LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName());
117 final var paramTypes = method.getParameterTypes();
118 final var paramValues = new Object[paramTypes.length];
119 for (int i = 0; i < paramTypes.length; i++) {
120 if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) {
121 paramValues[i] = req;
122 } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) {
123 paramValues[i] = resp;
124 }
125 if (paramTypes[i].isAssignableFrom(DaoProvider.class)) {
126 paramValues[i] = dao;
127 }
128 if (paramTypes[i].isAssignableFrom(PathParameters.class)) {
129 paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req));
130 }
131 }
132 method.invoke(this, paramValues);
133 } catch (InvocationTargetException ex) {
134 LOG.error("invocation of method {}::{} failed: {}",
135 method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage());
136 LOG.debug("Details: ", ex.getTargetException());
137 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage());
138 } catch (ReflectiveOperationException | ClassCastException ex) {
139 LOG.error("invocation of method {}::{} failed: {}",
140 method.getDeclaringClass().getName(), method.getName(), ex.getMessage());
141 LOG.debug("Details: ", ex);
142 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage());
143 }
144 }
146 @Override
147 public void init() throws ServletException {
148 scanForRequestMappings();
150 LOG.trace("{} initialized", getServletName());
151 }
153 private void scanForRequestMappings() {
154 try {
155 Method[] methods = getClass().getDeclaredMethods();
156 for (Method method : methods) {
157 Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class));
158 if (mapping.isPresent()) {
159 if (mapping.get().requestPath().isBlank()) {
160 LOG.warn("{} is annotated with {} but request path is empty",
161 method.getName(), RequestMapping.class.getSimpleName()
162 );
163 continue;
164 }
166 if (!Modifier.isPublic(method.getModifiers())) {
167 LOG.warn("{} is annotated with {} but is not public",
168 method.getName(), RequestMapping.class.getSimpleName()
169 );
170 continue;
171 }
172 if (Modifier.isAbstract(method.getModifiers())) {
173 LOG.warn("{} is annotated with {} but is abstract",
174 method.getName(), RequestMapping.class.getSimpleName()
175 );
176 continue;
177 }
179 boolean paramsInjectible = true;
180 for (var param : method.getParameterTypes()) {
181 paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param)
182 || HttpServletResponse.class.isAssignableFrom(param)
183 || PathParameters.class.isAssignableFrom(param)
184 || DaoProvider.class.isAssignableFrom(param);
185 }
186 if (paramsInjectible) {
187 try {
188 PathPattern pathPattern = new PathPattern(mapping.get().requestPath());
190 final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>());
191 final var currentMapping = methodMappings.putIfAbsent(pathPattern, method);
192 if (currentMapping != null) {
193 LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}",
194 mapping.get().method(),
195 mapping.get().requestPath(),
196 method.getName(),
197 getClass().getSimpleName(),
198 currentMapping.getName()
199 );
200 }
202 LOG.debug("{} {} maps to {}::{}",
203 mapping.get().method(),
204 mapping.get().requestPath(),
205 getClass().getSimpleName(),
206 method.getName()
207 );
208 } catch (IllegalArgumentException ex) {
209 LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid",
210 method.getName(), mapping.get().requestPath()
211 );
212 }
213 } else {
214 LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed",
215 method.getName(), RequestMapping.class.getSimpleName()
216 );
217 }
218 }
219 }
220 } catch (SecurityException ex) {
221 LOG.error("Scan for request mappings on declared methods failed.", ex);
222 }
223 }
225 @Override
226 public void destroy() {
227 mappings.clear();
228 LOG.trace("{} destroyed", getServletName());
229 }
231 /**
232 * Sets the name of the content page.
233 * <p>
234 * It is sufficient to specify the name without any extension. The extension
235 * is added automatically if not specified.
236 *
237 * @param req the servlet request object
238 * @param pageName the name of the content page
239 * @see Constants#REQ_ATTR_CONTENT_PAGE
240 */
241 protected void setContentPage(HttpServletRequest req, String pageName) {
242 req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName));
243 }
245 /**
246 * Sets the navigation menu.
247 *
248 * @param req the servlet request object
249 * @param jspName the name of the menu's jsp file
250 * @see Constants#REQ_ATTR_NAVIGATION
251 */
252 protected void setNavigationMenu(HttpServletRequest req, String jspName) {
253 req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName));
254 }
256 /**
257 * @param req the servlet request object
258 * @param location the location where to redirect
259 * @see Constants#REQ_ATTR_REDIRECT_LOCATION
260 */
261 protected void setRedirectLocation(HttpServletRequest req, String location) {
262 if (location.startsWith("./")) {
263 location = location.replaceFirst("\\./", baseHref(req));
264 }
265 req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location);
266 }
268 /**
269 * Specifies the names of additional stylesheets used by this Servlet.
270 * <p>
271 * It is sufficient to specify the name without any extension. The extension
272 * is added automatically if not specified.
273 *
274 * @param req the servlet request object
275 * @param stylesheets the names of the stylesheets
276 */
277 public void setStylesheet(HttpServletRequest req, String ... stylesheets) {
278 req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays
279 .stream(stylesheets)
280 .map(s -> enforceExt(s, ".css"))
281 .collect(Collectors.toUnmodifiableList()));
282 }
284 /**
285 * Sets the view model object.
286 * The type must match the expected type in the JSP file.
287 *
288 * @param req the servlet request object
289 * @param viewModel the view model object
290 */
291 public void setViewModel(HttpServletRequest req, Object viewModel) {
292 req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel);
293 }
295 private <T> Optional<T> parseParameter(String paramValue, Class<T> clazz) {
296 if (paramValue == null) return Optional.empty();
297 if (clazz.equals(Boolean.class)) {
298 if (paramValue.toLowerCase().equals("false") || paramValue.equals("0")) {
299 return Optional.of((T) Boolean.FALSE);
300 } else {
301 return Optional.of((T) Boolean.TRUE);
302 }
303 }
304 if (clazz.equals(String.class)) return Optional.of((T) paramValue);
305 if (java.sql.Date.class.isAssignableFrom(clazz)) {
306 try {
307 return Optional.of((T) java.sql.Date.valueOf(paramValue));
308 } catch (IllegalArgumentException ex) {
309 return Optional.empty();
310 }
311 }
312 try {
313 final Constructor<T> ctor = clazz.getConstructor(String.class);
314 return Optional.of(ctor.newInstance(paramValue));
315 } catch (ReflectiveOperationException e) {
316 // does not type check and is not convertible - treat as if the parameter was never set
317 return Optional.empty();
318 }
319 }
321 /**
322 * Obtains a request parameter of the specified type.
323 * The specified type must have a single-argument constructor accepting a string to perform conversion.
324 * The constructor of the specified type may throw an exception on conversion failures.
325 *
326 * @param req the servlet request object
327 * @param clazz the class object of the expected type
328 * @param name the name of the parameter
329 * @param <T> the expected type
330 * @return the parameter value or an empty optional, if no parameter with the specified name was found
331 */
332 protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) {
333 if (clazz.isArray()) {
334 final String[] paramValues = req.getParameterValues(name);
335 int len = paramValues == null ? 0 : paramValues.length;
336 final var array = (T) Array.newInstance(clazz.getComponentType(), len);
337 for (int i = 0; i < len; i++) {
338 try {
339 final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class);
340 Array.set(array, i, ctor.newInstance(paramValues[i]));
341 } catch (ReflectiveOperationException e) {
342 throw new RuntimeException(e);
343 }
344 }
345 return Optional.of(array);
346 } else {
347 return parseParameter(req.getParameter(name), clazz);
348 }
349 }
351 /**
352 * Tries to look up an entity with a key obtained from a request parameter.
353 *
354 * @param req the servlet request object
355 * @param clazz the class representing the type of the request parameter
356 * @param name the name of the request parameter
357 * @param find the find function (typically a DAO function)
358 * @param <T> the type of the request parameter
359 * @param <R> the type of the looked up entity
360 * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing
361 * @throws SQLException if the find function throws an exception
362 */
363 protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, SQLFindFunction<? super T, ? extends R> find) throws SQLException {
364 final var param = getParameter(req, clazz, name);
365 if (param.isPresent()) {
366 return Optional.ofNullable(find.apply(param.get()));
367 } else {
368 return Optional.empty();
369 }
370 }
372 private String sanitizeRequestPath(HttpServletRequest req) {
373 return Optional.ofNullable(req.getPathInfo()).orElse("/");
374 }
376 private Optional<Map.Entry<PathPattern, Method>> findMapping(HttpMethod method, HttpServletRequest req) {
377 return Optional.ofNullable(mappings.get(method)).flatMap(rm ->
378 rm.entrySet().stream().filter(
379 kv -> kv.getKey().matches(sanitizeRequestPath(req))
380 ).findAny()
381 );
382 }
384 protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
385 req.getRequestDispatcher(SITE_JSP).forward(req, resp);
386 }
388 protected Optional<String[]> availableLanguages() {
389 return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*"));
390 }
392 private static String baseHref(HttpServletRequest req) {
393 return String.format("%s://%s:%d%s/",
394 req.getScheme(),
395 req.getServerName(),
396 req.getServerPort(),
397 req.getContextPath());
398 }
400 private static String enforceExt(String filename, String ext) {
401 return filename.endsWith(ext) ? filename : filename + ext;
402 }
404 private static String jspPath(String filename) {
405 return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp");
406 }
408 private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
409 // the very first thing to do is to force UTF-8
410 req.setCharacterEncoding("UTF-8");
412 // choose the requested language as session language (if available) or fall back to english, otherwise
413 HttpSession session = req.getSession();
414 if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) {
415 Optional<List<String>> availableLanguages = availableLanguages().map(Arrays::asList);
416 Optional<Locale> reqLocale = Optional.of(req.getLocale());
417 Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH);
418 session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale);
419 LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage());
420 } else {
421 Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE);
422 resp.setLocale(sessionLocale);
423 LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale);
424 }
426 // set some internal request attributes
427 final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse("");
428 req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req));
429 req.setAttribute(Constants.REQ_ATTR_PATH, fullPath);
430 req.setAttribute(Constants.REQ_ATTR_RESOURCE_BUNDLE, getResourceBundleName());
432 // if this is an error path, bypass the normal flow
433 if (fullPath.startsWith("/error/")) {
434 final var mapping = findMapping(method, req);
435 if (mapping.isPresent()) {
436 invokeMapping(mapping.get(), req, resp, null);
437 }
438 return;
439 }
441 // obtain a connection and create the data access objects
442 final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME());
443 final var ds = db.getDataSource();
444 if (ds == null) {
445 resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details.");
446 return;
447 }
448 try (final var connection = ds.getConnection()) {
449 final var dao = createDataAccessObjects(connection);
450 try {
451 connection.setAutoCommit(false);
452 // call the handler, if available, or send an HTTP 404 error
453 final var mapping = findMapping(method, req);
454 if (mapping.isPresent()) {
455 invokeMapping(mapping.get(), req, resp, dao);
456 } else {
457 resp.sendError(HttpServletResponse.SC_NOT_FOUND);
458 }
459 connection.commit();
460 } catch (SQLException ex) {
461 LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage());
462 LOG.debug("Details: ", ex);
463 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode());
464 connection.rollback();
465 }
466 } catch (SQLException ex) {
467 LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage());
468 LOG.debug("Details: ", ex);
469 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode());
470 }
471 }
473 @Override
474 protected final void doGet(HttpServletRequest req, HttpServletResponse resp)
475 throws ServletException, IOException {
476 doProcess(HttpMethod.GET, req, resp);
477 }
479 @Override
480 protected final void doPost(HttpServletRequest req, HttpServletResponse resp)
481 throws ServletException, IOException {
482 doProcess(HttpMethod.POST, req, resp);
483 }
484 }