URIGenerator.java

package dev.orne.test.rnd.generators;

/*-
 * #%L
 * Orne Test Generators
 * %%
 * Copyright (C) 2022 Orne Developments
 * %%
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Lesser Public License for more details.
 * 
 * You should have received a copy of the GNU General Lesser Public
 * License along with this program.  If not, see
 * <http://www.gnu.org/licenses/lgpl-3.0.html>.
 * #L%
 */

import java.net.URI;
import java.net.URISyntaxException;

import javax.validation.constraints.NotNull;

import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.RandomUtils;
import org.apiguardian.api.API;
import org.apiguardian.api.API.Status;

import dev.orne.test.rnd.AbstractTypedGenerator;
import dev.orne.test.rnd.GenerationException;
import dev.orne.test.rnd.Priority;

/**
 * Generator of {@code URI} values.
 * 
 * @author <a href="mailto:wamphiry@orne.dev">(w) Iker Hernaez</a>
 * @version 1.0, 2022-11
 * @since 0.1
 * @see <a href="https://datatracker.ietf.org/doc/html/rfc2732">RFC 2732</a>
 * @see <a href="https://datatracker.ietf.org/doc/html/rfc2373">RFC 2373</a>
 */
@API(status=Status.STABLE, since="0.1")
@Priority(Priority.NATIVE_GENERATORS)
public class URIGenerator
extends AbstractTypedGenerator<URI> {

    /** The default value. */
    public static final URI DEFAULT_VALUE = URI.create("/");
    
    private static final String LOWALPHA = "abcdefghijklmnopqrstuvwxyz";
    private static final String UPALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private static final String ALPHA = LOWALPHA + UPALPHA;
    private static final String DIGIT = "0123456789";
    private static final String ALPHANUM = ALPHA + DIGIT;
    private static final String RESERVED = ";/?@&=+$,[]"; // Removed ':'
    private static final String MARK = "-_.!~*'()";
    private static final String UNRESERVED  = ALPHANUM + MARK;
    private static final String HEXDIG = DIGIT + "ABCDEF";
    private static final float ESCAPED_CHAR_P = 0.05f;
    private static final String UNESCAPED_URIC = RESERVED + UNRESERVED;
    private static final int SCHEME_MIN_LENGTH = 1;
    private static final int SCHEME_MAX_LENGTH = 10;
    private static final String SCHEME_REST_C = ALPHANUM + "+-.";
    private static final int USERINFO_MIN_LENGTH = 0;
    private static final int USERINFO_MAX_LENGTH = 20;
    private static final String UNESCAPED_USERINFO_C = UNRESERVED + ";&=+$,"; // Removed ':'
    private static final int DOMAIN_LABEL_MIN_LENGTH = 1;
    private static final int DOMAIN_LABEL_MAX_LENGTH = 20;
    private static final String DOMAIN_LABEL_MIDDLE_C = ALPHANUM + "-";
    private static final int TOP_LABEL_MIN_LENGTH = 1;
    private static final int TOP_LABEL_MAX_LENGTH = 5;
    private static final String TOP_LABEL_MIDDLE_C = ALPHANUM + "-";
    private static final int HOSTNAME_MIN_DOMAIN_LEVELS = 1;
    private static final int HOSTNAME_MAX_DOMAIN_LEVELS = 5;
    private static final char DOMAIN_LABEL_SEPARATOR = '.';
    private static final float TOP_LABEL_SUFFIX_P = 0.01f;
    private static final char TOP_LABEL_SUFFIX = '.';
    private static final int IP4_ADDRESS_MIN_NUM = 0;
    private static final int IP4_ADDRESS_MAX_NUM = 255;
    private static final int IP6_PIECE_MIN_LENGTH = 1;
    private static final int IP6_PIECE_MAX_LENGTH = 4;
    private static final int IP6_ADDRESS_MIN_LENGTH = 0;
    private static final int IP6_ADDRESS_MAX_LENGTH = 8;
    private static final float IP6_ABRV_P = 0.3f;
    private static final char IP6_ADDRESS_SEPARATOR = ':';
    private static final char IP6_ADDRESS_PREFIX = '[';
    private static final char IP6_ADDRESS_SUFFIX = ']';
    private static final float HOSTNAME_P = 0.8f;
    private static final float IP4_P = 0.5f;
    private static final int PORT_MIN = 1;
    private static final int PORT_MAX = 65535;
    private static final float PORT_P = 0.2f;
    private static final char PORT_PREFIX = ':';
    private static final char USERINFO_SUFFIX = '@';
    private static final float USERINFO_P = 0.2f;
    private static final int REG_NAME_MIN_LENGTH = 1;
    private static final int REG_NAME_MAX_LENGTH = 30;
    private static final String UNESCAPED_REG_NAME_C = UNRESERVED + "$,;@&=+"; // Removed ':'
    private static final float SERVER_P = 0.9f;
    private static final String UNESCAPED_PATH_C = UNRESERVED + "@&=+$,"; // Removed ':'
    private static final int PARAM_MIN_LENGTH = 1;
    private static final int PARAM_MAX_LENGTH = 10;
    private static final int SEGMENT_PATH_MIN_LENGTH = 1;
    private static final int SEGMENT_PATH_MAX_LENGTH = 20;
    private static final float SEGMENT_PARAM_P = 0.1f;
    private static final char SEGMENT_PARAM_SEPARATOR = ';';
    private static final int PATH_SEGMENTS_MIN_SEGMENTS = 0;
    private static final int PATH_SEGMENTS_MAX_SEGMENTS = 10;
    private static final char PATH_SEGMENTS_SEPARATOR = '/';
    private static final char ABSOLUTE_PATH_PREFIX = '/';
    private static final int QUERY_MIN_LENGTH = 1;
    private static final int QUERY_MAX_LENGTH = 50;
    private static final float QUERY_P = 0.5f;
    private static final float PATH_ABSOLUTE_P = 0.8f;
    private static final int FRAGMENT_MIN_LENGTH = 1;
    private static final int FRAGMENT_MAX_LENGTH = 20;
    private static final String UNESCAPED_RELATIVE_SEGMENT_C = UNRESERVED + ";@&=+$,";
    private static final int RELATIVE_SEGMENT_MIN_LENGTH = 1;
    private static final int RELATIVE_SEGMENT_MAX_LENGTH = 20;
    private static final float RELATIVE_PATH_ABSOLUTE_PATH_P = 0.6f;
    private static final float FRAGMENT_P = 0.2f;

    /**
     * Creates a new instance.
     */
    public URIGenerator() {
        super();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public @NotNull URI defaultValue() {
        return DEFAULT_VALUE;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public @NotNull URI randomValue() {
        return randomURI();
    }

    /**
     * Returns a random scheme.
     * 
     * @return The random scheme
     */
    public static @NotNull String randomScheme() {
        final int length = RandomUtils.nextInt(
                SCHEME_MIN_LENGTH,
                SCHEME_MAX_LENGTH + 1);
        final StringBuilder buffer = new StringBuilder();
        buffer.append(RandomStringUtils.randomAlphabetic(1));
        buffer.append(RandomStringUtils.random(length - 1, SCHEME_REST_C));
        return buffer.toString();
    }

    /**
     * Returns a random user info part of an URI.
     * 
     * @return The random user info
     */
    public static @NotNull String randomUserInfo() {
        final int length = RandomUtils.nextInt(
                USERINFO_MIN_LENGTH,
                USERINFO_MAX_LENGTH + 1);
        final StringBuilder buffer = new StringBuilder();
        for (int i = 0; i < length; i++) {
            if (RandomUtils.nextFloat(0, 1) < ESCAPED_CHAR_P) {
                buffer.append((char) RandomUtils.nextInt('\u0080', '\uFFFF'));
            } else {
                buffer.append(RandomStringUtils.random(1, UNESCAPED_USERINFO_C));
            }
        }
        return buffer.toString();
    }

    /**
     * Returns a random optional user info part of an URI.
     * 
     * @return The random user info, or {@code null}
     */
    public static String randomOptionalUserInfo() {
        if (RandomUtils.nextFloat(0, 1) < USERINFO_P) {
            return randomUserInfo();
        } else {
            return null;
        }
    }

    /**
     * Returns a random non top domain label.
     * 
     * @return The non top domain label
     */
    protected static @NotNull String randomDomainLabel() {
        final StringBuilder buffer = new StringBuilder();
        final int length = RandomUtils.nextInt(
                DOMAIN_LABEL_MIN_LENGTH,
                DOMAIN_LABEL_MAX_LENGTH + 1);
        buffer.append(RandomStringUtils.randomAlphanumeric(1));
        if (length > 1) {
            if (length > 2) {
                buffer.append(RandomStringUtils.random(length - 2, DOMAIN_LABEL_MIDDLE_C));
            }
            buffer.append(RandomStringUtils.randomAlphanumeric(1));
        }
        return buffer.toString();
    }

    /**
     * Returns a random top domain label.
     * 
     * @return The top domain label
     */
    protected static @NotNull String randomTopLabel() {
        final StringBuilder buffer = new StringBuilder();
        final int length = RandomUtils.nextInt(
                TOP_LABEL_MIN_LENGTH,
                TOP_LABEL_MAX_LENGTH + 1);
        buffer.append(RandomStringUtils.randomAlphabetic(1));
        if (length > 1) {
            if (length > 2) {
                buffer.append(RandomStringUtils.random(length - 2, TOP_LABEL_MIDDLE_C));
            }
            buffer.append(RandomStringUtils.randomAlphanumeric(1));
        }
        return buffer.toString();
    }

    /**
     * Returns a random host name.
     * 
     * @return The host name
     */
    public static @NotNull String randomHostName() {
        final StringBuilder buffer = new StringBuilder();
        final int domainLevels = RandomUtils.nextInt(
                HOSTNAME_MIN_DOMAIN_LEVELS,
                HOSTNAME_MAX_DOMAIN_LEVELS + 1);
        for (int i = 0; i < domainLevels; i++) {
            buffer.append(randomDomainLabel()).append(DOMAIN_LABEL_SEPARATOR);
        }
        buffer.append(randomTopLabel());
        if (RandomUtils.nextFloat(0, 1) < TOP_LABEL_SUFFIX_P) {
            buffer.append(TOP_LABEL_SUFFIX);
        }
        return buffer.toString();
    }

    /**
     * Returns a random IP4 address.
     * 
     * @return The IP4 address
     */
    public static @NotNull String randomIp4Address() {
        return String.format(
                "%d.%d.%d.%d",
                RandomUtils.nextInt(IP4_ADDRESS_MIN_NUM, IP4_ADDRESS_MAX_NUM + 1),
                RandomUtils.nextInt(IP4_ADDRESS_MIN_NUM, IP4_ADDRESS_MAX_NUM + 1),
                RandomUtils.nextInt(IP4_ADDRESS_MIN_NUM, IP4_ADDRESS_MAX_NUM + 1),
                RandomUtils.nextInt(IP4_ADDRESS_MIN_NUM, IP4_ADDRESS_MAX_NUM + 1));
    }

    /**
     * Returns a random IP6 address piece.
     * 
     * @return The random IP6 address piece
     */
    protected static @NotNull String randomIp6Piece() {
        final int length = RandomUtils.nextInt(
                IP6_PIECE_MIN_LENGTH,
                IP6_PIECE_MAX_LENGTH + 1);
        return RandomStringUtils.random(length, HEXDIG);
    }

    /**
     * Returns a random full IP6 address.
     * 
     * @return The full IP6 address
     */
    public static @NotNull String randomFullIp6Address() {
        return String.format(
                "%s:%s:%s:%s:%s:%s:%s:%s",
                randomIp6Piece(),
                randomIp6Piece(),
                randomIp6Piece(),
                randomIp6Piece(),
                randomIp6Piece(),
                randomIp6Piece(),
                randomIp6Piece(),
                randomIp6Piece());
    }

    /**
     * Returns a random abbreviated IP6 address.
     * 
     * @return The abbreviated IP6 address
     */
    public static @NotNull String randomAbbreviatedIp6Address() {
        final StringBuilder buffer = new StringBuilder();
        final int pieces = RandomUtils.nextInt(
                IP6_ADDRESS_MIN_LENGTH,
                IP6_ADDRESS_MAX_LENGTH);
        if (pieces == 0) {
            buffer.append(IP6_ADDRESS_SEPARATOR)
                    .append(IP6_ADDRESS_SEPARATOR);
        } else {
            final int prePieces = RandomUtils.nextInt(0, pieces + 1);
            if (prePieces > 0) {
                buffer.append(randomIp6Piece());
                for (int i = 1 ; i < prePieces; i++) {
                    buffer.append(IP6_ADDRESS_SEPARATOR).append(randomIp6Piece());
                }
            }
            buffer.append(IP6_ADDRESS_SEPARATOR);
            final int postPieces = pieces - prePieces;
            if (postPieces == 0) {
                buffer.append(IP6_ADDRESS_SEPARATOR);
            } else {
                for (int i = 0 ; i < postPieces; i++) {
                    buffer.append(IP6_ADDRESS_SEPARATOR).append(randomIp6Piece());
                }
            }
        }
        return buffer.toString();
    }

    /**
     * Returns a random IP6 address.
     * 
     * @return The IP6 address
     */
    public static @NotNull String randomIp6Address() {
        if (RandomUtils.nextFloat(0, 1) < IP6_ABRV_P) {
            return randomAbbreviatedIp6Address();
        } else {
            return randomFullIp6Address();
        }
    }

    /**
     * Returns a random host.
     * 
     * @return The host part
     */
    public static @NotNull String randomHost() {
        if (RandomUtils.nextFloat(0, 1) < HOSTNAME_P) {
            return randomHostName();
        } else if (RandomUtils.nextFloat(0, 1) < IP4_P) {
            return randomIp4Address();
        } else {
            return IP6_ADDRESS_PREFIX + randomIp6Address() + IP6_ADDRESS_SUFFIX;
        }
    }

    /**
     * Returns a random port.
     * 
     * @return The port
     */
    public static int randomPort() {
        return RandomUtils.nextInt(PORT_MIN, PORT_MAX + 1);
    }

    /**
     * Returns a random optional port.
     * 
     * @return The port, or {@code -1}
     */
    public static int randomOptionalPort() {
        if (RandomUtils.nextFloat(0, 1) < PORT_P) {
            return randomPort();
        } else {
            return -1;
        }
    }

    /**
     * Returns a random server authority part.
     * 
     * @return The server authority part
     */
    public static @NotNull String randomServerAuthority() {
        final StringBuilder buffer = new StringBuilder();
        final String userInfo = randomOptionalUserInfo();
        final String host = randomHost();
        final int port = randomOptionalPort();
        if (userInfo != null) {
            buffer.append(userInfo).append(USERINFO_SUFFIX);
        }
        buffer.append(host);
        if (port != -1) {
            buffer.append(PORT_PREFIX).append(port);
        }
        return buffer.toString();
    }

    /**
     * Returns a random registry based named authority part.
     * 
     * @return The registry based named authority part
     */
    public static @NotNull String randomRegistryBasedNamedAuthority() {
        final StringBuilder buffer = new StringBuilder();
        final int length = RandomUtils.nextInt(
                REG_NAME_MIN_LENGTH,
                REG_NAME_MAX_LENGTH + 1);
        for (int i = 0; i < length; i++) {
            if (RandomUtils.nextFloat(0, 1) < ESCAPED_CHAR_P) {
                buffer.append((char) RandomUtils.nextInt('\u0080', '\uFFFF'));
            } else {
                buffer.append(RandomStringUtils.random(1, UNESCAPED_REG_NAME_C));
            }
        }
        return buffer.toString();
    }

    /**
     * Returns a random authority part.
     * 
     * @return The authority part
     */
    public static @NotNull String randomAuthority() {
        if (RandomUtils.nextFloat(0, 1) < SERVER_P) {
            return randomServerAuthority();
        } else {
            return randomRegistryBasedNamedAuthority();
        }
    }

    /**
     * Returns a random path segment.
     * 
     * @return The path segment
     */
    protected static @NotNull String randomPathSegment() {
        final StringBuilder buffer = new StringBuilder();
        final int length = RandomUtils.nextInt(
                SEGMENT_PATH_MIN_LENGTH,
                SEGMENT_PATH_MAX_LENGTH + 1);
        buffer.append(RandomStringUtils.randomAlphabetic(1));
        for (int i = 1; i < length; i++) {
            if (RandomUtils.nextFloat(0, 1) < ESCAPED_CHAR_P) {
                buffer.append((char) RandomUtils.nextInt('\u0080', '\uFFFF'));
            } else {
                buffer.append(RandomStringUtils.random(1, UNESCAPED_PATH_C));
            }
        }
        if (RandomUtils.nextFloat(0, 1) < SEGMENT_PARAM_P) {
            buffer.append(SEGMENT_PARAM_SEPARATOR);
            final int paramLength = RandomUtils.nextInt(
                    PARAM_MIN_LENGTH,
                    PARAM_MAX_LENGTH + 1);
            for (int i = 0; i < paramLength; i++) {
                if (RandomUtils.nextFloat(0, 1) < ESCAPED_CHAR_P) {
                    buffer.append((char) RandomUtils.nextInt('\u0080', '\uFFFF'));
                } else {
                    buffer.append(RandomStringUtils.random(1, UNESCAPED_PATH_C));
                }
            }
        }
        return buffer.toString();
    }

    /**
     * Returns a random absolute path.
     * 
     * @return The absolute path
     */
    public static @NotNull String randomAbsolutePath() {
        final StringBuilder buffer = new StringBuilder();
        buffer.append(ABSOLUTE_PATH_PREFIX);
        final int length = RandomUtils.nextInt(
                PATH_SEGMENTS_MIN_SEGMENTS,
                PATH_SEGMENTS_MAX_SEGMENTS + 1);
        if (length > 0) {
            buffer.append(randomPathSegment());
            for (int i = 1; i < length; i++) {
                buffer.append(PATH_SEGMENTS_SEPARATOR).append(randomPathSegment());
            }
        }
        return buffer.toString();
    }

    /**
     * Returns a random relative path.
     * 
     * @return The relative path
     */
    public static @NotNull String randomRelativePath() {
        final StringBuilder buffer = new StringBuilder();
        final int length = RandomUtils.nextInt(
                RELATIVE_SEGMENT_MIN_LENGTH,
                RELATIVE_SEGMENT_MAX_LENGTH + 1);
        for (int i = 0; i < length; i++) {
            if (RandomUtils.nextFloat(0, 1) < ESCAPED_CHAR_P) {
                buffer.append((char) RandomUtils.nextInt('\u0080', '\uFFFF'));
            } else {
                buffer.append(RandomStringUtils.random(1, UNESCAPED_RELATIVE_SEGMENT_C));
            }
        }
        if (RandomUtils.nextFloat(0, 1) < RELATIVE_PATH_ABSOLUTE_PATH_P) {
            buffer.append(randomAbsolutePath());
        }
        return buffer.toString();
    }

    /**
     * Returns a random path part of an URI.
     * The path can be relative or absolute.
     * 
     * @return The path part
     */
    public static @NotNull String randomPath() {
        if (RandomUtils.nextFloat(0, 1) < PATH_ABSOLUTE_P) {
            return randomAbsolutePath();
        } else {
            return randomRelativePath();
        }
    }

    /**
     * Returns a random query part of an URI.
     * 
     * @return The query part
     */
    public static @NotNull String randomQuery() {
        final StringBuilder buffer = new StringBuilder();
        final int length = RandomUtils.nextInt(
                QUERY_MIN_LENGTH,
                QUERY_MAX_LENGTH + 1);
        for (int i = 0; i < length; i++) {
            if (RandomUtils.nextFloat(0, 1) < ESCAPED_CHAR_P) {
                buffer.append((char) RandomUtils.nextInt('\u0080', '\uFFFF'));
            } else {
                buffer.append(RandomStringUtils.random(1, UNESCAPED_URIC));
            }
        }
        return buffer.toString();
    }

    /**
     * Returns a random optional query part of an URI.
     * 
     * @return The query part, or {@code null}
     */
    public static String randomOptionalQuery() {
        if (RandomUtils.nextFloat(0, 1) < QUERY_P) {
            return randomQuery();
        } else {
            return null;
        }
    }

    /**
     * Returns a random fragment part of an URI.
     * 
     * @return The fragment part
     */
    public static @NotNull String randomFragment() {
        final StringBuilder buffer = new StringBuilder();
        final int length = RandomUtils.nextInt(
                FRAGMENT_MIN_LENGTH,
                FRAGMENT_MAX_LENGTH + 1);
        for (int i = 0; i < length; i++) {
            if (RandomUtils.nextFloat(0, 1) < ESCAPED_CHAR_P) {
                buffer.append((char) RandomUtils.nextInt('\u0080', '\uFFFF'));
            } else {
                buffer.append(RandomStringUtils.random(1, UNESCAPED_URIC));
            }
        }
        return buffer.toString();
    }

    /**
     * Returns a random optional fragment part of an URI.
     * 
     * @return The fragment part, or {@code null}
     */
    public static String randomOptionalFragment() {
        if (RandomUtils.nextFloat(0, 1) < FRAGMENT_P) {
            return randomFragment();
        } else {
            return null;
        }
    }

    /**
     * Returns a random absolute URI.
     * 
     * @return The absolute URI
     */
    public static @NotNull URI randomRelativeURI() {
        final String path;
        if (RandomUtils.nextFloat(0, 1) < PATH_ABSOLUTE_P) {
            path = randomAbsolutePath();
        } else {
            path = randomRelativePath();
        }
        try {
            return new URI(
                    null,
                    null,
                    path,
                    randomOptionalQuery(),
                    randomOptionalFragment());
        } catch (final URISyntaxException e) {
            throw new GenerationException("Error generating URI", e);
        }
    }

    /**
     * Returns a random absolute URI.
     * 
     * @return The absolute URI
     */
    public static @NotNull URI randomAbsoluteURI() {
        try {
            return new URI(
                    randomScheme(),
                    randomAuthority(),
                    randomAbsolutePath(),
                    randomOptionalQuery(),
                    randomOptionalFragment());
        } catch (final URISyntaxException e) {
            throw new GenerationException("Error generating URI", e);
        }
    }

    /**
     * Returns a random URI (absolute or not).
     * 
     * @return The URI
     */
    public static @NotNull URI randomURI() {
        if (RandomUtils.nextFloat(0, 1) < PATH_ABSOLUTE_P) {
            return randomAbsoluteURI();
        } else {
            return randomRelativeURI();
        }
    }
}