All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.chain.sequence.http.Client Maven / Gradle / Ivy

The newest version!
package com.chain.sequence.http;

import com.chain.sequence.exception.*;
import com.chain.sequence.common.*;

import java.io.*;
import java.lang.reflect.Type;
import java.net.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyStore;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import com.chain.sequence.http.session.ProdRefresher;
import com.chain.sequence.http.session.Refresher;
import com.google.gson.Gson;

import com.squareup.okhttp.CertificatePinner;
import com.squareup.okhttp.ConnectionPool;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;

import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;

import javax.net.ssl.*;

/**
 * The Client object encapsulates access to the ledger API server.
 */
public class Client {
  private AtomicInteger urlIndex;
  private List urls;
  private OkHttpClient httpClient;
  private String macaroon;
  private String ledgerName;
  public Refresher refresher;

  // Used to create empty, in-memory key stores.
  private static final char[] DEFAULT_KEYSTORE_PASSWORD = "password".toCharArray();
  private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
  private static String version = "dev"; // updated in the static initializer

  private static class BuildProperties {
    public String version;
  }

  static {
    InputStream in = Client.class.getClassLoader().getResourceAsStream("properties.json");
    if (in != null) {
      InputStreamReader inr = new InputStreamReader(in);
      version = Utils.serializer.fromJson(inr, BuildProperties.class).version;
    }
  }

  public Client(Builder builder) throws ConfigurationException {
    List urls = new ArrayList<>(builder.urls);
    if (urls.isEmpty()) {
      throw new ConfigurationException("No URL provided.");
    }
    if(builder.ledger == null || builder.ledger.isEmpty()) {
      throw new ConfigurationException("No ledger name provided");
    }
    if (builder.credential == null || builder.credential.isEmpty()) {
      throw new ConfigurationException("No credential provided");
    }
    this.urlIndex = new AtomicInteger(0);
    this.urls = urls;
    this.ledgerName = builder.ledger;
    this.macaroon = builder.credential;
    this.httpClient = buildHttpClient(builder);
    this.refresher = new ProdRefresher();
  }

  /**
   * Perform a single HTTP POST request against the API for a specific action.
   *
   * @param action The requested API action
   * @param body Body payload sent to the API as JSON
   * @param tClass Type of object to be deserialized from the response JSON
   * @return the result of the post request
   * @throws ChainException
   */
  public  T request(String action, Object body, final Type tClass) throws ChainException {
    ResponseCreator rc =
        new ResponseCreator() {
          public T create(Response response, Gson deserializer) throws IOException {
            return deserializer.fromJson(response.body().charStream(), tClass);
          }
        };

    if (this.refresher.needsRefresh()) {
      this.refresher.refresh(this.macaroon);
    }
    return post(action, body, rc);
  }

  /**
   * Returns the macaroon (possibly null).
   * @return the macaroon
   */
  public String macaroon() {
    return macaroon;
  }

  /**
   * Pins a public key to the HTTP client.
   * @param provider certificate provider
   * @param subjPubKeyInfoHash public key hash
   */
  public void pinCertificate(String provider, String subjPubKeyInfoHash) {
    CertificatePinner cp =
        new CertificatePinner.Builder().add(provider, subjPubKeyInfoHash).build();
    this.httpClient.setCertificatePinner(cp);
  }

  /**
   * Sets the default connect timeout for new connections. A value of 0 means no timeout.
   * @param timeout the number of time units for the default timeout
   * @param unit the unit of time
   */
  public void setConnectTimeout(long timeout, TimeUnit unit) {
    this.httpClient.setConnectTimeout(timeout, unit);
  }

  /**
   * Sets the default read timeout for new connections. A value of 0 means no timeout.
   * @param timeout the number of time units for the default timeout
   * @param unit the unit of time
   */
  public void setReadTimeout(long timeout, TimeUnit unit) {
    this.httpClient.setReadTimeout(timeout, unit);
  }

  /**
   * Sets the default write timeout for new connections. A value of 0 means no timeout.
   * @param timeout the number of time units for the default timeout
   * @param unit the unit of time
   */
  public void setWriteTimeout(long timeout, TimeUnit unit) {
    this.httpClient.setWriteTimeout(timeout, unit);
  }

  /**
   * Sets the proxy information for the HTTP client.
   * @param proxy proxy object
   */
  public void setProxy(Proxy proxy) {
    this.httpClient.setProxy(proxy);
  }

