Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
22 changes: 22 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
77 changes: 49 additions & 28 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,72 +2,93 @@ package cache

import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/pyr33x/proxy/pkg/err"
"github.com/redis/go-redis/v9"
"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()
}
50 changes: 50 additions & 0 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
101 changes: 101 additions & 0 deletions internal/proxy/server.go
Original file line number Diff line number Diff line change
@@ -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
}
}
61 changes: 60 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading