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