├── .travis.yml ├── terraform-registry-manifest.json ├── examples ├── data-sources │ └── restapi_object │ │ └── data-source.tf ├── resources │ └── restapi_object │ │ ├── resource.tf │ │ └── import.sh ├── provider │ └── provider.tf └── workingexamples │ ├── provider_with_oauth.tf │ ├── provider_with_headers.tf │ ├── update_field_with_put_request.tf │ └── dummy_users_with_fakeserver.tf ├── .release_info.md ├── tools └── tools.go ├── .gitignore ├── scripts ├── set-local-testing.rc ├── test.sh ├── do_release.sh ├── upload-github-release-asset.sh └── create-github-release.sh ├── SECURITY.md ├── main.go ├── fakeservercli ├── main.go └── README.md ├── .github └── workflows │ ├── gpginfo.yaml │ └── release.yaml ├── restapi ├── import_api_object_test.go ├── provider_test.go ├── delta_checker.go ├── common_test.go ├── common.go ├── datasource_api_object_test.go ├── datasource_api_object.go ├── resource_api_object_test.go ├── api_client_test.go ├── api_client.go ├── delta_checker_test.go ├── api_object_test.go ├── provider.go ├── resource_api_object.go └── api_object.go ├── .goreleaser.yml ├── docs ├── data-sources │ └── object.md ├── resources │ └── object.md └── index.md ├── go.mod ├── fakeserver └── fakeserver.go ├── README.md ├── LICENSE └── go.sum /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.x 4 | 5 | -------------------------------------------------------------------------------- /terraform-registry-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "metadata": { 4 | "protocol_versions": ["5.0"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/data-sources/restapi_object/data-source.tf: -------------------------------------------------------------------------------- 1 | data "restapi_object" "John" { 2 | path = "/api/objects" 3 | search_key = "first" 4 | search_value = "John" 5 | } -------------------------------------------------------------------------------- /.release_info.md: -------------------------------------------------------------------------------- 1 | ## Fixed 2 | - Fix a case where the provider crashes during delta comparison if `null` is a value in the `data` field. Thanks for the report in #287, @lonelyelk! 3 | -------------------------------------------------------------------------------- /examples/resources/restapi_object/resource.tf: -------------------------------------------------------------------------------- 1 | resource "restapi_object" "Foo2" { 2 | provider = restapi.restapi_headers 3 | path = "/api/objects" 4 | data = "{ \"id\": \"55555\", \"first\": \"Foo\", \"last\": \"Bar\" }" 5 | } 6 | -------------------------------------------------------------------------------- /examples/resources/restapi_object/import.sh: -------------------------------------------------------------------------------- 1 | # identifier: // 2 | 3 | # Examples: 4 | terraform import restapi_object.objects /api/objects 5 | terraform import restapi_object.object /api/objects/123 6 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tools 5 | 6 | import ( 7 | // document generation 8 | _ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs" 9 | ) 10 | -------------------------------------------------------------------------------- /examples/provider/provider.tf: -------------------------------------------------------------------------------- 1 | provider "restapi" { 2 | uri = "https://api.url.com" 3 | write_returns_object = true 4 | debug = true 5 | 6 | headers = { 7 | "X-Auth-Token" = var.AUTH_TOKEN, 8 | "Content-Type" = "application/json" 9 | } 10 | 11 | create_method = "PUT" 12 | update_method = "PUT" 13 | destroy_method = "PUT" 14 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | main.tf 2 | .terraform 3 | terraform-provider-restapi 4 | terraform-provider-restapi.exe 5 | terraform.tfstate 6 | terraform.tfstate.backup 7 | crash.log 8 | examples/terraform* 9 | **/github_api_token 10 | fakeservercli/fakeservercli 11 | scripts/terraform-provider-restapi* 12 | scripts/fakeserver* 13 | tmp 14 | tmpgpg 15 | dist 16 | .idea 17 | .terraform.lock.hcl 18 | dev.tfrc 19 | -------------------------------------------------------------------------------- /scripts/set-local-testing.rc: -------------------------------------------------------------------------------- 1 | if [ ! -f README.md ];then 2 | echo "Run this command from the root of the project directory" 3 | else 4 | FILE="dev.tfrc" 5 | CFG=`realpath $FILE` 6 | ROOTDIR=`dirname $CFG` 7 | 8 | echo " 9 | provider_installation { 10 | dev_overrides { 11 | \"Mastercard/restapi\" = \"$ROOTDIR\" 12 | } 13 | } 14 | " > "$FILE" 15 | 16 | export TF_CLI_CONFIG_FILE="$CFG" 17 | fi 18 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | cd $(dirname $0) 4 | 5 | export PATH="$PATH:$HOME/go/bin" 6 | 7 | export GOOS="" 8 | export GOARCH="" 9 | 10 | [[ -z "${GOPATH}" ]] && export GOPATH=$HOME/go 11 | 12 | cd ../restapi 13 | 14 | echo "Running tests..." 15 | if ! go test "$@";then 16 | echo "Failed testing. Aborting." 17 | exit 1 18 | fi 19 | 20 | #echo "Vetting result..." 21 | #go vet ./... 22 | 23 | #echo "Checking for linting..." 24 | #golint -set_exit_status ./... 25 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | To ensure you have the latest fixes/security updates, be sure to use the latest published version in our GitHub releases. This also comes for free by using the latest version published via the Terraform registry! 6 | 7 | ## Reporting a Vulnerability 8 | 9 | For potential vulnerabilities in this provider, please open a GitHub issue and tag the project maintainer directly for attention. 10 | 11 | Vulnerabilities will be acknowledged within 1 week of reporting. 12 | -------------------------------------------------------------------------------- /examples/workingexamples/provider_with_oauth.tf: -------------------------------------------------------------------------------- 1 | provider "restapi" { 2 | alias = "restapi_oauth" 3 | uri = "http://127.0.0.1:8080/" 4 | debug = true 5 | write_returns_object = true 6 | 7 | oauth_client_credentials { 8 | oauth_client_id = "example" 9 | oauth_client_secret = "example" 10 | oauth_token_endpoint = "https://example.com/tokenendpoint" 11 | oauth_scopes = ["openid"] 12 | endpoint_params = { 13 | audience = "myCoolAPI" 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Mastercard/terraform-provider-restapi/restapi" 5 | 6 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 7 | "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" 8 | ) 9 | 10 | // Generate the Terraform provider documentation using `tfplugindocs`: 11 | //go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs 12 | 13 | func main() { 14 | plugin.Serve(&plugin.ServeOpts{ 15 | ProviderFunc: func() *schema.Provider { 16 | return restapi.Provider() 17 | }, 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /fakeservercli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | fakeserver "github.com/Mastercard/terraform-provider-restapi/fakeserver" 9 | ) 10 | 11 | func main() { 12 | apiServerObjects := make(map[string]map[string]interface{}) 13 | 14 | port := flag.Int("port", 8080, "The port fakeserver will listen on") 15 | debug := flag.Bool("debug", false, "Enable debug output of the server") 16 | staticDir := flag.String("static_dir", "", "Serve static content from this directory") 17 | 18 | flag.Parse() 19 | 20 | svr := fakeserver.NewFakeServer(*port, apiServerObjects, false, *debug, *staticDir) 21 | 22 | fmt.Printf("Starting server on port %d...\n", *port) 23 | fmt.Println("Objects are at /api/objects/{id}") 24 | 25 | internalServer := svr.GetServer() 26 | err := internalServer.ListenAndServe() 27 | if nil != err { 28 | fmt.Printf("Error with the internal TCP server: %s", err) 29 | os.Exit(1) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/gpginfo.yaml: -------------------------------------------------------------------------------- 1 | name: GPG Info 2 | on: workflow_dispatch 3 | jobs: 4 | goreleaser: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - 8 | name: Import GPG key 9 | id: import_gpg 10 | uses: hashicorp/ghaction-import-gpg@v2.1.0 11 | env: 12 | # These secrets will need to be configured for the repository: 13 | GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} 14 | PASSPHRASE: ${{ secrets.PASSPHRASE }} 15 | - 16 | name: Get default key info 17 | run: | 18 | echo "GPG fingerprint: $GPG_FINGERPRINT" 19 | echo "" 20 | echo "ASCII armor export:" 21 | gpg --armor --export $GPG_FINGERPRINT 22 | echo "" 23 | echo "ASCII armor export + base64:" 24 | gpg --armor --export $GPG_FINGERPRINT | openssl enc -a 25 | env: 26 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 27 | -------------------------------------------------------------------------------- /examples/workingexamples/provider_with_headers.tf: -------------------------------------------------------------------------------- 1 | #This example demonstrates how you can pass external parameters into Terraform for use 2 | # in this provider. For example, if you would like to pass a secret authorization token 3 | # in from the environment, you could execute the following shell commands: 4 | #export TF_VAR_SECRET_TOKEN=$(some_special_thing_to_get_credential) 5 | #terraform apply 6 | # 7 | #This will cause the provider to send an HTTP request to the server with the following headers 8 | # X-Internal-Client: abc123 9 | # Authorization: 10 | 11 | variable "SECRET_TOKEN" { 12 | type = string 13 | } 14 | 15 | provider "restapi" { 16 | alias = "restapi_headers" 17 | uri = "http://127.0.0.1:8080/" 18 | debug = true 19 | write_returns_object = true 20 | 21 | headers = { 22 | X-Internal-Client = "abc123" 23 | Authorization = var.SECRET_TOKEN 24 | } 25 | } 26 | 27 | resource "restapi_object" "Foo2" { 28 | provider = restapi.restapi_headers 29 | path = "/api/objects" 30 | data = "{ \"id\": \"55555\", \"first\": \"Foo\", \"last\": \"Bar\" }" 31 | } 32 | -------------------------------------------------------------------------------- /examples/workingexamples/update_field_with_put_request.tf: -------------------------------------------------------------------------------- 1 | # This example demonstrate how you can use this provider to update one or more values 2 | # with a PUT method like you are doing it with curl. 3 | # To accomplish this you will need to update the default methods for create and destroy, ensure they are set to PUT. 4 | # You will also need to modifiy the path in the "restapi_object" resource to make it conform for a PUT request. 5 | # (Like you can see in the logs when a PUT request is called the provider add a {id} to the default path) 6 | # This example may work for each HTTP REQUEST METHOD if you adapt correctly the path and the "method" fields. 7 | 8 | provider "restapi" { 9 | uri = "https://api.url.com" 10 | write_returns_object = true 11 | debug = true 12 | 13 | headers = { 14 | "X-Auth-Token" = var.AUTH_TOKEN, 15 | "Content-Type" = "application/json" 16 | } 17 | 18 | create_method = "PUT" 19 | update_method = "PUT" 20 | destroy_method = "PUT" 21 | } 22 | 23 | resource "restapi_object" "put_request" { 24 | path = "/instance/v1/zones/fr-par-1/security_groups/{id}" 25 | id_attribute = var.ID 26 | object_id = var.ID 27 | data = "{ \"id\": \"${var.ID}\",\"FIELD_TO_UPDATE\": update_1 }" 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This GitHub action can publish assets for release when a tag is created. 2 | # Currently its setup to run on any tag that matches the pattern "v*" (ie. v0.1.0). 3 | # 4 | # This uses an action (hashicorp/ghaction-import-gpg) that assumes you set your 5 | # private key in the `GPG_PRIVATE_KEY` secret and passphrase in the `PASSPHRASE` 6 | # secret. If you would rather own your own GPG handling, please fork this action 7 | # or use an alternative one for key handling. 8 | # 9 | # You will need to pass the `--batch` flag to `gpg` in your signing step 10 | # in `goreleaser` to indicate this is being used in a non-interactive mode. 11 | # 12 | name: release 13 | on: 14 | push: 15 | tags: 16 | - 'v[0-9]+.[0-9]+.[0-9]+' 17 | permissions: 18 | contents: write 19 | jobs: 20 | goreleaser: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - 24 | name: Checkout 25 | uses: actions/checkout@v3 26 | - 27 | name: Unshallow 28 | run: git fetch --prune --unshallow 29 | - 30 | name: Set up Go 31 | uses: actions/setup-go@v3 32 | with: 33 | go-version-file: 'go.mod' 34 | cache: true 35 | - 36 | name: Import GPG key 37 | uses: crazy-max/ghaction-import-gpg@v5 38 | id: import_gpg 39 | with: 40 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 41 | passphrase: ${{ secrets.PASSPHRASE }} 42 | - 43 | name: Run GoReleaser 44 | uses: goreleaser/goreleaser-action@v3.0.0 45 | with: 46 | version: latest 47 | args: release --parallelism 2 --clean --timeout 1h --release-notes .release_info.md 48 | env: 49 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 50 | # GitHub sets this automatically 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | -------------------------------------------------------------------------------- /fakeservercli/README.md: -------------------------------------------------------------------------------- 1 | # A fake API object server you can start from the command line 2 | 3 | Fakeserver is used by the testing suite and is wrapped by a simple CLI tool that allows you to start it outside of the test suite. 4 | 5 | There are only a few options for `fakeservercli`: 6 | `-port` (int) - the port on 127.0.0.1 the fakeserver will bind to. Defaults to 8080 7 | `-debug` - Will produce verbose information to STDOUT on requests and responses 8 | `-static_dir` - When set, will serve files in this directory under the path /static/[name_of_file] 9 | 10 | Once running, fakeserver is expecting you to populate it with data that means whatever you like it to mean. 11 | 12 | There are a few things to know: 13 | - All objects are at `/api/objects/{id}` 14 | - `id` is a required field. It is how `fakeserver` finds the objects 15 | - All objects are internally represented and returned as strings for both keys and values 16 | - A GET to an ID will print the JSON representation of the object 17 | - A POST to `/api/objects` will save the object in memory and return the JSON representation of the object 18 | - A PUT to `/api/objects/{id}` will update the object at that location with the data sent (fields removed are not preserved) 19 | - A DELETE to `/api/objects/{id}` will remove the object at that ID from memory 20 | 21 | ### Populate the fakeserver 22 | ``` 23 | curl 127.0.0.1:8080/api/objects -X POST -d '{ "id": "1", "name": "Foo"}' 24 | curl 127.0.0.1:8080/api/objects -X POST -d '{ "id": "2", "name": "Bar"}' 25 | curl 127.0.0.1:8080/api/objects -X POST -d '{ "id": "3", "name": "Baz"}' 26 | ``` 27 | 28 | ### Rename an object 29 | This example changes the name of 'Baz' to 'Biz' 30 | ``` 31 | curl 127.0.0.1:8080/api/objects/3 -X PUT -d '{ "id": "3", "name": "Biz"}' 32 | ``` 33 | 34 | ### Delete an object 35 | This example just deletes the object with id=3 36 | ``` 37 | curl 127.0.0.1:8080/api/objects/3 -X DELETE 38 | ``` 39 | -------------------------------------------------------------------------------- /scripts/do_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | usage (){ 4 | echo "$0 - Tag and prepare a release 5 | USAGE: $0 (major|minor|patch|vX.Y.Z) 6 | The argument may be one of: 7 | major - Increments the current major version and performs the release 8 | minor - Increments the current minor version and preforms the release 9 | patch - Increments the current patch version and preforms the release 10 | vX.Y.Z - Sets the tag to the value of vX.Y.Z where X=major, Y=minor, and Z=patch 11 | " 12 | exit 1 13 | } 14 | 15 | if [ -z "$1" -o -n "$2" ];then 16 | usage 17 | fi 18 | 19 | TAG=`git describe --tags --abbrev=0` 20 | VERSION="${TAG#[vV]}" 21 | MAJOR="${VERSION%%\.*}" 22 | MINOR="${VERSION#*.}" 23 | MINOR="${MINOR%.*}" 24 | PATCH="${VERSION##*.}" 25 | echo "Current tag: v$MAJOR.$MINOR.$PATCH" 26 | 27 | #Determine what the user wanted 28 | case $1 in 29 | major) 30 | MAJOR=$((MAJOR+1)) 31 | MINOR=0 32 | PATCH=0 33 | TAG="v$MAJOR.$MINOR.$PATCH" 34 | ;; 35 | minor) 36 | MINOR=$((MINOR+1)) 37 | PATCH=0 38 | TAG="v$MAJOR.$MINOR.$PATCH" 39 | ;; 40 | patch) 41 | PATCH=$((PATCH+1)) 42 | TAG="v$MAJOR.$MINOR.$PATCH" 43 | ;; 44 | v*.*.*) 45 | TAG="$1" 46 | ;; 47 | *.*.*) 48 | TAG="v$1" 49 | ;; 50 | *) 51 | usage 52 | ;; 53 | esac 54 | 55 | echo "New tag: $TAG" 56 | 57 | #Build the docs first 58 | cd $(dirname $0) 59 | WORK_DIR=$(pwd) 60 | cd ../ 61 | 62 | go generate ./... 63 | DIFFOUTPUT=`git diff docs` 64 | if [ -n "$DIFFOUTPUT" ];then 65 | git commit -m 'Update docs before release' docs 66 | git push 67 | fi 68 | 69 | export REST_API_URI="http://127.0.0.1:8082" 70 | [[ -z "${GOPATH}" ]] && export GOPATH=$HOME/go 71 | export CGO_ENABLED=0 72 | 73 | #Get into the right directory and build/test 74 | cd "$WORK_DIR" 75 | ./test.sh 76 | cd ../ 77 | 78 | vi .release_info.md 79 | 80 | git commit -m "Changes for $TAG" .release_info.md 81 | 82 | git tag $TAG 83 | git push origin 84 | git push origin $TAG 85 | -------------------------------------------------------------------------------- /restapi/import_api_object_test.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/Mastercard/terraform-provider-restapi/fakeserver" 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 9 | ) 10 | 11 | func TestAccRestApiObject_importBasic(t *testing.T) { 12 | debug := false 13 | apiServerObjects := make(map[string]map[string]interface{}) 14 | 15 | svr := fakeserver.NewFakeServer(8082, apiServerObjects, true, debug, "") 16 | os.Setenv("REST_API_URI", "http://127.0.0.1:8082") 17 | 18 | opt := &apiClientOpt{ 19 | uri: "http://127.0.0.1:8082/", 20 | insecure: false, 21 | username: "", 22 | password: "", 23 | headers: make(map[string]string), 24 | timeout: 2, 25 | idAttribute: "id", 26 | copyKeys: make([]string, 0), 27 | writeReturnsObject: false, 28 | createReturnsObject: false, 29 | debug: debug, 30 | } 31 | client, err := NewAPIClient(opt) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | client.sendRequest("POST", "/api/objects", `{ "id": "1234", "first": "Foo", "last": "Bar" }`) 36 | 37 | resource.UnitTest(t, resource.TestCase{ 38 | Providers: testAccProviders, 39 | PreCheck: func() { svr.StartInBackground() }, 40 | Steps: []resource.TestStep{ 41 | { 42 | Config: generateTestResource( 43 | "Foo", 44 | `{ "id": "1234", "first": "Foo", "last": "Bar" }`, 45 | make(map[string]interface{}), 46 | ), 47 | }, 48 | { 49 | ResourceName: "restapi_object.Foo", 50 | ImportState: true, 51 | ImportStateId: "1234", 52 | ImportStateIdPrefix: "/api/objects/", 53 | ImportStateVerify: true, 54 | /* create_response isn't populated during import (we don't know the API response from creation) */ 55 | ImportStateVerifyIgnore: []string{"debug", "data", "create_response", "ignore_all_server_changes"}, 56 | }, 57 | }, 58 | }) 59 | 60 | svr.Shutdown() 61 | } 62 | -------------------------------------------------------------------------------- /examples/workingexamples/dummy_users_with_fakeserver.tf: -------------------------------------------------------------------------------- 1 | # In this example, we are using the fakeserver available with this provider 2 | # to create and manage imaginary users in our imaginary API server 3 | # https://github.com/Mastercard/terraform-provider-restapi/tree/master/fakeserver 4 | 5 | # To use this example fully, start up fakeserver and run this command 6 | # curl 127.0.0.1:8080/api/objects -X POST -d '{ "id": "8877", "first": "John", "last": "Doe" }' 7 | # curl 127.0.0.1:8080/api/objects -X POST -d '{ "id": "4433", "first": "Dave", "last": "Roe" }' 8 | # 9 | # After running terraform apply, you will now see three objects in the API server: 10 | # curl 127.0.0.1:8080/api/objects | jq 11 | 12 | provider "restapi" { 13 | uri = "http://127.0.0.1:8080/" 14 | debug = true 15 | write_returns_object = true 16 | } 17 | 18 | # This will make information about the user named "John Doe" available by finding him by first name 19 | data "restapi_object" "John" { 20 | path = "/api/objects" 21 | search_key = "first" 22 | search_value = "John" 23 | } 24 | 25 | # You can import the existing Dave Roe resource by executing: 26 | # terraform import restapi_object.Dave /api/objects/4433 27 | # ... and then manage it here, too! 28 | # The import will pull in Dave Roe, but the subsequent terraform apply will change the record to "Dave Boe" 29 | resource "restapi_object" "Dave" { 30 | path = "/api/objects" 31 | data = "{ \"id\": \"4433\", \"first\": \"Dave\", \"last\": \"Boe\" }" 32 | } 33 | # This will ADD the user named "Foo" as a managed resource 34 | resource "restapi_object" "Foo" { 35 | path = "/api/objects" 36 | data = "{ \"id\": \"1234\", \"first\": \"Foo\", \"last\": \"Bar\" }" 37 | } 38 | 39 | #Congrats to Jane and John! They got married. Give them the same last name by using variable interpolation 40 | resource "restapi_object" "Jane" { 41 | path = "/api/objects" 42 | data = "{ \"id\": \"7788\", \"first\": \"Jane\", \"last\": \"${data.restapi_object.John.api_data.last}\" }" 43 | } 44 | 45 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Visit https://goreleaser.com for documentation on how to customize this 2 | # behavior. 3 | version: 2 4 | 5 | before: 6 | hooks: 7 | # this is just an example and not a requirement for provider building/publishing 8 | - go mod tidy 9 | builds: 10 | - env: 11 | # goreleaser does not work with CGO, it could also complicate 12 | # usage by users in CI/CD systems like Terraform Cloud where 13 | # they are unable to install libraries. 14 | - CGO_ENABLED=0 15 | mod_timestamp: '{{ .CommitTimestamp }}' 16 | flags: 17 | - -trimpath 18 | ldflags: 19 | - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' 20 | goos: 21 | - freebsd 22 | - windows 23 | - linux 24 | - darwin 25 | goarch: 26 | - amd64 27 | - '386' 28 | - arm 29 | - arm64 30 | ignore: 31 | - goos: darwin 32 | goarch: '386' 33 | binary: '{{ .ProjectName }}_v{{ .Version }}' 34 | archives: 35 | - format: zip 36 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' 37 | checksum: 38 | extra_files: 39 | - glob: 'terraform-registry-manifest.json' 40 | name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' 41 | name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' 42 | algorithm: sha256 43 | signs: 44 | - artifacts: checksum 45 | args: 46 | # if you are using this in a GitHub action or some other automated pipeline, you 47 | # need to pass the batch flag to indicate its not interactive. 48 | - "--batch" 49 | - "--local-user" 50 | - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key 51 | - "--output" 52 | - "${signature}" 53 | - "--detach-sign" 54 | - "${artifact}" 55 | release: 56 | extra_files: 57 | - glob: 'terraform-registry-manifest.json' 58 | name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' 59 | # If you want to manually examine the release before its live, uncomment this line: 60 | # draft: true 61 | changelog: 62 | disable: true 63 | -------------------------------------------------------------------------------- /scripts/upload-github-release-asset.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Author: Stefan Buck 4 | # License: MIT 5 | # https://gist.github.com/stefanbuck/ce788fee19ab6eb0b4447a85fc99f447 6 | # 7 | # 8 | # This script accepts the following parameters: 9 | # 10 | # * owner 11 | # * repo 12 | # * tag 13 | # * filename 14 | # * github_api_token 15 | # 16 | # Script to upload a release asset using the GitHub API v3. 17 | # 18 | # Example: 19 | # 20 | # upload-github-release-asset.sh github_api_token=TOKEN owner=stefanbuck repo=playground tag=v0.1.0 filename=./build.zip 21 | # 22 | 23 | # Check dependencies. 24 | set -e 25 | xargs=$(which gxargs || which xargs) 26 | 27 | # Validate settings. 28 | [ "$TRACE" ] && set -x 29 | 30 | CONFIG=$@ 31 | 32 | for line in $CONFIG; do 33 | eval "$line" 34 | done 35 | 36 | # Define variables. 37 | GH_API="https://api.github.com" 38 | GH_REPO="$GH_API/repos/$owner/$repo" 39 | GH_TAGS="$GH_REPO/releases/tags/$tag" 40 | AUTH="Authorization: token $github_api_token" 41 | WGET_ARGS="--content-disposition --auth-no-challenge --no-cookie" 42 | CURL_ARGS="-LJO#" 43 | 44 | if [[ "$tag" == 'LATEST' ]]; then 45 | GH_TAGS="$GH_REPO/releases/latest" 46 | fi 47 | 48 | if [[ -z "$github_api_token" || -z "$owner" || -z "$repo" || -z "$filename" ]];then 49 | echo "USAGE: $0 github_api_token=TOKEN owner=someone repo=somerepo tag=vX.Y.Z filename=/path/to/file" 50 | exit 1 51 | fi 52 | 53 | if [[ ! -f "$filename" ]];then 54 | echo "ERROR: $filename does not exist" 55 | exit 1 56 | fi 57 | 58 | # Validate token. 59 | curl -o /dev/null -sH "$AUTH" $GH_REPO || { echo "Error: Invalid repo, token or network issue!"; exit 1; } 60 | 61 | # Read asset tags. 62 | response=$(curl -sH "$AUTH" $GH_TAGS) 63 | 64 | # Get ID of the asset based on given filename. 65 | eval $(echo "$response" | grep -m 1 "id.:" | grep -w id | tr : = | tr -cd '[[:alnum:]]=') 66 | [ "$id" ] || { echo "Error: Failed to get release id for tag: $tag"; echo "$response" | awk 'length($0)<100' >&2; exit 1; } 67 | 68 | # Upload asset 69 | echo "Uploading asset $filename... " 70 | 71 | # Construct url 72 | GH_ASSET="https://uploads.github.com/repos/$owner/$repo/releases/$id/assets?name=$(basename $filename)" 73 | 74 | curl --data-binary @"$filename" -H "Authorization: token $github_api_token" -H "Content-Type: application/octet-stream" $GH_ASSET 75 | echo 76 | -------------------------------------------------------------------------------- /scripts/create-github-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Author: Mastercard 4 | # License: Apache 2 5 | # 6 | # This script accepts the following parameters: 7 | # 8 | # * owner 9 | # * repo 10 | # * tag 11 | # * filename 12 | # * github_api_token 13 | # * draft 14 | # 15 | # Script to create a release GitHub API v3 16 | # 17 | # Example: 18 | # 19 | # create-github-release.sh github_api_token=TOKEN owner=stefanbuck repo=playground tag=v0.1.0 filename=./build.zip draft=true 20 | # 21 | 22 | # Check dependencies. 23 | set -e 24 | xargs=$(which gxargs || which xargs) 25 | 26 | # Validate settings. 27 | [ "$TRACE" ] && set -x 28 | 29 | CONFIG=$@ 30 | 31 | for line in $CONFIG; do 32 | eval "$line" 33 | done 34 | 35 | # Define variables. 36 | GH_API="https://api.github.com" 37 | GH_REPO="$GH_API/repos/$owner/$repo" 38 | GH_TAGS="$GH_REPO/releases/tags/$tag" 39 | AUTH="Authorization: token $github_api_token" 40 | WGET_ARGS="--content-disposition --auth-no-challenge --no-cookie" 41 | CURL_ARGS="-LJO#" 42 | 43 | if [[ "$draft" != 'true' ]]; then 44 | draft="false" 45 | fi 46 | 47 | if [[ "$tag" == 'LATEST' ]]; then 48 | GH_TAGS="$GH_REPO/releases/latest" 49 | fi 50 | 51 | if [[ -z "$github_api_token" || -z "$owner" || -z "$repo" ]];then 52 | echo "USAGE: $0 github_api_token=TOKEN owner=someone repo=somerepo tag=vX.Y.Z" 53 | exit 1 54 | fi 55 | 56 | # Validate token. 57 | curl -o /dev/null -sH "$AUTH" $GH_REPO || { echo "Error: Invalid repo, token or network issue!"; exit 1; } 58 | 59 | if [[ ! -f release_info.md ]];then 60 | echo "release_info.md file does not exist. Creating it now - hit enter to continue." 61 | read JUNK 62 | echo "## New" > release_info.md 63 | echo " - " >> release_info.md 64 | echo "" >> release_info.md 65 | echo "## Fixed" >> release_info.md 66 | echo " - " >> release_info.md 67 | echo "" >> release_info.md 68 | vi release_info.md 69 | if [[ ! -f release_info.md || -z "$(cat release_info.md)" ]];then 70 | echo "release_info.md file does not exist or is empty. I will not proceed." 71 | exit 1 72 | fi 73 | fi 74 | 75 | release_info=$(cat release_info.md) 76 | RELEASE_JSON=$(jq -n \ 77 | --arg tag "$tag" \ 78 | --arg name "$tag" \ 79 | --arg body "$release_info" \ 80 | '{ "tag_name": $tag, "name": $name, "body": $body, "draft": '$draft'}' \ 81 | ) 82 | 83 | # Send the data 84 | response=$(curl -sH "$AUTH" "$GH_REPO/releases" -X POST -d "$RELEASE_JSON") 85 | if [[ $? != 0 ]]; then 86 | echo "Non-zero response from curl command. Output follows:" 87 | echo "$response" 88 | exit 1 89 | fi 90 | -------------------------------------------------------------------------------- /docs/data-sources/object.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "restapi_object Data Source - terraform-provider-restapi" 4 | subcategory: "" 5 | description: |- 6 | Performs a cURL get command on the specified url. 7 | --- 8 | 9 | # restapi_object (Data Source) 10 | 11 | Performs a cURL get command on the specified url. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "restapi_object" "John" { 17 | path = "/api/objects" 18 | search_key = "first" 19 | search_value = "John" 20 | } 21 | ``` 22 | 23 | 24 | ## Schema 25 | 26 | ### Required 27 | 28 | - `path` (String) The API path on top of the base URL set in the provider that represents objects of this type on the API server. 29 | - `search_key` (String) When reading search results from the API, this key is used to identify the specific record to read. This should be a unique record such as 'name'. Similar to results_key, the value may be in the format of 'field/field/field' to search for data deeper in the returned object. 30 | - `search_value` (String) The value of 'search_key' will be compared to this value to determine if the correct object was found. Example: if 'search_key' is 'name' and 'search_value' is 'foo', the record in the array returned by the API with name=foo will be used. 31 | 32 | ### Optional 33 | 34 | - `debug` (Boolean) Whether to emit verbose debug output while working with the API object on the server. 35 | - `id_attribute` (String) Defaults to `id_attribute` set on the provider. Allows per-resource override of `id_attribute` (see `id_attribute` provider config documentation) 36 | - `query_string` (String) An optional query string to send when performing the search. 37 | - `read_query_string` (String) Defaults to `query_string` set on data source. This key allows setting a different or empty query string for reading the object. 38 | - `results_key` (String) 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. 39 | - `search_data` (String) Valid JSON object to pass to search request as body 40 | - `search_path` (String) The API path on top of the base URL set in the provider that represents the location to search for objects of this type on the API server. If not set, defaults to the value of path. 41 | 42 | ### Read-Only 43 | 44 | - `api_data` (Map of String) After data from the API server is read, this map will include k/v pairs usable in other terraform resources as readable objects. Currently the value is the golang fmt package's representation of the value (simple primitives are set as expected, but complex types like arrays and maps contain golang formatting). 45 | - `api_response` (String) The raw body of the HTTP response from the last read of the object. 46 | - `id` (String) The ID of this resource. 47 | -------------------------------------------------------------------------------- /restapi/provider_test.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/Mastercard/terraform-provider-restapi/fakeserver" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 11 | ) 12 | 13 | var testAccProvider *schema.Provider 14 | var testAccProviders map[string]*schema.Provider 15 | 16 | func init() { 17 | testAccProvider = Provider() 18 | testAccProviders = map[string]*schema.Provider{ 19 | "restapi": testAccProvider, 20 | } 21 | } 22 | 23 | func TestProvider(t *testing.T) { 24 | if err := Provider().InternalValidate(); err != nil { 25 | t.Fatalf("err: %v", err) 26 | } 27 | } 28 | 29 | func TestProvider_impl(t *testing.T) { 30 | var _ *schema.Provider = Provider() 31 | } 32 | 33 | func TestResourceProvider_RequireBasic(t *testing.T) { 34 | rp := Provider() 35 | raw := map[string]interface{}{} 36 | 37 | /* 38 | XXX: This is expected to work even though we are not 39 | explicitly declaring the required url parameter since 40 | the test suite is run with the ENV entry set. 41 | */ 42 | err := rp.Configure(context.TODO(), terraform.NewResourceConfigRaw(raw)) 43 | if err != nil { 44 | t.Fatalf("Provider failed with error: %v", err) 45 | } 46 | } 47 | 48 | func TestResourceProvider_Oauth(t *testing.T) { 49 | rp := Provider() 50 | raw := map[string]interface{}{ 51 | "uri": "http://foo.bar/baz", 52 | "oauth_client_credentials": map[string]interface{}{ 53 | "oauth_client_id": "test", 54 | "oauth_client_credentials": map[string]interface{}{ 55 | "audience": "coolAPI", 56 | }, 57 | }, 58 | } 59 | 60 | /* 61 | XXX: This is expected to work even though we are not 62 | explicitly declaring the required url parameter since 63 | the test suite is run with the ENV entry set. 64 | */ 65 | err := rp.Configure(context.TODO(), terraform.NewResourceConfigRaw(raw)) 66 | if err != nil { 67 | t.Fatalf("Provider failed with error: %v", err) 68 | } 69 | } 70 | 71 | func TestResourceProvider_RequireTestPath(t *testing.T) { 72 | debug := false 73 | apiServerObjects := make(map[string]map[string]interface{}) 74 | 75 | svr := fakeserver.NewFakeServer(8085, apiServerObjects, true, debug, "") 76 | svr.StartInBackground() 77 | 78 | rp := Provider() 79 | raw := map[string]interface{}{ 80 | "uri": "http://127.0.0.1:8085/", 81 | "test_path": "/api/objects", 82 | } 83 | 84 | err := rp.Configure(context.TODO(), terraform.NewResourceConfigRaw(raw)) 85 | if err != nil { 86 | t.Fatalf("Provider config failed when visiting %v at %v but it did not!", raw["test_path"], raw["uri"]) 87 | } 88 | 89 | /* Now test the inverse */ 90 | rp = Provider() 91 | raw = map[string]interface{}{ 92 | "uri": "http://127.0.0.1:8085/", 93 | "test_path": "/api/apaththatdoesnotexist", 94 | } 95 | 96 | err = rp.Configure(context.TODO(), terraform.NewResourceConfigRaw(raw)) 97 | if err == nil { 98 | t.Fatalf("Provider was expected to fail when visiting %v at %v but it did not!", raw["test_path"], raw["uri"]) 99 | } 100 | 101 | svr.Shutdown() 102 | } 103 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Mastercard/terraform-provider-restapi 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 9 | github.com/hashicorp/terraform-json v0.17.1 // indirect; forced so test cases pass 10 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.27.0 11 | golang.org/x/oauth2 v0.27.0 12 | golang.org/x/time v0.3.0 13 | ) 14 | 15 | require github.com/hashicorp/terraform-plugin-docs v0.16.0 16 | 17 | require ( 18 | github.com/Masterminds/goutils v1.1.1 // indirect 19 | github.com/Masterminds/semver/v3 v3.1.1 // indirect 20 | github.com/Masterminds/sprig/v3 v3.2.2 // indirect 21 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect 22 | github.com/agext/levenshtein v1.2.2 // indirect 23 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 24 | github.com/armon/go-radix v1.0.0 // indirect 25 | github.com/bgentry/speakeasy v0.1.0 // indirect 26 | github.com/cloudflare/circl v1.6.1 // indirect 27 | github.com/fatih/color v1.13.0 // indirect 28 | github.com/golang/protobuf v1.5.3 // indirect 29 | github.com/google/go-cmp v0.5.9 // indirect 30 | github.com/google/uuid v1.3.0 // indirect 31 | github.com/hashicorp/errwrap v1.1.0 // indirect 32 | github.com/hashicorp/go-checkpoint v0.5.0 // indirect 33 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 34 | github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect 35 | github.com/hashicorp/go-hclog v1.5.0 // indirect 36 | github.com/hashicorp/go-multierror v1.1.1 // indirect 37 | github.com/hashicorp/go-plugin v1.4.10 // indirect 38 | github.com/hashicorp/go-uuid v1.0.3 // indirect 39 | github.com/hashicorp/go-version v1.6.0 // indirect 40 | github.com/hashicorp/hc-install v0.5.2 // indirect 41 | github.com/hashicorp/hcl/v2 v2.17.0 // indirect 42 | github.com/hashicorp/logutils v1.0.0 // indirect 43 | github.com/hashicorp/terraform-exec v0.18.1 // indirect 44 | github.com/hashicorp/terraform-plugin-go v0.16.0 // indirect 45 | github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect 46 | github.com/hashicorp/terraform-registry-address v0.2.1 // indirect 47 | github.com/hashicorp/terraform-svchost v0.1.1 // indirect 48 | github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect 49 | github.com/huandu/xstrings v1.3.2 // indirect 50 | github.com/imdario/mergo v0.3.13 // indirect 51 | github.com/mattn/go-colorable v0.1.13 // indirect 52 | github.com/mattn/go-isatty v0.0.16 // indirect 53 | github.com/mitchellh/cli v1.1.5 // indirect 54 | github.com/mitchellh/copystructure v1.2.0 // indirect 55 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 56 | github.com/mitchellh/go-wordwrap v1.0.0 // indirect 57 | github.com/mitchellh/mapstructure v1.5.0 // indirect 58 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 59 | github.com/oklog/run v1.0.0 // indirect 60 | github.com/posener/complete v1.2.3 // indirect 61 | github.com/russross/blackfriday v1.6.0 // indirect 62 | github.com/shopspring/decimal v1.3.1 // indirect 63 | github.com/spf13/cast v1.5.0 // indirect 64 | github.com/stretchr/testify v1.8.1 // indirect 65 | github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect 66 | github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect 67 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 68 | github.com/zclconf/go-cty v1.13.2 // indirect 69 | golang.org/x/crypto v0.45.0 // indirect 70 | golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect 71 | golang.org/x/mod v0.29.0 // indirect 72 | golang.org/x/net v0.47.0 // indirect 73 | golang.org/x/sys v0.38.0 // indirect 74 | golang.org/x/text v0.31.0 // indirect 75 | google.golang.org/appengine v1.6.7 // indirect 76 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230706202418-f51705677e13 // indirect 77 | google.golang.org/grpc v1.56.3 // indirect 78 | google.golang.org/protobuf v1.33.0 // indirect 79 | ) 80 | -------------------------------------------------------------------------------- /restapi/delta_checker.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | ) 7 | 8 | /* 9 | * Performs a deep comparison of two maps - the resource as recorded in state, and the resource as returned by the API. 10 | * Accepts a third argument that is a set of fields that are to be ignored when looking for differences. 11 | * Returns 1. the recordedResource overlaid with fields that have been modified in actualResource but not ignored, and 2. a bool true if there were any changes. 12 | */ 13 | func getDelta(recordedResource map[string]interface{}, actualResource map[string]interface{}, ignoreList []string) (modifiedResource map[string]interface{}, hasChanges bool) { 14 | modifiedResource = map[string]interface{}{} 15 | hasChanges = false 16 | 17 | // Keep track of keys we've already checked in actualResource to reduce work when checking keys in actualResource 18 | checkedKeys := map[string]struct{}{} 19 | 20 | for key, valRecorded := range recordedResource { 21 | 22 | checkedKeys[key] = struct{}{} 23 | 24 | // If the ignore_list contains the current key, don't compare 25 | if contains(ignoreList, key) { 26 | modifiedResource[key] = valRecorded 27 | continue 28 | } 29 | 30 | valActual, actualHasKey := actualResource[key] 31 | 32 | if valRecorded == nil { 33 | // A JSON null was put in input data, confirm the result is either not set or is also null 34 | modifiedResource[key] = valActual 35 | if actualHasKey && valActual != nil { 36 | hasChanges = true 37 | } 38 | } else if reflect.TypeOf(valRecorded).Kind() == reflect.Map { 39 | // If valRecorded was a map, assert both values are maps 40 | subMapA, okA := valRecorded.(map[string]interface{}) 41 | subMapB, okB := valActual.(map[string]interface{}) 42 | if !okA || !okB { 43 | modifiedResource[key] = valActual 44 | hasChanges = true 45 | continue 46 | } 47 | // Recursively compare 48 | deeperIgnoreList := _descendIgnoreList(key, ignoreList) 49 | if modifiedSubResource, hasChange := getDelta(subMapA, subMapB, deeperIgnoreList); hasChange { 50 | modifiedResource[key] = modifiedSubResource 51 | hasChanges = true 52 | } else { 53 | modifiedResource[key] = valRecorded 54 | } 55 | } else if reflect.TypeOf(valRecorded).Kind() == reflect.Slice { 56 | // Since we don't support ignoring differences in lists (besides ignoring the list as a 57 | // whole), it is safe to deep compare the two list values. 58 | if !reflect.DeepEqual(valRecorded, valActual) { 59 | modifiedResource[key] = valActual 60 | hasChanges = true 61 | } else { 62 | modifiedResource[key] = valRecorded 63 | } 64 | } else if valRecorded != valActual { 65 | modifiedResource[key] = valActual 66 | hasChanges = true 67 | } else { 68 | // In this case, the recorded and actual values were the same 69 | modifiedResource[key] = valRecorded 70 | } 71 | 72 | } 73 | 74 | for key, valActual := range actualResource { 75 | // We may have already compared this key with recordedResource 76 | _, alreadyChecked := checkedKeys[key] 77 | if alreadyChecked { 78 | continue 79 | } 80 | 81 | // If the ignore_list contains the current key, don't compare. 82 | // Don't modify modifiedResource either - we don't want this key to be tracked 83 | if contains(ignoreList, key) { 84 | continue 85 | } 86 | 87 | // If we've gotten here, that means actualResource has an additional key that wasn't in recordedResource 88 | modifiedResource[key] = valActual 89 | hasChanges = true 90 | } 91 | 92 | return modifiedResource, hasChanges 93 | } 94 | 95 | /* 96 | * Modifies an ignoreList to be relative to a descended path. 97 | * E.g. given descendPath = "bar", and the ignoreList [foo, bar.alpha, bar.bravo], this returns [alpha, bravo] 98 | */ 99 | func _descendIgnoreList(descendPath string, ignoreList []string) []string { 100 | newIgnoreList := make([]string, len(ignoreList)) 101 | 102 | for _, ignorePath := range ignoreList { 103 | pathComponents := strings.Split(ignorePath, ".") 104 | // If this ignorePath starts with the descendPath, remove the first component and keep the rest 105 | if pathComponents[0] == descendPath { 106 | modifiedPath := strings.Join(pathComponents[1:], ".") 107 | newIgnoreList = append(newIgnoreList, modifiedPath) 108 | } 109 | } 110 | 111 | return newIgnoreList 112 | } 113 | 114 | func contains(list []string, elem string) bool { 115 | for _, a := range list { 116 | if a == elem { 117 | return true 118 | } 119 | } 120 | return false 121 | } 122 | -------------------------------------------------------------------------------- /docs/resources/object.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "restapi_object Resource - terraform-provider-restapi" 4 | subcategory: "" 5 | description: |- 6 | Acting as a wrapper of cURL, this object supports POST, GET, PUT and DELETE on the specified url 7 | --- 8 | 9 | # restapi_object (Resource) 10 | 11 | Acting as a wrapper of cURL, this object supports POST, GET, PUT and DELETE on the specified url 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "restapi_object" "Foo2" { 17 | provider = restapi.restapi_headers 18 | path = "/api/objects" 19 | data = "{ \"id\": \"55555\", \"first\": \"Foo\", \"last\": \"Bar\" }" 20 | } 21 | ``` 22 | 23 | 24 | ## Schema 25 | 26 | ### Required 27 | 28 | - `data` (String) Valid JSON object that this provider will manage with the API server. 29 | - `path` (String) The API path on top of the base URL set in the provider that represents objects of this type on the API server. 30 | 31 | ### Optional 32 | 33 | - `create_method` (String) Defaults to `create_method` set on the provider. Allows per-resource override of `create_method` (see `create_method` provider config documentation) 34 | - `create_path` (String) Defaults to `path`. The API path that represents where to CREATE (POST) objects of this type on the API server. The string `{id}` will be replaced with the terraform ID of the object if the data contains the `id_attribute`. 35 | - `debug` (Boolean) Whether to emit verbose debug output while working with the API object on the server. 36 | - `destroy_data` (String) Valid JSON object to pass during to destroy requests. 37 | - `destroy_method` (String) Defaults to `destroy_method` set on the provider. Allows per-resource override of `destroy_method` (see `destroy_method` provider config documentation) 38 | - `destroy_path` (String) Defaults to `path/{id}`. The API path that represents where to DESTROY (DELETE) objects of this type on the API server. The string `{id}` will be replaced with the terraform ID of the object. 39 | - `force_new` (List of String) Any changes to these values will result in recreating the resource instead of updating. 40 | - `id_attribute` (String) Defaults to `id_attribute` set on the provider. Allows per-resource override of `id_attribute` (see `id_attribute` provider config documentation) 41 | - `ignore_all_server_changes` (Boolean) By default Terraform will attempt to revert changes to remote resources. Set this to 'true' to ignore any remote changes. Default: false 42 | - `ignore_changes_to` (List of String) A list of fields to which remote changes will be ignored. For example, an API might add or remove metadata, such as a 'last_modified' field, which Terraform should not attempt to correct. To ignore changes to nested fields, use the dot syntax: 'metadata.timestamp' 43 | - `object_id` (String) 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. 44 | - `query_string` (String) Query string to be included in the path 45 | - `read_data` (String) Valid JSON object to pass during read requests. 46 | - `read_method` (String) Defaults to `read_method` set on the provider. Allows per-resource override of `read_method` (see `read_method` provider config documentation) 47 | - `read_path` (String) Defaults to `path/{id}`. The API path that represents where to READ (GET) objects of this type on the API server. The string `{id}` will be replaced with the terraform ID of the object. 48 | - `read_search` (Map of String) Custom search for `read_path`. This map will take `search_data`, `search_key`, `search_value`, `results_key` and `query_string` (see datasource config documentation) 49 | - `update_data` (String) Valid JSON object to pass during to update requests. 50 | - `update_method` (String) Defaults to `update_method` set on the provider. Allows per-resource override of `update_method` (see `update_method` provider config documentation) 51 | - `update_path` (String) Defaults to `path/{id}`. The API path that represents where to UPDATE (PUT) objects of this type on the API server. The string `{id}` will be replaced with the terraform ID of the object. 52 | 53 | ### Read-Only 54 | 55 | - `api_data` (Map of String) After data from the API server is read, this map will include k/v pairs usable in other terraform resources as readable objects. Currently the value is the golang fmt package's representation of the value (simple primitives are set as expected, but complex types like arrays and maps contain golang formatting). 56 | - `api_response` (String) The raw body of the HTTP response from the last read of the object. 57 | - `create_response` (String) The raw body of the HTTP response returned when creating the object. 58 | - `id` (String) The ID of this resource. 59 | 60 | ## Import 61 | 62 | Import is supported using the following syntax: 63 | 64 | ```shell 65 | # identifier: // 66 | 67 | # Examples: 68 | terraform import restapi_object.objects /api/objects 69 | terraform import restapi_object.object /api/objects/123 70 | ``` 71 | -------------------------------------------------------------------------------- /restapi/common_test.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 11 | ) 12 | 13 | func testAccCheckRestapiObjectExists(n string, id string, client *APIClient) resource.TestCheckFunc { 14 | return func(s *terraform.State) error { 15 | rs, ok := s.RootModule().Resources[n] 16 | if !ok { 17 | keys := make([]string, 0, len(s.RootModule().Resources)) 18 | for k := range s.RootModule().Resources { 19 | keys = append(keys, k) 20 | } 21 | return fmt.Errorf("RestAPI object not found in terraform state: %s. Found: %s", n, strings.Join(keys, ", ")) 22 | } 23 | 24 | if rs.Primary.ID == "" { 25 | return fmt.Errorf("RestAPI object id not set in terraform") 26 | } 27 | 28 | /* Make a throw-away API object to read from the API */ 29 | path := "/api/objects" 30 | opts := &apiObjectOpts{ 31 | path: path, 32 | id: id, 33 | idAttribute: "id", 34 | data: "{}", 35 | debug: true, 36 | } 37 | obj, err := NewAPIObject(client, opts) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | err = obj.readObject() 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | } 50 | 51 | func TestGetStringAtKey(t *testing.T) { 52 | debug := false 53 | testObj := make(map[string]interface{}) 54 | err := json.Unmarshal([]byte(` 55 | { 56 | "rootFoo": "bar", 57 | "top": { 58 | "foo": "bar", 59 | "number": 1234567890, 60 | "float": 1.23456789, 61 | "middle": { 62 | "bottom": { 63 | "foo": "bar" 64 | } 65 | }, 66 | "list": [ 67 | "bar", 68 | "baz" 69 | ] 70 | }, 71 | "trueFalse": true 72 | } 73 | `), &testObj) 74 | if nil != err { 75 | t.Fatalf("Error unmarshalling JSON: %s", err) 76 | } 77 | 78 | var res string 79 | 80 | res, err = GetStringAtKey(testObj, "rootFoo", debug) 81 | if err != nil { 82 | t.Fatalf("Error extracting 'rootFoo' from JSON payload: %s", err) 83 | } else if res != "bar" { 84 | t.Fatalf("Error: Expected 'bar', but got %s", res) 85 | } 86 | 87 | res, err = GetStringAtKey(testObj, "trueFalse", debug) 88 | if err != nil { 89 | t.Fatalf("Error extracting 'trueFalse' from JSON payload: %s", err) 90 | } else if res != "true" { 91 | t.Fatalf("Error: Expected 'true', but got %s", res) 92 | } 93 | 94 | res, err = GetStringAtKey(testObj, "top/foo", debug) 95 | if err != nil { 96 | t.Fatalf("Error extracting 'top/foo' from JSON payload: %s", err) 97 | } else if res != "bar" { 98 | t.Fatalf("Error: Expected 'bar', but got %s", res) 99 | } 100 | 101 | res, err = GetStringAtKey(testObj, "top/middle/bottom/foo", debug) 102 | if err != nil { 103 | t.Fatalf("Error extracting top/foo from JSON payload: %s", err) 104 | } else if res != "bar" { 105 | t.Fatalf("Error: Expected 'bar', but got %s", res) 106 | } 107 | 108 | _, err = GetStringAtKey(testObj, "top/middle/junk", debug) 109 | if err == nil { 110 | t.Fatalf("Error expected when trying to extract 'top/middle/junk' from payload") 111 | } 112 | 113 | res, err = GetStringAtKey(testObj, "top/number", debug) 114 | if err != nil { 115 | t.Fatalf("Error extracting 'top/number' from JSON payload: %s", err) 116 | } else if res != "1234567890" { 117 | t.Fatalf("Error: Expected '1234567890', but got %s", res) 118 | } 119 | 120 | res, err = GetStringAtKey(testObj, "top/float", debug) 121 | if err != nil { 122 | t.Fatalf("Error extracting 'top/float' from JSON payload: %s", err) 123 | } else if res != "1.23456789" { 124 | t.Fatalf("Error: Expected '1.23456789', but got %s", res) 125 | } 126 | } 127 | 128 | func TestGetListStringAtKey(t *testing.T) { 129 | debug := false 130 | testObj := make(map[string]interface{}) 131 | err := json.Unmarshal([]byte(` 132 | { 133 | "rootFoo": "bar", 134 | "items": [ 135 | { 136 | "foo": "bar", 137 | "number": 1, 138 | "list_numbers": [1, 2, 3], 139 | "test": [{"id": "3333"}, {"id": "1337"}], 140 | "resource": { 141 | "id": "123" 142 | } 143 | } 144 | ] 145 | } 146 | `), &testObj) 147 | if nil != err { 148 | t.Fatalf("Error unmarshalling JSON: %s", err) 149 | } 150 | 151 | var res string 152 | 153 | res, err = GetStringAtKey(testObj, "items/0/resource/id", debug) 154 | if err != nil { 155 | t.Fatalf("Error extracting 'resource' from JSON payload: %s", err) 156 | } else if res != "123" { 157 | t.Fatalf("Error: Expected '123', but got %s", res) 158 | } 159 | 160 | res, err = GetStringAtKey(testObj, "items/0/test/1/id", debug) 161 | if err != nil { 162 | t.Fatalf("Error extracting 'resource' from JSON payload: %s", err) 163 | } else if res != "1337" { 164 | t.Fatalf("Error: Expected '1337', but got %s", res) 165 | } 166 | 167 | res, err = GetStringAtKey(testObj, "items/0/list_numbers/1", debug) 168 | if err != nil { 169 | t.Fatalf("Error extracting 'resource' from JSON payload: %s", err) 170 | } else if res != "2" { 171 | t.Fatalf("Error: Expected '2', but got %s", res) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /restapi/common.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | ) 12 | 13 | // After any operation that returns API data, we'll stuff all the k,v pairs into the api_data map so users can consume the values elsewhere if they'd like 14 | func setResourceState(obj *APIObject, d *schema.ResourceData) { 15 | apiData := make(map[string]string) 16 | for k, v := range obj.apiData { 17 | apiData[k] = fmt.Sprintf("%v", v) 18 | } 19 | d.Set("api_data", apiData) 20 | d.Set("api_response", obj.apiResponse) 21 | } 22 | 23 | // GetStringAtKey uses GetObjectAtKey to verify the resulting object is either a JSON string or Number and returns it as a string 24 | func GetStringAtKey(data map[string]interface{}, path string, debug bool) (string, error) { 25 | res, err := GetObjectAtKey(data, path, debug) 26 | if err != nil { 27 | return "", err 28 | } 29 | 30 | /* JSON supports strings, numbers, objects and arrays. Allow a string OR number here */ 31 | switch tmp := res.(type) { 32 | case string: 33 | return tmp, nil 34 | case float64: 35 | return strconv.FormatFloat(tmp, 'f', -1, 64), nil 36 | case bool: 37 | return fmt.Sprintf("%v", res), nil 38 | default: 39 | return "", fmt.Errorf("object at path '%s' is not a JSON string or number (float64) - the go fmt package says it is '%T'", path, res) 40 | } 41 | } 42 | 43 | // 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. 44 | /* 45 | Example: 46 | Given: 47 | { 48 | "attrs": { 49 | "id": 1234 50 | }, 51 | "config": { 52 | "foo": "abc", 53 | "bar": "xyz" 54 | } 55 | } 56 | 57 | Result: 58 | attrs/id => 1234 59 | config/foo => "abc" 60 | */ 61 | func GetObjectAtKey(data map[string]interface{}, path string, debug bool) (interface{}, error) { 62 | hash := data 63 | 64 | parts := strings.Split(path, "/") 65 | part := "" 66 | seen := "" 67 | if debug { 68 | log.Printf("common.go:GetObjectAtKey: Locating results_key in parts: %v...", parts) 69 | } 70 | 71 | for len(parts) > 1 { 72 | /* AKA, Slice...*/ 73 | part, parts = parts[0], parts[1:] 74 | 75 | /* Protect against double slashes by mistake */ 76 | if part == "" { 77 | continue 78 | } 79 | 80 | /* See if this key exists in the hash at this point */ 81 | if _, ok := hash[part]; ok { 82 | if debug { 83 | log.Printf("common.go:GetObjectAtKey: %s - exists", part) 84 | } 85 | seen += "/" + part 86 | if tmp, ok := hash[part].(map[string]interface{}); ok { 87 | if debug { 88 | log.Printf("common.go:GetObjectAtKey: %s - is a map", part) 89 | } 90 | hash = tmp 91 | } else if tmp, ok := hash[part].([]interface{}); ok { 92 | if debug { 93 | log.Printf("common.go:GetObjectAtKey: %s - is a list", part) 94 | } 95 | mapString := make(map[string]interface{}) 96 | for key, value := range tmp { 97 | strKey := fmt.Sprintf("%v", key) 98 | mapString[strKey] = value 99 | } 100 | hash = mapString 101 | } else { 102 | if debug { 103 | log.Printf("common.go:GetObjectAtKey: %s - is a %T", part, hash[part]) 104 | } 105 | return nil, fmt.Errorf("GetObjectAtKey: Object at '%s' is not a map. Is this the right path?", seen) 106 | } 107 | } else { 108 | if debug { 109 | log.Printf("common.go:GetObjectAtKey: %s - MISSING", part) 110 | } 111 | return nil, fmt.Errorf("GetObjectAtKey: Failed to find '%s' in returned data structure after finding '%s'. Available: %s", part, seen, strings.Join(GetKeys(hash), ",")) 112 | } 113 | } /* End Loop through parts */ 114 | 115 | /* We have found the containing map of the value we want */ 116 | part = parts[0] /* One last time */ 117 | if _, ok := hash[part]; !ok { 118 | if debug { 119 | log.Printf("common.go:GetObjectAtKey: %s - MISSING (available: %s)", part, strings.Join(GetKeys(hash), ",")) 120 | } 121 | return nil, fmt.Errorf("GetObjectAtKey: Resulting map at '%s' does not have key '%s'. Available: %s", seen, part, strings.Join(GetKeys(hash), ",")) 122 | } 123 | 124 | if debug { 125 | log.Printf("common.go:GetObjectAtKey: %s - exists (%v)", part, hash[part]) 126 | } 127 | 128 | return hash[part], nil 129 | } 130 | 131 | // GetKeys is a handy helper to just dump the keys of a map into a slice 132 | func GetKeys(hash map[string]interface{}) []string { 133 | keys := make([]string, 0) 134 | for k := range hash { 135 | keys = append(keys, k) 136 | } 137 | return keys 138 | } 139 | 140 | // GetEnvOrDefault is a helper function that returns the value of the given environment variable, if one exists, or the default value 141 | func GetEnvOrDefault(k string, defaultvalue string) string { 142 | v := os.Getenv(k) 143 | if v == "" { 144 | return defaultvalue 145 | } 146 | return v 147 | } 148 | 149 | func expandStringSet(configured []interface{}) []string { 150 | return expandStringList(configured) 151 | } 152 | 153 | func expandStringList(configured []interface{}) []string { 154 | vs := make([]string, 0, len(configured)) 155 | for _, v := range configured { 156 | val, ok := v.(string) 157 | if ok && val != "" { 158 | vs = append(vs, v.(string)) 159 | } 160 | } 161 | return vs 162 | } 163 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "restapi Provider" 4 | subcategory: "" 5 | description: |- 6 | 7 | --- 8 | 9 | # restapi Provider 10 | 11 | 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | provider "restapi" { 17 | uri = "https://api.url.com" 18 | write_returns_object = true 19 | debug = true 20 | 21 | headers = { 22 | "X-Auth-Token" = var.AUTH_TOKEN, 23 | "Content-Type" = "application/json" 24 | } 25 | 26 | create_method = "PUT" 27 | update_method = "PUT" 28 | destroy_method = "PUT" 29 | } 30 | ``` 31 | 32 | 33 | ## Schema 34 | 35 | ### Required 36 | 37 | - `uri` (String) URI of the REST API endpoint. This serves as the base of all requests. 38 | 39 | ### Optional 40 | 41 | - `cert_file` (String) When set with the key_file parameter, the provider will load a client certificate as a file for mTLS authentication. 42 | - `cert_string` (String) When set with the key_string parameter, the provider will load a client certificate as a string for mTLS authentication. 43 | - `copy_keys` (List of String) When set, any PUT to the API for an object will copy these keys from the data the provider has gathered about the object. This is useful if internal API information must also be provided with updates, such as the revision of the object. 44 | - `create_method` (String) Defaults to `POST`. The HTTP method used to CREATE objects of this type on the API server. 45 | - `create_returns_object` (Boolean) Set this when the API returns the object created only on creation operations (POST). This is used by the provider to refresh internal data structures. 46 | - `debug` (Boolean) Enabling this will cause lots of debug information to be printed to STDOUT by the API client. 47 | - `destroy_method` (String) Defaults to `DELETE`. The HTTP method used to DELETE objects of this type on the API server. 48 | - `headers` (Map of String) 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. 49 | - `id_attribute` (String) 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"}}` 50 | - `insecure` (Boolean) When using https, this disables TLS verification of the host. 51 | - `key_file` (String) 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. 52 | - `key_string` (String) 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. 53 | - `oauth_client_credentials` (Block List, Max: 1) Configuration for oauth client credential flow using the https://pkg.go.dev/golang.org/x/oauth2 implementation (see [below for nested schema](#nestedblock--oauth_client_credentials)) 54 | - `password` (String) When set, will use this password for BASIC auth to the API. 55 | - `rate_limit` (Number) Set this to limit the number of requests per second made to the API. 56 | - `read_method` (String) Defaults to `GET`. The HTTP method used to READ objects of this type on the API server. 57 | - `root_ca_file` (String) When set, the provider will load a root CA certificate as a file for mTLS authentication. This is useful when the API server is using a self-signed certificate and the client needs to trust it. 58 | - `root_ca_string` (String) When set, the provider will load a root CA certificate as a string for mTLS authentication. This is useful when the API server is using a self-signed certificate and the client needs to trust it. 59 | - `test_path` (String) If set, the provider will issue a read_method request to this path after instantiation requiring a 200 OK response before proceeding. This is useful if your API provides a no-op endpoint that can signal if this provider is configured correctly. Response data will be ignored. 60 | - `timeout` (Number) When set, will cause requests taking longer than this time (in seconds) to be aborted. 61 | - `update_method` (String) Defaults to `PUT`. The HTTP method used to UPDATE objects of this type on the API server. 62 | - `use_cookies` (Boolean) Enable cookie jar to persist session. 63 | - `username` (String) When set, will use this username for BASIC auth to the API. 64 | - `write_returns_object` (Boolean) Set this when the API returns the object created on all write operations (POST, PUT). This is used by the provider to refresh internal data structures. 65 | - `xssi_prefix` (String) Trim the xssi prefix from response string, if present, before parsing. 66 | 67 | 68 | ### Nested Schema for `oauth_client_credentials` 69 | 70 | Required: 71 | 72 | - `oauth_client_id` (String) client id 73 | - `oauth_client_secret` (String) client secret 74 | - `oauth_token_endpoint` (String) oauth token endpoint 75 | 76 | Optional: 77 | 78 | - `endpoint_params` (Map of String) Additional key/values to pass to the underlying Oauth client library (as EndpointParams) 79 | - `oauth_scopes` (List of String) scopes 80 | -------------------------------------------------------------------------------- /restapi/datasource_api_object_test.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | /* 4 | See: 5 | https://www.terraform.io/docs/extend/testing/acceptance-tests/testcase.html 6 | https://github.com/terraform-providers/terraform-provider-local/blob/master/local/resource_local_file_test.go 7 | https://github.com/terraform-providers/terraform-provider-aws/blob/master/aws/resource_aws_db_security_group_test.go 8 | */ 9 | 10 | import ( 11 | "fmt" 12 | "os" 13 | "testing" 14 | 15 | "github.com/Mastercard/terraform-provider-restapi/fakeserver" 16 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 17 | ) 18 | 19 | func TestAccRestapiobject_Basic(t *testing.T) { 20 | debug := false 21 | apiServerObjects := make(map[string]map[string]interface{}) 22 | 23 | svr := fakeserver.NewFakeServer(8082, apiServerObjects, true, debug, "") 24 | os.Setenv("REST_API_URI", "http://127.0.0.1:8082") 25 | 26 | opt := &apiClientOpt{ 27 | uri: "http://127.0.0.1:8082/", 28 | insecure: false, 29 | username: "", 30 | password: "", 31 | headers: make(map[string]string), 32 | timeout: 2, 33 | idAttribute: "id", 34 | copyKeys: make([]string, 0), 35 | writeReturnsObject: false, 36 | createReturnsObject: false, 37 | debug: debug, 38 | } 39 | client, err := NewAPIClient(opt) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | /* Send a simple object */ 45 | client.sendRequest("POST", "/api/objects", ` 46 | { 47 | "id": "1234", 48 | "first": "Foo", 49 | "last": "Bar", 50 | "data": { 51 | "identifier": "FooBar" 52 | } 53 | } 54 | `) 55 | client.sendRequest("POST", "/api/objects", ` 56 | { 57 | "id": "4321", 58 | "first": "Foo", 59 | "last": "Baz", 60 | "data": { 61 | "identifier": "FooBaz" 62 | } 63 | } 64 | `) 65 | client.sendRequest("POST", "/api/objects", ` 66 | { 67 | "id": "5678", 68 | "first": "Nested", 69 | "last": "Fields", 70 | "data": { 71 | "identifier": "NestedFields" 72 | } 73 | } 74 | `) 75 | 76 | /* Send a complex object that we will pretend is the results of a search 77 | client.send_request("POST", "/api/objects", ` 78 | { 79 | "id": "people", 80 | "results": { 81 | "number": 2, 82 | "list": [ 83 | { "id": "1234", "first": "Foo", "last": "Bar" }, 84 | { "id": "4321", "first": "Foo", "last": "Baz" } 85 | ] 86 | } 87 | } 88 | `) 89 | */ 90 | 91 | resource.UnitTest(t, resource.TestCase{ 92 | Providers: testAccProviders, 93 | PreCheck: func() { svr.StartInBackground() }, 94 | Steps: []resource.TestStep{ 95 | { 96 | Config: fmt.Sprintf(` 97 | data "restapi_object" "Foo" { 98 | path = "/api/objects" 99 | search_key = "last" 100 | search_value = "Bar" 101 | debug = %t 102 | } 103 | `, debug), 104 | Check: resource.ComposeTestCheckFunc( 105 | testAccCheckRestapiObjectExists("data.restapi_object.Foo", "1234", client), 106 | resource.TestCheckResourceAttr("data.restapi_object.Foo", "id", "1234"), 107 | resource.TestCheckResourceAttr("data.restapi_object.Foo", "api_data.first", "Foo"), 108 | resource.TestCheckResourceAttr("data.restapi_object.Foo", "api_data.last", "Bar"), 109 | resource.TestCheckResourceAttr("data.restapi_object.Foo", "api_response", "{\"data\":{\"identifier\":\"FooBar\"},\"first\":\"Foo\",\"id\":\"1234\",\"last\":\"Bar\"}"), 110 | ), 111 | // PreventDiskCleanup: true, 112 | }, 113 | { 114 | Config: fmt.Sprintf(` 115 | data "restapi_object" "Nested" { 116 | path = "/api/objects" 117 | search_key = "data/identifier" 118 | search_value = "NestedFields" 119 | debug = %t 120 | } 121 | `, debug), 122 | Check: resource.ComposeTestCheckFunc( 123 | testAccCheckRestapiObjectExists("data.restapi_object.Nested", "5678", client), 124 | resource.TestCheckResourceAttr("data.restapi_object.Nested", "id", "5678"), 125 | resource.TestCheckResourceAttr("data.restapi_object.Nested", "api_data.first", "Nested"), 126 | resource.TestCheckResourceAttr("data.restapi_object.Nested", "api_data.last", "Fields"), 127 | ), 128 | }, 129 | { 130 | /* Similar to the first, but also with a query string */ 131 | Config: fmt.Sprintf(` 132 | data "restapi_object" "Baz" { 133 | path = "/api/objects" 134 | query_string = "someArg=foo&anotherArg=bar" 135 | search_key = "last" 136 | search_value = "Baz" 137 | debug = %t 138 | } 139 | `, debug), 140 | Check: resource.ComposeTestCheckFunc( 141 | testAccCheckRestapiObjectExists("data.restapi_object.Baz", "4321", client), 142 | resource.TestCheckResourceAttr("data.restapi_object.Baz", "id", "4321"), 143 | resource.TestCheckResourceAttr("data.restapi_object.Baz", "api_data.first", "Foo"), 144 | resource.TestCheckResourceAttr("data.restapi_object.Baz", "api_data.last", "Baz"), 145 | ), 146 | }, 147 | { 148 | /* Perform a test that mimicks a search (this will exercise search_path and results_key */ 149 | Config: fmt.Sprintf(` 150 | data "restapi_object" "Baz" { 151 | path = "/api/objects" 152 | search_path = "/api/object_list" 153 | search_key = "last" 154 | search_value = "Baz" 155 | results_key = "list" 156 | debug = %t 157 | } 158 | `, debug), 159 | Check: resource.ComposeTestCheckFunc( 160 | testAccCheckRestapiObjectExists("data.restapi_object.Baz", "4321", client), 161 | resource.TestCheckResourceAttr("data.restapi_object.Baz", "id", "4321"), 162 | resource.TestCheckResourceAttr("data.restapi_object.Baz", "api_data.first", "Foo"), 163 | resource.TestCheckResourceAttr("data.restapi_object.Baz", "api_data.last", "Baz"), 164 | ), 165 | }, 166 | }, 167 | }) 168 | 169 | svr.Shutdown() 170 | } 171 | -------------------------------------------------------------------------------- /restapi/datasource_api_object.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | 7 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 8 | ) 9 | 10 | func dataSourceRestAPI() *schema.Resource { 11 | return &schema.Resource{ 12 | Read: dataSourceRestAPIRead, 13 | Description: "Performs a cURL get command on the specified url.", 14 | 15 | Schema: map[string]*schema.Schema{ 16 | "path": { 17 | Type: schema.TypeString, 18 | Description: "The API path on top of the base URL set in the provider that represents objects of this type on the API server.", 19 | Required: true, 20 | }, 21 | "search_path": { 22 | Type: schema.TypeString, 23 | Description: "The API path on top of the base URL set in the provider that represents the location to search for objects of this type on the API server. If not set, defaults to the value of path.", 24 | Optional: true, 25 | }, 26 | "query_string": { 27 | Type: schema.TypeString, 28 | Description: "An optional query string to send when performing the search.", 29 | Optional: true, 30 | }, 31 | "read_query_string": { 32 | Type: schema.TypeString, 33 | /* Setting to "not-set" helps differentiate between the cases where 34 | read_query_string is explicitly set to zero-value for string ("") and 35 | when read_query_string is not set at all in the configuration. */ 36 | Default: "not-set", 37 | Description: "Defaults to `query_string` set on data source. This key allows setting a different or empty query string for reading the object.", 38 | Optional: true, 39 | }, 40 | "search_data": { 41 | Type: schema.TypeString, 42 | Description: "Valid JSON object to pass to search request as body", 43 | Optional: true, 44 | }, 45 | "search_key": { 46 | Type: schema.TypeString, 47 | Description: "When reading search results from the API, this key is used to identify the specific record to read. This should be a unique record such as 'name'. Similar to results_key, the value may be in the format of 'field/field/field' to search for data deeper in the returned object.", 48 | Required: true, 49 | }, 50 | "search_value": { 51 | Type: schema.TypeString, 52 | Description: "The value of 'search_key' will be compared to this value to determine if the correct object was found. Example: if 'search_key' is 'name' and 'search_value' is 'foo', the record in the array returned by the API with name=foo will be used.", 53 | Required: true, 54 | }, 55 | "results_key": { 56 | Type: schema.TypeString, 57 | Description: "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.", 58 | Optional: true, 59 | }, 60 | "id_attribute": { 61 | Type: schema.TypeString, 62 | Description: "Defaults to `id_attribute` set on the provider. Allows per-resource override of `id_attribute` (see `id_attribute` provider config documentation)", 63 | Optional: true, 64 | }, 65 | "debug": { 66 | Type: schema.TypeBool, 67 | Description: "Whether to emit verbose debug output while working with the API object on the server.", 68 | Optional: true, 69 | }, 70 | "api_data": { 71 | Type: schema.TypeMap, 72 | Elem: &schema.Schema{Type: schema.TypeString}, 73 | Description: "After data from the API server is read, this map will include k/v pairs usable in other terraform resources as readable objects. Currently the value is the golang fmt package's representation of the value (simple primitives are set as expected, but complex types like arrays and maps contain golang formatting).", 74 | Computed: true, 75 | }, 76 | "api_response": { 77 | Type: schema.TypeString, 78 | Description: "The raw body of the HTTP response from the last read of the object.", 79 | Computed: true, 80 | }, 81 | }, /* End schema */ 82 | 83 | } 84 | } 85 | 86 | func dataSourceRestAPIRead(d *schema.ResourceData, meta interface{}) error { 87 | path := d.Get("path").(string) 88 | searchPath := d.Get("search_path").(string) 89 | queryString := d.Get("query_string").(string) 90 | debug := d.Get("debug").(bool) 91 | client := meta.(*APIClient) 92 | if debug { 93 | log.Printf("datasource_api_object.go: Data routine called.") 94 | } 95 | 96 | readQueryString := d.Get("read_query_string").(string) 97 | if readQueryString == "not-set" { 98 | readQueryString = queryString 99 | } 100 | 101 | searchKey := d.Get("search_key").(string) 102 | searchValue := d.Get("search_value").(string) 103 | searchData := d.Get("search_data").(string) 104 | resultsKey := d.Get("results_key").(string) 105 | idAttribute := d.Get("id_attribute").(string) 106 | 107 | send := "" 108 | if len(searchData) > 0 { 109 | tmpData, _ := json.Marshal(searchData) 110 | send = string(tmpData) 111 | if debug { 112 | log.Printf("api_object.go: Using search data '%s'", send) 113 | } 114 | } 115 | 116 | if debug { 117 | log.Printf("datasource_api_object.go:\npath: %s\nsearch_path: %s\nquery_string: %s\nsearch_key: %s\nsearch_value: %s\nresults_key: %s\nid_attribute: %s", path, searchPath, queryString, searchKey, searchValue, resultsKey, idAttribute) 118 | } 119 | 120 | opts := &apiObjectOpts{ 121 | path: path, 122 | searchPath: searchPath, 123 | debug: debug, 124 | queryString: readQueryString, 125 | idAttribute: idAttribute, 126 | } 127 | 128 | obj, err := NewAPIObject(client, opts) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | if _, err := obj.findObject(queryString, searchKey, searchValue, resultsKey, send); err != nil { 134 | return err 135 | } 136 | 137 | /* Back to terraform-specific stuff. Create an api_object with the ID and refresh it object */ 138 | if debug { 139 | log.Printf("datasource_api_object.go: Attempting to construct api_object to refresh data") 140 | } 141 | 142 | d.SetId(obj.id) 143 | 144 | err = obj.readObject() 145 | if err == nil { 146 | /* Setting terraform ID tells terraform the object was created or it exists */ 147 | log.Printf("datasource_api_object.go: Data resource. Returned id is '%s'\n", obj.id) 148 | d.SetId(obj.id) 149 | setResourceState(obj, d) 150 | } 151 | return err 152 | } 153 | -------------------------------------------------------------------------------- /fakeserver/fakeserver.go: -------------------------------------------------------------------------------- 1 | package fakeserver 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | /*Fakeserver represents a HTTP server with objects to hold and return*/ 15 | type Fakeserver struct { 16 | server *http.Server 17 | objects map[string]map[string]interface{} 18 | debug bool 19 | running bool 20 | } 21 | 22 | /*NewFakeServer creates a HTTP server used for tests and debugging*/ 23 | func NewFakeServer(iPort int, iObjects map[string]map[string]interface{}, iStart bool, iDebug bool, dir string) *Fakeserver { 24 | serverMux := http.NewServeMux() 25 | 26 | svr := &Fakeserver{ 27 | debug: iDebug, 28 | objects: iObjects, 29 | running: false, 30 | } 31 | 32 | //If we were passed an argument for where to serve /static from... 33 | if dir != "" { 34 | _, err := os.Stat(dir) 35 | if err == nil { 36 | if svr.debug { 37 | log.Printf("fakeserver.go: Will serve static files in '%s' under /static path", dir) 38 | } 39 | serverMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(dir)))) 40 | } else { 41 | log.Printf("fakeserver.go: WARNING: Not serving /static because directory '%s' does not exist", dir) 42 | } 43 | } 44 | 45 | serverMux.HandleFunc("/api/", svr.handleAPIObject) 46 | 47 | apiObjectServer := &http.Server{ 48 | Addr: fmt.Sprintf("127.0.0.1:%d", iPort), 49 | Handler: serverMux, 50 | } 51 | 52 | svr.server = apiObjectServer 53 | 54 | if iStart { 55 | svr.StartInBackground() 56 | } 57 | if svr.debug { 58 | log.Printf("fakeserver.go: Set up fakeserver: port=%d, debug=%t\n", iPort, svr.debug) 59 | } 60 | 61 | return svr 62 | } 63 | 64 | /*StartInBackground starts the HTTP server in the background*/ 65 | func (svr *Fakeserver) StartInBackground() { 66 | go svr.server.ListenAndServe() 67 | 68 | /* Let the server start */ 69 | time.Sleep(1 * time.Second) 70 | svr.running = true 71 | } 72 | 73 | /*Shutdown closes the server*/ 74 | func (svr *Fakeserver) Shutdown() { 75 | svr.server.Close() 76 | svr.running = false 77 | } 78 | 79 | /*Running returns whether the server is running*/ 80 | func (svr *Fakeserver) Running() bool { 81 | return svr.running 82 | } 83 | 84 | /*GetServer returns the server object itself*/ 85 | func (svr *Fakeserver) GetServer() *http.Server { 86 | return svr.server 87 | } 88 | 89 | func (svr *Fakeserver) handleAPIObject(w http.ResponseWriter, r *http.Request) { 90 | var obj map[string]interface{} 91 | var id string 92 | var ok bool 93 | 94 | /* Assume this will never fail */ 95 | b, _ := io.ReadAll(r.Body) 96 | 97 | if svr.debug { 98 | log.Printf("fakeserver.go: Recieved request: %+v\n", r) 99 | log.Printf("fakeserver.go: Headers:\n") 100 | for name, headers := range r.Header { 101 | name = strings.ToLower(name) 102 | for _, h := range headers { 103 | log.Printf("fakeserver.go: %v: %v", name, h) 104 | } 105 | } 106 | log.Printf("fakeserver.go: BODY: %s\n", string(b)) 107 | log.Printf("fakeserver.go: IDs and objects:\n") 108 | for id, obj := range svr.objects { 109 | log.Printf(" %s: %+v\n", id, obj) 110 | } 111 | } 112 | 113 | path := r.URL.EscapedPath() 114 | parts := strings.Split(path, "/") 115 | if svr.debug { 116 | log.Printf("fakeserver.go: Request received: %s %s\n", r.Method, path) 117 | log.Printf("fakeserver.go: Split request up into %d parts: %v\n", len(parts), parts) 118 | if r.URL.RawQuery != "" { 119 | log.Printf("fakeserver.go: Query string: %s\n", r.URL.RawQuery) 120 | } 121 | } 122 | /* If it was a valid request, there will be three parts 123 | and the ID will exist */ 124 | if len(parts) == 4 { 125 | id = parts[3] 126 | obj, ok = svr.objects[id] 127 | if svr.debug { 128 | log.Printf("fakeserver.go: Detected ID %s (exists: %t, method: %s)", id, ok, r.Method) 129 | } 130 | 131 | /* Make sure the object requested exists unless it's being created */ 132 | if r.Method != "POST" && !ok { 133 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 134 | return 135 | } 136 | } else if path == "/api/object_list" && r.Method == "GET" { 137 | /* Provide a URL similar to /api/objects that will also show the number of results 138 | as if a search was performed (which just returns all objects */ 139 | tmp := make([]map[string]interface{}, 0) 140 | result := map[string]interface{}{ 141 | "results": true, 142 | "pages": 1, 143 | "page": 1, 144 | "list": &tmp, 145 | } 146 | for _, hash := range svr.objects { 147 | tmp = append(tmp, hash) 148 | } 149 | b, _ := json.Marshal(result) 150 | w.Write(b) 151 | return 152 | } else if path != "/api/objects" { 153 | /* How did something get to this handler with the wrong number of args??? */ 154 | if svr.debug { 155 | log.Printf("fakeserver.go: Bad request - got to /api/objects without the right number of args") 156 | } 157 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 158 | return 159 | } else if path == "/api/objects" && r.Method == "GET" { 160 | result := make([]map[string]interface{}, 0) 161 | for _, hash := range svr.objects { 162 | result = append(result, hash) 163 | } 164 | b, _ := json.Marshal(result) 165 | w.Write(b) 166 | return 167 | } 168 | 169 | if r.Method == "DELETE" { 170 | /* Get rid of this one */ 171 | delete(svr.objects, id) 172 | if svr.debug { 173 | log.Printf("fakeserver.go: Object deleted.\n") 174 | } 175 | return 176 | } 177 | /* if data was sent, parse the data */ 178 | if string(b) != "" { 179 | if svr.debug { 180 | log.Printf("fakeserver.go: data sent - unmarshalling from JSON: %s\n", string(b)) 181 | } 182 | 183 | err := json.Unmarshal(b, &obj) 184 | if err != nil { 185 | /* Failure goes back to the user as a 500. Log data here for 186 | debugging (which shouldn't ever fail!) */ 187 | log.Fatalf("fakeserver.go: Unmarshal of request failed: %s\n", err) 188 | log.Fatalf("\nBEGIN passed data:\n%s\nEND passed data.", string(b)) 189 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 190 | return 191 | } 192 | /* In the case of POST above, id is not yet known - set it here */ 193 | if id == "" { 194 | if val, ok := obj["id"]; ok { 195 | id = fmt.Sprintf("%v", val) 196 | } else if val, ok := obj["Id"]; ok { 197 | id = fmt.Sprintf("%v", val) 198 | } else if val, ok := obj["ID"]; ok { 199 | id = fmt.Sprintf("%v", val) 200 | } else { 201 | if svr.debug { 202 | log.Printf("fakeserver.go: Bad request - POST to /api/objects without id field") 203 | } 204 | http.Error(w, "POST sent with no id field in the data. Cannot persist this!", http.StatusBadRequest) 205 | return 206 | } 207 | } 208 | 209 | /* Overwrite our stored test object */ 210 | if svr.debug { 211 | log.Printf("fakeserver.go: Overwriting %s with new data:%+v\n", id, obj) 212 | } 213 | svr.objects[id] = obj 214 | 215 | /* Coax the data we were sent back to JSON and send it to the user */ 216 | b, _ := json.Marshal(obj) 217 | w.Write(b) 218 | return 219 | } 220 | /* No data was sent... must be just a retrieval */ 221 | if svr.debug { 222 | log.Printf("fakeserver.go: Returning object.\n") 223 | } 224 | b, _ = json.Marshal(obj) 225 | w.Write(b) 226 | } 227 | -------------------------------------------------------------------------------- /restapi/resource_api_object_test.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | /* 4 | See: 5 | https://www.terraform.io/docs/extend/testing/acceptance-tests/testcase.html 6 | https://github.com/terraform-providers/terraform-provider-local/blob/master/local/resource_local_file_test.go 7 | https://github.com/terraform-providers/terraform-provider-aws/blob/master/aws/resource_aws_db_security_group_test.go 8 | */ 9 | 10 | /* 11 | "log" 12 | "github.com/hashicorp/terraform/config" 13 | */ 14 | import ( 15 | "encoding/json" 16 | "fmt" 17 | "net/http" 18 | "os" 19 | "regexp" 20 | "testing" 21 | 22 | "github.com/Mastercard/terraform-provider-restapi/fakeserver" 23 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 24 | ) 25 | 26 | // example.Widget represents a concrete Go type that represents an API resource 27 | func TestAccRestApiObject_Basic(t *testing.T) { 28 | debug := false 29 | apiServerObjects := make(map[string]map[string]interface{}) 30 | 31 | svr := fakeserver.NewFakeServer(8082, apiServerObjects, true, debug, "") 32 | os.Setenv("REST_API_URI", "http://127.0.0.1:8082") 33 | 34 | opt := &apiClientOpt{ 35 | uri: "http://127.0.0.1:8082/", 36 | insecure: false, 37 | username: "", 38 | password: "", 39 | headers: make(map[string]string), 40 | timeout: 2, 41 | idAttribute: "id", 42 | copyKeys: make([]string, 0), 43 | writeReturnsObject: false, 44 | createReturnsObject: false, 45 | debug: debug, 46 | } 47 | client, err := NewAPIClient(opt) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | resource.UnitTest(t, resource.TestCase{ 53 | Providers: testAccProviders, 54 | PreCheck: func() { svr.StartInBackground() }, 55 | Steps: []resource.TestStep{ 56 | { 57 | Config: generateTestResource( 58 | "Foo", 59 | `{ "id": "1234", "first": "Foo", "last": "Bar" }`, 60 | make(map[string]interface{}), 61 | ), 62 | Check: resource.ComposeTestCheckFunc( 63 | testAccCheckRestapiObjectExists("restapi_object.Foo", "1234", client), 64 | resource.TestCheckResourceAttr("restapi_object.Foo", "id", "1234"), 65 | resource.TestCheckResourceAttr("restapi_object.Foo", "api_data.first", "Foo"), 66 | resource.TestCheckResourceAttr("restapi_object.Foo", "api_data.last", "Bar"), 67 | resource.TestCheckResourceAttr("restapi_object.Foo", "api_response", "{\"first\":\"Foo\",\"id\":\"1234\",\"last\":\"Bar\"}"), 68 | resource.TestCheckResourceAttr("restapi_object.Foo", "create_response", "{\"first\":\"Foo\",\"id\":\"1234\",\"last\":\"Bar\"}"), 69 | ), 70 | }, 71 | /* Try updating the object and check create_response is unmodified */ 72 | { 73 | Config: generateTestResource( 74 | "Foo", 75 | `{ "id": "1234", "first": "Updated", "last": "Value" }`, 76 | make(map[string]interface{}), 77 | ), 78 | Check: resource.ComposeTestCheckFunc( 79 | testAccCheckRestapiObjectExists("restapi_object.Foo", "1234", client), 80 | resource.TestCheckResourceAttr("restapi_object.Foo", "id", "1234"), 81 | resource.TestCheckResourceAttr("restapi_object.Foo", "api_data.first", "Updated"), 82 | resource.TestCheckResourceAttr("restapi_object.Foo", "api_data.last", "Value"), 83 | resource.TestCheckResourceAttr("restapi_object.Foo", "api_response", "{\"first\":\"Updated\",\"id\":\"1234\",\"last\":\"Value\"}"), 84 | resource.TestCheckResourceAttr("restapi_object.Foo", "create_response", "{\"first\":\"Foo\",\"id\":\"1234\",\"last\":\"Bar\"}"), 85 | ), 86 | }, 87 | /* Make a complex object with id_attribute as a child of another key 88 | Note that we have to pass "id" just so fakeserver won't get angry at us 89 | */ 90 | { 91 | Config: generateTestResource( 92 | "Bar", 93 | `{ "id": "4321", "attributes": { "id": "4321" }, "config": { "first": "Bar", "last": "Baz" } }`, 94 | map[string]interface{}{ 95 | "debug": debug, 96 | "id_attribute": "attributes/id", 97 | }, 98 | ), 99 | Check: resource.ComposeTestCheckFunc( 100 | testAccCheckRestapiObjectExists("restapi_object.Bar", "4321", client), 101 | resource.TestCheckResourceAttr("restapi_object.Bar", "id", "4321"), 102 | resource.TestCheckResourceAttrSet("restapi_object.Bar", "api_data.config"), 103 | ), 104 | }, 105 | }, 106 | }) 107 | 108 | svr.Shutdown() 109 | } 110 | 111 | // This function generates a terraform JSON configuration from 112 | // a name, JSON data and a list of params to set by coaxing it 113 | // all to maps and then serializing to JSON 114 | func generateTestResource(name string, data string, params map[string]interface{}) string { 115 | strData, _ := json.Marshal(data) 116 | config := []string{ 117 | `path = "/api/objects"`, 118 | fmt.Sprintf("data = %s", strData), 119 | } 120 | for k, v := range params { 121 | entry := fmt.Sprintf(`%s = "%v"`, k, v) 122 | config = append(config, entry) 123 | } 124 | strConfig := "" 125 | for _, v := range config { 126 | strConfig = strConfig + v + "\n" 127 | } 128 | 129 | return fmt.Sprintf(` 130 | resource "restapi_object" "%s" { 131 | %s 132 | } 133 | `, name, strConfig) 134 | } 135 | 136 | func mockServer(host string, returnCodes map[string]int, responses map[string]string) *http.Server { 137 | serverMux := http.NewServeMux() 138 | serverMux.HandleFunc("/api/", func(w http.ResponseWriter, req *http.Request) { 139 | key := fmt.Sprintf("%s %s", req.Method, req.RequestURI) // e.g. "PUT /api/objects/1234" 140 | returnCode, ok := returnCodes[key] 141 | if !ok { 142 | returnCode = http.StatusOK 143 | } 144 | w.WriteHeader(returnCode) 145 | responseBody, ok := responses[key] 146 | if !ok { 147 | responseBody = "" 148 | } 149 | w.Write([]byte(responseBody)) 150 | }) 151 | srv := &http.Server{ 152 | Addr: host, 153 | Handler: serverMux, 154 | } 155 | go srv.ListenAndServe() 156 | return srv 157 | } 158 | 159 | func TestAccRestApiObject_FailedUpdate(t *testing.T) { 160 | host := "127.0.0.1:8082" 161 | returnCodes := map[string]int{ 162 | "PUT /api/objects/1234": http.StatusBadRequest, 163 | } 164 | responses := map[string]string{ 165 | "GET /api/objects/1234": `{ "id": "1234", "foo": "Bar" }`, 166 | } 167 | srv := mockServer(host, returnCodes, responses) 168 | defer srv.Close() 169 | 170 | os.Setenv("REST_API_URI", "http://"+host) 171 | 172 | resource.UnitTest(t, resource.TestCase{ 173 | Providers: testAccProviders, 174 | Steps: []resource.TestStep{ 175 | { 176 | // Create the resource 177 | Config: generateTestResource( 178 | "Foo", 179 | `{ "id": "1234", "foo": "Bar" }`, 180 | make(map[string]interface{}), 181 | ), 182 | Check: resource.TestCheckResourceAttr("restapi_object.Foo", "data", `{ "id": "1234", "foo": "Bar" }`), 183 | }, 184 | { 185 | // Try update. It will fail becuase we return 400 for PUT operations from mock server 186 | Config: generateTestResource( 187 | "Foo", 188 | `{ "id": "1234", "foo": "Updated" }`, 189 | make(map[string]interface{}), 190 | ), 191 | ExpectError: regexp.MustCompile("unexpected response code '400'"), 192 | }, 193 | { 194 | // Expecting plan to be non-empty because the failed apply above shouldn't update terraform state 195 | Config: generateTestResource( 196 | "Foo", 197 | `{ "id": "1234", "foo": "Updated" }`, 198 | make(map[string]interface{}), 199 | ), 200 | PlanOnly: true, 201 | ExpectNonEmptyPlan: true, 202 | }, 203 | }, 204 | }) 205 | } 206 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/burbon/terraform-provider-restapi.svg?branch=master)](https://travis-ci.com/burbon/terraform-provider-restapi) 2 | [![Coverage Status](https://coveralls.io/repos/github/burbon/terraform-provider-restapi/badge.svg?branch=master)](https://coveralls.io/github/burbon/terraform-provider-restapi?branch=master) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/burbon/terraform-provider-restapi)](https://goreportcard.com/report/github.com/burbon/terraform-provider-restapi) 4 | # Terraform provider for generic REST APIs 5 | 6 | ## Maintenance Note 7 | This provider is largely feature-complete and in maintenance mode. 8 | * It's not dead - it's just slow moving and updates must be done very carefully 9 | * We encourage community participation with open issues for usage and remain welcoming of pull requests 10 | * Code updates happen sporadically throughout the year, driven primarily by security fixes and PRs 11 | * Because of the many API variations and flexibility of this provider, detailed per-API troubleshooting cannot be guaranteed 12 | 13 |   14 | 15 | ## About This Provider 16 | This terraform provider allows you to interact with APIs that may not yet have a first-class provider available by implementing a "dumb" REST API client. 17 | 18 | This provider is essentially created to be a terraform-wrapped `cURL` client. Because of this, you need to know quite a bit about the API you are interacting with as opposed to full-featured terraform providers written with a specific API in mind. 19 | 20 | There are a few requirements about how the API must work for this provider to be able to do its thing: 21 | * The API is expected to support the following HTTP methods: 22 | * POST: create an object 23 | * GET: read an object 24 | * PUT: update an object 25 | * DELETE: remove an object 26 | * An "object" in the API has a unique identifier the API will return 27 | * Objects live under a distinct path such that for the path `/api/v1/things`... 28 | * POST on `/api/v1/things` creates a new object 29 | * GET, PUT and DELETE on `/api/v1/things/{id}` manages an existing object 30 | 31 | Have a look at the [examples directory](examples) for some use cases. 32 | 33 |   34 | 35 | ## Provider Documentation 36 | This provider has only a few moving components, but LOTS of configurable parameters: 37 | * [provider documentation](https://registry.terraform.io/providers/Mastercard/restapi/latest/docs) 38 | * [restapi_object resource documentation](https://registry.terraform.io/providers/Mastercard/restapi/latest/docs/resources/object) 39 | * [restapi_object datasource documentation](https://registry.terraform.io/providers/Mastercard/restapi/latest/docs/data-sources/object) 40 | 41 |   42 | 43 | ## Usage 44 | * Try to set as few parameters as possible to begin with. The more complicated the configuration gets, the more difficult troubleshooting can become. 45 | * Play with the [fakeserver cli tool](fakeservercli/) (included in releases) to get a feel for how this API client is expected to work. Also see the [examples directory](examples) directory for some working use cases with fakeserver. 46 | * By default, data isn't considered sensitive. If you want to hide the data this provider submits as well as the data returned by the API, you would need to set environment variable `API_DATA_IS_SENSITIVE=true`. 47 | * The `*_path` elements are for very specific use cases where one might initially create an object in one location, but read/update/delete it on another path. For this reason, they allow for substitution to be done by the provider internally by injecting the `id` somewhere along the path. This is similar to terraform's substitution syntax in the form of `${variable.name}`, but must be done within the provider due to structure. The only substitution available is to replace the string `{id}` with the internal (terraform) `id` of the object as learned by the `id_attribute`. 48 | 49 |   50 | 51 | ### Troubleshooting 52 | Because this provider is just a terraform-wrapped `cURL`, the API details and the go implementation of this client are often leaked to you. 53 | This means you, as the user, will have a bit more troubleshooting on your hands than would typically be required of a full-fledged provider if you experience issues. 54 | 55 | Here are some tips for troubleshooting that may be helpful... 56 | 57 |   58 | 59 | #### Debug log 60 | **Rely heavily on the debug log.** The debug log, enabled by setting the environment variable `TF_LOG=1` and enabling the `debug` parameter on the provider, is the best way to figure out what is happening. 61 | 62 | If an unexpected error occurs, enable debug log and review the output: 63 | * Does the API return an odd HTTP response code? This is common for bad requests to the API. Look closely at the HTTP request details. 64 | * Does an unexpected golang 'unmarshaling' error occur? Take a look at the debug log and see if anything other than a hash (for resources) or an array (for the datasource) is being returned. For example, the provider cannot cope with cases where a JSON object is requested, but an array of JSON objects is returned. 65 | 66 |   67 | 68 | ### Importing existing resources 69 | This provider supports importing existing resources into the terraform state. Import is done according to the various provider/resource configuation settings to contact the API server and obtain data. That is: if a custom read method, path, or id attribute is defined, the provider will honor those settings to pull data in. 70 | 71 | To import data: 72 | `terraform import restapi.Name /path/to/resource`. 73 | 74 | See a concrete example [here](examples/workingexamples/dummy_users_with_fakeserver.tf). 75 | 76 |   77 | 78 | ## Installation 79 | There are two standard methods of installing this provider detailed [in Terraform's documentation](https://www.terraform.io/docs/configuration/providers.html#third-party-plugins). You can place the file in the directory of your .tf file in `terraform.d/plugins/{OS}_{ARCH}/` or place it in your home directory at `~/.terraform.d/plugins/{OS}_{ARCH}/`. 80 | 81 | The released binaries are named `terraform-provider-restapi_vX.Y.Z-{OS}-{ARCH}` so you know which binary to install. You *may* need to rename the binary you use during installation to just `terraform-provider-restapi_vX.Y.Z`. 82 | 83 | Once downloaded, be sure to make the plugin executable by running `chmod +x terraform-provider-restapi_vX.Y.Z-{OS}-{ARCH}`. 84 | 85 |   86 | 87 | ## Contributing 88 | Pull requests are always welcome! Please be sure the following things are taken care of with your pull request: 89 | * `go fmt` is run before pushing 90 | * Be sure to add a test case for new functionality (or explain why this cannot be done) 91 | * Run the `scripts/test.sh` script to be sure everything works 92 | * Ensure new attributes can also be set by environment variables 93 | 94 | #### Development environment requirements: 95 | * [Golang](https://golang.org/dl/) v1.11 or newer is installed and `go` is in your path 96 | * [Terraform](https://www.terraform.io/downloads.html) is installed and `terraform` is in your path 97 | 98 | To make development easy, you can use the Docker image [druggeri/tdk](https://hub.docker.com/r/druggeri/tdk) as a development environment: 99 | ``` 100 | docker run -it --name tdk --rm -v "$HOME/go":/root/go druggeri/tdk 101 | go get github.com/Mastercard/terraform-provider-restapi 102 | cd ~/go/src/github.com/Mastercard/terraform-provider-restapi 103 | #Hack hack hack 104 | ``` 105 | 106 | #### Local builds 107 | * Build the binary by running `go build` in project root directory (this generates the binary `terraform-provider-restapi`) 108 | * Create a directory structure in `~/.terraform.d/plugins` with pattern as `${host_name}/${namespace}/${type}/${version}/${os_arch}` 109 | * Example: `example.com/test/restapi/1.8.2/darwin_arm64` 110 | * Place the binary in the resulting directory -------------------------------------------------------------------------------- /restapi/api_client_test.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "crypto/tls" 8 | "crypto/x509" 9 | "crypto/x509/pkix" 10 | "encoding/pem" 11 | "log" 12 | "math/big" 13 | "net" 14 | "net/http" 15 | "os" 16 | "testing" 17 | "time" 18 | ) 19 | 20 | var ( 21 | apiClientServer *http.Server 22 | apiClientTLSServer *http.Server 23 | rootCA *x509.Certificate 24 | rootCAKey *ecdsa.PrivateKey 25 | serverCertPEM, serverKeyPEM []byte 26 | rootCAFilePath = "rootCA.pem" 27 | ) 28 | 29 | func TestAPIClient(t *testing.T) { 30 | debug := false 31 | 32 | if debug { 33 | log.Println("client_test.go: Starting HTTP server") 34 | } 35 | setupAPIClientServer() 36 | 37 | /* Notice the intentional trailing / */ 38 | opt := &apiClientOpt{ 39 | uri: "http://127.0.0.1:8083/", 40 | insecure: false, 41 | username: "", 42 | password: "", 43 | headers: make(map[string]string), 44 | timeout: 2, 45 | idAttribute: "id", 46 | copyKeys: make([]string, 0), 47 | writeReturnsObject: false, 48 | createReturnsObject: false, 49 | rateLimit: 1, 50 | debug: debug, 51 | } 52 | client, _ := NewAPIClient(opt) 53 | 54 | var res string 55 | var err error 56 | 57 | if debug { 58 | log.Printf("api_client_test.go: Testing standard OK request\n") 59 | } 60 | res, err = client.sendRequest("GET", "/ok", "") 61 | if err != nil { 62 | t.Fatalf("client_test.go: %s", err) 63 | } 64 | if res != "It works!" { 65 | t.Fatalf("client_test.go: Got back '%s' but expected 'It works!'\n", res) 66 | } 67 | 68 | if debug { 69 | log.Printf("api_client_test.go: Testing redirect request\n") 70 | } 71 | res, err = client.sendRequest("GET", "/redirect", "") 72 | if err != nil { 73 | t.Fatalf("client_test.go: %s", err) 74 | } 75 | if res != "It works!" { 76 | t.Fatalf("client_test.go: Got back '%s' but expected 'It works!'\n", res) 77 | } 78 | 79 | /* Verify timeout works */ 80 | if debug { 81 | log.Printf("api_client_test.go: Testing timeout aborts requests\n") 82 | } 83 | _, err = client.sendRequest("GET", "/slow", "") 84 | if err == nil { 85 | t.Fatalf("client_test.go: Timeout did not trigger on slow request") 86 | } 87 | 88 | if debug { 89 | log.Printf("api_client_test.go: Testing rate limited OK request\n") 90 | } 91 | startTime := time.Now().Unix() 92 | 93 | for i := 0; i < 4; i++ { 94 | client.sendRequest("GET", "/ok", "") 95 | } 96 | 97 | duration := time.Now().Unix() - startTime 98 | if duration < 3 { 99 | t.Fatalf("client_test.go: requests not delayed\n") 100 | } 101 | 102 | if debug { 103 | log.Println("client_test.go: Stopping HTTP server") 104 | } 105 | shutdownAPIClientServer() 106 | if debug { 107 | log.Println("client_test.go: Done") 108 | } 109 | 110 | // Setup and test HTTPS client with root CA 111 | setupAPIClientTLSServer() 112 | defer shutdownAPIClientTLSServer() 113 | defer os.Remove(rootCAFilePath) 114 | 115 | httpsOpt := &apiClientOpt{ 116 | uri: "https://127.0.0.1:8443/", 117 | insecure: false, 118 | username: "", 119 | password: "", 120 | headers: make(map[string]string), 121 | timeout: 2, 122 | idAttribute: "id", 123 | copyKeys: make([]string, 0), 124 | writeReturnsObject: false, 125 | createReturnsObject: false, 126 | rateLimit: 1, 127 | rootCAFile: rootCAFilePath, 128 | debug: debug, 129 | } 130 | httpsClient, httpsClientErr := NewAPIClient(httpsOpt) 131 | 132 | if httpsClientErr != nil { 133 | t.Fatalf("client_test.go: %s", httpsClientErr) 134 | } 135 | if debug { 136 | log.Printf("api_client_test.go: Testing HTTPS standard OK request\n") 137 | } 138 | res, err = httpsClient.sendRequest("GET", "/ok", "") 139 | if err != nil { 140 | t.Fatalf("client_test.go: %s", err) 141 | } 142 | if res != "It works!" { 143 | t.Fatalf("client_test.go: Got back '%s' but expected 'It works!'\n", res) 144 | } 145 | } 146 | 147 | func setupAPIClientServer() { 148 | serverMux := http.NewServeMux() 149 | serverMux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { 150 | w.Write([]byte("It works!")) 151 | }) 152 | serverMux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { 153 | time.Sleep(9999 * time.Second) 154 | w.Write([]byte("This will never return!!!!!")) 155 | }) 156 | serverMux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { 157 | http.Redirect(w, r, "/ok", http.StatusPermanentRedirect) 158 | }) 159 | 160 | apiClientServer = &http.Server{ 161 | Addr: "127.0.0.1:8083", 162 | Handler: serverMux, 163 | } 164 | go apiClientServer.ListenAndServe() 165 | /* let the server start */ 166 | time.Sleep(1 * time.Second) 167 | } 168 | 169 | func shutdownAPIClientServer() { 170 | apiClientServer.Close() 171 | } 172 | 173 | func setupAPIClientTLSServer() { 174 | generateCertificates() 175 | 176 | cert, _ := tls.X509KeyPair(serverCertPEM, serverKeyPEM) 177 | 178 | serverMux := http.NewServeMux() 179 | serverMux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { 180 | w.Write([]byte("It works!")) 181 | }) 182 | 183 | apiClientTLSServer = &http.Server{ 184 | Addr: "127.0.0.1:8443", 185 | Handler: serverMux, 186 | TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, 187 | } 188 | go apiClientTLSServer.ListenAndServeTLS("", "") 189 | /* let the server start */ 190 | time.Sleep(1 * time.Second) 191 | } 192 | 193 | func shutdownAPIClientTLSServer() { 194 | apiClientTLSServer.Close() 195 | } 196 | 197 | func generateCertificates() { 198 | // Create a CA certificate and key 199 | rootCAKey, _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 200 | rootCA = &x509.Certificate{ 201 | SerialNumber: big.NewInt(2024), 202 | Subject: pkix.Name{ 203 | Organization: []string{"Test Root CA"}, 204 | }, 205 | NotBefore: time.Now(), 206 | NotAfter: time.Now().AddDate(10, 0, 0), 207 | IsCA: true, 208 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 209 | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, 210 | BasicConstraintsValid: true, 211 | } 212 | rootCABytes, _ := x509.CreateCertificate(rand.Reader, rootCA, rootCA, &rootCAKey.PublicKey, rootCAKey) 213 | 214 | // Create a server certificate and key 215 | serverKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 216 | serverCert := &x509.Certificate{ 217 | SerialNumber: big.NewInt(2024), 218 | Subject: pkix.Name{ 219 | Organization: []string{"Test Server"}, 220 | }, 221 | NotBefore: time.Now(), 222 | NotAfter: time.Now().AddDate(0, 0, 1), 223 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 224 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, 225 | BasicConstraintsValid: true, 226 | } 227 | 228 | // Add IP SANs to the server certificate 229 | serverCert.IPAddresses = append(serverCert.IPAddresses, net.ParseIP("127.0.0.1")) 230 | 231 | serverCertBytes, _ := x509.CreateCertificate(rand.Reader, serverCert, rootCA, &serverKey.PublicKey, rootCAKey) 232 | 233 | // PEM encode the certificates and keys 234 | serverCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverCertBytes}) 235 | 236 | // Marshal the server private key 237 | serverKeyBytes, _ := x509.MarshalECPrivateKey(serverKey) 238 | serverKeyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: serverKeyBytes}) 239 | 240 | rootCAPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCABytes}) 241 | _ = os.WriteFile(rootCAFilePath, rootCAPEM, 0644) 242 | } 243 | -------------------------------------------------------------------------------- /restapi/api_client.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "log" 12 | "math" 13 | "net/http" 14 | "net/http/cookiejar" 15 | "net/url" 16 | "os" 17 | "strings" 18 | "time" 19 | 20 | "golang.org/x/oauth2" 21 | "golang.org/x/oauth2/clientcredentials" 22 | "golang.org/x/time/rate" 23 | ) 24 | 25 | type apiClientOpt struct { 26 | uri string 27 | insecure bool 28 | username string 29 | password string 30 | headers map[string]string 31 | timeout int 32 | idAttribute string 33 | createMethod string 34 | readMethod string 35 | readData string 36 | updateMethod string 37 | updateData string 38 | destroyMethod string 39 | destroyData string 40 | copyKeys []string 41 | writeReturnsObject bool 42 | createReturnsObject bool 43 | xssiPrefix string 44 | useCookies bool 45 | rateLimit float64 46 | oauthClientID string 47 | oauthClientSecret string 48 | oauthScopes []string 49 | oauthTokenURL string 50 | oauthEndpointParams url.Values 51 | certFile string 52 | keyFile string 53 | rootCAFile string 54 | certString string 55 | keyString string 56 | rootCAString string 57 | debug bool 58 | } 59 | 60 | /*APIClient is a HTTP client with additional controlling fields*/ 61 | type APIClient struct { 62 | httpClient *http.Client 63 | uri string 64 | insecure bool 65 | username string 66 | password string 67 | headers map[string]string 68 | idAttribute string 69 | createMethod string 70 | readMethod string 71 | readData string 72 | updateMethod string 73 | updateData string 74 | destroyMethod string 75 | destroyData string 76 | copyKeys []string 77 | writeReturnsObject bool 78 | createReturnsObject bool 79 | xssiPrefix string 80 | rateLimiter *rate.Limiter 81 | debug bool 82 | oauthConfig *clientcredentials.Config 83 | } 84 | 85 | // NewAPIClient makes a new api client for RESTful calls 86 | func NewAPIClient(opt *apiClientOpt) (*APIClient, error) { 87 | if opt.debug { 88 | log.Printf("api_client.go: Constructing debug api_client\n") 89 | } 90 | 91 | if opt.uri == "" { 92 | return nil, errors.New("uri must be set to construct an API client") 93 | } 94 | 95 | /* Sane default */ 96 | if opt.idAttribute == "" { 97 | opt.idAttribute = "id" 98 | } 99 | 100 | /* Remove any trailing slashes since we will append 101 | to this URL with our own root-prefixed location */ 102 | opt.uri = strings.TrimSuffix(opt.uri, "/") 103 | 104 | if opt.createMethod == "" { 105 | opt.createMethod = "POST" 106 | } 107 | if opt.readMethod == "" { 108 | opt.readMethod = "GET" 109 | } 110 | if opt.updateMethod == "" { 111 | opt.updateMethod = "PUT" 112 | } 113 | if opt.destroyMethod == "" { 114 | opt.destroyMethod = "DELETE" 115 | } 116 | 117 | tlsConfig := &tls.Config{ 118 | /* Disable TLS verification if requested */ 119 | InsecureSkipVerify: opt.insecure, 120 | } 121 | 122 | if opt.certString != "" && opt.keyString != "" { 123 | cert, err := tls.X509KeyPair([]byte(opt.certString), []byte(opt.keyString)) 124 | if err != nil { 125 | return nil, err 126 | } 127 | tlsConfig.Certificates = []tls.Certificate{cert} 128 | } 129 | 130 | if opt.certFile != "" && opt.keyFile != "" { 131 | cert, err := tls.LoadX509KeyPair(opt.certFile, opt.keyFile) 132 | if err != nil { 133 | return nil, err 134 | } 135 | tlsConfig.Certificates = []tls.Certificate{cert} 136 | } 137 | 138 | // Load root CA 139 | if opt.rootCAFile != "" || opt.rootCAString != "" { 140 | caCertPool := x509.NewCertPool() 141 | var rootCA []byte 142 | var err error 143 | 144 | if opt.rootCAFile != "" { 145 | if opt.debug { 146 | log.Printf("api_client.go: Reading root CA file: %s\n", opt.rootCAFile) 147 | } 148 | rootCA, err = os.ReadFile(opt.rootCAFile) 149 | if err != nil { 150 | return nil, fmt.Errorf("could not read root CA file: %v", err) 151 | } 152 | } else { 153 | if opt.debug { 154 | log.Printf("api_client.go: Using provided root CA string\n") 155 | } 156 | rootCA = []byte(opt.rootCAString) 157 | } 158 | 159 | if !caCertPool.AppendCertsFromPEM(rootCA) { 160 | return nil, errors.New("failed to append root CA certificate") 161 | } 162 | tlsConfig.RootCAs = caCertPool 163 | } 164 | 165 | tr := &http.Transport{ 166 | TLSClientConfig: tlsConfig, 167 | Proxy: http.ProxyFromEnvironment, 168 | } 169 | 170 | var cookieJar http.CookieJar 171 | 172 | if opt.useCookies { 173 | cookieJar, _ = cookiejar.New(nil) 174 | } 175 | 176 | rateLimit := rate.Limit(opt.rateLimit) 177 | bucketSize := int(math.Max(math.Round(opt.rateLimit), 1)) 178 | log.Printf("limit: %f bucket: %d", opt.rateLimit, bucketSize) 179 | rateLimiter := rate.NewLimiter(rateLimit, bucketSize) 180 | 181 | client := APIClient{ 182 | httpClient: &http.Client{ 183 | Timeout: time.Second * time.Duration(opt.timeout), 184 | Transport: tr, 185 | Jar: cookieJar, 186 | }, 187 | rateLimiter: rateLimiter, 188 | uri: opt.uri, 189 | insecure: opt.insecure, 190 | username: opt.username, 191 | password: opt.password, 192 | headers: opt.headers, 193 | idAttribute: opt.idAttribute, 194 | createMethod: opt.createMethod, 195 | readMethod: opt.readMethod, 196 | readData: opt.readData, 197 | updateMethod: opt.updateMethod, 198 | updateData: opt.updateData, 199 | destroyMethod: opt.destroyMethod, 200 | destroyData: opt.destroyData, 201 | copyKeys: opt.copyKeys, 202 | writeReturnsObject: opt.writeReturnsObject, 203 | createReturnsObject: opt.createReturnsObject, 204 | xssiPrefix: opt.xssiPrefix, 205 | debug: opt.debug, 206 | } 207 | 208 | if opt.oauthClientID != "" && opt.oauthClientSecret != "" && opt.oauthTokenURL != "" { 209 | client.oauthConfig = &clientcredentials.Config{ 210 | ClientID: opt.oauthClientID, 211 | ClientSecret: opt.oauthClientSecret, 212 | TokenURL: opt.oauthTokenURL, 213 | Scopes: opt.oauthScopes, 214 | EndpointParams: opt.oauthEndpointParams, 215 | } 216 | } 217 | 218 | if opt.debug { 219 | log.Printf("api_client.go: Constructed client:\n%s", client.toString()) 220 | } 221 | return &client, nil 222 | } 223 | 224 | // Convert the important bits about this object to string representation 225 | // This is useful for debugging. 226 | func (client *APIClient) toString() string { 227 | var buffer bytes.Buffer 228 | buffer.WriteString(fmt.Sprintf("uri: %s\n", client.uri)) 229 | buffer.WriteString(fmt.Sprintf("insecure: %t\n", client.insecure)) 230 | buffer.WriteString(fmt.Sprintf("username: %s\n", client.username)) 231 | buffer.WriteString(fmt.Sprintf("password: %s\n", client.password)) 232 | buffer.WriteString(fmt.Sprintf("id_attribute: %s\n", client.idAttribute)) 233 | buffer.WriteString(fmt.Sprintf("write_returns_object: %t\n", client.writeReturnsObject)) 234 | buffer.WriteString(fmt.Sprintf("create_returns_object: %t\n", client.createReturnsObject)) 235 | buffer.WriteString("headers:\n") 236 | for k, v := range client.headers { 237 | buffer.WriteString(fmt.Sprintf(" %s: %s\n", k, v)) 238 | } 239 | for _, n := range client.copyKeys { 240 | buffer.WriteString(fmt.Sprintf(" %s", n)) 241 | } 242 | return buffer.String() 243 | } 244 | 245 | /* 246 | Helper function that handles sending/receiving and handling 247 | 248 | of HTTP data in and out. 249 | */ 250 | func (client *APIClient) sendRequest(method string, path string, data string) (string, error) { 251 | fullURI := client.uri + path 252 | var req *http.Request 253 | var err error 254 | 255 | if client.debug { 256 | log.Printf("api_client.go: method='%s', path='%s', full uri (derived)='%s', data='%s'\n", method, path, fullURI, data) 257 | } 258 | 259 | buffer := bytes.NewBuffer([]byte(data)) 260 | 261 | if data == "" { 262 | req, err = http.NewRequest(method, fullURI, nil) 263 | } else { 264 | req, err = http.NewRequest(method, fullURI, buffer) 265 | 266 | /* Default of application/json, but allow headers array to overwrite later */ 267 | if err == nil { 268 | req.Header.Set("Content-Type", "application/json") 269 | } 270 | } 271 | 272 | if err != nil { 273 | log.Fatal(err) 274 | return "", err 275 | } 276 | 277 | if client.debug { 278 | log.Printf("api_client.go: Sending HTTP request to %s...\n", req.URL) 279 | } 280 | 281 | /* Allow for tokens or other pre-created secrets */ 282 | if len(client.headers) > 0 { 283 | for n, v := range client.headers { 284 | req.Header.Set(n, v) 285 | } 286 | } 287 | 288 | if client.oauthConfig != nil { 289 | ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client.httpClient) 290 | tokenSource := client.oauthConfig.TokenSource(ctx) 291 | token, err := tokenSource.Token() 292 | if err != nil { 293 | return "", err 294 | } 295 | req.Header.Set("Authorization", "Bearer "+token.AccessToken) 296 | } 297 | 298 | if client.username != "" && client.password != "" { 299 | /* ... and fall back to basic auth if configured */ 300 | req.SetBasicAuth(client.username, client.password) 301 | } 302 | 303 | if client.debug { 304 | log.Printf("api_client.go: Request headers:\n") 305 | for name, headers := range req.Header { 306 | for _, h := range headers { 307 | log.Printf("api_client.go: %v: %v", name, h) 308 | } 309 | } 310 | 311 | log.Printf("api_client.go: BODY:\n") 312 | body := "" 313 | if req.Body != nil { 314 | body = string(data) 315 | } 316 | log.Printf("%s\n", body) 317 | } 318 | 319 | if client.rateLimiter != nil { 320 | // Rate limiting 321 | if client.debug { 322 | log.Printf("Waiting for rate limit availability\n") 323 | } 324 | _ = client.rateLimiter.Wait(context.Background()) 325 | } 326 | 327 | resp, err := client.httpClient.Do(req) 328 | 329 | if err != nil { 330 | //log.Printf("api_client.go: Error detected: %s\n", err) 331 | return "", err 332 | } 333 | 334 | if client.debug { 335 | log.Printf("api_client.go: Response code: %d\n", resp.StatusCode) 336 | log.Printf("api_client.go: Response headers:\n") 337 | for name, headers := range resp.Header { 338 | for _, h := range headers { 339 | log.Printf("api_client.go: %v: %v", name, h) 340 | } 341 | } 342 | } 343 | 344 | bodyBytes, err2 := io.ReadAll(resp.Body) 345 | resp.Body.Close() 346 | 347 | if err2 != nil { 348 | return "", err2 349 | } 350 | body := strings.TrimPrefix(string(bodyBytes), client.xssiPrefix) 351 | if client.debug { 352 | log.Printf("api_client.go: BODY:\n%s\n", body) 353 | } 354 | 355 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 356 | return body, fmt.Errorf("unexpected response code '%d': %s", resp.StatusCode, body) 357 | } 358 | 359 | if body == "" { 360 | return "{}", nil 361 | } 362 | 363 | return body, nil 364 | 365 | } 366 | -------------------------------------------------------------------------------- /restapi/delta_checker_test.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | // Creating a type alias to save some typing in the test cases 10 | type MapAny = map[string]interface{} 11 | 12 | type deltaTestCase struct { 13 | testCase string 14 | testId int 15 | o1 map[string]interface{} 16 | o2 map[string]interface{} 17 | ignoreList []string 18 | resultHasDelta bool // True if the compared 19 | } 20 | 21 | var deltaTestCases = []deltaTestCase{ 22 | 23 | // Various cases where there are no changes 24 | { 25 | testCase: "No change 1", 26 | o1: MapAny{"foo": "bar"}, 27 | o2: MapAny{"foo": "bar"}, 28 | ignoreList: []string{}, 29 | resultHasDelta: false, 30 | }, 31 | 32 | { 33 | testCase: "No change - nested object", 34 | o1: MapAny{"foo": "bar", "inner": MapAny{"foo": "bar"}}, 35 | o2: MapAny{"foo": "bar", "inner": MapAny{"foo": "bar"}}, 36 | ignoreList: []string{}, 37 | resultHasDelta: false, 38 | }, 39 | 40 | { 41 | testCase: "No change - has an array", 42 | o1: MapAny{"foo": "bar", "list": []string{"foo", "bar"}}, 43 | o2: MapAny{"foo": "bar", "list": []string{"foo", "bar"}}, 44 | ignoreList: []string{}, 45 | resultHasDelta: false, 46 | }, 47 | 48 | { 49 | testCase: "No change - more types", 50 | o1: MapAny{"bool": true, "int": 4, "null": nil}, 51 | o2: MapAny{"bool": true, "int": 4, "null": nil}, 52 | ignoreList: []string{}, 53 | resultHasDelta: false, 54 | }, 55 | 56 | // These test cases come in pairs or sets. Each test in the set will have 57 | // the same o1 and o2 values. We'll first ensure that any changes by the 58 | // server are detected without an ignore_list, then we'll test the same 59 | // values again with one or more ignore_lists. 60 | 61 | // Change a field 62 | 63 | { 64 | testCase: "Server changes the value of a field", 65 | o1: MapAny{"foo": "bar"}, 66 | o2: MapAny{"foo": "changed"}, 67 | ignoreList: []string{}, 68 | resultHasDelta: true, 69 | }, 70 | 71 | { 72 | testCase: "Server changes the value of a field (ignored)", 73 | o1: MapAny{"foo": "bar"}, 74 | o2: MapAny{"foo": "changed"}, 75 | ignoreList: []string{"foo"}, 76 | resultHasDelta: false, 77 | }, 78 | 79 | // Handle nils in data 80 | { 81 | testCase: "Server changes the value of a field (nil provided)", 82 | o1: MapAny{"foo": nil}, 83 | o2: MapAny{"foo": "changed"}, 84 | ignoreList: []string{}, 85 | resultHasDelta: true, 86 | }, 87 | 88 | { 89 | testCase: "Server changes the value of a field (nil returned)", 90 | o1: MapAny{"foo": "bar"}, 91 | o2: MapAny{"foo": nil}, 92 | ignoreList: []string{}, 93 | resultHasDelta: true, 94 | }, 95 | 96 | { 97 | testCase: "Server omits setting the value of a null field", 98 | o1: MapAny{"foo": "bar", "baz": nil}, 99 | o2: MapAny{"foo": "bar"}, 100 | ignoreList: []string{}, 101 | resultHasDelta: false, 102 | }, 103 | 104 | // Add a field 105 | 106 | { 107 | testCase: "Server adds a field", 108 | o1: MapAny{"foo": "bar"}, 109 | o2: MapAny{"foo": "bar", "new": "field"}, 110 | ignoreList: []string{}, 111 | resultHasDelta: true, 112 | }, 113 | 114 | { 115 | testCase: "Server adds a field (ignored)", 116 | o1: MapAny{"foo": "bar"}, 117 | o2: MapAny{"foo": "bar", "new": "field"}, 118 | ignoreList: []string{"new"}, 119 | resultHasDelta: false, 120 | }, 121 | 122 | // Remove a field 123 | 124 | { 125 | testCase: "Server removes a field", 126 | o1: MapAny{"foo": "bar", "id": "foobar"}, 127 | o2: MapAny{"foo": "bar"}, 128 | ignoreList: []string{}, 129 | resultHasDelta: true, 130 | }, 131 | 132 | { 133 | testCase: "Server removes a field (ignored)", 134 | o1: MapAny{"foo": "bar", "id": "foobar"}, 135 | o2: MapAny{"foo": "bar"}, 136 | ignoreList: []string{"id"}, 137 | resultHasDelta: false, 138 | }, 139 | 140 | // Deep fields 141 | 142 | { 143 | testCase: "Server changes a deep field", 144 | o1: MapAny{"outside": MapAny{"change": "a"}}, 145 | o2: MapAny{"outside": MapAny{"change": "b"}}, 146 | ignoreList: []string{}, 147 | resultHasDelta: true, 148 | }, 149 | 150 | { 151 | testCase: "Server adds a deep field", 152 | o1: MapAny{"outside": MapAny{"change": "a"}}, 153 | o2: MapAny{"outside": MapAny{"change": "a", "add": "a"}}, 154 | ignoreList: []string{}, 155 | resultHasDelta: true, 156 | }, 157 | 158 | { 159 | testCase: "Server removes a deep field", 160 | o1: MapAny{"outside": MapAny{"change": "a", "remove": "a"}}, 161 | o2: MapAny{"outside": MapAny{"change": "a"}}, 162 | ignoreList: []string{}, 163 | resultHasDelta: true, 164 | }, 165 | 166 | // Deep fields (but ignored) 167 | 168 | { 169 | testCase: "Server changes a deep field (ignored)", 170 | o1: MapAny{"outside": MapAny{"change": "a"}}, 171 | o2: MapAny{"outside": MapAny{"change": "b"}}, 172 | ignoreList: []string{"outside.change"}, 173 | resultHasDelta: false, 174 | }, 175 | 176 | { 177 | testCase: "Server adds a deep field (ignored)", 178 | o1: MapAny{"outside": MapAny{"change": "a"}}, 179 | o2: MapAny{"outside": MapAny{"change": "a", "add": "a"}}, 180 | ignoreList: []string{"outside.add"}, 181 | resultHasDelta: false, 182 | }, 183 | 184 | { 185 | testCase: "Server removes a deep field (ignored)", 186 | o1: MapAny{"outside": MapAny{"change": "a", "remove": "a"}}, 187 | o2: MapAny{"outside": MapAny{"change": "a"}}, 188 | ignoreList: []string{"outside.remove"}, 189 | resultHasDelta: false, 190 | }, 191 | // Similar to 12: make sure we notice a change to a deep field even when we ignore some of them 192 | { 193 | testCase: "Server changes/adds/removes a deep field (ignored 2)", 194 | o1: MapAny{"outside": MapAny{"watch": "me", "change": "a", "remove": "a"}}, 195 | o2: MapAny{"outside": MapAny{"watch": "me_change", "change": "b", "add": "a"}}, 196 | ignoreList: []string{"outside.change", "outside.add", "outside.remove"}, 197 | resultHasDelta: true, 198 | }, 199 | 200 | // Similar to 12,13 but ignore the whole "outside" 201 | { 202 | testCase: "Server changes/adds/removes a deep field (ignore root field)", 203 | o1: MapAny{"outside": MapAny{"watch": "me", "change": "a", "remove": "a"}}, 204 | o2: MapAny{"outside": MapAny{"watch": "me_change", "change": "b", "add": "a"}}, 205 | ignoreList: []string{"outside"}, 206 | resultHasDelta: false, 207 | }, 208 | 209 | // Basic List Changes 210 | // Note: we don't support ignoring specific differences to lists - only ignoring the list as a whole 211 | { 212 | testCase: "Server adds to list", 213 | o1: MapAny{"list": []string{"foo", "bar"}}, 214 | o2: MapAny{"list": []string{"foo", "bar", "baz"}}, 215 | ignoreList: []string{}, 216 | resultHasDelta: true, 217 | }, 218 | 219 | { 220 | testCase: "Server removes from list", 221 | o1: MapAny{"list": []string{"foo", "bar"}}, 222 | o2: MapAny{"list": []string{"foo"}}, 223 | ignoreList: []string{}, 224 | resultHasDelta: true, 225 | }, 226 | 227 | { 228 | testCase: "Server changes an item in the list", 229 | o1: MapAny{"list": []string{"foo", "bar"}}, 230 | o2: MapAny{"list": []string{"foo", "BAR"}}, 231 | ignoreList: []string{}, 232 | resultHasDelta: true, 233 | }, 234 | 235 | { 236 | testCase: "Server rearranges the list", 237 | o1: MapAny{"list": []string{"foo", "bar"}}, 238 | o2: MapAny{"list": []string{"bar", "foo"}}, 239 | ignoreList: []string{}, 240 | resultHasDelta: true, 241 | }, 242 | 243 | { 244 | testCase: "Server changes the list but we ignore the whole list", 245 | o1: MapAny{"list": []string{"foo", "bar"}}, 246 | o2: MapAny{"list": []string{"bar", "foo"}}, 247 | ignoreList: []string{"list"}, 248 | resultHasDelta: false, 249 | }, 250 | 251 | // We don't currently support ignoring a change like this, but we could in the future with a syntax like `list[].val` similar to jq 252 | { 253 | testCase: "Server changes a sub-value in a list of objects", 254 | o1: MapAny{"list": []MapAny{{"key": "foo", "val": "x"}, {"key": "bar", "val": "x"}}}, 255 | o2: MapAny{"list": []MapAny{{"key": "foo", "val": "Y"}, {"key": "bar", "val": "Z"}}}, 256 | ignoreList: []string{}, 257 | resultHasDelta: true, 258 | }, 259 | } 260 | 261 | /* 262 | * Since I'm not super familiar with Go, and most of the hasDelta code is 263 | * effectively "untyped", I want to make sure my code doesn't fail when 264 | * comparing certain type combinations 265 | */ 266 | func generateTypeConversionTests() []deltaTestCase { 267 | typeValues := MapAny{ 268 | "string": "foo", 269 | "number": 42, 270 | "object": MapAny{"foo": "bar"}, 271 | "array": []string{"foo", "bar"}, 272 | "bool_true": true, 273 | "bool_false": false, 274 | } 275 | 276 | tests := make([]deltaTestCase, len(typeValues)*len(typeValues)) 277 | 278 | testCounter := 0 279 | for fromType, fromValue := range typeValues { 280 | for toType, toValue := range typeValues { 281 | tests = append(tests, deltaTestCase{ 282 | testCase: fmt.Sprintf("Type Conversion from [%s] to [%s]", fromType, toType), 283 | o1: MapAny{"value": fromValue}, 284 | o2: MapAny{"value": toValue}, 285 | ignoreList: []string{}, 286 | resultHasDelta: fromType != toType, 287 | }) 288 | 289 | testCounter += 1 290 | } 291 | } 292 | 293 | return tests 294 | } 295 | 296 | func TestHasDelta(t *testing.T) { 297 | // Run the main test cases 298 | for _, testCase := range deltaTestCases { 299 | _, result := getDelta(testCase.o1, testCase.o2, testCase.ignoreList) 300 | if result != testCase.resultHasDelta { 301 | t.Errorf("delta_checker_test.go: Test Case [%s] wanted [%v] got [%v]", testCase.testCase, testCase.resultHasDelta, result) 302 | } 303 | } 304 | 305 | // Test type changes 306 | for _, testCase := range generateTypeConversionTests() { 307 | _, result := getDelta(testCase.o1, testCase.o2, testCase.ignoreList) 308 | if result != testCase.resultHasDelta { 309 | t.Errorf("delta_checker_test.go: TYPE CONVERSION Test Case [%d:%s] wanted [%v] got [%v]", testCase.testId, testCase.testCase, testCase.resultHasDelta, result) 310 | } 311 | } 312 | } 313 | 314 | func TestHasDeltaModifiedResource(t *testing.T) { 315 | 316 | // Test modifiedResource return val 317 | 318 | recordedInput := map[string]interface{}{ 319 | "name": "Joey", 320 | "color": "tabby", 321 | "hobbies": map[string]interface{}{ 322 | "hunting": "birds", 323 | "eating": "plants", 324 | }, 325 | } 326 | 327 | actualInput := map[string]interface{}{ 328 | "color": "tabby", 329 | "hairball": true, 330 | "hobbies": map[string]interface{}{ 331 | "hunting": "birds", 332 | "eating": "plants", 333 | "sleeping": "yep", 334 | }, 335 | } 336 | 337 | expectedOutput := map[string]interface{}{ 338 | "name": "Joey", 339 | "color": "tabby", 340 | "hobbies": map[string]interface{}{ 341 | "hunting": "birds", 342 | "eating": "plants", 343 | }, 344 | } 345 | 346 | ignoreList := []string{"hairball", "hobbies.sleeping", "name"} 347 | 348 | modified, _ := getDelta(recordedInput, actualInput, ignoreList) 349 | if !reflect.DeepEqual(expectedOutput, modified) { 350 | t.Errorf("delta_checker_test.go: Unexpected delta: expected %v but got %v", expectedOutput, modified) 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /restapi/api_object_test.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "testing" 8 | 9 | "github.com/Mastercard/terraform-provider-restapi/fakeserver" 10 | ) 11 | 12 | var testDebug = false 13 | var httpServerDebug = false 14 | var apiObjectDebug = false 15 | var apiClientDebug = false 16 | 17 | type testAPIObject struct { 18 | TestCase string `json:"Test_case"` 19 | ID string `json:"Id"` 20 | Revision int `json:"Revision,omitempty"` 21 | Thing string `json:"Thing,omitempty"` 22 | IsCat bool `json:"Is_cat,omitempty"` 23 | Colors []string `json:"Colors,omitempty"` 24 | Attrs map[string]string `json:"Attrs,omitempty"` 25 | } 26 | 27 | var testingDataObjects = []string{ 28 | `{ 29 | "Test_case": "normal", 30 | "Id": "1", 31 | "Revision": 1, 32 | "Thing": "potato", 33 | "Is_cat": false, 34 | "Colors": [ 35 | "orange", 36 | "white" 37 | ], 38 | "Attrs": { 39 | "size": "6 in", 40 | "weight": "10 oz" 41 | } 42 | }`, 43 | `{ 44 | "Test_case": "minimal", 45 | "Id": "2", 46 | "Thing": "fork" 47 | }`, 48 | `{ 49 | "Test_case": "no Colors", 50 | "Id": "3", 51 | "Thing": "paper", 52 | "Is_cat": false, 53 | "Attrs": { 54 | "height": "8.5 in", 55 | "width": "11 in" 56 | } 57 | }`, 58 | `{ 59 | "Test_case": "no Attrs", 60 | "Id": "4", 61 | "Thing": "nothing", 62 | "Is_cat": false, 63 | "Colors": [ 64 | "none" 65 | ] 66 | }`, 67 | `{ 68 | "Test_case": "pet", 69 | "Id": "5", 70 | "Thing": "cat", 71 | "Is_cat": true, 72 | "Colors": [ 73 | "orange", 74 | "white" 75 | ], 76 | "Attrs": { 77 | "size": "1.5 ft", 78 | "weight": "15 lb" 79 | } 80 | }`, 81 | } 82 | 83 | var client, err = NewAPIClient(&apiClientOpt{ 84 | uri: "http://127.0.0.1:8081/", 85 | insecure: false, 86 | username: "", 87 | password: "", 88 | headers: make(map[string]string), 89 | timeout: 5, 90 | idAttribute: "Id", 91 | copyKeys: []string{"Thing"}, 92 | writeReturnsObject: true, 93 | createReturnsObject: false, 94 | debug: apiClientDebug, 95 | }) 96 | 97 | func generateTestObjects(dataObjects []string, t *testing.T, testDebug bool) (typed map[string]testAPIObject, untyped map[string]map[string]interface{}) { 98 | /* Messy... fakeserver wants "generic" objects, but it is much easier 99 | to write our test cases with typed (test_api_object) objects. Make 100 | maps of both */ 101 | typed = make(map[string]testAPIObject) 102 | untyped = make(map[string]map[string]interface{}) 103 | 104 | for _, dataObject := range dataObjects { 105 | testObj, apiServerObj := addTestAPIObject(dataObject, t, testDebug) 106 | 107 | id := testObj.ID 108 | testCase := testObj.TestCase 109 | 110 | if testDebug { 111 | log.Printf("api_object_test.go: Adding test object for case '%s' as id '%s'\n", testCase, id) 112 | } 113 | typed[id] = testObj 114 | 115 | if testDebug { 116 | log.Printf("api_object_test.go: Adding API server test object for case '%s' as id '%s'\n", testCase, id) 117 | } 118 | untyped[id] = apiServerObj 119 | } 120 | 121 | return typed, untyped 122 | } 123 | 124 | func addTestAPIObject(input string, t *testing.T, testDebug bool) (testObj testAPIObject, apiServerObj map[string]interface{}) { 125 | if err := json.Unmarshal([]byte(input), &testObj); err != nil { 126 | t.Fatalf("api_object_test.go: Failed to unmarshall JSON (to test_api_object) from '%s'", input) 127 | } 128 | 129 | if err := json.Unmarshal([]byte(input), &apiServerObj); err != nil { 130 | t.Fatalf("api_object_test.go: Failed to unmarshall JSON (to api_server_object) from '%s'", input) 131 | } 132 | 133 | return testObj, apiServerObj 134 | } 135 | 136 | func TestAPIObject(t *testing.T) { 137 | generatedObjects, apiServerObjects := generateTestObjects(testingDataObjects, t, testDebug) 138 | 139 | /* Construct a local map of test case objects with only the ID populated */ 140 | if testDebug { 141 | log.Println("api_object_test.go: Building test objects...") 142 | } 143 | 144 | /* Holds the full list of api_object items that we are testing 145 | indexed by the name of the test case */ 146 | var testingObjects = make(map[string]*APIObject) 147 | 148 | for id, testObj := range generatedObjects { 149 | if testDebug { 150 | log.Printf("api_object_test.go: '%s'\n", id) 151 | } 152 | 153 | objectOpts := &apiObjectOpts{ 154 | path: "/api/objects", 155 | data: fmt.Sprintf(`{ "Id": "%s" }`, id), /* Start with only an empty JSON object ID as our "data" */ 156 | debug: apiObjectDebug, /* Whether the object's debug is enabled */ 157 | } 158 | o, err := NewAPIObject(client, objectOpts) 159 | if err != nil { 160 | t.Fatalf("api_object_test.go: Failed to create new api_object for id '%s'", id) 161 | } 162 | 163 | testCase := testObj.TestCase 164 | testingObjects[testCase] = o 165 | } 166 | 167 | if testDebug { 168 | log.Println("api_object_test.go: Starting HTTP server") 169 | } 170 | svr := fakeserver.NewFakeServer(8081, apiServerObjects, true, httpServerDebug, "") 171 | 172 | /* Loop through all of the objects and GET their data from the server */ 173 | t.Run("read_object", func(t *testing.T) { 174 | if testDebug { 175 | log.Printf("api_object_test.go: Testing read_object()") 176 | } 177 | for testCase := range testingObjects { 178 | t.Run(testCase, func(t *testing.T) { 179 | if testDebug { 180 | log.Printf("api_object_test.go: Getting data for '%s' test case from server\n", testCase) 181 | } 182 | err := testingObjects[testCase].readObject() 183 | if err != nil { 184 | t.Fatalf("api_object_test.go: Failed to read data for test case '%s': %s", testCase, err) 185 | } 186 | }) 187 | } 188 | }) 189 | 190 | t.Run("read_object_with_read_data", func(t *testing.T) { 191 | if testDebug { 192 | log.Printf("api_object_test.go: Testing read_object() with read_data") 193 | } 194 | for testCase := range testingObjects { 195 | t.Run(testCase, func(t *testing.T) { 196 | if testDebug { 197 | log.Printf("api_object_test.go: Getting data for '%s' test case from server\n", testCase) 198 | } 199 | testingObjects[testCase].readData["path"] = "/" + testCase 200 | err := testingObjects[testCase].readObject() 201 | if err != nil { 202 | t.Fatalf("api_object_test.go: Failed to read data for test case '%s': %s", testCase, err) 203 | } 204 | }) 205 | } 206 | }) 207 | 208 | /* Verify our copy_keys is happy by seeing if Thing made it into the data hash */ 209 | t.Run("copy_keys", func(t *testing.T) { 210 | if testDebug { 211 | log.Printf("api_object_test.go: Testing copy_keys()") 212 | } 213 | if testingObjects["normal"].data["Thing"].(string) == "" { 214 | t.Fatalf("api_object_test.go: copy_keys for 'normal' object failed. Expected 'Thing' to be non-empty, but got '%+v'\n", testingObjects["normal"].data["Thing"]) 215 | } 216 | }) 217 | 218 | /* Go ahead and update one of our objects */ 219 | t.Run("update_object", func(t *testing.T) { 220 | if testDebug { 221 | log.Printf("api_object_test.go: Testing update_object()") 222 | } 223 | testingObjects["minimal"].data["Thing"] = "spoon" 224 | testingObjects["minimal"].updateObject() 225 | if err != nil { 226 | t.Fatalf("api_object_test.go: Failed in update_object() test: %s", err) 227 | } else if testingObjects["minimal"].apiData["Thing"] != "spoon" { 228 | t.Fatalf("api_object_test.go: Failed to update 'Thing' field of 'minimal' object. Expected it to be '%s' but it is '%s'\nFull obj: %+v\n", 229 | "spoon", testingObjects["minimal"].apiData["Thing"], testingObjects["minimal"]) 230 | } 231 | }) 232 | 233 | /* Update once more with update_data */ 234 | t.Run("update_object_with_update_data", func(t *testing.T) { 235 | if testDebug { 236 | log.Printf("api_object_test.go: Testing update_object() with update_data") 237 | } 238 | testingObjects["minimal"].updateData["Thing"] = "knife" 239 | testingObjects["minimal"].updateObject() 240 | if err != nil { 241 | t.Fatalf("api_object_test.go: Failed in update_object() test: %s", err) 242 | } else if testingObjects["minimal"].apiData["Thing"] != "knife" { 243 | t.Fatalf("api_object_test.go: Failed to update 'Thing' field of 'minimal' object. Expected it to be '%s' but it is '%s'\nFull obj: %+v\n", 244 | "knife", testingObjects["minimal"].apiData["Thing"], testingObjects["minimal"]) 245 | } 246 | }) 247 | 248 | /* Delete one and make sure a 404 follows */ 249 | t.Run("delete_object", func(t *testing.T) { 250 | if testDebug { 251 | log.Printf("api_object_test.go: Testing delete_object()") 252 | } 253 | testingObjects["pet"].deleteObject() 254 | err = testingObjects["pet"].readObject() 255 | if err != nil { 256 | t.Fatalf("api_object_test.go: 'pet' object deleted, but an error was returned when reading the object (expected the provider to cope with this!\n") 257 | } 258 | }) 259 | 260 | /* Recreate the one we just got rid of */ 261 | t.Run("create_object", func(t *testing.T) { 262 | if testDebug { 263 | log.Printf("api_object_test.go: Testing create_object()") 264 | } 265 | testingObjects["pet"].data["Thing"] = "dog" 266 | err = testingObjects["pet"].createObject() 267 | if err != nil { 268 | t.Fatalf("api_object_test.go: Failed in create_object() test: %s", err) 269 | } else if testingObjects["minimal"].apiData["Thing"] != "knife" { 270 | t.Fatalf("api_object_test.go: Failed to update 'Thing' field of 'minimal' object. Expected it to be '%s' but it is '%s'\nFull obj: %+v\n", 271 | "knife", testingObjects["minimal"].apiData["Thing"], testingObjects["minimal"]) 272 | } 273 | 274 | /* verify it's there */ 275 | err = testingObjects["pet"].readObject() 276 | if err != nil { 277 | t.Fatalf("api_object_test.go: Failed in read_object() test: %s", err) 278 | } else if testingObjects["pet"].apiData["Thing"] != "dog" { 279 | t.Fatalf("api_object_test.go: Failed in create_object() test. Object created is xpected it to be '%s' but it is '%s'\nFull obj: %+v\n", 280 | "dog", testingObjects["minimal"].apiData["Thing"], testingObjects["minimal"]) 281 | } 282 | }) 283 | 284 | t.Run("find_object", func(t *testing.T) { 285 | objectOpts := &apiObjectOpts{ 286 | path: "/api/objects", 287 | debug: apiObjectDebug, 288 | } 289 | object, err := NewAPIObject(client, objectOpts) 290 | if err != nil { 291 | t.Fatalf("api_object_test.go: Failed to create new api_object to find") 292 | } 293 | 294 | queryString := "" 295 | searchKey := "Thing" 296 | searchValue := "dog" 297 | resultsKey := "" 298 | searchData := "" 299 | tmpObj, err := object.findObject(queryString, searchKey, searchValue, resultsKey, searchData) 300 | if err != nil { 301 | t.Fatalf("api_object_test.go: Failed to find api_object: %s", searchValue) 302 | } 303 | 304 | if object.id != "5" { 305 | t.Errorf("%s: expected populated object from search to be %s but got %s", searchValue, "5", object.id) 306 | } 307 | 308 | if tmpObj["Id"] != "5" { 309 | t.Errorf("%s: expected found object from search to be %s but got %s from %v", searchValue, "5", tmpObj["Id"], tmpObj) 310 | } 311 | }) 312 | 313 | /* Delete it again with destroy_data and make sure a 404 follows */ 314 | t.Run("delete_object_with_destroy_data", func(t *testing.T) { 315 | if testDebug { 316 | log.Printf("api_object_test.go: Testing delete_object() with destroy_data") 317 | } 318 | testingObjects["pet"].destroyData["destroy"] = "true" 319 | testingObjects["pet"].deleteObject() 320 | err = testingObjects["pet"].readObject() 321 | if err != nil { 322 | t.Fatalf("api_object_test.go: 'pet' object deleted, but an error was returned when reading the object (expected the provider to cope with this!\n") 323 | } 324 | }) 325 | 326 | if testDebug { 327 | log.Println("api_object_test.go: Stopping HTTP server") 328 | } 329 | svr.Shutdown() 330 | if testDebug { 331 | log.Println("api_object_test.go: Done") 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /restapi/provider.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net/url" 7 | 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 9 | ) 10 | 11 | /*Provider implements the REST API provider*/ 12 | func Provider() *schema.Provider { 13 | return &schema.Provider{ 14 | Schema: map[string]*schema.Schema{ 15 | "uri": { 16 | Type: schema.TypeString, 17 | Required: true, 18 | DefaultFunc: schema.EnvDefaultFunc("REST_API_URI", nil), 19 | Description: "URI of the REST API endpoint. This serves as the base of all requests.", 20 | }, 21 | "insecure": { 22 | Type: schema.TypeBool, 23 | Optional: true, 24 | DefaultFunc: schema.EnvDefaultFunc("REST_API_INSECURE", nil), 25 | Description: "When using https, this disables TLS verification of the host.", 26 | }, 27 | "username": { 28 | Type: schema.TypeString, 29 | Optional: true, 30 | DefaultFunc: schema.EnvDefaultFunc("REST_API_USERNAME", nil), 31 | Description: "When set, will use this username for BASIC auth to the API.", 32 | }, 33 | "password": { 34 | Type: schema.TypeString, 35 | Optional: true, 36 | DefaultFunc: schema.EnvDefaultFunc("REST_API_PASSWORD", nil), 37 | Description: "When set, will use this password for BASIC auth to the API.", 38 | }, 39 | "headers": { 40 | Type: schema.TypeMap, 41 | Elem: schema.TypeString, 42 | Optional: true, 43 | Description: "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.", 44 | }, 45 | "use_cookies": { 46 | Type: schema.TypeBool, 47 | Optional: true, 48 | DefaultFunc: schema.EnvDefaultFunc("REST_API_USE_COOKIES", nil), 49 | Description: "Enable cookie jar to persist session.", 50 | }, 51 | "timeout": { 52 | Type: schema.TypeInt, 53 | Optional: true, 54 | DefaultFunc: schema.EnvDefaultFunc("REST_API_TIMEOUT", 0), 55 | Description: "When set, will cause requests taking longer than this time (in seconds) to be aborted.", 56 | }, 57 | "id_attribute": { 58 | Type: schema.TypeString, 59 | Optional: true, 60 | DefaultFunc: schema.EnvDefaultFunc("REST_API_ID_ATTRIBUTE", nil), 61 | 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\"}}`", 62 | }, 63 | "create_method": { 64 | Type: schema.TypeString, 65 | DefaultFunc: schema.EnvDefaultFunc("REST_API_CREATE_METHOD", nil), 66 | Description: "Defaults to `POST`. The HTTP method used to CREATE objects of this type on the API server.", 67 | Optional: true, 68 | }, 69 | "read_method": { 70 | Type: schema.TypeString, 71 | DefaultFunc: schema.EnvDefaultFunc("REST_API_READ_METHOD", nil), 72 | Description: "Defaults to `GET`. The HTTP method used to READ objects of this type on the API server.", 73 | Optional: true, 74 | }, 75 | "update_method": { 76 | Type: schema.TypeString, 77 | DefaultFunc: schema.EnvDefaultFunc("REST_API_UPDATE_METHOD", nil), 78 | Description: "Defaults to `PUT`. The HTTP method used to UPDATE objects of this type on the API server.", 79 | Optional: true, 80 | }, 81 | "destroy_method": { 82 | Type: schema.TypeString, 83 | DefaultFunc: schema.EnvDefaultFunc("REST_API_DESTROY_METHOD", nil), 84 | Description: "Defaults to `DELETE`. The HTTP method used to DELETE objects of this type on the API server.", 85 | Optional: true, 86 | }, 87 | "copy_keys": { 88 | Type: schema.TypeList, 89 | Elem: &schema.Schema{ 90 | Type: schema.TypeString, 91 | }, 92 | Optional: true, 93 | Description: "When set, any PUT to the API for an object will copy these keys from the data the provider has gathered about the object. This is useful if internal API information must also be provided with updates, such as the revision of the object.", 94 | }, 95 | "write_returns_object": { 96 | Type: schema.TypeBool, 97 | Optional: true, 98 | DefaultFunc: schema.EnvDefaultFunc("REST_API_WRO", nil), 99 | Description: "Set this when the API returns the object created on all write operations (POST, PUT). This is used by the provider to refresh internal data structures.", 100 | }, 101 | "create_returns_object": { 102 | Type: schema.TypeBool, 103 | Optional: true, 104 | DefaultFunc: schema.EnvDefaultFunc("REST_API_CRO", nil), 105 | Description: "Set this when the API returns the object created only on creation operations (POST). This is used by the provider to refresh internal data structures.", 106 | }, 107 | "xssi_prefix": { 108 | Type: schema.TypeString, 109 | Optional: true, 110 | DefaultFunc: schema.EnvDefaultFunc("REST_API_XSSI_PREFIX", nil), 111 | Description: "Trim the xssi prefix from response string, if present, before parsing.", 112 | }, 113 | "rate_limit": { 114 | Type: schema.TypeFloat, 115 | Optional: true, 116 | DefaultFunc: schema.EnvDefaultFunc("REST_API_RATE_LIMIT", math.MaxFloat64), 117 | Description: "Set this to limit the number of requests per second made to the API.", 118 | }, 119 | "test_path": { 120 | Type: schema.TypeString, 121 | Optional: true, 122 | DefaultFunc: schema.EnvDefaultFunc("REST_API_TEST_PATH", nil), 123 | Description: "If set, the provider will issue a read_method request to this path after instantiation requiring a 200 OK response before proceeding. This is useful if your API provides a no-op endpoint that can signal if this provider is configured correctly. Response data will be ignored.", 124 | }, 125 | "debug": { 126 | Type: schema.TypeBool, 127 | Optional: true, 128 | DefaultFunc: schema.EnvDefaultFunc("REST_API_DEBUG", nil), 129 | Description: "Enabling this will cause lots of debug information to be printed to STDOUT by the API client.", 130 | }, 131 | "oauth_client_credentials": { 132 | Type: schema.TypeList, 133 | Optional: true, 134 | MaxItems: 1, 135 | Description: "Configuration for oauth client credential flow using the https://pkg.go.dev/golang.org/x/oauth2 implementation", 136 | Elem: &schema.Resource{ 137 | Schema: map[string]*schema.Schema{ 138 | "oauth_client_id": { 139 | Type: schema.TypeString, 140 | Description: "client id", 141 | Required: true, 142 | }, 143 | "oauth_client_secret": { 144 | Type: schema.TypeString, 145 | Description: "client secret", 146 | Required: true, 147 | }, 148 | "oauth_token_endpoint": { 149 | Type: schema.TypeString, 150 | Description: "oauth token endpoint", 151 | Required: true, 152 | }, 153 | "oauth_scopes": { 154 | Type: schema.TypeList, 155 | Elem: &schema.Schema{Type: schema.TypeString}, 156 | Optional: true, 157 | Description: "scopes", 158 | }, 159 | "endpoint_params": { 160 | Type: schema.TypeMap, 161 | Optional: true, 162 | Description: "Additional key/values to pass to the underlying Oauth client library (as EndpointParams)", 163 | Elem: &schema.Schema{ 164 | Type: schema.TypeString, 165 | }, 166 | }, 167 | }, 168 | }, 169 | }, 170 | "cert_string": { 171 | Type: schema.TypeString, 172 | Optional: true, 173 | DefaultFunc: schema.EnvDefaultFunc("REST_API_CERT_STRING", nil), 174 | Description: "When set with the key_string parameter, the provider will load a client certificate as a string for mTLS authentication.", 175 | }, 176 | "key_string": { 177 | Type: schema.TypeString, 178 | Optional: true, 179 | DefaultFunc: schema.EnvDefaultFunc("REST_API_KEY_STRING", nil), 180 | Description: "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.", 181 | }, 182 | "cert_file": { 183 | Type: schema.TypeString, 184 | Optional: true, 185 | DefaultFunc: schema.EnvDefaultFunc("REST_API_CERT_FILE", nil), 186 | Description: "When set with the key_file parameter, the provider will load a client certificate as a file for mTLS authentication.", 187 | }, 188 | "key_file": { 189 | Type: schema.TypeString, 190 | Optional: true, 191 | DefaultFunc: schema.EnvDefaultFunc("REST_API_KEY_FILE", nil), 192 | Description: "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.", 193 | }, 194 | "root_ca_file": { 195 | Type: schema.TypeString, 196 | Optional: true, 197 | DefaultFunc: schema.EnvDefaultFunc("REST_API_ROOT_CA_FILE", nil), 198 | Description: "When set, the provider will load a root CA certificate as a file for mTLS authentication. This is useful when the API server is using a self-signed certificate and the client needs to trust it.", 199 | }, 200 | "root_ca_string": { 201 | Type: schema.TypeString, 202 | Optional: true, 203 | DefaultFunc: schema.EnvDefaultFunc("REST_API_ROOT_CA_STRING", nil), 204 | Description: "When set, the provider will load a root CA certificate as a string for mTLS authentication. This is useful when the API server is using a self-signed certificate and the client needs to trust it.", 205 | }, 206 | }, 207 | ResourcesMap: map[string]*schema.Resource{ 208 | /* Could only get terraform to recognize this resource if 209 | the name began with the provider's name and had at least 210 | one underscore. This is not documented anywhere I could find */ 211 | "restapi_object": resourceRestAPI(), 212 | }, 213 | DataSourcesMap: map[string]*schema.Resource{ 214 | "restapi_object": dataSourceRestAPI(), 215 | }, 216 | ConfigureFunc: configureProvider, 217 | } 218 | } 219 | 220 | func configureProvider(d *schema.ResourceData) (interface{}, error) { 221 | 222 | /* As "data-safe" as terraform says it is, you'd think 223 | it would have already coaxed this to a slice FOR me */ 224 | copyKeys := make([]string, 0) 225 | if iCopyKeys := d.Get("copy_keys"); iCopyKeys != nil { 226 | for _, v := range iCopyKeys.([]interface{}) { 227 | copyKeys = append(copyKeys, v.(string)) 228 | } 229 | } 230 | 231 | headers := make(map[string]string) 232 | if iHeaders := d.Get("headers"); iHeaders != nil { 233 | for k, v := range iHeaders.(map[string]interface{}) { 234 | headers[k] = v.(string) 235 | } 236 | } 237 | 238 | opt := &apiClientOpt{ 239 | uri: d.Get("uri").(string), 240 | insecure: d.Get("insecure").(bool), 241 | username: d.Get("username").(string), 242 | password: d.Get("password").(string), 243 | headers: headers, 244 | useCookies: d.Get("use_cookies").(bool), 245 | timeout: d.Get("timeout").(int), 246 | idAttribute: d.Get("id_attribute").(string), 247 | copyKeys: copyKeys, 248 | writeReturnsObject: d.Get("write_returns_object").(bool), 249 | createReturnsObject: d.Get("create_returns_object").(bool), 250 | xssiPrefix: d.Get("xssi_prefix").(string), 251 | rateLimit: d.Get("rate_limit").(float64), 252 | debug: d.Get("debug").(bool), 253 | } 254 | 255 | if v, ok := d.GetOk("create_method"); ok { 256 | opt.createMethod = v.(string) 257 | } 258 | if v, ok := d.GetOk("read_method"); ok { 259 | opt.readMethod = v.(string) 260 | } 261 | if v, ok := d.GetOk("update_method"); ok { 262 | opt.updateMethod = v.(string) 263 | } 264 | if v, ok := d.GetOk("destroy_method"); ok { 265 | opt.destroyMethod = v.(string) 266 | } 267 | if v, ok := d.GetOk("oauth_client_credentials"); ok { 268 | oauthConfig := v.([]interface{})[0].(map[string]interface{}) 269 | 270 | opt.oauthClientID = oauthConfig["oauth_client_id"].(string) 271 | opt.oauthClientSecret = oauthConfig["oauth_client_secret"].(string) 272 | opt.oauthTokenURL = oauthConfig["oauth_token_endpoint"].(string) 273 | opt.oauthScopes = expandStringSet(oauthConfig["oauth_scopes"].([]interface{})) 274 | 275 | if tmp, ok := oauthConfig["endpoint_params"]; ok { 276 | m := tmp.(map[string]interface{}) 277 | setVals := url.Values{} 278 | for k, val := range m { 279 | setVals.Add(k, val.(string)) 280 | } 281 | opt.oauthEndpointParams = setVals 282 | } 283 | } 284 | if v, ok := d.GetOk("cert_file"); ok { 285 | opt.certFile = v.(string) 286 | } 287 | if v, ok := d.GetOk("key_file"); ok { 288 | opt.keyFile = v.(string) 289 | } 290 | if v, ok := d.GetOk("cert_string"); ok { 291 | opt.certString = v.(string) 292 | } 293 | if v, ok := d.GetOk("key_string"); ok { 294 | opt.keyString = v.(string) 295 | } 296 | if v, ok := d.GetOk("root_ca_file"); ok { 297 | opt.rootCAFile = v.(string) 298 | } 299 | if v, ok := d.GetOk("root_ca_string"); ok { 300 | opt.rootCAString = v.(string) 301 | 302 | } 303 | client, err := NewAPIClient(opt) 304 | 305 | if v, ok := d.GetOk("test_path"); ok { 306 | testPath := v.(string) 307 | _, err := client.sendRequest(client.readMethod, testPath, "") 308 | if err != nil { 309 | return client, fmt.Errorf("a test request to %v after setting up the provider did not return an OK response - is your configuration correct? %v", testPath, err) 310 | } 311 | } 312 | return client, err 313 | } 314 | -------------------------------------------------------------------------------- /restapi/resource_api_object.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "runtime" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 12 | ) 13 | 14 | func resourceRestAPI() *schema.Resource { 15 | // Consider data sensitive if env variables is set to true. 16 | isDataSensitive, _ := strconv.ParseBool(GetEnvOrDefault("API_DATA_IS_SENSITIVE", "false")) 17 | 18 | return &schema.Resource{ 19 | Create: resourceRestAPICreate, 20 | Read: resourceRestAPIRead, 21 | Update: resourceRestAPIUpdate, 22 | Delete: resourceRestAPIDelete, 23 | Exists: resourceRestAPIExists, 24 | 25 | Description: "Acting as a wrapper of cURL, this object supports POST, GET, PUT and DELETE on the specified url", 26 | 27 | Importer: &schema.ResourceImporter{ 28 | State: resourceRestAPIImport, 29 | }, 30 | 31 | Schema: map[string]*schema.Schema{ 32 | "path": { 33 | Type: schema.TypeString, 34 | Description: "The API path on top of the base URL set in the provider that represents objects of this type on the API server.", 35 | Required: true, 36 | }, 37 | "create_path": { 38 | Type: schema.TypeString, 39 | Description: "Defaults to `path`. The API path that represents where to CREATE (POST) objects of this type on the API server. The string `{id}` will be replaced with the terraform ID of the object if the data contains the `id_attribute`.", 40 | Optional: true, 41 | }, 42 | "read_path": { 43 | Type: schema.TypeString, 44 | Description: "Defaults to `path/{id}`. The API path that represents where to READ (GET) objects of this type on the API server. The string `{id}` will be replaced with the terraform ID of the object.", 45 | Optional: true, 46 | }, 47 | "update_path": { 48 | Type: schema.TypeString, 49 | Description: "Defaults to `path/{id}`. The API path that represents where to UPDATE (PUT) objects of this type on the API server. The string `{id}` will be replaced with the terraform ID of the object.", 50 | Optional: true, 51 | }, 52 | "create_method": { 53 | Type: schema.TypeString, 54 | Description: "Defaults to `create_method` set on the provider. Allows per-resource override of `create_method` (see `create_method` provider config documentation)", 55 | Optional: true, 56 | }, 57 | "read_method": { 58 | Type: schema.TypeString, 59 | Description: "Defaults to `read_method` set on the provider. Allows per-resource override of `read_method` (see `read_method` provider config documentation)", 60 | Optional: true, 61 | }, 62 | "update_method": { 63 | Type: schema.TypeString, 64 | Description: "Defaults to `update_method` set on the provider. Allows per-resource override of `update_method` (see `update_method` provider config documentation)", 65 | Optional: true, 66 | }, 67 | "destroy_method": { 68 | Type: schema.TypeString, 69 | Description: "Defaults to `destroy_method` set on the provider. Allows per-resource override of `destroy_method` (see `destroy_method` provider config documentation)", 70 | Optional: true, 71 | }, 72 | "destroy_path": { 73 | Type: schema.TypeString, 74 | Description: "Defaults to `path/{id}`. The API path that represents where to DESTROY (DELETE) objects of this type on the API server. The string `{id}` will be replaced with the terraform ID of the object.", 75 | Optional: true, 76 | }, 77 | "id_attribute": { 78 | Type: schema.TypeString, 79 | Description: "Defaults to `id_attribute` set on the provider. Allows per-resource override of `id_attribute` (see `id_attribute` provider config documentation)", 80 | Optional: true, 81 | }, 82 | "object_id": { 83 | Type: schema.TypeString, 84 | 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.", 85 | Optional: true, 86 | }, 87 | "data": { 88 | Type: schema.TypeString, 89 | Description: "Valid JSON object that this provider will manage with the API server.", 90 | Required: true, 91 | Sensitive: isDataSensitive, 92 | ValidateFunc: func(val interface{}, key string) (warns []string, errs []error) { 93 | v := val.(string) 94 | if v != "" { 95 | data := make(map[string]interface{}) 96 | err := json.Unmarshal([]byte(v), &data) 97 | if err != nil { 98 | errs = append(errs, fmt.Errorf("data attribute is invalid JSON: %v", err)) 99 | } 100 | } 101 | return warns, errs 102 | }, 103 | }, 104 | "debug": { 105 | Type: schema.TypeBool, 106 | Description: "Whether to emit verbose debug output while working with the API object on the server.", 107 | Optional: true, 108 | }, 109 | "read_search": { 110 | Type: schema.TypeMap, 111 | Description: "Custom search for `read_path`. This map will take `search_data`, `search_key`, `search_value`, `results_key` and `query_string` (see datasource config documentation)", 112 | Optional: true, 113 | }, 114 | "query_string": { 115 | Type: schema.TypeString, 116 | Description: "Query string to be included in the path", 117 | Optional: true, 118 | }, 119 | "api_data": { 120 | Type: schema.TypeMap, 121 | Elem: &schema.Schema{ 122 | Type: schema.TypeString, 123 | }, 124 | Description: "After data from the API server is read, this map will include k/v pairs usable in other terraform resources as readable objects. Currently the value is the golang fmt package's representation of the value (simple primitives are set as expected, but complex types like arrays and maps contain golang formatting).", 125 | Computed: true, 126 | Sensitive: isDataSensitive, 127 | }, 128 | "api_response": { 129 | Type: schema.TypeString, 130 | Description: "The raw body of the HTTP response from the last read of the object.", 131 | Computed: true, 132 | Sensitive: isDataSensitive, 133 | }, 134 | "create_response": { 135 | Type: schema.TypeString, 136 | Description: "The raw body of the HTTP response returned when creating the object.", 137 | Computed: true, 138 | Sensitive: isDataSensitive, 139 | }, 140 | "force_new": { 141 | Type: schema.TypeList, 142 | Elem: &schema.Schema{Type: schema.TypeString}, 143 | Optional: true, 144 | ForceNew: true, 145 | Description: "Any changes to these values will result in recreating the resource instead of updating.", 146 | }, 147 | "read_data": { 148 | Type: schema.TypeString, 149 | Optional: true, 150 | Description: "Valid JSON object to pass during read requests.", 151 | Sensitive: isDataSensitive, 152 | ValidateFunc: func(val interface{}, key string) (warns []string, errs []error) { 153 | v := val.(string) 154 | if v != "" { 155 | data := make(map[string]interface{}) 156 | err := json.Unmarshal([]byte(v), &data) 157 | if err != nil { 158 | errs = append(errs, fmt.Errorf("read_data attribute is invalid JSON: %v", err)) 159 | } 160 | } 161 | return warns, errs 162 | }, 163 | }, 164 | "update_data": { 165 | Type: schema.TypeString, 166 | Optional: true, 167 | Description: "Valid JSON object to pass during to update requests.", 168 | Sensitive: isDataSensitive, 169 | ValidateFunc: func(val interface{}, key string) (warns []string, errs []error) { 170 | v := val.(string) 171 | if v != "" { 172 | data := make(map[string]interface{}) 173 | err := json.Unmarshal([]byte(v), &data) 174 | if err != nil { 175 | errs = append(errs, fmt.Errorf("update_data attribute is invalid JSON: %v", err)) 176 | } 177 | } 178 | return warns, errs 179 | }, 180 | }, 181 | "destroy_data": { 182 | Type: schema.TypeString, 183 | Optional: true, 184 | Description: "Valid JSON object to pass during to destroy requests.", 185 | Sensitive: isDataSensitive, 186 | ValidateFunc: func(val interface{}, key string) (warns []string, errs []error) { 187 | v := val.(string) 188 | if v != "" { 189 | data := make(map[string]interface{}) 190 | err := json.Unmarshal([]byte(v), &data) 191 | if err != nil { 192 | errs = append(errs, fmt.Errorf("destroy_data attribute is invalid JSON: %v", err)) 193 | } 194 | } 195 | return warns, errs 196 | }, 197 | }, 198 | "ignore_changes_to": { 199 | Type: schema.TypeList, 200 | Elem: &schema.Schema{Type: schema.TypeString}, 201 | Optional: true, 202 | Description: "A list of fields to which remote changes will be ignored. For example, an API might add or remove metadata, such as a 'last_modified' field, which Terraform should not attempt to correct. To ignore changes to nested fields, use the dot syntax: 'metadata.timestamp'", 203 | Sensitive: isDataSensitive, 204 | // TODO ValidateFunc not supported for lists, but should probably validate that the ignore paths are valid 205 | }, 206 | "ignore_all_server_changes": { 207 | Type: schema.TypeBool, 208 | Description: "By default Terraform will attempt to revert changes to remote resources. Set this to 'true' to ignore any remote changes. Default: false", 209 | Optional: true, 210 | Default: false, 211 | }, 212 | }, /* End schema */ 213 | 214 | } 215 | } 216 | 217 | /* 218 | Since there is nothing in the ResourceData structure other 219 | 220 | than the "id" passed on the command line, we have to use an opinionated 221 | view of the API paths to figure out how to read that object 222 | from the API 223 | */ 224 | func resourceRestAPIImport(d *schema.ResourceData, meta interface{}) (imported []*schema.ResourceData, err error) { 225 | input := d.Id() 226 | 227 | hasTrailingSlash := strings.HasSuffix(input, "/") 228 | var n int 229 | if hasTrailingSlash { 230 | n = strings.LastIndex(input[0:len(input)-1], "/") 231 | } else { 232 | n = strings.LastIndex(input, "/") 233 | } 234 | 235 | if n == -1 { 236 | return imported, fmt.Errorf("invalid path to import api_object '%s' - must be //", input) 237 | } 238 | 239 | path := input[0:n] 240 | d.Set("path", path) 241 | 242 | var id string 243 | if hasTrailingSlash { 244 | id = input[n+1 : len(input)-1] 245 | } else { 246 | id = input[n+1:] 247 | } 248 | 249 | d.Set("data", fmt.Sprintf(`{ "id": "%s" }`, id)) 250 | d.SetId(id) 251 | 252 | /* Troubleshooting is hard enough. Emit log messages so TF_LOG 253 | has useful information in case an import isn't working */ 254 | d.Set("debug", true) 255 | 256 | obj, err := makeAPIObject(d, meta) 257 | if err != nil { 258 | return imported, err 259 | } 260 | if obj.debug { 261 | log.Printf("resource_api_object.go: Import routine called. Object built:\n%s\n", obj.toString()) 262 | } 263 | 264 | err = obj.readObject() 265 | if err == nil { 266 | setResourceState(obj, d) 267 | /* Data that we set in the state above must be passed along 268 | as an item in the stack of imported data */ 269 | imported = append(imported, d) 270 | } 271 | 272 | return imported, err 273 | } 274 | 275 | func resourceRestAPICreate(d *schema.ResourceData, meta interface{}) error { 276 | obj, err := makeAPIObject(d, meta) 277 | if err != nil { 278 | return err 279 | } 280 | if obj.debug { 281 | log.Printf("resource_api_object.go: Create routine called. Object built:\n%s\n", obj.toString()) 282 | } 283 | 284 | err = obj.createObject() 285 | if err == nil { 286 | /* Setting terraform ID tells terraform the object was created or it exists */ 287 | d.SetId(obj.id) 288 | setResourceState(obj, d) 289 | /* Only set during create for APIs that don't return sensitive data on subsequent retrieval */ 290 | d.Set("create_response", obj.apiResponse) 291 | } 292 | return err 293 | } 294 | 295 | func resourceRestAPIRead(d *schema.ResourceData, meta interface{}) error { 296 | obj, err := makeAPIObject(d, meta) 297 | if err != nil { 298 | if strings.Contains(err.Error(), "error parsing data provided") { 299 | log.Printf("resource_api_object.go: WARNING! The data passed from Terraform's state is invalid! %v", err) 300 | log.Printf("resource_api_object.go: Continuing with partially constructed object...") 301 | } else { 302 | return err 303 | } 304 | } 305 | 306 | if obj.debug { 307 | log.Printf("resource_api_object.go: Read routine called. Object built:\n%s\n", obj.toString()) 308 | } 309 | 310 | err = obj.readObject() 311 | if err == nil { 312 | /* Setting terraform ID tells terraform the object was created or it exists */ 313 | log.Printf("resource_api_object.go: Read resource. Returned id is '%s'\n", obj.id) 314 | d.SetId(obj.id) 315 | 316 | setResourceState(obj, d) 317 | 318 | // Check whether the remote resource has changed. 319 | if !(d.Get("ignore_all_server_changes")).(bool) { 320 | ignoreList := []string{} 321 | v, ok := d.GetOk("ignore_changes_to") 322 | if ok { 323 | for _, s := range v.([]interface{}) { 324 | ignoreList = append(ignoreList, s.(string)) 325 | } 326 | } 327 | 328 | // This checks if there were any changes to the remote resource that will need to be corrected 329 | // by comparing the current state with the response returned by the api. 330 | modifiedResource, hasDifferences := getDelta(obj.data, obj.apiData, ignoreList) 331 | 332 | if hasDifferences { 333 | log.Printf("resource_api_object.go: Found differences in remote resource\n") 334 | encoded, err := json.Marshal(modifiedResource) 335 | if err != nil { 336 | return err 337 | } 338 | jsonString := string(encoded) 339 | d.Set("data", jsonString) 340 | } 341 | } 342 | 343 | } 344 | return err 345 | } 346 | 347 | func resourceRestAPIUpdate(d *schema.ResourceData, meta interface{}) error { 348 | obj, err := makeAPIObject(d, meta) 349 | if err != nil { 350 | d.Partial(true) 351 | return err 352 | } 353 | 354 | /* If copy_keys is not empty, we have to grab the latest 355 | data so we can copy anything needed before the update */ 356 | client := meta.(*APIClient) 357 | if len(client.copyKeys) > 0 { 358 | err = obj.readObject() 359 | if err != nil { 360 | return err 361 | } 362 | } 363 | 364 | if obj.debug { 365 | log.Printf("resource_api_object.go: Update routine called. Object built:\n%s\n", obj.toString()) 366 | } 367 | 368 | err = obj.updateObject() 369 | if err == nil { 370 | setResourceState(obj, d) 371 | } else { 372 | d.Partial(true) 373 | } 374 | return err 375 | } 376 | 377 | func resourceRestAPIDelete(d *schema.ResourceData, meta interface{}) error { 378 | obj, err := makeAPIObject(d, meta) 379 | if err != nil { 380 | return err 381 | } 382 | if obj.debug { 383 | log.Printf("resource_api_object.go: Delete routine called. Object built:\n%s\n", obj.toString()) 384 | } 385 | 386 | err = obj.deleteObject() 387 | if err != nil { 388 | if strings.Contains(err.Error(), "404") { 389 | /* 404 means it doesn't exist. Call that good enough */ 390 | err = nil 391 | } 392 | } 393 | return err 394 | } 395 | 396 | func resourceRestAPIExists(d *schema.ResourceData, meta interface{}) (exists bool, err error) { 397 | obj, err := makeAPIObject(d, meta) 398 | if err != nil { 399 | if strings.Contains(err.Error(), "error parsing data provided") { 400 | log.Printf("resource_api_object.go: WARNING! The data passed from Terraform's state is invalid! %v", err) 401 | log.Printf("resource_api_object.go: Continuing with partially constructed object...") 402 | } else { 403 | return exists, err 404 | } 405 | } 406 | 407 | if obj.debug { 408 | log.Printf("resource_api_object.go: Exists routine called. Object built: %s\n", obj.toString()) 409 | } 410 | 411 | /* Assume all errors indicate the object just doesn't exist. 412 | This may not be a good assumption... */ 413 | err = obj.readObject() 414 | if err == nil { 415 | exists = true 416 | } 417 | return exists, err 418 | } 419 | 420 | /* 421 | Simple helper routine to build an api_object struct 422 | 423 | for the various calls terraform will use. Unfortunately, 424 | terraform cannot just reuse objects, so each CRUD operation 425 | results in a new object created 426 | */ 427 | func makeAPIObject(d *schema.ResourceData, meta interface{}) (*APIObject, error) { 428 | opts, err := buildAPIObjectOpts(d) 429 | if err != nil { 430 | return nil, err 431 | } 432 | 433 | caller := "unknown" 434 | pc, _, _, ok := runtime.Caller(1) 435 | details := runtime.FuncForPC(pc) 436 | if ok && details != nil { 437 | parts := strings.Split(details.Name(), ".") 438 | caller = parts[len(parts)-1] 439 | } 440 | log.Printf("resource_rest_api.go: Constructing new APIObject in makeAPIObject (called by %s)", caller) 441 | 442 | obj, err := NewAPIObject(meta.(*APIClient), opts) 443 | 444 | return obj, err 445 | } 446 | 447 | func buildAPIObjectOpts(d *schema.ResourceData) (*apiObjectOpts, error) { 448 | opts := &apiObjectOpts{ 449 | path: d.Get("path").(string), 450 | } 451 | 452 | /* Allow user to override provider-level id_attribute */ 453 | if v, ok := d.GetOk("id_attribute"); ok { 454 | opts.idAttribute = v.(string) 455 | } 456 | 457 | /* Allow user to specify the ID manually */ 458 | if v, ok := d.GetOk("object_id"); ok { 459 | opts.id = v.(string) 460 | } else { 461 | /* If not specified, see if terraform has an ID */ 462 | opts.id = d.Id() 463 | } 464 | 465 | log.Printf("resource_rest_api.go: buildAPIObjectOpts routine called for id '%s'\n", opts.id) 466 | 467 | if v, ok := d.GetOk("create_path"); ok { 468 | opts.postPath = v.(string) 469 | } 470 | if v, ok := d.GetOk("read_path"); ok { 471 | opts.getPath = v.(string) 472 | } 473 | if v, ok := d.GetOk("update_path"); ok { 474 | opts.putPath = v.(string) 475 | } 476 | if v, ok := d.GetOk("create_method"); ok { 477 | opts.createMethod = v.(string) 478 | } 479 | if v, ok := d.GetOk("read_method"); ok { 480 | opts.readMethod = v.(string) 481 | } 482 | if v, ok := d.GetOk("read_data"); ok { 483 | opts.readData = v.(string) 484 | } 485 | if v, ok := d.GetOk("update_method"); ok { 486 | opts.updateMethod = v.(string) 487 | } 488 | if v, ok := d.GetOk("update_data"); ok { 489 | opts.updateData = v.(string) 490 | } 491 | if v, ok := d.GetOk("destroy_method"); ok { 492 | opts.destroyMethod = v.(string) 493 | } 494 | if v, ok := d.GetOk("destroy_data"); ok { 495 | opts.destroyData = v.(string) 496 | } 497 | if v, ok := d.GetOk("destroy_path"); ok { 498 | opts.deletePath = v.(string) 499 | } 500 | if v, ok := d.GetOk("query_string"); ok { 501 | opts.queryString = v.(string) 502 | } 503 | 504 | readSearch := expandReadSearch(d.Get("read_search").(map[string]interface{})) 505 | opts.readSearch = readSearch 506 | 507 | opts.data = d.Get("data").(string) 508 | opts.debug = d.Get("debug").(bool) 509 | 510 | return opts, nil 511 | } 512 | 513 | func expandReadSearch(v map[string]interface{}) (readSearch map[string]string) { 514 | readSearch = make(map[string]string) 515 | for key, val := range v { 516 | readSearch[key] = val.(string) 517 | } 518 | 519 | return 520 | } 521 | -------------------------------------------------------------------------------- /restapi/api_object.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "reflect" 9 | "strings" 10 | 11 | "github.com/davecgh/go-spew/spew" 12 | ) 13 | 14 | type apiObjectOpts struct { 15 | path string 16 | getPath string 17 | postPath string 18 | putPath string 19 | createMethod string 20 | readMethod string 21 | readData string 22 | updateMethod string 23 | updateData string 24 | destroyMethod string 25 | destroyData string 26 | deletePath string 27 | searchPath string 28 | queryString string 29 | debug bool 30 | readSearch map[string]string 31 | id string 32 | idAttribute string 33 | data string 34 | } 35 | 36 | /*APIObject is the state holding struct for a restapi_object resource*/ 37 | type APIObject struct { 38 | apiClient *APIClient 39 | getPath string 40 | postPath string 41 | putPath string 42 | createMethod string 43 | readMethod string 44 | updateMethod string 45 | destroyMethod string 46 | deletePath string 47 | searchPath string 48 | queryString string 49 | debug bool 50 | readSearch map[string]string 51 | id string 52 | idAttribute string 53 | 54 | /* Set internally */ 55 | data map[string]interface{} /* Data as managed by the user */ 56 | readData map[string]interface{} /* Read data as managed by the user */ 57 | updateData map[string]interface{} /* Update data as managed by the user */ 58 | destroyData map[string]interface{} /* Destroy data as managed by the user */ 59 | apiData map[string]interface{} /* Data as available from the API */ 60 | apiResponse string 61 | } 62 | 63 | // NewAPIObject makes an APIobject to manage a RESTful object in an API 64 | func NewAPIObject(iClient *APIClient, opts *apiObjectOpts) (*APIObject, error) { 65 | if opts.debug { 66 | log.Printf("api_object.go: Constructing debug api_object\n") 67 | log.Printf(" id: %s\n", opts.id) 68 | } 69 | 70 | /* id_attribute can be set either on the client (to apply for all calls with the server) 71 | or on a per object basis (for only calls to this kind of object). 72 | Permit overridding from the API client here by using the client-wide value only 73 | if a per-object value is not set */ 74 | if opts.idAttribute == "" { 75 | opts.idAttribute = iClient.idAttribute 76 | } 77 | 78 | if opts.createMethod == "" { 79 | opts.createMethod = iClient.createMethod 80 | } 81 | if opts.readMethod == "" { 82 | opts.readMethod = iClient.readMethod 83 | } 84 | if opts.readData == "" { 85 | opts.readData = iClient.readData 86 | } 87 | if opts.updateMethod == "" { 88 | opts.updateMethod = iClient.updateMethod 89 | } 90 | if opts.updateData == "" { 91 | opts.updateData = iClient.updateData 92 | } 93 | if opts.destroyMethod == "" { 94 | opts.destroyMethod = iClient.destroyMethod 95 | } 96 | if opts.destroyData == "" { 97 | opts.destroyData = iClient.destroyData 98 | } 99 | if opts.postPath == "" { 100 | opts.postPath = opts.path 101 | } 102 | if opts.getPath == "" { 103 | opts.getPath = opts.path + "/{id}" 104 | } 105 | if opts.putPath == "" { 106 | opts.putPath = opts.path + "/{id}" 107 | } 108 | if opts.deletePath == "" { 109 | opts.deletePath = opts.path + "/{id}" 110 | } 111 | if opts.searchPath == "" { 112 | opts.searchPath = opts.path 113 | } 114 | 115 | obj := APIObject{ 116 | apiClient: iClient, 117 | getPath: opts.getPath, 118 | postPath: opts.postPath, 119 | putPath: opts.putPath, 120 | createMethod: opts.createMethod, 121 | readMethod: opts.readMethod, 122 | updateMethod: opts.updateMethod, 123 | destroyMethod: opts.destroyMethod, 124 | deletePath: opts.deletePath, 125 | searchPath: opts.searchPath, 126 | queryString: opts.queryString, 127 | debug: opts.debug, 128 | readSearch: opts.readSearch, 129 | id: opts.id, 130 | idAttribute: opts.idAttribute, 131 | data: make(map[string]interface{}), 132 | readData: make(map[string]interface{}), 133 | updateData: make(map[string]interface{}), 134 | destroyData: make(map[string]interface{}), 135 | apiData: make(map[string]interface{}), 136 | } 137 | 138 | if opts.data != "" { 139 | if opts.debug { 140 | log.Printf("api_object.go: Parsing data: '%s'", opts.data) 141 | } 142 | 143 | err := json.Unmarshal([]byte(opts.data), &obj.data) 144 | if err != nil { 145 | return &obj, fmt.Errorf("api_object.go: error parsing data provided: %v", err.Error()) 146 | } 147 | 148 | /* Opportunistically set the object's ID if it is provided in the data. 149 | If it is not set, we will get it later in synchronize_state */ 150 | if obj.id == "" { 151 | var tmp string 152 | tmp, err := GetStringAtKey(obj.data, obj.idAttribute, obj.debug) 153 | if err == nil { 154 | if opts.debug { 155 | log.Printf("api_object.go: opportunisticly set id from data provided.") 156 | } 157 | obj.id = tmp 158 | } else if !obj.apiClient.writeReturnsObject && !obj.apiClient.createReturnsObject && obj.searchPath == "" { 159 | /* If the id is not set and we cannot obtain it 160 | later, error out to be safe */ 161 | return &obj, fmt.Errorf("provided data does not have %s attribute for the object's id and the client is not configured to read the object from a POST response; without an id, the object cannot be managed", obj.idAttribute) 162 | } 163 | } 164 | } 165 | 166 | if opts.readData != "" { 167 | if opts.debug { 168 | log.Printf("api_object.go: Parsing read data: '%s'", opts.readData) 169 | } 170 | 171 | err := json.Unmarshal([]byte(opts.readData), &obj.readData) 172 | if err != nil { 173 | return &obj, fmt.Errorf("api_object.go: error parsing read data provided: %v", err.Error()) 174 | } 175 | } 176 | 177 | if opts.updateData != "" { 178 | if opts.debug { 179 | log.Printf("api_object.go: Parsing update data: '%s'", opts.updateData) 180 | } 181 | 182 | err := json.Unmarshal([]byte(opts.updateData), &obj.updateData) 183 | if err != nil { 184 | return &obj, fmt.Errorf("api_object.go: error parsing update data provided: %v", err.Error()) 185 | } 186 | } 187 | 188 | if opts.destroyData != "" { 189 | if opts.debug { 190 | log.Printf("api_object.go: Parsing destroy data: '%s'", opts.destroyData) 191 | } 192 | 193 | err := json.Unmarshal([]byte(opts.destroyData), &obj.destroyData) 194 | if err != nil { 195 | return &obj, fmt.Errorf("api_object.go: error parsing destroy data provided: %v", err.Error()) 196 | } 197 | } 198 | 199 | if opts.debug { 200 | log.Printf("api_object.go: Constructed object: %s", obj.toString()) 201 | } 202 | return &obj, nil 203 | } 204 | 205 | // Convert the important bits about this object to string representation 206 | // This is useful for debugging. 207 | func (obj *APIObject) toString() string { 208 | var buffer bytes.Buffer 209 | buffer.WriteString(fmt.Sprintf("id: %s\n", obj.id)) 210 | buffer.WriteString(fmt.Sprintf("get_path: %s\n", obj.getPath)) 211 | buffer.WriteString(fmt.Sprintf("post_path: %s\n", obj.postPath)) 212 | buffer.WriteString(fmt.Sprintf("put_path: %s\n", obj.putPath)) 213 | buffer.WriteString(fmt.Sprintf("delete_path: %s\n", obj.deletePath)) 214 | buffer.WriteString(fmt.Sprintf("query_string: %s\n", obj.queryString)) 215 | buffer.WriteString(fmt.Sprintf("create_method: %s\n", obj.createMethod)) 216 | buffer.WriteString(fmt.Sprintf("read_method: %s\n", obj.readMethod)) 217 | buffer.WriteString(fmt.Sprintf("update_method: %s\n", obj.updateMethod)) 218 | buffer.WriteString(fmt.Sprintf("destroy_method: %s\n", obj.destroyMethod)) 219 | buffer.WriteString(fmt.Sprintf("debug: %t\n", obj.debug)) 220 | buffer.WriteString(fmt.Sprintf("read_search: %s\n", spew.Sdump(obj.readSearch))) 221 | buffer.WriteString(fmt.Sprintf("data: %s\n", spew.Sdump(obj.data))) 222 | buffer.WriteString(fmt.Sprintf("read_data: %s\n", spew.Sdump(obj.readData))) 223 | buffer.WriteString(fmt.Sprintf("update_data: %s\n", spew.Sdump(obj.updateData))) 224 | buffer.WriteString(fmt.Sprintf("destroy_data: %s\n", spew.Sdump(obj.destroyData))) 225 | buffer.WriteString(fmt.Sprintf("api_data: %s\n", spew.Sdump(obj.apiData))) 226 | return buffer.String() 227 | } 228 | 229 | /* 230 | Centralized function to ensure that our data as managed by 231 | 232 | the api_object is updated with data that has come back from 233 | the API 234 | */ 235 | func (obj *APIObject) updateState(state string) error { 236 | if obj.debug { 237 | log.Printf("api_object.go: Updating API object state to '%s'\n", state) 238 | } 239 | 240 | /* Other option - Decode as JSON Numbers instead of golang datatypes 241 | d := json.NewDecoder(strings.NewReader(res_str)) 242 | d.UseNumber() 243 | err = d.Decode(&obj.api_data) 244 | */ 245 | err := json.Unmarshal([]byte(state), &obj.apiData) 246 | if err != nil { 247 | return err 248 | } 249 | 250 | /* Store response body for parsing via jsondecode() */ 251 | obj.apiResponse = state 252 | 253 | /* A usable ID was not passed (in constructor or here), 254 | so we have to guess what it is from the data structure */ 255 | if obj.id == "" { 256 | val, err := GetStringAtKey(obj.apiData, obj.idAttribute, obj.debug) 257 | if err != nil { 258 | return fmt.Errorf("api_object.go: Error extracting ID from data element: %s", err) 259 | } 260 | obj.id = val 261 | } else if obj.debug { 262 | log.Printf("api_object.go: Not updating id. It is already set to '%s'\n", obj.id) 263 | } 264 | 265 | /* Any keys that come from the data we want to copy are done here */ 266 | if len(obj.apiClient.copyKeys) > 0 { 267 | for _, key := range obj.apiClient.copyKeys { 268 | if obj.debug { 269 | log.Printf("api_object.go: Copying key '%s' from api_data (%v) to data (%v)\n", key, obj.apiData[key], obj.data[key]) 270 | } 271 | obj.data[key] = obj.apiData[key] 272 | } 273 | } else if obj.debug { 274 | log.Printf("api_object.go: copy_keys is empty - not attempting to copy data") 275 | } 276 | 277 | if obj.debug { 278 | log.Printf("api_object.go: final object after synchronization of state:\n%+v\n", obj.toString()) 279 | } 280 | return err 281 | } 282 | 283 | func (obj *APIObject) createObject() error { 284 | /* Failsafe: The constructor should prevent this situation, but 285 | protect here also. If no id is set, and the API does not respond 286 | with the id of whatever gets created, we have no way to know what 287 | the object's id will be. Abandon this attempt */ 288 | if obj.id == "" && !obj.apiClient.writeReturnsObject && !obj.apiClient.createReturnsObject { 289 | 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") 290 | } 291 | 292 | b, _ := json.Marshal(obj.data) 293 | 294 | postPath := obj.postPath 295 | if obj.queryString != "" { 296 | if obj.debug { 297 | log.Printf("api_object.go: Adding query string '%s'", obj.queryString) 298 | } 299 | postPath = fmt.Sprintf("%s?%s", obj.postPath, obj.queryString) 300 | } 301 | 302 | resultString, err := obj.apiClient.sendRequest(obj.createMethod, strings.Replace(postPath, "{id}", obj.id, -1), string(b)) 303 | if err != nil { 304 | return err 305 | } 306 | 307 | /* We will need to sync state as well as get the object's ID */ 308 | if obj.apiClient.writeReturnsObject || obj.apiClient.createReturnsObject { 309 | if obj.debug { 310 | log.Printf("api_object.go: Parsing response from POST to update internal structures (write_returns_object=%t, create_returns_object=%t)...\n", 311 | obj.apiClient.writeReturnsObject, obj.apiClient.createReturnsObject) 312 | } 313 | err = obj.updateState(resultString) 314 | /* Yet another failsafe. In case something terrible went wrong internally, 315 | bail out so the user at least knows that the ID did not get set. */ 316 | if obj.id == "" { 317 | return fmt.Errorf("internal validation failed; object ID is not set, but *may* have been created; this should never happen") 318 | } 319 | } else { 320 | if obj.debug { 321 | log.Printf("api_object.go: Requesting created object from API (write_returns_object=%t, create_returns_object=%t)...\n", 322 | obj.apiClient.writeReturnsObject, obj.apiClient.createReturnsObject) 323 | } 324 | err = obj.readObject() 325 | } 326 | return err 327 | } 328 | 329 | func (obj *APIObject) readObject() error { 330 | if obj.id == "" { 331 | return fmt.Errorf("cannot read an object unless the ID has been set") 332 | } 333 | 334 | getPath := obj.getPath 335 | if obj.queryString != "" { 336 | if obj.debug { 337 | log.Printf("api_object.go: Adding query string '%s'", obj.queryString) 338 | } 339 | getPath = fmt.Sprintf("%s?%s", obj.getPath, obj.queryString) 340 | } 341 | 342 | send := "" 343 | if len(obj.readData) > 0 { 344 | readData, _ := json.Marshal(obj.readData) 345 | send = string(readData) 346 | if obj.debug { 347 | log.Printf("api_object.go: Using read data '%s'", send) 348 | } 349 | } 350 | 351 | resultString, err := obj.apiClient.sendRequest(obj.readMethod, strings.Replace(getPath, "{id}", obj.id, -1), send) 352 | if err != nil { 353 | if strings.Contains(err.Error(), "unexpected response code '404'") { 354 | log.Printf("api_object.go: 404 error while refreshing state for '%s' at path '%s'. Removing from state.", obj.id, obj.getPath) 355 | obj.id = "" 356 | return nil 357 | } 358 | return err 359 | } 360 | 361 | searchKey := obj.readSearch["search_key"] 362 | searchValue := obj.readSearch["search_value"] 363 | 364 | if searchKey != "" && searchValue != "" { 365 | 366 | obj.searchPath = strings.Replace(obj.getPath, "{id}", obj.id, -1) 367 | 368 | queryString := obj.readSearch["query_string"] 369 | if obj.queryString != "" { 370 | if obj.debug { 371 | log.Printf("api_object.go: Adding query string '%s'", obj.queryString) 372 | } 373 | queryString = fmt.Sprintf("%s&%s", obj.readSearch["query_string"], obj.queryString) 374 | } 375 | searchData := "" 376 | if len(obj.readSearch["search_data"]) > 0 { 377 | tmpData, _ := json.Marshal(obj.readSearch["search_data"]) 378 | searchData = string(tmpData) 379 | if obj.debug { 380 | log.Printf("api_object.go: Using search data '%s'", searchData) 381 | } 382 | } 383 | 384 | resultsKey := obj.readSearch["results_key"] 385 | objFound, err := obj.findObject(queryString, searchKey, searchValue, resultsKey, searchData) 386 | if err != nil || objFound == nil { 387 | log.Printf("api_object.go: Search did not find object with the '%s' key = '%s'", searchKey, searchValue) 388 | obj.id = "" 389 | return nil 390 | } 391 | objFoundString, _ := json.Marshal(objFound) 392 | return obj.updateState(string(objFoundString)) 393 | } 394 | 395 | return obj.updateState(resultString) 396 | } 397 | 398 | func (obj *APIObject) updateObject() error { 399 | if obj.id == "" { 400 | return fmt.Errorf("cannot update an object unless the ID has been set") 401 | } 402 | 403 | send := "" 404 | if len(obj.updateData) > 0 { 405 | updateData, _ := json.Marshal(obj.updateData) 406 | send = string(updateData) 407 | if obj.debug { 408 | log.Printf("api_object.go: Using update data '%s'", send) 409 | } 410 | } else { 411 | b, _ := json.Marshal(obj.data) 412 | send = string(b) 413 | } 414 | 415 | putPath := obj.putPath 416 | if obj.queryString != "" { 417 | if obj.debug { 418 | log.Printf("api_object.go: Adding query string '%s'", obj.queryString) 419 | } 420 | putPath = fmt.Sprintf("%s?%s", obj.putPath, obj.queryString) 421 | } 422 | 423 | resultString, err := obj.apiClient.sendRequest(obj.updateMethod, strings.Replace(putPath, "{id}", obj.id, -1), send) 424 | if err != nil { 425 | return err 426 | } 427 | 428 | if obj.apiClient.writeReturnsObject { 429 | if obj.debug { 430 | log.Printf("api_object.go: Parsing response from PUT to update internal structures (write_returns_object=true)...\n") 431 | } 432 | err = obj.updateState(resultString) 433 | } else { 434 | if obj.debug { 435 | log.Printf("api_object.go: Requesting updated object from API (write_returns_object=false)...\n") 436 | } 437 | err = obj.readObject() 438 | } 439 | return err 440 | } 441 | 442 | func (obj *APIObject) deleteObject() error { 443 | if obj.id == "" { 444 | log.Printf("WARNING: Attempting to delete an object that has no id set. Assuming this is OK.\n") 445 | return nil 446 | } 447 | 448 | deletePath := obj.deletePath 449 | if obj.queryString != "" { 450 | if obj.debug { 451 | log.Printf("api_object.go: Adding query string '%s'", obj.queryString) 452 | } 453 | deletePath = fmt.Sprintf("%s?%s", obj.deletePath, obj.queryString) 454 | } 455 | 456 | send := "" 457 | if len(obj.destroyData) > 0 { 458 | destroyData, _ := json.Marshal(obj.destroyData) 459 | send = string(destroyData) 460 | if obj.debug { 461 | log.Printf("api_object.go: Using destroy data '%s'", string(destroyData)) 462 | } 463 | } 464 | 465 | _, err := obj.apiClient.sendRequest(obj.destroyMethod, strings.Replace(deletePath, "{id}", obj.id, -1), send) 466 | if err != nil { 467 | return err 468 | } 469 | 470 | return nil 471 | } 472 | 473 | func (obj *APIObject) findObject(queryString string, searchKey string, searchValue string, resultsKey string, searchData string) (map[string]interface{}, error) { 474 | var objFound map[string]interface{} 475 | var dataArray []interface{} 476 | var ok bool 477 | 478 | /* 479 | Issue a GET to the base path and expect results to come back 480 | */ 481 | searchPath := obj.searchPath 482 | if queryString != "" { 483 | if obj.debug { 484 | log.Printf("api_object.go: Adding query string '%s'", queryString) 485 | } 486 | searchPath = fmt.Sprintf("%s?%s", obj.searchPath, queryString) 487 | } 488 | 489 | if obj.debug { 490 | log.Printf("api_object.go: Calling API on path '%s'", searchPath) 491 | } 492 | resultString, err := obj.apiClient.sendRequest(obj.apiClient.readMethod, searchPath, searchData) 493 | if err != nil { 494 | return objFound, err 495 | } 496 | 497 | /* 498 | Parse it seeking JSON data 499 | */ 500 | if obj.debug { 501 | log.Printf("api_object.go: Response received... parsing") 502 | } 503 | var result interface{} 504 | err = json.Unmarshal([]byte(resultString), &result) 505 | if err != nil { 506 | return objFound, err 507 | } 508 | 509 | if resultsKey != "" { 510 | var tmp interface{} 511 | 512 | if obj.debug { 513 | log.Printf("api_object.go: Locating '%s' in the results", resultsKey) 514 | } 515 | 516 | /* First verify the data we got back is a hash */ 517 | if _, ok = result.(map[string]interface{}); !ok { 518 | return objFound, fmt.Errorf("api_object.go: The results of a GET to '%s' did not return a hash. Cannot search within for results_key '%s'", searchPath, resultsKey) 519 | } 520 | 521 | tmp, err = GetObjectAtKey(result.(map[string]interface{}), resultsKey, obj.debug) 522 | if err != nil { 523 | return objFound, fmt.Errorf("api_object.go: Error finding results_key: %s", err) 524 | } 525 | if dataArray, ok = tmp.([]interface{}); !ok { 526 | return objFound, fmt.Errorf("api_object.go: The data at results_key location '%s' is not an array. It is a '%s'", resultsKey, reflect.TypeOf(tmp)) 527 | } 528 | } else { 529 | if obj.debug { 530 | log.Printf("api_object.go: results_key is not set - coaxing data to array of interfaces") 531 | } 532 | if dataArray, ok = result.([]interface{}); !ok { 533 | return objFound, fmt.Errorf("api_object.go: The results of a GET to '%s' did not return an array. It is a '%s'. Perhaps you meant to add a results_key?", searchPath, reflect.TypeOf(result)) 534 | } 535 | } 536 | 537 | /* Loop through all of the results seeking the specific record */ 538 | for _, item := range dataArray { 539 | var hash map[string]interface{} 540 | 541 | if hash, ok = item.(map[string]interface{}); !ok { 542 | return objFound, fmt.Errorf("api_object.go: The elements being searched for data are not a map of key value pairs") 543 | } 544 | 545 | if obj.debug { 546 | log.Printf("api_object.go: Examining %v", hash) 547 | log.Printf("api_object.go: Comparing '%s' to the value in '%s'", searchValue, searchKey) 548 | } 549 | 550 | tmp, err := GetStringAtKey(hash, searchKey, obj.debug) 551 | if err != nil { 552 | return objFound, fmt.Errorf("failed to get the value of '%s' in the results array at '%s': %s", searchKey, resultsKey, err) 553 | } 554 | 555 | /* We found our record */ 556 | if tmp == searchValue { 557 | objFound = hash 558 | obj.id, err = GetStringAtKey(hash, obj.idAttribute, obj.debug) 559 | if err != nil { 560 | return objFound, fmt.Errorf("failed to find id_attribute '%s' in the record: %s", obj.idAttribute, err) 561 | } 562 | 563 | if obj.debug { 564 | log.Printf("api_object.go: Found ID '%s'", obj.id) 565 | } 566 | 567 | /* But there is no id attribute??? */ 568 | if obj.id == "" { 569 | return objFound, fmt.Errorf("the object for '%s'='%s' did not have the id attribute '%s', or the value was empty", searchKey, searchValue, obj.idAttribute) 570 | } 571 | break 572 | } 573 | } 574 | 575 | if obj.id == "" { 576 | return objFound, fmt.Errorf("failed to find an object with the '%s' key = '%s' at %s", searchKey, searchValue, searchPath) 577 | } 578 | 579 | return objFound, nil 580 | } 581 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 2 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 3 | github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= 4 | github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 5 | github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= 6 | github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= 7 | github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= 8 | github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= 9 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 10 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= 11 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= 12 | github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= 13 | github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= 14 | github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= 15 | github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 16 | github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= 17 | github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= 18 | github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= 19 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 20 | github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= 21 | github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 22 | github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= 23 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 24 | github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 25 | github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= 26 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 27 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 28 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 30 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 32 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 33 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 34 | github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= 35 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 36 | github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= 37 | github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= 38 | github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= 39 | github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= 40 | github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= 41 | github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= 42 | github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk= 43 | github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8= 44 | github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= 45 | github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 46 | github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 47 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 48 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 49 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 50 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 51 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 52 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 53 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 54 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 55 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 56 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 57 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 58 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 59 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 60 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 61 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 62 | github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= 63 | github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= 64 | github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 65 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 66 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 67 | github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI= 68 | github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= 69 | github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= 70 | github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 71 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 72 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 73 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 74 | github.com/hashicorp/go-plugin v1.4.10 h1:xUbmA4jC6Dq163/fWcp8P3JuHilrHHMLNRxzGQJ9hNk= 75 | github.com/hashicorp/go-plugin v1.4.10/go.mod h1:6/1TEzT0eQznvI/gV2CM29DLSkAK/e58mUWKVsPaph0= 76 | github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 77 | github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= 78 | github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 79 | github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= 80 | github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 81 | github.com/hashicorp/hc-install v0.5.2 h1:SfwMFnEXVVirpwkDuSF5kymUOhrUxrTq3udEseZdOD0= 82 | github.com/hashicorp/hc-install v0.5.2/go.mod h1:9QISwe6newMWIfEiXpzuu1k9HAGtQYgnSH8H9T8wmoI= 83 | github.com/hashicorp/hcl/v2 v2.17.0 h1:z1XvSUyXd1HP10U4lrLg5e0JMVz6CPaJvAgxM0KNZVY= 84 | github.com/hashicorp/hcl/v2 v2.17.0/go.mod h1:gJyW2PTShkJqQBKpAmPO3yxMxIuoXkOF2TpqXzrQyx4= 85 | github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= 86 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 87 | github.com/hashicorp/terraform-exec v0.18.1 h1:LAbfDvNQU1l0NOQlTuudjczVhHj061fNX5H8XZxHlH4= 88 | github.com/hashicorp/terraform-exec v0.18.1/go.mod h1:58wg4IeuAJ6LVsLUeD2DWZZoc/bYi6dzhLHzxM41980= 89 | github.com/hashicorp/terraform-json v0.17.1 h1:eMfvh/uWggKmY7Pmb3T85u86E2EQg6EQHgyRwf3RkyA= 90 | github.com/hashicorp/terraform-json v0.17.1/go.mod h1:Huy6zt6euxaY9knPAFKjUITn8QxUFIe9VuSzb4zn/0o= 91 | github.com/hashicorp/terraform-plugin-docs v0.16.0 h1:UmxFr3AScl6Wged84jndJIfFccGyBZn52KtMNsS12dI= 92 | github.com/hashicorp/terraform-plugin-docs v0.16.0/go.mod h1:M3ZrlKBJAbPMtNOPwHicGi1c+hZUh7/g0ifT/z7TVfA= 93 | github.com/hashicorp/terraform-plugin-go v0.16.0 h1:DSOQ0rz5FUiVO4NUzMs8ln9gsPgHMTsfns7Nk+6gPuE= 94 | github.com/hashicorp/terraform-plugin-go v0.16.0/go.mod h1:4sn8bFuDbt+2+Yztt35IbOrvZc0zyEi87gJzsTgCES8= 95 | github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= 96 | github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= 97 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.27.0 h1:I8efBnjuDrgPjNF1MEypHy48VgcTIUY4X6rOFunrR3Y= 98 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.27.0/go.mod h1:cUEP4ly/nxlHy5HzD6YRrHydtlheGvGRJDhiWqqVik4= 99 | github.com/hashicorp/terraform-registry-address v0.2.1 h1:QuTf6oJ1+WSflJw6WYOHhLgwUiQ0FrROpHPYFtwTYWM= 100 | github.com/hashicorp/terraform-registry-address v0.2.1/go.mod h1:BSE9fIFzp0qWsJUUyGquo4ldV9k2n+psif6NYkBRS3Y= 101 | github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= 102 | github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= 103 | github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d h1:kJCB4vdITiW1eC1vq2e6IsrXKrZit1bv/TDYFGMp4BQ= 104 | github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= 105 | github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 106 | github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= 107 | github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 108 | github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 109 | github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= 110 | github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= 111 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 112 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 113 | github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE= 114 | github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= 115 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 116 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 117 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 118 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 119 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 120 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 121 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 122 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 123 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 124 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 125 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 126 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 127 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 128 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 129 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 130 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 131 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 132 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 133 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 134 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 135 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 136 | github.com/mitchellh/cli v1.1.5 h1:OxRIeJXpAMztws/XHlN2vu6imG5Dpq+j61AzAX5fLng= 137 | github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4= 138 | github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= 139 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 140 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 141 | github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= 142 | github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= 143 | github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= 144 | github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 145 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 146 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 147 | github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 148 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 149 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 150 | github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= 151 | github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= 152 | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= 153 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 154 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 155 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 156 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 157 | github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= 158 | github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= 159 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= 160 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 161 | github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= 162 | github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= 163 | github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= 164 | github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 165 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 166 | github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= 167 | github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 168 | github.com/skeema/knownhosts v1.1.0 h1:Wvr9V0MxhjRbl3f9nMnKnFfiWTJmtECJ9Njkea3ysW0= 169 | github.com/skeema/knownhosts v1.1.0/go.mod h1:sKFq3RD6/TKZkSWn8boUbDC7Qkgcv+8XXijpFO6roag= 170 | github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 171 | github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= 172 | github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= 173 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 174 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 175 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 176 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 177 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 178 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 179 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 180 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 181 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 182 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 183 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 184 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 185 | github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= 186 | github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= 187 | github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= 188 | github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= 189 | github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 190 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 191 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 192 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 193 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 194 | github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0= 195 | github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= 196 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 197 | golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 198 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 199 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 200 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 201 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 202 | golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= 203 | golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 204 | golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 205 | golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 206 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 207 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 208 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 209 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 210 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 211 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 212 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 213 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 214 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 215 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 216 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 217 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 218 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 219 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 220 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 221 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 222 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 223 | golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 224 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 225 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 226 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 227 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 228 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 229 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 230 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 231 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 232 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 233 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 234 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 235 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 236 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 237 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 238 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 239 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 240 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230706202418-f51705677e13 h1:6gyIbfp/fbCI0/DD/AuqEUorI2nXj6r3M2ItMl0Wj4Q= 241 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230706202418-f51705677e13/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= 242 | google.golang.org/grpc v1.56.3 h1:8I4C0Yq1EjstUzUJzpcRVbuYA2mODtEmpWiQoN/b2nc= 243 | google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= 244 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 245 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 246 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 247 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 248 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 249 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 250 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 251 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 252 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 253 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 254 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 255 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 256 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 257 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 258 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 259 | --------------------------------------------------------------------------------