001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2018 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.util.ArrayList;
027import java.util.Arrays;
028import java.util.Collections;
029import java.util.List;
030import java.util.Locale;
031import java.util.Optional;
032
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.CookieList;
037import org.jdrupes.httpcodec.types.DefaultMultiValueConverter;
038import org.jdrupes.httpcodec.types.ParameterizedValue;
039import org.jgrapes.core.Associator;
040import org.jgrapes.core.Channel;
041import org.jgrapes.core.Component;
042import org.jgrapes.core.annotation.Handler;
043import org.jgrapes.http.annotation.RequestHandler;
044import org.jgrapes.http.events.ProtocolSwitchAccepted;
045import org.jgrapes.http.events.Request;
046import org.jgrapes.io.IOSubchannel;
047
048/**
049 * A component that attempts to derive information about language preferences
050 * from requests in the specified scope (usually "/") and make them 
051 * available as a {@link Selection} object associated with the request 
052 * event using `Selection.class` as association identifier.
053 * 
054 * The component first checks if the event has an associated {@link Session}
055 * and whether that session has a value with key `Selection.class`. If
056 * such an entry exists, its value is assumed to be a {@link Selection} object
057 * which is (re-)used for all subsequent operations. Else, a new
058 * {@link Selection} object is created (and associated with the session, if
059 * a session exists).
060 * 
061 * If the {@link Selection} represents explicitly set values, it is used as
062 * result (i.e. as object associated with the event by `Selection.class`).
063 * 
064 * Else, the selector tries to derive the language preferences from the
065 * request. It first checks for a cookie with the specified name
066 * (see {@link #cookieName()}). If a cookie is found, its value is
067 * used to set the preferred locales. If no cookie is found, 
068 * the values derived from the `Accept-Language header` are set
069 * as fall-backs.
070 * 
071 * Whenever the language preferences 
072 * change (see {@link Selection#prefer(Locale)}), a cookie with the
073 * specified name and a path value set to the scope is created or
074 * updated accordingly. 
075 */
076public class LanguageSelector extends Component {
077
078    private String path;
079    private static final DefaultMultiValueConverter<List<Locale>,
080            Locale> LOCALE_LIST = new DefaultMultiValueConverter<>(
081                ArrayList<Locale>::new, Converters.LANGUAGE);
082    private String cookieName = LanguageSelector.class.getName();
083
084    /**
085     * Creates a new language selector component with its channel set to
086     * itself and the scope set to "/".
087     */
088    public LanguageSelector() {
089        this("/");
090    }
091
092    /**
093     * Creates a new language selector component with its channel set to
094     * itself and the scope set to the given value.
095     * 
096     * @param scope the scope
097     */
098    public LanguageSelector(String scope) {
099        this(Channel.SELF, scope);
100    }
101
102    /**
103     * Creates a new language selector component with its channel set 
104     * to the given channel and the scope to "/".
105     * 
106     * @param componentChannel the component channel
107     */
108    public LanguageSelector(Channel componentChannel) {
109        this(componentChannel, "/");
110    }
111
112    /**
113     * Creates a new language selector component with its channel set 
114     * to the given channel and the scope to the given scope.
115     * 
116     * @param componentChannel the component channel
117     * @param scope the scope
118     */
119    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
120    public LanguageSelector(Channel componentChannel, String scope) {
121        super(componentChannel);
122        if ("/".equals(scope) || !scope.endsWith("/")) {
123            path = scope;
124        } else {
125            path = scope.substring(0, scope.length() - 1);
126        }
127        String pattern;
128        if ("/".equals(path)) {
129            pattern = "/**";
130        } else {
131            pattern = path + "," + path + "/**";
132        }
133        RequestHandler.Evaluator.add(this, "onRequest", pattern);
134    }
135
136    /**
137     * Sets the name of the cookie used to store the locale.
138     * 
139     * @param cookieName the cookie name to use
140     * @return the locale selector for easy chaining
141     */
142    public LanguageSelector setCookieName(String cookieName) {
143        this.cookieName = cookieName;
144        return this;
145    }
146
147    /**
148     * Returns the cookie name. Defaults to the class name.
149     * 
150     * @return the cookie name
151     */
152    public String cookieName() {
153        return cookieName;
154    }
155
156    /**
157     * Associates the event with a {@link Selection} object
158     * using `Selection.class` as association identifier.
159     * 
160     * @param event the event
161     */
162    @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.EmptyCatchBlock" })
163    @RequestHandler(priority = 990, dynamic = true)
164    public void onRequest(Request.In event) {
165        @SuppressWarnings("PMD.AccessorClassGeneration")
166        final Selection selection = event.associated(Session.class)
167            .map(session -> (Selection) session.computeIfAbsent(
168                Selection.class, newKey -> new Selection(cookieName, path)))
169            .orElseGet(() -> new Selection(cookieName, path));
170        selection.setCurrentEvent(event);
171        event.setAssociated(Selection.class, selection);
172        if (selection.isExplicitlySet()) {
173            return;
174        }
175
176        // Try to get locale from cookies
177        final HttpRequest request = event.httpRequest();
178        Optional<String> localeNames = request.findValue(
179            HttpField.COOKIE, Converters.COOKIE_LIST)
180            .flatMap(cookieList -> cookieList.valueForName(cookieName));
181        if (localeNames.isPresent()) {
182            try {
183                List<Locale> cookieLocales = LOCALE_LIST
184                    .fromFieldValue(localeNames.get());
185                if (!cookieLocales.isEmpty()) {
186                    Collections.reverse(cookieLocales);
187                    cookieLocales.stream()
188                        .forEach(locale -> selection.prefer(locale));
189                    return;
190                }
191            } catch (ParseException e) {
192                // Unusable
193            }
194        }
195
196        // Last resport: Accept-Language header field
197        Optional<List<ParameterizedValue<Locale>>> accepted = request.findValue(
198            HttpField.ACCEPT_LANGUAGE, Converters.LANGUAGE_LIST);
199        if (accepted.isPresent()) {
200            Locale[] locales = accepted.get().stream()
201                .sorted(ParameterizedValue.WEIGHT_COMPARATOR)
202                .map(value -> value.value()).toArray(Locale[]::new);
203            selection.updateFallbacks(locales);
204        }
205    }
206
207    /**
208     * Handles a procotol switch by associating the language selection
209     * with the channel.
210     *
211     * @param event the event
212     * @param channel the channel
213     */
214    @Handler(priority = 1000)
215    public void onProtocolSwitchAccepted(
216            ProtocolSwitchAccepted event, IOSubchannel channel) {
217        event.requestEvent().associated(Selection.class)
218            .ifPresent(
219                selection -> channel.setAssociated(Selection.class, selection));
220    }
221
222    /**
223     * Convenience method to retrieve a locale from an associator.
224     * 
225     * @param assoc the associator
226     * @return the locale
227     */
228    public static Locale associatedLocale(Associator assoc) {
229        return assoc.associated(Selection.class)
230            .map(sel -> sel.get()[0]).orElse(Locale.getDefault());
231    }
232
233    /**
234     * Represents a locale selection.
235     */
236    @SuppressWarnings("serial")
237    public static class Selection implements Serializable {
238        private transient WeakReference<Request.In> currentEvent;
239        private final String cookieName;
240        private final String cookiePath;
241        private boolean explicitlySet;
242        private Locale[] locales;
243
244        @ConstructorProperties({ "cookieName", "cookiePath" })
245        private Selection(String cookieName, String cookiePath) {
246            this.cookieName = cookieName;
247            this.cookiePath = cookiePath;
248            this.currentEvent = new WeakReference<>(null);
249            explicitlySet = false;
250            locales = new Locale[] { Locale.getDefault() };
251        }
252
253        /**
254         * Gets the cookie name.
255         *
256         * @return the cookieName
257         */
258        public String getCookieName() {
259            return cookieName;
260        }
261
262        /**
263         * Gets the cookie path.
264         *
265         * @return the cookiePath
266         */
267        public String getCookiePath() {
268            return cookiePath;
269        }
270
271        /**
272         * Checks if is explicitly set.
273         *
274         * @return the explicitlySet
275         */
276        public boolean isExplicitlySet() {
277            return explicitlySet;
278        }
279
280        /**
281         * 
282         * @param locales
283         */
284        @SuppressWarnings("PMD.UseVarargs")
285        private void updateFallbacks(Locale[] locales) {
286            if (explicitlySet) {
287                return;
288            }
289            this.locales = Arrays.copyOf(locales, locales.length);
290        }
291
292        /**
293         * @param currentEvent the currentEvent to set
294         */
295        private Selection setCurrentEvent(Request.In currentEvent) {
296            this.currentEvent = new WeakReference<>(currentEvent);
297            return this;
298        }
299
300        /**
301         * Return the current locale.
302         * 
303         * @return the value;
304         */
305        public Locale[] get() {
306            return Arrays.copyOf(locales, locales.length);
307        }
308
309        /**
310         * Updates the current locale.
311         * 
312         * @param locale the locale
313         * @return the selection for easy chaining
314         */
315        public Selection prefer(Locale locale) {
316            explicitlySet = true;
317            List<Locale> list = new ArrayList<>(Arrays.asList(locales));
318            list.remove(locale);
319            list.add(0, locale);
320            this.locales = list.toArray(new Locale[0]);
321            HttpCookie localesCookie = new HttpCookie(cookieName,
322                LOCALE_LIST.asFieldValue(list));
323            localesCookie.setPath(cookiePath);
324            Request.In req = currentEvent.get();
325            if (req != null) {
326                req.httpRequest().response().ifPresent(resp -> {
327                    resp.computeIfAbsent(
328                        HttpField.SET_COOKIE, CookieList::new)
329                        .value().add(localesCookie);
330                });
331            }
332            return this;
333        }
334
335        /*
336         * (non-Javadoc)
337         * 
338         * @see java.lang.Object#toString()
339         */
340        @Override
341        public String toString() {
342            StringBuilder builder = new StringBuilder(50);
343            builder.append("Selection [");
344            if (locales != null) {
345                builder.append("locales=");
346                builder.append(Arrays.toString(locales));
347                builder.append(", ");
348            }
349            builder.append("explicitlySet=")
350                .append(explicitlySet)
351                .append(']');
352            return builder.toString();
353        }
354
355    }
356}