  /**
   * Defines an interface for deserializing HTTP responses into objects.
   * @param  the type of object to return
   */
  public interface ResponseCreator {
    /**
     * Deserializes an HTTP response into a Java object of type T.
     * @param response HTTP response object
     * @param deserializer json deserializer
     * @return an object of type T
     * @throws ChainException
     * @throws IOException
     */
    T create(Response response, Gson deserializer) throws ChainException, IOException;
  }
  /**
   * Builds and executes an HTTP Post request.
   * @param path the path to the endpoint
   * @param body the request body
   * @param respCreator object specifying the response structure
   * @return a response deserialized into type T
   * @throws ChainException
   */
  private  T post(String path, Object body, ResponseCreator respCreator)
      throws ChainException {
    RequestBody requestBody = RequestBody.create(this.JSON, Utils.serializer.toJson(body));

    ChainException exception = null;
    for (int attempt = 1; attempt - 1 <= MAX_RETRIES; attempt++) {

      int idx = this.urlIndex.get();
      URL endpointURL;
      try {
        String urlParts = this.urls.get(idx % this.urls.size()).toString();
        urlParts += "/" + this.refresher.teamName();
        urlParts += "/" + this.ledgerName;
        urlParts += "/" + path;
        endpointURL = new URL(urlParts);
      } catch (MalformedURLException ex) {
        throw new BadURLException(ex.getMessage());
      }

      Request req =
          new Request.Builder()
              .header("User-Agent", "sequence-sdk-java/" + version)
              .header("Macaroon", this.macaroon)
              .header("Discharge-Macaroon", this.refresher.dischargeMacaroon())
              .url(endpointURL)
              .method("POST", requestBody)
              .build();

      // Wait between retrys. The first attempt will not wait at all.
      if (attempt > 1) {
        int delayMillis = retryDelayMillis(attempt - 1);
        try {
          TimeUnit.MILLISECONDS.sleep(delayMillis);
        } catch (InterruptedException e) {
        }
      }

      try {
        Response resp = this.checkError(this.httpClient.newCall(req).execute());
        return respCreator.create(resp, Utils.serializer);
      } catch (IOException ex) {
        // This URL's process might be unhealthy; move to the next.
        this.nextURL(idx);

        // The OkHttp library already performs retries for some
        // I/O-related errors, but we've hit this case in a leader
        // failover, so do our own retries too.
        exception = new ConfigurationException(ex.getMessage());
      } catch (ConnectivityException ex) {
        // This URL's process might be unhealthy; move to the next.
        this.nextURL(idx);

        // ConnectivityExceptions are always retriable.
        exception = ex;
      } catch (APIException ex) {
        // Check if this error is retriable (either it's a status code that's
        // always retriable or the error is explicitly marked as temporary.
        if (!isRetriableStatusCode(ex.statusCode) && !ex.temporary) {
          throw ex;
        }

        // This URL's process might be unhealthy; move to the next.
        this.nextURL(idx);
        exception = ex;
      }
    }
    throw exception;
  }

  private OkHttpClient buildHttpClient(Builder builder) throws ConfigurationException {
    OkHttpClient httpClient = builder.baseHttpClient.clone();

    try {
      if (builder.trustManagers != null) {
        SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
        sslContext.init(builder.keyManagers, builder.trustManagers, null);
        httpClient.setSslSocketFactory(sslContext.getSocketFactory());
      }
    } catch (GeneralSecurityException ex) {
      throw new ConfigurationException("Unable to configure TLS", ex);
    }
    if (builder.readTimeoutUnit != null) {
      httpClient.setReadTimeout(builder.readTimeout, builder.readTimeoutUnit);
    }
    if (builder.writeTimeoutUnit != null) {
      httpClient.setWriteTimeout(builder.writeTimeout, builder.writeTimeoutUnit);
    }
    if (builder.connectTimeoutUnit != null) {
      httpClient.setConnectTimeout(builder.connectTimeout, builder.connectTimeoutUnit);
    }
    if (builder.pool != null) {
      httpClient.setConnectionPool(builder.pool);
    }
    if (builder.proxy != null) {
      httpClient.setProxy(builder.proxy);
    }
    if (builder.cp != null) {
      httpClient.setCertificatePinner(builder.cp);
    }
    if (builder.logger != null) {
      httpClient.interceptors().add(new LoggingInterceptor(builder.logger, builder.logLevel));
    }

    return httpClient;
  }

