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}