ClientOptions.java

package com.shengzhiai.yugu;

import com.shengzhiai.yugu.errors.ErrorTable;
import com.shengzhiai.yugu.errors.YuguErrors;
import com.shengzhiai.yugu.internal.Redact;
import com.shengzhiai.yugu.internal.WsTransport;

import java.net.URI;
import java.util.Locale;
import java.util.Random;

/**
 * Immutable client configuration. Build it with {@link #builder()} or directly through
 * {@link YuguClient#builder()}.
 *
 * <table>
 *   <caption>Defaults</caption>
 *   <tr><th>Option</th><th>Default</th></tr>
 *   <tr><td>baseUrl</td><td>{@code https://open.shengzhiai.com}</td></tr>
 *   <tr><td>wsBaseUrl</td><td>{@code wss://open.shengzhiai.com}, or derived from baseUrl when only baseUrl is set</td></tr>
 *   <tr><td>connectTimeoutMs</td><td>10000</td></tr>
 *   <tr><td>readTimeoutMs</td><td>120000 per attempt</td></tr>
 *   <tr><td>totalTimeoutMs</td><td>300000 per call including retries</td></tr>
 *   <tr><td>retry</td><td>{@link RetryPolicy#defaults()}</td></tr>
 *   <tr><td>autoIdempotencyKey</td><td>true</td></tr>
 *   <tr><td>logLevel</td><td>WARN</td></tr>
 *   <tr><td>logger</td><td>standard error</td></tr>
 *   <tr><td>audioPrecheck</td><td>WARN</td></tr>
 *   <tr><td>userAgent</td><td>{@code yugu-java-sdk/2.1.0}</td></tr>
 *   <tr><td>reconnect</td><td>{@link ReconnectPolicy#defaults()}</td></tr>
 *   <tr><td>audioBufferPolicy</td><td>REPLAY</td></tr>
 *   <tr><td>pingIntervalMs, pongTimeoutMs</td><td>15000, 30000</td></tr>
 *   <tr><td>resultTimeoutMs</td><td>300000 after end()</td></tr>
 * </table>
 */
public final class ClientOptions {
    /** Default REST base. */
    public static final String DEFAULT_BASE_URL = "https://open.shengzhiai.com";
    /** Default WebSocket base. */
    public static final String DEFAULT_WS_BASE_URL = "wss://open.shengzhiai.com";

    private final String baseUrl;
    private final String wsBaseUrl;
    private final String token;
    private final String appKey;
    private final String secretKey;
    private final long connectTimeoutMs;
    private final long readTimeoutMs;
    private final long totalTimeoutMs;
    private final RetryPolicy retry;
    private final boolean autoIdempotencyKey;
    private final LogLevel logLevel;
    private final YuguLogger logger;
    private final EventListener eventListener;
    private final AudioPrecheckMode audioPrecheck;
    private final boolean strictAudio;
    private final String userAgent;
    private final ReconnectPolicy reconnect;
    private final AudioBufferPolicy audioBufferPolicy;
    private final long pingIntervalMs;
    private final long pongTimeoutMs;
    private final long resultTimeoutMs;
    private final WsTransport transport;
    private final Random random;

