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}