001package org.apache.turbine.services.urlmapper;
002
003/*
004 * Licensed to the Apache Software Foundation (ASF) under one
005 * or more contributor license agreements.  See the NOTICE file
006 * distributed with this work for additional information
007 * regarding copyright ownership.  The ASF licenses this file
008 * to you under the Apache License, Version 2.0 (the
009 * "License"); you may not use this file except in compliance
010 * with the License.  You may obtain a copy of the License at
011 *
012 *   http://www.apache.org/licenses/LICENSE-2.0
013 *
014 * Unless required by applicable law or agreed to in writing,
015 * software distributed under the License is distributed on an
016 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
017 * KIND, either express or implied.  See the License for the
018 * specific language governing permissions and limitations
019 * under the License.
020 */
021
022import java.io.IOException;
023import java.io.InputStream;
024import java.util.Arrays;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.Objects;
031import java.util.Set;
032import java.util.regex.Matcher;
033import java.util.regex.Pattern;
034import java.util.stream.Collectors;
035import java.util.stream.Stream;
036
037import javax.xml.bind.JAXBContext;
038import javax.xml.bind.JAXBException;
039import javax.xml.bind.Unmarshaller;
040
041import org.apache.commons.configuration2.Configuration;
042import org.apache.fulcrum.parser.ParameterParser;
043import org.apache.logging.log4j.LogManager;
044import org.apache.logging.log4j.Logger;
045import org.apache.turbine.services.InitializationException;
046import org.apache.turbine.services.TurbineBaseService;
047import org.apache.turbine.services.TurbineServices;
048import org.apache.turbine.services.servlet.ServletService;
049import org.apache.turbine.services.urlmapper.model.URLMapEntry;
050import org.apache.turbine.services.urlmapper.model.URLMappingContainer;
051import org.apache.turbine.util.uri.TurbineURI;
052import org.apache.turbine.util.uri.URIParam;
053
054import com.fasterxml.jackson.databind.ObjectMapper;
055import com.fasterxml.jackson.databind.json.JsonMapper;
056import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
057
058/**
059 * The URL mapper service provides methods to map a set of parameters to a
060 * simplified URL and vice-versa. This service was inspired by the
061 * Liferay Friendly URL Mapper.
062 * <p>
063 * A mapper valve and a link pull tool are provided for easy application.
064 *
065 * @author <a href="mailto:tv@apache.org">Thomas Vandahl</a>
066 * @see URLMapperService
067 * @see MappedTemplateLink
068 * @see URLMapperValve
069 *
070 * @version $Id$
071 */
072public class TurbineURLMapperService
073        extends TurbineBaseService
074        implements URLMapperService
075{
076    /**
077     * Logging.
078     */
079    private static final Logger log = LogManager.getLogger(TurbineURLMapperService.class);
080
081    /**
082     * The default configuration file.
083     */
084    private static final String DEFAULT_CONFIGURATION_FILE = "/WEB-INF/conf/turbine-url-mapping.xml";
085
086    /**
087     * The configuration key for the configuration file.
088     */
089    private static final String CONFIGURATION_FILE_KEY = "configFile";
090
091    /**
092     * The container with the URL mappings.
093     */
094    private URLMappingContainer container;
095
096    /**
097     * Regex pattern for group names, equivalent to the characters defined in java {@link Pattern} (private) groupname method.
098     */
099    private static final Pattern NAMED_GROUPS_PATTERN = Pattern.compile("\\(\\?<([a-zA-Z][a-zA-Z0-9]*)>.+?\\)");
100
101    /**
102     * Regex pattern for multiple slashes
103     */
104    private static final Pattern MULTI_SLASH_PATTERN = Pattern.compile("[/]+");
105
106    /**
107     * Symbolic group name for context path
108     */
109    private static final String CONTEXT_PATH_PARAMETER = "contextPath";
110
111    /**
112     * Symbolic group name for web application root
113     */
114    private static final String WEBAPP_ROOT_PARAMETER = "webAppRoot";
115
116    /**
117     * Symbolic group names that will not be added to parameters
118     */
119    private static final Set<String> DEFAULT_PARAMETERS = new HashSet<>(Arrays.asList(
120            CONTEXT_PATH_PARAMETER,
121            WEBAPP_ROOT_PARAMETER
122    ));
123
124    /**
125     * Map a set of parameters (contained in TurbineURI PathInfo and QueryData)
126     * to a TurbineURI
127     *
128     * @param uri the URI to be modified (with setScriptName())
129     */
130    @Override
131    public void mapToURL(TurbineURI uri)
132    {
133        if (!uri.hasPathInfo() && !uri.hasQueryData())
134        {
135            return; // no mapping or mapping already done
136        }
137
138        List<URIParam> pathInfo = uri.getPathInfo();
139        List<URIParam> queryData = uri.getQueryData();
140
141        // Create map from list, taking only the first appearance of a key
142        // PathInfo takes precedence
143        Map<String, Object> uriParameterMap =
144                Stream.concat(pathInfo.stream(), queryData.stream())
145                    .collect(Collectors.toMap(
146                        URIParam::getKey,
147                        URIParam::getValue,
148                        (e1, e2) -> e1,
149                        LinkedHashMap::new));
150
151        for (URLMapEntry urlMap : container.getMapEntries())
152        {
153            Set<String> keys = new HashSet<>(uriParameterMap.keySet());
154            keys.removeAll(urlMap.getIgnoreParameters().keySet());
155
156            Set<String> entryKeys = new HashSet<>(urlMap.getGroupNamesMap().keySet());
157
158            Set<String> implicitKeysFound = urlMap.getImplicitParameters().entrySet().stream()
159                    .filter(entry -> Objects.equals(uriParameterMap.get(entry.getKey()), entry.getValue()))
160                    .map(Map.Entry::getKey)
161                    .collect(Collectors.toSet());
162
163            entryKeys.addAll(implicitKeysFound);
164
165            if (entryKeys.containsAll(keys))
166            {
167                Matcher matcher = NAMED_GROUPS_PATTERN.matcher(urlMap.getUrlPattern().pattern());
168                StringBuffer sb = new StringBuffer();
169
170                while (matcher.find())
171                {
172                    String key = matcher.group(1);
173
174                    if (CONTEXT_PATH_PARAMETER.equals(key))
175                    {
176                        // remove
177                        matcher.appendReplacement(sb, "");
178                    } else if (WEBAPP_ROOT_PARAMETER.equals(key))
179                    {
180                        matcher.appendReplacement(sb, uri.getScriptName());
181                    } else
182                    {
183                        boolean ignore = urlMap.getIgnoreParameters().keySet().stream()
184                                .anyMatch( x-> x.equals( key ) );
185                        matcher.appendReplacement(sb,
186                                 Matcher.quoteReplacement(
187                                        (!ignore)? Objects.toString(uriParameterMap.get(key)):""));
188                        // Remove handled parameters (all of them!)
189                        pathInfo.removeIf(uriParam -> key.equals(uriParam.getKey()));
190                        queryData.removeIf(uriParam -> key.equals(uriParam.getKey()));
191                    }
192                }
193
194                matcher.appendTail(sb);
195                
196                implicitKeysFound.forEach(key -> {
197                    pathInfo.removeIf(uriParam -> key.equals(uriParam.getKey()));
198                    queryData.removeIf(uriParam -> key.equals(uriParam.getKey()));
199                });
200
201                // Clean up
202                uri.setScriptName(MULTI_SLASH_PATTERN.matcher(sb).replaceAll("/").replaceFirst( "/$", "" ));
203                
204                break;
205            }
206        }
207        
208        log.debug("mapped to uri: {} ", uri);
209    }
210
211    /**
212     * Map a simplified URL to a set of parameters
213     *
214     * @param url the URL
215     * @param pp  a ParameterParser to use for parameter mangling
216     */
217    @Override
218    public void mapFromURL(String url, ParameterParser pp)
219    {
220        for (URLMapEntry urlMap : container.getMapEntries())
221        {
222            url = url.replaceFirst( "/$", "" );
223            Matcher matcher = urlMap.getUrlPattern().matcher(url);
224            if (matcher.matches())
225            {
226                // extract parameters from URL
227                urlMap.getGroupNamesMap().entrySet().stream()
228                        // ignore default parameters
229                        .filter(group -> !DEFAULT_PARAMETERS.contains(group.getKey()))
230                        .forEach(group ->
231                                pp.setString(group.getKey(), matcher.group(group.getValue().intValue())));
232
233                // add implicit parameters
234                urlMap.getImplicitParameters().entrySet().forEach(e ->
235                        pp.add(e.getKey(), e.getValue()));
236
237                // add override parameters
238                urlMap.getOverrideParameters().entrySet().forEach(e ->
239                        pp.setString(e.getKey(), e.getValue()));
240
241                // remove ignore parameters
242                urlMap.getIgnoreParameters().keySet().forEach(k ->
243                        pp.remove(k));
244                
245                log.debug("mapped {} params from url {} ", pp.getKeys().length, url);
246
247                break;
248            }
249        }
250    }
251
252    // ---- Service initialization ------------------------------------------
253
254    /**
255     * Initializes the service.
256     */
257    @Override
258    public void init() throws InitializationException
259    {
260        Configuration cfg = getConfiguration();
261
262        String configFile = cfg.getString(CONFIGURATION_FILE_KEY, DEFAULT_CONFIGURATION_FILE);
263
264        // context resource path has to begin with slash, cft.
265        // context.getResource
266        if (!configFile.startsWith("/"))
267        {
268            configFile = "/" + configFile;
269        }
270
271        ServletService servletService = (ServletService) TurbineServices.getInstance().getService(ServletService.SERVICE_NAME);
272
273        try (InputStream reader = servletService.getResourceAsStream(configFile))
274        {
275            if (configFile.endsWith(".xml"))
276            {
277                JAXBContext jaxb = JAXBContext.newInstance(URLMappingContainer.class);
278                Unmarshaller unmarshaller = jaxb.createUnmarshaller();
279                container = (URLMappingContainer) unmarshaller.unmarshal(reader);
280            } else if (configFile.endsWith(".yml"))
281            {
282                // org.apache.commons.configuration2.YAMLConfiguration does only expose property like configuration values,
283                // which is not what we need here -> java object deserialization.
284                ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
285                container = mapper.readValue(reader, URLMappingContainer.class);
286            } else if (configFile.endsWith(".json"))
287            {
288                ObjectMapper mapper = JsonMapper.builder().build();
289                container = mapper.readValue(reader, URLMappingContainer.class);
290            }
291        }
292        catch (IOException | JAXBException e)
293        {
294            throw new InitializationException("Could not load configuration file " + configFile, e);
295        }
296
297        // Get groupNamesMap for every Pattern and store it in the entry
298        for (URLMapEntry urlMap : container.getMapEntries())
299        {
300            int position = 1;
301            Map<String, Integer> groupNamesMap = new HashMap<>();
302            Matcher matcher = NAMED_GROUPS_PATTERN.matcher(urlMap.getUrlPattern().pattern());
303
304            while (matcher.find())
305            {
306                groupNamesMap.put(matcher.group(1), Integer.valueOf(position++));
307            }
308            urlMap.setGroupNamesMap(groupNamesMap);
309        }
310
311        log.info("Loaded {} url-mappings from {}", Integer.valueOf(container.getMapEntries().size()), configFile);
312
313        setInit(true);
314    }
315
316    /**
317     * Returns to uninitialized state.
318     */
319    @Override
320    public void shutdown()
321    {
322        container.getMapEntries().clear();
323        setInit(false);
324    }
325}