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}