From bfe8252eab28188782b28e5641a47d75e8fb8339 Mon Sep 17 00:00:00 2001 From: Jacob Michels Date: Wed, 7 Sep 2022 22:25:56 -0400 Subject: [PATCH] its a start --- README.md | 2 + config/config.go | 21 +++++++ coursesense.go | 89 +++++++++++++++++++++++++++- firestore/firestore.go | 1 + go.mod | 5 ++ go.sum | 2 + notifier/smtp.go | 1 + notifier/twilio.go | 1 + register/register.go | 41 +++++++++++++ server/server.go | 123 +++++++++++++++++++++++++++++++++++++++ trigger/trigger.go | 64 ++++++++++++++++++++ webadvisor/webadvisor.go | 1 + 12 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 config/config.go create mode 100644 firestore/firestore.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 notifier/smtp.go create mode 100644 notifier/twilio.go create mode 100644 register/register.go create mode 100644 server/server.go create mode 100644 trigger/trigger.go create mode 100644 webadvisor/webadvisor.go diff --git a/README.md b/README.md index 6000559..65d5bf8 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,5 @@ The University of Guelph updated their course selection system, so Course Sense This is a stateless Go service intended to be run in a serverless environment (I use Cloud Run) but a VM would work too. ## Project layout + +This project follows [Ben Johnson's Standard Project Layout](https://www.gobeyond.dev/standard-package-layout/) diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..b7b1ba8 --- /dev/null +++ b/config/config.go @@ -0,0 +1,21 @@ +package config + +type Config struct { + Firestore struct { + ProjectID string `mapstructure:"project_id"` + CredentialsFilePath string `mapstructure:"credentials_file"` + CollectionID string `mapstructure:"collection_id"` + } `mapstructure:"firestore"` + Twilio struct { + AccountSID string `mapstructure:"account_sid"` + AuthToken string `mapstructure:"auth_token"` + PhoneNumber string `mapstructure:"phone_number"` + } `mapstructure:"twilio"` + Smtp struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + From string `mapstructure:"from"` + } `mapstructure:"smtp"` +} diff --git a/coursesense.go b/coursesense.go index 9cb2418..aafc7f5 100644 --- a/coursesense.go +++ b/coursesense.go @@ -1,3 +1,90 @@ package coursesense -// +import ( + "context" + "errors" + "fmt" +) + +// Domain types are defined in this file + +type Course struct { + Department string `json:"department"` + Code int `json:"code"` +} + +func (c Course) Valid() error { + if c.Department == "" { + return errors.New("Department cannot be empty") + } + if c.Code <= 999 && c.Code >= 9999 { + return errors.New("Course code must be 4 digits") + } + + return nil +} + +type Section struct { + Course Course `json:"course"` + Code string `json:"code"` + Term string `json:"term"` +} + +func (s Section) Valid() error { + if s.Code == "" { + return errors.New("Section code cannot be empty") + } + if s.Term == "" { + return errors.New("Term cannot be empty") + } + return s.Course.Valid() +} + +func (s Section) String() string { + return fmt.Sprintf("%s*%d*%s*%s", s.Course.Department, s.Course.Code, s.Code, s.Term) +} + +// Service that gets information on course sections +type SectionService interface { + Exists(context.Context, Section) (bool, error) + GetAvailableSeats(context.Context, Section) (uint, error) +} + +// A user registered for notifications on a Section +type Watcher struct { + Email string `json:"email"` + Phone string `json:"phone"` +} + +func (w Watcher) Valid() error { + if w.Email == "" && w.Phone == "" { + return errors.New("At least one contact method needs to be present") + } + + return nil +} + +func (w Watcher) String() string { + return fmt.Sprintf("%s:%s", w.Email, w.Phone) +} + +// Service that manages Watchers +type WatcherService interface { + AddWatcher(context.Context, Section, Watcher) error + GetWatchedSections(context.Context) ([]Section, error) + GetWatchers(context.Context, Section) ([]Watcher, error) + RemoveWatchers(context.Context, Section) error +} + +// A type that sends can send notifications to Watchers +type Notifier interface { + Notify(context.Context, Section, ...Watcher) error +} + +type TriggerService interface { + Trigger(context.Context) error +} + +type RegistrationService interface { + Register(context.Context, Section, Watcher) error +} diff --git a/firestore/firestore.go b/firestore/firestore.go new file mode 100644 index 0000000..dfca601 --- /dev/null +++ b/firestore/firestore.go @@ -0,0 +1 @@ +package firestore diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..775787f --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/jacobmichels/Course-Sense-Go + +go 1.19 + +require github.com/julienschmidt/httprouter v1.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..096c54e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= diff --git a/notifier/smtp.go b/notifier/smtp.go new file mode 100644 index 0000000..ed45f23 --- /dev/null +++ b/notifier/smtp.go @@ -0,0 +1 @@ +package notifier diff --git a/notifier/twilio.go b/notifier/twilio.go new file mode 100644 index 0000000..ed45f23 --- /dev/null +++ b/notifier/twilio.go @@ -0,0 +1 @@ +package notifier diff --git a/register/register.go b/register/register.go new file mode 100644 index 0000000..0e026e6 --- /dev/null +++ b/register/register.go @@ -0,0 +1,41 @@ +package register + +import ( + "context" + "fmt" + + coursesense "github.com/jacobmichels/Course-Sense-Go" +) + +// Register implements RegistrationService +var _ coursesense.RegistrationService = Register{} + +type Register struct { + sectionService coursesense.SectionService + watcherService coursesense.WatcherService +} + +func NewRegister(s coursesense.SectionService, w coursesense.WatcherService) Register { + return Register{s, w} +} + +func (r Register) Register(ctx context.Context, section coursesense.Section, watcher coursesense.Watcher) error { + // Registration steps + // 1. Ensure the section exists + // 2. Use the watcher service to persist the watcher to the section + + exists, err := r.sectionService.Exists(ctx, section) + if err != nil { + return fmt.Errorf("failed to check if section exists: %w", err) + } + + if !exists { + return fmt.Errorf("section %s does not exist", section) + } + + if err := r.watcherService.AddWatcher(ctx, section, watcher); err != nil { + return fmt.Errorf("failed to persist %s to %s", watcher, section) + } + + return nil +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..4f903d7 --- /dev/null +++ b/server/server.go @@ -0,0 +1,123 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "time" + + coursesense "github.com/jacobmichels/Course-Sense-Go" + "github.com/julienschmidt/httprouter" +) + +type Server struct { + registrationService coursesense.RegistrationService + triggerService coursesense.TriggerService + addr string +} + +func NewServer(addr string, r coursesense.RegistrationService, t coursesense.TriggerService) Server { + return Server{r, t, addr} +} + +func (s Server) Start(ctx context.Context) error { + r := httprouter.New() + + // register routes + r.GET("/ping", s.pingHandler()) + r.GET("/trigger", s.triggerHandler()) + r.PUT("/register", s.registerHandler()) + + srv := http.Server{Addr: s.addr, Handler: r} + log.Printf("listening on addr %s", s.addr) + + // start server, respecting context cancelation + errChan := make(chan error) + go func() { errChan <- srv.ListenAndServe() }() + select { + case err := <-errChan: + if !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("server error: %w", err) + } + case <-ctx.Done(): + log.Println("gracefully shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("server shutdown error: %w", err) + } + log.Println("server shutdown complete") + } + + return nil +} + +func (s Server) pingHandler() httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + log.Println("Ping request received") + + if _, err := w.Write([]byte("OK")); err != nil { + log.Printf("Error writing ping response: %s", err) + } + } +} + +func (s Server) triggerHandler() httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + log.Println("Trigger request receieved") + + if err := s.triggerService.Trigger(r.Context()); err != nil { + log.Printf("trigger service failed: %s", err) + http.Error(w, "Trigger failed internally, please try again later. If error persists please contact service owner", http.StatusInternalServerError) + return + } + + log.Println("Trigger request succeeded") + } +} + +type RegisterRequest struct { + Section coursesense.Section `json:"section"` + Watcher coursesense.Watcher `json:"watcher"` +} + +func (r RegisterRequest) Valid() error { + err := r.Section.Valid() + if err != nil { + return err + } + + return r.Watcher.Valid() +} + +func (s Server) registerHandler() httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + log.Println("Register request receieved") + + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("error decoding register request: %s", err) + http.Error(w, "Failed to parse request", http.StatusBadRequest) + return + } + + if err := req.Valid(); err != nil { + log.Printf("register request invalid: %s", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := s.registrationService.Register(r.Context(), req.Section, req.Watcher); err != nil { + log.Printf("registration failed: %s", err) + http.Error(w, "Registration failed internally, please try again later. If error persists please contact service owner", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + log.Println("Register request succeeded") + } +} diff --git a/trigger/trigger.go b/trigger/trigger.go new file mode 100644 index 0000000..0b1dacb --- /dev/null +++ b/trigger/trigger.go @@ -0,0 +1,64 @@ +package trigger + +import ( + "context" + "fmt" + "log" + + coursesense "github.com/jacobmichels/Course-Sense-Go" +) + +var _ coursesense.TriggerService = Trigger{} + +type Trigger struct { + sectionService coursesense.SectionService + watcherService coursesense.WatcherService + notifiers []coursesense.Notifier +} + +func NewTrigger(s coursesense.SectionService, w coursesense.WatcherService, n ...coursesense.Notifier) Trigger { + return Trigger{s, w, n} +} + +func (t Trigger) Trigger(ctx context.Context) error { + // Trigger steps + // 1. Get all watched sections from the watcher service + // 2. Loop over the sections, checking the available capacity on each + // 3. If availability is found, use the notifiers to notify the watchers for that section, then remove said watchers once successfully notified + + sections, err := t.watcherService.GetWatchedSections(ctx) + if err != nil { + return fmt.Errorf("failed to get watched sections: %w", err) + } + + for _, section := range sections { + available, err := t.sectionService.GetAvailableSeats(ctx, section) + if err != nil { + return fmt.Errorf("failed to get available seats for %s: %w", section, err) + } + + if available == 0 { + continue + } + + log.Printf("%d available seats found for %s", available, section) + + watchers, err := t.watcherService.GetWatchers(ctx, section) + if err != nil { + return fmt.Errorf("failed to get watchers for %s: %w", section, err) + } + + for _, notifier := range t.notifiers { + err := notifier.Notify(ctx, section, watchers...) + if err != nil { + return fmt.Errorf("failed to notify watchers for %s: %w", section, err) + } + } + + if err := t.watcherService.RemoveWatchers(ctx, section); err != nil { + return fmt.Errorf("failed to remove watchers from %s: %w", section, err) + } + } + + return nil +} diff --git a/webadvisor/webadvisor.go b/webadvisor/webadvisor.go new file mode 100644 index 0000000..a5b79fc --- /dev/null +++ b/webadvisor/webadvisor.go @@ -0,0 +1 @@ +package webadvisor