Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 70 additions & 6 deletions api.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package forecast

import (
"bytes"
"encoding/json"
"fmt"
"io"
Expand All @@ -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)
Expand All @@ -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)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like we shouldn't be throwing away the body? Shouldn't a create return the created entity (which would include its ID)? And shouldn't an update return the updated entity? That implies that doRequest should really also be generic, so that you can unmarshal the body into the expected response type?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree, not including that was an oversight. Will fix this!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have addressed this in the latest commit

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
}
53 changes: 53 additions & 0 deletions assignment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading