001/*
002 *  Copyright 2020 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.core.util;
017
018import java.io.ByteArrayOutputStream;
019import java.nio.charset.StandardCharsets;
020import java.util.Map;
021import java.util.function.Predicate;
022
023/**
024 * Utility class for encoding and decoding URL, following the RFC 3986 
025 * @see <a href="https://tools.ietf.org/html/rfc3986">https://tools.ietf.org/html/rfc3986</a>
026 */
027public final class URIUtils
028{
029    private static final String __NAME_VALUE_SEPARATOR = "=";
030    private static final String __PARAMETER_SEPARATOR = "&";
031    
032    private static Predicate<Byte> _isAlpha = c -> c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
033    private static Predicate<Byte> _isDigit = c -> c >= '0' && c <= '9';
034    private static Predicate<Byte> _isSubDelimiter = c -> '!' == c || '$' == c || '&' == c || '\'' == c || '(' == c || ')' == c 
035                                                       || '*' == c || '+' == c || ',' == c || ';' == c || '=' == c;
036    private static Predicate<Byte> _isUnreserved = _isAlpha.or(_isDigit).or(c -> '-' == c || '.' == c || '_' == c || '~' == c);
037    private static Predicate<Byte> _isPchar = _isUnreserved.or(_isSubDelimiter).or(c -> ':' == c || '@' == c);
038    
039    private URIUtils()
040    {
041        // empty
042    }
043    
044    /**
045     * Encode a parameter's value using UTF-8 encoding
046     * @param value the value or the name of the request's parameter to encode
047     * @return the encoded value.
048     */
049    public static String encodeParameter(String value)
050    {
051        return _encodeUriComponent(value, _isPchar.or(c -> '/' == c || '?' == c)
052                                                  .and(Predicate.not(c -> '=' == c || '+' == c || '&' == c)));
053    }
054    
055    /**
056     * Encode an URL path 
057     * @param path the path to encode (before question-mark character)
058     * @return the encoded path.
059     */
060    public static String encodePath(String path)
061    {
062        return _encodeUriComponent(path, _isPchar.or(c -> '/' == c)
063                                                 .and(Predicate.not(c -> ';' == c)));
064    }
065    
066    /**
067     * Encode an URL path segment
068     * @param pathSegment the path segment to encode
069     * @return the encoded path segment.
070     */
071    public static String encodePathSegment(String pathSegment)
072    {
073        return _encodeUriComponent(pathSegment, _isPchar.and(Predicate.not(c -> ';' == c)));
074    }
075    
076    /**
077     * Encode a request header value.
078     * @param header the value to encode
079     * @return the encoded value.
080     */
081    public static String encodeHeader(String header)
082    {
083        return _encodeUriComponent(header, _isUnreserved);
084    }
085
086    // Implementation taken from Spring's UriUtils#encode
087    @SuppressWarnings("all")
088    private static String _encodeUriComponent(String uriComponent, Predicate<Byte> charactersToKeep)
089    {
090        byte[] bytes = uriComponent.getBytes(StandardCharsets.UTF_8);
091        ByteArrayOutputStream bos = new ByteArrayOutputStream(bytes.length);
092        
093        for (byte b : bytes) 
094        {
095            if (b < 0) 
096            {
097                b += 256;
098            }
099            if (charactersToKeep.test(b))
100            {
101                bos.write(b);
102            }
103            else 
104            {
105                bos.write('%');
106                char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16));
107                char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16));
108                bos.write(hex1);
109                bos.write(hex2);
110            }
111        }
112
113        return new String(bos.toByteArray(), StandardCharsets.US_ASCII);
114    }
115    
116    /**
117     * Encode an URL path
118     * @param path the path to encode (before question-mark character)
119     * @param parameters the parameters. Can be null.
120     * @return the encoded path
121     */
122    public static String encodeURI(String path, Map<String, String> parameters)
123    {
124        return _buildURI(path, parameters, true);
125    }
126    
127    /**
128     * Build an URL.
129     * @param path the URL path.
130     * @param parameters the URL parameters. Can be null.
131     * @return the computed URL.
132     */
133    public static String buildURI(String path, Map<String, String> parameters)
134    {
135        return _buildURI(path, parameters, false);
136    }
137    
138    private static String _buildURI(String path, Map<String, String> parameters, boolean encode)
139    {
140        StringBuilder sb = new StringBuilder();
141        sb.append(encode ? encodePath(path) : path);
142        
143        if (parameters != null && !parameters.isEmpty())
144        {
145            StringBuilder query = new StringBuilder();
146            
147            for (String paramName : parameters.keySet())
148            {
149                String encodedName = encode ? encodeParameter(paramName) : paramName;
150                String encodedValue = encode ? encodeParameter(parameters.get(paramName)) : parameters.get(paramName);
151                
152                if (query.length() > 0) 
153                {
154                    query.append(__PARAMETER_SEPARATOR);
155                }
156                
157                query.append(encodedName)
158                    .append(__NAME_VALUE_SEPARATOR)
159                    .append(encodedValue);
160            }
161            
162            sb.append("?").append(query.toString());
163        }
164        
165        return sb.toString();
166    }
167    
168    /**
169     * Decodes an URI-encoded String.
170     * @param source the String to decode.
171     * @return the decoded String.
172     */
173    // Implementation taken from Spring's UriUtils#decode
174    @SuppressWarnings("all")
175    public static String decode(String source)
176    {
177        int length = source.length();
178        ByteArrayOutputStream bos = new ByteArrayOutputStream(length);
179        boolean changed = false;
180        for (int i = 0; i < length; i++) 
181        {
182            char ch = source.charAt(i);
183            
184            if (ch == '%') 
185            {
186                if ((i + 2) < length) 
187                {
188                    char hex1 = source.charAt(i + 1);
189                    char hex2 = source.charAt(i + 2);
190                    int u = Character.digit(hex1, 16);
191                    int l = Character.digit(hex2, 16);
192                    
193                    if (u == -1 || l == -1) 
194                    {
195                        throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\"");
196                    }
197                    
198                    bos.write((byte) ((u << 4) + l));
199                    
200                    i += 2;
201                    changed = true;
202                }
203                else 
204                {
205                    throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\"");
206                }
207            }
208            else if (ch > 0 && ch < 128)
209            {
210                // to avoid the overhead of decoding/recoding an ASCII char
211                bos.write(ch);
212            }
213            else if (Character.isHighSurrogate(ch))
214            {
215                if ((i + 1) < length && Character.isLowSurrogate(source.charAt(i + 1))) 
216                {
217                    for (byte b : String.valueOf(new char[]{ch, source.charAt(i + 1)}).getBytes(StandardCharsets.UTF_8))
218                    {
219                        bos.write(b);
220                    }
221                    
222                    i += 1;
223                }
224                else 
225                {
226                    throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\"");
227                }
228            }
229            else 
230            {
231                for (byte b : String.valueOf(ch).getBytes(StandardCharsets.UTF_8))
232                {
233                    bos.write(b);
234                }
235            }
236        }
237        
238        return changed ? new String(bos.toByteArray(), StandardCharsets.UTF_8) : source;
239    }
240}