Skip to content

Commit 9faa6bd

Browse files
atyeatye
andauthored
updates (#139)
Co-authored-by: atye <[email protected]>
1 parent 136507b commit 9faa6bd

File tree

7 files changed

+261
-32
lines changed

7 files changed

+261
-32
lines changed

cmd/main.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"time"
1515

1616
"github.com/atye/wikitable2json/internal/server"
17+
"github.com/atye/wikitable2json/internal/server/metrics"
1718
"github.com/atye/wikitable2json/pkg/client"
1819
)
1920

@@ -43,14 +44,27 @@ func main() {
4344
cacheExpiration = defaultCacheExpiration
4445
}
4546

47+
googleMeasurementId := os.Getenv("GOOGLE_MEASUREMENT_ID")
48+
googleAPISecret := os.Getenv("GOOGLE_API_SECRET")
49+
50+
httpClient := &http.Client{Timeout: 10 * time.Second}
51+
52+
var mp server.MetricsPublisher
53+
mp = metrics.NewNoOpClient()
54+
if googleMeasurementId != "" && googleAPISecret != "" {
55+
mp = metrics.NewGoogleClient(googleMeasurementId, googleAPISecret, httpClient)
56+
}
57+
4658
dist, err := fs.Sub(swagger, "static/dist")
4759
if err != nil {
4860
handleErr(err)
4961
}
5062

5163
mux := http.NewServeMux()
5264
mux.Handle("GET /", http.StripPrefix("/", http.FileServer(http.FS(dist))))
53-
mux.Handle("GET /api/{page}", server.HeaderMW(server.NewServer(client.NewClient("", client.WithHTTPClient(&http.Client{Timeout: 10 * time.Second})), server.NewCache(cacheSize, cacheExpiration))))
65+
mux.Handle("GET /api/{page}",
66+
server.HeaderMW(server.RequestValidationAndMetricsMW(
67+
server.NewServer(client.NewClient("", client.WithHTTPClient(httpClient)), server.NewCache(cacheSize, cacheExpiration)), mp)))
5468
svr := &http.Server{
5569
Addr: fmt.Sprintf(":%s", port),
5670
Handler: mux,
@@ -73,7 +87,7 @@ func main() {
7387
defer cancel()
7488
err := svr.Shutdown(ctx)
7589
if err != nil {
76-
log.Printf("main: shutting down server: %v", err)
90+
log.Printf("main: shutting down server: %v\n", err)
7791
_ = svr.Close()
7892
}
7993
}

internal/server/metrics/google.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package metrics
2+
3+
import (
4+
"bytes"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"encoding/json"
8+
"fmt"
9+
"net/http"
10+
)
11+
12+
var (
13+
apiURL = "https://www.google-analytics.com/mp/collect?measurement_id=%s&api_secret=%s"
14+
)
15+
16+
type GoogleClient struct {
17+
measurementID string
18+
apiSecret string
19+
httpClient *http.Client
20+
}
21+
22+
type gaEvent struct {
23+
ClientID string `json:"client_id"`
24+
Events []event `json:"events"`
25+
}
26+
27+
type event struct {
28+
Name string `json:"name"`
29+
Params map[string]interface{} `json:"params"`
30+
}
31+
32+
func NewGoogleClient(measurementID, apiSecret string, client *http.Client) *GoogleClient {
33+
return &GoogleClient{
34+
measurementID: measurementID,
35+
apiSecret: apiSecret,
36+
httpClient: client,
37+
}
38+
}
39+
40+
func (c *GoogleClient) Publish(code int, ip, page, lang string) error {
41+
hash := sha256.Sum256([]byte(ip))
42+
43+
event := gaEvent{
44+
ClientID: hex.EncodeToString(hash[:]),
45+
Events: []event{
46+
{
47+
Name: "page_request",
48+
Params: map[string]interface{}{
49+
"page": page,
50+
"lang": lang,
51+
"code": code,
52+
},
53+
},
54+
},
55+
}
56+
57+
body, err := json.Marshal(event)
58+
if err != nil {
59+
return err
60+
}
61+
62+
// https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference#response_codes
63+
// The Measurement Protocol always returns a 2xx status code if the HTTP request was received.
64+
// The Measurement Protocol does not return an error code if the payload data was malformed, or if the data in the payload was incorrect or was not processed by Google Analytics.
65+
_, err = c.httpClient.Post(fmt.Sprintf(apiURL, c.measurementID, c.apiSecret), "application/json", bytes.NewBuffer(body))
66+
return err
67+
}

internal/server/metrics/no-op.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package metrics
2+
3+
type NoOpClient struct{}
4+
5+
func NewNoOpClient() NoOpClient {
6+
return NoOpClient{}
7+
}
8+
9+
func (NoOpClient) Publish(code int, ip, page, lang string) error { return nil }

internal/server/middleware.go

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
package server
22

3-
import "net/http"
3+
import (
4+
"context"
5+
"log"
6+
"net/http"
7+
8+
"github.com/atye/wikitable2json/pkg/client/status"
9+
)
10+
11+
type contextKey string
12+
13+
var (
14+
pageKey contextKey = "page"
15+
queryKey contextKey = "query"
16+
)
417

518
func HeaderMW(next http.Handler) http.Handler {
619
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -9,3 +22,48 @@ func HeaderMW(next http.Handler) http.Handler {
922
next.ServeHTTP(w, r)
1023
})
1124
}
25+
26+
type MetricsPublisher interface {
27+
Publish(code int, ip string, page string, lang string) error
28+
}
29+
30+
func RequestValidationAndMetricsMW(main http.Handler, mp MetricsPublisher) http.Handler {
31+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32+
page := r.PathValue("page")
33+
if page == "" {
34+
writeError(w, status.NewStatus("page value must be supplied in /api/{page}", http.StatusBadRequest))
35+
return
36+
}
37+
38+
qv, err := parseParameters(r)
39+
if err != nil {
40+
writeError(w, err)
41+
return
42+
}
43+
44+
ctx := r.Context()
45+
ctx = context.WithValue(ctx, pageKey, page)
46+
ctx = context.WithValue(ctx, queryKey, qv)
47+
r = r.WithContext(ctx)
48+
49+
rec := &statusRecorder{ResponseWriter: w, Status: http.StatusOK}
50+
main.ServeHTTP(rec, r)
51+
52+
go func() {
53+
err := mp.Publish(rec.Status, r.RemoteAddr, page, qv.lang)
54+
if err != nil {
55+
log.Printf("publishing metric: %v\n", err)
56+
}
57+
}()
58+
})
59+
}
60+
61+
type statusRecorder struct {
62+
http.ResponseWriter
63+
Status int
64+
}
65+
66+
func (rec *statusRecorder) WriteHeader(code int) {
67+
rec.Status = code
68+
rec.ResponseWriter.WriteHeader(code)
69+
}

