Add status handler to ensure we kick out API servers that are blocked
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Tyler 2019-06-12 19:03:38 -04:00
parent 80306bfe85
commit ede215888a
5 changed files with 285 additions and 12 deletions

1
go.mod
View File

@ -3,6 +3,7 @@ module git.meow.tf/ow-api/ow-api
go 1.12
require (
github.com/PuerkitoBio/goquery v1.5.1-0.20190109230704-3dcf72e6c17f
github.com/go-redis/redis v6.14.2+incompatible
github.com/julienschmidt/httprouter v1.2.0
github.com/onsi/ginkgo v1.8.0 // indirect

60
main.go
View File

@ -16,7 +16,7 @@ import (
)
const (
Version = "2.0.10"
Version = "2.0.11"
OpAdd = "add"
OpRemove = "remove"
@ -78,9 +78,12 @@ func main() {
router := httprouter.New()
// PC
router.GET("/v1/stats/pc/:region/:tag/heroes/:heroes", heroes)
router.GET("/v1/stats/pc/:region/:tag/profile", profile)
router.GET("/v1/stats/pc/:region/:tag/complete", stats)
router.GET("/v1/stats/pc/:region/:tag/heroes/:heroes", injectPlatform("pc", heroes))
router.GET("/v1/stats/pc/:region/:tag/profile", injectPlatform("pc", profile))
router.GET("/v1/stats/pc/:region/:tag/complete", injectPlatform("pc", stats))
router.GET("/v1/stats/pc/:tag/heroes/:heroes", injectPlatform("pc", heroes))
router.GET("/v1/stats/pc/:tag/profile", injectPlatform("pc", profile))
router.GET("/v1/stats/pc/:tag/complete", injectPlatform("pc", stats))
// Console
router.GET("/v1/stats/psn/:tag/heroes/:heroes", injectPlatform("psn", heroes))
@ -93,6 +96,8 @@ func main() {
// Version
router.GET("/v1/version", versionHandler)
router.GET("/v1/status", statusHandler)
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
})
@ -259,7 +264,7 @@ func stats(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
data, err := statsResponse(w, ps, nil)
if err != nil {
writeError(w, err.Error())
writeError(w, err)
return
}
@ -283,7 +288,7 @@ func profile(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
data, err := statsResponse(w, ps, profilePatch)
if err != nil {
writeError(w, err.Error())
writeError(w, err)
return
}
@ -299,7 +304,7 @@ func heroes(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if len(names) == 0 {
w.WriteHeader(http.StatusBadRequest)
writeError(w, "Name list must contain at least one hero")
writeError(w, errors.New("name list must contain at least one hero"))
return
}
@ -333,7 +338,7 @@ func heroes(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if err != nil {
w.WriteHeader(http.StatusBadRequest)
writeError(w, err.Error())
writeError(w, err)
return
}
@ -341,7 +346,7 @@ func heroes(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
data, err := statsResponse(w, ps, patch)
if err != nil {
writeError(w, err.Error())
writeError(w, err)
return
}
@ -386,7 +391,34 @@ func versionHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(&versionObject{Version: Version}); err != nil {
writeError(w, err.Error())
writeError(w, err)
}
}
type statusObject struct {
ResponseCode int `json:"responseCode"`
Error string `json:"error,omitempty"`
}
func statusHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
status := &statusObject{}
res, err := http.DefaultClient.Head("https://playoverwatch.com")
if err == nil {
status.ResponseCode = res.StatusCode
if res.StatusCode != http.StatusOK {
w.WriteHeader(res.StatusCode)
}
} else {
status.Error = err.Error()
}
if err := json.NewEncoder(w).Encode(status); err != nil {
writeError(w, err)
}
}
@ -394,10 +426,14 @@ type errorObject struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, err string) {
func writeError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(&errorObject{Error: err}); err != nil {
if err == ovrstat.ErrPlayerNotFound {
w.WriteHeader(http.StatusNotFound)
}
if err := json.NewEncoder(w).Encode(&errorObject{Error: err.Error()}); err != nil {
return
}
}

22
validator/error.go Normal file
View File

@ -0,0 +1,22 @@
package validator
func NewSelectorError(message, parent, selector string) *parentSelectorError {
return &parentSelectorError{message, parent, selector}
}
// errorString is a trivial implementation of error.
type parentSelectorError struct {
message, parent, selector string
}
func (e *parentSelectorError) Error() string {
str := e.message + ": "
if e.parent != "" {
str += e.parent + ","
}
str += e.selector
return str
}

203
validator/validator.go Normal file
View File

@ -0,0 +1,203 @@
package validator
import (
"encoding/json"
"errors"
"github.com/PuerkitoBio/goquery"
"log"
"net/http"
"regexp"
)
const (
baseURL = "https://playoverwatch.com/en-us/career"
apiURL = "https://playoverwatch.com/en-us/career/platforms/"
)
type Platform struct {
Platform string `json:"platform"`
ID int `json:"id"`
Name string `json:"name"`
URLName string `json:"urlName"`
PlayerLevel int `json:"playerLevel"`
Portrait string `json:"portrait"`
IsPublic bool `json:"isPublic"`
}
var (
careerInitRegexp = regexp.MustCompile(`window\.app\.career\.init\((\d+)\,`)
// Response/server errors
errInvalidResponse = errors.New("error querying the response")
errUnexpectedStatus = errors.New("unexpected status code")
errRequestBlocked = errors.New("request blocked")
errNoApiResponse = errors.New("unable to contact api endpoint")
errApiInvalidJson = errors.New("invalid json response")
errApiUnexpectedStatus = errors.New("unexpected status code from api")
// Invalid/missing elements
errNoMasthead = errors.New("unable to find masthead")
errNoCareerInit = errors.New("no career init call found")
// Element definitions
mastheadElements = []string{
"img.player-portrait",
"div.player-level div.u-vertical-center",
"div.player-level",
"div.player-rank",
"div.endorsement-level div.u-center",
"div.EndorsementIcon",
"div.masthead p.masthead-detail.h4 span",
}
)
func ValidateEndpoint() error {
url := baseURL + "/pc/cats-11481"
res, err := http.Get(url)
if err != nil {
return errInvalidResponse
}
if res.StatusCode == http.StatusForbidden {
return errRequestBlocked
} else if res.StatusCode != http.StatusOK {
return errUnexpectedStatus
}
defer res.Body.Close()
pd, err := goquery.NewDocumentFromReader(res.Body)
if err != nil {
return errInvalidResponse
}
masthead := pd.Find("div.masthead")
if masthead.Length() < 1 {
return errNoMasthead
}
// Validate masthead elements
if err := validateElementsExist(masthead.First(), "div.masthead", mastheadElements...); err != nil {
return err
}
code, err := pd.Html()
if err != nil {
return err
}
split := careerInitRegexp.FindStringSubmatch(code)
if split == nil || len(split) < 2 {
return errNoCareerInit
}
// Validate API response
if err := validateApi(split[1]); err != nil {
return err
}
// Validate specific elements
if err := validateElementsExist(pd.Selection, "", "div#quickplay", "div#competitive"); err != nil {
return err
}
if err := validateDetailedStats(pd.Find("div#quickplay").First(), "div#quickplay"); err != nil {
return err
}
if err := validateDetailedStats(pd.Find("div#competitive").First(), "div#competitive"); err != nil {
return err
}
return nil
}
func validateDetailedStats(s *goquery.Selection, parent string) error {
if err := validateElementsExist(s, parent, "div.progress-category", "div.js-stats"); err != nil {
return err
}
var err error
// Validate hero stats
err = validateHeroStats(s.Find("div.progress-category").Parent(), parent)
if err != nil {
log.Println("No hero stats: " + err.Error())
return err
}
return validateCareerStats(s.Find("div.js-stats").Parent(), parent)
}
func validateHeroStats(s *goquery.Selection, parent string) error {
selectors := []string{
"div.progress-category", // Top level
"div.progress-category div.ProgressBar", // ProgressBar
"div.progress-category div.ProgressBar div.ProgressBar-title", // ProgressBar Title
"div.progress-category div.ProgressBar div.ProgressBar-description", // ProgressBar Description
}
return validateElementsExist(s, parent, selectors...)
}
func validateCareerStats(careerStatsSelector *goquery.Selection, parent string) error {
if err := validateElementsExist(careerStatsSelector, parent, "select option"); err != nil {
return err
}
selectors := []string{
"div.row div.js-stats", // Top level
"div.row div.js-stats div.column.xs-12", // stat boxes
"div.row div.js-stats div.column.xs-12 .stat-title", // stat boxes
"div.row div.js-stats div.column.xs-12 table.DataTable", // data table
"div.row div.js-stats div.column.xs-12 table.DataTable tbody", // data table tbody
"div.row div.js-stats div.column.xs-12 table.DataTable tbody tr", // data table tbody tr
"div.row div.js-stats div.column.xs-12 table.DataTable tbody tr td", // data table tbody tr td
}
return validateElementsExist(careerStatsSelector, parent, selectors...)
}
func validateApi(code string) error {
var platforms []Platform
apires, err := http.Get(apiURL + code)
if err != nil {
return errNoApiResponse
}
defer apires.Body.Close()
if apires.StatusCode != http.StatusOK {
return errApiUnexpectedStatus
}
if err := json.NewDecoder(apires.Body).Decode(&platforms); err != nil {
return errApiInvalidJson
}
return nil
}
func validateElementsExist(s *goquery.Selection, parent string, elements ...string) error {
var e *goquery.Selection
for _, selector := range elements {
e = s.Find(selector)
if e.Length() < 1 {
return errors.New("unable to find element: " + parent + " " + selector)
}
}
return nil
}

View File

@ -0,0 +1,11 @@
package validator
import "testing"
func Test_Validator(t *testing.T) {
err := ValidateEndpoint()
if err != nil {
t.Fatal(err)
}
}