  private static final Random randomGenerator = new Random();
  private static final int MAX_RETRIES = 10;
  private static final int RETRY_BASE_DELAY_MILLIS = 40;

  // the max amount of time cored leader election could take
  private static final int RETRY_MAX_DELAY_MILLIS = 15000;

  private static int retryDelayMillis(int retryAttempt) {
    // Calculate the max delay as base * 2 ^ (retryAttempt - 1).
    int max = RETRY_BASE_DELAY_MILLIS * (1 << (retryAttempt - 1));
    max = Math.min(max, RETRY_MAX_DELAY_MILLIS);

    // To incorporate jitter, use a pseudo random delay between [max/2, max] millis.
    return randomGenerator.nextInt(max / 2) + max / 2 + 1;
  }

  private static final int[] RETRIABLE_STATUS_CODES = {
    408, // Request Timeout
    429, // Too Many Requests
    500, // Internal Server Error
    502, // Bad Gateway
    503, // Service Unavailable
    504, // Gateway Timeout
    509, // Bandwidth Limit Exceeded
  };

  private static boolean isRetriableStatusCode(int statusCode) {
    for (int i = 0; i < RETRIABLE_STATUS_CODES.length; i++) {
      if (RETRIABLE_STATUS_CODES[i] == statusCode) {
        return true;
      }
    }
    return false;
  }

  private Response checkError(Response response) throws ChainException {
    String rid = response.headers().get("Chain-Request-ID");
    if (rid == null || rid.length() == 0) {
      // Header field Chain-Request-ID is set by the backend
      // API server. If this field is set, then we can expect
      // the body to be well-formed JSON. If it's not set,
      // then we are probably talking to a gateway or proxy.
      throw new ConnectivityException(response);
    }

    if ((response.code() / 100) != 2) {
      try {
        APIException err =
            Utils.serializer.fromJson(response.body().charStream(), APIException.class);
        if (err.code != null) {
          err.requestId = rid;
          err.statusCode = response.code();
          throw err;
        }
      } catch (IOException ex) {
        throw new JSONException("Unable to read body. " + ex.getMessage(), rid);
      }
    }
    return response;
  }

  private void nextURL(int failedIndex) {
    if (this.urls.size() == 1) {
      return; // No point contending on the CAS if there's only one URL.
    }

    // A request to the url at failedIndex just failed. Move to the next
    // URL in the items.
    int nextIndex = failedIndex + 1;
    this.urlIndex.compareAndSet(failedIndex, nextIndex);
  }

  /**
   * Overrides {@link Object#hashCode()}
   * @return the hash code
   */
  @Override
  public int hashCode() {
    int code = this.urls.hashCode();
    if (this.macaroon != null) {
      code = code * 31 + this.macaroon.hashCode();
    }
    return code;
  }

  /**
   * Overrides {@link Object#equals(Object)}
   * @param o the object to compare
   * @return a boolean specifying equality
   */
  @Override
  public boolean equals(Object o) {
    if (o == null) return false;
    if (!(o instanceof Client)) return false;

    Client other = (Client) o;
    if (!this.urls.equals(other.urls)) {
      return false;
    }
    return Objects.equals(this.macaroon, other.macaroon);
  }

  /**
   * A builder class for creating client objects
   */
  public static class Builder {
    private OkHttpClient baseHttpClient;
    private List urls;
    private String credential;
    private String ledger;
    private CertificatePinner cp;
    private KeyManager[] keyManagers;
    private TrustManager[] trustManagers;
    private long connectTimeout;
    private TimeUnit connectTimeoutUnit;
    private long readTimeout;
    private TimeUnit readTimeoutUnit;
    private long writeTimeout;
    private TimeUnit writeTimeoutUnit;
    private Proxy proxy;
    private ConnectionPool pool;
    private OutputStream logger;
    private LoggingInterceptor.Level logLevel;

    public Builder() {
      this.baseHttpClient = new OkHttpClient();
      this.baseHttpClient.setFollowRedirects(false);
      this.urls = new ArrayList<>();
      this.setDefaults();
    }

    private void setDefaults() {
      try {
        this.setURL("https://api.seq.com");
      } catch (BadURLException ex) {
        //hard coded
      }

      this.setReadTimeout(30, TimeUnit.SECONDS);
      this.setWriteTimeout(30, TimeUnit.SECONDS);
      this.setConnectTimeout(30, TimeUnit.SECONDS);
      this.setConnectionPool(50, 2, TimeUnit.MINUTES);
      this.logLevel = LoggingInterceptor.Level.ERRORS;
    }

