diff --git a/api.go b/api.go index 5d12313..7b1ba28 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,29 +47,79 @@ 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) + err = api.initClient() + if err != nil { + return result, err + } + r, err := api.client.Do(req) + if err != nil { + return result, err + } + defer r.Body.Close() + if r.StatusCode >= http.StatusBadRequest { + body, err := io.ReadAll(r.Body) if err != nil { return result, err } - api.client = &http.Client{ - Jar: jar, - } + + return result, fmt.Errorf("%s: %s", r.Status, string(body)) + } + + err = json.NewDecoder(r.Body).Decode(&result) + return result, err +} + +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 result, err + } + req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", api.URL, path), bytes.NewReader(b)) + if err != nil { + 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[TResult](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) + _, err = doRequest[struct{}](api, req) + return err +} + +func doRequest[T any](api *API, req *http.Request) (T, error) { + var result T + err := api.initClient() + if err != nil { + return result, err } r, err := api.client.Do(req) if err != nil { return result, err } + defer r.Body.Close() if r.StatusCode >= http.StatusBadRequest { body, err := io.ReadAll(r.Body) if err != nil { return result, err } - return result, fmt.Errorf("%s: %s", r.Status, string(body)) } 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 fbd5839..10a703f 100644 --- a/assignment.go +++ b/assignment.go @@ -2,13 +2,18 @@ package forecast import ( "encoding/csv" + "fmt" "io" + "net/http" "net/url" "strconv" "strings" "time" ) +type assignmentContainer struct { + Assignment Assignment `json:"assignment"` +} type assignmentsContainer struct { Assignments Assignments `json:"assignments"` } @@ -33,6 +38,24 @@ type Assignment struct { 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 { ProjectID int @@ -135,6 +158,36 @@ func (api *API) AssignmentsWithFilter(filter AssignmentFilter) (Assignments, err return container.Assignments, nil } +// CreateAssignment creates a new assignment on the Forecast account +// A PersonID of '0' will include "Everyone" +func (api *API) CreateAssignment(assignment AssignmentRequest) (Assignment, error) { + if assignment.ProjectID < 1 { + return Assignment{}, fmt.Errorf("unable to create assignment - project_id is not valid") + } + aContainer, err := mutate[assignmentRequestContainer, assignmentContainer](api, http.MethodPost, "assignments", assignmentRequestContainer{Assignment: assignment}) + return aContainer.Assignment, err +} + +// UpdateAssignment updates an existing assignment on the Forecast account +func (api *API) UpdateAssignment(assignment AssignmentRequest, assignmentID int) (Assignment, error) { + if assignment.ProjectID < 1 { + return Assignment{}, fmt.Errorf("unable to update assignment - project_id is not valid") + } + if assignmentID < 1 { + return Assignment{}, fmt.Errorf("unable to update assignment - assignment_id is not valid") + } + 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 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..bba2025 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,148 @@ func testAssignment(t *testing.T, when spec.G, it spec.S) { }) }) }) + + when("CreateAssignment", 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, assignmentTestFile) + }) + + it("should return the created assignment", func() { + assignment, err := api.CreateAssignment(assignmentRequest) + Expect(err).ShouldNot(HaveOccurred()) + Expect(assignment.ID).To(Equal(1234567)) + }) + }) + + 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.CreateAssignment(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.CreateAssignment(invalid) + Expect(err).Should(HaveOccurred()) + }) + }) + }) + + when("UpdateAssignment", 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 the updated assignment", func() { + assignment, err := api.UpdateAssignment(assignmentRequest, 1234567) + Expect(err).ShouldNot(HaveOccurred()) + Expect(assignment.ID).To(Equal(1234567)) + }) + }) + + 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.UpdateAssignment(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.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.UpdateAssignment(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..e9929b6 --- /dev/null +++ b/testdata/assignment.json @@ -0,0 +1,16 @@ +{ + "assignment": { + "id": 1234567, + "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 + } +}