Skip to content

Commit

Permalink
SPIN1305: Add custom date range queries (#7)
Browse files Browse the repository at this point in the history
* add route for cost-explorer date range for GET, expand key for cache to enable multiple date range objects to be cached.

* add comment to add date range unit test, updated README, etc.

* updates, added input cleansing, and good API errors when input is bad

* added endpoint querystring for StartTime, EndTime to readme

* changes per PR review

* cleaned up logic per PR

* cleaned up debug statements

* clean up time to string conversion

* refactored new code into smaller functions, built unit tests for new code

* sort out unwrapping time errors back to client caller, adding tests for dates

* updated with PR suggestions

* nit

* moar nits
  • Loading branch information
damnski authored Nov 18, 2019
1 parent 349b6b2 commit fecaf4b
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 13 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ GET /v1/cost/version
GET /v1/cost/metrics
GET /v1/cost/{account}/spaces/{spaceid}
GET /v1/cost/{account}/spaces/{spaceid}[?StartTime=2019-10-01&EndTime=2019-10-30]
```

## Usage
Expand Down
89 changes: 76 additions & 13 deletions api/handlers_spaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,59 @@ import (
log "github.com/sirupsen/logrus"
)

// getTimeDefault returns time range from beginning of month to day-of-month now
func getTimeDefault() (string, string) {
// if it's the first day of the month, get today's usage thus far
y, m, d := time.Now().Date()
if d == 1 {
d = 3
}
return fmt.Sprintf("%d-%02d-01", y, m), fmt.Sprintf("%d-%02d-%02d", y, m, d)
}

// getTimeAPI returns time parsed from API input
func getTimeAPI(startTime, endTime string) (string, string, error) {
log.Debugf("startTime: %s, endTime: %s ", startTime, endTime)

// sTmp and eTmp temporary vars to hold time.Time objects
sTmp, err := time.Parse("2006-01-02", startTime)
if err != nil {
return "", "", errors.Wrapf(err, "error parsing StartTime from input")
}

eTmp, err := time.Parse("2006-01-02", endTime)
if err != nil {
return "", "", errors.Wrapf(err, "error parsing EndTime from input")
}

// if time on the API input is already borked, don't continue
// end time is greater than start time, logically
timeValidity := eTmp.After(sTmp)
if !timeValidity {
return "", "", errors.Errorf("endTime should be greater than startTime")
}

// convert time.Time to a string
return sTmp.Format("2006-01-02"), eTmp.Format("2006-01-02"), nil
}

// SpaceGetHandler gets the cost for a space, grouped by the service. By default,
// it pulls data from the start of the month until now.
func (s *server) SpaceGetHandler(w http.ResponseWriter, r *http.Request) {
w = LogWriter{w}

// loop thru and log given API input vars in debug
vars := mux.Vars(r)
for k, v := range vars {
log.Debugf("key=%v, value=%v", k, v)
}

// get vars from the API route
account := vars["account"]
startTime := vars["StartTime"]
endTime := vars["EndTime"]
spaceID := vars["space"]

ceService, ok := s.costExplorerServices[account]
if !ok {
msg := fmt.Sprintf("cost explorer service not found for account: %s", account)
Expand All @@ -37,15 +84,24 @@ func (s *server) SpaceGetHandler(w http.ResponseWriter, r *http.Request) {
}
log.Debugf("found cost explorer result cache %+v", *resultCache)

spaceID := vars["space"]
log.Debugf("getting costs for space %s", spaceID)

y, m, d := time.Now().Date()
// vars for checking input times parse and are valid
var start string
var end string
var err error

// if it's the first day of the month, get todays usage thus far
if d == 1 {
d = 2
// Did we get cost-explorer start and end times on the API?
// set defaults, else verify times given on API
if endTime == "" || startTime == "" {
log.Debug("no start or end time given on API input, assigning defaults")
start, end = getTimeDefault()
} else {
start, end, err = getTimeAPI(startTime, endTime)
if err != nil {
handleError(w, err)
return
}
}

input := costexplorer.GetCostAndUsageInput{
Filter: &costexplorer.Expression{
And: []*costexplorer.Expression{
Expand Down Expand Up @@ -108,27 +164,33 @@ func (s *server) SpaceGetHandler(w http.ResponseWriter, r *http.Request) {
aws.String("USAGE_QUANTITY"),
},
TimePeriod: &costexplorer.DateInterval{
End: aws.String(fmt.Sprintf("%d-%02d-%02d", y, m, d)),
Start: aws.String(fmt.Sprintf("%d-%02d-01", y, m)),
Start: aws.String(start),
End: aws.String(end),
},
}

// create a cacheKey more unique than spaceID for managing cache objects.
// Since we will accept date-range cost exploring, concatenate the spaceID
// and the start and end time so we can cache each time-based result
cacheKey := fmt.Sprintf("%s_%s_%s", spaceID, startTime, endTime)
log.Debugf("cacheKey: %s", cacheKey)

// the object is not found in the cache, call AWS cost-explorer and set cache
var out []*costexplorer.ResultByTime
c, expire, ok := resultCache.GetWithExpiration(spaceID)
c, expire, ok := resultCache.GetWithExpiration(cacheKey)
if !ok || c == nil {
log.Debugf("cache empty for org, space: %s, %s, calling cost-explorer", Org, spaceID)
log.Debugf("cache empty for org, and space-cacheKey: %s, %s, calling cost-explorer", Org, cacheKey)
// call cost-explorer
var err error
out, err = ceService.GetCostAndUsage(r.Context(), &input)
if err != nil {
msg := fmt.Sprintf("failed to get costs for space %s: %s", spaceID, err.Error())
msg := fmt.Sprintf("failed to get costs for space %s: %s", cacheKey, err.Error())
handleError(w, errors.Wrap(err, msg))
return
}

// cache results
resultCache.SetDefault(spaceID, out)
resultCache.SetDefault(cacheKey, out)
} else {
// cached object was found
out = c.([]*costexplorer.ResultByTime)
Expand All @@ -147,4 +209,5 @@ func (s *server) SpaceGetHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(j)

}
110 changes: 110 additions & 0 deletions api/handlers_spaces_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package api

import (
"fmt"
"testing"
"time"
)

func TestGetTimeDefault(t *testing.T) {
// use defaults derived in code
startTime, endTime := getTimeDefault()

// tests should match defaults from getTimeDefault
y, m, d := time.Now().Date()
if d == 1 {
d = 5
}

sTime := fmt.Sprintf("%d-%02d-01", y, m)
eTime := fmt.Sprintf("%d-%02d-%02d", y, m, d)

if startTime == sTime {
t.Logf("got expected default sTime: %s\n", sTime)
} else {
t.Errorf("got unexpected sTime: %s\n", sTime)
}
if endTime == eTime {
t.Logf("got expected default eTime: %s\n", eTime)
} else {
t.Errorf("got unexpected eTime: %s\n", eTime)
}

// negative tests for non-matching defaults from getTimeDefault
sTime = fmt.Sprint("2006-01-02")
eTime = fmt.Sprint("2006-13-40")

if startTime != sTime {
t.Logf("negative test sTime: %s does not match: %s", sTime, startTime)
} else {
t.Errorf("got unexpected sTime: %s\n", sTime)
}
if endTime != eTime {
t.Logf("negative test eTime: %s does not match: %s", eTime, endTime)
} else {
t.Errorf("got unexpected eTime: %s\n", eTime)
}

}

func TestGetTimeAPI(t *testing.T) {
startTime := "2019-11-01"
endTime := "2019-11-30"

startResult, endResult, err := getTimeAPI(startTime, endTime)
if err != nil {
t.Errorf("got unexpected error: %s", err)
}
if startResult == startTime {
t.Logf("got expected startResult from getTimeAPI: %s", startResult)
}
if endResult == endTime {
t.Logf("got expected endResult from getTimeAPI: %s", endResult)
}

// negative tests for non-matching API inputs from getTimeDefault
// bad start time fails
sTime := fmt.Sprint("2006-01-022")
eTime := fmt.Sprint("2006-12-02")

neg00startResult, neg00endResult, err := getTimeAPI(sTime, eTime)
if err != nil {
t.Logf("negative test got expected error: %s", err)
}
if neg00startResult == startTime {
t.Logf("negative test expected neg00_startResult from getTimeAPI: %s", neg00startResult)
}
if neg00endResult == endTime {
t.Logf("negative test got expected neg00_endResult from getTimeAPI: %s", neg00endResult)
}

// bad end time fails
sTime = fmt.Sprint("2006-01-02")
eTime = fmt.Sprint("2006-12-403")

neg01startResult, neg01endResult, err := getTimeAPI(sTime, eTime)
if err != nil {
t.Logf("negative test got expected error: %s", err)
}
if neg01startResult == "" {
t.Logf("negative test expected empty neg01startResult: %s", neg01startResult)
}
if neg01endResult == endTime {
t.Logf("negative test got expected neg01endResult from getTimeAPI: %s", neg01endResult)
}

// start after end fails
sTime = fmt.Sprint("2006-01-30")
eTime = fmt.Sprint("2006-01-01")

neg02startResult, neg02endResult, err := getTimeAPI(sTime, eTime)
if err != nil {
t.Logf("negative test got expected error for start after end : %s", err)
}
if neg02startResult == startTime {
t.Logf("negative test expected neg02startResult from getTimeAPI: %s", neg02startResult)
}
if neg02endResult == endTime {
t.Logf("negative test got expected neg02endResult from getTimeAPI: %s", neg02endResult)
}
}
3 changes: 3 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@ func (s *server) routes() {
api.HandleFunc("/version", s.VersionHandler).Methods(http.MethodGet)
api.Handle("/metrics", promhttp.Handler()).Methods(http.MethodGet)

api.HandleFunc("/{account}/spaces/{space}", s.SpaceGetHandler).
Queries("EndTime", "{EndTime}", "StartTime", "{StartTime}").
Methods(http.MethodGet)
api.HandleFunc("/{account}/spaces/{space}", s.SpaceGetHandler).Methods(http.MethodGet)
}
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5i
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
Expand Down

0 comments on commit fecaf4b

Please sign in to comment.