    /**
     * Sets a URL for the client to use.
     * @param url the URL of the Chain Core or HSM.
     */
    public Builder setURL(String url) throws BadURLException {
      try {
        this.urls = new ArrayList(Arrays.asList(new URL(url)));
      } catch (MalformedURLException e) {
        throw new BadURLException(e.getMessage());
      }
      return this;
    }

    /**
     * Sets the credential for the client
     * @param credential The access token for the Chain Core or HSM
     */
    public Builder setCredential(String credential) {
      this.credential = credential;
      return this;
    }

    /**
     * Sets the ledger name for the client
     * @param ledger The access token for the Chain Core or HSM
     */
    public Builder setLedger(String ledger) {
      this.ledger = ledger;
      return this;
    }

    /**
     * Sets the client's certificate and key for TLS client authentication.
     * PEM-encoded, ECDSA or RSA private keys adhering to PKCS#1 or PKCS#8
     * are supported.
     * @param certStream input stream of PEM-encoded X.509 certificate
     * @param keyStream input stream of PEM-encoded private key
     */
    public Builder setX509KeyPair(InputStream certStream, InputStream keyStream)
        throws ConfigurationException {
      try (PEMParser parser = new PEMParser(new InputStreamReader(keyStream))) {
        // Extract certs from PEM-encoded input.
        CertificateFactory factory = CertificateFactory.getInstance("X.509");
        X509Certificate certificate = (X509Certificate) factory.generateCertificate(certStream);
        Security.addProvider(new BouncyCastleProvider());

        // TODO(vniu): dynamic support for ECDSA and RSA
        // Parse the private key from PEM-encoded input.
        Object obj = parser.readObject();
        PrivateKey key = null;
        while (obj != null) {
          if (obj instanceof PEMKeyPair) {
            // PKCS#1 Private Key found.
            PEMKeyPair kp = (PEMKeyPair) obj;
            key = getPrivateKey(kp.getPrivateKeyInfo().getEncoded());
            break;
          } else if (obj instanceof PrivateKeyInfo) {
            // PKCS#8 Private Key found.
            PrivateKeyInfo info = (PrivateKeyInfo) obj;
            key = getPrivateKey(info.getEncoded());
            break;
          } else {
            obj = parser.readObject();
          }
        }
        if (key == null) {
          throw new ConfigurationException("Unsupported private key provided");
        }
        // Create a new key store and input the pair.
        KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
        keyStore.load(null, DEFAULT_KEYSTORE_PASSWORD);
        keyStore.setCertificateEntry("cert", certificate);
        keyStore.setKeyEntry(
            "key", key, DEFAULT_KEYSTORE_PASSWORD, new X509Certificate[] {certificate});

        // Use key store to build a key manager.
        KeyManagerFactory keyManagerFactory =
            KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        keyManagerFactory.init(keyStore, DEFAULT_KEYSTORE_PASSWORD);

        this.keyManagers = keyManagerFactory.getKeyManagers();
        return this;
      } catch (GeneralSecurityException | IOException ex) {
        throw new ConfigurationException("Unable to store X.509 cert/key pair", ex);
      }
    }

    /**
     * Retrieves the correct private key by trying all supported algorithms. ECDSA and RSA
     * keys are currently supported.
     * @param encodedKey ASN1 encoded private key
     */
    private PrivateKey getPrivateKey(byte[] encodedKey) throws NoSuchAlgorithmException {
      for (String algorithm : new String[] {"RSA", "ECDSA"}) {
        try {
          return KeyFactory.getInstance(algorithm)
              .generatePrivate(new PKCS8EncodedKeySpec(encodedKey));
        } catch (InvalidKeySpecException ignore) {
        }
      }
      return null;
    }

    /**
     * Sets the client's certificate and key for TLS client authentication.
     * @param certPath file path to PEM-encoded X.509 certificate
     * @param keyPath file path to PEM-encoded private key
     */
    public Builder setX509KeyPair(String certPath, String keyPath) throws ConfigurationException {
      try (InputStream certStream =
              new ByteArrayInputStream(Files.readAllBytes(Paths.get(certPath)));
          InputStream keyStream =
              new ByteArrayInputStream(Files.readAllBytes(Paths.get(keyPath)))) {
        return setX509KeyPair(certStream, keyStream);
      } catch (IOException ex) {
        throw new ConfigurationException("Unable to store X509 cert/key pair", ex);
      }
    }

