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