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().forEach(selection::prefer); 238 return; 239 } 240 } catch (ParseException e) { 241 // Unusable 242 } 243 } 244 245 // Last resport: Accept-Language header field 246 Optional<List<ParameterizedValue<Locale>>> accepted = request.findValue( 247 HttpField.ACCEPT_LANGUAGE, Converters.LANGUAGE_LIST); 248 if (accepted.isPresent()) { 249 Locale[] locales = accepted.get().stream() 250 .sorted(ParameterizedValue.WEIGHT_COMPARATOR) 251 .map(ParameterizedValue::value).toArray(Locale[]::new); 252 selection.updateFallbacks(locales); 253 } 254 } 255 256 /** 257 * Handles a procotol switch by associating the language selection 258 * with the channel. 259 * 260 * @param event the event 261 * @param channel the channel 262 */ 263 @Handler(priority = 1000) 264 public void onProtocolSwitchAccepted( 265 ProtocolSwitchAccepted event, IOSubchannel channel) { 266 event.requestEvent().associated(Selection.class) 267 .ifPresent( 268 selection -> channel.setAssociated(Selection.class, selection)); 269 } 270 271 /** 272 * Convenience method to retrieve a locale from an associator. 273 * 274 * @param assoc the associator 275 * @return the locale 276 */ 277 public static Locale associatedLocale(Associator assoc) { 278 return assoc.associated(Selection.class) 279 .map(sel -> sel.get()[0]).orElse(Locale.getDefault()); 280 } 281 282 /** 283 * Represents a locale selection. 284 */ 285 @SuppressWarnings({ "serial", "PMD.DataflowAnomalyAnalysis" }) 286 public static final class Selection implements Serializable { 287 private transient WeakReference<Request.In> currentEvent; 288 private final String cookieName; 289 private final String cookiePath; 290 private final long cookieMaxAge; 291 private final SameSiteAttribute cookieSameSite; 292 private boolean explicitlySet; 293 private Locale[] locales; 294 295 @ConstructorProperties({ "cookieName", "cookiePath", "cookieMaxAge", 296 "cookieSameSite" }) 297 private Selection(String cookieName, String cookiePath, 298 long cookieMaxAge, SameSiteAttribute cookieSameSite) { 299 this.cookieName = cookieName; 300 this.cookiePath = cookiePath; 301 this.cookieMaxAge = cookieMaxAge; 302 this.cookieSameSite = cookieSameSite; 303 this.currentEvent = new WeakReference<>(null); 304 explicitlySet = false; 305 locales = new Locale[] { Locale.getDefault() }; 306 } 307 308 /** 309 * Gets the cookie name. 310 * 311 * @return the cookieName 312 */ 313 public String getCookieName() { 314 return cookieName; 315 } 316 317 /** 318 * Gets the cookie path. 319 * 320 * @return the cookiePath 321 */ 322 public String getCookiePath() { 323 return cookiePath; 324 } 325 326 /** 327 * Gets the cookie max age. 328 * 329 * @return the cookie max age 330 */ 331 public long getCookieMaxAge() { 332 return cookieMaxAge; 333 } 334 335 /** 336 * Gets the cookie same site. 337 * 338 * @return the cookie same site 339 */ 340 public SameSiteAttribute getCookieSameSite() { 341 return cookieSameSite; 342 } 343 344 /** 345 * Checks if is explicitly set. 346 * 347 * @return the explicitlySet 348 */ 349 public boolean isExplicitlySet() { 350 return explicitlySet; 351 } 352 353 /** 354 * 355 * @param locales 356 */ 357 @SuppressWarnings("PMD.UseVarargs") 358 private void updateFallbacks(Locale[] locales) { 359 if (explicitlySet) { 360 return; 361 } 362 this.locales = Arrays.copyOf(locales, locales.length); 363 } 364 365 /** 366 * @param currentEvent the currentEvent to set 367 */ 368 private Selection setCurrentEvent(Request.In currentEvent) { 369 this.currentEvent = new WeakReference<>(currentEvent); 370 return this; 371 } 372 373 /** 374 * Return the current locale. 375 * 376 * @return the value; 377 */ 378 public Locale[] get() { 379 return Arrays.copyOf(locales, locales.length); 380 } 381 382 /** 383 * Updates the current locale. 384 * 385 * @param locale the locale 386 * @return the selection for easy chaining 387 */ 388 public Selection prefer(Locale locale) { 389 explicitlySet = true; 390 List<Locale> list = new ArrayList<>(Arrays.asList(locales)); 391 list.remove(locale); 392 list.add(0, locale); 393 this.locales = list.toArray(new Locale[0]); 394 Request.In req = currentEvent.get(); 395 if (req != null) { 396 req.httpRequest().response().ifPresent(resp -> { 397 resp.computeIfAbsent(HttpField.SET_COOKIE, 398 () -> new CookieList(cookieSameSite)) 399 .value().add(getCookie()); 400 }); 401 } 402 return this; 403 } 404 405 /** 406 * Returns a cookie that reflects the current selection. 407 * 408 * @return the cookie 409 */ 410 public HttpCookie getCookie() { 411 HttpCookie localesCookie = new HttpCookie(cookieName, 412 LOCALE_LIST.asFieldValue(Arrays.asList(locales))); 413 localesCookie.setPath(cookiePath); 414 localesCookie.setMaxAge(cookieMaxAge); 415 return localesCookie; 416 } 417 418 /* 419 * (non-Javadoc) 420 * 421 * @see java.lang.Object#toString() 422 */ 423 @Override 424 public String toString() { 425 StringBuilder builder = new StringBuilder(50); 426 builder.append("Selection ["); 427 if (locales != null) { 428 builder.append("locales=").append(Arrays.toString(locales)) 429 .append(", "); 430 } 431 builder.append("explicitlySet=").append(explicitlySet).append(']'); 432 return builder.toString(); 433 } 434 435 } 436}