From bc4357b00bb1287ad3af3525295d47c832a4c9ef Mon Sep 17 00:00:00 2001 From: Dylan Bonner Date: Tue, 19 May 2026 15:09:21 +0100 Subject: [PATCH 1/2] add write support for assignments --- api.go | 72 +++++++++++++++-- assignment.go | 47 +++++++++++ assignment_test.go | 170 ++++++++++++++++++++++++++++++++++----- testdata/assignment.json | 16 ++++ 4 files changed, 278 insertions(+), 27 deletions(-) create mode 100644 testdata/assignment.json diff --git a/api.go b/api.go index 5d12313..9c28a0a 100644 --- a/api.go +++ b/api.go @@ -1,6 +1,7 @@ package forecast import ( + "bytes" "encoding/json" "fmt" "io" @@ -25,6 +26,19 @@ func New(url string, accountID string, accessToken string) *API { } } +func (api *API) initClient() error { + if api.client == nil { + jar, err := cookiejar.New(nil) + if err != nil { + return err + } + api.client = &http.Client{ + Jar: jar, + } + } + return nil +} + func get[T any](api *API, path string) (T, error) { var result T req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s", api.URL, path), nil) @@ -33,14 +47,9 @@ func get[T any](api *API, path string) (T, error) { } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", api.token)) req.Header.Set("Forecast-Account-ID", api.AccountID) - if api.client == nil { - jar, err := cookiejar.New(nil) - if err != nil { - return result, err - } - api.client = &http.Client{ - Jar: jar, - } + err = api.initClient() + if err != nil { + return result, err } r, err := api.client.Do(req) if err != nil { @@ -59,3 +68,50 @@ func get[T any](api *API, path string) (T, error) { err = json.NewDecoder(r.Body).Decode(&result) return result, err } + +func mutate[T any](api *API, method string, path string, content T) error { + b, err := json.Marshal(content) + if err != nil { + return err + } + req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", api.URL, path), bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", api.token)) + req.Header.Set("Forecast-Account-ID", api.AccountID) + req.Header.Set("Content-Type", "application/json;charset=utf-8") + return doRequest(api, req) +} + +func mutateNoBody(api *API, method string, path string) error { + req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", api.URL, path), nil) + if err != nil { + return err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", api.token)) + req.Header.Set("Forecast-Account-ID", api.AccountID) + return doRequest(api, req) +} + +func doRequest(api *API, req *http.Request) error { + err := api.initClient() + if err != nil { + return err + } + r, err := api.client.Do(req) + if err != nil { + return err + } + defer r.Body.Close() + if r.StatusCode >= http.StatusBadRequest { + body, err := io.ReadAll(r.Body) + if err != nil { + return err + } + + return fmt.Errorf("%s: %s", r.Status, string(body)) + } + + return nil +} diff --git a/assignment.go b/assignment.go index fbd5839..ba7dd11 100644 --- a/assignment.go +++ b/assignment.go @@ -2,7 +2,9 @@ package forecast import ( "encoding/csv" + "fmt" "io" + "net/http" "net/url" "strconv" "strings" @@ -32,6 +34,23 @@ type Assignment struct { RepeatedAssignmentSetID int `json:"repeated_assignment_set_id"` ActiveOnDaysOff bool `json:"active_on_days_off"` } +type assignmentRequestContainer struct { + Assignment AssignmentRequest `json:"assignment"` +} + +// AssignmentRequest is used as a payload when sending a mutation request for a Forecast assignment +type AssignmentRequest struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + Allocation *int `json:"allocation"` + Notes string `json:"notes"` + HarvestProjectTaskID *int `json:"harvest_project_task_id"` + ProjectID int `json:"project_id"` + PersonID int `json:"person_id"` + PlaceholderID *int `json:"placeholder_id"` + RepeatedAssignmentSetID *int `json:"repeated_assignment_set_id"` + ActiveOnDaysOff bool `json:"active_on_days_off"` +} // AssignmentFilter is used to filter assignments type AssignmentFilter struct { @@ -135,6 +154,34 @@ func (api *API) AssignmentsWithFilter(filter AssignmentFilter) (Assignments, err return container.Assignments, nil } +// PostAssignment creates a new assignment on the Forecast account +// A PersonID of '0' will include "Everyone" +func (api *API) PostAssignment(assignment AssignmentRequest) error { + if assignment.ProjectID < 1 { + return fmt.Errorf("unable to POST assignment - project_id is not valid") + } + return mutate(api, http.MethodPost, "assignments", assignmentRequestContainer{Assignment: assignment}) +} + +// PutAssignment updates an existing assignment on the Forecast account +func (api *API) PutAssignment(assignment AssignmentRequest, assignmentID int) error { + if assignment.ProjectID < 1 { + return fmt.Errorf("unable to PUT assignment - project_id is not valid") + } + if assignmentID < 1 { + return fmt.Errorf("unable to PUT assignment - assignment_id is not valid") + } + return mutate(api, http.MethodPut, fmt.Sprintf("assignments/%d", assignmentID), assignmentRequestContainer{Assignment: assignment}) +} + +// DeleteAssignment deletes an assignment on the Forecast account +func (api *API) DeleteAssignment(assignmentID int) error { + if assignmentID < 1 { + return fmt.Errorf("unable to DELETE assignment - assignment_id is not valid") + } + return mutateNoBody(api, http.MethodDelete, fmt.Sprintf("assignments/%d", assignmentID)) +} + // ToParams formats url.Values as a string func ToParams(values url.Values) string { if len(values) == 0 { diff --git a/assignment_test.go b/assignment_test.go index d5fd810..b0f3848 100644 --- a/assignment_test.go +++ b/assignment_test.go @@ -12,6 +12,11 @@ import ( "github.com/sclevine/spec" ) +const ( + assignmentsTestFile = "assignments.json" + assignmentTestFile = "assignment.json" +) + func testAssignment(t *testing.T, when spec.G, it spec.S) { var ( server *httptest.Server @@ -104,7 +109,7 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { when("when a response is returned from the server", func() { it.Before(func() { handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := ReadFile("assignments.json") + response := ReadFile(assignmentsTestFile) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "%s", response) @@ -184,12 +189,7 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { when("when an error is returned from the server", func() { it.Before(func() { - handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := "error" - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "%s", response) - }) + handler = httpHandler(http.StatusBadRequest, assignmentsTestFile) }) it("should return an error", func() { @@ -203,12 +203,7 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { when("Assignments()", func() { when("when a response is returned from the server", func() { it.Before(func() { - handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := ReadFile("assignments.json") - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "%s", response) - }) + handler = httpHandler(http.StatusOK, assignmentsTestFile) }) it("should return assignments and a nil error", func() { @@ -220,12 +215,7 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { when("when an error is returned from the server", func() { it.Before(func() { - handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := "error" - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "%s", response) - }) + handler = httpHandler(http.StatusBadRequest, assignmentsTestFile) }) it("should return an error", func() { @@ -235,4 +225,146 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { }) }) }) + + when("PostAssignment", func() { + assignmentRequest := forecast.AssignmentRequest{ + StartDate: "2017-10-30", + EndDate: "2017-11-30", + Allocation: nil, + Notes: "", + HarvestProjectTaskID: nil, + ProjectID: 123456, + PersonID: 654321, + PlaceholderID: nil, + RepeatedAssignmentSetID: nil, + ActiveOnDaysOff: false, + } + + when("when a successful response is returned from the server", func() { + it.Before(func() { + handler = httpHandler(http.StatusCreated, "") + }) + + it("should return a nil error", func() { + err := api.PostAssignment(assignmentRequest) + Expect(err).ShouldNot(HaveOccurred()) + }) + }) + + when("when an error is returned from the server", func() { + it.Before(func() { + handler = httpHandler(http.StatusBadRequest, "") + }) + + it("should return an error", func() { + err := api.PostAssignment(assignmentRequest) + Expect(err).Should(HaveOccurred()) + }) + }) + + when("when project_id is zero", func() { + it("should return an error without calling the server", func() { + invalid := assignmentRequest + invalid.ProjectID = 0 + err := api.PostAssignment(invalid) + Expect(err).Should(HaveOccurred()) + }) + }) + }) + + when("PutAssignment", func() { + assignmentRequest := forecast.AssignmentRequest{ + StartDate: "2017-10-30", + EndDate: "2017-11-30", + Allocation: nil, + Notes: "", + HarvestProjectTaskID: nil, + ProjectID: 222222, + PersonID: 333333, + PlaceholderID: nil, + RepeatedAssignmentSetID: nil, + ActiveOnDaysOff: false, + } + + when("when a successful response is returned from the server", func() { + it.Before(func() { + handler = httpHandler(http.StatusOK, assignmentTestFile) + }) + + it("should return a nil error", func() { + err := api.PutAssignment(assignmentRequest, 1234567) + Expect(err).ShouldNot(HaveOccurred()) + }) + }) + + when("when an error is returned from the server", func() { + it.Before(func() { + handler = httpHandler(http.StatusNotFound, assignmentTestFile) + }) + + it("should return an error", func() { + err := api.PutAssignment(assignmentRequest, 1234567) + Expect(err).Should(HaveOccurred()) + }) + }) + + when("when project_id is zero", func() { + it("should return an error without calling the server", func() { + invalid := assignmentRequest + invalid.ProjectID = 0 + err := api.PutAssignment(invalid, 1234567) + Expect(err).Should(HaveOccurred()) + }) + }) + + when("when assignmentID is zero", func() { + it("should return an error without calling the server", func() { + err := api.PutAssignment(assignmentRequest, 0) + Expect(err).Should(HaveOccurred()) + }) + }) + }) + + when("DeleteAssignment", func() { + when("when a successful response is returned from the server", func() { + it.Before(func() { + handler = httpHandler(http.StatusOK, "") + }) + + it("should return a nil error", func() { + err := api.DeleteAssignment(1234567) + Expect(err).ShouldNot(HaveOccurred()) + }) + }) + + when("an error is returned from the server", func() { + it.Before(func() { + handler = httpHandler(http.StatusBadRequest, "") + }) + + it("should return an error", func() { + err := api.DeleteAssignment(1234567) + Expect(err).Should(HaveOccurred()) + }) + }) + + when("when assignmentID is zero", func() { + it("should return an error without calling the server", func() { + err := api.DeleteAssignment(0) + Expect(err).Should(HaveOccurred()) + }) + }) + }) +} + +func httpHandler(statusCode int, testFilePath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + response := "" + if testFilePath != "" { + response = ReadFile(testFilePath) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(statusCode) + fmt.Fprintf(w, "%s", response) + } } diff --git a/testdata/assignment.json b/testdata/assignment.json new file mode 100644 index 0000000..e502be6 --- /dev/null +++ b/testdata/assignment.json @@ -0,0 +1,16 @@ +{ + "assignment": { + "id": 1000001, + "start_date": "2017-10-30", + "end_date": "2017-11-30", + "allocation": null, + "notes": null, + "updated_at": "2017-05-02T19:07:00.478Z", + "updated_by_id": 111111, + "project_id": 222222, + "person_id": 333333, + "placeholder_id": null, + "repeated_assignment_set_id": null, + "active_on_days_off": false + } +} \ No newline at end of file From c446ffed05c3b5ea1c74962b72d12a833374ff33 Mon Sep 17 00:00:00 2001 From: Dylan Bonner Date: Tue, 19 May 2026 16:58:46 +0100 Subject: [PATCH 2/2] return mutated entity --- api.go | 32 ++++++++++++++++++++------------ assignment.go | 26 ++++++++++++++++---------- assignment_test.go | 26 ++++++++++++++------------ testdata/assignment.json | 4 ++-- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/api.go b/api.go index 9c28a0a..7b1ba28 100644 --- a/api.go +++ b/api.go @@ -69,19 +69,21 @@ func get[T any](api *API, path string) (T, error) { return result, err } -func mutate[T any](api *API, method string, path string, content T) error { +func mutate[TBody any, TResult any](api *API, method string, path string, content TBody) (TResult, error) { + var result TResult b, err := json.Marshal(content) if err != nil { - return err + return result, err } req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", api.URL, path), bytes.NewReader(b)) if err != nil { - return err + return result, err } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", api.token)) req.Header.Set("Forecast-Account-ID", api.AccountID) req.Header.Set("Content-Type", "application/json;charset=utf-8") - return doRequest(api, req) + + return doRequest[TResult](api, req) } func mutateNoBody(api *API, method string, path string) error { @@ -91,27 +93,33 @@ func mutateNoBody(api *API, method string, path string) error { } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", api.token)) req.Header.Set("Forecast-Account-ID", api.AccountID) - return doRequest(api, req) + _, err = doRequest[struct{}](api, req) + return err } -func doRequest(api *API, req *http.Request) error { +func doRequest[T any](api *API, req *http.Request) (T, error) { + var result T err := api.initClient() if err != nil { - return err + return result, err } r, err := api.client.Do(req) if err != nil { - return err + return result, err } + defer r.Body.Close() if r.StatusCode >= http.StatusBadRequest { body, err := io.ReadAll(r.Body) if err != nil { - return err + return result, err } - - return fmt.Errorf("%s: %s", r.Status, string(body)) + return result, fmt.Errorf("%s: %s", r.Status, string(body)) } - return nil + err = json.NewDecoder(r.Body).Decode(&result) + if err == io.EOF { // empty response body, nothing to decode + err = nil + } + return result, err } diff --git a/assignment.go b/assignment.go index ba7dd11..10a703f 100644 --- a/assignment.go +++ b/assignment.go @@ -11,6 +11,9 @@ import ( "time" ) +type assignmentContainer struct { + Assignment Assignment `json:"assignment"` +} type assignmentsContainer struct { Assignments Assignments `json:"assignments"` } @@ -34,6 +37,7 @@ type Assignment struct { RepeatedAssignmentSetID int `json:"repeated_assignment_set_id"` ActiveOnDaysOff bool `json:"active_on_days_off"` } + type assignmentRequestContainer struct { Assignment AssignmentRequest `json:"assignment"` } @@ -154,30 +158,32 @@ func (api *API) AssignmentsWithFilter(filter AssignmentFilter) (Assignments, err return container.Assignments, nil } -// PostAssignment creates a new assignment on the Forecast account +// CreateAssignment creates a new assignment on the Forecast account // A PersonID of '0' will include "Everyone" -func (api *API) PostAssignment(assignment AssignmentRequest) error { +func (api *API) CreateAssignment(assignment AssignmentRequest) (Assignment, error) { if assignment.ProjectID < 1 { - return fmt.Errorf("unable to POST assignment - project_id is not valid") + return Assignment{}, fmt.Errorf("unable to create assignment - project_id is not valid") } - return mutate(api, http.MethodPost, "assignments", assignmentRequestContainer{Assignment: assignment}) + aContainer, err := mutate[assignmentRequestContainer, assignmentContainer](api, http.MethodPost, "assignments", assignmentRequestContainer{Assignment: assignment}) + return aContainer.Assignment, err } -// PutAssignment updates an existing assignment on the Forecast account -func (api *API) PutAssignment(assignment AssignmentRequest, assignmentID int) error { +// UpdateAssignment updates an existing assignment on the Forecast account +func (api *API) UpdateAssignment(assignment AssignmentRequest, assignmentID int) (Assignment, error) { if assignment.ProjectID < 1 { - return fmt.Errorf("unable to PUT assignment - project_id is not valid") + return Assignment{}, fmt.Errorf("unable to update assignment - project_id is not valid") } if assignmentID < 1 { - return fmt.Errorf("unable to PUT assignment - assignment_id is not valid") + return Assignment{}, fmt.Errorf("unable to update assignment - assignment_id is not valid") } - return mutate(api, http.MethodPut, fmt.Sprintf("assignments/%d", assignmentID), assignmentRequestContainer{Assignment: assignment}) + aContainer, err := mutate[assignmentRequestContainer, assignmentContainer](api, http.MethodPut, fmt.Sprintf("assignments/%d", assignmentID), assignmentRequestContainer{Assignment: assignment}) + return aContainer.Assignment, err } // DeleteAssignment deletes an assignment on the Forecast account func (api *API) DeleteAssignment(assignmentID int) error { if assignmentID < 1 { - return fmt.Errorf("unable to DELETE assignment - assignment_id is not valid") + return fmt.Errorf("unable to delete assignment - assignment_id is not valid") } return mutateNoBody(api, http.MethodDelete, fmt.Sprintf("assignments/%d", assignmentID)) } diff --git a/assignment_test.go b/assignment_test.go index b0f3848..bba2025 100644 --- a/assignment_test.go +++ b/assignment_test.go @@ -226,7 +226,7 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { }) }) - when("PostAssignment", func() { + when("CreateAssignment", func() { assignmentRequest := forecast.AssignmentRequest{ StartDate: "2017-10-30", EndDate: "2017-11-30", @@ -242,12 +242,13 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { when("when a successful response is returned from the server", func() { it.Before(func() { - handler = httpHandler(http.StatusCreated, "") + handler = httpHandler(http.StatusCreated, assignmentTestFile) }) - it("should return a nil error", func() { - err := api.PostAssignment(assignmentRequest) + it("should return the created assignment", func() { + assignment, err := api.CreateAssignment(assignmentRequest) Expect(err).ShouldNot(HaveOccurred()) + Expect(assignment.ID).To(Equal(1234567)) }) }) @@ -257,7 +258,7 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { }) it("should return an error", func() { - err := api.PostAssignment(assignmentRequest) + _, err := api.CreateAssignment(assignmentRequest) Expect(err).Should(HaveOccurred()) }) }) @@ -266,13 +267,13 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { it("should return an error without calling the server", func() { invalid := assignmentRequest invalid.ProjectID = 0 - err := api.PostAssignment(invalid) + _, err := api.CreateAssignment(invalid) Expect(err).Should(HaveOccurred()) }) }) }) - when("PutAssignment", func() { + when("UpdateAssignment", func() { assignmentRequest := forecast.AssignmentRequest{ StartDate: "2017-10-30", EndDate: "2017-11-30", @@ -291,9 +292,10 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { handler = httpHandler(http.StatusOK, assignmentTestFile) }) - it("should return a nil error", func() { - err := api.PutAssignment(assignmentRequest, 1234567) + it("should return the updated assignment", func() { + assignment, err := api.UpdateAssignment(assignmentRequest, 1234567) Expect(err).ShouldNot(HaveOccurred()) + Expect(assignment.ID).To(Equal(1234567)) }) }) @@ -303,7 +305,7 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { }) it("should return an error", func() { - err := api.PutAssignment(assignmentRequest, 1234567) + _, err := api.UpdateAssignment(assignmentRequest, 1234567) Expect(err).Should(HaveOccurred()) }) }) @@ -312,14 +314,14 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { it("should return an error without calling the server", func() { invalid := assignmentRequest invalid.ProjectID = 0 - err := api.PutAssignment(invalid, 1234567) + _, err := api.UpdateAssignment(invalid, 1234567) Expect(err).Should(HaveOccurred()) }) }) when("when assignmentID is zero", func() { it("should return an error without calling the server", func() { - err := api.PutAssignment(assignmentRequest, 0) + _, err := api.UpdateAssignment(assignmentRequest, 0) Expect(err).Should(HaveOccurred()) }) }) diff --git a/testdata/assignment.json b/testdata/assignment.json index e502be6..e9929b6 100644 --- a/testdata/assignment.json +++ b/testdata/assignment.json @@ -1,6 +1,6 @@ { "assignment": { - "id": 1000001, + "id": 1234567, "start_date": "2017-10-30", "end_date": "2017-11-30", "allocation": null, @@ -13,4 +13,4 @@ "repeated_assignment_set_id": null, "active_on_days_off": false } -} \ No newline at end of file +}