001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2017-2022 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.http; 020 021import java.beans.ConstructorProperties; 022import java.io.Serializable; 023import java.lang.ref.WeakReference; 024import java.net.HttpCookie; 025import java.text.ParseException; 026import java.time.Duration; 027import java.util.ArrayList; 028import java.util.Arrays; 029import java.util.Collections; 030import java.util.List; 031import java.util.Locale; 032import java.util.Optional; 033import org.jdrupes.httpcodec.protocols.http.HttpField; 034import org.jdrupes.httpcodec.protocols.http.HttpRequest; 035import org.jdrupes.httpcodec.types.Converters; 036import org.jdrupes.httpcodec.types.Converters.SameSiteAttribute; 037import org.jdrupes.httpcodec.types.CookieList; 038import org.jdrupes.httpcodec.types.DefaultMultiValueConverter; 039import org.jdrupes.httpcodec.types.ParameterizedValue; 040import org.jgrapes.core.Associator; 041import org.jgrapes.core.Channel; 042import org.jgrapes.core.Component; 043import org.jgrapes.core.annotation.Handler; 044import org.jgrapes.http.annotation.RequestHandler; 045import org.jgrapes.http.events.ProtocolSwitchAccepted; 046import org.jgrapes.http.events.Request; 047import org.jgrapes.io.IOSubchannel; 048 049/** 050 * A component that attempts to derive information about language preferences 051 * from requests in the specified scope (usually "/") and make them 052 * available as a {@link Selection} object associated with the request 053 * event using `Selection.class` as association identifier. 054 * 055 * The component first checks if the event has an associated {@link Session} 056 * and whether that session has a value with key `Selection.class`. If 057 * such an entry exists, its value is assumed to be a {@link Selection} object 058 * which is (re-)used for all subsequent operations. Else, a new 059 * {@link Selection} object is created (and associated with the session, if 060 * a session exists). 061 * 062 * If the {@link Selection} represents explicitly set values, it is used as 063 * result (i.e. as object associated with the event by `Selection.class`). 064 * 065 * Else, the selector tries to derive the language preferences from the 066 * request. It first checks for a cookie with the specified name 067 * (see {@link #cookieName()}). If a cookie is found, its value is 068 * used to set the preferred locales. If no cookie is found, 069 * the values derived from the `Accept-Language header` are set 070 * as fall-backs. 071 * 072 * Whenever the language preferences 073 * change (see {@link Selection#prefer(Locale)}), a cookie with the 074 * specified name and a path value set to the scope is created and 075 * added to the request's response. This new cookie is then sent with 076 * the response to the browser. 077 * 078 * Note that this scheme does not work in a SPA where browser and 079 * server only communicate over a WebSocket. 080 */ 081public class LanguageSelector extends Component { 082 083 private String path; 084 private static final DefaultMultiValueConverter<List<Locale>, 085 Locale> LOCALE_LIST = new DefaultMultiValueConverter<>( 086 ArrayList<Locale>::new, Converters.LANGUAGE); 087 private String cookieName = LanguageSelector.class.getName(); 088 private long cookieMaxAge = Duration.ofDays(365).toSeconds(); 089 private SameSiteAttribute cookieSameSite = SameSiteAttribute.LAX; 090 091 /** 092 * Creates a new language selector component with its channel set to 093 * itself and the scope set to "/". 094 */ 095 public LanguageSelector() { 096 this("/"); 097 } 098 099 /** 100 * Creates a new language selector component with its channel set to 101 * itself and the scope set to the given value. 102 * 103 * @param scope the scope 104 */ 105 public LanguageSelector(String scope) { 106 this(Channel.SELF, scope); 107 } 108 109 /** 110 * Creates a new language selector component with its channel set 111 * to the given channel and the scope to "/". 112 * 113 * @param componentChannel the component channel 114 */ 115 public LanguageSelector(Channel componentChannel) { 116 this(componentChannel, "/"); 117 } 118 119 /** 120 * Creates a new language selector component with its channel set 121 * to the given channel and the scope to the given scope. 122 * 123 * @param componentChannel the component channel 124 * @param scope the scope 125 */ 126 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 127 public LanguageSelector(Channel componentChannel, String scope) { 128 super(componentChannel); 129 if ("/".equals(scope) || !scope.endsWith("/")) { 130 path = scope; 131 } else { 132 path = scope.substring(0, scope.length() - 1); 133 } 134 String pattern; 135 if ("/".equals(path)) { 136 pattern = "/**"; 137 } else { 138 pattern = path + "," + path + "/**"; 139 } 140 RequestHandler.Evaluator.add(this, "onRequest", pattern); 141 } 142 143 /** 144 * Sets the name of the cookie used to store the locale. 145 * 146 * @param cookieName the cookie name to use 147 * @return the locale selector for easy chaining 148 */ 149 public LanguageSelector setCookieName(String cookieName) { 150 this.cookieName = cookieName; 151 return this; 152 } 153 154 /** 155 * Returns the cookie name. Defaults to the class name. 156 * 157 * @return the cookie name 158 */ 159 public String cookieName() { 160 return cookieName; 161 } 162 163 /** 164 * Sets the max age of the cookie used to store the preferences. 165 * Defaults to one year. 166 * 167 * @param maxAge the max age 168 * @return the language selector 169 * @see HttpCookie#setMaxAge(long) 170 */ 171 public LanguageSelector setCookieMaxAge(Duration maxAge) { 172 cookieMaxAge = maxAge.toSeconds(); 173 return this; 174 } 175 176 /** 177 * Returns the max age of the cookie used to store the preferences. 178 * 179 * @return the duration 180 */ 181 public Duration cookieMaxAge() { 182 return Duration.ofSeconds(cookieMaxAge); 183 } 184 185 /** 186 * Sets the same site attribute for the locale cookie defaults to 187 * `Lax`. 188 * 189 * @param attribute the attribute 190 * @return the language selector 191 */ 192 public LanguageSelector setSameSiteAttribute(SameSiteAttribute attribute) { 193 cookieSameSite = attribute; 194 return this; 195 } 196 197 /** 198 * Returns the value of the same site attribute. 199 * 200 * @return the same site attribute 201 */ 202 public SameSiteAttribute sameSiteAttribute() { 203 return cookieSameSite; 204 } 205 206 /** 207 * Associates the event with a {@link Selection} object 208 * using `Selection.class` as association identifier. 209 * 210 * @param event the event 211 */ 212 @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.EmptyCatchBlock" }) 213 @RequestHandler(priority = 990, dynamic = true) 214 public void onRequest(Request.In event) { 215 @SuppressWarnings("PMD.AccessorClassGeneration") 216 final Selection selection 217 = (Selection) Session.from(event).computeIfAbsent(Selection.class, 218 newKey -> new Selection(cookieName, path, cookieMaxAge, 219 cookieSameSite)); 220 selection.setCurrentEvent(event); 221 event.setAssociated(Selection.class, selection); 222 if (selection.isExplicitlySet()) { 223 return; 224 } 225 226 // Try to get locale from cookies 227 final HttpRequest request = event.httpRequest(); 228 Optional<String> localeNames = request.findValue( 229 HttpField.COOKIE, Converters.COOKIE_LIST) 230 .flatMap(cookieList -> cookieList.valueForName(cookieName)); 231 if (localeNames.isPresent()) { 232 try { 233 List<Locale> cookieLocales = LOCALE_LIST 234 .fromFieldValue(localeNames.get()); 235 if (!cookieLocales.isEmpty()) { 236 Collections.reverse(cookieLocales); 237 cookieLocales.stream() 238 .forEach(locale -> selection.prefer(locale)); 239 return; 240 } 241 } catch (ParseException e) { 242 // Unusable 243 } 244 } 245 246 // Last resport: Accept-Language header field 247 Optional<List<ParameterizedValue<Locale>>> accepted = request.findValue( 248 HttpField.ACCEPT_LANGUAGE, Converters.LANGUAGE_LIST); 249 if (accepted.isPresent()) { 250 Locale[] locales = accepted.get().stream() 251 .sorted(ParameterizedValue.WEIGHT_COMPARATOR) 252 .map(value -> value.value()).toArray(Locale[]::new); 253 selection.updateFallbacks(locales); 254 } 255 } 256 257 /** 258 * Handles a procotol switch by associating the language selection 259 * with the channel. 260 * 261 * @param event the event 262 * @param channel the channel 263 */ 264 @Handler(priority = 1000) 265 public void onProtocolSwitchAccepted( 266 ProtocolSwitchAccepted event, IOSubchannel channel) { 267 event.requestEvent().associated(Selection.class) 268 .ifPresent( 269 selection -> channel.setAssociated(Selection.class, selection)); 270 } 271 272 /** 273 * Convenience method to retrieve a locale from an associator. 274 * 275 * @param assoc the associator 276 * @return the locale 277 */ 278 public static Locale associatedLocale(Associator assoc) { 279 return assoc.associated(Selection.class) 280 .map(sel -> sel.get()[0]).orElse(Locale.getDefault()); 281 } 282 283 /** 284 * Represents a locale selection. 285 */ 286 @SuppressWarnings({ "serial", "PMD.DataflowAnomalyAnalysis" }) 287 public static class Selection implements Serializable { 288 private transient WeakReference<Request.In> currentEvent; 289 private final String cookieName; 290 private final String cookiePath; 291 private final long cookieMaxAge; 292 private final SameSiteAttribute cookieSameSite; 293 private boolean explicitlySet; 294 private Locale[] locales; 295 296 @ConstructorProperties({ "cookieName", "cookiePath", "cookieMaxAge", 297 "cookieSameSite" }) 298 private Selection(String cookieName, String cookiePath, 299 long cookieMaxAge, SameSiteAttribute cookieSameSite) { 300 this.cookieName = cookieName; 301 this.cookiePath = cookiePath; 302 this.cookieMaxAge = cookieMaxAge; 303 this.cookieSameSite = cookieSameSite; 304 this.currentEvent = new WeakReference<>(null); 305 explicitlySet = false; 306 locales = new Locale[] { Locale.getDefault() }; 307 } 308 309 /** 310 * Gets the cookie name. 311 * 312 * @return the cookieName 313 */ 314 public String getCookieName() { 315 return cookieName; 316 } 317 318 /** 319 * Gets the cookie path. 320 * 321 * @return the cookiePath 322 */ 323 public String getCookiePath() { 324 return cookiePath; 325 } 326 327 /** 328 * Gets the cookie max age. 329 * 330 * @return the cookie max age 331 */ 332 public long getCookieMaxAge() { 333 return cookieMaxAge; 334 } 335 336 /** 337 * Gets the cookie same site. 338 * 339 * @return the cookie same site 340 */ 341 public SameSiteAttribute getCookieSameSite() { 342 return cookieSameSite; 343 } 344 345 /** 346 * Checks if is explicitly set. 347 * 348 * @return the explicitlySet 349 */ 350 public boolean isExplicitlySet() { 351 return explicitlySet; 352 } 353 354 /** 355 * 356 * @param locales 357 */ 358 @SuppressWarnings("PMD.UseVarargs") 359 private void updateFallbacks(Locale[] locales) { 360 if (explicitlySet) { 361 return; 362 } 363 this.locales = Arrays.copyOf(locales, locales.length); 364 } 365 366 /** 367 * @param currentEvent the currentEvent to set 368 */ 369 private Selection setCurrentEvent(Request.In currentEvent) { 370 this.currentEvent = new WeakReference<>(currentEvent); 371 return this; 372 } 373 374 /** 375 * Return the current locale. 376 * 377 * @return the value; 378 */ 379 public Locale[] get() { 380 return Arrays.copyOf(locales, locales.length); 381 } 382 383 /** 384 * Updates the current locale. 385 * 386 * @param locale the locale 387 * @return the selection for easy chaining 388 */ 389 public Selection prefer(Locale locale) { 390 explicitlySet = true; 391 List<Locale> list = new ArrayList<>(Arrays.asList(locales)); 392 list.remove(locale); 393 list.add(0, locale); 394 this.locales = list.toArray(new Locale[0]); 395 Request.In req = currentEvent.get(); 396 if (req != null) { 397 req.httpRequest().response().ifPresent(resp -> { 398 resp.computeIfAbsent(HttpField.SET_COOKIE, 399 () -> new CookieList(cookieSameSite)) 400 .value().add(getCookie()); 401 }); 402 } 403 return this; 404 } 405 406 /** 407 * Returns a cookie that reflects the current selection. 408 * 409 * @return the cookie 410 */ 411 public HttpCookie getCookie() { 412 HttpCookie localesCookie = new HttpCookie(cookieName, 413 LOCALE_LIST.asFieldValue(Arrays.asList(locales))); 414 localesCookie.setPath(cookiePath); 415 localesCookie.setMaxAge(cookieMaxAge); 416 return localesCookie; 417 } 418 419 /* 420 * (non-Javadoc) 421 * 422 * @see java.lang.Object#toString() 423 */ 424 @Override 425 public String toString() { 426 StringBuilder builder = new StringBuilder(50); 427 builder.append("Selection ["); 428 if (locales != null) { 429 builder.append("locales="); 430 builder.append(Arrays.toString(locales)); 431 builder.append(", "); 432 } 433 builder.append("explicitlySet=") 434 .append(explicitlySet) 435 .append(']'); 436 return builder.toString(); 437 } 438 439 } 440}