All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.fizzed.crux.uri.MutableUri Maven / Gradle / Ivy

/*
 * Copyright 2015 Fizzed, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.fizzed.crux.uri;

import java.net.URI;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

/**
 * Helps to build a URI.  Why another one?  Unlike Java's URI this one allows
 * modification after it's been created. This one has a simple fluent style
 * to help build uris.
 *
 * https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
 * URI = scheme ":" ["//" authority] path ["?" query] ["#" fragment]
 */
public class MutableUri extends Uri {

    public MutableUri() {
        // empty
    }
    
    public MutableUri(String uri) {
        // use our new string parser
        this.parse(uri);
    }
    
    public MutableUri(URI uri) {
        this.parse(uri);
    }
    
    @SuppressWarnings("OverridableMethodCallInConstructor")
    public MutableUri(Uri uri) {
        this.scheme = uri.scheme;
        this.hasAuthority = uri.hasAuthority;
        this.userInfo = uri.userInfo;
        this.host = uri.host;
        this.port = uri.port;
        this.rels = copy(uri.rels);
        this.query = copy(uri.query);
        this.fragment = uri.fragment;
    }
    
    @Deprecated
    public Uri toImmutable() {
        return this.immutable();
    }
    
    public Uri immutable() {
        return new Uri(this.scheme, this.hasAuthority, this.userInfo, this.host, this.port, this.rels, this.query, this.fragment);
    }
    
    public Uri toUri() {
        return this.immutable();
    }
    
    public MutableUri scheme(String scheme) {
        this.scheme = scheme;
        return this;
    }
    
    public MutableUri userInfo(String userInfo) {
        this.userInfo = userInfo;
        return this;
    }
    
    public MutableUri host(String host) {
        this.host = host;
        return this;
    }
    
    public MutableUri port(Integer port) {
        this.port = port;
        return this;
    }
    
    /**
     * Either sets an absolute or relative path.  The path MUST be url-encoded. If
     * the path starts with "/" then it will set the entire path, otherwise it will be
     * considered relative to the current path. If you want
     * to safely build a path and have this library do the url-encoding for you
     * then take a look at the rel() method as an alternative.
     * 
     * @param path The path to set such as "/a/b/c" if absolute or if the current
     *      path is "/a/b" and you call this method with "c" then the final
     *      path would be "/a/b/c".
     * @return This instance
     * @see #rel(java.lang.String[])
     */
    public MutableUri path(String path) {
        // clear it?
        if (path == null) {
            this.rels = null;
            return this;
        }

        List newRels = splitPath(path, true);
        
        // if absolute then normalize it (chop off leading empty rel)
        if (path.length() > 0 && path.charAt(0) == '/') {
            newRels = normalizeRootPath(newRels);
            this.rels = null;
        }
        
        // create array if its missing
        if (this.rels == null) {
            this.rels = new ArrayList<>();
        }
        
        // append everything
        this.rels.addAll(newRels);

        return this;
    }
    
    /**
     * Only adds the relative path if the value is present in the optional.
     * @param rels One or more optionals.
     * @return 
     */
    public MutableUri relIfPresent(Optional... rels) {
        if (rels == null || rels.length == 0) {
            return this;
        }
        
        for (Optional rel : rels) {
            // optionals should always be non-null
            Objects.requireNonNull(rel, "rel was null");
            if (rel.isPresent()) {
                this.rel(rel.get());
            }
        }
        
        return this;
    }
    
    
    /**
     * Adds one or more relative path components as-is. If the existing path
     * is "/a/b" and you supply "c@/d" to this method then the underlying
     * path would be "/a/b/c%40%2fd". This is the recommended method to safely
     * build a path in a url.
     * @param rels One or more relative path components to add
     * @return This instance
     */
    public MutableUri rel(Object... rels) {
        if (rels == null || rels.length == 0) {
            return this;
        }
        
        String[] strings = new String[rels.length];
        
        for (int i = 0; i < rels.length; i++) {
            Object rel = rels[i];
            Objects.requireNonNull(rel, "rel was null");
            strings[i] = Objects.toString(rel);
        }
        
        return rel(strings);
    }
    
    /**
     * Adds one or more relative path components as-is. If the existing path
     * is "/a/b" and you supply "c@/d" to this method then the underlying
     * path would be "/a/b/c%40%2fd". This is the recommended method to safely
     * build a path in a url.
     * @param rels One or more relative path components to add
     * @return This instance
     */
    public MutableUri rel(String... rels) {
        if (rels == null || rels.length == 0) {
            return this;    // nothing to do
        }
        
        if (this.rels == null) {
            this.rels = new ArrayList<>();
        }
        
        for (String rel : rels) {
            Objects.requireNonNull(rel, "rel was null");
            this.rels.add(rel);
        }
        
        return this;
    }
    
    public MutableUri queryIfPresent(String name, Optional value) {
        Objects.requireNonNull(name, "name was null");
        Objects.requireNonNull(name, "value was null");
        
        if (value.isPresent()) {
            this.query(name, value.get());
        }
        
        return this;
    }
    
