001/*
002 *    Copyright 2017 by Dimitar Dimitrov
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 */
016
017// NOTE: this class overuses nested classes, because I find I often need to copy/paste it to proprietary projects.
018package io.github.ddimitrov.nuggets;
019
020import org.jetbrains.annotations.Nullable;
021
022import java.io.ByteArrayInputStream;
023import java.io.IOException;
024import java.io.InputStream;
025import java.net.*;
026import java.nio.charset.Charset;
027import java.util.*;
028import java.util.function.Function;
029
030import static io.github.ddimitrov.nuggets.Exceptions.rethrow;
031import static io.github.ddimitrov.nuggets.Functions.fallback;
032
033/**
034 * <p>URL schema handlers - a quick way to add support for data URLs and
035 * Classpath URLs to your project.</p>
036 *
037 * <p>The official {@link java.net.URLStreamHandlerFactory URLStreamHandlerFactory}
038 * extension API has a number of drawbacks, the most significant of which that there
039 * can only be one.</p>
040 *
041 * <p>An alternative, suggested way is to keep using the default implementation
042 * ({@link sun.misc.Launcher.Factory}) and just plant your Handler classes in a package
043 * following the default convention {@code sun.net.www.protocol.<schema>}.</p>
044 *
045 * <p>For example, to add support for data URLs and cp URLs under the
046 * schemas cp:com/foobar/MyResource.txt, add the following 2 files:</p>
047 * <ul>
048 *     <li><b>{@code File: <src-root>/sun/net/www/protocol/data/Handler.java}</b>
049 *         <pre><code>
050 * package sun.net.www.protocol.data;      // the schema is the last part of the package
051 * public class Handler extends DataUrl {} // the class name needs to be Handler
052 *         </code></pre>
053 *     </li>
054 *     <li><b>{@code File: <src-root>/sun/net/www/protocol/cp/Handler.java}</b>
055 *         <pre><code>
056 * package sun.net.www.protocol.cp;
057 * public class Handler extends UrlStreamHandlers.ResolversUrl {
058 *    public Handler() { super(Foobar.class.getClassLoader::getResource, ClassLoader::getSystemResource); }
059 * }
060 *         </code></pre>
061 *     </li>
062 * </ul>
063 *
064 * <p>With this setup you don't need to set any system properties or muck
065 * with factories. Also, this way all protocols shipped with the JRE will
066 * keep working.</p>
067 *
068 */
069public final class UrlStreamHandlers {
070    static final Charset US_ASCII = Charset.forName("US-ASCII");
071    static final Charset UTF8 = Charset.forName("UTF-8");
072
073    private UrlStreamHandlers() {}
074
075    /**
076     * Factory method for URL-encoded data-URLs from a string.
077     * These have the advantages that they are more readable when few special characters are used.
078     * @param mediaType the optional MIME type for the resource (pass {@code null} if you don't care)
079     * @param data the resource that should be encoded by the URL.
080     * @return an URL that would yield {@code data} when resolved.
081     */
082    public static String createDataUrl(@Nullable String mediaType, String data) {
083        StringBuilder sb = new StringBuilder("data:");
084        if (mediaType!=null) sb.append(mediaType);
085        sb.append(",");
086        String encoded = rethrow(()->URLEncoder.encode(data, UTF8.name()));
087        sb.append(encoded);
088        return sb.toString();
089    }
090
091    /**
092     * Factory method for Base64-encoded data-URLs from a string.
093     * These have the advantages that they are more compact when representing binary data.
094     * @param mediaType the optional MIME type for the resource (pass {@code null} if you don't care)
095     * @param data the resource that should be encoded by the URL.
096     * @return an URL that would yield {@code data} when resolved.
097     */
098    public static String createDataUrlBase64(@Nullable String mediaType, byte[] data) {
099        StringBuilder sb = new StringBuilder("data:");
100        if (mediaType!=null) sb.append(mediaType);
101        sb.append(";base64");
102        sb.append(",");
103        String encoded = Base64.getUrlEncoder().encodeToString(data);
104        sb.append(encoded);
105        return sb.toString();
106    }
107
108    /**
109     * <p>An URL stream handler that adds support for data URLs. Use when an API
110     * demands an URL, but you just want to feed it a string or a bunch of bytes.
111     * </p>
112     *
113     * @see <a href="https://tools.ietf.org/html/rfc2397">RFC2397</a>
114     * @see <a href="https://en.wikipedia.org/wiki/Data_URI_scheme">Wikipedia: Data URI scheme</a>
115     * @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs">MDN: Data URIs</a>
116     */
117    public static class DataUrl extends URLStreamHandler {
118        @Override
119        protected URLConnection openConnection(URL u) {
120            return new DataUrlConnection(u);
121        }
122    }
123
124    static class DataUrlConnection extends URLConnection {
125        private Map<String, String> headers;
126        private byte[] content;
127        private String contentStr;
128
129        DataUrlConnection(URL url) { super(url); }
130
131        @Override
132        public void connect() throws IOException {
133            String[] typeAndContent = url.toString().split(":", 2)[1].split(",",2);
134            if (typeAndContent.length!=2) throw new IllegalArgumentException("Not a valid data URL: " + url);
135
136            String[] mime = typeAndContent[0].split(";");
137            boolean base64 = "base64".equalsIgnoreCase(mime[mime.length - 1]);
138            if (base64) mime = Arrays.copyOfRange(mime, 0, mime.length - 1);
139
140            boolean hasMediaType = mime[0].contains("/") && !mime[0].contains("=");
141            // we don't use this: String mediaType= hasMediaType ? mime[0] : "text/plain";
142            if (hasMediaType) mime = Arrays.copyOfRange(mime, 1, mime.length);
143
144            Charset charset = US_ASCII;
145            for (String mimeParam : mime) {
146                if (mimeParam.isEmpty()) continue;
147
148                String[] param = mimeParam.split("=", 2);
149                if (param.length != 2) {
150                    throw new IllegalArgumentException("Malformed parameter: " + mimeParam);
151                }
152                if (param[0].equalsIgnoreCase("charset")) {
153                    charset = Charset.forName(param[1]);
154                }
155            }
156
157            String urlDecodedContent = URLDecoder.decode(typeAndContent[1], charset.name());
158            if (base64) {
159                String s = urlDecodedContent.replaceAll("[\\s\\r\\n]+", "");
160                content = Base64.getUrlDecoder().decode(s);
161            } else {
162                contentStr = urlDecodedContent;
163                content = contentStr.getBytes(charset);
164            }
165
166            headers = new HashMap<>();
167            headers.put("content-type", typeAndContent[0]);
168            headers.put("content-length", String.valueOf(content.length));
169            headers.put("content-encoding", "identity");
170            headers.put("date", new Date().toString());
171            headers.put("expires", new Date(Long.MAX_VALUE).toString());
172            headers.put("last-modified", new Date(0).toString());
173
174            this.connected = true;
175        }
176
177        @Override
178        public String getHeaderField(String name) {
179            return headers.get(name);
180        }
181
182
183        @Override
184        public InputStream getInputStream() throws IOException {
185            connect();
186            return new ByteArrayInputStream(content);
187        }
188
189        @Override
190        public Object getContent() throws IOException {
191            connect();
192            return content;
193        }
194
195        @Override @SuppressWarnings("rawtypes")
196        public Object getContent(Class[] classes) throws IOException {
197            connect();
198            for (Class negotiatedType : classes) {
199                if (negotiatedType==byte[].class) return content;
200                if (negotiatedType==String.class) {
201                    return fallback(false, Objects::nonNull, // return the first non-null
202                            bytes -> contentStr,
203                            bytes -> new String(content, Charset.defaultCharset()),
204                            bytes -> new String(content, US_ASCII),
205                            bytes -> new String(content, UTF8)
206                    ).apply(content);
207                }
208            }
209            return super.getContent(classes);
210        }
211    }
212
213    /**
214     * <p>An URL stream handler that resolves to another URL schema - use this to build
215     * custom URL schemas that can shorten URIs or lookup resources.</p>
216     *
217     * <p>This class is abstract, so you may want to consider instead the {@link ResolversUrl},
218     * which uses lambda functions to resolve the resource.</p>
219     */
220    public static abstract class ResolvingUrl extends URLStreamHandler {
221        protected abstract URL resolve(String resourcePath);
222
223        @Override
224        protected URLConnection openConnection(URL u) throws IOException {
225            String resource = u.toString().split(":", 2)[1];
226            try {
227                URL resolvedUrl = resolve(resource);
228                if (resolvedUrl==null) {
229                    throw new ProtocolException("Resource could not be located: " + resource);
230                }
231                return resolvedUrl.openConnection();
232            } catch (IOException e) {
233                throw e;
234            } catch (Exception e) {
235                ProtocolException pe = new ProtocolException("Resource could not be located: " + resource);
236                pe.initCause(e);
237                throw pe;
238            }
239        }
240    }
241
242    /**
243     * <p>An URL stream handler that resolves to another URL schema - use this to build
244     * custom URL schemas that can shorten URIs or lookup resources.</p>
245     *
246     * <p>This class accepts resolver strategies as lambdas in the constructor.
247     * This is convenient when you just want to pass a method reference to a {@code getResources()}
248     * method as in this example:</p>
249     * <pre><code>
250     * package sun.net.www.protocol.cp;
251     * public class Handler extends UrlStreamHandlers.ResolversUrl {
252     *    public Handler() { super(Foobar.class.getClassLoader::getResource, ClassLoader::getSystemResource); }
253     * }
254     * </code></pre>
255     */
256    public static class ResolversUrl extends ResolvingUrl {
257        private final Function<String, URL> resolver;
258
259        @SuppressWarnings("varargs") // passing varargs down to fallback could be potentially unsafe, but we know what it does
260        @SafeVarargs
261        public ResolversUrl(Function<String, URL>... resolvers) {
262            resolver = fallback(false, Objects::nonNull, resolvers);
263        }
264
265        @Override
266        protected URL resolve(String resourcePath) {
267            return resolver.apply(resourcePath);
268        }
269    }
270
271}