Skip to content

Commit

Permalink
Add most popular fetching function
Browse files Browse the repository at this point in the history
  • Loading branch information
earthboundkid committed Apr 24, 2020
1 parent 324bfdd commit fd30159
Show file tree
Hide file tree
Showing 9 changed files with 570 additions and 2 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,5 @@ dist
.forestry

# Local Netlify folder
.netlify
.netlify
functions/
12 changes: 12 additions & 0 deletions cmd/spotlightpa-api/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package main

import (
"os"

"github.com/carlmjohnson/exitcode"
"github.com/spotlightpa/poor-richard/pkg/api"
)

func main() {
exitcode.Exit(api.CLI(os.Args[1:]))
}
15 changes: 15 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module github.com/spotlightpa/poor-richard

go 1.14

require (
github.com/aws/aws-lambda-go v1.16.0 // indirect
github.com/carlmjohnson/exitcode v0.0.4
github.com/carlmjohnson/flagext v0.0.11
github.com/getsentry/sentry-go v0.6.0
github.com/peterbourgon/ff/v2 v2.0.0
github.com/piotrkubisa/apigo v2.0.0+incompatible
github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 // indirect
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
)
198 changes: 198 additions & 0 deletions go.sum

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion netlify.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[build]
base = ""
command = "yarn build:prod"
functions = "functions"
publish = "public"

[build.environment]
Expand All @@ -10,6 +11,8 @@ HUGO_ENV = "production"
HUGO_ENABLEGITINFO = "true"
NODE_ENV = "production"
NODE_VERSION = "12"
GO_IMPORT_PATH = "github.com/carlmjohnson/netlify-go-function-demo"
GO111MODULE = "on"

[context.deploy-preview]
command = "yarn build:stage"
Expand Down Expand Up @@ -43,8 +46,12 @@ for = "/@src/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable" # 1 year


[[headers]]
for = "/*.(woff|woff2)"
[headers.values]
Access-Control-Allow-Origin = "*"

[[redirects]]
from = "/api/*"
to = "/.netlify/functions/spotlightpa-api/:splat"
status = 200
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"format": "yarn run format:eslint; yarn run format:prettier",
"format:eslint": "eslint --fix --ext .js,.vue --ignore-path .gitignore .",
"format:prettier": "prettier --ignore-path .gitignore --write **/**/*.{scss,js,yml,vue}",
"build:funcs": "bash -c 'GOBIN=\"$PWD/functions\" go install ./...'",
"build:parcel": "parcel build src/entrypoints/* --experimental-scope-hoisting",
"build:stage": "yarn run build:parcel && hugo version && hugo --minify --environment staging --baseURL ${DEPLOY_PRIME_URL:-https://www.spotlightpa.org}",
"build:prod": "yarn run build:parcel && hugo version && hugo",
Expand Down
106 changes: 106 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package api

import (
"flag"
"fmt"
"log"
"net/http"
"os"
"time"

"github.com/carlmjohnson/flagext"
"github.com/getsentry/sentry-go"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/peterbourgon/ff/v2"
"github.com/piotrkubisa/apigo"
)

const AppName = "spotlightpa-api"

var BuildVersion string = "Development"

func CLI(args []string) error {
var app appEnv
if err := app.parseArgs(args); err != nil {
fmt.Fprintf(os.Stderr, "Startup error: %v\n", err)
return err
}
if err := app.exec(); err != nil {
fmt.Fprintf(os.Stderr, "Runtime error: %v\n", err)
return err
}
return nil
}

func (app *appEnv) parseArgs(args []string) error {
fl := flag.NewFlagSet(AppName, flag.ContinueOnError)
app.Logger = log.New(nil, AppName+" ", log.LstdFlags)
flagext.LoggerVar(fl, app.Logger, "silent", flagext.LogSilent, "silence logging")

fl.BoolVar(&app.isLambda, "lambda", false, "use AWS Lambda rather than HTTP")
fl.StringVar(&app.port, "port", ":12345", "listen on port (HTTP only)")
sentryDSN := fl.String("sentry-dsn", "", "DSN `pseudo-URL` for Sentry")
fl.StringVar(&app.googleCreds, "google-creds", "", "JSON credentials for Google")
fl.StringVar(&app.viewID, "view-id", "", "view ID for Google Analytics")

fl.Usage = func() {
fmt.Fprintf(fl.Output(), "spotlightpa-api help\n\n")
fl.PrintDefaults()
}
if err := ff.Parse(fl, args, ff.WithEnvVarPrefix("POOR_RICHARD")); err != nil {
return err
}

if err := app.initSentry(*sentryDSN, app.Logger); err != nil {
return err
}

return nil
}

type appEnv struct {
port string
isLambda bool
*log.Logger
googleCreds string
viewID string
}

func (app *appEnv) exec() error {
app.Printf("starting %s (%s)", AppName, BuildVersion)
routes := sentryhttp.
New(sentryhttp.Options{
WaitForDelivery: true,
Timeout: 5 * time.Second,
}).
Handle(app.routes())

if app.isLambda {
app.Printf("starting on AWS Lambda")
apigo.ListenAndServe("", routes)
panic("unreachable")
}

app.Printf("starting on port %s", app.port)

return http.ListenAndServe(app.port, routes)
}

