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 "/". The handler's priority 094 * is set to 990. 095 */ 096 public LanguageSelector() { 097 this("/"); 098 } 099 100 /** 101 * Creates a new language selector component with its channel set to 102 * itself and the scope set to the given value. The handler's priority 103 * is set to 990. 104 * 105 * @param scope the scope 106 */ 107 public LanguageSelector(String scope) { 108 this(Channel.SELF, scope); 109 } 110 111 /** 112 * Creates a new language selector component with its channel set 113 * to the given channel and the scope to "/". The handler's priority 114 * is set to 990. 115 * 116 * @param componentChannel the component channel 117 */ 118 public LanguageSelector(Channel componentChannel) { 119 this(componentChannel, "/"); 120 } 121 122 /** 123 * Creates a new language selector component with its channel set 124 * to the given channel and the scope to the given scope. The 125 * handler's priority is set to 990. 126 * 127 * @param componentChannel the component channel 128 * @param scope the scope 129 */ 130 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 131 public LanguageSelector(Channel componentChannel, String scope) { 132 this(componentChannel, scope, 990); 133 } 134 135 /** 136 * Creates a new language selector component with its channel set 137 * to the given channel and the scope to the given scope. The 138 * handler's priority is set to the given value. 139 * 140 * @param componentChannel the component channel 141 * @param scope the scope 142 * @param priority the priority 143 */ 144 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 145 public LanguageSelector(Channel componentChannel, String scope, 146 int priority) { 147 super(componentChannel); 148 if ("/".equals(scope) || !scope.endsWith("/")) { 149 path = scope; 150 } else { 151 path = scope.substring(0, scope.length() - 1); 152 } 153 String pattern; 154 if ("/".equals(path)) { 155 pattern = "/**"; 156 } else { 157 pattern = path + "," + path + "/**"; 158 } 159 RequestHandler.Evaluator.add(this, "onRequest", pattern, priority); 160 } 161 162 /** 163 * Sets the name of the cookie used to store the locale. 164 * 165 * @param cookieName the cookie name to use 166 * @return the locale selector for easy chaining 167 */ 168 public LanguageSelector setCookieName(String cookieName) { 169 this.cookieName = cookieName; 170 return this; 171 } 172 173 /** 174 * Returns the cookie name. Defaults to the class name. 175 * 176 * @return the cookie name 177 */ 178 public String cookieName() { 179 return cookieName; 180 } 181 182 /** 183 * Sets the max age of the cookie used to store the preferences. 184 * Defaults to one year. 185 * 186 * @param maxAge the max age 187 * @return the language selector 188 * @see HttpCookie#setMaxAge(long) 189 */ 190 public LanguageSelector setCookieMaxAge(Duration maxAge) { 191 cookieMaxAge = maxAge.toSeconds(); 192 return this; 193 } 194 195 /** 196 * Returns the max age of the cookie used to store the preferences. 197 * 198 * @return the duration 199 */ 200 public Duration cookieMaxAge() { 201 return Duration.ofSeconds(cookieMaxAge); 202 } 203 204 /** 205 * Sets the same site attribute for the locale cookie defaults to 206 * `Lax`. 207 * 208 * @param attribute the attribute 209 * @return the language selector 210 */ 211 public LanguageSelector setSameSiteAttribute(SameSiteAttribute attribute) { 212 cookieSameSite = attribute; 213 return this; 214 } 215 216 /** 217 * Returns the value of the same site attribute. 218 * 219 * @return the same site attribute 220 */ 221 public SameSiteAttribute sameSiteAttribute() { 222 return cookieSameSite; 223 } 224 225 /** 226 * Associates the event with a {@link Selection} object 227 * using `Selection.class` as association identifier. 228 * Does nothing if the request has already been fulfilled. 229 * 230 * @param event the event 231 */ 232 @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.EmptyCatchBlock" }) 233 @RequestHandler(dynamic = true) 234 public void onRequest(Request.In event) { 235 if (event.fulfilled()) { 236 return; 237 } 238 @SuppressWarnings("PMD.AccessorClassGeneration") 239 final Selection selection 240 = (Selection) Session.from(event).computeIfAbsent(Selection.class, 241 newKey -> new Selection(cookieName, path, cookieMaxAge, 242 cookieSameSite)); 243 selection.setCurrentEvent(event); 244 event.setAssociated(Selection.class, selection); 245 if (selection.isExplicitlySet()) { 246 return; 247 } 248 249 // Try to get locale from cookies 250 final HttpRequest request = event.httpRequest(); 251 Optional<String> localeNames = request.findValue( 252 HttpField.COOKIE, Converters.COOKIE_LIST) 253 .flatMap(cookieList -> cookieList.valueForName(cookieName)); 254 if (localeNames.isPresent()) { 255 try { 256 List<Locale> cookieLocales = LOCALE_LIST 257 .fromFieldValue(localeNames.get()); 258 if (!cookieLocales.isEmpty()) { 259 Collections.reverse(cookieLocales); 260 cookieLocales.stream().forEach(selection::prefer); 261 return; 262 } 263 } catch (ParseException e) { 264 // Unusable 265 } 266 } 267 268 // Last resport: Accept-Language header field 269 Optional<List<ParameterizedValue<Locale>>> accepted = request.findValue( 270 HttpField.ACCEPT_LANGUAGE, Converters.LANGUAGE_LIST); 271 if (accepted.isPresent()) { 272 Locale[] locales = accepted.get().stream() 273 .sorted(ParameterizedValue.WEIGHT_COMPARATOR) 274 .map(ParameterizedValue::value).toArray(Locale[]::new); 275 selection.updateFallbacks(locales); 276 } 277 } 278 279 /** 280 * Handles a procotol switch by associating the language selection 281 * with the channel. 282 * 283 * @param event the event 284 * @param channel the channel 285 */ 286 @Handler(priority = 1000) 287 public void onProtocolSwitchAccepted( 288 ProtocolSwitchAccepted event, IOSubchannel channel) { 289 event.requestEvent().associated(Selection.class) 290 .ifPresent( 291 selection -> channel.setAssociated(Selection.class, selection)); 292 } 293 294 /** 295 * Convenience method to retrieve a locale from an associator. 296 * 297 * @param assoc the associator 298 * @return the locale 299 */ 300 public static Locale associatedLocale(Associator assoc) { 301 return assoc.associated(Selection.class) 302 .map(sel -> sel.get()[0]).orElse(Locale.getDefault()); 303 } 304 305 /** 306 * Represents a locale selection. 307 */ 308 @SuppressWarnings({ "serial", "PMD.DataflowAnomalyAnalysis" }) 309 public static final class Selection implements Serializable { 310 private transient WeakReference<Request.In> currentEvent; 311 private final String cookieName; 312 private final String cookiePath; 313 private final long cookieMaxAge; 314 private final SameSiteAttribute cookieSameSite; 315 private boolean explicitlySet; 316 private Locale[] locales; 317 318 @ConstructorProperties({ "cookieName", "cookiePath", "cookieMaxAge", 319 "cookieSameSite" }) 320 private Selection(String cookieName, String cookiePath, 321 long cookieMaxAge, SameSiteAttribute cookieSameSite) { 322 this.cookieName = cookieName; 323 this.cookiePath = cookiePath; 324 this.cookieMaxAge = cookieMaxAge; 325 this.cookieSameSite = cookieSameSite; 326 this.currentEvent = new WeakReference<>(null); 327 explicitlySet = false; 328 locales = new Locale[] { Locale.getDefault() }; 329 } 330 331 /** 332 * Gets the cookie name. 333 * 334 * @return the cookieName 335 */ 336 public String getCookieName() { 337 return cookieName; 338 } 339 340 /** 341 * Gets the cookie path. 342 * 343 * @return the cookiePath 344 */ 345 public String getCookiePath() { 346 return cookiePath; 347 } 348 349 /** 350 * Gets the cookie max age. 351 * 352 * @return the cookie max age 353 */ 354 public long getCookieMaxAge() { 355 return cookieMaxAge; 356 } 357 358 /** 359 * Gets the cookie same site. 360 * 361 * @return the cookie same site 362 */ 363 public SameSiteAttribute getCookieSameSite() { 364 return cookieSameSite; 365 } 366 367 /** 368 * Checks if is explicitly set. 369 * 370 * @return the explicitlySet 371 */ 372 public boolean isExplicitlySet() { 373 return explicitlySet; 374 } 375 376 /** 377 * 378 * @param locales 379 */ 380 @SuppressWarnings("PMD.UseVarargs") 381 private void updateFallbacks(Locale[] locales) { 382 if (explicitlySet) { 383 return; 384 } 385 this.locales = Arrays.copyOf(locales, locales.length); 386 } 387 388 /** 389 * @param currentEvent the currentEvent to set 390 */ 391 private Selection setCurrentEvent(Request.In currentEvent) { 392 this.currentEvent = new WeakReference<>(currentEvent); 393 return this; 394 } 395 396 /** 397 * Return the current locale. 398 * 399 * @return the value; 400 */ 401 public Locale[] get() { 402 return Arrays.copyOf(locales, locales.length); 403 } 404 405 /** 406 * Updates the current locale. 407 * 408 * @param locale the locale 409 * @return the selection for easy chaining 410 */ 411 public Selection prefer(Locale locale) { 412 explicitlySet = true; 413 List<Locale> list = new ArrayList<>(Arrays.asList(locales)); 414 list.remove(locale); 415 list.add(0, locale); 416 this.locales = list.toArray(new Locale[0]); 417 Request.In req = currentEvent.get(); 418 if (req != null) { 419 req.httpRequest().response().ifPresent(resp -> { 420 resp.computeIfAbsent(HttpField.SET_COOKIE, 421 () -> new CookieList(cookieSameSite)) 422 .value().add(getCookie()); 423 }); 424 } 425 return this; 426 } 427 428 /** 429 * Returns a cookie that reflects the current selection. 430 * 431 * @return the cookie 432 */ 433 public HttpCookie getCookie() { 434 HttpCookie localesCookie = new HttpCookie(cookieName, 435 LOCALE_LIST.asFieldValue(Arrays.asList(locales))); 436 localesCookie.setPath(cookiePath); 437 localesCookie.setMaxAge(cookieMaxAge); 438 return localesCookie; 439 } 440 441 /* 442 * (non-Javadoc) 443 * 444 * @see java.lang.Object#toString() 445 */ 446 @Override 447 public String toString() { 448 StringBuilder builder = new StringBuilder(50); 449 builder.append("Selection ["); 450 if (locales != null) { 451 builder.append("locales=").append(Arrays.toString(locales)) 452 .append(", "); 453 } 454 builder.append("explicitlySet=").append(explicitlySet).append(']'); 455 return builder.toString(); 456 } 457 458 } 459}