Skip to content

Commit

Permalink
its a start
Browse files Browse the repository at this point in the history
  • Loading branch information
jacobmichels committed Sep 8, 2022
1 parent 83861d2 commit bfe8252
Show file tree
Hide file tree
Showing 12 changed files with 350 additions and 1 deletion.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
21 changes: 21 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -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"`
}
89 changes: 88 additions & 1 deletion coursesense.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions firestore/firestore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package firestore
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/jacobmichels/Course-Sense-Go

go 1.19

require github.com/julienschmidt/httprouter v1.3.0
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
1 change: 1 addition & 0 deletions notifier/smtp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package notifier
1 change: 1 addition & 0 deletions notifier/twilio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package notifier
41 changes: 41 additions & 0 deletions register/register.go
Original file line number Diff line number Diff line change
@@ -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
}
123 changes: 123 additions & 0 deletions server/server.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
64 changes: 64 additions & 0 deletions trigger/trigger.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions webadvisor/webadvisor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package webadvisor

0 comments on commit bfe8252

Please sign in to comment.