func (app *appEnv) initSentry(dsn string, l *log.Logger) error {
var transport sentry.Transport
if app.isLambda {
l.Printf("setting sentry sync with timeout")
transport = &sentry.HTTPSyncTransport{Timeout: 5 * time.Second}
}
return sentry.Init(sentry.ClientOptions{
Dsn: dsn,
Release: BuildVersion,
Transport: transport,
})
}

func (app *appEnv) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/most-popular", app.getMostPopular)
return app.versionMiddleware(mux)
}
179 changes: 179 additions & 0 deletions pkg/api/google-analytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package api

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"

"golang.org/x/net/context/ctxhttp"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)

func (app *appEnv) getMostPopular(w http.ResponseWriter, r *http.Request) {
type response struct {
Pages []string `json:"pages"`
}
var (
resp response
err error
)
resp.Pages, err = getMostPopular(r.Context(), app.googleCreds, app.viewID)
if err != nil {
app.errorResponse(r.Context(), w, err)
return
}
w.Header().Set("Cache-Control", "public, max-age=300")
w.Header().Set("Access-Control-Allow-Origin", "*")
app.jsonResponse(http.StatusOK, w, &resp)
}

func getMostPopular(ctx context.Context, jsonGoogleCredentials, viewID string) ([]string, error) {
var (
client *http.Client
err error
)
if len(jsonGoogleCredentials) == 0 {
client, err = google.DefaultClient(ctx, "https://www.googleapis.com/auth/analytics.readonly")
} else {
creds, errShdw := google.CredentialsFromJSON(
ctx,
[]byte(jsonGoogleCredentials),
"https://www.googleapis.com/auth/analytics.readonly",
)
client = oauth2.NewClient(oauth2.NoContext, creds.TokenSource)
err = errShdw
}
if err != nil {
return nil, err
}

var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.Encode(&AnalyticsRequest{
ReportRequests: []ReportRequest{{
ViewID: viewID,
Metrics: []Metric{{
Expression: "ga:uniquePageviews",
}},
Dimensions: []Dimension{{
Name: "ga:pagePath",
}},
DateRanges: []DateRange{{
StartDate: "2daysAgo",
EndDate: "yesterday",
}},
OrderBys: []OrderBy{{
FieldName: "ga:uniquePageviews",
SortOrder: "DESCENDING",
}},
FiltersExpression: `ga:pagePath=~^/news/\d\d\d\d`,
PageSize: 20,
}},
})

resp, err := ctxhttp.Post(
ctx,
client,
"https://analyticsreporting.googleapis.com/v4/reports:batchGet",
"application/json",
&buf,
)
if err != nil {
return nil, err
}
defer resp.Body.Close()

var data AnalyticsResponse
dec := json.NewDecoder(resp.Body)
if err = dec.Decode(&data); err != nil {
return nil, err
}
if len(data.Reports) != 1 {
return nil, fmt.Errorf("got bad report length: %d", len(data.Reports))
}
pages := make([]string, 0, len(data.Reports[0].Data.Rows))
for _, row := range data.Reports[0].Data.Rows {
if len(row.Dimensions) != 1 {
return nil, fmt.Errorf("got bad row length: %d", len(row.Dimensions))
}
pages = append(pages, row.Dimensions[0])
}
// todo normalize their crap data
return pages, nil
}

type AnalyticsRequest struct {
ReportRequests []ReportRequest `json:"reportRequests"`
}

type ReportRequest struct {
ViewID string `json:"viewId"`
DateRanges []DateRange `json:"dateRanges"`
Dimensions []Dimension `json:"dimensions"`
Metrics []Metric `json:"metrics"`
FiltersExpression string `json:"filtersExpression"`
OrderBys []OrderBy `json:"orderBys"`
PageSize int `json:"pageSize"`
PageToken string `json:"pageToken"`
}

type DateRange struct {
EndDate string `json:"endDate"`
StartDate string `json:"startDate"`
}

type Dimension struct {
Name string `json:"name"`
}

type Metric struct {
Expression string `json:"expression"`
}

type OrderBy struct {
FieldName string `json:"fieldName"`
SortOrder string `json:"sortOrder"`
}

type AnalyticsResponse struct {
Reports []Report `json:"reports"`
}

type Report struct {
ColumnHeader ColumnHeader `json:"columnHeader"`
Data Data `json:"data"`
}

type Data struct {
Rows []Row `json:"rows"`
Totals []Values `json:"totals"`
RowCount int `json:"rowCount"`
Minimums []Values `json:"minimums"`
Maximums []Values `json:"maximums"`
}

type MetricHeaderEntry struct {
Name string `json:"name"`
Type string `json:"type"`
}

type MetricHeader struct {
MetricHeaderEntries []MetricHeaderEntry `json:"metricHeaderEntries"`
}

type ColumnHeader struct {
Dimensions []string `json:"dimensions"`
MetricHeader MetricHeader `json:"metricHeader"`
}

type Values struct {
Values []string `json:"values"`
}

type Row struct {
Dimensions []string `json:"dimensions"`
Metrics []Values `json:"metrics"`
}
Loading

0 comments on commit fd30159

Please sign in to comment.