    public MutableUri query(String name, Object value) {
        Objects.requireNonNull(name, "name was null");
        return this.query(name, Objects.toString(value, null));
    }
    
    public MutableUri query(String name, String value) {
        Objects.requireNonNull(name, "name was null");
        
        List values = getQueryValues(name);
        
        values.add(value);

        return this;
    }
    
    /**
     * Adds the entire map as query values.
     * @param queryMap The map to add.  Must not be a null map.
     * @return 
     */
    public MutableUri query(Map queryMap) {
        Objects.requireNonNull(queryMap, "queryMap was null");
        queryMap.forEach((name, value) -> {
            if (value instanceof Iterable) {
                Iterable it = (Iterable)value;
                it.forEach(v -> {
                    this.query(name, v);
                });
            } else {
                this.query(name, value);
            }
        });
        return this;
    }
    
    public MutableUri setQueryIfPresent(String name, Optional value) {
        Objects.requireNonNull(name, "name was null");
        Objects.requireNonNull(name, "value was null");
        
        if (value.isPresent()) {
            this.setQuery(name, value.get());
        }
        
        return this;
    }
    
    public MutableUri setQuery(String name, Object value) {
        Objects.requireNonNull(name, "name was null");
        return this.setQuery(name, Objects.toString(value, null));
    }
    
    public MutableUri setQuery(String name, String value) {
        Objects.requireNonNull(name, "name was null");
        
        List values = getQueryValues(name);
        
        values.clear();
        values.add(value);

        return this;
    }
    
    /**
     * Adds the entire map as query values.
     * @param queryMap The map to add or null to clear.  The value can either be
     *      a value or an Iterable of values.
     * @return 
     */
    public MutableUri setQuery(Map queryMap) {
        if (this.query != null) {
            this.query.clear();
        }
        
        if (queryMap != null) {
            queryMap.forEach((name, value) -> {
                if (value instanceof Iterable) {
                    Iterable it = (Iterable)value;
                    it.forEach(v -> {
                        this.setQuery(name, v);
                    });
                } else {
                    this.setQuery(name, value);
                }
            });
        }
        
        return this;
    }
    
    private List getQueryValues(String name) {
        Objects.requireNonNull(name, "name cannot be null");
        
        if (this.query == null) {
            this.query = new LinkedHashMap<>(); // order of insertion important
        }
        
        return this.query.computeIfAbsent(name, (key) -> new ArrayList<>());
    }
    
    /**
     * Sets the non url-encoded fragment.
     * @param fragment Non url-encoded fragment or null to clear.
     * @return 
     */
    public MutableUri fragment(String fragment) {
        this.fragment = fragment;
        return this;
    }

    private MutableUri parse(URI uri) {
        // parsing a URI as a string is WAY more reliable
        return this.parse(uri.toString());
    }

