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

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

mercurial