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}