Skip to content

Commit 5d57a72

Browse files
authored
feat(cms): Adding a projects-wide event service (#296)
* Added events service scaffold * updated environment variable * update codeowners * review changes * review changes
1 parent 5dc7bb2 commit 5d57a72

File tree

8 files changed

+292
-0
lines changed

8 files changed

+292
-0
lines changed

CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
backend/ @Varun-Sethu
22
postgres/ @Varun-Sethu
33
utilities/ @Varun-Sethu
4+
services/ @Varun-Sethu
45

56
frontend/ @fafnirZ
67
next/ @fafnirZ

services/events-service/Dockerfile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
FROM golang:1.19-alpine as app-builder
2+
WORKDIR /go/src/app
3+
COPY . .
4+
RUN apk add git
5+
# Static build required so that we can safely copy the binary over.
6+
# `-tags timetzdata` embeds zone info from the "time/tzdata" package.
7+
RUN CGO_ENABLED=0 go install -ldflags '-extldflags "-static"' -tags timetzdata
8+
9+
FROM scratch
10+
# the test program:
11+
COPY --from=app-builder /go/bin/events-service /events-service
12+
# the tls certificates:
13+
# NB: this pulls directly from the upstream image, which already has ca-certificates:
14+
COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
15+
ENTRYPOINT ["/events-service"]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
services:
2+
events-service:
3+
container_name: events-service
4+
build:
5+
context: .
6+
dockerfile: ./Dockerfile
7+
ports:
8+
- 8080:8080
9+
environment:
10+
- FB_TOKEN=please_replace_me

services/events-service/fetcher.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package main
2+
3+
// Mostly copied from: https://github.com/csesoc/csesoc.unsw.edu.au/blob/dev/backend/server/events/events.go
4+
5+
import (
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io/ioutil"
10+
"net/http"
11+
"os"
12+
"time"
13+
)
14+
15+
type (
16+
FbResponse struct {
17+
Data []FbRespEvent `json:"data"`
18+
Error FbRespError `json:"error"`
19+
}
20+
21+
// FbRespEvent - struct to unmarshal event specifics
22+
FbRespEvent struct {
23+
Description string `json:"description"`
24+
Name string `json:"name"`
25+
Start string `json:"start_time"`
26+
End string `json:"end_time"`
27+
ID string `json:"id"`
28+
Place FbRespPlace `json:"place"`
29+
IsCancelled bool `json:"is_cancelled"`
30+
IsOnline bool `json:"is_online"`
31+
Cover FbRespCover `json:"cover"`
32+
}
33+
34+
// FbRespError - struct to unmarshal any error response
35+
FbRespError struct {
36+
ErrorType int `json:"type"`
37+
Message string `json:"message"`
38+
}
39+
40+
// FbRespCover - struct to unmarshal the URI of the cover image
41+
FbRespCover struct {
42+
CoverURI string `json:"source"`
43+
}
44+
45+
// FbRespPlace - event location can come with added information, so we only take the name
46+
FbRespPlace struct {
47+
Name string `json:"name"`
48+
}
49+
50+
// MarshalledEvents - struct to pack up events with the last update time to be marshalled.
51+
MarshalledEvents struct {
52+
LastUpdate int64 `json:"updated"`
53+
Events []Event `json:"events"`
54+
}
55+
56+
// Event - struct to store an individual event with all the info we want
57+
Event struct {
58+
Name string `json:"name"`
59+
Description string `json:"description"`
60+
Start string `json:"start_time"`
61+
End string `json:"end_time"`
62+
ID string `json:"fb_event_id"`
63+
Place string `json:"place"`
64+
CoverURL string `json:"fb_cover_img"`
65+
}
66+
)
67+
68+
const (
69+
FBEventPath = "/csesoc/events"
70+
FBGraphAPIPath = "https://graph.facebook.com/v15.0"
71+
)
72+
73+
var FBAccessToken = os.Getenv("FB_TOKEN")
74+
75+
// fetchEvents just fetches directly from the FB api, much of this code is bonked from the old website
76+
// i wish go had monads :(
77+
func queryFbApi() (FbResponse, error) {
78+
eventsReq, err := http.Get(
79+
fmt.Sprintf(
80+
"%s%s?access_token=%s&since=%d",
81+
FBGraphAPIPath, FBEventPath, FBAccessToken, time.Now().Unix(),
82+
),
83+
)
84+
if err != nil {
85+
return FbResponse{}, fmt.Errorf("there was an error fetching events from FB: %w", err)
86+
}
87+
88+
defer eventsReq.Body.Close()
89+
if eventsReq.StatusCode != http.StatusOK {
90+
return FbResponse{}, fmt.Errorf("facebook api returned a http status: %d", eventsReq.StatusCode)
91+
}
92+
93+
unparsedEvents, readErr := ioutil.ReadAll(eventsReq.Body)
94+
if readErr != nil {
95+
return FbResponse{}, fmt.Errorf("failed to read response body %w", readErr)
96+
}
97+
98+
var apiResp FbResponse
99+
json.Unmarshal(unparsedEvents, &apiResp)
100+
if (apiResp.Error != FbRespError{}) {
101+
return FbResponse{}, errors.New("facebook api returned an error")
102+
}
103+
104+
return apiResp, nil
105+
}
106+
107+
// parseFbResponse takes a raw fb API response and converts it into a sequence of events
108+
func parseFbResponse(resp FbResponse) []Event {
109+
events := []Event{}
110+
111+
for _, event := range resp.Data {
112+
if !event.IsCancelled {
113+
parsedEvent := Event{
114+
Name: event.Name, Description: event.Description,
115+
Start: event.Start, End: event.End,
116+
ID: event.ID, Place: event.Place.Name,
117+
CoverURL: event.Cover.CoverURI,
118+
}
119+
120+
if event.IsOnline {
121+
parsedEvent.Place = "Online"
122+
}
123+
124+
events = append(events, parsedEvent)
125+
}
126+
}
127+
128+
return events
129+
}
130+
131+
// GetFBEvents returns a sequence of all CSESoc events
132+
func GetFBEvents() ([]Event, error) {
133+
if rawFbData, err := queryFbApi(); err != nil {
134+
return parseFbResponse(rawFbData), nil
135+
} else {
136+
return nil, err
137+
}
138+
}

services/events-service/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module events-service
2+
3+
go 1.18

services/events-service/main.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"net/http"
7+
"sync"
8+
"time"
9+
)
10+
11+
func main() {
12+
eventService := EventsService{
13+
cache: responseCache{
14+
cacheLock: sync.RWMutex{},
15+
expiryTime: time.Now(),
16+
},
17+
}
18+
19+
http.HandleFunc("/events-api/v1/get-all", func(w http.ResponseWriter, r *http.Request) {
20+
fmt.Fprint(w, string(eventService.FetchEvents()))
21+
})
22+
23+
// listen to port
24+
err := http.ListenAndServe(":8080", nil)
25+
if err != nil {
26+
log.Fatal(
27+
fmt.Errorf("failed to start server: %w", err))
28+
}
29+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"sync"
6+
"time"
7+
)
8+
9+
// general cache configuration
10+
const (
11+
entryLifespan = 10 * time.Minute
12+
)
13+
14+
// responseCache is maintains a cache of the responses returned by the FB API
15+
// entries are invalidated from the cache once the expiryTime has passed
16+
type responseCache struct {
17+
cachedResp []byte
18+
cacheLock sync.RWMutex
19+
expiryTime time.Time
20+
}
21+
22+
// HasExpired determines if the entry in the response cache is still valid
23+
func (rc *responseCache) HasExpired() bool {
24+
rc.cacheLock.RLock()
25+
defer rc.cacheLock.RUnlock()
26+
27+
return rc.expiryTime.Before(time.Now())
28+
}
29+
30+
// Read reads the value of the response cache and returns an error if the entry has expired
31+
func (rc *responseCache) Read() ([]byte, error) {
32+
rc.cacheLock.RLock()
33+
defer rc.cacheLock.RUnlock()
34+
35+
if rc.HasExpired() {
36+
return nil, errors.New("entry in the cached has expired")
37+
}
38+
39+
return rc.cachedResp, nil
40+
}
41+
42+
// RenewWith refreshes the contents of the cache with the new value and sets the expiry time
43+
// the expiry time is computed time.Now() + entryLifeSpan
44+
func (rc *responseCache) RenewWith(newResponse []byte) {
45+
rc.cacheLock.Lock()
46+
defer rc.cacheLock.Unlock()
47+
48+
rc.cachedResp = newResponse
49+
rc.expiryTime = time.Now().Add(entryLifespan)
50+
}
51+
52+
// Renew completely refreshes the expiry time for the value in the cache
53+
func (rc *responseCache) Renew() {
54+
rc.cacheLock.Lock()
55+
defer rc.cacheLock.Unlock()
56+
57+
rc.expiryTime = time.Now().Add(entryLifespan)
58+
}

services/events-service/service.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log"
7+
)
8+
9+
type EventsService struct {
10+
cache responseCache
11+
}
12+
13+
// FetchEvents reads from the FB API and process the data into a nice clean format
14+
// to be returned to the user :)
15+
func (service *EventsService) FetchEvents() []byte {
16+
if service.cache.HasExpired() {
17+
events, err := GetFBEvents()
18+
// if theres an error log it and renew the cache
19+
// keeping the old stale data
20+
if err != nil {
21+
log.Printf("Cache refresh service failed, error: %v", err)
22+
service.cache.Renew()
23+
} else {
24+
parsedJson, _ := json.Marshal(events)
25+
service.cache.RenewWith(parsedJson)
26+
}
27+
28+
}
29+
30+
resp, err := service.cache.Read()
31+
if err != nil {
32+
panic(fmt.Errorf(
33+
"fatal error, cache should not be empty at this point: %w", err,
34+
))
35+
}
36+
37+
return resp
38+
}

0 commit comments

Comments
 (0)