    private ClientOptions(BaseBuilder<?> b) {
        boolean hasToken = b.token != null && !b.token.isEmpty();
        boolean hasKey = b.appKey != null && !b.appKey.isEmpty() && b.secretKey != null && !b.secretKey.isEmpty();
        if (!hasToken && !hasKey) {
            throw invalid("credentials missing: set token(...) or apiKey(appKey, secretKey)");
        }
        this.baseUrl = stripSlash(checkUrl(b.baseUrl == null ? DEFAULT_BASE_URL : b.baseUrl, "baseUrl", "http", "https"));
        String ws = b.wsBaseUrl;
        if (ws == null) {
            ws = b.baseUrl == null ? DEFAULT_WS_BASE_URL : deriveWs(this.baseUrl);
        }
        this.wsBaseUrl = stripSlash(checkUrl(ws, "wsBaseUrl", "ws", "wss"));
        this.token = hasKey ? null : b.token;
        this.appKey = hasKey ? b.appKey : null;
        this.secretKey = hasKey ? b.secretKey : null;
        this.connectTimeoutMs = positive(b.connectTimeoutMs, "connectTimeoutMs");
        this.readTimeoutMs = positive(b.readTimeoutMs, "readTimeoutMs");
        this.totalTimeoutMs = positive(b.totalTimeoutMs, "totalTimeoutMs");
        this.retry = b.retry == null ? RetryPolicy.defaults() : b.retry;
        this.autoIdempotencyKey = b.autoIdempotencyKey;
        this.logLevel = b.logLevel == null ? LogLevel.WARN : b.logLevel;
        this.logger = b.logger;
        this.eventListener = b.eventListener;
        this.audioPrecheck = b.audioPrecheck == null ? AudioPrecheckMode.WARN : b.audioPrecheck;
        this.strictAudio = b.strictAudio;
        this.userAgent = b.userAgent == null || b.userAgent.isEmpty() ? YuguClient.USER_AGENT : b.userAgent;
        this.reconnect = b.reconnect == null ? ReconnectPolicy.defaults() : b.reconnect;
        this.audioBufferPolicy = b.audioBufferPolicy == null ? AudioBufferPolicy.REPLAY : b.audioBufferPolicy;
        this.pingIntervalMs = positive(b.pingIntervalMs, "pingIntervalMs");
        this.pongTimeoutMs = positive(b.pongTimeoutMs, "pongTimeoutMs");
        this.resultTimeoutMs = positive(b.resultTimeoutMs, "resultTimeoutMs");
        this.transport = b.transport;
        this.random = b.random;
    }

    private static RuntimeException invalid(String message) {
        return YuguErrors.local(ErrorTable.INVALID_ARGUMENT, message, null);
    }

    private static long positive(long v, String name) {
        if (v <= 0) {
            throw invalid(name + " must be > 0, got " + v);
        }
        return v;
    }

    private static String checkUrl(String url, String name, String... schemes) {
        try {
            URI u = new URI(url);
            String s = u.getScheme() == null ? "" : u.getScheme().toLowerCase(Locale.ROOT);
            for (String ok : schemes) {
                if (ok.equals(s) && u.getHost() != null) {
                    return url;
                }
            }
        } catch (java.net.URISyntaxException e) {
            throw invalid(name + " is not a valid URL: " + url);
        }
        throw invalid(name + " must use " + String.join(" or ", schemes) + ": " + url);
    }

    private static String deriveWs(String base) {
        if (base.regionMatches(true, 0, "https://", 0, 8)) {
            return "wss://" + base.substring(8);
        }
        return "ws://" + base.substring(base.indexOf("://") + 3);
    }

    private static String stripSlash(String s) {
        String r = s;
        while (r.endsWith("/")) {
            r = r.substring(0, r.length() - 1);
        }
        return r;
    }

    /** @return a builder with the defaults */
    public static Builder builder() {
        return new Builder();
    }

    /** @return a builder initialised with these options */
    public Builder toBuilder() {
        Builder b = new Builder();
        b.copyFrom(this);
        return b;
    }

    /** @return REST base without trailing slash */
    public String getBaseUrl() {
        return baseUrl;
    }

    /** @return WebSocket base without trailing slash */
    public String getWsBaseUrl() {
        return wsBaseUrl;
    }

    /** @return true for token (JWT) authentication */
    public boolean isTokenAuth() {
        return token != null;
    }

    /** @return app key, null for token authentication */
    public String getAppKey() {
        return appKey;
    }

    /** @return JWT, null for key authentication; SDK internal */
    String getToken() {
        return token;
    }

    /** @return secret key; SDK internal */
    String getSecretKey() {
        return secretKey;
    }

    /** @return connect timeout */
    public long getConnectTimeoutMs() {
        return connectTimeoutMs;
    }

    /** @return read timeout per attempt */
    public long getReadTimeoutMs() {
        return readTimeoutMs;
    }

    /** @return deadline per call */
    public long getTotalTimeoutMs() {
        return totalTimeoutMs;
    }

    /** @return retry policy */
    public RetryPolicy getRetry() {
        return retry;
    }

    /** @return whether write calls get a generated idempotency key */
    public boolean isAutoIdempotencyKey() {
        return autoIdempotencyKey;
    }

    /** @return log level */
    public LogLevel getLogLevel() {
        return logLevel;
    }

    /** @return logger, null for standard error */
    public YuguLogger getLogger() {
        return logger;
    }

    /** @return metrics listener, may be null */
    public EventListener getEventListener() {
        return eventListener;
    }

