001/* 002 * SPDX-License-Identifier: Apache-2.0 003 * 004 * Copyright 2024-2026 The Enola <https://enola.dev> Authors 005 * 006 * Licensed under the Apache License, Version 2.0 (the "License"); 007 * you may not use this file except in compliance with the License. 008 * You may obtain a copy of the License at 009 * 010 * https://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package dev.enola.common.io.iri; 019 020import com.google.common.base.CharMatcher; 021import com.google.common.collect.ImmutableMultimap; 022import com.google.common.net.HostAndPort; 023import com.google.common.net.HostSpecifier; 024import com.google.common.net.InetAddresses; 025import com.google.common.net.InternetDomainName; 026import com.google.errorprone.annotations.CanIgnoreReturnValue; 027 028import org.jspecify.annotations.Nullable; 029 030import java.net.URI; 031import java.net.URISyntaxException; 032import java.text.ParseException; 033import java.util.Locale; 034import java.util.Objects; 035 036/** 037 * URL is <a href="https://url.spec.whatwg.org">WHATWG URL Living Standard</a> inspired 038 * implementation. That standard is the de-facto successor of RFC 3987 & RFC 3986, which were 039 * the successors that obsoleted the original RFCs 2396 (with more minor RFC RF 2732 in between 040 * them). 041 * 042 * <p>This class is intentionally named the same as {@link java.net.URL}, because that class anyway 043 * should never ever be used anymore in modern Java (<a 044 * href="https://errorprone.info/bugpattern/URLEqualsHashCode">because its equals() and hashCode() 045 * methods use blocking outgoing internet connections</a>). 046 * 047 * <p>This class strictly speaking represents an <i>URL <b>Reference</b></i>, not just an 048 * <i>URL</i>; meaning that it can either an absolute with a <code>scheme:</code>, or relative. 049 * 050 * <p>This class is logically (but not technically, for efficiency) immutable. It has a {@link 051 * Builder} to programmatically configure instances of it. You can also just construct it from a 052 * String with {@link #parseUnencoded(String)}. To modify, use {@link #newBuilder()}, set what you 053 * need, and {@link Builder#build()} it. 054 * 055 * <p>This class never throws any runtime exceptions for supposedly "invalid" input. It allows e.g. 056 * "http://example.org/~{username}" (e.g. URI Templates à la RFC 6570, or other similar syntaxes) or 057 * (Glob-like) "**.{txt,json,yaml}" or "?.txt" or "[a-c].txt", etc. It may however not always parse 058 * a String input as you intended... ;-) Jokes apart, if you must validate, you can - with {@link 059 * #validate()}; this may throw an an exception. (The {@link #toURI()} method is the only other one 060 * which <code>throws 061 * </code> - naturally.) 062 * 063 * <p>This class leaves parsing (decoding) its {@link #authority()} to host/IPv4 & IPv6/port, 064 * and IDNA for host, up to others. E.g. Guava's {@link HostAndPort} and {@link 065 * com.google.common.net.InternetDomainName} and {@link com.google.common.net.InetAddresses} may be 066 * useful; they are used by {@link #validate()}. 067 * 068 * <p>This class never ever by itself changes an URL you construct based on a String input. 069 * 070 * <p>This class can {@link #normalize()}! But {@link #equals(Object)} does <b>*NOT*</b> normalize! 071 * (This makes it suitable for use in RDF-like applications; see e.g. <a 072 * href="https://en.wikipedia.org/wiki/Uniform_Resource_Identifier">Wikipedia</a> for background 073 * reading.) You <i>can</i> use {@link #equalsNormalized(URL)}, if you must. 074 * 075 * <p>This class does not yet directly support Windows Drive Letter scheme; you're welcome to add 076 * support for that, if you need it. 077 * 078 * <p>TODO TBC How does this class deal with escaping? AVOID URI's getRawXYZ() methods! 079 * 080 * <p>This class accepts International Domain Names (IDN) in the host part of an authority, but it 081 * does not yet have RFC 3490 Puny Code conversion support; as in, it cannot (itself) transform e.g. 082 * "https://☃.net" (a Snowman!) to "https://xn--n3h.net/" in {@link #normalize()}; you're welcome to 083 * add support for that, if you need it. 084 * 085 * <p>This class if null-safe. Its accessor methods never return null, but empty Strings instead. 086 * 087 * <p>This class never makes any network access! (Yes, looking at you, {@link 088 * java.net.URL#equals(Object)} - OMG!) 089 * 090 * <p>This class does not know about any specific schemes, and doesn't treat e.g. http: different 091 * from any other scheme. There are no hard-coded default ports or anything like that here. (TODO 092 * except to {@link #normalize()} e.g. ports, maybe?) TODO Re-read and re-think about if this really 093 * makes sense, and is Spec compliant? This may not really work? 094 * 095 * <p>This class is performance efficient, and parses lazily, not (possibly unnecessarily) ahead of 096 * time. 097 * 098 * <p>This class is memory efficient, and uses as little Java heap space as possible. 099 * 100 * <p>This class is intentionally final and not extensible. 101 * 102 * <p>This class has no dependencies on any other framework, and could be single-file copy/pasted! 103 * TODO For realz?! ;-) Or do depend on (just) Guava? It's handy e.g. for Multimap... 104 * 105 * @author <a href="https://www.vorburger.ch">Michael Vorburger.ch</a> originally wrote this class 106 * for <a href="https://enola.dev">Enola.dev</a>. 107 */ 108public final class URL implements Comparable<URL> { 109 // TODO extends IRI 110 111 // TODO If you are reading this, please help us to make this class stop lying about itself? ;-) 112 // It currently doesn't really do what's described above - yet... but you can help to make it! 113 114 // TODO Actually fully read https://url.spec.whatwg.org first.. :=) 115 116 // TODO Research existing implementations for inspiration... 117 // - https://github.com/square/okhttp/issues/1486 118 // - https://github.com/palominolabs/url-builder 119 // - https://github.com/dmfs/uri-toolkit 120 // - https://github.com/RustedBones/capturl 121 // - https://github.com/lemonlabsuk/scala-uri 122 // - others? 123 124 // TODO Add more shit to URLTest and make it pass 125 126 // TODO Consider using CharSequence instead of String, for substring performance? 127 128 // TODO Use com.google.common.net.UrlEscapers where appropriate 129 130 // TODO Test with https://jsdom.github.io/whatwg-url/ 131 132 // TODO Test against https://github.com/web-platform-tests/wpt/tree/master/url 133 134 // TODO See if there is interest in getting this accepted into Guava 135 // see https://github.com/google/guava/issues/1005 136 // and https://github.com/google/guava/issues/1691 137 // and https://github.com/google/guava/issues/1756 138 // et al. 139 140 // TODO Factor out into separate GitHub repo, e.g. https://github.com/vorburger/jiri 141 142 // TODO Get listed on https://url.spec.whatwg.org ? 143 144 // TODO What's the right naming - encoded, or escaped? 145 public static URL parseUnencoded(String unencoded) { 146 var url = new URL(); 147 url.string = Objects.requireNonNull(unencoded); 148 return url; 149 } 150 151 // TODO public static URL fromEscaped(String unencoded, Charset cs) { 152 // see e.g. https://github.com/lemonlabsuk/scala-uri?tab=readme-ov-file#character-sets why CS 153 154 public static URL from(URI uri) { 155 // TODO Use getRawXYZ() here, or non-raw?! 156 return builder() 157 .scheme(uri.getScheme()) 158 .authority(uri.getAuthority()) 159 .path(uri.getPath()) 160 .query(uri.getQuery()) 161 .fragment(uri.getFragment()) 162 .build(); 163 // TODO Or?! return of(uri.toString()); 164 // TODO Or?! return of(uri.toASCIIString()); 165 } 166 167 /** 168 * Returns a new {@link Builder}. This method exists purely for convenience for folks used to 169 * typing <code>URL#builder()</code> (e.g. as is popular in generated <a 170 * href="https://protobuf.dev">Protocol Buffers</a> and similar code) instead of <code> 171 * new URL.Builder()</code> - they are equivalent. 172 */ 173 public static Builder builder() { 174 return new Builder(); 175 } 176 177 public static class Builder { 178 // Keep this in sync with below! 179 private @Nullable String scheme; 180 private @Nullable String authority; 181 private @Nullable String path; 182 // TODO private @Nullable List<String> paths; 183 private @Nullable String query; 184 // TODO private @Nullable Multimap<String, String> queryMap; 185 private @Nullable String fragment; 186 187 public URL build() { 188 var url = new URL(); 189 url.scheme = scheme; 190 url.authority = authority; 191 url.path = path; 192 // TODO url.paths = paths; 193 url.query = query; 194 // TODO url.queryMap = queryMap; 195 url.fragment = fragment; 196 return url; 197 } 198 199 @CanIgnoreReturnValue 200 public Builder scheme(String scheme) { 201 this.scheme = scheme; 202 return this; 203 } 204 205 @CanIgnoreReturnValue 206 public Builder authority(String authority) { 207 this.authority = authority; 208 return this; 209 } 210 211 @CanIgnoreReturnValue 212 public Builder path(String path) { 213 this.path = path; 214 return this; 215 } 216 217 @CanIgnoreReturnValue 218 public Builder query(String query) { 219 this.query = query; 220 return this; 221 } 222 223 public Builder fragment(String fragment) { 224 this.fragment = fragment; 225 return this; 226 } 227 } 228 229 // TODO Save memory by using Object which is either String or a Parsed.. 230 // Keep this in sync with above! 231 private @Nullable String scheme; 232 private @Nullable String authority; 233 private @Nullable String path; 234 // TODO private @Nullable List<String> paths; 235 private @Nullable String query; 236 // TODO private @Nullable Multimap<String, String> queryMap; 237 private @Nullable String fragment; 238 private @Nullable String string; 239 240 public Builder newBuilder() { 241 return new Builder(); 242 } 243 244 public String scheme() { 245 if (scheme == null) scheme = find_scheme(); 246 return scheme; 247 } 248 249 private String find_scheme() { 250 return null; // TODO 251 } 252 253 public boolean hasScheme(String scheme) { 254 // TODO Implement more optimized 255 return scheme().equals(scheme); 256 } 257 258 public boolean isAbsolute() { 259 return !scheme().isBlank(); 260 } 261 262 // /** Scheme specific part is just everything after the : colon of the scheme. */ 263 /* public CharSequence schemeSpecificPart() { 264 return null; // TODO 265 } */ 266 267 public String authority() { 268 return null; // TODO 269 } 270 271 public String path() { 272 return null; // TODO 273 } 274 275 public String query() { 276 return null; // TODO 277 } 278 279 public String fragment() { 280 return null; // TODO 281 } 282 283 // TODO Allow both & and ; as query delimiters?! 284 public ImmutableMultimap<String, String> queryMap() { 285 return null; // TODO 286 } 287 288 public ImmutableMultimap<String, String> queryParameter(String key) { 289 return null; // TODO 290 } 291 292 public URL base() { 293 return null; // TODO as in URIs.base() 294 } 295 296 /** Resolve, e.g. as in {@link URI#resolve(URI)}. */ 297 public URL resolve(URL url) { 298 return null; // TODO 299 } 300 301 /** Resolve, e.g. as in {@link URI#resolve(URI)}. */ 302 public URL resolve(String string) { 303 return resolve(URL.parseUnencoded(string)); 304 } 305 306 /** Relativize, e.g. as in {@link URI#relativize(URI)}. */ 307 public URL relativize(URL url) { 308 return null; // TODO 309 } 310 311 @Override 312 public String toString() { 313 if (string == null) string = stringify(); 314 return string; 315 } 316 317 private String stringify() { 318 return "TODO"; 319 } 320 321 public URI toURI() throws URISyntaxException { 322 return new URI(toString()); 323 } 324 325 public URL normalize() { 326 // TODO Keep result in a lazily initialized field? But... memory?! 327 var builder = newBuilder(); 328 builder.scheme(scheme().toLowerCase(Locale.ROOT)); 329 // TODO ... FIXME 330 // TODO Should we drop default ports for a few well-known schemes? 331 return builder.build(); 332 } 333 334 /** Equality check, with {@link #normalize()}-ation. */ 335 public boolean equalsNormalized(URL o) { 336 return this.normalize().toString().equals(o.normalize().toString()); 337 } 338 339 /** 340 * Equality check, based on {@link #toString()}. 341 * 342 * <p>This does <b>NOT</b> {@link #normalize()}! See {@link #equalsNormalized(URL)}. 343 */ 344 @Override 345 public boolean equals(Object o) { 346 if (this == o) return true; 347 if (o == null || getClass() != o.getClass()) return false; 348 return toString().equals(o.toString()); 349 } 350 351 @Override 352 public int hashCode() { 353 return toString().hashCode(); 354 } 355 356 /** 357 * Comparison, based purely on {@link #toString()}. This does <b>NOT</b> {@link #normalize()}! 358 */ 359 @Override 360 public int compareTo(URL o) { 361 return toString().compareTo(o.toString()); 362 } 363 364 public void validate() throws ValidationException { 365 var scheme = scheme(); 366 if (isAbsolute() && scheme.isBlank()) throw new ValidationException(this, "Blank scheme"); 367 if (isAbsolute() && !CharAscii.INSTANCE.matchesAllOf(scheme)) 368 throw new ValidationException(this, "Invalid scheme: " + scheme); 369 370 try { 371 var authority = authority(); 372 if (!authority.isBlank()) 373 validateHostname(HostAndPort.fromString(authority()).getHost()); 374 } catch (IllegalArgumentException e) { 375 throw new ValidationException(this, "Invalid authority", e); 376 } 377 378 // TODO ... 379 } 380 381 private void validateHostname(String host) throws ValidationException { 382 try { 383 HostSpecifier.from(host); 384 } catch (ParseException e) { 385 throw new ValidationException(this, "Host invalid", e); 386 } 387 // TODO Is this still required, or did HostSpecifier.from() already do (exactly?!) this... 388 if (InetAddresses.isUriInetAddress(host)) return; 389 if (!InternetDomainName.isValid(host)) 390 throw new ValidationException( 391 this, 392 "Host is neither an URI IP Address nor a valid Internet Domain Name (IDN): " 393 + host); 394 } 395 396 private URL() {} 397 398 private static final class CharAscii extends CharMatcher { 399 400 static final CharMatcher INSTANCE = new CharAscii(); 401 402 @Override 403 public boolean matches(char c) { 404 // https://url.spec.whatwg.org/#url-representation *permits* upper-case 405 return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); 406 } 407 } 408 409 public static class ValidationException extends Exception { 410 // This was originally loosely inspired by java.net.URISyntaxException 411 412 private final URL url; 413 414 private ValidationException(URL url, String reason) { 415 super(reason); 416 this.url = url; 417 } 418 419 public ValidationException(URL url, String reason, Exception e) { 420 super(reason, e); 421 this.url = url; 422 } 423 424 public String getReason() { 425 return super.getMessage(); 426 } 427 428 public URL getURL() { 429 return url; 430 } 431 432 @Override 433 public String getMessage() { 434 return getReason() + ": " + getURL(); 435 } 436 } 437}