diff --git a/docs/data-sources/object.md b/docs/data-sources/object.md index 5b9081a5..3c8c5226 100644 --- a/docs/data-sources/object.md +++ b/docs/data-sources/object.md @@ -24,6 +24,7 @@ description: |- - **debug** (Boolean, Optional) Whether to emit verbose debug output while working with the API object on the server. - **id** (String, Optional) The ID of this resource. - **id_attribute** (String, Optional) Defaults to `id_attribute` set on the provider. Allows per-resource override of `id_attribute` (see `id_attribute` provider config documentation) +- **id_attribute_url** (Boolean, Optional) Defaults to `id_attribute_url` set on the provider. Allows per-resource override of `id_attribute_url` (see `id_attribute_url` provider config documentation) - **query_string** (String, Optional) An optional query string to send when performing the search. - **read_query_string** (String, Optional) Defaults to `query_string` set on data source. This key allows setting a different or empty query string for reading the object. - **results_key** (String, Optional) When issuing a GET to the path, this JSON key is used to locate the results array. The format is 'field/field/field'. Example: 'results/values'. If omitted, it is assumed the results coming back are already an array and are to be used exactly as-is. diff --git a/docs/index.md b/docs/index.md index 5c9b4c78..936f3b88 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,6 +28,7 @@ description: |- - **destroy_method** (String, Optional) Defaults to `DELETE`. The HTTP method used to DELETE objects of this type on the API server. - **headers** (Map of String, Optional) A map of header names and values to set on all outbound requests. This is useful if you want to use a script via the 'external' provider or provide a pre-approved token or change Content-Type from `application/json`. If `username` and `password` are set and Authorization is one of the headers defined here, the BASIC auth credentials take precedence. - **id_attribute** (String, Optional) When set, this key will be used to operate on REST objects. For example, if the ID is set to 'name', changes to the API object will be to http://foo.com/bar/VALUE_OF_NAME. This value may also be a '/'-delimited path to the id attribute if it is multiple levels deep in the data (such as `attributes/id` in the case of an object `{ "attributes": { "id": 1234 }, "config": { "name": "foo", "something": "bar"}}` +- **id_attribute_url** (Boolean, Optional) When set, the key specified in `id_attribute` will be parsed as a URL to extract the ID. - **insecure** (Boolean, Optional) When using https, this disables TLS verification of the host. - **key_file** (String, Optional) When set with the cert_file parameter, the provider will load a client certificate as a file for mTLS authentication. Note that this mechanism simply delegates to golang's tls.LoadX509KeyPair which does not support passphrase protected private keys. The most robust security protections available to the key_file are simple file system permissions. - **key_string** (String, Optional) When set with the cert_string parameter, the provider will load a client certificate as a string for mTLS authentication. Note that this mechanism simply delegates to golang's tls.LoadX509KeyPair which does not support passphrase protected private keys. The most robust security protections available to the key_file are simple file system permissions. diff --git a/restapi/api_client.go b/restapi/api_client.go index b8d31f5e..170efd37 100644 --- a/restapi/api_client.go +++ b/restapi/api_client.go @@ -27,6 +27,7 @@ type apiClientOpt struct { headers map[string]string timeout int idAttribute string + idAttributeUrl bool createMethod string readMethod string updateMethod string @@ -60,6 +61,7 @@ type APIClient struct { password string headers map[string]string idAttribute string + idAttributeUrl bool createMethod string readMethod string updateMethod string @@ -159,6 +161,7 @@ func NewAPIClient(opt *apiClientOpt) (*APIClient, error) { password: opt.password, headers: opt.headers, idAttribute: opt.idAttribute, + idAttributeUrl: opt.idAttributeUrl, createMethod: opt.createMethod, readMethod: opt.readMethod, updateMethod: opt.updateMethod, @@ -197,6 +200,7 @@ func (client *APIClient) toString() string { buffer.WriteString(fmt.Sprintf("username: %s\n", client.username)) buffer.WriteString(fmt.Sprintf("password: %s\n", client.password)) buffer.WriteString(fmt.Sprintf("id_attribute: %s\n", client.idAttribute)) + buffer.WriteString(fmt.Sprintf("id_attribute_url: %t\n", client.idAttributeUrl)) buffer.WriteString(fmt.Sprintf("write_returns_object: %t\n", client.writeReturnsObject)) buffer.WriteString(fmt.Sprintf("create_returns_object: %t\n", client.createReturnsObject)) buffer.WriteString("headers:\n") @@ -319,6 +323,41 @@ func (client *APIClient) sendRequest(method string, path string, data string) (s return body, fmt.Errorf("unexpected response code '%d': %s", resp.StatusCode, body) } + location := resp.Header.Get("Location") + if client.debug { + log.Printf("api_client.go: Found Location: '%s'", location) + } + + if resp.StatusCode == 201 && location != "" { + locationURL, err := url.Parse(location) + if err != nil { + return "", fmt.Errorf("could not parse location header data: '%s'", location) + } + + req.URL = locationURL + req.Method = "GET" + + resp, err := client.httpClient.Do(req) + if err != nil { + return "", err + } + + bodyBytes, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return "", err + } + + body = strings.TrimPrefix(string(bodyBytes), client.xssiPrefix) + if client.debug { + log.Printf("api_client.go: BODY:\n%s\n", body) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return body, fmt.Errorf("unexpected response code '%d': %s", resp.StatusCode, body) + } + } + return body, nil } diff --git a/restapi/api_object.go b/restapi/api_object.go index 771e8215..551edad9 100644 --- a/restapi/api_object.go +++ b/restapi/api_object.go @@ -12,43 +12,45 @@ import ( ) type apiObjectOpts struct { - path string - getPath string - postPath string - putPath string - createMethod string - readMethod string - updateMethod string - updateData string - destroyMethod string - destroyData string - deletePath string - searchPath string - queryString string - debug bool - readSearch map[string]string - id string - idAttribute string - data string + path string + getPath string + postPath string + putPath string + createMethod string + readMethod string + updateMethod string + updateData string + destroyMethod string + destroyData string + deletePath string + searchPath string + queryString string + debug bool + readSearch map[string]string + id string + idAttribute string + idAttributeUrl bool + data string } /*APIObject is the state holding struct for a restapi_object resource*/ type APIObject struct { - apiClient *APIClient - getPath string - postPath string - putPath string - createMethod string - readMethod string - updateMethod string - destroyMethod string - deletePath string - searchPath string - queryString string - debug bool - readSearch map[string]string - id string - idAttribute string + apiClient *APIClient + getPath string + postPath string + putPath string + createMethod string + readMethod string + updateMethod string + destroyMethod string + deletePath string + searchPath string + queryString string + debug bool + readSearch map[string]string + id string + idAttribute string + idAttributeUrl bool /* Set internally */ data map[string]interface{} /* Data as managed by the user */ @@ -65,14 +67,18 @@ func NewAPIObject(iClient *APIClient, opts *apiObjectOpts) (*APIObject, error) { log.Printf(" id: %s\n", opts.id) } - /* id_attribute can be set either on the client (to apply for all calls with the server) - or on a per object basis (for only calls to this kind of object). - Permit overridding from the API client here by using the client-wide value only - if a per-object value is not set */ + /* id_attribute and id_attribute_url can be set either on the client (to apply + for all calls with the server) or on a per object basis (for only calls to + this kind of object). Permit overridding from the API client here by using + the client-wide value only if a per-object value is not set */ if opts.idAttribute == "" { opts.idAttribute = iClient.idAttribute } + if opts.idAttributeUrl { + opts.idAttributeUrl = iClient.idAttributeUrl + } + if opts.createMethod == "" { opts.createMethod = iClient.createMethod } @@ -108,25 +114,26 @@ func NewAPIObject(iClient *APIClient, opts *apiObjectOpts) (*APIObject, error) { } obj := APIObject{ - apiClient: iClient, - getPath: opts.getPath, - postPath: opts.postPath, - putPath: opts.putPath, - createMethod: opts.createMethod, - readMethod: opts.readMethod, - updateMethod: opts.updateMethod, - destroyMethod: opts.destroyMethod, - deletePath: opts.deletePath, - searchPath: opts.searchPath, - queryString: opts.queryString, - debug: opts.debug, - readSearch: opts.readSearch, - id: opts.id, - idAttribute: opts.idAttribute, - data: make(map[string]interface{}), - updateData: make(map[string]interface{}), - destroyData: make(map[string]interface{}), - apiData: make(map[string]interface{}), + apiClient: iClient, + getPath: opts.getPath, + postPath: opts.postPath, + putPath: opts.putPath, + createMethod: opts.createMethod, + readMethod: opts.readMethod, + updateMethod: opts.updateMethod, + destroyMethod: opts.destroyMethod, + deletePath: opts.deletePath, + searchPath: opts.searchPath, + queryString: opts.queryString, + debug: opts.debug, + readSearch: opts.readSearch, + id: opts.id, + idAttribute: opts.idAttribute, + idAttributeUrl: opts.idAttributeUrl, + data: make(map[string]interface{}), + updateData: make(map[string]interface{}), + destroyData: make(map[string]interface{}), + apiData: make(map[string]interface{}), } if opts.data != "" { @@ -239,6 +246,12 @@ func (obj *APIObject) updateState(state string) error { if err != nil { return fmt.Errorf("api_object.go: Error extracting ID from data element: %s", err) } + if obj.idAttributeUrl { + val, err = parseIdAsURL(val) + if err != nil { + return fmt.Errorf("api_object.go: Error extracting ID as URL: %s", err) + } + } obj.id = val } else if obj.debug { log.Printf("api_object.go: Not updating id. It is already set to '%s'\n", obj.id) @@ -268,7 +281,7 @@ func (obj *APIObject) createObject() error { with the id of whatever gets created, we have no way to know what the object's id will be. Abandon this attempt */ if obj.id == "" && !obj.apiClient.writeReturnsObject && !obj.apiClient.createReturnsObject { - return fmt.Errorf("provided object does not have an id set and the client is not configured to read the object from a POST or PUT response; please set write_returns_object to true, or include an id in the object's data") + return fmt.Errorf("provided object does not have an id set and the client is not configured to read the object from a POST or PUT response; please set write_returns_object or create_returns_object to true, or include an id in the object's data") } b, _ := json.Marshal(obj.data) @@ -516,11 +529,19 @@ func (obj *APIObject) findObject(queryString string, searchKey string, searchVal /* We found our record */ if tmp == searchValue { objFound = hash - obj.id, err = GetStringAtKey(hash, obj.idAttribute, obj.debug) + val, err := GetStringAtKey(hash, obj.idAttribute, obj.debug) if err != nil { return objFound, (fmt.Errorf("failed to find id_attribute '%s' in the record: %s", obj.idAttribute, err)) } + if obj.idAttributeUrl { + val, err = parseIdAsURL(val) + if err != nil { + return objFound, fmt.Errorf("api_object.go: Error extracting ID as URL: %s", err) + } + } + obj.id = val + if obj.debug { log.Printf("api_object.go: Found ID '%s'", obj.id) } diff --git a/restapi/common.go b/restapi/common.go index 7c831645..01245785 100755 --- a/restapi/common.go +++ b/restapi/common.go @@ -6,6 +6,7 @@ import ( "os" "strconv" "strings" + "net/url" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -22,6 +23,25 @@ func setResourceState(obj *APIObject, d *schema.ResourceData) { d.Set("api_response", obj.apiResponse) } + +func parseIdAsURL(object_url string) (string, error) { + parsedUrl, err := url.Parse(object_url) + if err != nil { + return "", fmt.Errorf("could not parse url: %v", err) + } + + segments := strings.Split(strings.TrimRight(parsedUrl.Path, "/"), "/") + + object_id := segments[len(segments)-1] + + if object_id == "" { + return "", fmt.Errorf("could not extract id from %s", object_url) + } + + return object_id, nil +} + + /*GetStringAtKey uses GetObjectAtKey to verify the resulting object is either a JSON string or Number and returns it as a string */ func GetStringAtKey(data map[string]interface{}, path string, debug bool) (string, error) { @@ -41,6 +61,7 @@ func GetStringAtKey(data map[string]interface{}, path string, debug bool) (strin } } + /*GetObjectAtKey is a handy helper that will dig through a map and find something at the defined key. The returned data is not type checked Example: diff --git a/restapi/common_test.go b/restapi/common_test.go index 5bfff75f..76cb576c 100644 --- a/restapi/common_test.go +++ b/restapi/common_test.go @@ -163,3 +163,34 @@ func TestGetListStringAtKey(t *testing.T) { t.Fatalf("Error: Expected '2', but got %s", res) } } + +func TestParseIdAsURL(t *testing.T) { + testCases := []struct { + url string + expectedID string + shouldError bool + expectedErr string + }{ + {"https://example.com/path/to/89d364bc-d738-4594-86ad-c12f4c437500", "89d364bc-d738-4594-86ad-c12f4c437500", false, ""}, + {"https://example.com/path/to/1234/", "1234", false, ""}, + {"https://example.com", "", true, "could not extract id from https://example.com"}, + } + + for _, tc := range testCases { + actualID, err := parseIdAsURL(tc.url) + + if tc.shouldError { + if err == nil || err.Error() != tc.expectedErr { + t.Errorf("Expected error '%s' but got '%v'", tc.expectedErr, err) + } + } else { + if err != nil { + t.Errorf("Expected no error but got '%v'", err) + } + + if actualID != tc.expectedID { + t.Errorf("Expected ID '%s' but got '%s'", tc.expectedID, actualID) + } + } + } +} \ No newline at end of file diff --git a/restapi/provider.go b/restapi/provider.go index 6dab97af..57a3a31e 100644 --- a/restapi/provider.go +++ b/restapi/provider.go @@ -60,6 +60,12 @@ func Provider() *schema.Provider { DefaultFunc: schema.EnvDefaultFunc("REST_API_ID_ATTRIBUTE", nil), Description: "When set, this key will be used to operate on REST objects. For example, if the ID is set to 'name', changes to the API object will be to http://foo.com/bar/VALUE_OF_NAME. This value may also be a '/'-delimeted path to the id attribute if it is multple levels deep in the data (such as `attributes/id` in the case of an object `{ \"attributes\": { \"id\": 1234 }, \"config\": { \"name\": \"foo\", \"something\": \"bar\"}}`", }, + "id_attribute_url": { + Type: schema.TypeBool, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("REST_API_ID_ATTRIBUTE_URL", nil), + Description: "When set, the key specified in `id_attribute` will be parsed as a URL to extract the ID.", + }, "create_method": { Type: schema.TypeString, DefaultFunc: schema.EnvDefaultFunc("REST_API_CREATE_METHOD", nil), @@ -233,6 +239,7 @@ func configureProvider(d *schema.ResourceData) (interface{}, error) { useCookies: d.Get("use_cookies").(bool), timeout: d.Get("timeout").(int), idAttribute: d.Get("id_attribute").(string), + idAttributeUrl: d.Get("id_attribute_url").(bool), copyKeys: copyKeys, writeReturnsObject: d.Get("write_returns_object").(bool), createReturnsObject: d.Get("create_returns_object").(bool), diff --git a/restapi/resource_api_object.go b/restapi/resource_api_object.go index 16bf8603..da6baaa1 100644 --- a/restapi/resource_api_object.go +++ b/restapi/resource_api_object.go @@ -77,6 +77,11 @@ func resourceRestAPI() *schema.Resource { Description: "Defaults to `id_attribute` set on the provider. Allows per-resource override of `id_attribute` (see `id_attribute` provider config documentation)", Optional: true, }, + "id_attribute_url": { + Type: schema.TypeString, + Description: "Defaults to `id_attribute_url` set on the provider. Allows per-resource override of `id_attribute_url` (see `id_attribute_url` provider config documentation)", + Optional: true, + }, "object_id": { Type: schema.TypeString, Description: "Defaults to the id learned by the provider during normal operations and `id_attribute`. Allows you to set the id manually. This is used in conjunction with the `*_path` attributes.",