diff --git a/go.mod b/go.mod index 50b93b5..7b3884e 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,15 @@ module github.com/pyr33x/proxy go 1.25.4 +require ( + github.com/joho/godotenv v1.5.1 + github.com/pyr33x/envy v0.4.2 + github.com/redis/go-redis/v9 v9.17.0 + go.uber.org/zap v1.27.1 +) + require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/pyr33x/envy v0.4.2 // indirect - github.com/redis/go-redis/v9 v9.17.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.1 // indirect ) diff --git a/go.sum b/go.sum index 512e3e2..8d45f51 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,34 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pyr33x/envy v0.4.2 h1:wwi3Fa5r3XvE/CzWVC6ENPpg2cBCI/4obP9wBOfAkXU= github.com/pyr33x/envy v0.4.2/go.mod h1:ZbCBVojzFK0McudtLwBZtQWRtm+4B9kj7nrCr/fQd/A= github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM= github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cache/cache.go b/internal/cache/cache.go index b0f59d7..45a6e85 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -2,7 +2,9 @@ package cache import ( "context" + "encoding/json" "fmt" + "net/http" "time" "github.com/pyr33x/proxy/pkg/err" @@ -10,64 +12,83 @@ import ( "go.uber.org/zap" ) -type Cache interface { +type Caching interface { Get(ctx context.Context, key string) string Put(ctx context.Context, key string, value any) error + Clear(ctx context.Context) error } -type cache struct { +type Cache struct { rdb *redis.Client - sugar *zap.SugaredLogger + logger *zap.Logger expiration time.Duration } -func NewCacheRepository(rdb *redis.Client, logger *zap.Logger) *cache { - return &cache{ +type CacheValue struct { + Status int + Header http.Header + Body []byte +} + +func NewCacheRepository(rdb *redis.Client, logger *zap.Logger) *Cache { + return &Cache{ rdb: rdb, - sugar: logger.Sugar(), + logger: logger, expiration: 60 * time.Second, } } -func (c *cache) Get(ctx context.Context, key string) (string, bool) { +func (c *Cache) Get(ctx context.Context, key string) (*CacheValue, bool) { if key == "" { - c.sugar.Warn("attempted to get cache with empty key") - return "", false + c.logger.Warn("attempted to get cache with empty key") + return nil, false } - res, err := c.rdb.Get(ctx, key).Result() + raw, err := c.rdb.Get(ctx, key).Bytes() if err != nil { - if err == redis.Nil { - c.sugar.Warn("cache miss", - "key", key, - ) - return "", false - } - - c.sugar.Error("failed to read from cache", - "key", key, - "error", err, + c.logger.Info("cache miss", + zap.String("key", key), + zap.String("state", "MISS"), ) - return "", false + return nil, false } - return res, true + var val CacheValue + if err := json.Unmarshal(raw, &val); err != nil { + return nil, false + } + + return &val, true } -func (c *cache) Put(ctx context.Context, key string, value any) error { +func (c *Cache) Put(ctx context.Context, key string, value CacheValue) error { if key == "" { return err.ErrEmptyCacheKey } - err := c.rdb.Set(ctx, key, value, c.expiration).Err() + b, err := json.Marshal(value) if err != nil { - c.sugar.Error("failed to write to cache", - "key", key, - "expiration", c.expiration, - "error", err, + c.logger.Info("failed to marshal value", + zap.String("key", key), + zap.Any("value", value), + zap.Error(err), + ) + return err + } + + err = c.rdb.Set(ctx, key, b, c.expiration).Err() + if err != nil { + c.logger.Error("failed to write to cache", + zap.String("key", key), + zap.Duration("expiration", c.expiration), + zap.Error(err), ) return fmt.Errorf("cache put failed: %w", err) } return nil } + +func (c *Cache) Clear(ctx context.Context) error { + return c.rdb.FlushAll(ctx).Err() +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..976f5c1 --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,50 @@ +package proxy + +import ( + "context" + "net/http" + "time" + + "github.com/pyr33x/proxy/internal/adapter/redis" + "github.com/pyr33x/proxy/internal/cache" + "github.com/pyr33x/proxy/pkg/config" + "go.uber.org/zap" +) + +type Server struct { + Proxy ProxyServer + Origin OriginServer + Cache *cache.Cache + logger *zap.Logger +} + +type ProxyServer struct { + Port string +} + +type OriginServer struct { + URL string +} + +func NewProxyServer(ctx context.Context, cfg *config.Config, logger *zap.Logger) *http.Server { + rdb := redis.New(ctx, &cfg.Redis, logger).GetClient() + + srv := &Server{ + Proxy: ProxyServer{ + Port: cfg.Server.Proxy.Port, + }, + Origin: OriginServer{ + URL: cfg.Server.Origin.URL, + }, + Cache: cache.NewCacheRepository(rdb, logger), + logger: logger, + } + + return &http.Server{ + Addr: ":" + srv.Proxy.Port, + Handler: srv.Serve(), + IdleTimeout: time.Minute, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go new file mode 100644 index 0000000..d0918e8 --- /dev/null +++ b/internal/proxy/server.go @@ -0,0 +1,101 @@ +package proxy + +import ( + "io" + "maps" + "net/http" + + "github.com/pyr33x/proxy/internal/cache" + "go.uber.org/zap" +) + +func (srv *Server) Serve() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", srv.ServeProxy) + mux.HandleFunc("/clear", srv.Clear) + return mux +} + +func (srv *Server) ServeProxy(w http.ResponseWriter, r *http.Request) { + cacheKey := r.Method + ":" + r.URL.String() + originURL := srv.Origin.URL + r.URL.String() + + c, ok := srv.Cache.Get(r.Context(), cacheKey) + if ok { + srv.WriteHeaders(w, "HIT", c) + srv.logger.Info("cache hit", + zap.String("key", cacheKey), + zap.String("state", "HIT"), + ) + srv.logger.Info("forwarding", + zap.String("origin", originURL), + ) + return + } + + srv.logger.Info("forwarding", + zap.String("origin", originURL), + ) + resp, err := http.Get(originURL) + if err != nil { + srv.logger.Error("error forwarding request", + zap.Error(err), + zap.String("origin", originURL), + ) + http.Error(w, "error forwarding request", http.StatusInternalServerError) + return + } + defer resp.Body.Close() //nolint:errcheck + + body, err := io.ReadAll(resp.Body) + if err != nil { + srv.logger.Error("error reading response body", + zap.Error(err), + zap.String("origin", originURL), + ) + http.Error(w, "error reading response body", http.StatusInternalServerError) + return + } + + cached := cache.CacheValue{ + Status: resp.StatusCode, + Header: resp.Header.Clone(), + Body: body, + } + + if err := srv.Cache.Put(r.Context(), cacheKey, cached); err != nil { + srv.logger.Error("failed to write to cache", + zap.Error(err), + zap.String("key", cacheKey), + ) + } + + srv.WriteHeaders(w, "MISS", &cached) +} + +func (srv *Server) Clear(w http.ResponseWriter, r *http.Request) { + if err := srv.Cache.Clear(r.Context()); err != nil { + srv.logger.Error("failed to clear cache") + return + } + + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("cleaned proxy cache")); err != nil { + srv.logger.Error("failed to write proxy cache clean response", + zap.Error(err), + ) + return + } +} + +func (srv *Server) WriteHeaders(w http.ResponseWriter, state string, cached *cache.CacheValue) { + maps.Copy(w.Header(), cached.Header) + w.Header().Set("X-Cache", state) + w.WriteHeader(cached.Status) + if _, err := w.Write(cached.Body); err != nil { + srv.logger.Error("failed to write cached body", + zap.Error(err), + ) + return + } +} diff --git a/main.go b/main.go index 38dd16d..8805242 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,62 @@ package main -func main() {} +import ( + "context" + "net/http" + "os/signal" + "syscall" + "time" + + zl "github.com/pyr33x/proxy/internal/adapter/zap" + "github.com/pyr33x/proxy/internal/proxy" + "github.com/pyr33x/proxy/pkg/config" + "go.uber.org/zap" + + _ "github.com/joho/godotenv/autoload" +) + +func gracefulShutdown(logger *zap.Logger, proxy *http.Server, done chan struct{}) { + stopCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + <-stopCtx.Done() + logger.Info("shutdown triggered...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := proxy.Shutdown(shutdownCtx); err != nil { + logger.Error("failed to shutdown proxy server", + zap.Error(err), + ) + return + } + + logger.Info("exiting proxy...") + done <- struct{}{} +} + +func main() { + done := make(chan struct{}) + + cfg := config.New() + logger := zl.New(cfg.Zap.Environment).GetLogger() + proxy := proxy.NewProxyServer(context.Background(), cfg, logger) + + go gracefulShutdown(logger, proxy, done) + + logger.Info("attached proxy", + zap.String("bind", proxy.Addr), + ) + + if err := proxy.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("failed to listen and serve to the proxy server", + zap.Error(err), + ) + close(done) + return + } + + <-done + logger.Info("shutdown complete") +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 64eff7a..65c0903 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -3,8 +3,22 @@ package config import "github.com/pyr33x/envy" type Config struct { - Redis Redis - Zap Zap + Server Server + Redis Redis + Zap Zap +} + +type Server struct { + Origin Origin + Proxy Proxy +} + +type Origin struct { + URL string +} + +type Proxy struct { + Port string } type Zap struct { @@ -25,6 +39,17 @@ type Base struct { func New() *Config { return &Config{ + Server: Server{ + Origin: Origin{ + URL: envy.GetString("ORIGIN_URL", "http://dummyjson.com"), + }, + Proxy: Proxy{ + Port: envy.GetString("PROXY_PORT", "1337"), + }, + }, + Zap: Zap{ + Environment: envy.GetString("ZAP_ENV", "dev"), + }, Redis: Redis{ Base: Base{ Host: envy.GetString("REDIS_HOST", "127.0.0.1"), @@ -34,8 +59,5 @@ func New() *Config { }, Database: envy.GetInt("REDIS_DATABASE", 0), }, - Zap: Zap{ - Environment: envy.GetString("ZAP_ENV", "dev"), - }, } } diff --git a/watch.bash b/watch.bash deleted file mode 100644 index 1baffe5..0000000 --- a/watch.bash +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/env bash -echo "running air watch..." -air