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 &amp; 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 &amp; 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}