diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..21d13ab --- /dev/null +++ b/handler.go @@ -0,0 +1,17 @@ +package joist + +import ( + "fmt" + "net/http" +) + +type ( + HandlerFn func(w http.ResponseWriter, r *http.Request) error + MiddlewareFn func(HandlerFn) HandlerFn +) + +func (h HandlerFn) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := h(w, r); err != nil { + http.Error(w, fmt.Sprintf(`An internal error has occurred: %s`, err), http.StatusInternalServerError) + } +} diff --git a/interfaces.go b/interfaces.go new file mode 100644 index 0000000..e63123c --- /dev/null +++ b/interfaces.go @@ -0,0 +1,22 @@ +package joist + +import ( + "context" + "time" +) + +type Storer interface { + Close() error + Delete(ctx context.Context, key string) error + Get(ctx context.Context, key string) ([]byte, error) + GetForPrefix(ctx context.Context, prefix string) (map[string][]byte, error) + Put(ctx context.Context, key string, val []byte, ttl time.Duration) error +} + +type RouteCacher interface { + Add(name string, path string) error + AddRouteInfo(routeInfo RouteInfo) error + GetPath(name string) (string, error) + GetRouteInfo(name string) (RouteInfo, error) + All() (map[string]string, error) +} diff --git a/internal/badger.go b/internal/badger/badger.go similarity index 72% rename from internal/badger.go rename to internal/badger/badger.go index e694f4c..9f36eaf 100644 --- a/internal/badger.go +++ b/internal/badger/badger.go @@ -1,4 +1,4 @@ -package internal +package badger import ( "context" @@ -12,13 +12,6 @@ import ( const defaultBaseDir = "./data/badger" -type Storer interface { - Close() error - Delete(context.Context, string) error - Get(context.Context, string) ([]byte, error) - Put(context.Context, string, []byte, time.Duration) error -} - type BadgerOpts func(*badgerStore) func WithBaseDir(baseDir string) BadgerOpts { @@ -58,6 +51,48 @@ func (b *badgerStore) Put(ctx context.Context, key string, val []byte, ttl time. } } +func (b *badgerStore) GetForPrefix(ctx context.Context, prefix string) (map[string][]byte, error) { + done := make(chan struct { + val map[string][]byte + err error + }, 1) + + go func() { + valCopy := make(map[string][]byte) + err := b.db.View(func(txn *badger.Txn) error { + it := txn.NewIterator(badger.DefaultIteratorOptions) + defer it.Close() + prefix := []byte(prefix) + for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { + item := it.Item() + k := item.Key() + err := item.Value(func(v []byte) error { + valCopy[string(k)] = v + return nil + }) + if err != nil { + done <- struct { + val map[string][]byte + err error + }{make(map[string][]byte), err} + } + } + return nil + }) + done <- struct { + val map[string][]byte + err error + }{valCopy, err} + }() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case res := <-done: + return res.val, nil + } +} + func (b *badgerStore) Get(ctx context.Context, key string) ([]byte, error) { done := make(chan struct { val []byte diff --git a/internal/badger_test.go b/internal/badger/badger_test.go similarity index 99% rename from internal/badger_test.go rename to internal/badger/badger_test.go index 331b6e9..123f730 100644 --- a/internal/badger_test.go +++ b/internal/badger/badger_test.go @@ -1,4 +1,4 @@ -package internal +package badger import ( "context" diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 7e331c1..e89e786 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -6,18 +6,22 @@ import ( "path/filepath" ) -var ErrCacheUninitialized = errors.New("the route cache is uninitialized") +var ( + ErrCacheUninitialized = errors.New("the route cache is uninitialized") + ErrFailedToCreateRouteCache = errors.New("failed to create route cache") + ErrRouterNotCreated = errors.New("router not created") +) type ErrRouteNotFound struct { - n string + route string +} + +func NewErrRouteNotFound(route string) error { + return &ErrRouteNotFound{route: route} } func (e ErrRouteNotFound) Error() string { - return "route not found: " + string(e.n) -} - -func NewErrRouteNotFound(n string) error { - return &ErrRouteNotFound{n: n} + return "route not found: " + string(e.route) } type ErrBadgerInit struct { @@ -25,6 +29,10 @@ type ErrBadgerInit struct { subDir string } +func NewErrBadgerInit(baseDir, subDir string) error { + return &ErrBadgerInit{baseDir: baseDir, subDir: subDir} +} + func (e ErrBadgerInit) Error() string { if e.subDir == "" { return fmt.Sprintf("failed to initialize badger store at base directory: %s", e.baseDir) @@ -33,6 +41,19 @@ func (e ErrBadgerInit) Error() string { return fmt.Sprintf("failed to initialize badger store at directory: %s", filepath.Join(e.baseDir, e.subDir)) } -func NewErrBadgerInit(baseDir, subDir string) error { - return &ErrBadgerInit{baseDir: baseDir, subDir: subDir} +type WrappedErr struct { + e error + with error +} + +func Wrap(e, with error) WrappedErr { + return WrappedErr{e: e, with: with} +} + +func (e WrappedErr) Error() string { + return fmt.Sprintf(`%s: %s`, e.e, e.with) +} + +func (e WrappedErr) Unwrap() error { + return e.e } diff --git a/internal/route_cache.go b/internal/route_cache.go deleted file mode 100644 index 9524140..0000000 --- a/internal/route_cache.go +++ /dev/null @@ -1,135 +0,0 @@ -package internal - -import ( - "context" - "fmt" - "log" - "net/http" - "regexp" - "strings" - "sync" - "time" - - ee "git.markbailey.dev/cerbervs/joist/internal/errors" - "github.com/go-chi/chi/v5" -) - -const suff = "route_cache" - -type RouteCacher interface { - Store(string, string) error - GetPath(string) (string, error) - All() (map[string]string, error) -} - -type routeCache struct { - rx chi.Router - rs map[string]string - s Storer -} - -var ( - rcLock sync.Mutex - rcOnce sync.Once - c *routeCache -) - -func NewRouteCache(store Storer, router chi.Router) (*routeCache, error) { - rcOnce.Do(func() { - if store == nil { - st, err := NewBadgerStore(WithSubDir("route_cache")) - if err != nil { - log.Println(err) - return - } - - store = st - } - - c = &routeCache{ - rx: router, - rs: make(map[string]string), - s: store, - } - }) - - if c == nil { - return nil, ee.ErrCacheUninitialized - } - - rcLock.Lock() - defer rcLock.Unlock() - - return c, nil -} - -func (r *routeCache) All() (map[string]string, error) { - if r.rs == nil { - return nil, ee.ErrCacheUninitialized - } - return r.rs, nil -} - -func (r *routeCache) Store(name string, path string) error { - return nil -} - -func (r *routeCache) GetPath(name string) (string, error) { - if path, ok := r.rs[name]; ok { - return path, nil - } - - return string(""), ee.NewErrRouteNotFound(string(name)) -} - -func (r *routeCache) UnmarshallFromStore() error { - if err := chi.Walk(r.rx, walker(r.s)); err != nil { - return err - } - - return nil -} - -func walker(store Storer) chi.WalkFunc { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - return func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { - routeName, err := convertRouteToRouteName(route) - if err != nil { - return fmt.Errorf(`failed to name route "%s"`, route) - } - - if routeName[len(routeName)-2:] != ".*" { - route = regexp.MustCompile(`:.*}`).ReplaceAllString(route, "}") - if len(route) > 1 { - route = strings.TrimRight(route, "/") - } - store.Put(ctx, routeName, []byte(route), 1*time.Hour) - } - - return nil - } -} - -func convertRouteToRouteName(route string) (string, error) { - name := "app" - if route == "/" { - return name + ".home", nil - } - - route = strings.ReplaceAll(route, "/", ".") - route = strings.ReplaceAll(route, "-", "_") - - paramRegex := regexp.MustCompile(`\.{[^}]*}`) - params := paramRegex.FindAllString(route, -1) - route = paramRegex.ReplaceAllString(route, "") - - if len(params) > 0 { - route += ".params" - } - - name += strings.TrimRight(route, ".") - - return name, nil -} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 0000000..c8f4b9f --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,7 @@ +package middleware + +import "net/http" + +type Handler func(w http.ResponseWriter, r *http.Request) error + +type Middleware func(Handler) Handler diff --git a/route_cache.go b/route_cache.go new file mode 100644 index 0000000..662b55b --- /dev/null +++ b/route_cache.go @@ -0,0 +1,150 @@ +package joist + +import ( + "context" + "errors" + "log" + "sync" + "time" + + "git.markbailey.dev/cerbervs/joist/internal/badger" + jerr "git.markbailey.dev/cerbervs/joist/internal/errors" +) + +type routeCache struct { + rs map[string]string + ris map[string]*RouteInfo + s Storer + ttl time.Duration +} + +type RouteCacheOpt func(*routeCache) + +var ( + rcOnce sync.Once + cache *routeCache +) + +func WithTTL(ttl time.Duration) RouteCacheOpt { + return func(r *routeCache) { + r.ttl = ttl + } +} + +func WithStore(store Storer) RouteCacheOpt { + return func(r *routeCache) { + r.s = store + } +} + +func NewRouteCacheService(opts ...RouteCacheOpt) (*routeCache, error) { + rcOnce.Do(func() { + c, err := newRouteCache(opts...) + if err != nil { + log.Fatal(err) + } + + cache = c + }) + + if cache == nil { + return nil, jerr.ErrCacheUninitialized + } + + return cache, nil +} + +func (r *routeCache) All() (map[string]string, error) { + if r.rs == nil { + return nil, jerr.ErrCacheUninitialized + } + return r.rs, nil +} + +func (r *routeCache) Add(name string, path string) error { + ttl := r.ttl + if ttl == 0 { + ttl = 1 * time.Hour + } + + if err := r.s.Put(context.Background(), name, []byte(path), ttl); err != nil { + return err + } + + r.rs[name] = path + + return nil +} + +func (r *routeCache) GetPath(name string) (string, error) { + if path, ok := r.rs[name]; ok { + return path, nil + } + + if path, err := r.s.Get(context.Background(), name); err == nil { + return string(path), nil + } + + return "", errors.New("route not found in cache") +} + +func (r *routeCache) AddRouteInfo(routeInfo RouteInfo) error { + ttl := r.ttl + if ttl == 0 { + ttl = 1 * time.Hour + } + + if err := r.s.Put(context.Background(), routeInfo.Name+":method", []byte(routeInfo.Method), ttl); err != nil { + return err + } + if err := r.s.Put(context.Background(), routeInfo.Name+":path", []byte(routeInfo.Path), ttl); err != nil { + return err + } + if err := r.s.Put(context.Background(), routeInfo.Name+":description", []byte(routeInfo.Description), ttl); err != nil { + return err + } + if err := r.s.Put(context.Background(), routeInfo.Name+":title", []byte(routeInfo.Title), ttl); err != nil { + return err + } + + r.rs[routeInfo.Name] = routeInfo.Path + r.ris[routeInfo.Name] = &routeInfo + + return nil +} + +func (r *routeCache) GetRouteInfo(name string) (RouteInfo, error) { + if ri, ok := r.ris[name]; ok { + return *ri, nil + } + + if info, err := r.s.GetForPrefix(context.Background(), name); err == nil { + return RouteInfo{ + Method: string(info[":method"]), + Name: name, + Path: string(info[":path"]), + Description: string(info[":description"]), + Title: string(info[":title"]), + }, nil + } + + return RouteInfo{}, errors.New("route info not found in cache") +} + +func newRouteCache(opts ...RouteCacheOpt) (*routeCache, error) { + s, err := badger.NewBadgerStore(badger.WithSubDir("route_cache")) + if err != nil { + return nil, jerr.Wrap(jerr.ErrFailedToCreateRouteCache, err) + } + + cache := &routeCache{ + rs: make(map[string]string), + s: s, + } + + for _, opt := range opts { + opt(cache) + } + + return cache, nil +} diff --git a/internal/route_cache_test.go b/route_cache_test.go similarity index 82% rename from internal/route_cache_test.go rename to route_cache_test.go index fccad65..1b633d2 100644 --- a/internal/route_cache_test.go +++ b/route_cache_test.go @@ -1,4 +1,4 @@ -package internal +package joist import "testing" diff --git a/routing.go b/routing.go new file mode 100644 index 0000000..80887b6 --- /dev/null +++ b/routing.go @@ -0,0 +1,92 @@ +package joist + +import ( + "context" + "log" + "net/http" + "regexp" + "strings" + "time" + + jerr "git.markbailey.dev/cerbervs/joist/internal/errors" + "github.com/go-chi/chi/v5" +) + +type RouteInfo struct { + Method string + Name string + Path string + Description string + Title string +} + +func NewRouteInfo(method, path, description, title string) (RouteInfo, error) { + name, err := convertRouteToRouteName(path) + if err != nil { + return RouteInfo{}, err + } + + return RouteInfo{ + Method: method, + Name: name, + Path: path, + Description: description, + Title: title, + }, nil +} + +type routeInfoKey struct{} + +type Router struct { + chi.Mux + cache RouteCacher + cacheTTL time.Duration +} + +func NewRouter() Router { + ttl := 8 * time.Hour + + cache, err := NewRouteCacheService(WithTTL(ttl)) + if err != nil { + log.Fatal(jerr.Wrap(err, jerr.ErrRouterNotCreated)) + } + + return Router{cache: cache, cacheTTL: ttl} +} + +func (ro *Router) WithRouteInfo(ri RouteInfo, h HandlerFn) http.HandlerFunc { + ro.cache.AddRouteInfo(ri) + + return func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), routeInfoKey{}, ri) + h.ServeHTTP(w, r.WithContext(ctx)) + } +} + +// TODO: move to base handler when it's implemented +func GetRouteInfo(r *http.Request) (RouteInfo, bool) { + info, ok := r.Context().Value(routeInfoKey{}).(RouteInfo) + return info, ok +} + +func convertRouteToRouteName(route string) (string, error) { + name := "app" + if route == "/" { + return name + ".home", nil + } + + route = strings.ReplaceAll(route, "/", ".") + route = strings.ReplaceAll(route, "-", "_") + + paramRegex := regexp.MustCompile(`\.{[^}]*}`) + params := paramRegex.FindAllString(route, -1) + route = paramRegex.ReplaceAllString(route, "") + + if len(params) > 0 { + route += ".params" + } + + name += strings.TrimRight(route, ".") + + return name, nil +}