internal/server/middleware_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package server
22

33
import (
4+
"context"
45
"net/http"
56
"net/http/httptest"
7+
"sync"
68
"testing"
9+
"time"
710
)
811

912
func TestHeaderMW(t *testing.T) {
@@ -23,3 +26,81 @@ func TestHeaderMW(t *testing.T) {
2326
t.Errorf("expected *, got %s", w.Header().Get("Content-Type"))
2427
}
2528
}
29+
func TestRequestValidationAndMetricsMW(t *testing.T) {
30+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
31+
32+
mp := &mockPublisher{}
33+
sut := RequestValidationAndMetricsMW(handler, mp)
34+
35+
w := httptest.NewRecorder()
36+
r := httptest.NewRequest(http.MethodGet, "http://test.com/api/page?verbose=true", nil)
37+
r.SetPathValue("page", "page")
38+
sut.ServeHTTP(w, r)
39+
40+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
41+
defer cancel()
42+
43+
loop:
44+
for {
45+
select {
46+
case <-ctx.Done():
47+
t.Errorf("timed out waiting for publish to be called")
48+
default:
49+
if !mp.getPublishedCalled() {
50+
time.Sleep(500 * time.Millisecond)
51+
continue
52+
}
53+
break loop
54+
}
55+
}
56+
}
57+
58+
func TestRequestValidationAndMetricsMWEmptyPage(t *testing.T) {
59+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
60+
61+
mp := &mockPublisher{}
62+
sut := RequestValidationAndMetricsMW(handler, mp)
63+
64+
w := httptest.NewRecorder()
65+
r := httptest.NewRequest(http.MethodGet, "http://test.com/api?verbose=true", nil)
66+
r.SetPathValue("page", "")
67+
sut.ServeHTTP(w, r)
68+
69+
if w.Code != http.StatusBadRequest {
70+
t.Errorf("expected %d, got %d", http.StatusBadRequest, w.Code)
71+
}
72+
}
73+
74+
func TestRequestValidationAndMetricsMWBadParameter(t *testing.T) {
75+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
76+
77+
mp := &mockPublisher{}
78+
sut := RequestValidationAndMetricsMW(handler, mp)
79+
80+
w := httptest.NewRecorder()
81+
r := httptest.NewRequest(http.MethodGet, "http://test.com/api/page?table=x", nil)
82+
r.SetPathValue("page", "page")
83+
sut.ServeHTTP(w, r)
84+
85+
if w.Code != http.StatusBadRequest {
86+
t.Errorf("expected %d, got %d", http.StatusBadRequest, w.Code)
87+
}
88+
}
89+
90+
type mockPublisher struct {
91+
publishCalled bool
92+
lock sync.Mutex
93+
}
94+
95+
func (m *mockPublisher) Publish(code int, ip string, page string, lang string) error {
96+
m.lock.Lock()
97+
defer m.lock.Unlock()
98+
m.publishCalled = true
99+
return nil
100+
}
101+
102+
func (m *mockPublisher) getPublishedCalled() bool {
103+
m.lock.Lock()
104+
defer m.lock.Unlock()
105+
return m.publishCalled
106+
}

internal/server/server.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,17 @@ func NewServer(client TableGetter, cache *Cache) *Server {
4141

4242
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
4343
ctx := r.Context()
44+
var err error
4445

45-
page := r.PathValue("page")
46-
if page == "" {
47-
writeError(w, status.NewStatus("page value must be supplied in /api/{page}", http.StatusBadRequest))
46+
page, ok := ctx.Value(pageKey).(string)
47+
if !ok {
48+
writeError(w, status.NewStatus("something went wrong. no page in request context", http.StatusInternalServerError))
4849
return
4950
}
5051

51-
qv, err := parseParameters(r)
52-
if err != nil {
53-
writeError(w, err)
52+
qv, ok := ctx.Value(queryKey).(queryValues)
53+
if !ok {
54+
writeError(w, status.NewStatus("something went wrong. no query values in request context", http.StatusInternalServerError))
5455
return
5556
}
5657

0 commit comments

Comments
 (0)