|
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; |
|
30 |
|
31 import de.uapcore.lightpit.dao.DataAccessObject; |
|
32 import de.uapcore.lightpit.dao.PostgresDataAccessObject; |
|
33 import org.slf4j.Logger; |
|
34 import org.slf4j.LoggerFactory; |
|
35 |
|
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; |
|
48 |
|
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 AbstractServlet extends HttpServlet { |
|
54 |
|
55 private static final Logger LOG = LoggerFactory.getLogger(AbstractServlet.class); |
|
56 |
|
57 private static final String SITE_JSP = jspPath("site"); |
|
58 |
|
59 /** |
|
60 * Invocation mapping gathered from the {@link RequestMapping} annotations. |
|
61 * <p> |
|
62 * Paths in this map must always start with a leading slash, although |
|
63 * the specification in the annotation must not start with a leading slash. |
|
64 * <p> |
|
65 * The reason for this is the different handling of empty paths in |
|
66 * {@link HttpServletRequest#getPathInfo()}. |
|
67 */ |
|
68 private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>(); |
|
69 |
|
70 /** |
|
71 * Returns the name of the resource bundle associated with this servlet. |
|
72 * |
|
73 * @return the resource bundle base name |
|
74 */ |
|
75 protected abstract String getResourceBundleName(); |
|
76 |
|
77 |
|
78 /** |
|
79 * Creates a set of data access objects for the specified connection. |
|
80 * |
|
81 * @param connection the SQL connection |
|
82 * @return a set of data access objects |
|
83 */ |
|
84 private DataAccessObject createDataAccessObjects(Connection connection) { |
|
85 final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); |
|
86 if (df.getDialect() == DataSourceProvider.Dialect.Postgres) { |
|
87 return new PostgresDataAccessObject(connection); |
|
88 } |
|
89 throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug."); |
|
90 } |
|
91 |
|
92 private void invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException { |
|
93 final var pathPattern = mapping.getKey(); |
|
94 final var method = mapping.getValue(); |
|
95 try { |
|
96 LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName()); |
|
97 final var paramTypes = method.getParameterTypes(); |
|
98 final var paramValues = new Object[paramTypes.length]; |
|
99 for (int i = 0; i < paramTypes.length; i++) { |
|
100 if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) { |
|
101 paramValues[i] = req; |
|
102 } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) { |
|
103 paramValues[i] = resp; |
|
104 } |
|
105 if (paramTypes[i].isAssignableFrom(DataAccessObject.class)) { |
|
106 paramValues[i] = dao; |
|
107 } |
|
108 if (paramTypes[i].isAssignableFrom(PathParameters.class)) { |
|
109 paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req)); |
|
110 } |
|
111 } |
|
112 method.invoke(this, paramValues); |
|
113 } catch (InvocationTargetException ex) { |
|
114 LOG.error("invocation of method {}::{} failed: {}", |
|
115 method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage()); |
|
116 LOG.debug("Details: ", ex.getTargetException()); |
|
117 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage()); |
|
118 } catch (ReflectiveOperationException | ClassCastException ex) { |
|
119 LOG.error("invocation of method {}::{} failed: {}", |
|
120 method.getDeclaringClass().getName(), method.getName(), ex.getMessage()); |
|
121 LOG.debug("Details: ", ex); |
|
122 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); |
|
123 } |
|
124 } |
|
125 |
|
126 @Override |
|
127 public void init() throws ServletException { |
|
128 scanForRequestMappings(); |
|
129 |
|
130 LOG.trace("{} initialized", getServletName()); |
|
131 } |
|
132 |
|
133 private void scanForRequestMappings() { |
|
134 try { |
|
135 Method[] methods = getClass().getDeclaredMethods(); |
|
136 for (Method method : methods) { |
|
137 Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class)); |
|
138 if (mapping.isPresent()) { |
|
139 if (mapping.get().requestPath().isBlank()) { |
|
140 LOG.warn("{} is annotated with {} but request path is empty", |
|
141 method.getName(), RequestMapping.class.getSimpleName() |
|
142 ); |
|
143 continue; |
|
144 } |
|
145 |
|
146 if (!Modifier.isPublic(method.getModifiers())) { |
|
147 LOG.warn("{} is annotated with {} but is not public", |
|
148 method.getName(), RequestMapping.class.getSimpleName() |
|
149 ); |
|
150 continue; |
|
151 } |
|
152 if (Modifier.isAbstract(method.getModifiers())) { |
|
153 LOG.warn("{} is annotated with {} but is abstract", |
|
154 method.getName(), RequestMapping.class.getSimpleName() |
|
155 ); |
|
156 continue; |
|
157 } |
|
158 |
|
159 boolean paramsInjectible = true; |
|
160 for (var param : method.getParameterTypes()) { |
|
161 paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param) |
|
162 || HttpServletResponse.class.isAssignableFrom(param) |
|
163 || PathParameters.class.isAssignableFrom(param) |
|
164 || DataAccessObject.class.isAssignableFrom(param); |
|
165 } |
|
166 if (paramsInjectible) { |
|
167 try { |
|
168 PathPattern pathPattern = new PathPattern(mapping.get().requestPath()); |
|
169 |
|
170 final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>()); |
|
171 final var currentMapping = methodMappings.putIfAbsent(pathPattern, method); |
|
172 if (currentMapping != null) { |
|
173 LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}", |
|
174 mapping.get().method(), |
|
175 mapping.get().requestPath(), |
|
176 method.getName(), |
|
177 getClass().getSimpleName(), |
|
178 currentMapping.getName() |
|
179 ); |
|
180 } |
|
181 |
|
182 LOG.debug("{} {} maps to {}::{}", |
|
183 mapping.get().method(), |
|
184 mapping.get().requestPath(), |
|
185 getClass().getSimpleName(), |
|
186 method.getName() |
|
187 ); |
|
188 } catch (IllegalArgumentException ex) { |
|
189 LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid", |
|
190 method.getName(), mapping.get().requestPath() |
|
191 ); |
|
192 } |
|
193 } else { |
|
194 LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed", |
|
195 method.getName(), RequestMapping.class.getSimpleName() |
|
196 ); |
|
197 } |
|
198 } |
|
199 } |
|
200 } catch (SecurityException ex) { |
|
201 LOG.error("Scan for request mappings on declared methods failed.", ex); |
|
202 } |
|
203 } |
|
204 |
|
205 @Override |
|
206 public void destroy() { |
|
207 mappings.clear(); |
|
208 LOG.trace("{} destroyed", getServletName()); |
|
209 } |
|
210 |
|
211 /** |
|
212 * Sets the name of the content page. |
|
213 * <p> |
|
214 * It is sufficient to specify the name without any extension. The extension |
|
215 * is added automatically if not specified. |
|
216 * |
|
217 * @param req the servlet request object |
|
218 * @param pageName the name of the content page |
|
219 * @see Constants#REQ_ATTR_CONTENT_PAGE |
|
220 */ |
|
221 protected void setContentPage(HttpServletRequest req, String pageName) { |
|
222 req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName)); |
|
223 } |
|
224 |
|
225 /** |
|
226 * Sets the navigation menu. |
|
227 * |
|
228 * @param req the servlet request object |
|
229 * @param jspName the name of the menu's jsp file |
|
230 * @see Constants#REQ_ATTR_NAVIGATION |
|
231 */ |
|
232 protected void setNavigationMenu(HttpServletRequest req, String jspName) { |
|
233 req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName)); |
|
234 } |
|
235 |
|
236 /** |
|
237 * @param req the servlet request object |
|
238 * @param location the location where to redirect |
|
239 * @see Constants#REQ_ATTR_REDIRECT_LOCATION |
|
240 */ |
|
241 protected void setRedirectLocation(HttpServletRequest req, String location) { |
|
242 if (location.startsWith("./")) { |
|
243 location = location.replaceFirst("\\./", baseHref(req)); |
|
244 } |
|
245 req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location); |
|
246 } |
|
247 |
|
248 /** |
|
249 * Specifies the names of additional stylesheets used by this Servlet. |
|
250 * <p> |
|
251 * It is sufficient to specify the name without any extension. The extension |
|
252 * is added automatically if not specified. |
|
253 * |
|
254 * @param req the servlet request object |
|
255 * @param stylesheets the names of the stylesheets |
|
256 */ |
|
257 public void setStylesheet(HttpServletRequest req, String ... stylesheets) { |
|
258 req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays |
|
259 .stream(stylesheets) |
|
260 .map(s -> enforceExt(s, ".css")) |
|
261 .collect(Collectors.toUnmodifiableList())); |
|
262 } |
|
263 |
|
264 /** |
|
265 * Sets the view model object. |
|
266 * The type must match the expected type in the JSP file. |
|
267 * |
|
268 * @param req the servlet request object |
|
269 * @param viewModel the view model object |
|
270 */ |
|
271 public void setViewModel(HttpServletRequest req, Object viewModel) { |
|
272 req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel); |
|
273 } |
|
274 |
|
275 private <T> Optional<T> parseParameter(String paramValue, Class<T> clazz) { |
|
276 if (paramValue == null) return Optional.empty(); |
|
277 if (clazz.equals(Boolean.class)) { |
|
278 if (paramValue.equalsIgnoreCase("false") || paramValue.equals("0")) { |
|
279 return Optional.of((T) Boolean.FALSE); |
|
280 } else { |
|
281 return Optional.of((T) Boolean.TRUE); |
|
282 } |
|
283 } |
|
284 if (clazz.equals(String.class)) return Optional.of((T) paramValue); |
|
285 if (java.sql.Date.class.isAssignableFrom(clazz)) { |
|
286 try { |
|
287 return Optional.of((T) java.sql.Date.valueOf(paramValue)); |
|
288 } catch (IllegalArgumentException ex) { |
|
289 return Optional.empty(); |
|
290 } |
|
291 } |
|
292 try { |
|
293 final Constructor<T> ctor = clazz.getConstructor(String.class); |
|
294 return Optional.of(ctor.newInstance(paramValue)); |
|
295 } catch (ReflectiveOperationException e) { |
|
296 // does not type check and is not convertible - treat as if the parameter was never set |
|
297 return Optional.empty(); |
|
298 } |
|
299 } |
|
300 |
|
301 /** |
|
302 * Obtains a request parameter of the specified type. |
|
303 * The specified type must have a single-argument constructor accepting a string to perform conversion. |
|
304 * The constructor of the specified type may throw an exception on conversion failures. |
|
305 * |
|
306 * @param req the servlet request object |
|
307 * @param clazz the class object of the expected type |
|
308 * @param name the name of the parameter |
|
309 * @param <T> the expected type |
|
310 * @return the parameter value or an empty optional, if no parameter with the specified name was found |
|
311 */ |
|
312 protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) { |
|
313 if (clazz.isArray()) { |
|
314 final String[] paramValues = req.getParameterValues(name); |
|
315 int len = paramValues == null ? 0 : paramValues.length; |
|
316 final var array = (T) Array.newInstance(clazz.getComponentType(), len); |
|
317 for (int i = 0; i < len; i++) { |
|
318 try { |
|
319 final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class); |
|
320 Array.set(array, i, ctor.newInstance(paramValues[i])); |
|
321 } catch (ReflectiveOperationException e) { |
|
322 throw new RuntimeException(e); |
|
323 } |
|
324 } |
|
325 return Optional.of(array); |
|
326 } else { |
|
327 return parseParameter(req.getParameter(name), clazz); |
|
328 } |
|
329 } |
|
330 |
|
331 /** |
|
332 * Tries to look up an entity with a key obtained from a request parameter. |
|
333 * |
|
334 * @param req the servlet request object |
|
335 * @param clazz the class representing the type of the request parameter |
|
336 * @param name the name of the request parameter |
|
337 * @param find the find function (typically a DAO function) |
|
338 * @param <T> the type of the request parameter |
|
339 * @param <R> the type of the looked up entity |
|
340 * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing |
|
341 * @throws SQLException if the find function throws an exception |
|
342 */ |
|
343 protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, Function<? super T, ? extends R> find) { |
|
344 final var param = getParameter(req, clazz, name); |
|
345 if (param.isPresent()) { |
|
346 return Optional.ofNullable(find.apply(param.get())); |
|
347 } else { |
|
348 return Optional.empty(); |
|
349 } |
|
350 } |
|
351 |
|
352 protected void setAttributeFromParameter(HttpServletRequest req, String name) { |
|
353 final var parm = req.getParameter(name); |
|
354 if (parm != null) { |
|
355 req.setAttribute(name, parm); |
|
356 } |
|
357 } |
|
358 |
|
359 private String sanitizeRequestPath(HttpServletRequest req) { |
|
360 return Optional.ofNullable(req.getPathInfo()).orElse("/"); |
|
361 } |
|
362 |
|
363 private Optional<Map.Entry<PathPattern, Method>> findMapping(HttpMethod method, HttpServletRequest req) { |
|
364 return Optional.ofNullable(mappings.get(method)).flatMap(rm -> |
|
365 rm.entrySet().stream().filter( |
|
366 kv -> kv.getKey().matches(sanitizeRequestPath(req)) |
|
367 ).findAny() |
|
368 ); |
|
369 } |
|
370 |
|
371 protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { |
|
372 req.getRequestDispatcher(SITE_JSP).forward(req, resp); |
|
373 } |
|
374 |
|
375 protected Optional<String[]> availableLanguages() { |
|
376 return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*")); |
|
377 } |
|
378 |
|
379 private static String baseHref(HttpServletRequest req) { |
|
380 return String.format("%s://%s:%d%s/", |
|
381 req.getScheme(), |
|
382 req.getServerName(), |
|
383 req.getServerPort(), |
|
384 req.getContextPath()); |
|
385 } |
|
386 |
|
387 private static String enforceExt(String filename, String ext) { |
|
388 return filename.endsWith(ext) ? filename : filename + ext; |
|
389 } |
|
390 |
|
391 private static String jspPath(String filename) { |
|
392 return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp"); |
|
393 } |
|
394 |
|
395 private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { |
|
396 // the very first thing to do is to force UTF-8 |
|
397 req.setCharacterEncoding("UTF-8"); |
|
398 |
|
399 // choose the requested language as session language (if available) or fall back to english, otherwise |
|
400 HttpSession session = req.getSession(); |
|
401 if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) { |
|
402 Optional<List<String>> availableLanguages = availableLanguages().map(Arrays::asList); |
|
403 Optional<Locale> reqLocale = Optional.of(req.getLocale()); |
|
404 Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH); |
|
405 session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale); |
|
406 LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage()); |
|
407 } else { |
|
408 Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE); |
|
409 resp.setLocale(sessionLocale); |
|
410 LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale); |
|
411 } |
|
412 |
|
413 // set some internal request attributes |
|
414 final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse(""); |
|
415 req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req)); |
|
416 req.setAttribute(Constants.REQ_ATTR_PATH, fullPath); |
|
417 req.setAttribute(Constants.REQ_ATTR_RESOURCE_BUNDLE, getResourceBundleName()); |
|
418 |
|
419 // if this is an error path, bypass the normal flow |
|
420 if (fullPath.startsWith("/error/")) { |
|
421 final var mapping = findMapping(method, req); |
|
422 if (mapping.isPresent()) { |
|
423 invokeMapping(mapping.get(), req, resp, null); |
|
424 } |
|
425 return; |
|
426 } |
|
427 |
|
428 // obtain a connection and create the data access objects |
|
429 final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); |
|
430 final var ds = db.getDataSource(); |
|
431 if (ds == null) { |
|
432 resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details."); |
|
433 return; |
|
434 } |
|
435 try (final var connection = ds.getConnection()) { |
|
436 final var dao = createDataAccessObjects(connection); |
|
437 try { |
|
438 connection.setAutoCommit(false); |
|
439 // call the handler, if available, or send an HTTP 404 error |
|
440 final var mapping = findMapping(method, req); |
|
441 if (mapping.isPresent()) { |
|
442 invokeMapping(mapping.get(), req, resp, dao); |
|
443 } else { |
|
444 resp.sendError(HttpServletResponse.SC_NOT_FOUND); |
|
445 } |
|
446 connection.commit(); |
|
447 } catch (SQLException ex) { |
|
448 LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage()); |
|
449 LOG.debug("Details: ", ex); |
|
450 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode()); |
|
451 connection.rollback(); |
|
452 } |
|
453 } catch (SQLException ex) { |
|
454 LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage()); |
|
455 LOG.debug("Details: ", ex); |
|
456 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode()); |
|
457 } |
|
458 } |
|
459 |
|
460 @Override |
|
461 protected final void doGet(HttpServletRequest req, HttpServletResponse resp) |
|
462 throws ServletException, IOException { |
|
463 doProcess(HttpMethod.GET, req, resp); |
|
464 } |
|
465 |
|
466 @Override |
|
467 protected final void doPost(HttpServletRequest req, HttpServletResponse resp) |
|
468 throws ServletException, IOException { |
|
469 doProcess(HttpMethod.POST, req, resp); |
|
470 } |
|
471 } |