/**
 * The MIT License
 *
 * <p>Copyright (c) 2013-2020 Jeevanandam M. ([email protected])
 *
 * <p>Permission is hereby granted, free of charge, to any person obtaining a copy of this software
 * and associated documentation files (the "Software"), to deal in the Software without restriction,
 * including without limitation the rights to use, copy, modify, merge, publish, distribute,
 * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * <p>The above copyright notice and this permission notice shall be included in all copies or
 * substantial portions of the Software.
 *
 * <p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
 * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
 * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */
package com.myjeeva.digitalocean.impl;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import com.myjeeva.digitalocean.DigitalOcean;
import com.myjeeva.digitalocean.common.ActionType;
import com.myjeeva.digitalocean.common.ApiAction;
import com.myjeeva.digitalocean.common.Constants;
import com.myjeeva.digitalocean.common.RequestMethod;
import com.myjeeva.digitalocean.exception.DigitalOceanException;
import com.myjeeva.digitalocean.exception.RequestUnsuccessfulException;
import com.myjeeva.digitalocean.http.client.methods.CustomHttpDelete;
import com.myjeeva.digitalocean.pojo.Account;
import com.myjeeva.digitalocean.pojo.Action;
import com.myjeeva.digitalocean.pojo.Actions;
import com.myjeeva.digitalocean.pojo.Backups;
import com.myjeeva.digitalocean.pojo.Certificate;
import com.myjeeva.digitalocean.pojo.Certificates;
import com.myjeeva.digitalocean.pojo.Delete;
import com.myjeeva.digitalocean.pojo.Domain;
import com.myjeeva.digitalocean.pojo.DomainRecord;
import com.myjeeva.digitalocean.pojo.DomainRecords;
import com.myjeeva.digitalocean.pojo.Domains;
import com.myjeeva.digitalocean.pojo.Droplet;
import com.myjeeva.digitalocean.pojo.DropletAction;
import com.myjeeva.digitalocean.pojo.Droplets;
import com.myjeeva.digitalocean.pojo.Firewall;
import com.myjeeva.digitalocean.pojo.Firewalls;
import com.myjeeva.digitalocean.pojo.FloatingIP;
import com.myjeeva.digitalocean.pojo.FloatingIPAction;
import com.myjeeva.digitalocean.pojo.FloatingIPs;
import com.myjeeva.digitalocean.pojo.ForwardingRules;
import com.myjeeva.digitalocean.pojo.HealthCheck;
import com.myjeeva.digitalocean.pojo.Image;
import com.myjeeva.digitalocean.pojo.ImageAction;
import com.myjeeva.digitalocean.pojo.Images;
import com.myjeeva.digitalocean.pojo.Kernels;
import com.myjeeva.digitalocean.pojo.Key;
import com.myjeeva.digitalocean.pojo.Keys;
import com.myjeeva.digitalocean.pojo.LoadBalancer;
import com.myjeeva.digitalocean.pojo.LoadBalancers;
import com.myjeeva.digitalocean.pojo.Neighbors;
import com.myjeeva.digitalocean.pojo.Project;
import com.myjeeva.digitalocean.pojo.Projects;
import com.myjeeva.digitalocean.pojo.Regions;
import com.myjeeva.digitalocean.pojo.Resource;
import com.myjeeva.digitalocean.pojo.Resources;
import com.myjeeva.digitalocean.pojo.Response;
import com.myjeeva.digitalocean.pojo.Sizes;
import com.myjeeva.digitalocean.pojo.Snapshot;
import com.myjeeva.digitalocean.pojo.Snapshots;
import com.myjeeva.digitalocean.pojo.Tag;
import com.myjeeva.digitalocean.pojo.Tags;
import com.myjeeva.digitalocean.pojo.Volume;
import com.myjeeva.digitalocean.pojo.VolumeAction;
import com.myjeeva.digitalocean.pojo.Volumes;
import com.myjeeva.digitalocean.serializer.DropletSerializer;
import com.myjeeva.digitalocean.serializer.FirewallSerializer;
import com.myjeeva.digitalocean.serializer.LoadBalancerSerializer;
import com.myjeeva.digitalocean.serializer.VolumeSerializer;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.ParseException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPatch;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicHeader;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * DigitalOcean API client wrapper methods Implementation
 *
 * @author Jeevanandam M. ([email protected])
 */
public class DigitalOceanClient implements DigitalOcean, Constants {

  private static final Logger log = LoggerFactory.getLogger(DigitalOceanClient.class);

  /** Http client */
  protected CloseableHttpClient httpClient;

  /** OAuth Authorization Token for Accessing DigitalOcean API */
  protected String authToken;

  /** DigitalOcean API version. defaults to v2 from constructor */
  protected String apiVersion;

  /** DigitalOcean API Host is <code>api.digitalocean.com</code> */
  protected String apiHost = "api.digitalocean.com";

  /** Gson Parser instance for deserialize */
  private Gson deserialize;

  /** Gson Parser instance for serialize */
  private Gson serialize;

  /** API Request Header */
  private Header[] requestHeaders;

  /**
   * DigitalOcean Client Constructor
   *
   * @param authToken a {@link String} object
   */
  public DigitalOceanClient(String authToken) {
    this("v2", authToken);
  }

  /**
   * DigitalOcean Client Constructor
   *
   * @param apiVersion a {@link String} object
   * @param authToken a {@link String} object
   */
  public DigitalOceanClient(String apiVersion, String authToken) {
    this(apiVersion, authToken, null);
  }

  /**
   * DigitalOcean Client Constructor
   *
   * @param apiVersion a {@link String} object
   * @param authToken a {@link String} object
   * @param httpClient a {@link CloseableHttpClient} object
   */
  public DigitalOceanClient(String apiVersion, String authToken, CloseableHttpClient httpClient) {

    if (!"v2".equalsIgnoreCase(apiVersion)) {
      throw new IllegalArgumentException("Only API version 2 is supported.");
    }

    this.apiVersion = apiVersion;
    this.authToken = authToken;
    this.httpClient = httpClient;
    initialize();
  }

  /** @return the httpClient */
  public HttpClient getHttpClient() {
    return httpClient;
  }

  /** @param httpClient the httpClient to set */
  public void setHttpClient(CloseableHttpClient httpClient) {
    this.httpClient = httpClient;
  }

  /** @return the authToken */
  public String getAuthToken() {
    return authToken;
  }

  /** @param authToken the authToken to set */
  public void setAuthToken(String authToken) {
    this.authToken = authToken;
  }

  /** @return the apiVersion */
  public String getApiVersion() {
    return apiVersion;
  }

  /** @param apiVersion the apiVersion to set */
  public void setApiVersion(String apiVersion) {
    this.apiVersion = apiVersion;
  }

  // =======================================
  // Droplet access/manipulation methods
  // =======================================

  @Override
  public Droplets getAvailableDroplets(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    return (Droplets)
        perform(new ApiRequest(ApiAction.AVAILABLE_DROPLETS, pageNo, perPage)).getData();
  }

  @Override
  public Kernels getDropletKernels(Integer dropletId, Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletIdAndPageNo(dropletId, pageNo);

    Object[] params = {dropletId};
    return (Kernels)
        perform(new ApiRequest(ApiAction.GET_DROPLETS_KERNELS, params, pageNo, perPage)).getData();
  }

  @Override
  public Snapshots getDropletSnapshots(Integer dropletId, Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletIdAndPageNo(dropletId, pageNo);

    Object[] params = {dropletId};
    return (Snapshots)
        perform(new ApiRequest(ApiAction.GET_DROPLET_SNAPSHOTS, params, pageNo, perPage)).getData();
  }

  @Override
  public Backups getDropletBackups(Integer dropletId, Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletIdAndPageNo(dropletId, pageNo);

    Object[] params = {dropletId};
    return (Backups)
        perform(new ApiRequest(ApiAction.GET_DROPLET_BACKUPS, params, pageNo, perPage)).getData();
  }

  @Override
  public Droplet getDropletInfo(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Droplet) perform(new ApiRequest(ApiAction.GET_DROPLET_INFO, params)).getData();
  }

  @Override
  public Droplet createDroplet(Droplet droplet)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == droplet
        || StringUtils.isBlank(droplet.getName())
        || null == droplet.getRegion()
        || null == droplet.getSize()
        || (null == droplet.getImage()
            || (null == droplet.getImage().getId() && null == droplet.getImage().getSlug()))) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Region Slug, Size Slug, Image Id/Slug] for create droplet.");
    }

    return (Droplet) perform(new ApiRequest(ApiAction.CREATE_DROPLET, droplet)).getData();
  }

  @Override
  public Droplets createDroplets(Droplet droplet)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == droplet
        || (null == droplet.getNames() || droplet.getNames().isEmpty())
        || null == droplet.getRegion()
        || null == droplet.getSize()
        || (null == droplet.getImage()
            || (null == droplet.getImage().getId() && null == droplet.getImage().getSlug()))) {
      throw new IllegalArgumentException(
          "Missing required parameters [Names, Region Slug, Size Slug, Image Id/Slug] for creating multiple droplets.");
    }

    if (StringUtils.isNotBlank(droplet.getName())) {
      throw new IllegalArgumentException(
          "Name parameter is not allowed, while creating multiple droplet instead use 'names' attributes.");
    }

    return (Droplets) perform(new ApiRequest(ApiAction.CREATE_DROPLETS, droplet)).getData();
  }

  @Override
  public Delete deleteDroplet(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_DROPLET, params)).getData();
  }

  @Override
  public Delete deleteDropletByTagName(String tagName)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(tagName, "Missing required parameter - tagName.");

    Map<String, String> queryParams = new HashMap<String, String>();
    queryParams.put("tag_name", tagName);
    return (Delete)
        perform(new ApiRequest(ApiAction.DELETE_DROPLET_BY_TAG_NAME, null, queryParams, null))
            .getData();
  }

  @Override
  public Droplets getDropletNeighbors(Integer dropletId, Integer pageNo)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletIdAndPageNo(dropletId, pageNo);

    Object[] params = {dropletId};
    return (Droplets)
        perform(new ApiRequest(ApiAction.GET_DROPLET_NEIGHBORS, params, pageNo, null)).getData();
  }

  @Override
  public Neighbors getAllDropletNeighbors(Integer pageNo)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    return (Neighbors) perform(new ApiRequest(ApiAction.ALL_DROPLET_NEIGHBORS, pageNo)).getData();
  }

  @Override
  public Droplets getAvailableDropletsByTagName(String tagName, Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(tagName, "Missing required parameter - tagName.");
    validatePageNo(pageNo);

    Map<String, String> queryParams = new HashMap<String, String>();
    queryParams.put("tag_name", tagName);

    return (Droplets)
        perform(new ApiRequest(ApiAction.AVAILABLE_DROPLETS, pageNo, queryParams, perPage))
            .getData();
  }

  // Droplet action methods

  @Override
  public Action rebootDroplet(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.REBOOT_DROPLET, new DropletAction(ActionType.REBOOT), params))
            .getData();
  }

  @Override
  public Action powerCycleDroplet(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.POWER_CYCLE_DROPLET,
                    new DropletAction(ActionType.POWER_CYCLE),
                    params))
            .getData();
  }

  @Override
  public Action shutdownDroplet(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.SHUTDOWN_DROPLET, new DropletAction(ActionType.SHUTDOWN), params))
            .getData();
  }

  @Override
  public Action powerOffDroplet(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.POWER_OFF_DROPLET, new DropletAction(ActionType.POWER_OFF), params))
            .getData();
  }

  @Override
  public Action powerOnDroplet(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.POWER_ON_DROPLET, new DropletAction(ActionType.POWER_ON), params))
            .getData();
  }

  @Override
  public Action resetDropletPassword(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.RESET_DROPLET_PASSWORD,
                    new DropletAction(ActionType.PASSWORD_RESET),
                    params))
            .getData();
  }

  @Override
  public Action resizeDroplet(Integer dropletId, String size)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    DropletAction action = new DropletAction(ActionType.RESIZE);
    action.setSize(size);
    return (Action) perform(new ApiRequest(ApiAction.RESIZE_DROPLET, action, params)).getData();
  }

  @Override
  public Action resizeDroplet(Integer dropletId, String size, Boolean disk)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    DropletAction action = new DropletAction(ActionType.RESIZE);
    action.setSize(size);
    action.setDisk(disk);
    return (Action) perform(new ApiRequest(ApiAction.RESIZE_DROPLET, action, params)).getData();
  }

  @Override
  public Action takeDropletSnapshot(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.SNAPSHOT_DROPLET, new DropletAction(ActionType.SNAPSHOT), params))
            .getData();
  }

  @Override
  public Action takeDropletSnapshot(Integer dropletId, String snapshotName)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    DropletAction action = new DropletAction(ActionType.SNAPSHOT);
    action.setName(snapshotName);
    return (Action) perform(new ApiRequest(ApiAction.SNAPSHOT_DROPLET, action, params)).getData();
  }

  @Override
  public Action restoreDroplet(Integer dropletId, Integer imageId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    DropletAction action = new DropletAction(ActionType.RESTORE);
    action.setImage(imageId);
    return (Action) perform(new ApiRequest(ApiAction.RESTORE_DROPLET, action, params)).getData();
  }

  @Override
  public Action rebuildDroplet(Integer dropletId, Integer imageId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    DropletAction action = new DropletAction(ActionType.REBUILD);
    action.setImage(imageId);
    return (Action) perform(new ApiRequest(ApiAction.REBUILD_DROPLET, action, params)).getData();
  }

  @Override
  public Action enableDropletBackups(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.ENABLE_DROPLET_BACKUPS,
                    new DropletAction(ActionType.ENABLE_BACKUPS),
                    params))
            .getData();
  }

  @Override
  public Action disableDropletBackups(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.DISABLE_DROPLET_BACKUPS,
                    new DropletAction(ActionType.DISABLE_BACKUPS),
                    params))
            .getData();
  }

  @Override
  public Action renameDroplet(Integer dropletId, String name)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    DropletAction action = new DropletAction(ActionType.RENAME);
    action.setName(name);
    return (Action) perform(new ApiRequest(ApiAction.RENAME_DROPLET, action, params)).getData();
  }

  @Override
  public Action changeDropletKernel(Integer dropletId, Integer kernelId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    DropletAction action = new DropletAction(ActionType.CHANGE_KERNEL);
    action.setKernel(kernelId);
    return (Action)
        perform(new ApiRequest(ApiAction.CHANGE_DROPLET_KERNEL, action, params)).getData();
  }

  @Override
  public Action enableDropletIpv6(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.ENABLE_DROPLET_IPV6,
                    new DropletAction(ActionType.ENABLE_IPV6),
                    params))
            .getData();
  }

  @Override
  public Action enableDropletPrivateNetworking(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    Object[] params = {dropletId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.ENABLE_DROPLET_PRIVATE_NETWORKING,
                    new DropletAction(ActionType.ENABLE_PRIVATE_NETWORKING),
                    params))
            .getData();
  }

  // ==============================================
  // Account manipulation/access methods
  // ==============================================

  @Override
  public Account getAccountInfo() throws DigitalOceanException, RequestUnsuccessfulException {
    return (Account) perform(new ApiRequest(ApiAction.GET_ACCOUNT_INFO)).getData();
  }

  // ==============================================
  // Actions manipulation/access methods
  // ==============================================

  @Override
  public Actions getAvailableActions(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);
    return (Actions)
        perform(new ApiRequest(ApiAction.AVAILABLE_ACTIONS, pageNo, perPage)).getData();
  }

  @Override
  public Action getActionInfo(Integer actionId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(actionId, "Missing required parameter - actionId");

    Object[] params = {actionId};
    return (Action) perform(new ApiRequest(ApiAction.GET_ACTION_INFO, params)).getData();
  }

  @Override
  public Actions getAvailableDropletActions(Integer dropletId, Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletIdAndPageNo(dropletId, pageNo);

    Object[] params = {dropletId};
    return (Actions)
        perform(new ApiRequest(ApiAction.GET_DROPLET_ACTIONS, params, pageNo, perPage)).getData();
  }

  @Override
  public Actions getAvailableImageActions(Integer imageId, Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(imageId, "Missing required parameter - imageId.");
    validatePageNo(pageNo);

    Object[] params = {imageId};
    return (Actions)
        perform(new ApiRequest(ApiAction.GET_IMAGE_ACTIONS, params, pageNo, perPage)).getData();
  }

  @Override
  public Actions getAvailableFloatingIPActions(String ipAddress, Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(ipAddress, "Missing required parameter - ipAddress.");
    validatePageNo(pageNo);

    Object[] params = {ipAddress};
    return (Actions)
        perform(new ApiRequest(ApiAction.GET_FLOATING_IP_ACTIONS, params, pageNo, perPage))
            .getData();
  }

  @Override
  public Action getFloatingIPActionInfo(String ipAddress, Integer actionId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(ipAddress, "Missing required parameter - ipAddress.");
    checkNullAndThrowError(actionId, "Missing required parameter - actionId.");

    Object[] params = {ipAddress, actionId};
    return (Action)
        perform(new ApiRequest(ApiAction.GET_FLOATING_IP_ACTION_INFO, params)).getData();
  }

  @Override
  public Actions getAvailableVolumeActions(String volumeId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(volumeId, "Missing required parameter - volumeId.");

    Object[] params = {volumeId};
    return (Actions) perform(new ApiRequest(ApiAction.GET_VOLUME_ACTIONS, params)).getData();
  }

  @Override
  public Action getVolumeAction(String volumeId, Integer actionId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(volumeId, "Missing required parameter - volumeId.");
    checkNullAndThrowError(actionId, "Missing required parameter - actionId.");

    Object[] params = {volumeId, actionId};
    return (Action) perform(new ApiRequest(ApiAction.GET_VOLUME_ACTION, params)).getData();
  }

  // =======================================
  // Images access/manipulation methods
  // =======================================

  @Override
  public Images getAvailableImages(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);
    return (Images) perform(new ApiRequest(ApiAction.AVAILABLE_IMAGES, pageNo, perPage)).getData();
  }

  @Override
  public Images getAvailableImages(Integer pageNo, Integer perPage, ActionType type)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    Map<String, String> qp;
    if (ActionType.DISTRIBUTION.equals(type) || ActionType.APPLICATION.equals(type)) {
      qp = new HashMap<String, String>();
      qp.put("type", type.toString());
    } else {
      throw new DigitalOceanException(
          "Incorrect type value [Allowed: DISTRIBUTION or APPLICATION].");
    }

    return (Images)
        perform(new ApiRequest(ApiAction.AVAILABLE_IMAGES, pageNo, qp, perPage)).getData();
  }

  @Override
  public Images getUserImages(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);
    Map<String, String> qp = new HashMap<String, String>();
    qp.put("private", "true");
    return (Images)
        perform(new ApiRequest(ApiAction.AVAILABLE_IMAGES, pageNo, qp, perPage)).getData();
  }

  @Override
  public Image getImageInfo(Integer imageId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(imageId, "Missing required parameter - imageId.");

    Object[] params = {imageId};
    return (Image) perform(new ApiRequest(ApiAction.GET_IMAGE_INFO, params)).getData();
  }

  @Override
  public Image getImageInfo(String slug)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(slug, "Missing required parameter - slug.");

    Object[] params = {slug};
    return (Image) perform(new ApiRequest(ApiAction.GET_IMAGE_INFO, params)).getData();
  }

  @Override
  public Image createCustomImage(Image image)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == image
        || StringUtils.isBlank(image.getName())
        || StringUtils.isBlank(image.getUrl())
        || StringUtils.isBlank(image.getRegion())) {
      throw new IllegalArgumentException(
          "Missing required parameter to create custom image [name, url, or region].");
    }

    return (Image) perform(new ApiRequest(ApiAction.CREATE_CUSTOM_IMAGE, image)).getData();
  }

  @Override
  public Image updateImage(Image image) throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == image || null == image.getName()) {
      throw new IllegalArgumentException("Missing required parameter - image object.");
    }

    Object[] params = {image.getId()};
    return (Image) perform(new ApiRequest(ApiAction.UPDATE_IMAGE_INFO, image, params)).getData();
  }

  @Override
  public Delete deleteImage(Integer imageId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(imageId, "Missing required parameter - imageId.");

    Object[] params = {imageId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_IMAGE, params)).getData();
  }

  @Override
  public Action transferImage(Integer imageId, String regionSlug)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(imageId, "Missing required parameter - imageId.");
    checkBlankAndThrowError(regionSlug, "Missing required parameter - regionSlug.");

    Object[] params = {imageId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.TRANSFER_IMAGE,
                    new ImageAction(ActionType.TRANSFER, regionSlug),
                    params))
            .getData();
  }

  @Override
  public Action convertImage(Integer imageId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(imageId, "Missing required parameter - imageId.");

    Object[] params = {imageId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.CONVERT_IMAGE, new ImageAction(ActionType.CONVERT), params))
            .getData();
  }

  // =======================================
  // Regions methods
  // =======================================

  @Override
  public Regions getAvailableRegions(Integer pageNo)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);
    return (Regions)
        perform(new ApiRequest(ApiAction.AVAILABLE_REGIONS, pageNo, DEFAULT_PAGE_SIZE)).getData();
  }

  // =======================================
  // Sizes methods
  // =======================================

  @Override
  public Sizes getAvailableSizes(Integer pageNo)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);
    return (Sizes)
        perform(new ApiRequest(ApiAction.AVAILABLE_SIZES, pageNo, DEFAULT_PAGE_SIZE)).getData();
  }

  // =======================================
  // Domain methods
  // =======================================

  @Override
  public Domains getAvailableDomains(Integer pageNo)
      throws DigitalOceanException, RequestUnsuccessfulException {
    return (Domains)
        perform(new ApiRequest(ApiAction.AVAILABLE_DOMAINS, pageNo, DEFAULT_PAGE_SIZE)).getData();
  }

  @Override
  public Domain getDomainInfo(String domainName)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(domainName, "Missing required parameter - domainName.");

    Object[] params = {domainName};
    return (Domain) perform(new ApiRequest(ApiAction.GET_DOMAIN_INFO, params)).getData();
  }

  @Override
  public Domain createDomain(Domain domain)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(domain.getName(), "Missing required parameter - domainName.");
    // #89 - removed the validation in-favor of
    // https://developers.digitalocean.com/documentation/changelog/api-v2/create-domains-without-providing-an-ip-address/
    // checkBlankAndThrowError(domain.getIpAddress(), "Missing required parameter - ipAddress.");

    return (Domain) perform(new ApiRequest(ApiAction.CREATE_DOMAIN, domain)).getData();
  }

  @Override
  public Delete deleteDomain(String domainName)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(domainName, "Missing required parameter - domainName.");

    Object[] params = {domainName};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_DOMAIN, params)).getData();
  }

  @Override
  public DomainRecords getDomainRecords(String domainName, Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(domainName, "Missing required parameter - domainName.");

    Object[] params = {domainName};
    return (DomainRecords)
        perform(new ApiRequest(ApiAction.GET_DOMAIN_RECORDS, params, pageNo, perPage)).getData();
  }

  @Override
  public DomainRecord getDomainRecordInfo(String domainName, Integer recordId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(domainName, "Missing required parameter - domainName.");
    checkNullAndThrowError(recordId, "Missing required parameter - recordId.");

    Object[] params = {domainName, recordId};
    return (DomainRecord)
        perform(new ApiRequest(ApiAction.GET_DOMAIN_RECORD_INFO, params)).getData();
  }

  @Override
  public DomainRecord createDomainRecord(String domainName, DomainRecord domainRecord)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(domainName, "Missing required parameter - domainName.");
    checkNullAndThrowError(domainRecord, "Missing required parameter - domainRecord");

    Object[] params = {domainName};
    return (DomainRecord)
        perform(new ApiRequest(ApiAction.CREATE_DOMAIN_RECORD, domainRecord, params)).getData();
  }

  @Override
  public DomainRecord updateDomainRecord(
      String domainName, Integer recordId, DomainRecord domainRecord)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(domainName, "Missing required parameter - domainName.");
    checkNullAndThrowError(recordId, "Missing required parameter - recordId.");
    checkNullAndThrowError(domainRecord, "Missing required parameter - domainRecord");

    Object[] params = {domainName, recordId};
    return (DomainRecord)
        perform(new ApiRequest(ApiAction.UPDATE_DOMAIN_RECORD, domainRecord, params)).getData();
  }

  @Override
  public Delete deleteDomainRecord(String domainName, Integer recordId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(domainName, "Missing required parameter - domainName.");
    checkNullAndThrowError(recordId, "Missing required parameter - recordId.");

    Object[] params = {domainName, recordId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_DOMAIN_RECORD, params)).getData();
  }

  @Override
  public Keys getAvailableKeys(Integer pageNo)
      throws DigitalOceanException, RequestUnsuccessfulException {
    return (Keys)
        perform(new ApiRequest(ApiAction.AVAILABLE_KEYS, pageNo, DEFAULT_PAGE_SIZE)).getData();
  }

  @Override
  public Key getKeyInfo(Integer sshKeyId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(sshKeyId, "Missing required parameter - sshKeyId.");

    Object[] params = {sshKeyId};
    return (Key) perform(new ApiRequest(ApiAction.GET_KEY_INFO, params)).getData();
  }

  @Override
  public Key getKeyInfo(String fingerprint)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(fingerprint, "Missing required parameter - fingerprint.");

    Object[] params = {fingerprint};
    return (Key) perform(new ApiRequest(ApiAction.GET_KEY_INFO, params)).getData();
  }

  @Override
  public Key createKey(Key newKey) throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(newKey, "Missing required parameter - newKey");
    checkBlankAndThrowError(newKey.getName(), "Missing required parameter - name.");
    checkBlankAndThrowError(newKey.getPublicKey(), "Missing required parameter - publicKey.");

    return (Key) perform(new ApiRequest(ApiAction.CREATE_KEY, newKey)).getData();
  }

  @Override
  public Key updateKey(Integer sshKeyId, String newSshKeyName)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(sshKeyId, "Missing required parameter - sshKeyId.");
    checkBlankAndThrowError(newSshKeyName, "Missing required parameter - newSshKeyName.");

    Object[] params = {sshKeyId};
    return (Key)
        perform(new ApiRequest(ApiAction.UPDATE_KEY, new Key(newSshKeyName), params)).getData();
  }

  @Override
  public Key updateKey(String fingerprint, String newSshKeyName)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(fingerprint, "Missing required parameter - fingerprint.");
    checkBlankAndThrowError(newSshKeyName, "Missing required parameter - newSshKeyName.");

    Object[] params = {fingerprint};
    return (Key)
        perform(new ApiRequest(ApiAction.UPDATE_KEY, new Key(newSshKeyName), params)).getData();
  }

  @Override
  public Delete deleteKey(Integer sshKeyId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkNullAndThrowError(sshKeyId, "Missing required parameter - sshKeyId.");

    Object[] params = {sshKeyId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_KEY, params)).getData();
  }

  @Override
  public Delete deleteKey(String fingerprint)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(fingerprint, "Missing required parameter - fingerprint.");

    Object[] params = {fingerprint};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_KEY, params)).getData();
  }

  // =======================================
  // Floating IPs methods
  // =======================================

  @Override
  public FloatingIPs getAvailableFloatingIPs(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    return (FloatingIPs) perform(new ApiRequest(ApiAction.FLOATING_IPS, pageNo, perPage)).getData();
  }

  @Override
  public FloatingIP createFloatingIP(Integer dropletId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);

    return (FloatingIP)
        perform(new ApiRequest(ApiAction.CREATE_FLOATING_IP, new FloatingIPAction(dropletId)))
            .getData();
  }

  @Override
  public FloatingIP createFloatingIP(String region)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(region, "Missing required parameter - region.");

    return (FloatingIP)
        perform(new ApiRequest(ApiAction.CREATE_FLOATING_IP, new FloatingIPAction(region)))
            .getData();
  }

  @Override
  public FloatingIP getFloatingIPInfo(String ipAddress)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(ipAddress, "Missing required parameter - ipAddress.");

    Object[] params = {ipAddress};
    return (FloatingIP) perform(new ApiRequest(ApiAction.GET_FLOATING_IP_INFO, params)).getData();
  }

  @Override
  public Delete deleteFloatingIP(String ipAddress)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(ipAddress, "Missing required parameter - ipAddress.");

    Object[] params = {ipAddress};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_FLOATING_IP, params)).getData();
  }

  @Override
  public Action assignFloatingIP(Integer dropletId, String ipAddress)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);
    checkBlankAndThrowError(ipAddress, "Missing required parameter - ipAddress.");

    Object[] params = {ipAddress};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.ASSIGN_FLOATING_IP,
                    new FloatingIPAction(dropletId, "assign"),
                    params))
            .getData();
  }

  @Override
  public Action unassignFloatingIP(String ipAddress)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(ipAddress, "Missing required parameter - ipAddress.");

    Object[] params = {ipAddress};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.UNASSIGN_FLOATING_IP, new FloatingIPAction(null, "unassign"), params))
            .getData();
  }

  // =======================================
  // Tags methods
  // =======================================

  @Override
  public Tags getAvailableTags(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    return (Tags) perform(new ApiRequest(ApiAction.AVAILABLE_TAGS, pageNo, perPage)).getData();
  }

  @Override
  public Tag createTag(String name) throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(name, "Missing required parameter - tag name");

    return (Tag) perform(new ApiRequest(ApiAction.CREATE_TAG, new Tag(name))).getData();
  }

  @Override
  public Tag getTag(String name) throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(name, "Missing required parameter - tag name");

    Object[] params = {name};
    return (Tag) perform(new ApiRequest(ApiAction.GET_TAG, params)).getData();
  }

  @Override
  public Delete deleteTag(String name) throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(name, "Missing required parameter - tag name");

    Object[] params = {name};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_TAG, params)).getData();
  }

  @Override
  public Response tagResources(String name, List<Resource> resources)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(name, "Missing required parameter - tag name");
    if (null == resources || resources.isEmpty()) {
      throw new IllegalArgumentException("Missing required parameter - list of resources for tag");
    }

    Object[] params = {name};
    return (Response)
        perform(new ApiRequest(ApiAction.TAG_RESOURCE, new Resources(resources), params)).getData();
  }

  @Override
  public Response untagResources(String name, List<Resource> resources)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(name, "Missing required parameter - tag name");
    if (null == resources || resources.isEmpty()) {
      throw new IllegalArgumentException(
          "Missing required parameter - list of resources for untag");
    }

    Object[] params = {name};
    return (Response)
        perform(new ApiRequest(ApiAction.UNTAG_RESOURCE, new Resources(resources), params))
            .getData();
  }

  // =======================================
  // Volume access/manipulation methods
  // =======================================

  @Override
  public Volumes getAvailableVolumes(String regionSlug)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(regionSlug, "Missing required parameter - regionSlug.");

    Map<String, String> data = new HashMap<String, String>();
    data.put("region", regionSlug);
    return (Volumes) perform(new ApiRequest(ApiAction.AVAILABLE_VOLUMES, data)).getData();
  }

  @Override
  public Volume createVolume(Volume volume)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == volume
        || StringUtils.isBlank(volume.getName())
        || null == volume.getRegion()
        || null == volume.getSize()) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Region Slug, Size] for create volume.");
    }

    return (Volume) perform(new ApiRequest(ApiAction.CREATE_VOLUME, volume)).getData();
  }

  @Override
  public Volume getVolumeInfo(String volumeId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(volumeId, "Missing required parameter - volumeId.");

    Object[] params = {volumeId};
    return (Volume) perform(new ApiRequest(ApiAction.GET_VOLUME_INFO, params)).getData();
  }

  @Override
  public Volumes getVolumeInfo(String volumeName, String regionSlug)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(volumeName, "Missing required parameter - volumeName.");
    checkBlankAndThrowError(regionSlug, "Missing required parameter - regionSlug.");

    Map<String, String> data = new HashMap<String, String>();
    data.put("region", regionSlug);
    data.put("name", volumeName);
    return (Volumes) perform(new ApiRequest(ApiAction.GET_VOLUME_INFO_BY_NAME, data)).getData();
  }

  @Override
  public Delete deleteVolume(String volumeId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(volumeId, "Missing required parameter - volumeId.");

    Object[] params = {volumeId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_VOLUME, params)).getData();
  }

  @Override
  public Delete deleteVolume(String volumeName, String regionSlug)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(volumeName, "Missing required parameter - volumeName.");
    checkBlankAndThrowError(regionSlug, "Missing required parameter - regionSlug.");

    Map<String, String> data = new HashMap<String, String>();
    data.put("region", regionSlug);
    data.put("name", volumeName);
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_VOLUME_BY_NAME, data)).getData();
  }

  @Override
  public Action attachVolume(Integer dropletId, String volumeId, String regionSlug)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);
    checkBlankAndThrowError(volumeId, "Missing required parameter - volumeId.");
    checkBlankAndThrowError(regionSlug, "Missing required parameter - regionSlug.");

    Object[] params = {volumeId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.ACTIONS_VOLUME,
                    new VolumeAction(ActionType.ATTACH, dropletId, regionSlug),
                    params))
            .getData();
  }

  @Override
  public Action attachVolumeByName(Integer dropletId, String volumeName, String regionSlug)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);
    checkBlankAndThrowError(volumeName, "Missing required parameter - volumeName.");
    checkBlankAndThrowError(regionSlug, "Missing required parameter - regionSlug.");

    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.ACTIONS_VOLUME_BY_NAME,
                    new VolumeAction(ActionType.ATTACH, dropletId, regionSlug, volumeName, null)))
            .getData();
  }

  @Override
  public Action detachVolume(Integer dropletId, String volumeId, String regionSlug)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);
    checkBlankAndThrowError(volumeId, "Missing required parameter - volumeId.");
    checkBlankAndThrowError(regionSlug, "Missing required parameter - regionSlug.");

    Object[] params = {volumeId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.ACTIONS_VOLUME,
                    new VolumeAction(ActionType.DETACH, dropletId, regionSlug),
                    params))
            .getData();
  }

  @Override
  public Action detachVolumeByName(Integer dropletId, String volumeName, String regionSlug)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateDropletId(dropletId);
    checkBlankAndThrowError(volumeName, "Missing required parameter - volumeName.");
    checkBlankAndThrowError(regionSlug, "Missing required parameter - regionSlug.");

    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.ACTIONS_VOLUME_BY_NAME,
                    new VolumeAction(ActionType.DETACH, dropletId, regionSlug, volumeName, null)))
            .getData();
  }

  @Override
  public Action resizeVolume(String volumeId, String regionSlug, Double sizeGigabytes)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(volumeId, "Missing required parameter - volumeId.");
    checkBlankAndThrowError(regionSlug, "Missing required parameter - regionSlug.");

    if (null == sizeGigabytes) {
      throw new IllegalArgumentException("Missing required parameter - sizeGigabytes.");
    }

    Object[] params = {volumeId};
    return (Action)
        perform(
                new ApiRequest(
                    ApiAction.ACTIONS_VOLUME,
                    new VolumeAction(ActionType.RESIZE, regionSlug, sizeGigabytes),
                    params))
            .getData();
  }

  @Override
  public Snapshots getVolumeSnapshots(String volumeId, Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);
    checkBlankAndThrowError(volumeId, "Missing required parameter - volumeId.");

    Object[] params = {volumeId};
    return (Snapshots)
        perform(new ApiRequest(ApiAction.GET_VOLUME_SNAPSHOTS, params, pageNo, perPage)).getData();
  }

  @Override
  public Snapshot takeVolumeSnapshot(String volumeId, String snapshotName)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(volumeId, "Missing required parameter - volumeId.");
    checkBlankAndThrowError(snapshotName, "Missing required parameter - snapshotName.");

    Map<String, String> data = new HashMap<String, String>();
    data.put("name", snapshotName);

    Object[] params = {volumeId};
    return (Snapshot) perform(new ApiRequest(ApiAction.SNAPSHOT_VOLUME, data, params)).getData();
  }

  // ===========================================
  // Snapshots manipulation methods
  // ===========================================

  @Override
  public Snapshots getAvailableSnapshots(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    return (Snapshots)
        perform(new ApiRequest(ApiAction.AVAILABLE_SNAPSHOTS, pageNo, perPage)).getData();
  }

  @Override
  public Snapshots getAllDropletSnapshots(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    Map<String, String> qp = new HashMap<String, String>();
    qp.put("resource_type", "droplet");

    return (Snapshots)
        perform(new ApiRequest(ApiAction.ALL_DROPLET_SNAPSHOTS, pageNo, qp, perPage)).getData();
  }

  @Override
  public Snapshots getAllVolumeSnapshots(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    Map<String, String> qp = new HashMap<String, String>();
    qp.put("resource_type", "volume");

    return (Snapshots)
        perform(new ApiRequest(ApiAction.ALL_VOLUME_SNAPSHOTS, pageNo, qp, perPage)).getData();
  }

  @Override
  public Snapshot getSnaphotInfo(String snapshotId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateSnapshotId(snapshotId);

    Object[] params = {snapshotId};
    return (Snapshot) perform(new ApiRequest(ApiAction.GET_SNAPSHOT_INFO, params)).getData();
  }

  @Override
  public Delete deleteSnapshot(String snapshotId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateSnapshotId(snapshotId);

    Object[] params = {snapshotId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_SNAPSHOT, params)).getData();
  }

  // ===========================================
  // Load balancers manipulation methods
  // ===========================================

  @Override
  public LoadBalancer createLoadBalancer(LoadBalancer loadBalancer)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == loadBalancer
        || StringUtils.isBlank(loadBalancer.getName())
        || null == loadBalancer.getRegion()) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Region Slug] for create loadBalancer.");
    }
    validateForwardingRules(loadBalancer.getForwardingRules());
    validateHealthCheck(loadBalancer.getHealthCheck());

    return (LoadBalancer)
        perform(new ApiRequest(ApiAction.CREATE_LOAD_BALANCER, loadBalancer)).getData();
  }

  @Override
  public LoadBalancer getLoadBalancerInfo(String loadBalancerId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateLoadBalancerId(loadBalancerId);

    Object[] params = {loadBalancerId};
    return (LoadBalancer)
        perform(new ApiRequest(ApiAction.GET_LOAD_BALANCER_INFO, params)).getData();
  }

  @Override
  public LoadBalancers getAvailableLoadBalancers(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    return (LoadBalancers)
        perform(new ApiRequest(ApiAction.AVAILABLE_LOAD_BALANCERS, pageNo, perPage)).getData();
  }

  @Override
  public LoadBalancer updateLoadBalancer(LoadBalancer loadBalancer)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == loadBalancer
        || StringUtils.isBlank(loadBalancer.getId())
        || StringUtils.isBlank(loadBalancer.getName())
        || null == loadBalancer.getRegion()) {
      throw new IllegalArgumentException(
          "Missing required parameters [Id, Name, Region Slug] for update loadBalancer.");
    }
    validateForwardingRules(loadBalancer.getForwardingRules());
    validateHealthCheck(loadBalancer.getHealthCheck());

    Object[] params = {loadBalancer.getId()};

    return (LoadBalancer)
        perform(new ApiRequest(ApiAction.UPDATE_LOAD_BALANCER, loadBalancer, params)).getData();
  }

  @Override
  public Response addDropletsToLoadBalancer(String loadBalancerId, List<Integer> dropletIds)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateLoadBalancerId(loadBalancerId);

    if (null == dropletIds || dropletIds.isEmpty()) {
      throw new IllegalArgumentException("Missing required parameters [dropletIds].");
    }

    Object[] params = {loadBalancerId};
    Map<String, List<Integer>> data = new HashMap<>();
    data.put("droplet_ids", dropletIds);
    return (Response)
        perform(new ApiRequest(ApiAction.ADD_DROPLET_TO_LOAD_BALANCER, data, params)).getData();
  }

  @Override
  public Delete removeDropletsFromLoadBalancer(String loadBalancerId, List<Integer> dropletIds)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateLoadBalancerId(loadBalancerId);

    if (null == dropletIds || dropletIds.isEmpty()) {
      throw new IllegalArgumentException("Missing required parameters [dropletIds].");
    }

    Object[] params = {loadBalancerId};
    Map<String, List<Integer>> data = new HashMap<>();
    data.put("droplet_ids", dropletIds);
    return (Delete)
        perform(new ApiRequest(ApiAction.REMOVE_DROPLET_FROM_LOAD_BALANCER, data, params))
            .getData();
  }

  @Override
  public Response addForwardingRulesToLoadBalancer(
      String loadBalancerId, List<ForwardingRules> forwardingRules)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateLoadBalancerId(loadBalancerId);

    if (null == forwardingRules || forwardingRules.isEmpty()) {
      throw new IllegalArgumentException("Missing required parameters [forwardingRules].");
    }

    Object[] params = {loadBalancerId};
    Map<String, List<ForwardingRules>> data = new HashMap<>();
    data.put("forwarding_rules", forwardingRules);
    return (Response)
        perform(new ApiRequest(ApiAction.ADD_FORWARDING_RULES_TO_LOAD_BALANCER, data, params))
            .getData();
  }

  @Override
  public Delete removeForwardingRulesFromLoadBalancer(
      String loadBalancerId, List<ForwardingRules> forwardingRules)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateLoadBalancerId(loadBalancerId);

    if (null == forwardingRules || forwardingRules.isEmpty()) {
      throw new IllegalArgumentException("Missing required parameters [forwardingRules].");
    }

    Object[] params = {loadBalancerId};
    Map<String, List<ForwardingRules>> data = new HashMap<>();
    data.put("forwarding_rules", forwardingRules);
    return (Delete)
        perform(new ApiRequest(ApiAction.REMOVE_FORWARDING_RULES_FROM_LOAD_BALANCER, data, params))
            .getData();
  }

  @Override
  public Delete deleteLoadBalancer(String loadBalancerId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validateLoadBalancerId(loadBalancerId);

    Object[] params = {loadBalancerId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_LOAD_BALANCER, params)).getData();
  }

  // ===========================================
  // Certificates manipulation methods
  // ===========================================

  @Override
  public Certificates getAvailableCertificates(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    return (Certificates)
        perform(new ApiRequest(ApiAction.AVAILABLE_CERTIFICATES, pageNo, perPage)).getData();
  }

  @Override
  public Certificate createCertificate(Certificate certificate)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == certificate
        || StringUtils.isBlank(certificate.getName())
        || StringUtils.isBlank(certificate.getPrivateKey())
        || StringUtils.isBlank(certificate.getLeafCertificate())
        || StringUtils.isBlank(certificate.getCertificateChain())) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Private Key, Leaf Certificate, Certificate Chain] for create certificate.");
    }

    return (Certificate)
        perform(new ApiRequest(ApiAction.CREATE_CERTIFICATE, certificate)).getData();
  }

  @Override
  public Certificate createLetsEncryptCertificate(Certificate certificate)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == certificate
        || StringUtils.isBlank(certificate.getName())
        || StringUtils.isBlank(certificate.getType())
        || certificate.getType() != "lets_encrypt"
        || certificate.getDnsNames() == null
        || certificate.getDnsNames().isEmpty()) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Type(lets_encrypt), List of DNS Names] for create Let's Encrypt certificate.");
    }

    return (Certificate)
        perform(new ApiRequest(ApiAction.CREATE_CERTIFICATE, certificate)).getData();
  }

  @Override
  public Certificate getCertificateInfo(String certificateId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(certificateId, "Missing required parameter - certificateId.");

    Object[] params = {certificateId};
    return (Certificate) perform(new ApiRequest(ApiAction.GET_CERTIFICATE_INFO, params)).getData();
  }

  @Override
  public Delete deleteCertificate(String certificateId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(certificateId, "Missing required parameter - certificateId.");

    Object[] params = {certificateId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_CERTIFICATE, params)).getData();
  }

  // ===========================================
  // Firewall manipulation methods
  // ===========================================

  @Override
  public Firewall createFirewall(Firewall firewall)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == firewall
        || StringUtils.isBlank(firewall.getName())
        || firewall.getInboundRules().isEmpty()
        || firewall.getOutboundRules().isEmpty()) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Inbound rules, Outbound rules] for create firewall.");
    }
    return (Firewall) perform(new ApiRequest(ApiAction.CREATE_FIREWALL, firewall)).getData();
  }

  @Override
  public Firewall getFirewallInfo(String firewallId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(
        firewallId, "Missing required parameters [FirewallID] for get firewall info.");

    Object[] params = {firewallId};
    return (Firewall) perform(new ApiRequest(ApiAction.GET_FIREWALL_INFO, params)).getData();
  }

  @Override
  public Firewall updateFirewall(Firewall firewall)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == firewall
        || StringUtils.isBlank(firewall.getName())
        || firewall.getInboundRules().isEmpty()
        || firewall.getOutboundRules().isEmpty()) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Inbound rules, Outbound rules] for update firewall info.");
    }

    Object[] params = {firewall.getId()};
    return (Firewall)
        perform(new ApiRequest(ApiAction.UPDATE_FIREWALL, firewall, params)).getData();
  }

  @Override
  public Delete deleteFirewall(String firewallId)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(
        firewallId, "Missing required parameters [ID] for delete firewall info.");

    Object[] params = {firewallId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_FIREWALL, params)).getData();
  }

  @Override
  public Response addDropletsToFirewall(String firewallId, List<Integer> dropletIds)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(firewallId, "Missing required parameter [firewallId].");

    if (null == dropletIds || dropletIds.isEmpty()) {
      throw new IllegalArgumentException("Missing required parameters [dropletIds].");
    }

    Object[] params = {firewallId};
    Map<String, List<Integer>> data = new HashMap<>();
    data.put("droplet_ids", dropletIds);
    return (Response)
        perform(new ApiRequest(ApiAction.ADD_DROPLET_TO_FIREWALL, data, params)).getData();
  }

  @Override
  public Delete removeDropletsFromFirewall(String firewallId, List<Integer> dropletIds)
      throws DigitalOceanException, RequestUnsuccessfulException {
    checkBlankAndThrowError(firewallId, "Missing required parameter [firewallId].");

    if (null == dropletIds || dropletIds.isEmpty()) {
      throw new IllegalArgumentException("Missing required parameters [dropletIds].");
    }

    Object[] params = {firewallId};
    Map<String, List<Integer>> data = new HashMap<>();
    data.put("droplet_ids", dropletIds);
    return (Delete)
        perform(new ApiRequest(ApiAction.REMOVE_DROPLET_FROM_FIREWALL, data, params)).getData();
  }

  @Override
  public Firewalls getAvailableFirewalls(Integer pageNo, Integer perPage)
      throws DigitalOceanException, RequestUnsuccessfulException {
    validatePageNo(pageNo);

    return (Firewalls)
        perform(new ApiRequest(ApiAction.AVAILABLE_FIREWALLS, pageNo, perPage)).getData();
  }

  @Override
  public Project createProject(Project project)
      throws DigitalOceanException, RequestUnsuccessfulException {

    if (null == project || StringUtils.isBlank(project.getName()) || null == project.getPurpose()) {
      throw new IllegalArgumentException("Missing required parameters [Name, Purpose].");
    }

    return (Project) perform(new ApiRequest(ApiAction.CREATE_PROJECT, project)).getData();
  }

  @Override
  public Projects getAvailableProjects()
      throws DigitalOceanException, RequestUnsuccessfulException {
    return (Projects) perform(new ApiRequest(ApiAction.GET_ALL_PROJECTS)).getData();
  }

  @Override
  public Project updateProject(Project project)
      throws DigitalOceanException, RequestUnsuccessfulException {

    if (null == project
        || StringUtils.isBlank(project.getName())
        || StringUtils.isBlank(project.getDescription())
        || StringUtils.isBlank(project.getPurpose())) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Description, Purpose].");
    }

    Object[] params = {project.getId()};

    return (Project) perform(new ApiRequest(ApiAction.UPDATE_PROJECT, project, params)).getData();
  }

  @Override
  public Project patchProject(Project project)
      throws DigitalOceanException, RequestUnsuccessfulException {

    if (null == project
        || StringUtils.isBlank(project.getName())
        || StringUtils.isBlank(project.getDescription())
        || StringUtils.isBlank(project.getPurpose())) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Description, Purpose].");
    }

    Object[] params = {project.getId()};

    return (Project) perform(new ApiRequest(ApiAction.PATCH_PROJECT, project, params)).getData();
  }

  @Override
  public Project getProject(String projectId)
      throws DigitalOceanException, RequestUnsuccessfulException {

    validateProjectId(projectId);

    Object[] params = {projectId};
    return (Project) perform(new ApiRequest(ApiAction.GET_PROJECT, params)).getData();
  }

  @Override
  public Project getDefaultProject() throws DigitalOceanException, RequestUnsuccessfulException {
    return (Project) perform(new ApiRequest(ApiAction.GET_DEFAULT_PROJECT)).getData();
  }

  @Override
  public Project updateDefaultProject(Project project)
      throws DigitalOceanException, RequestUnsuccessfulException {

    if (null == project
        || StringUtils.isBlank(project.getName())
        || StringUtils.isBlank(project.getDescription())
        || StringUtils.isBlank(project.getPurpose())) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Description, Purpose].");
    }

    return (Project) perform(new ApiRequest(ApiAction.UPDATE_DEFAULT_PROJECT, project)).getData();
  }

  @Override
  public Project patchDefaultProject(Project project)
      throws DigitalOceanException, RequestUnsuccessfulException {

    if (null == project
        || StringUtils.isBlank(project.getName())
        || StringUtils.isBlank(project.getDescription())
        || StringUtils.isBlank(project.getPurpose())) {
      throw new IllegalArgumentException(
          "Missing required parameters [Name, Description, Purpose].");
    }

    return (Project) perform(new ApiRequest(ApiAction.PATCH_DEFAULT_PROJECT, project)).getData();
  }

  @Override
  public Delete deleteProject(String projectId)
      throws DigitalOceanException, RequestUnsuccessfulException {

    checkBlankAndThrowError(projectId, "Missing required parameter - projectId.");

    Object[] params = {projectId};
    return (Delete) perform(new ApiRequest(ApiAction.DELETE_PROJECT, params)).getData();
  }

  //
  // Private methods
  //

  private ApiResponse perform(ApiRequest request)
      throws DigitalOceanException, RequestUnsuccessfulException {

    URI uri = createUri(request);
    String response = null;

    if (RequestMethod.GET == request.getMethod()) {
      response = doGet(uri);
    } else if (RequestMethod.POST == request.getMethod()) {
      response = doPost(uri, createRequestData(request));
    } else if (RequestMethod.PUT == request.getMethod()) {
      response = doPut(uri, createRequestData(request));
    } else if (RequestMethod.DELETE == request.getMethod()) {
      response = doDelete(uri, createRequestData(request));
    } else if (RequestMethod.PATCH == request.getMethod()) {
      response = doPatch(uri, createRequestData(request));
    }

    ApiResponse apiResponse = new ApiResponse(request.getApiAction(), true);

    try {
      if (request.isCollectionElement()) {
        apiResponse.setData(deserialize.fromJson(response, request.getClazz()));
      } else {
        JsonObject rootObject = JsonParser.parseString(response).getAsJsonObject();
        JsonObject elementObject = rootObject.get(request.getElementName()).getAsJsonObject();
        fetchAddElement(Constants.RATE_LIMIT_ELEMENT_NAME, rootObject, elementObject);
        fetchAddElement(Constants.LINKS_ELEMENT_NAME, rootObject, elementObject);
        fetchAddElement(Constants.META_ELEMENT_NAME, rootObject, elementObject);
        apiResponse.setData(deserialize.fromJson(elementObject, request.getClazz()));
      }
    } catch (JsonSyntaxException jse) {
      log.error("Error occurred while parsing response", jse);
      apiResponse.setRequestSuccess(false);
    }

    log.debug("API Response:: " + apiResponse.toString());

    return apiResponse;
  }

  private void fetchAddElement(String key, JsonObject rootObject, JsonObject elementObject) {
    JsonElement ele = rootObject.get(key);
    if (null != ele) {
      elementObject.add(key, ele);
    }
  }

  private String doGet(URI uri) throws DigitalOceanException, RequestUnsuccessfulException {
    HttpGet get = new HttpGet(uri);
    get.setHeaders(requestHeaders);
    return executeHttpRequest(get);
  }

  private String doPost(URI uri, HttpEntity entity)
      throws DigitalOceanException, RequestUnsuccessfulException {
    HttpPost post = new HttpPost(uri);
    post.setHeaders(requestHeaders);

    if (null != entity) {
      post.setEntity(entity);
    }

    return executeHttpRequest(post);
  }

  private String doPut(URI uri, HttpEntity entity)
      throws DigitalOceanException, RequestUnsuccessfulException {
    HttpPut put = new HttpPut(uri);
    put.setHeaders(requestHeaders);

    if (null != entity) {
      put.setEntity(entity);
    }

    return executeHttpRequest(put);
  }

  private String doDelete(URI uri, HttpEntity entity)
      throws DigitalOceanException, RequestUnsuccessfulException {
    if (null == entity) {
      HttpDelete delete = new HttpDelete(uri);
      delete.setHeaders(requestHeaders);
      delete.setHeader(HttpHeaders.CONTENT_TYPE, JSON_CONTENT_TYPE);
      return executeHttpRequest(delete);
    }

    CustomHttpDelete delete = new CustomHttpDelete(uri);
    delete.setHeaders(requestHeaders);
    delete.setEntity(entity);
    return executeHttpRequest(delete);
  }

  private String doPatch(URI uri, HttpEntity entity)
      throws DigitalOceanException, RequestUnsuccessfulException {
    HttpPatch patch = new HttpPatch(uri);
    patch.setHeaders(requestHeaders);

    if (null != entity) {
      patch.setEntity(entity);
    }

    return executeHttpRequest(patch);
  }

  private String executeHttpRequest(HttpUriRequest request)
      throws DigitalOceanException, RequestUnsuccessfulException {
    log.debug("HTTP Request:: {} {}", request.getMethod(), request.getURI());
    String response = "";
    CloseableHttpResponse httpResponse = null;

    try {
      httpResponse = httpClient.execute(request);
      log.debug("HTTP Response Object:: {}", httpResponse);

      response = appendRateLimitValues(evaluateResponse(httpResponse), httpResponse);
      log.debug("Parsed Response:: {}", response);
    } catch (IOException ioe) {
      throw new RequestUnsuccessfulException(ioe.getMessage(), ioe);
    } finally {
      try {
        if (null != httpResponse) {
          httpResponse.close();
        }
      } catch (IOException e) {
        // Ignoring close exception, really no impact.
        // Since response object is 99.999999% success rate
        // this is nothing to do with DigitalOcean, its
        // typical handling of HttpClient request/response
        log.error("Error occurred while closing a response.", e);
      }
    }

    return response;
  }

  private String evaluateResponse(HttpResponse httpResponse) throws DigitalOceanException {
    int statusCode = httpResponse.getStatusLine().getStatusCode();
    String response = "";

    if (HttpStatus.SC_OK == statusCode
        || HttpStatus.SC_CREATED == statusCode
        || HttpStatus.SC_ACCEPTED == statusCode) {
      response = httpResponseToString(httpResponse);
    } else if (HttpStatus.SC_NO_CONTENT == statusCode) {
      // in a way its always true from client perspective if there is no exception.
      response = String.format(NO_CONTENT_JSON_STRUCT, statusCode);
    }

    if (statusCode >= 400 && statusCode < 510) {
      String jsonStr = httpResponseToString(httpResponse);
      log.debug("JSON Response: {}", jsonStr);

      JsonObject jsonObj = null;
      String errorMsg = StringUtils.EMPTY;
      String id = StringUtils.EMPTY;
      try {
        jsonObj = JsonParser.parseString(jsonStr).getAsJsonObject();
        id = jsonObj.get("id").getAsString();
        errorMsg = jsonObj.get("message").getAsString();
      } catch (JsonSyntaxException e) {
        errorMsg =
            "Digital Oceans server are on maintenance. Wait for official messages "
                + "from digital ocean itself. Such as 'Cloud Control Panel, API & Support Ticket System Unavailable'";
      }

      String errorMsgFull =
          String.format(
              "\nHTTP Status Code: %s\nError Id: %s\nError Message: %s", statusCode, id, errorMsg);
      log.debug(errorMsgFull);

      throw new DigitalOceanException(errorMsg, id, statusCode);
    }

    return response;
  }

  private String httpResponseToString(HttpResponse httpResponse) {
    String response = StringUtils.EMPTY;
    if (null != httpResponse.getEntity()) {
      try {
        response = EntityUtils.toString(httpResponse.getEntity(), UTF_8);
      } catch (ParseException pe) {
        log.error(pe.getMessage(), pe);
      } catch (IOException ioe) {
        log.error(ioe.getMessage(), ioe);
      }
    }
    return response;
  }

  private URI createUri(ApiRequest request) {
    URIBuilder ub = new URIBuilder();
    ub.setScheme(HTTPS_SCHEME);
    ub.setHost(apiHost);
    ub.setPath(createPath(request));

    if (null != request.getPageNo()) {
      ub.setParameter(PARAM_PAGE_NO, request.getPageNo().toString());
    }

    if (RequestMethod.GET == request.getMethod()) {
      if (null == request.getPerPage()) {
        ub.setParameter(PARAM_PER_PAGE, String.valueOf(DEFAULT_PAGE_SIZE)); // As per DO
        // documentation
      } else {
        ub.setParameter(PARAM_PER_PAGE, request.getPerPage().toString());
      }
    }

    if (null != request.getQueryParams()) {
      for (Map.Entry<String, String> entry : request.getQueryParams().entrySet()) {
        ub.setParameter(entry.getKey(), entry.getValue());
      }
    }

    URI uri = null;
    try {
      uri = ub.build();
    } catch (URISyntaxException use) {
      log.error(use.getMessage(), use);
    }

    return uri;
  }

  private String createPath(ApiRequest request) {
    String path = URL_PATH_SEPARATOR + apiVersion + request.getApiAction().getPath();
    return (null == request.getPathParams() ? path : String.format(path, request.getPathParams()));
  }

  private HttpEntity createRequestData(ApiRequest request) {
    StringEntity data = null;

    if (null != request.getData()) {
      String inputData = serialize.toJson(request.getData());
      try {
        data = new StringEntity(inputData);
      } catch (UnsupportedEncodingException e) {
        log.error(e.getMessage(), e);
      }
    }

    return data;
  }

  private String appendRateLimitValues(String response, HttpResponse httpResponse) {
    if (StringUtils.isBlank(response)) {
      return StringUtils.EMPTY;
    }

    // Occasionally the DigitalOcean API will fail to send rate limit headers.
    // Simply omit rate limit data in that case.
    String rateLimit = getSimpleHeaderValue(HDR_RATE_LIMIT, httpResponse);
    String rateRemaining = getSimpleHeaderValue(HDR_RATE_REMAINING, httpResponse);
    String rateReset = getSimpleHeaderValue(HDR_RATE_RESET, httpResponse);
    if (rateLimit == null || rateRemaining == null || rateReset == null) {
      return response;
    }

    String rateLimitData =
        String.format(
            RATE_LIMIT_JSON_STRUCT,
            rateLimit,
            rateRemaining,
            getDateString(rateReset, DATE_FORMAT));
    log.debug("RateLimitData:: {}", rateLimitData);

    return StringUtils.substringBeforeLast(response, "}") + ", " + rateLimitData + "}";
  }

  private String getDateString(String epochString, String dateFormat) {
    long epoch = Long.parseLong(epochString);
    Date expiry = new Date(epoch * 1000);

    SimpleDateFormat formatter = new SimpleDateFormat(dateFormat);
    String dateString = formatter.format(expiry);
    log.debug(dateString);

    return dateString;
  }

  /** Easy method for HTTP header values (first/last) */
  private String getSimpleHeaderValue(String header, HttpResponse httpResponse, boolean first) {
    if (StringUtils.isBlank(header)) {
      return StringUtils.EMPTY;
    }

    Header h;
    if (first) {
      h = httpResponse.getFirstHeader(header);
    } else {
      h = httpResponse.getLastHeader(header);
    }
    if (h == null) {
      return null;
    }
    return h.getValue();
  }

  /** Easy method for HTTP header values. defaults to first one. */
  private String getSimpleHeaderValue(String header, HttpResponse httpResponse) {
    return getSimpleHeaderValue(header, httpResponse, true);
  }

  // =======================================
  // Validation methods
  // =======================================

  private void validateDropletIdAndPageNo(Integer dropletId, Integer pageNo) {
    validateDropletId(dropletId);
    validatePageNo(pageNo);
  }

  private void validateSnapshotId(String snapshotId) {
    checkBlankAndThrowError(snapshotId, "Missing required parameter - snapshotId.");
  }

  private void validateDropletId(Integer dropletId) {
    checkNullAndThrowError(dropletId, "Missing required parameter - dropletId.");
  }

  private void validateLoadBalancerId(String loadBalancerId) {
    checkBlankAndThrowError(loadBalancerId, "Missing required parameter - loadBalancerId.");
  }

  private void validatePageNo(Integer pageNo) {
    checkNullAndThrowError(pageNo, "Missing required parameter - pageNo.");
  }

  private void validateProjectId(String projectId) {
    checkNullAndThrowError(projectId, "Missing required parameter - projectId.");
  }

  private void checkNullAndThrowError(Object obj, String msg) {
    if (null == obj) {
      log.error(msg);
      throw new IllegalArgumentException(msg);
    }
  }

  // It checks for null, whitespace and length
  private void checkBlankAndThrowError(String str, String msg) {
    if (StringUtils.isBlank(str)) {
      log.error(msg);
      throw new IllegalArgumentException(msg);
    }
  }

  private void validateForwardingRules(List<ForwardingRules> rules) {
    if (null == rules || rules.isEmpty()) {
      throw new IllegalArgumentException("Missing required parameters [ForwardingRules]");
    }

    for (ForwardingRules rule : rules) validateForwardingRule(rule);
  }

  private void validateForwardingRule(ForwardingRules rule) {
    if (null == rule
        || null == rule.getEntryProtocol()
        || null == rule.getEntryPort()
        || null == rule.getTargetProtocol()
        || null == rule.getTargetPort()) {
      throw new IllegalArgumentException(
          "Missing required parameters [Entry Protocol, Entry Port, Target Protocol, Target Port] for forwarding rules.");
    }
  }

  private void validateHealthCheck(HealthCheck healthCheck) {
    if (null != healthCheck
        && (null == healthCheck.getProtocol() || null == healthCheck.getPort())) {
      throw new IllegalArgumentException(
          "Missing required parameters [Protocol, Port] for health check");
    }
  }

  private void initialize() {
    this.deserialize = new GsonBuilder().setDateFormat(DATE_FORMAT).create();

    this.serialize =
        new GsonBuilder()
            .setDateFormat(DATE_FORMAT)
            .registerTypeAdapter(Droplet.class, new DropletSerializer())
            .registerTypeAdapter(Volume.class, new VolumeSerializer())
            .registerTypeAdapter(LoadBalancer.class, new LoadBalancerSerializer())
            .registerTypeAdapter(Firewall.class, new FirewallSerializer())
            .excludeFieldsWithoutExposeAnnotation()
            .create();

    Header[] headers = {
      new BasicHeader(HDR_USER_AGENT, USER_AGENT),
      new BasicHeader(HDR_CONTENT_TYPE, JSON_CONTENT_TYPE),
      new BasicHeader(HDR_AUTHORIZATION, "Bearer " + authToken)
    };
    log.debug("API Request Headers:: " + headers);

    this.requestHeaders = headers;

    if (null == this.httpClient) {
      this.httpClient = HttpClients.createDefault();
    }
  }
}