Add status handler to ensure we kick out API servers that are blocked
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
80306bfe85
commit
ede215888a
1
go.mod
1
go.mod
@ -3,6 +3,7 @@ module git.meow.tf/ow-api/ow-api
|
|||||||
go 1.12
|
go 1.12
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.5.1-0.20190109230704-3dcf72e6c17f
|
||||||
github.com/go-redis/redis v6.14.2+incompatible
|
github.com/go-redis/redis v6.14.2+incompatible
|
||||||
github.com/julienschmidt/httprouter v1.2.0
|
github.com/julienschmidt/httprouter v1.2.0
|
||||||
github.com/onsi/ginkgo v1.8.0 // indirect
|
github.com/onsi/ginkgo v1.8.0 // indirect
|
||||||
|
60
main.go
60
main.go
@ -16,7 +16,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Version = "2.0.10"
|
Version = "2.0.11"
|
||||||
|
|
||||||
OpAdd = "add"
|
OpAdd = "add"
|
||||||
OpRemove = "remove"
|
OpRemove = "remove"
|
||||||
@ -78,9 +78,12 @@ func main() {
|
|||||||
router := httprouter.New()
|
router := httprouter.New()
|
||||||
|
|
||||||
// PC
|
// PC
|
||||||
router.GET("/v1/stats/pc/:region/:tag/heroes/:heroes", heroes)
|
router.GET("/v1/stats/pc/:region/:tag/heroes/:heroes", injectPlatform("pc", heroes))
|
||||||
router.GET("/v1/stats/pc/:region/:tag/profile", profile)
|
router.GET("/v1/stats/pc/:region/:tag/profile", injectPlatform("pc", profile))
|
||||||
router.GET("/v1/stats/pc/:region/:tag/complete", stats)
|
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
|
// Console
|
||||||
router.GET("/v1/stats/psn/:tag/heroes/:heroes", injectPlatform("psn", heroes))
|
router.GET("/v1/stats/psn/:tag/heroes/:heroes", injectPlatform("psn", heroes))
|
||||||
@ -93,6 +96,8 @@ func main() {
|
|||||||
// Version
|
// Version
|
||||||
router.GET("/v1/version", versionHandler)
|
router.GET("/v1/version", versionHandler)
|
||||||
|
|
||||||
|
router.GET("/v1/status", statusHandler)
|
||||||
|
|
||||||
c := cors.New(cors.Options{
|
c := cors.New(cors.Options{
|
||||||
AllowedOrigins: []string{"*"},
|
AllowedOrigins: []string{"*"},
|
||||||
})
|
})
|
||||||
@ -259,7 +264,7 @@ func stats(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|||||||
data, err := statsResponse(w, ps, nil)
|
data, err := statsResponse(w, ps, nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error())
|
writeError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,7 +288,7 @@ func profile(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|||||||
data, err := statsResponse(w, ps, profilePatch)
|
data, err := statsResponse(w, ps, profilePatch)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error())
|
writeError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,7 +304,7 @@ func heroes(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|||||||
|
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,7 +338,7 @@ func heroes(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
writeError(w, err.Error())
|
writeError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,7 +346,7 @@ func heroes(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
|||||||
data, err := statsResponse(w, ps, patch)
|
data, err := statsResponse(w, ps, patch)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error())
|
writeError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -386,7 +391,34 @@ func versionHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(&versionObject{Version: Version}); err != nil {
|
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"`
|
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")
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
22
validator/error.go
Normal file
22
validator/error.go
Normal 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
203
validator/validator.go
Normal 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
|
||||||
|
}
|
11
validator/validator_test.go
Normal file
11
validator/validator_test.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func Test_Validator(t *testing.T) {
|
||||||
|
err := ValidateEndpoint()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user