    /** @return precheck mode */
    public AudioPrecheckMode getAudioPrecheck() {
        return audioPrecheck;
    }

    /** @return whether warning 1001 in a result is thrown as {@code AudioQualityException} */
    public boolean isStrictAudio() {
        return strictAudio;
    }

    /** @return User-Agent */
    public String getUserAgent() {
        return userAgent;
    }

    /** @return reconnect policy of streams */
    public ReconnectPolicy getReconnect() {
        return reconnect;
    }

    /** @return buffer policy of streams */
    public AudioBufferPolicy getAudioBufferPolicy() {
        return audioBufferPolicy;
    }

    /** @return protocol ping interval */
    public long getPingIntervalMs() {
        return pingIntervalMs;
    }

    /** @return pong timeout after which the connection counts as lost */
    public long getPongTimeoutMs() {
        return pongTimeoutMs;
    }

    /** @return wait for the final result after end() */
    public long getResultTimeoutMs() {
        return resultTimeoutMs;
    }

    WsTransport getTransport() {
        return transport;
    }

    Random getRandom() {
        return random;
    }

    @Override
    public String toString() {
        return "ClientOptions{baseUrl=" + baseUrl + ", wsBaseUrl=" + wsBaseUrl
                + ", auth=" + (token != null ? "token(***)" : "appKey(" + Redact.appKey(appKey) + ")")
                + ", connectTimeoutMs=" + connectTimeoutMs + ", readTimeoutMs=" + readTimeoutMs
                + ", totalTimeoutMs=" + totalTimeoutMs + ", retry=" + retry + ", autoIdempotencyKey=" + autoIdempotencyKey
                + ", logLevel=" + logLevel + ", audioPrecheck=" + audioPrecheck + ", strictAudio=" + strictAudio
                + ", userAgent=" + userAgent + ", reconnect=" + reconnect + ", audioBufferPolicy=" + audioBufferPolicy
                + ", pingIntervalMs=" + pingIntervalMs + ", pongTimeoutMs=" + pongTimeoutMs
                + ", resultTimeoutMs=" + resultTimeoutMs + '}';
    }

    /** Builder that produces {@link ClientOptions}. */
    public static final class Builder extends BaseBuilder<Builder> {
        Builder() {
        }

        /** @return the options */
        public ClientOptions build() {
            return buildOptions();
        }
    }

    /**
     * Setters shared by {@link ClientOptions.Builder} and {@link YuguClient.Builder}.
     *
     * @param <B> concrete builder type
     */
    public abstract static class BaseBuilder<B extends BaseBuilder<B>> {
        private String baseUrl;
        private String wsBaseUrl;
        private String token;
        private String appKey;
        private String secretKey;
        private long connectTimeoutMs = 10_000;
        private long readTimeoutMs = 120_000;
        private long totalTimeoutMs = 300_000;
        private RetryPolicy retry;
        private boolean autoIdempotencyKey = true;
        private LogLevel logLevel;
        private YuguLogger logger;
        private EventListener eventListener;
        private AudioPrecheckMode audioPrecheck;
        private boolean strictAudio;
        private String userAgent;
        private ReconnectPolicy reconnect;
        private AudioBufferPolicy audioBufferPolicy;
        private long pingIntervalMs = 15_000;
        private long pongTimeoutMs = 30_000;
        private long resultTimeoutMs = 300_000;
        private WsTransport transport;
        private Random random;

        BaseBuilder() {
        }

        @SuppressWarnings("unchecked")
        private B self() {
            return (B) this;
        }

        void copyFrom(ClientOptions o) {
            baseUrl = o.baseUrl;
            wsBaseUrl = o.wsBaseUrl;
            token = o.token;
            appKey = o.appKey;
            secretKey = o.secretKey;
            connectTimeoutMs = o.connectTimeoutMs;
            readTimeoutMs = o.readTimeoutMs;
            totalTimeoutMs = o.totalTimeoutMs;
            retry = o.retry;
            autoIdempotencyKey = o.autoIdempotencyKey;
            logLevel = o.logLevel;
            logger = o.logger;
            eventListener = o.eventListener;
            audioPrecheck = o.audioPrecheck;
            strictAudio = o.strictAudio;
            userAgent = o.userAgent;
            reconnect = o.reconnect;
            audioBufferPolicy = o.audioBufferPolicy;
            pingIntervalMs = o.pingIntervalMs;
            pongTimeoutMs = o.pongTimeoutMs;
            resultTimeoutMs = o.resultTimeoutMs;
            transport = o.transport;
            random = o.random;
        }

        ClientOptions buildOptions() {
            return new ClientOptions(this);
        }

        /**
         * REST base, default {@code https://open.shengzhiai.com}. When {@link #wsBaseUrl(String)} is not
         * set, the WebSocket base is derived from it (http to ws, https to wss).
         *
         * @param v URL
         * @return this
         */
        public B baseUrl(String v) {
            this.baseUrl = v;
            return self();
        }

        /** @param v WebSocket base, default {@code wss://open.shengzhiai.com} @return this */
        public B wsBaseUrl(String v) {
            this.wsBaseUrl = v;
            return self();
        }

        /** @param v JWT for Bearer authentication @return this */
        public B token(String v) {
            this.token = v;
            return self();
        }

        /**
         * Key pair for signature authentication, recommended for servers.
         *
         * @param appKey    app key
         * @param secretKey secret key, never sent or logged
         * @return this
         */
        public B apiKey(String appKey, String secretKey) {
            this.appKey = appKey;
            this.secretKey = secretKey;
            return self();
        }

        /** @param v connect timeout in ms, default 10000 @return this */
        public B connectTimeoutMs(long v) {
            this.connectTimeoutMs = v;
            return self();
        }

        /** @param v read timeout per attempt in ms, default 120000 @return this */
        public B readTimeoutMs(long v) {
            this.readTimeoutMs = v;
            return self();
        }

        /** @param v deadline per call including retries in ms, default 300000 @return this */
        public B totalTimeoutMs(long v) {
            this.totalTimeoutMs = v;
            return self();
        }

        /** @param v retry policy, default {@link RetryPolicy#defaults()} @return this */
        public B retry(RetryPolicy v) {
            this.retry = v;
            return self();
        }

        /**
         * Generate an idempotency key for write calls without one, default true. When false and the
         * caller passes no key, write calls are not retried.
         *
         * @param v flag
         * @return this
         */
        public B autoIdempotencyKey(boolean v) {
            this.autoIdempotencyKey = v;
            return self();
        }

        /** @param v log level, default WARN @return this */
        public B logLevel(LogLevel v) {
            this.logLevel = v;
            return self();
        }

        /** @param v log sink, default standard error @return this */
        public B logger(YuguLogger v) {
            this.logger = v;
            return self();
        }

        /** @param v metrics listener @return this */
        public B eventListener(EventListener v) {
            this.eventListener = v;
            return self();
        }

        /** @param v audio precheck mode, default WARN @return this */
        public B audioPrecheck(AudioPrecheckMode v) {
            this.audioPrecheck = v;
            return self();
        }

        /**
         * Throw {@code AudioQualityException} when the platform reports warning 1001 (no valid audio)
         * instead of returning the result, default false.
         *
         * @param v flag
         * @return this
         */
        public B strictAudio(boolean v) {
            this.strictAudio = v;
            return self();
        }

        /** @param v User-Agent, default {@code yugu-java-sdk/2.1.0} @return this */
        public B userAgent(String v) {
            this.userAgent = v;
            return self();
        }

        /** @param v reconnect policy of streams @return this */
        public B reconnect(ReconnectPolicy v) {
            this.reconnect = v;
            return self();
        }

        /** @param v buffer policy of streams, default REPLAY @return this */
        public B audioBufferPolicy(AudioBufferPolicy v) {
            this.audioBufferPolicy = v;
            return self();
        }

        /** @param v protocol ping interval in ms, default 15000 @return this */
        public B pingIntervalMs(long v) {
            this.pingIntervalMs = v;
            return self();
        }

        /** @param v pong timeout in ms, default 30000 @return this */
        public B pongTimeoutMs(long v) {
            this.pongTimeoutMs = v;
            return self();
        }

        /** @param v wait for the final result after end() in ms, default 300000 @return this */
        public B resultTimeoutMs(long v) {
            this.resultTimeoutMs = v;
            return self();
        }

        /** Test hook: replaces the WebSocket transport. */
        B transport(WsTransport v) {
            this.transport = v;
            return self();
        }

        /** Test hook: random source of the backoff jitter. */
        B random(Random v) {
            this.random = v;
            return self();
        }
    }
}