    private MutableUri parse(String uri) {
        // https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
        // URI = scheme ":" ["//" authority] path ["?" query] ["#" fragment]
        // authority = [userinfo "@"] host [":" port]

        // we will implement our own parsing routine
        int pos = 0;
        int len = uri.length();

        // scheme is everything to start up to the "://" such as ldap://[2001:db8::7]/c=GB?objectClass?one
        boolean maybeHasAuthority = false;

        int schemeEndPos = uri.indexOf("://", pos);
        if (schemeEndPos > 0) {
            this.scheme = uri.substring(pos, schemeEndPos);
            pos = schemeEndPos + 3; // skip over :// on next part to parse
            maybeHasAuthority = true;
            this.hasAuthority = true;
        } else {
            // this could be a scheme:path scenario such as tel:+1-816-555-1212
            schemeEndPos = uri.indexOf(":", pos);
            if (schemeEndPos > 0) {
                this.scheme = uri.substring(pos, schemeEndPos);
                pos = schemeEndPos + 1; // skip over : on next part to parse
                // the rest is apparently always a path
            } else {
                // there isn't any scheme, the rest must be a path
                // we will simply continue on parsing it as a path
            }
        }

        boolean maybeHasPath = true;
        boolean maybeHasQuery = true;

        if (maybeHasAuthority) {
            int authorityEndPos = uri.indexOf("/", pos);
            if (authorityEndPos < 0) {
                // definitely no path
                maybeHasPath = false;
                // was it instead a ?
                authorityEndPos = uri.indexOf("?", pos);
                if (authorityEndPos < 0) {
                    maybeHasQuery = false;
                    // was it instead a #
                    authorityEndPos = uri.indexOf("#", pos);
                    if (authorityEndPos < 0) {
                        // the rest of the uri must be the authority
                        // e.g. https://[email protected]:123
                        authorityEndPos = len;
                    }
                }
            }

            // any userinfo?
            int userInfoPos = uri.indexOf("@", pos);
            if (userInfoPos > 0 && userInfoPos <= authorityEndPos) {        // in case an @ symbol is AFTER the authority
                this.userInfo = urlDecode(uri.substring(pos, userInfoPos));
                pos = userInfoPos + 1;  // skip @ char
            }

            // we're left with a potential host[:port]
            int hostEndPos = uri.indexOf(":", pos);

            // does the host start with an "[" indicating its an ipv6 address?
            if (pos < len && uri.charAt(pos) == '[') {
                // find the next matching ]
                int closingSquarePos = uri.indexOf(']', pos);
                if (closingSquarePos < 0) {
                    throw new IllegalArgumentException("Invalid IPv6 host (no matching ] char)");
                }
                hostEndPos = uri.indexOf(":", closingSquarePos);
            }

            // was no port found?
            if (hostEndPos < 0 || hostEndPos >= authorityEndPos) {
                // host makes up rest of authority
                hostEndPos = authorityEndPos;
                this.host = trimAndBlankToNull(uri.substring(pos, hostEndPos));
            } else {
                // a port was found and we should parse it and the host
                this.host = trimAndBlankToNull(uri.substring(pos, hostEndPos));
                pos = hostEndPos + 1;       // skip over : char
                String portStr = uri.substring(pos, authorityEndPos);
                try {
                    this.port = Integer.parseInt(portStr);
                } catch (Exception e) {
                    throw new IllegalArgumentException("port " + portStr + " was not an integer in uri " + uri);
                }
            }

            pos = authorityEndPos;
        }

        // we may be done at this point
        if (pos >= len) {
            return this;
        }

        if (maybeHasPath) {
            // we're left with the path, query, fragment
            int pathEndPos = uri.indexOf("?", pos);
            if (pathEndPos < 0) {
                // we know there is no query
                maybeHasQuery = false;
                // we may need to read to the fragment
                pathEndPos = uri.indexOf("#", pos);
                if (pathEndPos < 0) {
                    // the rest of the uri is path
                    pathEndPos = len;
                }
            }

            final String rawPath = uri.substring(pos, pathEndPos);
            this.path(rawPath);
            pos = pathEndPos;

            // we may be done at this point
            if (pos >= len) {
                return this;
            }
        }

        if (maybeHasQuery) {
            // there must have been a ?, we'll skip over it
            pos++;

            int queryEndPos = uri.indexOf("#", pos);
            if (queryEndPos < 0) {
                // the rest of the uri is query
                queryEndPos = len;
            }

            final String rawQuery = uri.substring(pos, queryEndPos);
            // parse raw query now
            {
                // get rid of map to rebuild
                this.query = null;

                // split on ampersand...
                String[] pairs = rawQuery.split("&");
                for (String pair : pairs) {
                    String[] nv = pair.split("=");
                    switch (nv.length) {
                        case 1:
                            this.query(urlDecode(nv[0]), null);
                            break;
                        case 2:
                            this.query(urlDecode(nv[0]), urlDecode(nv[1]));
                            break;
                        default:
                            throw new IllegalArgumentException("Name value pair [" + pair + "] in query [" + rawQuery + "] missing = char");
                    }
                }
            }

            pos = queryEndPos;

            // we may be done at this point
            if (pos >= len) {
                return this;
            }
        }

        // there must have been a #, we'll skip over it
        pos++;

        // remaining part is now fragment
        String rawFragment = uri.substring(pos);
        this.fragment = urlDecode(rawFragment);
        
        return this;
    }
    
    static private List normalizeRootPath(List rels) {
        if (rels.size() < 2) {
            // no path at all
            return null;
        } else {
            // drop off the empty root
            return rels.subList(1, rels.size());
        }
    }
    
    static List normalizeRels(List rels) {
        if (rels == null || rels.isEmpty()) {
            return rels;
        }
        
        List newRels = new ArrayList<>();
        
        for (String rel : rels) {
            switch (rel) {
                case ".":
                    // skip it
                    break;
                case "..":
                    // remove last newRel (if it exists)
                    if (newRels.size() > 0) {
                        newRels.remove(newRels.size()-1);
                    }
                    break;
                default:
                    newRels.add(rel);
                    break;
            }
        }
        
        return newRels;
    }
    
    // not sure we want this public (so package-level for now)
    static List splitPath(String path, boolean decode) {
        List paths = new ArrayList<>();
        splitPath(path, paths, decode);
        return paths;
    }
    
    // not sure we want this public (so package-level for now)
    static void splitPath(String path, List paths, boolean decode) {
        int pos = 0;
        for (int i = 0; i < path.length(); i++) {
            // found slash or on last char?
            if (path.charAt(i) == '/') {
                String p = path.substring(pos, i);
                paths.add((decode ? urlDecode(p) : p));
                pos = i+1;
            }
        }
        // add last token?
        if (pos < path.length()) {
            String p = path.substring(pos);
            paths.add((decode ? urlDecode(p) : p));
        } else if (pos == path.length()) {
            // add an empty string at end
            paths.add("");
        }
    }

    static String trimAndBlankToNull(String s) {
        if (s != null) {
            s = s.trim();
            if (s.length() == 0) {
                return null;
            }
        }
        return s;
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy