// Package lwApi is a minimalist API client to LiquidWeb's (https://www.liquidweb.com) API: // // https://cart.liquidweb.com/storm/api/docs/v1 // // https://cart.liquidweb.com/storm/api/docs/bleed // // As you might have guessed from the above API documentation links, there are API versions: // "v1" and "bleed". As the name suggests, if you always want the latest features and abilities, // use "bleed". If you want long term compatibility (at the cost of being a little further behind // sometimes), use "v1". package lwApi import ( "bytes" "crypto/tls" "encoding/json" "fmt" "io/ioutil" "net/http" "sync" "time" ) // A LWAPIConfig holds the configuration details used to call // the API with the client. type LWAPIConfig struct { Username *string Password *string Token *string Url string Timeout uint Insecure bool } // A Client holds the packages *LWAPIConfig and *http.Client. To get a *Client, call New. type Client struct { Headers http.Header config *LWAPIConfig httpClient *http.Client mutex sync.Mutex } // A LWAPIError is used to identify error responses when JSON unmarshalling json from a // byte slice. type LWAPIError struct { ErrorMsg string `json:"error,omitempty"` ErrorClass string `json:"error_class,omitempty"` ErrorFullMsg string `json:"full_message,omitempty"` } // Given a LWAPIError, returns a string containing the ErrorClass and ErrorFullMsg. func (e LWAPIError) Error() string { return fmt.Sprintf("%v: %v", e.ErrorClass, e.ErrorFullMsg) } // Given a LWAPIError, returns boolean if ErrorClass was present or not. You can // use this function to determine if a LWAPIRes response indicates an error or not. func (e LWAPIError) HadError() bool { return e.ErrorClass != "" } // LWAPIRes is a convenient interface used (for example) by CallInto to ensure a passed // struct knows how to indicate whether or not it had an error. type LWAPIRes interface { Error() string HadError() bool } // New takes a *LWAPIConfig, and gives you a *Client. If there's an error, it is returned. // When using this package, this should be the first function you call. Below is an example // that demonstrates creating the config and passing it to New. // // Example: // // username := "ExampleUsername" // password := "ExamplePassword" // // config := lwApi.LWAPIConfig{ // Username: &username, // Password: &password, // Url: "api.liquidweb.com", // } // apiClient, newErr := lwApi.New(&config) // if newErr != nil { // panic(newErr) // } func New(config *LWAPIConfig) (*Client, error) { if err := processConfig(config); err != nil { return nil, err } httpClient := &http.Client{Timeout: time.Duration(time.Duration(config.Timeout) * time.Second)} if config.Insecure { tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } httpClient.Transport = tr } headers := make(http.Header) client := Client{ config: config, httpClient: httpClient, Headers: headers, mutex: sync.Mutex{}, } return &client, nil } // Call takes a path, such as "network/zone/details" and a params structure. // It is recommended that the params be a map[string]interface{}, but you can use // anything that serializes to the right json structure. // A `interface{}` and an error are returned, in typical go fasion. // // Example: // // args := map[string]interface{}{ // "uniq_id": "ABC123", // } // got, gotErr := apiClient.Call("bleed/asset/details", args) // if gotErr != nil { // panic(gotErr) // } func (client *Client) Call(method string, params interface{}) (interface{}, error) { bsRb, err := client.CallRaw(method, params) if err != nil { return nil, err } var resp interface{} resp, err = client.callRawRespToInterface(bsRb) return resp, err } // CallInto is like call, but instead of returning an interface you pass it a // struct which is filled, much like the json.Unmarshal function. The struct // you pass must satisfy the LWAPIRes interface. If you embed the LWAPIError // struct from this package into your struct, this will be taken care of for you. // // Example: // // type ZoneDetails struct { // lwApi.LWAPIError // AvlZone string `json:"availability_zone"` // Desc string `json:"description"` // GatewayDevs []string `json:"gateway_devices"` // HvType string `json:"hv_type"` // ID int `json:"id"` // Legacy int `json:"legacy"` // Name string `json:"name"` // Status string `json:"status"` // SourceHVs []string `json:"valid_source_hvs"` // } // var zone ZoneDetails // err = apiClient.CallInto("network/zone/details", paramers, &zone) // if err != nil { // log.Fatal(err) // } // fmt.Printf("Got struct %#v\n", zone) func (client *Client) CallInto(method string, params interface{}, into LWAPIRes) error { bsRb, err := client.CallRaw(method, params) if err != nil { return err } err = json.Unmarshal(bsRb, into) if err != nil { return err } if into.HadError() { // the LWAPIRes satisfies the Error interface, so we can just return it on // error. return into } return nil } // Similar to CallInto(), but populates an interface without needing to satisfy // the LWAPIRes interface. // Example: // // type ZoneDetails struct { // AvlZone string `json:"availability_zone"` // Desc string `json:"description"` // GatewayDevs []string `json:"gateway_devices"` // HvType string `json:"hv_type"` // ID int `json:"id"` // Legacy int `json:"legacy"` // Name string `json:"name"` // Status string `json:"status"` // SourceHVs []string `json:"valid_source_hvs"` // } // var zone ZoneDetails // err = apiClient.CallIntoInterface("network/zone/details", params, &zone) func (client *Client) CallIntoInterface(method string, params interface{}, into interface{}) error { bsRb, err := client.CallRaw(method, params) if err != nil { return err } if _, err = client.callRawRespToInterface(bsRb, &into); err != nil { return err } return nil } // CallRaw is just like Call, except it returns the raw json as a byte slice. However, in contrast to // Call, CallRaw does *not* check the API response for LiquidWeb specific exceptions as defined in // the type LWAPIError. As such, if calling this function directly, you must check for LiquidWeb specific // exceptions yourself. // // Example: // // args := map[string]interface{}{ // "uniq_id": "ABC123", // } // got, gotErr := apiClient.CallRaw("bleed/asset/details", args) // if gotErr != nil { // panic(gotErr) // } // // Check got now for LiquidWeb specific exceptions, as described above. func (client *Client) CallRaw(method string, params interface{}) ([]byte, error) { config := client.config // api wants the "params" prefix key. Do it here so consumers dont have // to do this everytime. args := map[string]interface{}{ "params": params, } encodedArgs, encodeErr := json.Marshal(args) if encodeErr != nil { return nil, encodeErr } // formulate the HTTP POST request url := fmt.Sprintf("%s/%s", config.Url, method) req, reqErr := http.NewRequest("POST", url, bytes.NewReader(encodedArgs)) if reqErr != nil { return nil, reqErr } // We need a unique copy of the headers map in each request struct, otherwise // we can end up with a concurrent map access and a panic. client.mutex.Lock() for name, value := range client.Headers { newvalue := make([]string, len(value)) copy(newvalue, value) req.Header[name] = newvalue } client.mutex.Unlock() if config.Token != nil { // Oauth2 token req.Header.Add("Authorization", "Bearer "+*config.Token) } else if config.Username != nil && config.Password != nil { // HTTP basic auth req.SetBasicAuth(*config.Username, *config.Password) } else { return nil, fmt.Errorf("No valid credential provided") } // make the POST request resp, doErr := client.httpClient.Do(req) if doErr != nil { return nil, doErr } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("Bad HTTP response code [%d] from [%s]", resp.StatusCode, url) } // read the response body into a byte slice bsRb, readErr := ioutil.ReadAll(resp.Body) if readErr != nil { return nil, readErr } return bsRb, nil } /* private */ func processConfig(config *LWAPIConfig) error { if config.Url == "" { return fmt.Errorf("url is missing from config") } if config.Timeout == 0 { config.Timeout = 20 } if config.Token != nil { // Oauth2 token if *config.Token == "" { return fmt.Errorf("Bearer token provided, but empty") } } else if config.Username != nil && config.Password != nil { // HTTP basic auth if *config.Username == "" { return fmt.Errorf("provided username is empty") } if *config.Password == "" { return fmt.Errorf("provided password is empty") } } else { return fmt.Errorf("No valid credential provided") } return nil } func (client *Client) callRawRespToInterface(dataBytes []byte, into ...interface{}) (dataInter interface{}, err error) { var raw map[string]interface{} if err = json.Unmarshal(dataBytes, &raw); err == nil { if errorClass, exists := raw["error_class"]; exists { errorClassStr := fmt.Sprintf("%s", errorClass) if errorClassStr != "" { err = LWAPIError{ ErrorClass: errorClassStr, ErrorFullMsg: fmt.Sprintf("%s", raw["full_message"]), ErrorMsg: fmt.Sprintf("%s", raw["error"]), } return } } } else { return } if len(into) > 0 { dataInter = &into[0] } err = json.Unmarshal(dataBytes, &dataInter) return }