From 4a3ebc2312028e559fc2f22d02c041ee48bf3947 Mon Sep 17 00:00:00 2001 From: DJ Date: Tue, 3 Nov 2020 21:13:04 +0900 Subject: [PATCH] Implement Projects Resource (#4) * Implement new resource: asana_project * add env variables to tests.yml * generate accessors --- .github/workflows/tests.yml | 3 + TESTING.md | 3 + api/api-accessors.go | 89 +++++ api/api.go | 2 + api/opts.go | 11 + api/params.go | 28 ++ api/projects.go | 132 +++++- asana/config.go | 58 +++ asana/data_source_scaffolding.go | 25 -- asana/helpers.go | 9 + asana/import_resource_asana_project_test.go | 33 ++ asana/provider.go | 60 ++- asana/provider_test.go | 33 +- asana/resource_asana_project.go | 400 +++++++++++++++++++ asana/resource_asana_project_test.go | 85 ++++ asana/resource_scaffolding.go | 49 --- docs/data-sources/scaffolding_data_source.md | 23 -- docs/index.md | 72 +++- docs/resources/project.md | 85 ++++ docs/resources/scaffolding_resource.md | 26 -- helper/test/config.go | 6 + helper/test/helper.go | 110 +++++ main.go | 2 +- 23 files changed, 1196 insertions(+), 148 deletions(-) create mode 100644 api/opts.go create mode 100644 api/params.go create mode 100644 asana/config.go delete mode 100644 asana/data_source_scaffolding.go create mode 100644 asana/helpers.go create mode 100644 asana/import_resource_asana_project_test.go create mode 100644 asana/resource_asana_project.go create mode 100644 asana/resource_asana_project_test.go delete mode 100644 asana/resource_scaffolding.go delete mode 100644 docs/data-sources/scaffolding_data_source.md create mode 100644 docs/resources/project.md delete mode 100644 docs/resources/scaffolding_resource.md create mode 100644 helper/test/helper.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 04344f0..6641ff7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,3 +42,6 @@ jobs: - name: Run make test run: make testacc TEST="./asana/" + env: + ASANA_ACCESS_TOKEN: ${{ secrets.ASANA_ACCESS_TOKEN }} + ASANA_WORKSPACE_ID: ${{ secrets.ASANA_WORKSPACE_ID }} \ No newline at end of file diff --git a/TESTING.md b/TESTING.md index 079048d..e66e98b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -33,9 +33,12 @@ The following parameters are available for running the test. The absence of some will cause certain tests to be skipped. * **TF_ACC** (`integer`) **Required** - must be set to `1`. +* **ASANA_ACCESS_TOKEN** (`string`) **Required** - A valid Asana access token. +* **ASANA_WORKSPACE_ID** (`string`) **Required** - A valid Asana workspace ID. **For example:** ```bash export TF_ACC=1 +export ASANA_ACCESS_TOKEN=... $ make testacc TEST="./NAME_OF_TEST/" 2>&1 | tee test.log ``` diff --git a/api/api-accessors.go b/api/api-accessors.go index 6431005..68b793c 100644 --- a/api/api-accessors.go +++ b/api/api-accessors.go @@ -212,6 +212,28 @@ func (e *Enum) GetName() string { return *e.Name } +// HasFields checks if InputOutputOpts has any Fields. +func (i *InputOutputOpts) HasFields() bool { + if i == nil || i.Fields == nil { + return false + } + if len(i.Fields) == 0 { + return false + } + return true +} + +// HasFields checks if InputOutputParams has any Fields. +func (i *InputOutputParams) HasFields() bool { + if i == nil || i.Fields == nil { + return false + } + if len(i.Fields) == 0 { + return false + } + return true +} + // GetArchived returns the Archived field if it's non-nil, zero value otherwise. func (p *Project) GetArchived() bool { if p == nil || p.Archived == nil { @@ -408,6 +430,73 @@ func (p *Project) GetWorkspace() *Workspace { return p.Workspace } +// GetColor returns the Color field if it's non-nil, zero value otherwise. +func (p *ProjectRequestOpts) GetColor() string { + if p == nil || p.Color == nil { + return "" + } + return *p.Color +} + +// GetCurrentStatus returns the CurrentStatus field. +func (p *ProjectRequestOpts) GetCurrentStatus() *ProjectStatus { + if p == nil { + return nil + } + return p.CurrentStatus +} + +// GetDueOn returns the DueOn field if it's non-nil, zero value otherwise. +func (p *ProjectRequestOpts) GetDueOn() string { + if p == nil || p.DueOn == nil { + return "" + } + return *p.DueOn +} + +// GetIsTemplate returns the IsTemplate field if it's non-nil, zero value otherwise. +func (p *ProjectRequestOpts) GetIsTemplate() bool { + if p == nil || p.IsTemplate == nil { + return false + } + return *p.IsTemplate +} + +// GetOwner returns the Owner field if it's non-nil, zero value otherwise. +func (p *ProjectRequestOpts) GetOwner() string { + if p == nil || p.Owner == nil { + return "" + } + return *p.Owner +} + +// GetStartOn returns the StartOn field if it's non-nil, zero value otherwise. +func (p *ProjectRequestOpts) GetStartOn() string { + if p == nil || p.StartOn == nil { + return "" + } + return *p.StartOn +} + +// GetData returns the Data field. +func (p *ProjectResponse) GetData() *Project { + if p == nil { + return nil + } + return p.Data +} + +// HasData checks if ProjectsResponse has any Data. +func (p *ProjectsResponse) HasData() bool { + if p == nil || p.Data == nil { + return false + } + if len(p.Data) == 0 { + return false + } + return true +} + // GetAuthor returns the Author field. func (p *ProjectStatus) GetAuthor() *User { if p == nil { diff --git a/api/api.go b/api/api.go index 48a5ea6..6214e9d 100644 --- a/api/api.go +++ b/api/api.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "github.com/davidji99/simpleresty" "github.com/go-resty/resty/v2" "net/http" @@ -127,6 +128,7 @@ func (c *Client) setupClient() { c.http.SetHeader("Content-type", DefaultContentTypeHeader). SetHeader("Accept", DefaultAcceptHeader). SetHeader("User-Agent", c.userAgent). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", c.accessToken)). SetTimeout(1 * time.Minute). SetAllowGetMethodPayload(true) diff --git a/api/opts.go b/api/opts.go new file mode 100644 index 0000000..47daee1 --- /dev/null +++ b/api/opts.go @@ -0,0 +1,11 @@ +package api + +// InputOutputOpts represents the options available for POST or PUT request +// or when the `application/json` content type. +type InputOutputOpts struct { + // Provides “pretty” output. + Pretty bool `json:"opt_pretty"` + + // Defines fields to return. + Fields []string `json:"opt_fields"` +} diff --git a/api/params.go b/api/params.go new file mode 100644 index 0000000..a4e4d28 --- /dev/null +++ b/api/params.go @@ -0,0 +1,28 @@ +package api + +// InputOutputParams specify query parameters to control how your request is interpreted and how the response is generated. +// This struct is used for any GET requests or any PUT/POST requests that use the +// `application/x-www-form-urlencoded` content type. +// +// Reference: https://developers.asana.com/docs/input-output-options +type InputOutputParams struct { + // Provides the response in "pretty" output. In the case of JSON this means doing proper line breaking + // and indentation to make it readable. This will take extra time and increase the response size + // so it is advisable only to use this during debugging. + Pretty bool `url:"opt_pretty"` + + // Some requests return compact representations of objects, to conserve resources and complete + // the request more efficiently. Other times requests return more information than you may need. + // This option allows you to list the exact set of fields that the API should be sure to return for the objects. + // The field names should be provided as paths, described below. + Fields []string `url:"opt_fields"` +} + +// ListParams represents the query parameters available to most (if not all) list methods. +type ListParams struct { + // Results per page. + Limit int `url:"opt_fields"` + + // Offset token. + Offset string `url:"offset"` +} diff --git a/api/projects.go b/api/projects.go index 99c4698..f46c468 100644 --- a/api/projects.go +++ b/api/projects.go @@ -1,6 +1,10 @@ package api -import "time" +import ( + "fmt" + "github.com/davidji99/simpleresty" + "time" +) // ProjectsService handles communication with the project related // methods of the Asana API. @@ -8,6 +12,14 @@ import "time" // Asana API docs: https://developers.asana.com/docs/projects type ProjectsService service +type ProjectResponse struct { + Data *Project `json:"data,omitempty"` +} + +type ProjectsResponse struct { + Data []*Project `json:"data,omitempty"` +} + // Project represents a prioritized list of tasks in Asana or a board with columns of tasks represented as cards. type Project struct { CommonResourceFields @@ -93,3 +105,121 @@ type Project struct { // Create-only. The team that this project is shared with. This field only exists for projects in organizations. Team *Team `json:"team,omitempty"` } + +// ProjectListParams represents the query parameters available when retrieving all projects. +type ProjectListParams struct { + // The workspace or organization to filter projects on. + Workspace string `url:"workspace"` + + // The team to filter projects on. + Team string `url:"team"` + + // Only return projects whose archived field takes on the value of this parameter. + Archived bool `url:"archived"` +} + +// ProjectRequestOpts represents the options available when creating or updating a Project. +type ProjectRequestOpts struct { + Archived bool `json:"archived"` + Color *string `json:"color"` + CurrentStatus *ProjectStatus `json:"current_status,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` + DefaultView string `json:"default_view,omitempty"` + DueOn *string `json:"due_on"` + Followers string `json:"followers,omitempty"` + HTMLNotes string `json:"html_notes,omitempty"` + IsTemplate *bool `json:"is_template,omitempty"` + Name string `json:"name,omitempty"` + Notes string `json:"notes,omitempty"` + Owner *string `json:"owner"` + Public bool `json:"public"` + StartOn *string `json:"start_on"` + Team string `json:"team,omitempty"` + Workspace string `json:"workspace,omitempty"` +} + +// List returns the compact project records for some filtered set of projects. Use one or more of the parameters +// provided to filter the projects returned. +// +// Asana API docs: https://developers.asana.com/docs/get-multiple-projects +func (p *ProjectsService) List(params ...interface{}) (*ProjectsResponse, *simpleresty.Response, error) { + result := new(ProjectsResponse) + urlStr, urlStrErr := p.client.http.RequestURLWithQueryParams( + fmt.Sprintf("/projects"), params...) + if urlStrErr != nil { + return nil, nil, urlStrErr + } + + response, err := p.client.http.Get(urlStr, result, nil) + + return result, response, err +} + +// Create a new project in a workspace or team. +// +// Every project is required to be created in a specific workspace or organization, and this cannot be changed once set. +// Note that you can use the workspace parameter regardless of whether or not it is an organization. +// If the workspace for your project is an organization, you must also supply a team to share the project with. +// Returns the full record of the newly created project. +// +// Asana API docs: https://developers.asana.com/docs/create-a-project +func (p *ProjectsService) Create(createOpts *ProjectRequestOpts, ioOpts *InputOutputOpts) (*ProjectResponse, *simpleresty.Response, error) { + result := new(ProjectResponse) + urlStr := p.client.http.RequestURL("/projects") + + body := struct { + Data *ProjectRequestOpts `json:"data"` + Options *InputOutputOpts `json:"options,omitempty"` + }{Data: createOpts, Options: ioOpts} + + response, err := p.client.http.Post(urlStr, result, body) + return result, response, err +} + +// Get returns the complete project record for a single project. +// +// Asana API docs: https://developers.asana.com/docs/get-a-project +func (p *ProjectsService) Get(id string, params ...interface{}) (*ProjectResponse, *simpleresty.Response, error) { + result := new(ProjectResponse) + urlStr, urlStrErr := p.client.http.RequestURLWithQueryParams( + fmt.Sprintf("/projects/%s", id), params...) + if urlStrErr != nil { + return nil, nil, urlStrErr + } + + response, err := p.client.http.Get(urlStr, result, nil) + + return result, response, err +} + +// Update a project. +// +// A specific, existing project can be updated by making a PUT request on the URL for that project. +// Only the fields provided in the data block will be updated; any unspecified fields will remain unchanged. +// When using this method, it is best to specify only those fields you wish to change, or else you may overwrite changes made by another user since you last retrieved the task. +// +// Returns the complete updated project record. +// +// Asana API docs: https://developers.asana.com/docs/update-a-project +func (p *ProjectsService) Update(id string, updateOpts *ProjectRequestOpts, ioOpts *InputOutputOpts) (*ProjectResponse, *simpleresty.Response, error) { + result := new(ProjectResponse) + urlStr := p.client.http.RequestURL("/projects/%s", id) + + body := struct { + Data *ProjectRequestOpts `json:"data"` + Options *InputOutputOpts `json:"options,omitempty"` + }{Data: updateOpts, Options: ioOpts} + + response, err := p.client.http.Put(urlStr, result, body) + return result, response, err +} + +// Delete a project. +// +// Asana API docs: https://developers.asana.com/docs/delete-a-project +func (p *ProjectsService) Delete(id string) (*simpleresty.Response, error) { + urlStr := p.client.http.RequestURL("/projects/%s", id) + + response, err := p.client.http.Delete(urlStr, nil, nil) + return response, err +} diff --git a/asana/config.go b/asana/config.go new file mode 100644 index 0000000..7095244 --- /dev/null +++ b/asana/config.go @@ -0,0 +1,58 @@ +package asana + +import ( + "fmt" + "github.com/davidji99/terraform-provider-asana/api" + "github.com/davidji99/terraform-provider-asana/version" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "log" +) + +var ( + UserAgent = fmt.Sprintf("terraform-provider-herokux/v%s", version.ProviderVersion) +) + +type Config struct { + API *api.Client + Headers map[string]string + acessToken string + baseURL string +} + +func NewConfig() *Config { + return &Config{} +} + +func (c *Config) initializeAPI() error { + // Initialize the custom API client for non Heroku Platform APIs + api, clientInitErr := api.New( + api.AccessToken(c.acessToken), api.CustomHTTPHeaders(c.Headers), + api.UserAgent(UserAgent), api.BaseURL(c.baseURL)) + if clientInitErr != nil { + return clientInitErr + } + c.API = api + + log.Printf("[INFO] Asana Client configured") + + return nil +} + +func (c *Config) applySchema(d *schema.ResourceData) (err error) { + if v, ok := d.GetOk("headers"); ok { + headersRaw := v.(map[string]interface{}) + h := make(map[string]string) + + for k, v := range headersRaw { + h[k] = fmt.Sprintf("%v", v) + } + + c.Headers = h + } + + if v, ok := d.GetOk("url"); ok { + c.baseURL = v.(string) + } + + return nil +} diff --git a/asana/data_source_scaffolding.go b/asana/data_source_scaffolding.go deleted file mode 100644 index cdd6c00..0000000 --- a/asana/data_source_scaffolding.go +++ /dev/null @@ -1,25 +0,0 @@ -package asana - -import ( - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -func dataSourceScaffolding() *schema.Resource { - return &schema.Resource{ - Read: dataSourceScaffoldingRead, - - Schema: map[string]*schema.Schema{ - "sample_attribute": { - Type: schema.TypeString, - Required: true, - }, - }, - } -} - -func dataSourceScaffoldingRead(d *schema.ResourceData, meta interface{}) error { - // use the meta value to retrieve your client from the provider configure method - // client := meta.(*apiClient) - - return nil -} diff --git a/asana/helpers.go b/asana/helpers.go new file mode 100644 index 0000000..f083668 --- /dev/null +++ b/asana/helpers.go @@ -0,0 +1,9 @@ +package asana + +func Bool(v bool) *bool { + return &v +} + +func String(v string) *string { + return &v +} diff --git a/asana/import_resource_asana_project_test.go b/asana/import_resource_asana_project_test.go new file mode 100644 index 0000000..ae0c6e2 --- /dev/null +++ b/asana/import_resource_asana_project_test.go @@ -0,0 +1,33 @@ +package asana + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "testing" +) + +func TestAccAsanaProject_importBasic(t *testing.T) { + var providers []*schema.Provider + workspaceID := testAccConfig.GetWorkspaceIDorSkip(t) + name := fmt.Sprintf("tftest-%s", acctest.RandString(10)) + defaultView := "board" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: testAccProviderFactories(&providers), + Steps: []resource.TestStep{ + { + Config: testAccCheckAsanaProject_basic(workspaceID, name, defaultView), + }, + { + ResourceName: "asana_project.foobar", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} diff --git a/asana/provider.go b/asana/provider.go index 59773d3..c8f02d8 100644 --- a/asana/provider.go +++ b/asana/provider.go @@ -2,12 +2,14 @@ package asana import ( "context" + "github.com/davidji99/terraform-provider-asana/api" + "log" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -func New() *schema.Provider { +func Provider() *schema.Provider { return &schema.Provider{ Schema: map[string]*schema.Schema{ "access_token": { @@ -15,22 +17,62 @@ func New() *schema.Provider { Optional: true, DefaultFunc: schema.EnvDefaultFunc("ASANA_ACCESS_TOKEN", nil), }, + + "url": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("ASANA_URL", api.DefaultAPIBaseURL), + }, + + "headers": { + Type: schema.TypeMap, + Elem: schema.TypeString, + Optional: true, + }, }, DataSourcesMap: map[string]*schema.Resource{}, - ResourcesMap: map[string]*schema.Resource{}, + ResourcesMap: map[string]*schema.Resource{ + "asana_project": resourceAsanaProject(), + }, ConfigureContextFunc: providerConfigure, } } -type apiClient struct { - // Add whatever fields, client or connection info, etc. here - // you would need to setup to communicate with the upstream - // API. -} - func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { - return nil, nil + log.Println("[INFO] Initializing Asana Provider") + + var diags diag.Diagnostics + + config := NewConfig() + + if applySchemaErr := config.applySchema(d); applySchemaErr != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to retrieve and set provider attributes", + Detail: applySchemaErr.Error(), + }) + + return nil, diags + } + + if token, ok := d.GetOk("access_token"); ok { + config.acessToken = token.(string) + } + + if err := config.initializeAPI(); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to initialize API client", + Detail: err.Error(), + }) + + return nil, diags + } + + log.Printf("[DEBUG] Asana Provider initialized") + + return config, diags } diff --git a/asana/provider_test.go b/asana/provider_test.go index 7c60455..dc75842 100644 --- a/asana/provider_test.go +++ b/asana/provider_test.go @@ -12,23 +12,48 @@ var testAccProvider *schema.Provider var testAccConfig *helper.TestConfig func init() { - testAccProvider = New() + testAccProvider = Provider() testAccProviders = map[string]*schema.Provider{ - "herokux": testAccProvider, + "asana": testAccProvider, } testAccConfig = helper.NewTestConfig() } func TestProvider(t *testing.T) { - if err := New().InternalValidate(); err != nil { + if err := Provider().InternalValidate(); err != nil { t.Fatalf("err: %s", err) } } func TestProvider_impl(t *testing.T) { - var _ *schema.Provider = New() + var _ *schema.Provider = Provider() } func testAccPreCheck(t *testing.T) { testAccConfig.GetOrAbort(t, helper.TestConfigAsanaAccessToken) } + +func testAccProviderFactories(providers *[]*schema.Provider) map[string]func() (*schema.Provider, error) { + return testAccProviderFactoriesInit(providers, []string{ + "asana", + }) +} + +// testAccProviderFactoriesInit creates ProviderFactories for the provider under testing. +func testAccProviderFactoriesInit(providers *[]*schema.Provider, providerNames []string) map[string]func() (*schema.Provider, error) { + var factories = make(map[string]func() (*schema.Provider, error), len(providerNames)) + + for _, name := range providerNames { + p := Provider() + + factories[name] = func() (*schema.Provider, error) { //nolint:unparam + return p, nil + } + + if providers != nil { + *providers = append(*providers, p) + } + } + + return factories +} diff --git a/asana/resource_asana_project.go b/asana/resource_asana_project.go new file mode 100644 index 0000000..856b1ce --- /dev/null +++ b/asana/resource_asana_project.go @@ -0,0 +1,400 @@ +package asana + +import ( + "context" + "fmt" + "github.com/davidji99/terraform-provider-asana/api" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "log" + "regexp" + "strings" +) + +func resourceAsanaProject() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceAsanaProjectCreate, + ReadContext: resourceAsanaProjectRead, + UpdateContext: resourceAsanaProjectUpdate, + DeleteContext: resourceAsanaProjectDelete, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "workspace_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "name": { + Type: schema.TypeString, + Required: true, + }, + + "default_view": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"list", "board", "calendar", "timeline"}, false), + }, + + "team_id": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + Computed: true, + }, + + "archived": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + + "color": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + + "custom_fields": { + Type: schema.TypeMap, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + Computed: true, + }, + + "due_on": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ValidateFunc: validateDates, + }, + + "followers": { + Type: schema.TypeSet, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + Computed: true, + ForceNew: true, + }, + + "html_notes": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + + "is_template": { + Type: schema.TypeBool, + Optional: true, + Computed: true, + }, + + "notes": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + + "owner": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + + "is_public": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + + "start_on": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ValidateFunc: validateDates, + RequiredWith: []string{"due_on"}, + }, + + //"current_status": {}, + }, + } +} + +func validateDates(v interface{}, k string) (ws []string, errors []error) { + date := v.(string) + if !regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`).MatchString(date) { + errors = append(errors, fmt.Errorf("%s attribute value "+ + "must follow this format: YYYY-MM-DD", k)) + } + return +} + +func resourceAsanaProjectImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + return nil, nil +} + +func constructProjectOpts(d *schema.ResourceData) *api.ProjectRequestOpts { + opts := &api.ProjectRequestOpts{} + + if v, ok := d.GetOk("custom_fields"); ok { + vs := v.(map[string]interface{}) + log.Printf("[DEBUG] project custom_fields is : %v", vs) + opts.CustomFields = vs + } + + if v, ok := d.GetOk("default_view"); ok { + vs := v.(string) + log.Printf("[DEBUG] project default_view is : %v", vs) + opts.DefaultView = vs + } + + if v, ok := d.GetOk("html_notes"); ok { + vs := v.(string) + log.Printf("[DEBUG] project html_notes is : %v", vs) + opts.HTMLNotes = vs + } + + if v, ok := d.GetOk("notes"); ok { + vs := v.(string) + log.Printf("[DEBUG] project notes is : %v", vs) + opts.Notes = vs + } + + if v, ok := d.GetOk("name"); ok { + vs := v.(string) + log.Printf("[DEBUG] project name is : %v", vs) + opts.Name = vs + } + + if v, ok := d.GetOk("workspace_id"); ok { + vs := v.(string) + log.Printf("[DEBUG] project workspace_id is : %v", vs) + opts.Workspace = vs + } + + opts.Public = d.Get("is_public").(bool) + log.Printf("[DEBUG] project is_public is : %v", opts.Public) + + opts.Archived = d.Get("archived").(bool) + log.Printf("[DEBUG] project archived is : %v", opts.Archived) + + // The extra use of GetOk is needed here as project templates are a paid plan feature. + if _, ok := d.GetOk("is_template"); ok { + opts.IsTemplate = Bool(d.Get("is_template").(bool)) + log.Printf("[DEBUG] project is_template is : %v", opts.IsTemplate) + } + + return opts +} + +func resourceAsanaProjectCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*Config).API + opts := constructProjectOpts(d) + var diags diag.Diagnostics + + if v, ok := d.GetOk("color"); ok { + vs := v.(string) + log.Printf("[DEBUG] new project color is : %v", vs) + opts.Color = &vs + } + + if v, ok := d.GetOk("due_on"); ok { + vs := v.(string) + log.Printf("[DEBUG] new project due_on is : %v", vs) + opts.DueOn = &vs + } + + if v, ok := d.GetOk("owner"); ok { + vs := v.(string) + log.Printf("[DEBUG] new project owner is : %v", vs) + opts.Owner = &vs + } + + if v, ok := d.GetOk("start_on"); ok { + vs := v.(string) + log.Printf("[DEBUG] new project start_on is : %v", vs) + opts.StartOn = &vs + } + + if v, ok := d.GetOk("followers"); ok { + vs := v.(*schema.Set).List() + fl := make([]string, 0) + for _, f := range vs { + fl = append(fl, f.(string)) + } + + opts.Followers = strings.Join(fl, ",") + log.Printf("[DEBUG] project followers is : %v", opts.Followers) + } + + if v, ok := d.GetOk("team_id"); ok { + vs := v.(string) + log.Printf("[DEBUG] project team_id is : %v", vs) + opts.Team = vs + } + + log.Printf("[DEBUG] Creating new project with the following options: %v", opts) + + p, _, createErr := client.Projects.Create(opts, &api.InputOutputOpts{ + Fields: []string{"html_notes", "is_template"}, + }) + if createErr != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to create new project", + Detail: createErr.Error(), + }) + return diags + } + + log.Printf("[DEBUG] Created new project") + + d.SetId(p.GetData().GetGID()) + + return resourceAsanaProjectRead(ctx, d, meta) +} + +func resourceAsanaProjectRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*Config).API + + var diags diag.Diagnostics + + p, _, readErr := client.Projects.Get(d.Id()) + if readErr != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("Unable to read/fetch info about project %s", d.Id()), + Detail: readErr.Error(), + }) + return diags + } + + d.Set("name", p.GetData().GetName()) + d.Set("workspace_id", p.GetData().GetWorkspace().GetGID()) + d.Set("archived", p.GetData().GetArchived()) + d.Set("color", p.GetData().GetColor()) + d.Set("default_view", p.GetData().GetDefaultView()) + d.Set("due_on", p.GetData().GetDueOn()) + d.Set("html_notes", p.GetData().GetHtmlNotes()) + d.Set("is_template", p.GetData().GetIsTemplate()) + d.Set("notes", p.GetData().GetNotes()) + d.Set("is_public", p.GetData().GetPublic()) + d.Set("start_on", p.GetData().GetStartOn()) + + if p.GetData().GetTeam() != nil { + d.Set("team_id", p.GetData().GetTeam().GetGID()) + + } else { + d.Set("team_id", "") + } + + // Construct a schema friendly version of custom_fields + cf := make(map[string]interface{}, 0) + for _, f := range p.GetData().CustomFields { + cf[f.GetGID()] = f.GetTextValue() + } + d.Set("custom_fields", cf) + + // Construct a schema friendly version of followers + followers := make([]string, 0) + for _, f := range p.GetData().Followers { + followers = append(followers, f.GetGID()) + } + d.Set("followers", followers) + + return diags +} + +func resourceAsanaProjectUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*Config).API + opts := constructProjectOpts(d) + var diags diag.Diagnostics + + if d.HasChange("color") { + _, n := d.GetChange("color") + + if n == "null" { + opts.Color = nil + } else { + opts.Color = String(n.(string)) + } + log.Printf("[DEBUG] updated project color is : %v", opts.Color) + } + + if d.HasChange("due_on") { + _, n := d.GetChange("due_on") + + if n == "null" { + opts.DueOn = nil + } else { + opts.DueOn = String(n.(string)) + } + log.Printf("[DEBUG] updated project due_on is : %v", opts.DueOn) + } + + if d.HasChange("owner") { + _, n := d.GetChange("owner") + + if n == "null" { + opts.Owner = nil + } else { + opts.Owner = String(n.(string)) + } + log.Printf("[DEBUG] updated project owner is : %v", opts.Owner) + } + + if d.HasChange("start_on") { + _, n := d.GetChange("start_on") + + if n == "null" { + opts.StartOn = nil + } else { + opts.StartOn = String(n.(string)) + } + log.Printf("[DEBUG] updated project start_on is : %v", opts.StartOn) + } + + log.Printf("[DEBUG] Updating project with the following options: %v", opts) + + _, _, updateErr := client.Projects.Update(d.Id(), opts, &api.InputOutputOpts{ + Fields: []string{"html_notes", "is_template"}, + }) + if updateErr != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("Unable to update existing project %s", d.Id()), + Detail: updateErr.Error(), + }) + return diags + } + + log.Printf("[DEBUG] Updated project") + + return resourceAsanaProjectRead(ctx, d, meta) +} + +func resourceAsanaProjectDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*Config).API + + var diags diag.Diagnostics + + _, deleteErr := client.Projects.Delete(d.Id()) + if deleteErr != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("Unable to delete project %s", d.Id()), + Detail: deleteErr.Error(), + }) + + return diags + } + + d.SetId("") + + return diags +} diff --git a/asana/resource_asana_project_test.go b/asana/resource_asana_project_test.go new file mode 100644 index 0000000..7b275c8 --- /dev/null +++ b/asana/resource_asana_project_test.go @@ -0,0 +1,85 @@ +package asana + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "testing" +) + +func TestAccAsanaProject_Basic(t *testing.T) { + workspaceID := testAccConfig.GetWorkspaceIDorSkip(t) + name := fmt.Sprintf("tftest-%s", acctest.RandString(10)) + defaultView := "board" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCheckAsanaProject_basic(workspaceID, name, defaultView), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "asana_project.foobar", "workspace_id", workspaceID), + resource.TestCheckResourceAttr( + "asana_project.foobar", "name", name), + resource.TestCheckResourceAttr( + "asana_project.foobar", "default_view", defaultView), + resource.TestCheckResourceAttr( + "asana_project.foobar", "team_id", ""), + resource.TestCheckResourceAttr( + "asana_project.foobar", "archived", "false"), + resource.TestCheckResourceAttr( + "asana_project.foobar", "color", ""), + resource.TestCheckResourceAttr( + "asana_project.foobar", "custom_fields.%", "0"), + resource.TestCheckResourceAttr( + "asana_project.foobar", "due_on", ""), + resource.TestCheckResourceAttr( + "asana_project.foobar", "followers.#", "1"), + resource.TestCheckResourceAttr( + "asana_project.foobar", "html_notes", ""), + resource.TestCheckResourceAttr( + "asana_project.foobar", "is_template", "false"), + resource.TestCheckResourceAttr( + "asana_project.foobar", "notes", ""), + resource.TestCheckResourceAttr( + "asana_project.foobar", "is_public", "false"), + resource.TestCheckResourceAttr( + "asana_project.foobar", "start_on", ""), + ), + }, + { + Config: testAccCheckAsanaProject_Updatedbasic(workspaceID, fmt.Sprintf("%s_updated", name), defaultView), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "asana_project.foobar", "name", fmt.Sprintf("%s_updated", name)), + resource.TestCheckResourceAttr( + "asana_project.foobar", "is_public", "true"), + ), + }, + }, + }) +} + +func testAccCheckAsanaProject_basic(workspaceID, name, defaultView string) string { + return fmt.Sprintf(` +resource "asana_project" "foobar" { + workspace_id = "%s" + name = "%s" + default_view = "%s" + is_public = false +} +`, workspaceID, name, defaultView) +} + +func testAccCheckAsanaProject_Updatedbasic(workspaceID, name, defaultView string) string { + return fmt.Sprintf(` +resource "asana_project" "foobar" { + workspace_id = "%s" + name = "%s" + default_view = "%s" + is_public = true +} +`, workspaceID, name, defaultView) +} diff --git a/asana/resource_scaffolding.go b/asana/resource_scaffolding.go deleted file mode 100644 index c15c8ba..0000000 --- a/asana/resource_scaffolding.go +++ /dev/null @@ -1,49 +0,0 @@ -package asana - -import ( - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -func resourceScaffolding() *schema.Resource { - return &schema.Resource{ - Create: resourceScaffoldingCreate, - Read: resourceScaffoldingRead, - Update: resourceScaffoldingUpdate, - Delete: resourceScaffoldingDelete, - - Schema: map[string]*schema.Schema{ - "sample_attribute": { - Type: schema.TypeString, - Optional: true, - }, - }, - } -} - -func resourceScaffoldingCreate(d *schema.ResourceData, meta interface{}) error { - // use the meta value to retrieve your client from the provider configure method - // client := meta.(*apiClient) - - return nil -} - -func resourceScaffoldingRead(d *schema.ResourceData, meta interface{}) error { - // use the meta value to retrieve your client from the provider configure method - // client := meta.(*apiClient) - - return nil -} - -func resourceScaffoldingUpdate(d *schema.ResourceData, meta interface{}) error { - // use the meta value to retrieve your client from the provider configure method - // client := meta.(*apiClient) - - return nil -} - -func resourceScaffoldingDelete(d *schema.ResourceData, meta interface{}) error { - // use the meta value to retrieve your client from the provider configure method - // client := meta.(*apiClient) - - return nil -} diff --git a/docs/data-sources/scaffolding_data_source.md b/docs/data-sources/scaffolding_data_source.md deleted file mode 100644 index 6ebe207..0000000 --- a/docs/data-sources/scaffolding_data_source.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -layout: "scaffolding" -page_title: "Scaffolding: scaffolding_data_source" -sidebar_current: "docs-scaffolding-data-source" -description: |- - Sample data source in the Terraform provider scaffolding. ---- - -# scaffolding_data_source - -Sample data source in the Terraform provider scaffolding. - -## Example Usage - -```hcl -data "scaffolding_data_source" "example" { - sample_attribute = "foo" -} -``` - -## Attributes Reference - -* `sample_attribute` - Sample attribute. diff --git a/docs/index.md b/docs/index.md index e5ec946..4d55af4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,25 +1,77 @@ --- -layout: "scaffolding" -page_title: "Provider: Scaffolding" -sidebar_current: "docs-scaffolding-index" +layout: "asana" +page_title: "Provider: Asana" +sidebar_current: "docs-asana-index" description: |- - Terraform provider scaffolding. + Use the Asana provider to interact with the resources in Asana. --- -# Scaffolding Provider +# Asana Provider -Use this paragraph to give a high-level overview of your provider, and any configuration it requires. +The Asana provider interacts with [Asana APIs](https://developers.asana.com/docs) to create various resources +in your account. -Use the navigation to the left to read about the available resources. +## Contributing + +Development happens in the [GitHub repo](https://github.com/davidji99/terraform-provider-asana): + +* [Releases](https://github.com/davidji99/terraform-provider-asana/releases) +* [Issues](https://github.com/davidji99/terraform-provider-asana/issues) ## Example Usage ```hcl -provider "scaffolding" { +# Configure the Asana provider +provider "asana" { + # ... } -# Example resource configuration -resource "scaffolding_resource" "example" { +# Create a new Project +resource "asana_project" "foobar" { # ... } ``` + +## Authentication + +The Asana provider offers a flexible means of providing credentials for authentication. +The following methods are supported, listed in order of precedence, and explained below: + +- Static credentials +- Environment variables + +### Static credentials + +Credentials can be provided statically by adding an `access_token` arguments to the Asana provider block: + +```hcl +provider "asana" { + access_token = "SOME_ACCESS_TOKEN" +} +``` + +### Environment variables + +When the Asana provider block does not contain an `access_token` argument, the missing credential will be sourced +from the environment via the `ASANA_ACCESS_TOKEN` environment variables respectively: + +```hcl +provider "asana" {} +``` + +```shell +$ export ASANA_ACCESS_TOKEN="SOME_KEY" +$ terraform plan +Refreshing Terraform state in-memory prior to plan... +``` + +## Argument Reference + +The following arguments are supported: + +* `access_token` - (Required) Asana personal access token. It must be provided, but it can also + be sourced from [other locations](#Authentication). + +* `url` - (Optional) Custom Base Asana API endpoint. Defaults to `https://app.asana.com/api/1.0`. + +* `headers` - (Optional) Additional API headers. \ No newline at end of file diff --git a/docs/resources/project.md b/docs/resources/project.md new file mode 100644 index 0000000..98bfdb9 --- /dev/null +++ b/docs/resources/project.md @@ -0,0 +1,85 @@ +--- +layout: "asana" +page_title: "Asana: asana_project" +sidebar_current: "docs-asana-resource-project" +description: |- + Provides a resource to manage an Asana Project. +--- + +# asana\_project + +This resource manages an Asana project. A project represents a prioritized list of tasks in Asana or a board with +columns of tasks represented as cards. It exists in a single workspace or organization and is accessible to a subset +of users in that workspace or organization, depending on its permissions. + +## Example Usage + +```hcl-terraform +resource "asana_project" "foobar" { + workspace_id = "12345" + name = "my_foobar_project" + default_view = "board" + is_public = false +} +``` + +## Argument Reference + +The following arguments are supported: + +* `workspace_id` - (Required) `` The workspace you wish to create the project in. + +* `name` - (Required) `` Name of the project. This is generally a short sentence fragment that fits on a line +in the UI for maximum readability. However, it can be longer. + +* `default_view` - (Required) `` The default view of a project. +Acceptable values: `list`, `board`, `calendar`, `timeline`. Case-sensitive. + +* `team_id` - `` The team that this project is shared with. This field only exists for projects in organizations. +This attribute is only available on initial project creation. Updates will result in resource destruction/recreation. + +* `archived` - `` True if the project is archived, false if not. +Archived projects do not show in the UI by default and may be treated differently for queries. Default: `false`. + +* `color` - `` Color of the project. Set value to `"null"` to remove the color. + +* `custom_fields` - `` Define custom field values. + +* `due_on` - `` The day on which this project is due. This takes a date with format YYYY-MM-DD. +Set value to `"null"` to remove the date. + +* `followers` - `` List of users. Followers are a subset of members who receive all +notifications for a project, the default notification setting when adding members to a project in-product. +This attribute is only available on initial project creation. Updates will result in resource destruction/recreation. + +* `html_notes` - `` The notes of the project with formatting as HTML. + +* `is_template` - `` Determines if the project is a template. This attribute is only available for accounts +on a paid plan. + +* `notes` - `` More detailed, free-form textual information associated with the project. + +* `owner` - `` The current owner of the project. Set value to `"null"` to remove the owner +. +* `is_public` - `` True if the project is public to the organization. If false, do not share this project +with other users in this organization without explicitly checking to see if they have access. +Defaults to `false`. + +* `start_on` - `` The day on which work for this project begins, or `"null"` if the project has no start date. +This takes a date with YYYY-MM-DD format. Note: the `due_on` attribute must be defined in your configuration when setting +or unsetting the this attribute. Additionally, `start_on` and `due_on` cannot be the same date. + +## Attributes Reference + +The following attributes are exported: + +N/A + +## Import + +An existing project can be imported using the project ID. + +For example: +```shell script +$ terraform import asana_project.foobar +``` \ No newline at end of file diff --git a/docs/resources/scaffolding_resource.md b/docs/resources/scaffolding_resource.md deleted file mode 100644 index be95927..0000000 --- a/docs/resources/scaffolding_resource.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -layout: "scaffolding" -page_title: "Scaffolding: scaffolding_resource" -sidebar_current: "docs-scaffolding-resource" -description: |- - Sample resource in the Terraform provider scaffolding. ---- - -# scaffolding_resource - -Sample resource in the Terraform provider scaffolding. - -## Example Usage - -```hcl -resource "scaffolding_resource" "example" { - sample_attribute = "foo" -} -``` - -## Argument Reference - -The following arguments are supported: - -* `sample_attribute` - Sample attribute. - diff --git a/helper/test/config.go b/helper/test/config.go index 2c24c26..e2303b2 100644 --- a/helper/test/config.go +++ b/helper/test/config.go @@ -12,11 +12,13 @@ type TestConfigKey int const ( TestConfigAsanaAccessToken TestConfigKey = iota + TestConfigAsanaWorkspaceID TestConfigAcceptanceTestKey ) var testConfigKeyToEnvName = map[TestConfigKey]string{ TestConfigAsanaAccessToken: "ASANA_ACCESS_TOKEN", + TestConfigAsanaWorkspaceID: "ASANA_WORKSPACE_ID", TestConfigAcceptanceTestKey: resource.TestEnvVar, } @@ -67,3 +69,7 @@ func (t *TestConfig) SkipUnlessAccTest(testing *testing.T) { testing.Skip(fmt.Sprintf("Acceptance tests skipped unless env '%s' set", TestConfigAcceptanceTestKey.String())) } } + +func (t *TestConfig) GetWorkspaceIDorSkip(testing *testing.T) (val string) { + return t.GetOrSkip(testing, TestConfigAsanaWorkspaceID) +} diff --git a/helper/test/helper.go b/helper/test/helper.go new file mode 100644 index 0000000..4681efa --- /dev/null +++ b/helper/test/helper.go @@ -0,0 +1,110 @@ +package test + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "strings" +) + +func GenerateRandomCIDR() string { + return fmt.Sprintf("%d.%d.%d.%d/32", acctest.RandIntRange(1, 10), acctest.RandIntRange(1, 10), + acctest.RandIntRange(1, 10), acctest.RandIntRange(1, 10)) +} + +const ( + sentinelIndex = "*" +) + +// The below few functions were copied from the AWS provider. + +// TestCheckTypeSetElemAttr is a resource.TestCheckFunc that accepts a resource +// name, an attribute path, which should use the sentinel value '*' for indexing +// into a TypeSet. The function verifies that an element matches the provided +// value. +// +// Use this function over SDK provided TestCheckFunctions when validating a +// TypeSet where its elements are a simple value +func TestCheckTypeSetElemAttr(name, attr, value string) resource.TestCheckFunc { + return func(s *terraform.State) error { + is, err := instanceState(s, name) + if err != nil { + return err + } + + err = testCheckTypeSetElem(is, attr, value) + if err != nil { + return fmt.Errorf("%q error: %s", name, err) + } + + return nil + } +} + +// TestCheckTypeSetElemAttrPair is a TestCheckFunc that verifies a pair of name/key +// combinations are equal where the first uses the sentinel value to index into a +// TypeSet. +// +// E.g., tfawsresource.TestCheckTypeSetElemAttrPair("aws_autoscaling_group.bar", "availability_zones.*", "data.aws_availability_zones.available", "names.0") +func TestCheckTypeSetElemAttrPair(nameFirst, keyFirst, nameSecond, keySecond string) resource.TestCheckFunc { + return func(s *terraform.State) error { + isFirst, err := instanceState(s, nameFirst) + if err != nil { + return err + } + + isSecond, err := instanceState(s, nameSecond) + if err != nil { + return err + } + + vSecond, okSecond := isSecond.Attributes[keySecond] + if !okSecond { + return fmt.Errorf("%s: Attribute %q not set, cannot be checked against TypeSet", nameSecond, keySecond) + } + + return testCheckTypeSetElem(isFirst, keyFirst, vSecond) + } +} + +// instanceState returns the primary instance state for the given +// resource name in the root module. +func instanceState(s *terraform.State, name string) (*terraform.InstanceState, error) { + ms := s.RootModule() + rs, ok := ms.Resources[name] + if !ok { + return nil, fmt.Errorf("Not found: %s in %s", name, ms.Path) + } + + is := rs.Primary + if is == nil { + return nil, fmt.Errorf("No primary instance: %s in %s", name, ms.Path) + } + + return is, nil +} + +func testCheckTypeSetElem(is *terraform.InstanceState, attr, value string) error { + attrParts := strings.Split(attr, ".") + if attrParts[len(attrParts)-1] != sentinelIndex { + return fmt.Errorf("%q does not end with the special value %q", attr, sentinelIndex) + } + for stateKey, stateValue := range is.Attributes { + if stateValue == value { + stateKeyParts := strings.Split(stateKey, ".") + if len(stateKeyParts) == len(attrParts) { + for i := range attrParts { + if attrParts[i] != stateKeyParts[i] && attrParts[i] != sentinelIndex { + break + } + if i == len(attrParts)-1 { + return nil + } + } + } + } + } + + return fmt.Errorf("no TypeSet element %q, with value %q in state: %#v", attr, value, is.Attributes) +} diff --git a/main.go b/main.go index b96d47c..97cc1d8 100644 --- a/main.go +++ b/main.go @@ -6,5 +6,5 @@ import ( ) func main() { - plugin.Serve(&plugin.ServeOpts{ProviderFunc: asana.New}) + plugin.Serve(&plugin.ServeOpts{ProviderFunc: asana.Provider}) }