From 4fc826c1f64e014f3cb41ca0a11680ae2584e7bf Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Tue, 12 Dec 2023 21:30:26 +0000 Subject: [PATCH] Complete rewrite of the library. --- base.go | 100 ---- base_test.go | 89 ---- cache.go | 14 - cache/cache.go | 148 ++++++ cache/cache_test.go | 148 ++++++ execute_query.go | 100 ++++ go.mod | 20 +- go.sum | 44 +- helpers.go | 43 ++ helpers_test.go | 43 ++ http.go | 50 ++ logging/logging.go | 134 +++++ logging/logging_bench_test.go | 32 ++ main.go | 36 ++ main_bench_test.go | 9 + main_test.go | 21 + query.go | 194 +++----- query_bench_test.go | 23 + query_test.go | 692 ++++++++------------------ request.go | 115 ----- semver.yaml | 4 +- structs.go | 71 +-- suite_test.go | 27 + utils/helpers/helpers.go | 13 - utils/helpers/helpers_test.go | 70 --- utils/ordered_map/ordered_map.go | 121 ----- utils/ordered_map/ordered_map_test.go | 164 ------ 27 files changed, 1153 insertions(+), 1372 deletions(-) delete mode 100644 base.go delete mode 100644 base_test.go delete mode 100644 cache.go create mode 100644 cache/cache.go create mode 100644 cache/cache_test.go create mode 100644 execute_query.go create mode 100644 helpers.go create mode 100644 helpers_test.go create mode 100644 http.go create mode 100644 logging/logging.go create mode 100644 logging/logging_bench_test.go create mode 100644 main.go create mode 100644 main_bench_test.go create mode 100644 main_test.go create mode 100644 query_bench_test.go delete mode 100644 request.go create mode 100644 suite_test.go delete mode 100644 utils/helpers/helpers.go delete mode 100644 utils/helpers/helpers_test.go delete mode 100644 utils/ordered_map/ordered_map.go delete mode 100644 utils/ordered_map/ordered_map_test.go diff --git a/base.go b/base.go deleted file mode 100644 index a58d27b..0000000 --- a/base.go +++ /dev/null @@ -1,100 +0,0 @@ -package gql - -import ( - "crypto/tls" - "fmt" - "time" - - libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache" - libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" - - "golang.org/x/net/http2" - - "net/http" - "strings" - - "github.com/gookit/goutil/envutil" -) - -func NewConnection() *BaseClient { - - b := &BaseClient{ - client: &http.Client{}, - MaxGoRoutines: -1, - } - - 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) - - b.retries.enabled = envutil.GetBool("GRAPHQL_RETRIES_ENABLED", true) - b.retries.max = envutil.GetInt("GRAPHQL_RETRIES_MAX", 1) - b.retries.delay = envutil.GetInt("GRAPHQL_RETRIES_DELAY", 300) - - b.enableCache() - - b_tmp_log_level := envutil.Getenv("LOG_LEVEL", "info") - - b.Logger = libpack_logging.NewLogger() - - var httpClient *http.Client - - httpTransport := &http.Transport{ - MaxIdleConns: 10, - IdleConnTimeout: 15 * time.Second, - ResponseHeaderTimeout: 15 * time.Second, - DisableKeepAlives: false, - } - - if strings.HasPrefix(b.endpoint, "http://") { - httpClient = &http.Client{ - Transport: httpTransport, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - } else { - tlsClientConfig := &tls.Config{} - if strings.HasPrefix(b.endpoint, "https://") { - tlsClientConfig.InsecureSkipVerify = true - } - http2Transport := &http2.Transport{ - AllowHTTP: true, - TLSClientConfig: tlsClientConfig, - ReadIdleTimeout: 15 * time.Second, - PingTimeout: 15 * time.Second, - } - httpClient = &http.Client{ - Transport: http2Transport, - } - } - b.client = httpClient - - b.Logger.Debug("GraphQL client initialized", map[string]interface{}{"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}) - b.Logger.Info("GraphQL client initialized") - return b -} - -func (b *BaseClient) SetEndpoint(endpoint string) { - b.endpoint = endpoint -} - -func (b *BaseClient) SetOutput(output string) { - b.responseType = output -} - -func (b *BaseClient) enableCache() { - var err error - b.cache.client = libpack_cache.New(time.Duration(b.cache.ttl) * time.Second * 2) - if err != nil { - fmt.Println(">> Error while creating cache client;", "error", err.Error()) - panic(err) - } -} - -func (b *BaseClient) disableCache() { - b.Logger.Debug("Disabling cache") - b.cache.enabled = false -} diff --git a/base_test.go b/base_test.go deleted file mode 100644 index d7a415e..0000000 --- a/base_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package gql - -import ( - "os" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewConnection(t *testing.T) { - tests := []struct { - want *BaseClient - cache bool - endpoint string - output string - name string - }{ - { - name: "Test NewConnection https", - endpoint: "https://api.github.com/graphql", - output: "string", - cache: true, - want: &BaseClient{ - endpoint: "https://api.github.com/graphql", - responseType: "string", - }, - }, - { - name: "Test NewConnection http", - endpoint: "http://api.github.com/graphql", - output: "mapstring", - cache: false, - want: &BaseClient{ - endpoint: "http://api.github.com/graphql", - responseType: "mapstring", - }, - }, - } - 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", tt.endpoint) - got := NewConnection() - got.SetEndpoint(tt.endpoint) - got.SetOutput(tt.output) - if tt.cache { - got.enableCache() - } else { - got.disableCache() - } - - assert.Equal(t, tt.want.endpoint, got.endpoint) - assert.Equal(t, tt.want.responseType, got.responseType) - assert.Equal(t, tt.cache, got.cache.enabled) - }) - } -} - -func TestBaseClient_SetEndpoint(t *testing.T) { - type fields struct { - } - type args struct { - endpoint string - } - tests := []struct { - name string - fields fields - args args - want string - }{ - { - name: "Test SetEndpoint", - args: args{ - endpoint: "https://potato/graphql", - }, - want: "https://potato/graphql", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b := NewConnection() - b.SetEndpoint(tt.args.endpoint) - assert.Equal(t, tt.want, b.endpoint) - }) - } -} diff --git a/cache.go b/cache.go deleted file mode 100644 index 8eb7af6..0000000 --- a/cache.go +++ /dev/null @@ -1,14 +0,0 @@ -package gql - -func (c *BaseClient) cacheLookup(hash string) []byte { - if c.cache.client != nil { - obj, found := c.cache.client.Get(hash) - if found { - c.Logger.Debug("Cache hit", map[string]interface{}{"hash": hash}) - return obj - } - // if error is not nil it means that hash is not present in cache - c.Logger.Debug("Cache miss", map[string]interface{}{"hash": hash}) - } - return nil -} diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..e5d50cf --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,148 @@ +package libpack_cache + +import ( + "bytes" + "compress/gzip" + "flag" + "io" + "sync" + "time" +) + +type CacheEntry struct { + ExpiresAt time.Time + Value []byte +} + +type Cache struct { + bytePool sync.Pool + entries sync.Map + globalTTL time.Duration + sync.RWMutex +} + +func New(globalTTL time.Duration) *Cache { + cache := &Cache{ + globalTTL: globalTTL, + } + + // Initialize the byte pool. + cache.bytePool.New = func() interface{} { + return make([]byte, 0) + } + + // Start the cache cleanup. + if flag.Lookup("test.v") == nil { + go cache.cleanupRoutine(globalTTL) + } + return cache +} + +func (c *Cache) cleanupRoutine(globalTTL time.Duration) { + ticker := time.NewTicker(globalTTL / 2) + defer ticker.Stop() + + for range ticker.C { + c.CleanExpiredEntries() + } +} + +func (c *Cache) Set(key string, value []byte, ttl time.Duration) { + c.Lock() + defer c.Unlock() + expiresAt := time.Now().Add(ttl) + + compressedValue, err := c.compress(value) + if err != nil { + return + } + + // Get a byte slice from the pool and ensure it's properly sized. + b := c.bytePool.Get().([]byte) + if cap(b) < len(compressedValue) { + b = make([]byte, len(compressedValue)) + } else { + b = b[:len(compressedValue)] + } + + copy(b, compressedValue) + + entry := CacheEntry{ + Value: b, + ExpiresAt: expiresAt, + } + c.entries.Store(key, entry) +} + +func (c *Cache) Get(key string) ([]byte, bool) { + c.RLock() + defer c.RUnlock() + + entry, ok := c.entries.Load(key) + if !ok || entry.(CacheEntry).ExpiresAt.Before(time.Now()) { + return nil, false + } + compressedValue := entry.(CacheEntry).Value + value, err := c.decompress(compressedValue) + if err != nil { + return nil, false + } + + return value, true +} + +func (c *Cache) Delete(key string) { + c.Lock() + defer c.Unlock() + + entry, ok := c.entries.Load(key) + if !ok { + return + } + + // Return the byte slice to the pool. + c.bytePool.Put(entry.(CacheEntry).Value) + + // Delete the entry from the cache. + c.entries.Delete(key) +} + +func (c *Cache) CleanExpiredEntries() { + now := time.Now() + c.entries.Range(func(key, value interface{}) bool { + entry := value.(CacheEntry) + if entry.ExpiresAt.Before(now) { + // Return the byte slice to the pool. + c.bytePool.Put(entry.Value) + + // Delete the entry from the cache. + c.entries.Delete(key) + } + + // Return true to continue iterating over the map. + return true + }) +} + +func (c *Cache) compress(data []byte) ([]byte, error) { + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + _, err := w.Write(data) + if err != nil { + return nil, err + } + err = w.Close() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func (c *Cache) decompress(data []byte) ([]byte, error) { + r, err := gzip.NewReader(bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} diff --git a/cache/cache_test.go b/cache/cache_test.go new file mode 100644 index 0000000..7f48541 --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,148 @@ +package libpack_cache + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type CacheTestSuite struct { + suite.Suite +} + +func (suite *CacheTestSuite) SetupTest() { +} + +func TestCachingTestSuite(t *testing.T) { + suite.Run(t, new(CacheTestSuite)) +} + +func (suite *CacheTestSuite) Test_New() { + suite.T().Run("should return a new cache", func(t *testing.T) { + cache := New(2 * time.Second) + suite.NotNil(cache) + }) +} + +func (suite *CacheTestSuite) Test_CacheUse() { + cache := New(30 * time.Second) + tests := []struct { + name string + cache_value string + }{ + { + name: "test1", + cache_value: "test1-123", + }, + { + name: "test2", + cache_value: "test2-123", + }, + } + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cache.Set(tt.name, []byte(tt.name), 5*time.Second) + c, ok := cache.Get(tt.name) + suite.Equal(true, ok) + suite.Equal(tt.name, string(c)) + }) + } +} + +func (suite *CacheTestSuite) Test_CacheDelete() { + cache := New(30 * time.Second) + tests := []struct { + name string + cache_value string + }{ + { + name: "test1", + cache_value: "test1-123", + }, + { + name: "test2", + cache_value: "test2-123", + }, + } + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cache.Set(tt.name, []byte(tt.name), 5*time.Second) + c, ok := cache.Get(tt.name) + suite.Equal(true, ok) + suite.Equal(tt.name, string(c)) + cache.Delete(tt.name) + c, ok = cache.Get(tt.name) + suite.Equal(false, ok) + suite.Equal("", string(c)) + }) + } +} + +func (suite *CacheTestSuite) Test_CacheExpire() { + cache := New(30 * time.Second) + tests := []struct { + name string + cache_value string + ttl time.Duration + }{ + { + name: "test1", + cache_value: "test1-123", + ttl: 2 * time.Second, + }, + { + name: "test2", + cache_value: "test2-123", + ttl: 5 * time.Second, + }, + } + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cache.Set(tt.name, []byte(tt.name), tt.ttl) + c, ok := cache.Get(tt.name) + suite.Equal(true, ok) + suite.Equal(tt.name, string(c)) + time.Sleep(tt.ttl) + c, ok = cache.Get(tt.name) + suite.Equal(false, ok) + suite.Equal("", string(c)) + }) + } +} + +func (suite *CacheTestSuite) Test_CacheCleanExpiredEntries() { + cache := New(5 * time.Second) + tests := []struct { + name string + cache_value string + ttl time.Duration + }{ + { + name: "test1", + cache_value: "test1-123", + ttl: 2 * time.Second, + }, + { + name: "test2", + cache_value: "test2-123", + ttl: 5 * time.Second, + }, + } + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cache.Set(tt.name, []byte(tt.name), tt.ttl) + c, ok := cache.Get(tt.name) + suite.Equal(true, ok) + suite.Equal(tt.name, string(c)) + time.Sleep(tt.ttl) + c, ok = cache.Get(tt.name) + suite.Equal(false, ok) + suite.Equal("", string(c)) + cache.CleanExpiredEntries() + c, ok = cache.Get(tt.name) + suite.Equal(false, ok) + suite.Equal("", string(c)) + }) + } +} diff --git a/execute_query.go b/execute_query.go new file mode 100644 index 0000000..0e2e905 --- /dev/null +++ b/execute_query.go @@ -0,0 +1,100 @@ +package gql + +import ( + "bytes" + "fmt" + "io" + "net/http" + "time" + + "github.com/avast/retry-go/v4" +) + +func (qe *QueryExecutor) executeQuery() ([]byte, error) { + httpRequest, err := http.NewRequest("POST", qe.endpoint, bytes.NewBuffer(qe.Query)) + if err != nil { + qe.Logger.Error("Can't create HTTP request;", map[string]interface{}{"error": err}) + return nil, err + } + + for key, value := range qe.Headers { + httpRequest.Header.Set(key, value.(string)) + } + + var retries_max int + + if qe.Retries { + qe.Logger.Debug("Retries enabled - setting max retries", map[string]interface{}{"retries": qe.retries_number}) + retries_max = qe.retries_number + } else { + qe.Logger.Debug("Retries disabled - setting max retries", map[string]interface{}{"retries": 1}) + retries_max = 1 + } + + var httpResponse *http.Response + var queryResult queryResults + + err = retry.Do( + func() error { + httpResponse, err = qe.client.Do(httpRequest) + if err != nil { + qe.Logger.Debug("Error while executing http request", map[string]interface{}{"error": err.Error()}) + return err + } + defer io.Copy(io.Discard, httpResponse.Body) + defer httpResponse.Body.Close() + + if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 204 { + return fmt.Errorf("HTTP error - unacceptable status code: \"%s\" for \"%s\"", httpResponse.Status, httpRequest.URL) + } + + body, err := io.ReadAll(httpResponse.Body) + if err != nil { + qe.Logger.Debug("Error while reading http response;", map[string]interface{}{"error": err.Error()}) + return fmt.Errorf("Error while reading http response: %s", err.Error()) + } + + err = json.Unmarshal(body, &queryResult) + if err != nil { + qe.Logger.Debug("Error while unmarshalling http response;", map[string]interface{}{"error": err.Error()}) + return fmt.Errorf("Error while unmarshalling http response: %s", err.Error()) + } + + return nil + }, + retry.OnRetry(func(n uint, err error) { + qe.Logger.Warning("Retrying query", map[string]interface{}{"error": err.Error(), "attempt": n}) + }), + retry.Attempts(uint(retries_max)), + retry.DelayType(retry.BackOffDelay), + retry.Delay(time.Duration(qe.retries_delay)), + retry.LastErrorOnly(true), + ) + + if err != nil { + qe.Logger.Debug("Error while executing http request - target server", map[string]interface{}{"error": err.Error()}) + return nil, err + } + + if len(queryResult.Errors) > 0 { + qe.Logger.Debug("Error while executing query;", map[string]interface{}{"error": queryResult.Errors}) + return nil, fmt.Errorf("Error while executing query: %s", queryResult.Errors) + } + + if queryResult.Data == nil { + qe.Logger.Debug("Error while executing query", map[string]interface{}{"error": "no data"}) + return nil, fmt.Errorf("Error while executing query: no data") + } + + json_data, err := json.Marshal(queryResult.Data) + if err != nil { + qe.Logger.Debug("Error while marshalling query result;", map[string]interface{}{"error": err.Error(), "data": queryResult.Data}) + return nil, fmt.Errorf("Error while marshalling query result: %s. Data: %s", err.Error(), queryResult.Data) + } + + if qe.CacheKey != "no-cache" { + qe.cache.Set(qe.CacheKey, json_data, qe.CacheTTL) + } + + return json_data, nil +} diff --git a/go.mod b/go.mod index 3a4955b..afbe628 100644 --- a/go.mod +++ b/go.mod @@ -1,30 +1,28 @@ module github.com/lukaszraczylo/go-simple-graphql -go 1.21 +go 1.21.5 require ( - github.com/avast/retry-go/v4 v4.5.0 + github.com/avast/retry-go/v4 v4.5.1 github.com/gookit/goutil v0.6.14 github.com/json-iterator/go v1.1.12 - github.com/lukaszraczylo/graphql-monitoring-proxy v0.5.30 + github.com/rs/zerolog v1.31.0 github.com/stretchr/testify v1.8.4 - golang.org/x/net v0.17.0 + golang.org/x/net v0.19.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/gookit/color v1.5.4 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rs/zerolog v1.31.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.4.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/term v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ae032a3..3a5390b 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,6 @@ -github.com/avast/retry-go/v4 v4.5.0 h1:QoRAZZ90cj5oni2Lsgl2GW8mNTnUCnmpx/iKpwVisHg= -github.com/avast/retry-go/v4 v4.5.0/go.mod h1:7hLEXp0oku2Nir2xBAsg0PTphp9z71bN5Aq1fboC3+I= -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/avast/retry-go/v4 v4.5.1 h1:AxIx0HGi4VZ3I02jr78j5lZ3M6x1E0Ivxa6b0pUUh7o= +github.com/avast/retry-go/v4 v4.5.1/go.mod h1:/sipNsvNB3RRuT5iNcb6h73nw3IBmXJ/H3XrCQYSOpc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -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= @@ -15,28 +12,18 @@ github.com/gookit/goutil v0.6.14 h1:96elyOG4BvVoDaiT7vx1vHPrVyEtFfYlPPBODR0/FGQ= github.com/gookit/goutil v0.6.14/go.mod h1:YyDBddefmjS+mU2PDPgCcjVzTDM5WgExiDv5ZA/b8I8= 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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lukaszraczylo/graphql-monitoring-proxy v0.5.30 h1:IBYANBymBwZZvaE6ct33YdLXJbZ4h/efKPm0kaPZzrk= -github.com/lukaszraczylo/graphql-monitoring-proxy v0.5.30/go.mod h1:arpPBqH2dAxTX1WPWpYi95CN/dvXb9ojoMIitMglLUI= 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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 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/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= @@ -46,23 +33,22 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/helpers.go b/helpers.go new file mode 100644 index 0000000..c286bb5 --- /dev/null +++ b/helpers.go @@ -0,0 +1,43 @@ +package gql + +import ( + "fmt" + + "github.com/gookit/goutil/strutil" +) + +func searchForKeysInMapStringInterface(msi map[string]interface{}, key string) (value any) { + if msi == nil { + return nil + } + return msi[key] +} + +func calculateHash(query *Query) string { + return strutil.Md5(query.JsonQuery) +} + +func (b *BaseClient) cacheLookup(hash string) []byte { + obj, _ := b.cache.Get(hash) + return obj +} + +func (b *BaseClient) decodeResponse(response []byte) (any, error) { + switch b.responseType { + case "mapstring": + var result map[string]interface{} + err := json.Unmarshal(response, &result) + if err != nil { + b.Logger.Error("Can't decode response into mapstring", map[string]interface{}{"error": err.Error()}) + return nil, err + } + return result, nil + case "string": + return string(response), nil + case "byte": + return response, nil + default: + b.Logger.Error("Can't decode response;", map[string]interface{}{"error": "unknown response type"}) + return nil, fmt.Errorf("Can't decode response - unknown response type specified") + } +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..51092b2 --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,43 @@ +package gql + +import "testing" + +func (suite *Tests) Test_searchForKeysInMapStringInterface() { + type args struct { + msi map[string]interface{} + key string + } + tests := []struct { + wantValue any + args args + name string + }{ + { + name: "Test_searchForKeysInMapStringInterface_exists", + args: args{ + msi: map[string]interface{}{ + "gqlcache": true, + "test": "test2", + }, + key: "test", + }, + wantValue: "test2", + }, + { + name: "Test_searchForKeysInMapStringInterface_not_exists", + args: args{ + msi: map[string]interface{}{ + "gqlcache": true, + "test": "test2", + }, + key: "test-new", + }, + wantValue: nil, + }, + } + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + assert.Equal(tt.wantValue, searchForKeysInMapStringInterface(tt.args.msi, tt.args.key)) + }) + } +} diff --git a/http.go b/http.go new file mode 100644 index 0000000..4b83a48 --- /dev/null +++ b/http.go @@ -0,0 +1,50 @@ +package gql + +import ( + "crypto/tls" + "net/http" + "strings" + "time" + + "golang.org/x/net/http2" +) + +func (b *BaseClient) createHttpClient() (http_client *http.Client) { + httpTransport := &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 15 * time.Second, + ResponseHeaderTimeout: 15 * time.Second, + DisableKeepAlives: false, + } + + if strings.HasPrefix(b.endpoint, "http://") { + http_client = &http.Client{ + Transport: httpTransport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + b.Logger.Debug("Using HTTP/1.1 over http") + } else if strings.HasPrefix(b.endpoint, "https://") { + tlsClientConfig := &tls.Config{} + if strings.HasPrefix(b.endpoint, "https://") { + tlsClientConfig.InsecureSkipVerify = true + } + http2Transport := &http2.Transport{ + AllowHTTP: true, + TLSClientConfig: tlsClientConfig, + ReadIdleTimeout: 15 * time.Second, + PingTimeout: 15 * time.Second, + } + http_client = &http.Client{ + Transport: http2Transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + b.Logger.Debug("Using HTTP/2 over https") + } else { + b.Logger.Critical("Invalid endpoint - neither http or https") + } + return http_client +} diff --git a/logging/logging.go b/logging/logging.go new file mode 100644 index 0000000..5f10ff9 --- /dev/null +++ b/logging/logging.go @@ -0,0 +1,134 @@ +package libpack_logging + +import ( + "os" + "strings" + "sync" + "time" + + "github.com/gookit/goutil/envutil" + "github.com/rs/zerolog" +) + +type LogConfig struct { + baseLogger zerolog.Logger + errorLogger zerolog.Logger + nopLogger zerolog.Logger + mu sync.Mutex + minLevel zerolog.Level +} + +func init() { + zerolog.TimeFieldFormat = time.RFC3339 + zerolog.MessageFieldName = "msg" + zerolog.TimestampFieldName = "ts" + zerolog.LevelFieldName = "level" + zerolog.LevelFatalValue = "critical" +} + +func getMinLogLevel() zerolog.Level { + levelStr := strings.ToLower(envutil.Getenv("LOG_LEVEL", "info")) + return matchLogLevel(levelStr) +} + +// Add function set the minimum log level instead of using environment variable +func (lc *LogConfig) SetLogLevel(level string) { + lc.mu.Lock() + defer lc.mu.Unlock() + lc.minLevel = matchLogLevel(level) +} + +// Match the log level provided as string with the log level provided as zerolog.Level +func matchLogLevel(level string) zerolog.Level { + switch level { + case "debug": + return zerolog.DebugLevel + case "warn": + return zerolog.WarnLevel + case "error": + return zerolog.ErrorLevel + case "critical": + return zerolog.FatalLevel + default: + return zerolog.InfoLevel + } +} + +func NewLogger() *LogConfig { + zl := zerolog.New(os.Stdout).With().Timestamp().Logger() + // Determine the minimum log level from environment variable + return &LogConfig{ + baseLogger: zl, + errorLogger: zl.Output(os.Stderr), + nopLogger: zerolog.Nop(), + minLevel: getMinLogLevel(), + } +} + +func (lc *LogConfig) getLogger(level zerolog.Level) zerolog.Logger { + if level >= zerolog.ErrorLevel { + return lc.errorLogger + } + if level < lc.minLevel { + return lc.nopLogger + } + return lc.baseLogger +} + +func (lc *LogConfig) Log(level zerolog.Level, message string, fields []map[string]interface{}) { + if level < lc.minLevel { + return + } + logger := lc.getLogger(level) + event := logger.WithLevel(level).CallerSkipFrame(3) + if len(fields) == 0 { + event.Msg(message) + return + } + + field := fields[0] + for k, val := range field { + switch v := val.(type) { + case string: + event.Str(k, v) + case int: + event.Int(k, v) + case float64: + event.Float64(k, v) + default: + event.Interface(k, v) + } + } + event.Msg(message) +} + +func (lc *LogConfig) Info(message string, fields ...map[string]interface{}) { + lc.Log(zerolog.InfoLevel, message, fields) +} + +func (lc *LogConfig) Debug(message string, fields ...map[string]interface{}) { + lc.Log(zerolog.DebugLevel, message, fields) +} + +func (lc *LogConfig) Warn(message string, fields ...map[string]interface{}) { + lc.Log(zerolog.WarnLevel, message, fields) +} + +// alias Warning to Warn +func (lc *LogConfig) Warning(message string, fields ...map[string]interface{}) { + lc.Warn(message, fields...) +} + +func (lc *LogConfig) Error(message string, fields ...map[string]interface{}) { + lc.Log(zerolog.ErrorLevel, message, fields) +} + +func (lc *LogConfig) Critical(message string, fields ...map[string]interface{}) { + lc.Log(zerolog.FatalLevel, message, fields) + os.Exit(1) +} + +// alias Fatal to Critical +func (lc *LogConfig) Fatal(message string, fields ...map[string]interface{}) { + lc.Critical(message, fields...) +} diff --git a/logging/logging_bench_test.go b/logging/logging_bench_test.go new file mode 100644 index 0000000..68da011 --- /dev/null +++ b/logging/logging_bench_test.go @@ -0,0 +1,32 @@ +package libpack_logging + +import ( + "os" + "testing" +) + +func BenchmarkNewLogger(b *testing.B) { + for i := 0; i < b.N; i++ { + NewLogger() + } +} + +func BenchmarkInfoLog(b *testing.B) { + oldEnv := os.Getenv("LOG_LEVEL") + os.Setenv("LOG_LEVEL", "info") + oldStdout := os.Stdout + oldStderr := os.Stderr + os.Stdout, _ = os.Open(os.DevNull) + os.Stderr, _ = os.Open(os.DevNull) + defer func() { + os.Stdout = oldStdout + os.Stderr = oldStderr + os.Setenv("LOG_LEVEL", oldEnv) + }() + + testsLogger := NewLogger() + b.ResetTimer() + for i := 0; i < b.N; i++ { + testsLogger.Info("test", map[string]interface{}{"test": "test"}) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..de338ea --- /dev/null +++ b/main.go @@ -0,0 +1,36 @@ +package gql + +import ( + "time" + + "github.com/gookit/goutil/envutil" + cache "github.com/lukaszraczylo/go-simple-graphql/cache" + logging "github.com/lukaszraczylo/go-simple-graphql/logging" +) + +func NewConnection() (b *BaseClient) { + b = &BaseClient{ + endpoint: envutil.Getenv("GRAPHQL_ENDPOINT", "https://api.github.com/graphql"), + responseType: envutil.Getenv("GRAPHQL_RESPONSE_TYPE", "string"), + Logger: logging.NewLogger(), + cache: cache.New(time.Duration(envutil.GetInt("GRAPHQL_CACHE_TTL", 5)) * time.Second), + cache_global: envutil.GetBool("GRAPHQL_CACHE_ENABLED", false), + retries_enable: envutil.GetBool("GRAPHQL_RETRIES_ENABLE", false), + retries_delay: time.Duration(envutil.GetInt("GRAPHQL_RETRIES_DELAY", 250) * int(time.Millisecond)), + retries_number: envutil.GetInt("GRAPHQL_RETRIES_NUMBER", 3), + } + b.client = b.createHttpClient() + b.Logger.Debug("Created new GraphQL client connection", map[string]interface{}{"values": b}) + return b +} + +func (b *BaseClient) SetEndpoint(endpoint string) { + b.endpoint = endpoint +} + +func (b *BaseClient) SetOutput(responseType string) { + // allowed are byte, string, mapstring + // check if responseType is allowed + // TODO: implement + b.responseType = responseType +} diff --git a/main_bench_test.go b/main_bench_test.go new file mode 100644 index 0000000..3c7a544 --- /dev/null +++ b/main_bench_test.go @@ -0,0 +1,9 @@ +package gql + +import "testing" + +func Benchmark_newConnection(b *testing.B) { + for i := 0; i < b.N; i++ { + NewConnection() + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..463309a --- /dev/null +++ b/main_test.go @@ -0,0 +1,21 @@ +package gql + +import ( + "testing" +) + +func (suite *Tests) TestNewConnection() { + tests := []struct { + name string + }{ + { + name: "TestNewConnection", + }, + } + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + got := NewConnection() + assert.NotNil(suite.T(), got) + }) + } +} diff --git a/query.go b/query.go index 221d772..059d656 100644 --- a/query.go +++ b/query.go @@ -1,152 +1,108 @@ package gql import ( - "context" "fmt" - "strconv" + "github.com/gookit/goutil" "github.com/gookit/goutil/strutil" - "github.com/lukaszraczylo/go-simple-graphql/utils/helpers" - jsoniter "github.com/json-iterator/go" ) var json = jsoniter.ConfigCompatibleWithStandardLibrary -func (c *BaseClient) convertToJson(v any) []byte { - json, err := json.Marshal(v) +func (b *BaseClient) convertToJSON(v any) []byte { + jsonData, err := json.Marshal(v) if err != nil { - c.Logger.Error("Can't convert to json;", map[string]interface{}{"error": err.Error()}) + b.Logger.Error("Can't convert to json;", map[string]interface{}{"error": err.Error()}) return nil } - return json + return jsonData } -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 - } +func (b *BaseClient) compileQuery(query_partials ...any) *Query { + q := new(Query) + for _, partial := range query_partials { + switch val := partial.(type) { + case string: + q.Query = val + case map[string]interface{}: + q.Variables = val } } - - query.compiledQuery = c.convertToJson(request{ - Query: query.query, - Variables: query.variables, - }) - - c.Logger.Debug("Clearing previously prepared variables and query") - query.variables = nil - - if query.context == nil { - query.context = context.Background() + if q.Query == "" { + b.Logger.Error("Can't compile query;", map[string]interface{}{"error": "query is empty"}) + return nil } - - if c.validate { - c.Logger.Warning("Validating query is active") - } - - query.query = "" - c.Logger.Debug("Query prepared", map[string]interface{}{"query": query.compiledQuery}) - - return query + q.JsonQuery = b.convertToJSON(q) + return q } -// 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 (b *BaseClient) Query(query string, variables map[string]interface{}, headers map[string]interface{}) (returned_value any, err error) { + compiledQuery := b.compileQuery(query, variables) + if compiledQuery.JsonQuery == nil { + b.Logger.Error("Can't compile query;", map[string]interface{}{"error": "query is empty"}) + return nil, fmt.Errorf("Can't compile query") + } -func (c *BaseClient) Query(queryContent string, queryVariables interface{}, queryHeaders map[string]interface{}) (any, error) { + b.Logger.Debug("Compiled query;", map[string]interface{}{"query": compiledQuery}) + enable_cache, enable_retries, recompile_required := compiledQuery.parseHeadersAndVariables(headers) - compiledQuery := c.NewQuery(queryContent, queryVariables) - parseQueryHeaders, enabledCache, headersModified, enabledRetries := compiledQuery.parseQueryHeaders(queryHeaders) + if recompile_required { + compiledQuery = b.compileQuery(query, variables) + } - var should_cache, retries_enabled bool var queryHash string - if headersModified { - should_cache = enabledCache - retries_enabled = enabledRetries - } else { - should_cache = c.cache.enabled - retries_enabled = c.retries.enabled - } - - // if queryContent does not start with `query` then we don't want to cache it - // because it's probably a mutation or subscription. - if !strutil.HasPrefix(queryContent, "query") { - should_cache = false - } - - if should_cache { - queryHash = strutil.Md5(fmt.Sprintf("%s-%+v", compiledQuery.compiledQuery, queryHeaders)) - } - - q := &queryExecutor{ - client: c, - query: compiledQuery.compiledQuery, - headers: parseQueryHeaders, - context: context.Background(), - should_cache: should_cache, - retries_enabled: retries_enabled, - hash: queryHash, - } - - q.execute() - defer q.done() - - return q.result.data, q.result.errors -} - -func (c *BaseClient) decodeResponse(jsonData []byte) any { - switch c.responseType { - case "mapstring": - var response map[string]interface{} - err := json.Unmarshal(jsonData, &response) - if err != nil { - c.Logger.Error("Error while converting to map[string]interface{};", map[string]interface{}{"error": err.Error()}) - return nil + if (enable_cache || b.cache_global) && strutil.HasPrefix(compiledQuery.Query, "query") { + b.Logger.Debug("Cache enabled") + queryHash = calculateHash(compiledQuery) + cached_value := b.cacheLookup(queryHash) + if cached_value != nil { + b.Logger.Debug("Cache hit", map[string]interface{}{"query": compiledQuery}) + return cached_value, nil + } else { + b.Logger.Debug("Cache miss", map[string]interface{}{"query": compiledQuery}) } - return response - case "string": - return helpers.BytesToString(jsonData) - case "byte": - return jsonData - default: - c.Logger.Error("Unknown response type", map[string]interface{}{"response": c.responseType}) - return nil } -} -func (q *Query) parseQueryHeaders(queryHeaders map[string]interface{}) (returnHeaders map[string]interface{}, cache_enabled bool, headers_modified bool, retries_enabled bool) { - returnHeaders = make(map[string]interface{}) - var err error + if enable_retries || b.retries_enable { + b.Logger.Debug("Retries enabled") + } - for k, v := range queryHeaders { - if k == "gqlcache" { - cache_enabled, err = strconv.ParseBool(fmt.Sprintf("%v", v)) - if err != nil { - panic(fmt.Sprintf("Unable to parse gqlcache value %s", err.Error())) + q := &QueryExecutor{ + BaseClient: b, + Query: compiledQuery.JsonQuery, + Headers: headers, + CacheKey: func() string { + if queryHash != "" { + return queryHash + } else { + return "no-cache" } - headers_modified = true - continue - } - if k == "gqlretries" { - retries_enabled, _ = strconv.ParseBool(fmt.Sprintf("%v", v)) - continue - } - returnHeaders[k] = v + }(), + Retries: enable_retries || b.retries_enable, } - return returnHeaders, cache_enabled, headers_modified, retries_enabled + defer func() { q = nil }() + rv, err := q.executeQuery() + if err != nil { + b.Logger.Error("Error while executing query;", map[string]interface{}{"error": err.Error()}) + return nil, err + } + returned_value, err = q.decodeResponse(rv) + return returned_value, err +} + +func (q *Query) parseHeadersAndVariables(headers map[string]interface{}) (enable_cache bool, enable_retries bool, recompile_required bool) { + if headers != nil { + enable_cache, _ = goutil.ToBool(searchForKeysInMapStringInterface(headers, "gqlcache")) + enable_retries, _ = goutil.ToBool(searchForKeysInMapStringInterface(headers, "gqlretries")) + } + if q.Variables != nil { + enable_cache, _ = goutil.ToBool(searchForKeysInMapStringInterface(q.Variables, "gqlcache")) + enable_retries, _ = goutil.ToBool(searchForKeysInMapStringInterface(q.Variables, "gqlretries")) + delete(q.Variables, "gqlcache") + delete(q.Variables, "gqlretries") + recompile_required = true + } + return } diff --git a/query_bench_test.go b/query_bench_test.go new file mode 100644 index 0000000..e2000ab --- /dev/null +++ b/query_bench_test.go @@ -0,0 +1,23 @@ +package gql + +import "testing" + +func Benchmark_compileQuery(bn *testing.B) { + b := NewConnection() + bn.ResetTimer() + for i := 0; i < bn.N; i++ { + b.compileQuery( + `query { viewer { login } }`, + ) + } +} + +func Benchmark_convertToJson(bn *testing.B) { + b := NewConnection() + bn.ResetTimer() + for i := 0; i < bn.N; i++ { + b.convertToJSON( + `query { viewer { login } }`, + ) + } +} diff --git a/query_test.go b/query_test.go index 94a802e..fb82a40 100644 --- a/query_test.go +++ b/query_test.go @@ -1,570 +1,304 @@ package gql import ( - "math/rand" "reflect" "testing" - - "github.com/stretchr/testify/assert" ) -func TestBaseClient_convertToJson(t *testing.T) { - type fields struct { - } +func (suite *Tests) TestBaseClient_convertToJSON() { type args struct { v any } tests := []struct { - name string - fields fields - args args - want []byte + name string + args args + want []byte }{ { - name: "Test convertToJson", - fields: fields{}, + name: "TestBaseClient_convertToJSON_query", + args: args{ + v: `query { viewer { login } }`, + }, + want: []byte(`"query { viewer { login } }"`), + }, + { + name: "TestBaseClient_convertToJSON_variables", args: args{ v: map[string]interface{}{ - "query": "query { hello }", + "login": "lukaszraczylo", + "first": 10, }, }, - want: []byte(`{"query":"query { hello }"}`), - }, - { - name: "Test convertToJson with variables", - fields: fields{}, - args: args{ - v: map[string]interface{}{ - "query": "query { hello }", - "variables": map[string]interface{}{"name": "John"}, - }, - }, - want: []byte(`{"query":"query { hello }","variables":{"name":"John"}}`), - }, - { - name: "Test NewQuery with modifier", - fields: fields{}, - args: args{ - v: map[string]interface{}{ - "query": "query @cached(ttl: 120) { hello }", - "variables": map[string]interface{}{"name": "John"}, - }, - }, - want: []byte(`{"query":"query @cached(ttl: 120) { hello }","variables":{"name":"John"}}`), + want: []byte(`{"first":10,"login":"lukaszraczylo"}`), }, } for _, tt := range tests { - 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) + suite.T().Run(tt.name, func(t *testing.T) { + b := NewConnection() + if got := b.convertToJSON(tt.args.v); !reflect.DeepEqual(got, tt.want) { + assert.Equal(tt.want, got) } }) } } -func TestBaseClient_NewQuery(t *testing.T) { - type fields struct { - } +func (suite *Tests) TestBaseClient_compileQuery() { type args struct { - q []any + query_partials []any } tests := []struct { - fields fields - want *Query - name string - args args + wantQ *Query + name string + args args }{ { - name: "Test NewQuery", - fields: fields{}, + name: "TestBaseClient_compileQuery_query", args: args{ - q: []any{ - "query { hello }", + query_partials: []any{ + `query { viewer { login } }`, }, }, - want: &Query{ - compiledQuery: []byte(`{"variables":null,"query":"query { hello }"}`), + wantQ: &Query{ + Query: `query { viewer { login } }`, + JsonQuery: []byte(`{"query":"query { viewer { login } }"}`), }, }, { - name: "Test NewQuery with variables", - fields: fields{}, + name: "TestBaseClient_compileQuery_with_variables", args: args{ - q: []any{ - "query { hello }", - map[string]interface{}{"name": "John"}, + query_partials: []any{ + `query { viewer { login } }`, + map[string]interface{}{ + "login": "lukaszraczylo", + "first": 10, + }, }, }, - want: &Query{ - compiledQuery: []byte(`{"variables":{"name":"John"},"query":"query { hello }"}`), + wantQ: &Query{ + Query: `query { viewer { login } }`, + Variables: map[string]interface{}{ + "login": "lukaszraczylo", + "first": 10, + }, + JsonQuery: []byte(`{"variables":{"first":10,"login":"lukaszraczylo"},"query":"query { viewer { login } }"}`), }, }, { - name: "Test NewQuery with modifier", - fields: fields{}, + name: "TestBaseClient_compileQuery_variables_without_query", args: args{ - q: []any{ - "query @cached(ttl: 120) { hello }", - map[string]interface{}{"name": "John"}, + query_partials: []any{ + map[string]interface{}{ + "login": "lukaszraczylo", + "first": 10, + }, }, }, - want: &Query{ - compiledQuery: []byte(`{"variables":{"name":"John"},"query":"query @cached(ttl: 120) { hello }"}`), - }, + wantQ: nil, }, } 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) + suite.T().Run(tt.name, func(t *testing.T) { + b := NewConnection() + got := b.compileQuery(tt.args.query_partials...) + assert.Equal(tt.wantQ, got) }) } } -func TestBaseClient_decodeResponse(t *testing.T) { - type fields struct { - responseType string - } +func (suite *Tests) TestBaseClient_Query() { type args struct { - jsonData []byte + variables map[string]interface{} + headers map[string]interface{} + query string } tests := []struct { - fields fields - want any - name string - args args + args args + wantReturned_value any + name string + graphQLServer string + wantType string + wantErr bool }{ { - name: "Test decodeResponse - string", - fields: fields{ - responseType: "string", - }, + name: "TestBaseClient_Query_spacex_working", + graphQLServer: "https://spacex-production.up.railway.app/", 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"}}`), - }, - { - name: "Test decodeResponse - mapstring", - fields: fields{ - responseType: "mapstring", - }, - args: args{ - jsonData: []byte(`{"data":{"hello":"world"}}`), - }, - want: map[string]interface{}{ - "data": map[string]interface{}{ - "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 { - want any - args args - name string - fields fields - wantErr bool - }{ - { - name: "Test Query - failing", - fields: fields{ - graphql_endpoint: "https://spacex-production.up.railway.app/", - }, - args: args{ - queryContent: "query { hello }", - }, - want: ``, - wantErr: true, - }, - { - name: "Test Query - success", - fields: fields{ - graphql_endpoint: "https://spacex-production.up.railway.app/", - }, - args: args{ - queryContent: `query Dragons { + query: `query Dragons { dragons { name } }`, - queryHeaders: map[string]interface{}{ - "x-apollo-operation-name": "Missions-Potato-Test-Golang", + variables: nil, + headers: map[string]interface{}{ + "x-apollo-operation-name": "Dragons/github.com/lukaszraczylo/go-simple-graphql", "content-type": "application/json", }, }, - wantErr: false, - want: `{"dragons":[{"name":"Dragon 1"},{"name":"Dragon 2"}]}`, + wantReturned_value: string(`{"dragons":[{"name":"Dragon 1"},{"name":"Dragon 2"}]}`), + wantErr: false, }, { - name: "Test Query - success", - fields: fields{ - repeat: 2, - graphql_endpoint: "https://spacex-production.up.railway.app/", - }, + name: "TestBaseClient_Query_spacex_invalid_query", + graphQLServer: "https://spacex-production.up.railway.app/", args: args{ - queryContent: `query Dragons { - dragons { - name - first_flight - } - }`, - queryHeaders: map[string]interface{}{ - "x-apollo-operation-name": "Missions-Potato-Test-Golang-Cached", - "content-type": "application/json", - }, - }, - wantErr: false, - want: `{"dragons":[{"first_flight":"2010-12-08","name":"Dragon 1"},{"first_flight":"2019-03-02","name":"Dragon 2"}]}`, - }, - } - for _, tt := range tests { - 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 { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.want, got) - } - } - }) - } -} - -/// 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) - } -} - -func TestBaseClient_parseQueryHeaders(t *testing.T) { - type fields struct { - } - type args struct { - queryHeaders map[string]interface{} - } - tests := []struct { - fields fields - args args - wantReturnHeaders map[string]interface{} - name string - }{ - { - name: "Test parseQueryHeaders - no change", - fields: fields{}, - args: args{ - queryHeaders: map[string]interface{}{ - "x-test-header": "test", - "x-test-header-next": true, - }, - }, - wantReturnHeaders: map[string]interface{}{ - "x-test-header": "test", - "x-test-header-next": true, - }, - }, - { - name: "Test parseQueryHeaders - lib-headers removed", - fields: fields{}, - args: args{ - queryHeaders: map[string]interface{}{ - "x-test-header": "test", - "gqlcache": true, - }, - }, - wantReturnHeaders: map[string]interface{}{ - "x-test-header": "test", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - q := &Query{} - if gotReturnHeaders, _, _, _ := q.parseQueryHeaders(tt.args.queryHeaders); !reflect.DeepEqual(gotReturnHeaders, tt.wantReturnHeaders) { - t.Errorf("BaseClient.parseQueryHeaders() = %v, want %v", gotReturnHeaders, tt.wantReturnHeaders) - } - }) - } -} - -func TestBaseClient_QueryCache(t *testing.T) { - type fields struct { - graphql_endpoint string - cache_enabled bool - } - type args struct { - queryVariables interface{} - queryHeaders map[string]interface{} - queryContent string - } - tests := []struct { - want any - args args - name string - fields fields - wantErr bool - }{ - { - name: "Test QueryCache - enabled", - fields: fields{ - graphql_endpoint: "https://spacex-production.up.railway.app/", - cache_enabled: true, - }, - args: args{ - queryContent: `query Dragons { + query: `query Dragons { dragons { name } - }`, - queryHeaders: map[string]interface{}{ - "x-apollo-operation-name": "Missions-Potato-Test-Golang", + `, + variables: nil, + headers: map[string]interface{}{ + "x-apollo-operation-name": "Dragons/github.com/lukaszraczylo/go-simple-graphql", "content-type": "application/json", }, }, + wantReturned_value: nil, + wantErr: true, }, { - name: "Test QueryCache - disabled", - fields: fields{ - graphql_endpoint: "https://spacex-production.up.railway.app/", - cache_enabled: false, - }, + name: "TestBaseClient_Query_tgbotapp_working", + graphQLServer: "https://telegram-bot.app/v1/graphql", args: args{ - queryContent: `query Dragons { - dragons { + query: `query { + __type(name: "Query") { name } }`, - queryHeaders: map[string]interface{}{ - "x-apollo-operation-name": "Missions-Potato-Test-Golang", - "content-type": "application/json", + }, + wantReturned_value: string(`{"__type":null}`), + wantErr: false, + }, + { + name: "TestBaseClient_Query_tgbotapp_invalid_query", + graphQLServer: "https://telegram-bot.app/v1/graphql", + args: args{ + query: `query { + __type(name: "Query") { + name + } + `, + }, + wantReturned_value: nil, + wantErr: true, + }, + { + name: "TestBaseClient_Query_tgbotapp_invalid_wrong_field", + graphQLServer: "https://telegram-bot.app/v1/graphql", + args: args{ + query: `query { + __type(name: "Query") { + name2 + } + }`, + }, + wantReturned_value: nil, + wantErr: true, + }, + { + name: "TestBaseClient_Query_tgbotapp_wrong_url", + graphQLServer: "https://telegram-bot.app/v0/graphql", + args: args{ + query: `query { + __type(name: "Query") { + name + } + }`, + }, + wantReturned_value: nil, + wantErr: true, + }, + { + name: "TestBaseClient_Query_tgbotapp_valid_query_mapstring", + graphQLServer: "https://telegram-bot.app/v1/graphql", + wantType: "mapstring", + args: args{ + query: `query { + __type(name: "Query") { + name + } + }`, + }, + wantReturned_value: map[string]interface{}{"__type": nil}, + wantErr: false, + }, + { + name: "TestBaseClient_Query_tgbotapp_valid_query_byte", + graphQLServer: "https://telegram-bot.app/v1/graphql", + wantType: "byte", + args: args{ + query: `query { + __type(name: "Query") { + name + } + }`, + }, + wantReturned_value: []byte(`{"__type":null}`), + wantErr: false, + }, + { + name: "TestBaseClient_Query_tgbotapp_valid_query_invalid_type", + graphQLServer: "https://telegram-bot.app/v1/graphql", + wantType: "potato", + args: args{ + query: `query { + __type(name: "Query") { + name + } + }`, + }, + wantReturned_value: nil, + wantErr: true, + }, + { + name: "TestBaseClient_Query_tgbotapp_valid_query_cache", + graphQLServer: "https://telegram-bot.app/v1/graphql", + args: args{ + query: `query { + __type(name: "Query") { + name + } + }`, + variables: map[string]interface{}{ + "gqlcache": true, }, }, + wantReturned_value: string(`{"__type":null}`), + wantErr: false, + }, + { + name: "TestBaseClient_Query_tgbotapp_valid_query_with_retry", + graphQLServer: "https://telegram-bot.app/v0/graphql", + wantType: "byte", + args: args{ + query: `query { + __type(name: "Query") { + name + } + }`, + variables: map[string]interface{}{ + "gqlretries": true, + }, + }, + wantReturned_value: nil, + wantErr: true, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := NewConnection() - c.cache.enabled = tt.fields.cache_enabled - - if tt.fields.graphql_endpoint != "" { - c.endpoint = tt.fields.graphql_endpoint + suite.T().Run(tt.name, func(t *testing.T) { + b := NewConnection() + if tt.graphQLServer != "" { + b.SetEndpoint(tt.graphQLServer) } - - _, err := c.Query(tt.args.queryContent, tt.args.queryVariables, tt.args.queryHeaders) - if !tt.wantErr { - assert.NoError(t, err) + if tt.wantType != "" { + b.SetOutput(tt.wantType) + } + gotReturned_value, err := b.Query(tt.args.query, tt.args.variables, tt.args.headers) + if tt.wantErr { + assert.Error(err) } else { - assert.Error(t, err) - } - }) - } -} - -func TestBaseClient_QueryCacheRandomizedRace(t *testing.T) { - type fields struct { - graphql_endpoint string - cache_enabled bool - } - type args struct { - queryVariables interface{} - queryHeaders map[string]interface{} - queryContent string - } - tests := []struct { - want any - args args - name string - fields fields - wantErr bool - }{ - { - name: "Test QueryCache - enabled", - fields: fields{ - graphql_endpoint: "https://spacex-production.up.railway.app/", - }, - args: args{ - queryContent: `query Dragons { - dragons { - name - } - }`, - queryHeaders: map[string]interface{}{ - "x-apollo-operation-name": "Missions-Potato-Test-Golang", - "content-type": "application/json", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := NewConnection() - if tt.fields.graphql_endpoint != "" { - c.endpoint = tt.fields.graphql_endpoint - } - - for i := 0; i < 10; i++ { - c.cache.enabled = rand.Intn(2) == 0 - go func() { - _, err := c.Query(tt.args.queryContent, tt.args.queryVariables, tt.args.queryHeaders) - if !tt.wantErr { - assert.NoError(t, err) - } else { - assert.Error(t, err) - } - }() - } - }) - } -} - -func TestBaseClient_QueryCacheRandomizedRaceViaHeader(t *testing.T) { - type fields struct { - graphql_endpoint string - cache_enabled bool - } - type args struct { - queryVariables interface{} - queryHeaders map[string]interface{} - queryContent string - } - tests := []struct { - want any - args args - name string - fields fields - wantErr bool - }{ - { - name: "Test QueryCache - enabled", - fields: fields{ - graphql_endpoint: "https://spacex-production.up.railway.app/", - }, - args: args{ - queryContent: `query Dragons { - dragons { - name - } - }`, - queryHeaders: map[string]interface{}{ - "x-apollo-operation-name": "Missions-Potato-Test-Golang", - "content-type": "application/json", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := NewConnection() - if tt.fields.graphql_endpoint != "" { - c.endpoint = tt.fields.graphql_endpoint - } - - for i := 0; i < 10; i++ { - go func() { - tt.args.queryHeaders["gqlcache"] = rand.Intn(2) == 0 - _, err := c.Query(tt.args.queryContent, tt.args.queryVariables, tt.args.queryHeaders) - if !tt.wantErr { - assert.NoError(t, err) - } else { - assert.Error(t, err) - } - }() + assert.NoError(err) } + assert.Equal(tt.wantReturned_value, gotReturned_value) }) } } diff --git a/request.go b/request.go deleted file mode 100644 index c951403..0000000 --- a/request.go +++ /dev/null @@ -1,115 +0,0 @@ -package gql - -import ( - "bytes" - "fmt" - "io" - "net/http" - "time" - - "github.com/avast/retry-go/v4" -) - -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("Error while creating http request;", map[string]interface{}{"error": err.Error()}) - return - } - - for key, value := range headers.(map[string]interface{}) { - httpRequest.Header.Add(key, fmt.Sprintf("%s", value)) - } - - var retries_available = c.retries.max - if !c.retries.enabled { - retries_available = 1 - } - - var httpResponse *http.Response - - err = retry.Do( - func() error { - httpResponse, err = c.client.Do(httpRequest) - if err != nil { - c.Logger.Error("Error while executing http request;", map[string]interface{}{"error": err.Error()}) - return err - } - defer io.Copy(io.Discard, httpResponse.Body) // equivalent to `cp body /dev/null` - defer httpResponse.Body.Close() - - if httpResponse.StatusCode <= 200 && httpResponse.StatusCode >= 204 { - return err - } - - body, err := io.ReadAll(httpResponse.Body) - if err != nil { - c.Logger.Error("Error while reading http response;", map[string]interface{}{"error": err.Error()}) - return err - } - - err = json.Unmarshal(body, &queryResult) - if err != nil { - c.Logger.Error("Error while unmarshalling http response;", map[string]interface{}{"error": err.Error()}) - return err - } - - return nil - }, - retry.OnRetry(func(n uint, err error) { - c.Logger.Error("Retrying query", map[string]interface{}{"error": err.Error()}) - }), - retry.Attempts(uint(retries_available)), - retry.DelayType(retry.BackOffDelay), - retry.Delay(time.Duration(c.retries.delay)*time.Second), - retry.LastErrorOnly(true), - ) - - if len(queryResult.Errors) > 0 { - return nil, fmt.Errorf("%v", queryResult.Errors) - } - return queryResult.Data, nil -} - -func (q *queryExecutor) execute() { - if q.should_cache { - cachedResponse := q.client.cacheLookup(q.hash) - if cachedResponse != nil { - q.client.Logger.Debug("Found cached response", map[string]interface{}{"hash": q.hash}) - q.result.data = q.client.decodeResponse(cachedResponse) - return // return cached response - } else { - q.client.Logger.Debug("Cache miss", map[string]interface{}{"hash": q.hash}) - } - } - - if q.retries_enabled { - q.client.Logger.Debug("Retries enabled") - q.client.retries.enabled = true - } - - response, err := q.client.executeQuery(q.query, q.headers) - if err != nil { - q.client.Logger.Error("Error while executing query;", map[string]interface{}{"error": err.Error()}) - q.result.errors = err - return - } - - jsonData, err := json.Marshal(response) - if err != nil { - q.client.Logger.Error("Error while converting to json;", map[string]interface{}{"error": err.Error()}) - q.result.errors = err - return - } - - if q.should_cache { - q.client.cache.client.Set(q.hash, jsonData, time.Duration(q.client.cache.ttl)*time.Second) - } - - q.result.data = q.client.decodeResponse(jsonData) -} - -func (q *queryExecutor) done() { - q = nil -} diff --git a/semver.yaml b/semver.yaml index 47d1ebb..d758f4c 100644 --- a/semver.yaml +++ b/semver.yaml @@ -1,9 +1,9 @@ version: 1 force: major: 1 - minor: 1 + minor: 2 patch: 0 - commit: 6a8f99f6ef8f65a303dad74c272ff12bf9fe526f + commit: 3217edf37fd1765c4f2594e3ba122a6a9c251296 wording: minor: - minor-release diff --git a/structs.go b/structs.go index 66773b5..9fd2509 100644 --- a/structs.go +++ b/structs.go @@ -1,62 +1,41 @@ package gql import ( - "context" "net/http" + "time" - libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache" - libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" + cache "github.com/lukaszraczylo/go-simple-graphql/cache" + logging "github.com/lukaszraczylo/go-simple-graphql/logging" ) -type cacheStore struct { - client *libpack_cache.Cache - ttl int - enabled bool -} - -type retriesConfig struct { - max int - delay int - enabled bool -} - type BaseClient struct { - retries retriesConfig - cache cacheStore - endpoint string - responseType string - Logger *libpack_logging.LogConfig - client *http.Client - MaxGoRoutines int - validate bool + cache *cache.Cache + Logger *logging.LogConfig + client *http.Client + endpoint string + responseType string + retries_delay time.Duration + retries_number int + MaxGoRoutines int + cache_global bool + retries_enable bool } type Query struct { - context context.Context - variables map[string]any `json:"variables"` - query string `json:"query"` - compiledQuery []byte `json:"compiledQuery"` + Variables map[string]interface{} `json:"variables,omitempty"` + Query string `json:"query,omitempty"` + JsonQuery []byte `json:"jsonQuery,omitempty"` } -type queryExecutor struct { - result queryExecutorResult - context context.Context - client *BaseClient - headers map[string]interface{} - hash string - query []byte - should_cache bool - retries_enabled bool -} - -type queryExecutorResult struct { - data interface{} - errors error -} - -type request struct { - Variables any `json:"variables"` - Query string `json:"query"` +type QueryExecutor struct { + Result any + Error error + *BaseClient + Headers map[string]interface{} + CacheKey string + Query []byte + CacheTTL time.Duration + Retries bool } type queryResults struct { diff --git a/suite_test.go b/suite_test.go new file mode 100644 index 0000000..9d77708 --- /dev/null +++ b/suite_test.go @@ -0,0 +1,27 @@ +package gql + +import ( + "testing" + + assertions "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type Tests struct { + suite.Suite +} + +var ( + assert *assertions.Assertions +) + +func (suite *Tests) SetupTest() { + assert = assertions.New(suite.T()) +} + +func (suite *Tests) BeforeTest(suiteName, testName string) { +} + +func TestSuite(t *testing.T) { + suite.Run(t, new(Tests)) +} diff --git a/utils/helpers/helpers.go b/utils/helpers/helpers.go deleted file mode 100644 index 5e0ed66..0000000 --- a/utils/helpers/helpers.go +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 49fbf71..0000000 --- a/utils/helpers/helpers_test.go +++ /dev/null @@ -1,70 +0,0 @@ -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/ordered_map/ordered_map.go b/utils/ordered_map/ordered_map.go deleted file mode 100644 index 181485a..0000000 --- a/utils/ordered_map/ordered_map.go +++ /dev/null @@ -1,121 +0,0 @@ -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 { - if v == nil { - e.buf.WriteString("null") - return nil - } - - switch val := v.(type) { - case *OrderedMap: - data, err := json.Marshal(val.values) - if err != nil { - return err - } - e.buf.Write(data) - default: - data, err := json.Marshal(val) - if err != nil { - return err - } - e.buf.Write(data) - } - return nil -} - -func (o OrderedMap) Keys() []string { - return o.keys -} - -func (o OrderedMap) Values() []interface{} { - var values []interface{} - for _, k := range o.keys { - values = append(values, o.values[k]) - } - return values -} - -func (o *OrderedMap) Delete(key string) { - delete(o.values, key) - for i, k := range o.keys { - if k == key { - o.keys = append(o.keys[:i], o.keys[i+1:]...) - break - } - } -} - -func (o OrderedMap) Exists(key string) bool { - _, exists := o.values[key] - return exists -} - -func (o *OrderedMap) UnmarshalJSON(data []byte) error { - var values map[string]interface{} - err := json.Unmarshal(data, &values) - if err != nil { - return err - } - - o.keys = []string{} - o.values = map[string]interface{}{} - for k, v := range values { - o.Set(k, v) - } - - return nil -} diff --git a/utils/ordered_map/ordered_map_test.go b/utils/ordered_map/ordered_map_test.go deleted file mode 100644 index fc6fc43..0000000 --- a/utils/ordered_map/ordered_map_test.go +++ /dev/null @@ -1,164 +0,0 @@ -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" - } -}