src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java

changeset 179
623c340058f3
parent 168
1c3694ae224c
equal deleted inserted replaced
178:88207b860cba 179:623c340058f3
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 AbstractLightPITServlet extends HttpServlet {
54
55 private static final Logger LOG = LoggerFactory.getLogger(AbstractLightPITServlet.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 }

mercurial