From e0913f882409b286264ef55e2b3b5c0b97ec319d Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 7 Apr 2023 15:27:44 +0100 Subject: [PATCH] New minor release after complete rewrite. Brings neat and testable codebase with minimum allocations in mind. --- README.md | 11 +- base.go | 94 ++++++ base_test.go | 103 ++++++ cache.go | 14 + go.mod | 24 +- go.sum | 73 ++--- gql.go | 152 --------- gql_test.go | 66 ---- logger.go | 141 ++++++++ pkg/logging/logging.go | 77 ----- pkg/logging/logging_test.go | 373 ---------------------- query.go | 200 +++++------- query_bench_test.go | 69 ---- query_test.go | 444 +++++++++++++------------- request.go | 44 +++ sample/client.go | 37 +++ semver.yaml | 3 +- structs.go | 50 +++ utils/concurrency/concurrency.go | 30 ++ utils/concurrency/concurrency_test.go | 74 +++++ utils/helpers/helpers.go | 13 + utils/helpers/helpers_test.go | 70 ++++ utils/logger/logger.go | 72 +++++ utils/logger/logger_test.go | 84 +++++ utils/ordered_map/ordered_map.go | 64 ++++ utils/ordered_map/ordered_map_test.go | 164 ++++++++++ 26 files changed, 1417 insertions(+), 1129 deletions(-) create mode 100644 base.go create mode 100644 base_test.go create mode 100644 cache.go delete mode 100644 gql.go delete mode 100644 gql_test.go create mode 100644 logger.go delete mode 100644 pkg/logging/logging.go delete mode 100644 pkg/logging/logging_test.go delete mode 100644 query_bench_test.go create mode 100644 request.go create mode 100644 sample/client.go create mode 100644 structs.go create mode 100644 utils/concurrency/concurrency.go create mode 100644 utils/concurrency/concurrency_test.go create mode 100644 utils/helpers/helpers.go create mode 100644 utils/helpers/helpers_test.go create mode 100644 utils/logger/logger.go create mode 100644 utils/logger/logger_test.go create mode 100644 utils/ordered_map/ordered_map.go create mode 100644 utils/ordered_map/ordered_map_test.go diff --git a/README.md b/README.md index 1fe878c..4bb1561 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,19 @@ Therefore, I present you the simple client to which you can copy & paste your gr ### Environment variables * `GRAPHQL_ENDPOINT` - Your GraphQL endpoint. Default: `http://127.0.0.1:9090/v1/graphql` -* `GRAPHQL_CACHE` - Should the query cache be enabled? Default: `false` +* `GRAPHQL_CACHE_ENABLED` - Should the query cache be enabled? Default: `false` * `GRAPHQL_CACHE_TTL` - Cache TTL in seconds for SELECT type of queries. Default: `5` -* `LOG_LEVEL` - Logging level. Default: `info` -* `RETRIES_ENABLE` - Should retries be enabled? Default: `false` +* `GRAPHQL_OUTPUT` - Output format. Default: `byte`, available: `byte`, `string` +* `LOG_LEVEL` - Logging level. Default: `info` available: `debug`, `info`, `warn`, `error` + ### Cache You have two options to enable the cache: -* Use `GRAPHQL_CACHE` environment variable which will enable the cache globally. It may be desired if you want to use the cache for all queries. +* Use `GRAPHQL_CACHE_ENABLED` environment variable which will enable the cache globally. It may be desired if you want to use the cache for all queries. * Add `gqlcache: true` header for your query which will enable the cache for this query only with `GRAPHQL_CACHE_TTL` TTL. Example: diff --git a/base.go b/base.go new file mode 100644 index 0000000..a1bb11f --- /dev/null +++ b/base.go @@ -0,0 +1,94 @@ +package gql + +import ( + "crypto/tls" + "time" + + "github.com/lukaszraczylo/go-simple-graphql/utils/concurrency" + "github.com/lukaszraczylo/go-simple-graphql/utils/logger" + + "golang.org/x/net/http2" + + "log" + "net/http" + "os" + "strings" + + "github.com/allegro/bigcache" + "github.com/gookit/goutil/envutil" +) + +func NewConnection() *BaseClient { + + b := &BaseClient{ + client: &http.Client{}, + concurrencyManager: concurrency.NewPool(-1), + MaxGoRoutines: -1, + LoggingLevel: logger.Warn, + LoggerColorful: true, + } + + if b.LoggerWriter == nil { + b.LoggerWriter = log.New(os.Stdout, "", log.LstdFlags) + } else { + b.LoggerWriter = b.LoggerWriter + } + + b.endpoint = envutil.Getenv("GRAPHQL_ENDPOINT", "https://api.github.com/graphql") + b.responseType = envutil.Getenv("GRAPHQL_OUTPUT", "string") + b.validate = envutil.GetBool("GRAPHQL_VALIDATE", false) + b.cache.enabled = envutil.GetBool("GRAPHQL_CACHE_ENABLED", true) + b.cache.ttl = envutil.GetInt("GRAPHQL_CACHE_TTL", 60) + + if b.cache.enabled { + var err error + b.cache.client, err = bigcache.NewBigCache(bigcache.DefaultConfig(time.Duration(b.cache.ttl) * time.Second)) + if err != nil { + b.Logger.Error(b, "Error while creating cache client;", "error", err.Error()) + } + } + + b_tmp_log_level := envutil.Getenv("LOG_LEVEL", "info") + + switch b_tmp_log_level { + case "silent": + b.LoggingLevel = logger.Silent + case "error": + b.LoggingLevel = logger.Error + case "warn": + b.LoggingLevel = logger.Warn + case "info": + b.LoggingLevel = logger.Info + case "debug": + b.LoggingLevel = logger.Debug + } + + b.Logger = NewLogger(b.LoggerWriter, logger.Config{ + Colorful: b.LoggerColorful, + LogLevel: b.LoggingLevel, + }) + + var httpClient *http.Client + if strings.HasPrefix(b.endpoint, "http://") { + httpClient = &http.Client{ + Transport: http.DefaultTransport, + } + } else { + tlsClientConfig := &tls.Config{} + if strings.HasPrefix(b.endpoint, "https://") { + tlsClientConfig.InsecureSkipVerify = true + } + http2Transport := &http2.Transport{ + AllowHTTP: true, + TLSClientConfig: tlsClientConfig, + } + httpClient = &http.Client{ + Transport: http2Transport, + } + } + b.client = httpClient + + b.Logger.Debug(b, "graphQL client initialized;", "endpoint", b.endpoint, "responseType", b.responseType, "validate", b.validate, "cache", b.cache.enabled, "cacheTTL", b.cache.ttl, "maxGoRoutines", b.MaxGoRoutines, "loggingLevel", b_tmp_log_level, "loggerColorful", b.LoggerColorful) + b.Logger.Info(b, "graphQL client initialized;") + return b +} diff --git a/base_test.go b/base_test.go new file mode 100644 index 0000000..55e9c13 --- /dev/null +++ b/base_test.go @@ -0,0 +1,103 @@ +package gql + +import ( + "os" + "testing" + + "github.com/lukaszraczylo/go-simple-graphql/utils/logger" + + "github.com/stretchr/testify/assert" +) + +func TestNewConnection(t *testing.T) { + tests := []struct { + want *BaseClient + name string + }{ + { + name: "Test NewConnection", + want: &BaseClient{ + endpoint: "https://api.github.com/graphql", + responseType: "string", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // temporarily set GRAPHQL_ENDPOINT env variable to https://api.github.com/graphql + // and GRAPHQL_OUTPUT to string + current := os.Getenv("GRAPHQL_ENDPOINT") + defer os.Setenv("GRAPHQL_ENDPOINT", current) + os.Setenv("GRAPHQL_ENDPOINT", "https://api.github.com/graphql") + + got := NewConnection() + assert.Equal(t, tt.want.endpoint, got.endpoint) + assert.Equal(t, tt.want.responseType, got.responseType) + }) + } +} + +func TestNewConnectionLogLevels(t *testing.T) { + tests := []struct { + want *BaseClient + name string + env string + }{ + { + name: "Test NewConnection with log level set to debug", + env: "debug", + want: &BaseClient{ + endpoint: "https://api.github.com/graphql", + responseType: "string", + LoggingLevel: logger.Debug, + }, + }, + + { + name: "Test NewConnection with log level set to info", + env: "info", + want: &BaseClient{ + endpoint: "https://api.github.com/graphql", + responseType: "string", + LoggingLevel: logger.Info, + }, + }, + + { + name: "Test NewConnection with log level set to warn", + env: "warn", + want: &BaseClient{ + endpoint: "https://api.github.com/graphql", + responseType: "string", + LoggingLevel: logger.Warn, + }, + }, + + { + name: "Test NewConnection with log level set to error", + env: "error", + want: &BaseClient{ + endpoint: "https://api.github.com/graphql", + responseType: "string", + LoggingLevel: logger.Error, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // temporarily set GRAPHQL_ENDPOINT env variable to https://api.github.com/graphql + // and GRAPHQL_OUTPUT to string + current := os.Getenv("GRAPHQL_ENDPOINT") + defer os.Setenv("GRAPHQL_ENDPOINT", current) + os.Setenv("GRAPHQL_ENDPOINT", "https://api.github.com/graphql") + current_log_level := os.Getenv("LOG_LEVEL") + defer os.Setenv("LOG_LEVEL", current_log_level) + os.Setenv("LOG_LEVEL", tt.env) + + got := NewConnection() + assert.Equal(t, tt.want.endpoint, got.endpoint) + assert.Equal(t, tt.want.responseType, got.responseType) + assert.Equal(t, tt.want.LoggingLevel, got.LoggingLevel) + }) + } +} diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..1eaa11d --- /dev/null +++ b/cache.go @@ -0,0 +1,14 @@ +package gql + +func (c *BaseClient) cacheLookup(hash string) []byte { + if c.cache.client != nil { + g, err := c.cache.client.Get(hash) + if err == nil { + c.Logger.Debug(c, "Cache hit;", "hash", hash) + return g + } + // if error is not nil it means that hash is not present in cache + c.Logger.Debug(c, "Cache miss;", "hash", hash) + } + return nil +} diff --git a/go.mod b/go.mod index 23c7316..02c61f0 100644 --- a/go.mod +++ b/go.mod @@ -3,30 +3,26 @@ module github.com/lukaszraczylo/go-simple-graphql go 1.20 require ( - github.com/allegro/bigcache/v3 v3.1.0 - github.com/avast/retry-go v3.0.0+incompatible - github.com/buger/jsonparser v1.1.1 + github.com/allegro/bigcache v1.2.1 + github.com/gookit/goutil v0.6.8 github.com/json-iterator/go v1.1.12 - github.com/lukaszraczylo/pandati v0.0.20 - github.com/rs/zerolog v1.29.0 - github.com/sethvargo/go-retry v0.2.4 - github.com/stretchr/testify v1.7.1 + github.com/stretchr/testify v1.8.2 golang.org/x/net v0.8.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/imdario/mergo v0.3.15 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect + github.com/gookit/color v1.5.3 // indirect + github.com/kr/pretty v0.3.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/tidwall/gjson v1.14.4 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.1 // indirect - github.com/wI2L/jsondiff v0.3.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.6.0 // indirect + golang.org/x/term v0.6.0 // indirect golang.org/x/text v0.8.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b2b89e1..8193924 100644 --- a/go.sum +++ b/go.sum @@ -1,67 +1,58 @@ -github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk= -github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I= -github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= -github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= -github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc= +github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= -github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/gookit/color v1.5.3 h1:twfIhZs4QLCtimkP7MOxlF3A0U/5cDPseRT9M/+2SCE= +github.com/gookit/color v1.5.3/go.mod h1:NUzwzeehUfl7GIb36pqId+UGmRfQcU/WiiyTTeNjHtE= +github.com/gookit/goutil v0.6.8 h1:B2XXSCGav5TXWtKRT9i/s/owOLXXB7sY6UsfqeSLroE= +github.com/gookit/goutil v0.6.8/go.mod h1:u+Isykc6RQcZ4GQzulsaGm+Famd97U5Tzp3aQyo+jyA= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/lukaszraczylo/pandati v0.0.18 h1:6khtDY6QXkiyh4a3dzHtIzx/9UqUGfHjzKBCA0SIurI= -github.com/lukaszraczylo/pandati v0.0.18/go.mod h1:Aqlc0KH+ssvrscYYbEKMv7n5cFwe+0MjsJbikNz1h60= -github.com/lukaszraczylo/pandati v0.0.20 h1:LqF7CS3z9Zi04KwEY8wd/YUpfWKSwi0nc7pagKQLXHc= -github.com/lukaszraczylo/pandati v0.0.20/go.mod h1:ZegK9ll9UebKV1uNMF73qdLb43G/O4CSOdPglu6NniQ= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= -github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= -github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= -github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/wI2L/jsondiff v0.3.0 h1:iTzQ9u/d86GE9RsBzVHX88f2EA1vQUboHwLhSQFc1s4= -github.com/wI2L/jsondiff v0.3.0/go.mod h1:y1IMzNNjlSsk3IUoJdRJO7VRBtzMvRgyo4Vu0LdHpTc= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 h1:yZNXmy+j/JpX19vZkVktWqAo7Gny4PBWYYK3zskGpx4= +golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/gql.go b/gql.go deleted file mode 100644 index 6ddc33e..0000000 --- a/gql.go +++ /dev/null @@ -1,152 +0,0 @@ -// Package go-simple-graphql is a wrapper for GraphQL queries execution. -// It's sole purpose is to allow developer to execute queries without specific fields / types mapping -// which can be really confusing and even impossible whilst dealing with Hasura custom generated types. -// -// Library supports advanced error reporting on unsuccessful queries. -// Library also uses HTTP2 for communication with GraphQL API if it's supported by server - -// Environment variables: -// GRAPHQL_ENDPOINT - GraphQL endpoint to use. Default: http://127.0.0.1:9090/v1/graphql -// LOG_LEVEL - Log level to use. Default: INFO -// Available log levels: DEBUG, INFO, WARN, ERROR, FATAL - -package gql - -import ( - "crypto/tls" - "fmt" - "net/http" - "os" - "strconv" - "strings" - "time" - - bigcache "github.com/allegro/bigcache/v3" - jsoniter "github.com/json-iterator/go" - "github.com/lukaszraczylo/go-simple-graphql/pkg/logging" - retry "github.com/sethvargo/go-retry" - "golang.org/x/net/http2" -) - -var ( - json = jsoniter.ConfigCompatibleWithStandardLibrary -) - -type GraphQL struct { - BackoffSetup retry.Backoff - HttpClient *http.Client - Log *logging.LogConfig - CacheStore *bigcache.BigCache - Endpoint string - RetriesNumber int - RetriesDelay time.Duration - Cache bool - RetriesEnable bool -} - -func pickGraphqlEndpoint() (graphqlEndpoint string) { - value, present := os.LookupEnv("GRAPHQL_ENDPOINT") - if present && value != "" { - graphqlEndpoint = value - } else { - graphqlEndpoint = "http://127.0.0.1:9090/v1/graphql" - fmt.Println("Setting default endpoint", graphqlEndpoint) - } - return graphqlEndpoint -} - -func setCacheTTL() int { - value, present := os.LookupEnv("GRAPHQL_CACHE_TTL") - if present && value != "" { - i, err := strconv.Atoi(value) - if err != nil { - panic("Invalid value for query cache ttl") - } - return int(i) - } else { - return 5 - } -} - -func setCacheEnabled() bool { - value, present := os.LookupEnv("GRAPHQL_CACHE") - if present && value != "" { - b, err := strconv.ParseBool(value) - if err != nil { - panic("Invalid value for query cache") - } - return b - } else { - return false - } -} - -func NewConnection() *GraphQL { - - endpoint := pickGraphqlEndpoint() - var httpClient *http.Client - if strings.HasPrefix(endpoint, "http://") { - httpClient = &http.Client{ - Transport: http.DefaultTransport, - } - } else { - tlsClientConfig := &tls.Config{} - if strings.HasPrefix(endpoint, "https://") { - tlsClientConfig.InsecureSkipVerify = true - } - http2Transport := &http2.Transport{ - AllowHTTP: true, - TLSClientConfig: tlsClientConfig, - } - httpClient = &http.Client{ - Transport: http2Transport, - } - } - - g := GraphQL{ - Endpoint: endpoint, - HttpClient: httpClient, - Log: logging.NewLogger(), - Cache: setCacheEnabled(), - CacheStore: setupCache(), - RetriesEnable: false, - RetriesNumber: 1, - RetriesDelay: 250, - BackoffSetup: nil, - } - var err error - retriesEnable, retriesEnableExists := os.LookupEnv("RETRIES_ENABLE") - if retriesEnableExists { - g.RetriesEnable, err = strconv.ParseBool(retriesEnable) - if err != nil { - panic("Invalid value for RETRIES_ENABLE") - } - retriesNumber, retriesNumberExists := os.LookupEnv("RETRIES_NUMBER") - if retriesNumberExists { - g.RetriesNumber, err = strconv.Atoi(retriesNumber) - if err != nil { - panic("Invalid value for RETRIES_NUMBER") - } - } - retriesDelay, retriesDelayExists := os.LookupEnv("RETRIES_DELAY") - if retriesDelayExists { - retDel, err := strconv.Atoi(retriesDelay) - if err != nil { - panic("Invalid value for RETRIES_DELAY") - } - g.RetriesDelay = time.Duration(retDel) * time.Millisecond - } - } - g.BackoffSetup = retry.NewExponential(g.RetriesDelay) - g.BackoffSetup = retry.WithMaxRetries(uint64(g.RetriesNumber), g.BackoffSetup) - - return &g -} - -func setupCache() *bigcache.BigCache { - cache, err := bigcache.NewBigCache(bigcache.DefaultConfig(time.Duration(setCacheTTL()) * time.Second)) - if err != nil { - panic("Error creating cache: " + err.Error()) - } - return cache -} diff --git a/gql_test.go b/gql_test.go deleted file mode 100644 index 8208d74..0000000 --- a/gql_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package gql - -import ( - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -func mockGraphQLServerResponses(responseQuery string) (*httptest.Server, string) { - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - rw.Write([]byte(responseQuery)) - })) - serverURL := server.URL + "/v1/graphql" - return server, serverURL -} - -type TestSuite struct { - suite.Suite -} - -func (suite *TestSuite) SetupTest() { - os.Unsetenv("GRAPHQL_ENDPOINT") -} - -func TestSuiteRun(t *testing.T) { - suite.Run(t, new(TestSuite)) -} - -func (suite *TestSuite) TestNewConnection() { - type args struct { - endpoint string - } - tests := []struct { - want *GraphQL - name string - args args - }{ - { - name: "New connection: Env variable endpoint", - args: args{ - endpoint: "http://localhost:8080/graphql", - }, - want: &GraphQL{ - Endpoint: "http://localhost:8080/graphql", - }, - }, - { - name: "New connection: No env variable endpoint", - args: args{}, - want: &GraphQL{ - Endpoint: "http://127.0.0.1:9090/v1/graphql", - }, - }, - } - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - os.Setenv("GRAPHQL_ENDPOINT", tt.args.endpoint) - got := NewConnection() - assert.Equal(t, tt.want.Endpoint, got.Endpoint) - }) - } -} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..94d31db --- /dev/null +++ b/logger.go @@ -0,0 +1,141 @@ +package gql + +import ( + "fmt" + "reflect" + "time" + + "github.com/lukaszraczylo/go-simple-graphql/utils/logger" + + "github.com/gookit/goutil/timex" +) + +func NewLogger(writer Writer, config logger.Config) Logger { + colorfulPrint := "%s[%s]%s %%s %%s" + closeColor, debugColor, infoColor, warnColor, errorColor := "\033[0m", "\033[1;34m", "\033[1;32m", "\033[1;33m", "\033[1;31m" + if !config.Colorful { + closeColor, debugColor, infoColor, warnColor, errorColor = "", "", "", "", "" + } + debugStr := fmt.Sprintf(colorfulPrint, debugColor, "DEBUG", closeColor) + infoStr := fmt.Sprintf(colorfulPrint, infoColor, "INFO", closeColor) + warnStr := fmt.Sprintf(colorfulPrint, warnColor, "WARN", closeColor) + errStr := fmt.Sprintf(colorfulPrint, errorColor, "ERROR", closeColor) + + return &logging{ + Writer: writer, + Config: config, + infoStr: infoStr, + warnStr: warnStr, + errStr: errStr, + debugStr: debugStr, + } +} + +func setDate() string { + tx := time.Now() + return timex.DateFormat(tx, "Y-m-d H:I:S") +} + +func (l logging) Debug(client *BaseClient, msg string, data ...any) { + if l.LogLevel >= logger.Debug { + t := setDate() + msg, data = censorSensitiveInfo(client, msg, data...) + if client != nil { + l.Printf(l.debugStr, fmt.Sprintf("[%s] %s", t, msg), sprint(data...)) + } else { + l.Printf(l.debugStr, msg, sprint(data...)) + } + } +} + +func (l logging) Info(client *BaseClient, msg string, data ...any) { + if l.LogLevel >= logger.Info { + t := setDate() + msg, data = censorSensitiveInfo(client, msg, data...) + if client != nil { + l.Printf(l.infoStr, fmt.Sprintf("[%s] %s", t, msg), sprint(data...)) + } else { + l.Printf(l.infoStr, msg, sprint(data...)) + } + } +} + +func (l logging) Warn(client *BaseClient, msg string, data ...any) { + if l.LogLevel >= logger.Warn { + t := setDate() + msg, data = censorSensitiveInfo(client, msg, data...) + if client != nil { + l.Printf(l.warnStr, fmt.Sprintf("[%s] %s", t, msg), sprint(data...)) + } else { + l.Printf(l.warnStr, msg, sprint(data...)) + } + } +} + +func (l logging) Error(client *BaseClient, msg string, data ...any) { + if l.LogLevel >= logger.Error { + t := setDate() + msg, data = censorSensitiveInfo(client, msg, data...) + if client != nil { + l.Printf(l.errStr, fmt.Sprintf("[%s] %s", t, msg), sprint(data...)) + } else { + l.Printf(l.errStr, msg, sprint(data...)) + } + } +} + +type Logger interface { + Debug(*BaseClient, string, ...any) + Info(*BaseClient, string, ...any) + Warn(*BaseClient, string, ...any) + Error(*BaseClient, string, ...any) +} + +type Writer interface { + Printf(string, ...any) +} + +type logging struct { + Writer + debugStr string + infoStr string + warnStr string + errStr string + logger.Config +} + +func censorSensitiveInfo(client *BaseClient, a string, b ...any) (string, []any) { + // if client == nil || client.Token == "" { + // return a, b + // } + // tokenFixed := strings.Split(client.Token, ":")[1] + // for t := range b { + // if b[t] != nil { + // rV := reflect.TypeOf(b[t]).Kind() + // if rV == reflect.String { + // b[t] = strings.ReplaceAll(b[t].(string), tokenFixed, strings.Repeat("X", len(tokenFixed))) + // } + // } + // } + // a = strings.ReplaceAll(a, tokenFixed, strings.Repeat("X", len(tokenFixed))) + // return a, b + return a, b +} + +func sprint(a ...any) string { + for t := range a { + if a[t] != nil { + rV := reflect.TypeOf(a[t]).Kind() + if rV == reflect.Ptr { + a[t] = reflect.ValueOf(a[t]).Elem().Interface() + } + rV = reflect.TypeOf(a[t]).Kind() + if rV == reflect.Struct || rV == reflect.Map || rV == reflect.Slice { + a[t], _ = logger.Serialize(a[t]) + } + } + } + r := fmt.Sprintln(a...) + r = r[:len(r)-1] + return r +} diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go deleted file mode 100644 index 3dd3d40..0000000 --- a/pkg/logging/logging.go +++ /dev/null @@ -1,77 +0,0 @@ -package logging - -import ( - "fmt" - stdlog "log" - "os" - "time" - - "github.com/lukaszraczylo/pandati" - "github.com/rs/zerolog" -) - -type LogConfig struct { - DefaultOutput *os.File - Logger zerolog.Logger -} - -func NewLogger() *LogConfig { - log := new(LogConfig) - switch dll := os.Getenv("LOG_LEVEL"); dll { - case "debug": - zerolog.SetGlobalLevel(zerolog.DebugLevel) - case "warn": - zerolog.SetGlobalLevel(zerolog.WarnLevel) - case "error": - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - default: - zerolog.SetGlobalLevel(zerolog.InfoLevel) - } - zerolog.TimeFieldFormat = time.RFC3339 - zerolog.MessageFieldName = "short_message" - zerolog.TimestampFieldName = "timestamp" - zerolog.LevelFatalValue = "critical" - log.Logger = log.Logger.With().Timestamp().Logger() - stdlog.SetFlags(0) - stdlog.SetOutput(log.Logger) - return log -} - -func (l *LogConfig) Critical(message string, v ...map[string]interface{}) { - w := l.Logger.Output(os.Stderr) - log(w.Fatal(), message, v...) -} - -func (l *LogConfig) Error(message string, v ...map[string]interface{}) { - w := l.Logger.Output(os.Stderr) - log(w.Error(), message, v...) -} - -func (l *LogConfig) Warning(message string, v ...map[string]interface{}) { - w := l.Logger.Output(os.Stdout) - log(w.Warn(), message, v...) -} - -func (l *LogConfig) Info(message string, v ...map[string]interface{}) { - w := l.Logger.Output(os.Stdout) - log(w.Info(), message, v...) -} - -func (l *LogConfig) Debug(message string, v ...map[string]interface{}) { - w := l.Logger.Output(os.Stdout) - log(w.Debug(), message, v...) -} - -func log(contextLog *zerolog.Event, message string, v ...map[string]interface{}) { - if len(v) > 0 { - for _, m := range v { - for k, v := range m { - if !pandati.IsZero(v) && !pandati.IsZero(k) { - contextLog.Str(k, fmt.Sprintf("%v", v)) - } - } - } - } - // contextLog.Str("_stack_trace", pandati.Trace()) - contextLog.Msg(message) -} diff --git a/pkg/logging/logging_test.go b/pkg/logging/logging_test.go deleted file mode 100644 index 78219a8..0000000 --- a/pkg/logging/logging_test.go +++ /dev/null @@ -1,373 +0,0 @@ -package logging - -import ( - "errors" - "io/ioutil" - "os" - "reflect" - "testing" - - "github.com/buger/jsonparser" - "github.com/stretchr/testify/suite" -) - -type LoggingTestSuite struct { - suite.Suite -} - -var ( - testsLogger *LogConfig -) - -type stdoutCapture struct { - oldStdout *os.File - readPipe *os.File -} - -func (sc *stdoutCapture) StartCapture() { - sc.oldStdout = os.Stdout - sc.readPipe, os.Stdout, _ = os.Pipe() -} - -func (sc *stdoutCapture) StopCapture() (string, error) { - if sc.oldStdout == nil || sc.readPipe == nil { - return "", errors.New("StartCapture not called before StopCapture on Stdout") - } - os.Stdout.Close() - os.Stdout = sc.oldStdout - bytes, err := ioutil.ReadAll(sc.readPipe) - if err != nil { - return "", err - } - return string(bytes), nil -} - -type stderrCapture struct { - oldStderr *os.File - readPipe *os.File -} - -func (sc *stderrCapture) StartCapture() { - sc.oldStderr = os.Stderr - sc.readPipe, os.Stderr, _ = os.Pipe() -} - -func (sc *stderrCapture) StopCapture() (string, error) { - if sc.oldStderr == nil || sc.readPipe == nil { - return "", errors.New("StartCapture not called before StopCapture on Stderr") - } - os.Stderr.Close() - os.Stderr = sc.oldStderr - bytes, err := ioutil.ReadAll(sc.readPipe) - if err != nil { - return "", err - } - return string(bytes), nil -} - -func (suite *LoggingTestSuite) SetupTest() { -} - -func TestLoggingTestSuite(t *testing.T) { - suite.Run(t, new(LoggingTestSuite)) -} - -func (suite *LoggingTestSuite) TestLogConfig_AllHandlers() { - type args struct { - message string - } - tests := []struct { - name string - args args - wantLevel string - wantMessage string - envMinLogLevel string - loggerType string - stdOutExpect bool - stdErrExpect bool - }{ - { - name: "Test log: Error", - loggerType: "Error", - args: args{ - message: "This is a error message", - }, - wantLevel: "error", - wantMessage: "This is a error message", - stdErrExpect: true, - stdOutExpect: false, - }, - { - name: "Test log: Warning", - loggerType: "Warning", - args: args{ - message: "This is a warning message", - }, - wantLevel: "warn", - wantMessage: "This is a warning message", - stdErrExpect: false, - stdOutExpect: true, - envMinLogLevel: "info", - }, - { - name: "Test log: Warning | Min level: Debug", - loggerType: "Warning", - args: args{ - message: "This is a warning message", - }, - wantLevel: "warn", - wantMessage: "This is a warning message", - stdErrExpect: false, - stdOutExpect: true, - envMinLogLevel: "debug", - }, - { - name: "Test log: Info", - loggerType: "Info", - args: args{ - message: "This is a info message", - }, - wantLevel: "info", - wantMessage: "This is a info message", - stdErrExpect: false, - stdOutExpect: true, - }, - { - name: "Test log: Info | Min level: Warn", - loggerType: "Info", - args: args{ - message: "This is a info message", - }, - wantLevel: "", - wantMessage: "", - stdErrExpect: false, - stdOutExpect: false, - envMinLogLevel: "warn", - }, - { - name: "Test log: Warning | Min level: Warn", - loggerType: "Warning", - args: args{ - message: "This is a warning message", - }, - wantLevel: "warn", - wantMessage: "This is a warning message", - stdErrExpect: false, - stdOutExpect: true, - envMinLogLevel: "warn", - }, - { - name: "Test log: Warning | Min level: Error", - loggerType: "Warning", - args: args{ - message: "This is an error message", - }, - wantLevel: "", - wantMessage: "", - stdErrExpect: false, - stdOutExpect: false, - envMinLogLevel: "error", - }, - { - name: "Test log: Debug | Min level: Debug", - loggerType: "Debug", - args: args{ - message: "This is a debug message", - }, - wantLevel: "debug", - wantMessage: "This is a debug message", - stdErrExpect: false, - stdOutExpect: true, - envMinLogLevel: "debug", - }, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - if tt.envMinLogLevel != "" { - os.Setenv("LOG_LEVEL", tt.envMinLogLevel) - defer os.Unsetenv("LOG_LEVEL") - } - testsLogger = NewLogger() - - captureStdout := stdoutCapture{} - captureStdout.StartCapture() - captureStderr := stderrCapture{} - captureStderr.StartCapture() - - reflect.ValueOf(testsLogger).MethodByName(tt.loggerType).Call([]reflect.Value{reflect.ValueOf(tt.args.message)}) - - stdoutOut, err := captureStdout.StopCapture() - if err != nil { - suite.T().Fatal(err) - } - - stderrOut, err := captureStderr.StopCapture() - if err != nil { - suite.T().Fatal(err) - } - - if tt.stdErrExpect && !tt.stdOutExpect { - gotLvl, gotMsg, err := getResponseValues(stderrOut, "short_message") - suite.NoError(err, "Failed in [STDERR]: "+tt.name) - suite.Equal(tt.wantLevel, gotLvl, "Failed in [STDERR]: "+tt.name) - suite.Equal(tt.wantMessage, gotMsg, "Failed in [STDERR]: "+tt.name) - suite.Equal("", stdoutOut, "Failed in [STDERR]: "+tt.name) - } - if tt.stdOutExpect && !tt.stdErrExpect { - gotLvl, gotMsg, err := getResponseValues(stdoutOut, "short_message") - suite.NoError(err, "Failed in [STDOUT]: "+tt.name) - suite.Equal(tt.wantLevel, gotLvl, "Failed in [STDOUT]: "+tt.name) - suite.Equal(tt.wantMessage, gotMsg, "Failed in [STDOUT]: "+tt.name) - suite.Equal("", stderrOut, "Failed in [STDOUT]: "+tt.name) - } - if !tt.stdErrExpect && !tt.stdOutExpect { - suite.Equal("", stderrOut, "Failed in [NEITHER]: "+tt.name) - suite.Equal("", stdoutOut, "Failed in [NEITHER]: "+tt.name) - } - os.Unsetenv("LOG_LEVEL") - }) - } -} - -func (suite *LoggingTestSuite) TestFullMessage() { - type args struct { - extraFields map[string]interface{} - message string - } - extraFields := make(map[string]interface{}) - extraFields["_full_message"] = "full message" - - tests := []struct { - args args - name string - wantLevel string - wantMessage string - envMinLogLevel string - loggerType string - stdOutExpect bool - stdErrExpect bool - }{ - { - name: "Test log: Error", - loggerType: "Error", - args: args{ - message: "This is a error message", - extraFields: extraFields, - }, - wantLevel: "error", - wantMessage: extraFields["_full_message"].(string), - stdErrExpect: true, - stdOutExpect: false, - }, - { - name: "Test log: Info", - loggerType: "Info", - args: args{ - message: "This is a info message", - extraFields: extraFields, - }, - wantMessage: extraFields["_full_message"].(string), - stdErrExpect: false, - stdOutExpect: true, - }, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - if tt.envMinLogLevel != "" { - os.Setenv("LOG_LEVEL", tt.envMinLogLevel) - defer os.Unsetenv("LOG_LEVEL") - } - testsLogger = NewLogger() - - captureStdout := stdoutCapture{} - captureStdout.StartCapture() - captureStderr := stderrCapture{} - captureStderr.StartCapture() - - reflect.ValueOf(testsLogger).MethodByName(tt.loggerType).Call([]reflect.Value{ - reflect.ValueOf(tt.args.message), - reflect.ValueOf(tt.args.extraFields), - }) - - stdoutOut, err := captureStdout.StopCapture() - if err != nil { - suite.T().Fatal(err) - } - - stderrOut, err := captureStderr.StopCapture() - if err != nil { - suite.T().Fatal(err) - } - - if tt.stdErrExpect && !tt.stdOutExpect { - _, gotMsg, err := getResponseValues(stderrOut, "_full_message") - suite.NoError(err, "Failed in [STDERR]: "+tt.name) - suite.Equal(tt.wantMessage, gotMsg, "Failed in [STDERR]: "+tt.name) - } - if tt.stdOutExpect && !tt.stdErrExpect { - _, gotMsg, err := getResponseValues(stdoutOut, "_full_message") - suite.NoError(err, "Failed in [STDOUT]: "+tt.name) - suite.Equal(tt.wantMessage, gotMsg, "Failed in [STDOUT]: "+tt.name) - } - os.Unsetenv("LOG_LEVEL") - }) - } -} - -func Test_getResponseValues(t *testing.T) { - type args struct { - sourceJson string - } - tests := []struct { - name string - args args - wantGotLvl string - wantGotMsg string - wantErr bool - }{ - { - name: "Test with json", - args: args{ - sourceJson: `{"level": "debug", "short_message": "hello world"`, - }, - wantGotLvl: "debug", - wantGotMsg: "hello world", - wantErr: false, - }, - { - name: "Test with json, wrong message field", - args: args{ - sourceJson: `{"level": "debug", "message": "hello world"`, - }, - wantGotLvl: "debug", - wantGotMsg: "", - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotGotLvl, gotGotMsg, err := getResponseValues(tt.args.sourceJson, "short_message") - if (err != nil) != tt.wantErr { - t.Errorf("getResponseValues() error = %v, wantErr %v", err, tt.wantErr) - return - } - if gotGotLvl != tt.wantGotLvl { - t.Errorf("getResponseValues() gotGotLvl = %v, want %v", gotGotLvl, tt.wantGotLvl) - } - if gotGotMsg != tt.wantGotMsg { - t.Errorf("getResponseValues() gotGotMsg = %v, want %v", gotGotMsg, tt.wantGotMsg) - } - }) - } -} - -func getResponseValues(sourceJson string, key string) (gotLvl, gotMsg string, err error) { - gotLvl, err = jsonparser.GetString([]byte(sourceJson), "level") - if err != nil { - return - } - gotMsg, err = jsonparser.GetString([]byte(sourceJson), key) - return -} diff --git a/query.go b/query.go index e1a283b..1898497 100644 --- a/query.go +++ b/query.go @@ -1,141 +1,117 @@ package gql import ( - "bytes" - "crypto/md5" - "errors" - "fmt" - "io/ioutil" - "net/http" + "context" - retry "github.com/avast/retry-go" - "github.com/lukaszraczylo/pandati" + "github.com/lukaszraczylo/go-simple-graphql/utils/helpers" + + "github.com/gookit/goutil/strutil" + + jsoniter "github.com/json-iterator/go" ) -type requestBase struct { - Variables interface{} `json:"variables"` - Query string `json:"query"` -} +var json = jsoniter.ConfigCompatibleWithStandardLibrary -type queryResults struct { - Data interface{} `json:"data"` - Errors []struct { - Message interface{} `json:"message"` - } `json:"errors"` -} - -func (g *GraphQL) queryBuilder(queryContent string, queryVariables interface{}) ([]byte, error) { - var qb = &requestBase{ - Query: queryContent, - Variables: queryVariables, - } - - j2, err := json.Marshal(qb) +func (c *BaseClient) convertToJson(v any) []byte { + json, err := json.Marshal(v) if err != nil { - g.Log.Critical("Unable to marshal the query", map[string]interface{}{"_error": err.Error(), "_query": queryContent, "_variables": queryVariables}) - return []byte{}, err + c.Logger.Error(c, "Can't convert to json;", "error", err.Error()) + return nil } - return j2, err + return json } -func (g *GraphQL) Query(queryContent string, queryVariables interface{}, queryHeaders map[string]interface{}) (responseContent string, err error) { - g.Log.Debug("Query details", map[string]interface{}{"_query": queryContent, "_variables": queryVariables}) - query, err := g.queryBuilder(queryContent, queryVariables) - if err != nil { - g.Log.Error("Unable to build the query", map[string]interface{}{"_error": err.Error()}) - return "", err +func (c *BaseClient) NewQuery(q ...any) *Query { + query := &Query{} + + if len(q) > 0 { + for _, v := range q { + switch val := v.(type) { + case string: + query.query = val + case map[string]interface{}: + query.variables = val + case context.Context: + query.context = val + } + } } - var body []byte - var queryResult queryResults + query.compiledQuery = c.convertToJson(request{ + Query: query.query, + Variables: query.variables, + }) + + c.Logger.Debug(c, "Clearing previously prepared variables and query") + query.variables = nil + + if query.context == nil { + query.context = context.Background() + } + + if c.validate { + c.Logger.Warn(c, "Validating query is not active") + } + + query.query = "" + c.Logger.Debug(c, "Query prepared;", "query", helpers.BytesToString(query.compiledQuery)) + + return query +} + +// Query is a function that sends a query to the server +// It takes 3 arguments: +// 1. queryContent: the query string +// 2. queryVariables: the variables for the query +// 3. queryHeaders: the headers for the query +// It looks a bit weird because of the backward compatibility + +func (c *BaseClient) Query(queryContent string, queryVariables interface{}, queryHeaders map[string]interface{}) (any, error) { + query := c.NewQuery(queryContent, queryVariables) + var queryHash string + var cachedResponse []byte - cacheEnabled := queryHeaders["gqlcache"] == true - // If header 'gqlcache' is set to true, we will enforce the cache for the query - if cacheEnabled || g.Cache { - g.Log.Debug("Forced cache for query", map[string]interface{}{"_query": queryContent, "_headers": queryHeaders}) - g.Cache = true - queryHash = fmt.Sprintf("%x", md5.Sum(query)) + if c.cache.enabled { + queryHash = strutil.Md5(query.compiledQuery) + c.Logger.Debug(c, "Hash calculated;", "hash:", queryHash) - g.Log.Debug("Checking the cache for the query", map[string]interface{}{"_query": queryHash}) - if entry, entryInfo, err := g.CacheStore.GetWithInfo(queryHash); err == nil { - g.Log.Debug("Found the query in the cache", map[string]interface{}{"_query": queryHash}) - if pandati.IsZero(entryInfo.EntryStatus) { - return pandati.BytesToString(entry), nil - } + cachedResponse = c.cacheLookup(queryHash) + if cachedResponse != nil { + c.Logger.Debug(c, "Found cached response") + return c.decodeResponse(cachedResponse), nil } else { - g.Log.Debug("Unable to find the query in the cache", map[string]interface{}{"_query": queryHash, "_error": err.Error()}) + c.Logger.Debug(c, "No cached response found") } } - httpRequest, err := http.NewRequest("POST", g.Endpoint, bytes.NewBuffer(query)) - // httpRequest.Header.Set("Content-Type", "application/json") + response, err := c.executeQuery(query.compiledQuery, queryHeaders) if err != nil { - g.Log.Error("Unable to create the request", map[string]interface{}{"_error": err.Error()}) - return "", err + c.Logger.Error(c, "Error while executing query;", "error", err.Error()) + return nil, err } - for header, value := range queryHeaders { - httpRequest.Header.Add(header, fmt.Sprintf("%v", value)) - } - - var httpResponse *http.Response - - err = retry.Do( - func() error { - g.Log.Debug("Sending the query") - httpResponse, err = g.HttpClient.Do(httpRequest) - - if err != nil { - g.Log.Debug("Unable to send the query RETRY", map[string]interface{}{"_error": err.Error()}) - return err - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode <= 200 && httpResponse.StatusCode >= 204 { - return err - } - - body, err = ioutil.ReadAll(httpResponse.Body) - if err != nil { - g.Log.Debug("Unable to read the response", map[string]interface{}{"_error": err.Error()}) - return err - } - - err = json.Unmarshal(body, &queryResult) - if err != nil { - g.Log.Debug("Unable to unmarshal the query", map[string]interface{}{"_error": err.Error()}) - return err - } - return nil - }, - ) - - if !pandati.IsZero(queryResult.Errors) { - g.Log.Error("Query returned error", map[string]interface{}{"_query": queryContent, "_variables": queryVariables, "_error": fmt.Sprintf("%v", queryResult.Errors), "_response_code": httpResponse.StatusCode}) - return "", fmt.Errorf("%v", queryResult.Errors) - } - - if pandati.IsZero(queryResult.Data) { - g.Log.Error("Query returned no data", map[string]interface{}{"_query": queryContent, "_variables": queryVariables, "_response_code": httpResponse.StatusCode}) - return "", errors.New("Query returned no data") - } - - responseContent, err = json.MarshalToString(queryResult.Data) + jsonData, err := json.Marshal(response) if err != nil { - g.Log.Error("Invalid data result", map[string]interface{}{"_query": queryContent, "_variables": queryVariables, "_result": responseContent, "_response_code": httpResponse.StatusCode}) - return "", err + c.Logger.Error(c, "Error while converting to json;", "error", err.Error()) + return nil, err } - if g.Cache { - g.Log.Debug("Caching the query", map[string]interface{}{"_query": queryHash}) - if queryContent[0:5] == "query" { - err = g.CacheStore.Set(queryHash, pandati.StringToBytes(responseContent)) - if err != nil { - g.Log.Error("Unable to cache the query", map[string]interface{}{"_query": queryHash, "_error": err.Error()}) - } - } + if c.cache.enabled { + c.cache.client.Set(queryHash, jsonData) } - return + return c.decodeResponse(jsonData), err +} + +func (c *BaseClient) decodeResponse(jsonData []byte) any { + switch c.responseType { + case "string": + return helpers.BytesToString(jsonData) + case "byte": + return jsonData + default: + c.Logger.Error(c, "Unknown response type", "response", c.responseType) + return nil + } } diff --git a/query_bench_test.go b/query_bench_test.go deleted file mode 100644 index 2c188c8..0000000 --- a/query_bench_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package gql - -// import ( -// "testing" -// ) - -// func Benchmark_GraphQL_Query(t *testing.B) { -// if testing.Short() { -// t.Skip("Skipping test in short / CI mode") -// } -// type args struct { -// queryContent string -// queryVariables interface{} -// } -// tests := []struct { -// name string -// endpoint string -// isLocal bool -// args args -// wantResult string -// mockedReply string -// wantErr bool -// }{ -// { -// name: "Valid query", -// endpoint: "http://hasura.local/v1/graphql", -// isLocal: false, -// args: args{ -// queryContent: `query listUserBots { -// tbl_bots(limit: 1) { -// bot_name -// } -// }`, -// queryVariables: nil, -// }, -// wantResult: `{"tbl_bots":[{"bot_name":"littleMentionBot"}]}`, -// mockedReply: `{"data":{"tbl_bots":[{"bot_name":"littleMentionBot"}]}}`, -// wantErr: false, -// }, -// { -// name: "Invalid query", -// endpoint: "http://hasura.local/v1/graphql", -// isLocal: false, -// args: args{ -// queryContent: `query listUserBots { -// tbl_botz(limit: 1) { -// bot_name -// } -// }`, -// queryVariables: nil, -// }, -// mockedReply: `{"errors":[{"message":"Unknown field \"tbl_botz\" on type \"Query\""}]}`, -// wantResult: ``, -// wantErr: true, -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.B) { -// g := NewConnection() -// server, serverURL := mockGraphQLServerResponses(tt.mockedReply) -// g.Endpoint = serverURL -// for n := 0; n < t.N; n++ { -// g.Query(tt.args.queryContent, tt.args.queryVariables, nil) -// } -// server.Close() -// }) -// } -// } diff --git a/query_test.go b/query_test.go index e3dcd8d..11d08d5 100644 --- a/query_test.go +++ b/query_test.go @@ -1,282 +1,288 @@ package gql import ( - "fmt" - "os" + "reflect" "testing" "github.com/stretchr/testify/assert" ) -func (suite *TestSuite) Test_GraphQL_queryBuilder() { - +func TestBaseClient_convertToJson(t *testing.T) { + type fields struct { + } type args struct { - queryVariables interface{} - queryContent string + v any } tests := []struct { - name string - args args - want []byte - wantErr bool + name string + fields fields + args args + want []byte }{ { - name: "Valid query", + name: "Test convertToJson", + fields: fields{}, args: args{ - queryContent: `query listUserBots { tbl_bots { bot_name } }`, - queryVariables: nil, + v: map[string]interface{}{ + "query": "query { hello }", + }, }, - want: []byte(`{"variables":null,"query":"query listUserBots { tbl_bots { bot_name } }"}`), - wantErr: false, + want: []byte(`{"query":"query { hello }"}`), }, { - name: "Valid query with variables", + name: "Test convertToJson with variables", + fields: fields{}, args: args{ - queryContent: `query listUserBots { tbl_bots { bot_name } }`, - queryVariables: map[string]interface{}{"user_id": 1}, + v: map[string]interface{}{ + "query": "query { hello }", + "variables": map[string]interface{}{"name": "John"}, + }, }, - want: []byte(`{"variables":{"user_id":1},"query":"query listUserBots { tbl_bots { bot_name } }"}`), - wantErr: false, + want: []byte(`{"query":"query { hello }","variables":{"name":"John"}}`), }, } for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - q := NewConnection() - got, gotErr := q.queryBuilder(tt.args.queryContent, tt.args.queryVariables) - assert.Equal(t, tt.want, got) - if tt.wantErr { - assert.Error(t, gotErr) + t.Run(tt.name, func(t *testing.T) { + c := &BaseClient{} + if got := c.convertToJson(tt.args.v); !reflect.DeepEqual(got, tt.want) { + t.Errorf("BaseClient.convertToJson() = %v, want %v", got, tt.want) } }) } } -func (suite *TestSuite) Test_GraphQL_Query() { - if testing.Short() { - suite.T().Skip("Skipping test in short / CI mode") +func TestBaseClient_NewQuery(t *testing.T) { + type fields struct { } - g := NewConnection() + type args struct { + q []any + } + tests := []struct { + fields fields + want *Query + name string + args args + }{ + { + name: "Test NewQuery", + fields: fields{}, + args: args{ + q: []any{ + "query { hello }", + }, + }, + want: &Query{ + compiledQuery: []byte(`{"variables":null,"query":"query { hello }"}`), + }, + }, + { + name: "Test NewQuery with variables", + fields: fields{}, + args: args{ + q: []any{ + "query { hello }", + map[string]interface{}{"name": "John"}, + }, + }, + want: &Query{ + compiledQuery: []byte(`{"variables":{"name":"John"},"query":"query { hello }"}`), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewConnection() + gotQuery := c.NewQuery(tt.args.q...) + assert.Equal(t, tt.want.query, gotQuery.query) + assert.Equal(t, tt.want.variables, gotQuery.variables) + assert.Equal(t, tt.want.compiledQuery, gotQuery.compiledQuery) + }) + } +} +func TestBaseClient_decodeResponse(t *testing.T) { + type fields struct { + responseType string + } + type args struct { + jsonData []byte + } + tests := []struct { + fields fields + want any + name string + args args + }{ + { + name: "Test decodeResponse - string", + fields: fields{ + responseType: "string", + }, + args: args{ + jsonData: []byte(`{"data":{"hello":"world"}}`), + }, + want: `{"data":{"hello":"world"}}`, + }, + { + name: "Test decodeResponse - byte", + fields: fields{ + responseType: "byte", + }, + args: args{ + jsonData: []byte(`{"data":{"hello":"world"}}`), + }, + want: []byte(`{"data":{"hello":"world"}}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewConnection() + c.responseType = tt.fields.responseType + if got := c.decodeResponse(tt.args.jsonData); !reflect.DeepEqual(got, tt.want) { + t.Errorf("BaseClient.decodeResponse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBaseClient_Query(t *testing.T) { + type fields struct { + graphql_endpoint string + repeat int + } type args struct { queryVariables interface{} queryHeaders map[string]interface{} queryContent string } tests := []struct { - args args - name string - endpoint string - wantResult string - isLocal bool - cache_enabled bool - wantErr bool + want any + args args + name string + fields fields + wantErr bool }{ { - name: "Valid query, no cache", - endpoint: "https://telegram-bot.app/v1/graphql", - isLocal: true, - cache_enabled: false, + name: "Test Query - failing", + fields: fields{}, args: args{ - queryContent: `query packages_prices { - sub_group_packages_aggregate(where: {enabled: {_eq: true}}) { - aggregate { - count - } - } - }`, - queryVariables: nil, + queryContent: "query { hello }", }, - wantResult: `{"sub_group_packages_aggregate":{"aggregate":{"count":4}}}`, - wantErr: false, + wantErr: true, }, { - name: "Valid query, no cache #2", - endpoint: "https://telegram-bot.app/v1/graphql", - isLocal: true, - cache_enabled: false, - args: args{ - queryContent: `query packages_prices { - sub_group_packages_aggregate(where: {enabled: {_eq: true}}) { - aggregate { - count - } - } - }`, - queryVariables: nil, + name: "Test Query - success", + fields: fields{ + graphql_endpoint: "https://spacex-production.up.railway.app/", }, - wantResult: `{"sub_group_packages_aggregate":{"aggregate":{"count":4}}}`, - wantErr: false, - }, - { - name: "Valid query, with cache empty", - endpoint: "https://telegram-bot.app/v1/graphql", - isLocal: true, - cache_enabled: true, args: args{ - queryContent: `query packages_prices { - sub_group_packages_aggregate(where: {enabled: {_eq: true}}) { - aggregate { - count - } + queryContent: `query Dragons { + dragons { + name } }`, - queryVariables: nil, - }, - wantResult: `{"sub_group_packages_aggregate":{"aggregate":{"count":4}}}`, - wantErr: false, - }, - { - name: "Valid query, with cache per query empty", - endpoint: "https://telegram-bot.app/v1/graphql", - isLocal: true, - cache_enabled: false, - args: args{ - queryContent: `query packages_prices_no_inlinecache { - sub_group_packages_aggregate(where: {enabled: {_eq: true}}) { - aggregate { - count - } - } - }`, - queryVariables: nil, queryHeaders: map[string]interface{}{ - "gqlcache": false, + "x-apollo-operation-name": "Missions-Potato-Test-Golang", + "content-type": "application/json", }, }, - wantResult: `{"sub_group_packages_aggregate":{"aggregate":{"count":4}}}`, - wantErr: false, + wantErr: false, + want: `{"dragons":[{"name":"Dragon 1"},{"name":"Dragon 2"}]}`, }, { - name: "Valid query, with cache per query", - endpoint: "https://telegram-bot.app/v1/graphql", - isLocal: true, - cache_enabled: false, + name: "Test Query - success", + fields: fields{ + repeat: 2, + graphql_endpoint: "https://spacex-production.up.railway.app/", + }, args: args{ - queryContent: `query packages_prices_inlinecache { - sub_group_packages_aggregate(where: {enabled: {_eq: true}}) { - aggregate { - count - } + queryContent: `query Dragons { + dragons { + name + first_flight } }`, - queryVariables: nil, queryHeaders: map[string]interface{}{ - "gqlcache": true, + "x-apollo-operation-name": "Missions-Potato-Test-Golang-Cached", + "content-type": "application/json", }, }, - wantResult: `{"sub_group_packages_aggregate":{"aggregate":{"count":4}}}`, - wantErr: false, - }, - { - name: "Valid query, with cache per query [verify]", - endpoint: "https://telegram-bot.app/v1/graphql", - isLocal: true, - cache_enabled: false, - args: args{ - queryContent: `query packages_prices_inlinecache { - sub_group_packages_aggregate(where: {enabled: {_eq: true}}) { - aggregate { - count - } - } - }`, - queryVariables: nil, - queryHeaders: map[string]interface{}{ - "gqlcache": true, - }, - }, - wantResult: `{"sub_group_packages_aggregate":{"aggregate":{"count":4}}}`, - wantErr: false, - }, - { - name: "Valid query, with cache filled", - endpoint: "https://telegram-bot.app/v1/graphql", - isLocal: true, - cache_enabled: true, - args: args{ - queryContent: `query packages_prices { - sub_group_packages_aggregate(where: {enabled: {_eq: true}}) { - aggregate { - count - } - } - }`, - queryVariables: nil, - }, - wantResult: `{"sub_group_packages_aggregate":{"aggregate":{"count":4}}}`, - wantErr: false, - }, - { - name: "Invalid query", - endpoint: "https://telegram-bot.app/v1/graphql", - isLocal: true, - args: args{ - queryContent: `query packages_pricez { - sub_group_packages_aggregate(where: {enabled: {_eq: true}}) { - aggregate { - count - } - }`, - queryVariables: nil, - }, - wantResult: ``, - wantErr: true, - }, - { - name: "Valid query to https endpoint", - endpoint: "https://telegram-bot.app/v1/graphql", - isLocal: false, - args: args{ - queryContent: `query packages_prices { - sub_group_packages_aggregate(where: {enabled: {_eq: true}}) { - aggregate { - count - } - } - }`, - queryVariables: nil, - }, - wantResult: `{"sub_group_packages_aggregate":{"aggregate":{"count":4}}}`, - wantErr: false, - }, - { - name: "Valid query to github endpoint", - endpoint: "https://api.github.com/graphql", - isLocal: false, - args: args{ - queryContent: `query { - repository(name: "semver-generator", owner: "lukaszraczylo", followRenames: true) { - releases(last: 2) { - nodes { - tag { - name - } - } - } - } - }`, - queryVariables: nil, - queryHeaders: map[string]interface{}{ - "Authorization": "Bearer " + os.Getenv("GITHUB_TOKEN"), - }, - }, - wantResult: `{"repository":{"releases":{"nodes":[`, - wantErr: false, + wantErr: false, + want: `{"dragons":[{"first_flight":"2010-12-08","name":"Dragon 1"},{"first_flight":"2019-03-02","name":"Dragon 2"}]}`, }, } for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - // if tt.isLocal { - // os.Setenv("GRAPHQL_ENDPOINT", tt.endpoint) - // } - g.Endpoint = tt.endpoint - g.Cache = tt.cache_enabled - gotResult, gotErr := g.Query(tt.args.queryContent, tt.args.queryVariables, tt.args.queryHeaders) - if tt.wantErr { - assert.Error(t, gotErr) + t.Run(tt.name, func(t *testing.T) { + c := NewConnection() + + if tt.fields.graphql_endpoint != "" { + c.endpoint = tt.fields.graphql_endpoint + } + + repeat := 1 + if tt.fields.repeat != 0 { + repeat = tt.fields.repeat + } + + for i := 0; i < repeat; i++ { + + got, err := c.Query(tt.args.queryContent, tt.args.queryVariables, tt.args.queryHeaders) + if (err != nil) != tt.wantErr { + t.Errorf("BaseClient.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("BaseClient.Query() = %v, want %v", got, tt.want) + } } - assert.Contains(t, gotResult, tt.wantResult) }) } - fmt.Println(g.CacheStore.Capacity()) +} + +/// benchmarks + +func BenchmarkBaseClient_convertToJson(b *testing.B) { + c := NewConnection() + query := c.NewQuery("query { hello }") + for i := 0; i < b.N; i++ { + _ = c.convertToJson(query) + } +} + +func BenchmarkNewQuery(b *testing.B) { + c := NewConnection() + for i := 0; i < b.N; i++ { + _ = c.NewQuery("query { hello }") + } +} + +func BenchmarkDecodeResponse(b *testing.B) { + c := NewConnection() + for i := 0; i < b.N; i++ { + _ = c.decodeResponse([]byte(`{"data":{"hello":"world"}}`)) + } +} + +func BenchmarkQueryNoCache(b *testing.B) { + c := NewConnection() + c.cache.enabled = false + for i := 0; i < b.N; i++ { + _, _ = c.Query(`query MyQuery { + bots { + bot_name + } + }`, nil, nil) + } +} + +func BenchmarkQueryWithCache(b *testing.B) { + c := NewConnection() + c.cache.enabled = true + for i := 0; i < b.N; i++ { + _, _ = c.Query(`query MyQuery { + bots { + bot_name + } + }`, nil, nil) + } } diff --git a/request.go b/request.go new file mode 100644 index 0000000..79c7397 --- /dev/null +++ b/request.go @@ -0,0 +1,44 @@ +package gql + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" +) + +func (c *BaseClient) executeQuery(query []byte, headers any) (result any, err error) { + var queryResult queryResults + httpRequest, err := http.NewRequest("POST", c.endpoint, bytes.NewBuffer(query)) + if err != nil { + c.Logger.Error(c, "Error while creating http request;", "error", err.Error()) + return + } + + for key, value := range headers.(map[string]interface{}) { + httpRequest.Header.Add(key, value.(string)) + } + + var httpResponse *http.Response + + httpResponse, err = c.client.Do(httpRequest) + if err != nil { + c.Logger.Error(c, "Error while executing http request;", "error", err.Error()) + return + } + defer httpResponse.Body.Close() + body, err := ioutil.ReadAll(httpResponse.Body) + if err != nil { + c.Logger.Error(c, "Error while reading http response;", "error", err.Error()) + return + } + err = json.Unmarshal(body, &queryResult) + if err != nil { + c.Logger.Error(c, "Error while unmarshalling http response;", "error", err.Error()) + return + } + if len(queryResult.Errors) > 0 { + return nil, fmt.Errorf("%v", queryResult.Errors) + } + return queryResult.Data, nil +} diff --git a/sample/client.go b/sample/client.go new file mode 100644 index 0000000..0048279 --- /dev/null +++ b/sample/client.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + + graphql "github.com/lukaszraczylo/go-simple-graphql" +) + +func main() { + gql := graphql.NewConnection() + + query := `query MyQuery { + bots { + bot_name + ddd + } + }` + _, err := gql.Query(query, nil, nil) + if err != nil { + fmt.Println("Error returned from query:", err) + } + + query = `query MyQuery { + badwords(distinct_on: word) { + word + } + }` + gql.Query(query, nil, nil) + + // Repeated to use the cached response + query = `query MyQuery { + badwords(distinct_on: word) { + word + } + }` + gql.Query(query, nil, nil) +} diff --git a/semver.yaml b/semver.yaml index 214c141..47d1ebb 100644 --- a/semver.yaml +++ b/semver.yaml @@ -1,8 +1,9 @@ version: 1 force: major: 1 - minor: 0 + minor: 1 patch: 0 + commit: 6a8f99f6ef8f65a303dad74c272ff12bf9fe526f wording: minor: - minor-release diff --git a/structs.go b/structs.go new file mode 100644 index 0000000..aa8d7e4 --- /dev/null +++ b/structs.go @@ -0,0 +1,50 @@ +package gql + +import ( + "context" + "net/http" + + "github.com/lukaszraczylo/go-simple-graphql/utils/concurrency" + "github.com/lukaszraczylo/go-simple-graphql/utils/logger" + + "github.com/allegro/bigcache" +) + +type cacheStore struct { + client *bigcache.BigCache + ttl int + enabled bool +} + +type BaseClient struct { + Logger Logger + LoggerWriter Writer + client *http.Client + concurrencyManager *concurrency.Pool + endpoint string + responseType string + cache cacheStore + MaxGoRoutines int + LoggingLevel logger.LogLevel + LoggerColorful bool + validate bool +} + +type Query struct { + context context.Context + variables map[string]any `json:"variables"` + query string `json:"query"` + compiledQuery []byte `json:"compiledQuery"` +} + +type request struct { + Variables any `json:"variables"` + Query string `json:"query"` +} + +type queryResults struct { + Data interface{} `json:"data"` + Errors []struct { + Message interface{} `json:"message"` + } `json:"errors"` +} diff --git a/utils/concurrency/concurrency.go b/utils/concurrency/concurrency.go new file mode 100644 index 0000000..5ccb124 --- /dev/null +++ b/utils/concurrency/concurrency.go @@ -0,0 +1,30 @@ +package concurrency + +type Pool struct { + queue chan struct{} + jobs int +} + +func NewPool(size int) *Pool { + if size < 0 { + size = 0 + } + return &Pool{ + jobs: size, + queue: make(chan struct{}, size), + } +} + +func (p *Pool) Enqueue(job func(params ...any), params ...any) { + if p.jobs == 0 { + go job(params...) + return + } + p.queue <- struct{}{} + go func() { + defer func() { + <-p.queue + }() + job(params...) + }() +} diff --git a/utils/concurrency/concurrency_test.go b/utils/concurrency/concurrency_test.go new file mode 100644 index 0000000..e81b7e2 --- /dev/null +++ b/utils/concurrency/concurrency_test.go @@ -0,0 +1,74 @@ +package concurrency + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewPool(t *testing.T) { + type args struct { + size int + } + tests := []struct { + want *Pool + name string + args args + }{ + { + name: "TestNewPool", + args: args{ + size: 0, + }, + want: &Pool{ + jobs: 0, + queue: make(chan struct{}, 0), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewPool(tt.args.size) + assert.Equal(t, got.jobs, tt.want.jobs) + assert.Equal(t, cap(got.queue), cap(tt.want.queue)) + }) + } +} + +func TestPool_Enqueue(t *testing.T) { + type fields struct { + queue chan struct{} + jobs int + } + type args struct { + job func(params ...any) + params []any + } + tests := []struct { + name string + fields fields + args args + }{ + { + name: "TestPool_Enqueue", + fields: fields{ + queue: make(chan struct{}, 0), + jobs: 0, + }, + args: args{ + job: func(params ...any) { + return + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Pool{ + queue: tt.fields.queue, + jobs: tt.fields.jobs, + } + p.Enqueue(tt.args.job, tt.args.params...) + }) + } +} diff --git a/utils/helpers/helpers.go b/utils/helpers/helpers.go new file mode 100644 index 0000000..5e0ed66 --- /dev/null +++ b/utils/helpers/helpers.go @@ -0,0 +1,13 @@ +package helpers + +import ( + "unsafe" +) + +func StringToBytes(s string) []byte { + return unsafe.Slice(unsafe.StringData(s), len(s)) +} + +func BytesToString(b []byte) string { + return unsafe.String(unsafe.SliceData(b), len(b)) +} diff --git a/utils/helpers/helpers_test.go b/utils/helpers/helpers_test.go new file mode 100644 index 0000000..49fbf71 --- /dev/null +++ b/utils/helpers/helpers_test.go @@ -0,0 +1,70 @@ +package helpers + +import ( + "reflect" + "testing" +) + +func TestStringToBytes(t *testing.T) { + type args struct { + s string + } + tests := []struct { + name string + args args + want []byte + }{ + { + name: "TestStringToBytes", + args: args{ + s: "hello world", + }, + want: []byte("hello world"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := StringToBytes(tt.args.s); !reflect.DeepEqual(got, tt.want) { + t.Errorf("StringToBytes() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBytesToString(t *testing.T) { + type args struct { + b []byte + } + tests := []struct { + name string + want string + args args + }{ + { + name: "TestBytesToString", + args: args{ + b: []byte("hello world"), + }, + want: "hello world", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := BytesToString(tt.args.b); got != tt.want { + t.Errorf("BytesToString() = %v, want %v", got, tt.want) + } + }) + } +} + +func BenchmarkStringToBytes(b *testing.B) { + for i := 0; i < b.N; i++ { + StringToBytes("hello world") + } +} + +func BenchmarkBytesToString(b *testing.B) { + for i := 0; i < b.N; i++ { + BytesToString([]byte("hello world")) + } +} diff --git a/utils/logger/logger.go b/utils/logger/logger.go new file mode 100644 index 0000000..8658275 --- /dev/null +++ b/utils/logger/logger.go @@ -0,0 +1,72 @@ +package logger + +import ( + "reflect" + + "github.com/lukaszraczylo/go-simple-graphql/utils/helpers" + "github.com/lukaszraczylo/go-simple-graphql/utils/ordered_map" + + jsoniter "github.com/json-iterator/go" +) + +var json = jsoniter.ConfigCompatibleWithStandardLibrary + +const ( + reset = "\033[0m" + redBold = "\033[1;31m" + greenBold = "\033[1;32m" + yellowBold = "\033[1;33m" + blueBold = "\033[1;34m" +) + +type LogLevel int + +const ( + Silent LogLevel = iota + Error + Warn + Info + Debug +) + +type Config struct { + Colorful bool + LogLevel LogLevel +} + +func Serialize(v any) (string, error) { + marshal, err := json.Marshal(internalSerialize(v)) + if err != nil { + return "", err + } + return helpers.BytesToString(marshal), nil +} + +func internalSerialize(v any) any { + if v != nil { + switch reflect.TypeOf(v).Kind() { + case reflect.Struct: + r := ordered_map.New() + name := reflect.TypeOf(v).Name() + if name != "" { + r.Set("_", reflect.TypeOf(v).String()) + } + for i := 0; i < reflect.ValueOf(v).NumField(); i++ { + r.Set(reflect.TypeOf(v).Field(i).Name, internalSerialize(reflect.ValueOf(v).Field(i).Interface())) + } + return r + case reflect.Ptr: + if !reflect.ValueOf(v).IsNil() { + return internalSerialize(reflect.ValueOf(v).Elem().Interface()) + } + case reflect.Slice: + var r []any + tmpSlice := reflect.ValueOf(v) + for i := 0; i < tmpSlice.Len(); i++ { + r = append(r, internalSerialize(tmpSlice.Index(i).Interface())) + } + return r + } + } + return v +} diff --git a/utils/logger/logger_test.go b/utils/logger/logger_test.go new file mode 100644 index 0000000..03b27a7 --- /dev/null +++ b/utils/logger/logger_test.go @@ -0,0 +1,84 @@ +package logger + +import "testing" + +type Person struct { + Name string + Age int +} + +func TestSerializeStruct(t *testing.T) { + person := Person{Name: "Alice", Age: 30} + expected := `{"_":"logger.Person","Name":"Alice","Age":30}` + actual, err := Serialize(person) + if err != nil { + t.Errorf("Error: %v", err) + } + if actual != expected { + t.Errorf("Expected: %v\nActual: %v", expected, actual) + } +} + +func TestSerializeNilPointer(t *testing.T) { + var person *Person + expected := `null` + actual, err := Serialize(person) + if err != nil { + t.Errorf("Error: %v", err) + } + if actual != expected { + t.Errorf("Expected: %v\nActual: %v", expected, actual) + } +} + +func TestSerializeNonNilPointer(t *testing.T) { + person := &Person{Name: "Alice", Age: 30} + expected := `{"_":"logger.Person","Name":"Alice","Age":30}` + actual, err := Serialize(person) + if err != nil { + t.Errorf("Error: %v", err) + } + if actual != expected { + t.Errorf("Expected: %v\nActual: %v", expected, actual) + } +} + +func TestSerializeSlice(t *testing.T) { + people := []Person{ + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 40}, + } + expected := `[{"_":"logger.Person","Name":"Alice","Age":30},{"_":"logger.Person","Name":"Bob","Age":40}]` + actual, err := Serialize(people) + if err != nil { + t.Errorf("Error: %v", err) + } + if actual != expected { + t.Errorf("Expected: %v\nActual: %v", expected, actual) + } +} + +func TestSerializeSliceOfPointers(t *testing.T) { + people := []*Person{ + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 40}, + } + expected := `[{"_":"logger.Person","Name":"Alice","Age":30},{"_":"logger.Person","Name":"Bob","Age":40}]` + + actual, err := Serialize(people) + if err != nil { + t.Errorf("Error: %v", err) + } + if actual != expected { + t.Errorf("Expected: %v\nActual: %v", expected, actual) + } +} + +func TestSerializeUnsupportedType(t *testing.T) { + type UnsupportedType chan int + unsupportedType := make(UnsupportedType) + _, err := Serialize(unsupportedType) + if err == nil { + t.Errorf("Expected an error, but got nil") + } +} diff --git a/utils/ordered_map/ordered_map.go b/utils/ordered_map/ordered_map.go new file mode 100644 index 0000000..075c886 --- /dev/null +++ b/utils/ordered_map/ordered_map.go @@ -0,0 +1,64 @@ +package ordered_map + +import ( + "bytes" + + jsoniter "github.com/json-iterator/go" +) + +var json = jsoniter.ConfigCompatibleWithStandardLibrary + +type OrderedMap struct { + values map[string]interface{} + keys []string +} + +func New() *OrderedMap { + o := OrderedMap{} + o.keys = []string{} + o.values = map[string]interface{}{} + return &o +} + +func (o *OrderedMap) Set(key string, value interface{}) { + _, exists := o.values[key] + if !exists { + o.keys = append(o.keys, key) + } + o.values[key] = value +} + +func (o OrderedMap) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", "") + for i, k := range o.keys { + if i > 0 { + buf.WriteByte(',') + } + buf.WriteByte('"') + buf.WriteString(k) + buf.WriteString(`":`) + customEncoder := &customJsonEncoder{buf: &buf} + if err := customEncoder.Encode(o.values[k]); err != nil { + return nil, err + } + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +type customJsonEncoder struct { + buf *bytes.Buffer +} + +func (e *customJsonEncoder) Encode(v interface{}) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + e.buf.Write(data) + return nil +} diff --git a/utils/ordered_map/ordered_map_test.go b/utils/ordered_map/ordered_map_test.go new file mode 100644 index 0000000..fc6fc43 --- /dev/null +++ b/utils/ordered_map/ordered_map_test.go @@ -0,0 +1,164 @@ +package ordered_map + +import ( + "reflect" + "testing" +) + +func TestNew(t *testing.T) { + tests := []struct { + want *OrderedMap + name string + }{ + { + name: "TestNew", + want: &OrderedMap{ + keys: []string{}, + values: map[string]interface{}{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := New(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("New() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOrderedMap_Set(t *testing.T) { + type fields struct { + values map[string]interface{} + keys []string + } + type args struct { + value interface{} + key string + } + tests := []struct { + args args + name string + fields fields + }{ + { + name: "TestOrderedMap_Set", + fields: fields{ + values: map[string]interface{}{}, + keys: []string{}, + }, + args: args{}, + }, + { + name: "TestOrderedMap_Set2", + fields: fields{ + values: map[string]interface{}{ + "hello": "world", + }, + keys: []string{"hello"}, + }, + args: args{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &OrderedMap{ + values: tt.fields.values, + keys: tt.fields.keys, + } + o.Set(tt.args.key, tt.args.value) + for _, key := range o.keys { + if _, ok := o.values[key]; !ok { + t.Errorf("OrderedMap.Set() key not found in values map") + } + } + }) + } +} + +func TestOrderedMap_MarshalJSON(t *testing.T) { + type fields struct { + values map[string]interface{} + keys []string + } + tests := []struct { + name string + fields fields + want []byte + wantErr bool + }{ + { + name: "TestOrderedMap_MarshalJSON", + fields: fields{ + values: map[string]interface{}{ + "hello": "world", + }, + keys: []string{"hello"}, + }, + want: []byte(`{"hello":"world"}`), + wantErr: false, + }, + { + name: "TestOrderedMap_MarshalJSON2", + fields: fields{ + values: map[string]interface{}{ + "hello": "world", + "foo": "bar", + }, + keys: []string{"hello", "foo"}, + }, + want: []byte(`{"hello":"world","foo":"bar"}`), + wantErr: false, + }, + { + name: "TestOrderedMap_MarshalJSON_with_error", + fields: fields{ + values: map[string]interface{}{ + "hello": "world", + "foo": make(chan int), + }, + keys: []string{"hello", "foo"}, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := OrderedMap{ + values: tt.fields.values, + keys: tt.fields.keys, + } + got, err := o.MarshalJSON() + if (err != nil) != tt.wantErr { + t.Errorf("OrderedMap.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("OrderedMap.MarshalJSON() = %v, want %v", got, tt.want) + } + }) + } +} + +func BenchmarkOrderedMapTest(b *testing.B) { + m := New() + for i := 0; i < b.N; i++ { + m.Set("hello", "world") + } +} + +func BenchmarkOrderedMarshalJSON(b *testing.B) { + m := New() + for i := 0; i < b.N; i++ { + m.Set("hello", "world") + m.MarshalJSON() + } +} + +func BenchmarkMapTest(b *testing.B) { + m := make(map[string]interface{}) + for i := 0; i < b.N; i++ { + m["hello"] = "world" + } +}