    /**
     * Trusts the given CA certs, and no others. Use this if you are running
     * your own CA, or are using a self-signed server certificate.
     * @param is input stream of the certificates to trust, in PEM format.
     */
    public Builder setTrustedCerts(InputStream is) throws ConfigurationException {
      try {
        // Extract certs from PEM-encoded input.
        CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
        Collection certificates =
            certificateFactory.generateCertificates(is);
        if (certificates.isEmpty()) {
          throw new IllegalArgumentException("expected non-empty set of trusted certificates");
        }

        // Create a new key store and input the cert.
        KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
        keyStore.load(null, DEFAULT_KEYSTORE_PASSWORD);
        int index = 0;
        for (Certificate certificate : certificates) {
          String certificateAlias = Integer.toString(index++);
          keyStore.setCertificateEntry(certificateAlias, certificate);
        }

        // Use key store to build an X509 trust manager.
        KeyManagerFactory keyManagerFactory =
            KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        keyManagerFactory.init(keyStore, DEFAULT_KEYSTORE_PASSWORD);
        TrustManagerFactory trustManagerFactory =
            TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        trustManagerFactory.init(keyStore);
        TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
        if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) {
          throw new IllegalStateException(
              "Unexpected default trust managers:" + Arrays.toString(trustManagers));
        }

        this.trustManagers = trustManagers;
        return this;
      } catch (GeneralSecurityException | IOException ex) {
        throw new ConfigurationException("Unable to configure trusted CA certs", ex);
      }
    }

    /**
     * Trusts the given CA certs, and no others. Use this if you are running
     * your own CA, or are using a self-signed server certificate.
     * @param path The path of a file containing certificates to trust, in PEM format.
     */
    public Builder setTrustedCerts(String path) throws ConfigurationException {
      try (InputStream is = new FileInputStream(path)) {
        return setTrustedCerts(is);
      } catch (IOException ex) {
        throw new ConfigurationException("Unable to configure trusted CA certs", ex);
      }
    }

    /**
     * Sets the certificate pinner for the client
     * @param provider certificate provider
     * @param subjPubKeyInfoHash public key hash
     */
    public Builder pinCertificate(String provider, String subjPubKeyInfoHash) {
      this.cp = new CertificatePinner.Builder().add(provider, subjPubKeyInfoHash).build();
      return this;
    }

    /**
     * Sets the connect timeout for the client
     * @param timeout the number of time units for the default timeout
     * @param unit the unit of time
     */
    public Builder setConnectTimeout(long timeout, TimeUnit unit) {
      this.connectTimeout = timeout;
      this.connectTimeoutUnit = unit;
      return this;
    }

    /**
     * Sets the read timeout for the client
     * @param timeout the number of time units for the default timeout
     * @param unit the unit of time
     */
    public Builder setReadTimeout(long timeout, TimeUnit unit) {
      this.readTimeout = timeout;
      this.readTimeoutUnit = unit;
      return this;
    }

    /**
     * Sets the write timeout for the client
     * @param timeout the number of time units for the default timeout
     * @param unit the unit of time
     */
    public Builder setWriteTimeout(long timeout, TimeUnit unit) {
      this.writeTimeout = timeout;
      this.writeTimeoutUnit = unit;
      return this;
    }

    /**
     * Sets the proxy for the client
     * @param proxy
     */
    public Builder setProxy(Proxy proxy) {
      this.proxy = proxy;
      return this;
    }

    /**
     * Sets the connection pool for the client
     * @param maxIdle the maximum number of idle http connections in the pool
     * @param timeout the number of time units until an idle http connection in the pool is closed
     * @param unit the unit of time
     */
    public Builder setConnectionPool(int maxIdle, long timeout, TimeUnit unit) {
      this.pool = new ConnectionPool(maxIdle, unit.toMillis(timeout));
      return this;
    }

    /**
     * Sets the request logger.
     * @param logger the output stream to log the requests to
     */
    public Builder setLogger(OutputStream logger) {
      this.logger = logger;
      return this;
    }

    /**
     * Sets the level of the request logger.
     * @param level all, errors or none
     */
    public Builder setLogLevel(LoggingInterceptor.Level level) {
      this.logLevel = level;
      return this;
    }

    /**
     * Builds a client with all of the provided parameters.
     */
    public Client build() throws ConfigurationException {
      return new Client(this);
    }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy