diff --git a/consts/consts.go b/consts/consts.go index fe0d5f714..d020d1370 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -34,6 +34,8 @@ const ( PlaceholderAlbumArt = "navidrome-600x600.png" PlaceholderAvatar = "logo-192x192.png" + + DefaultCachedHttpClientTTL = 10 * time.Second ) // Cache options diff --git a/core/agents/agents_suite_test.go b/core/agents/agents_suite_test.go new file mode 100644 index 000000000..bba05b03a --- /dev/null +++ b/core/agents/agents_suite_test.go @@ -0,0 +1,17 @@ +package agents + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestAgents(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelCritical) + RegisterFailHandler(Fail) + RunSpecs(t, "Agents Test Suite") +} diff --git a/core/agents/cached_http_client.go b/core/agents/cached_http_client.go new file mode 100644 index 000000000..ba827a275 --- /dev/null +++ b/core/agents/cached_http_client.go @@ -0,0 +1,102 @@ +package agents + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "io" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/ReneKroon/ttlcache/v2" + "github.com/navidrome/navidrome/log" +) + +const cacheSizeLimit = 1000 + +type CachedHTTPClient struct { + cache *ttlcache.Cache + hc httpDoer +} + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +type requestData struct { + Method string + Header http.Header + URL string + Body *string +} + +func NewCachedHTTPClient(wrapped httpDoer, ttl time.Duration) *CachedHTTPClient { + c := &CachedHTTPClient{hc: wrapped} + c.cache = ttlcache.NewCache() + c.cache.SetCacheSizeLimit(cacheSizeLimit) + c.cache.SkipTTLExtensionOnHit(true) + c.cache.SetLoaderFunction(func(key string) (interface{}, time.Duration, error) { + req, err := c.deserializeReq(key) + if err != nil { + return nil, 0, err + } + resp, err := c.hc.Do(req) + if err != nil { + return nil, 0, err + } + return c.serializeResponse(resp), ttl, nil + }) + c.cache.SetNewItemCallback(func(key string, value interface{}) { + log.Trace("New request cached", "req", key, "resp", value) + }) + return c +} + +func (c *CachedHTTPClient) Do(req *http.Request) (*http.Response, error) { + key := c.serializeReq(req) + respStr, err := c.cache.Get(key) + if err != nil { + return nil, err + } + return c.deserializeResponse(req, respStr.(string)) +} + +func (c *CachedHTTPClient) serializeReq(req *http.Request) string { + data := requestData{ + Method: req.Method, + Header: req.Header, + URL: req.URL.String(), + } + if req.Body != nil { + bodyData, _ := ioutil.ReadAll(req.Body) + bodyStr := base64.StdEncoding.EncodeToString(bodyData) + data.Body = &bodyStr + } + j, _ := json.Marshal(&data) + return string(j) +} + +func (c *CachedHTTPClient) deserializeReq(reqStr string) (*http.Request, error) { + var data requestData + _ = json.Unmarshal([]byte(reqStr), &data) + var body io.Reader + if data.Body != nil { + bodyStr, _ := base64.StdEncoding.DecodeString(*data.Body) + body = strings.NewReader(string(bodyStr)) + } + return http.NewRequest(data.Method, data.URL, body) +} + +func (c *CachedHTTPClient) serializeResponse(resp *http.Response) string { + var b = &bytes.Buffer{} + _ = resp.Write(b) + return b.String() +} + +func (c *CachedHTTPClient) deserializeResponse(req *http.Request, respStr string) (*http.Response, error) { + r := bufio.NewReader(strings.NewReader(respStr)) + return http.ReadResponse(r, req) +} diff --git a/core/agents/cached_http_client_test.go b/core/agents/cached_http_client_test.go new file mode 100644 index 000000000..7d1249b3a --- /dev/null +++ b/core/agents/cached_http_client_test.go @@ -0,0 +1,81 @@ +package agents + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/consts" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("CachedHttpClient", func() { + Context("Default TTL", func() { + var chc *CachedHTTPClient + var ts *httptest.Server + var requestsReceived int + + BeforeEach(func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestsReceived++ + _, _ = fmt.Fprintf(w, "Hello, %s", r.URL.Query()["name"]) + })) + chc = NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL) + }) + + AfterEach(func() { + defer ts.Close() + }) + + It("caches repeated requests", func() { + r, _ := http.NewRequest("GET", ts.URL+"?name=doe", nil) + resp, err := chc.Do(r) + Expect(err).To(BeNil()) + body, err := ioutil.ReadAll(resp.Body) + Expect(err).To(BeNil()) + Expect(string(body)).To(Equal("Hello, [doe]")) + Expect(requestsReceived).To(Equal(1)) + + // Same request + r, _ = http.NewRequest("GET", ts.URL+"?name=doe", nil) + resp, err = chc.Do(r) + Expect(err).To(BeNil()) + body, err = ioutil.ReadAll(resp.Body) + Expect(err).To(BeNil()) + Expect(string(body)).To(Equal("Hello, [doe]")) + Expect(requestsReceived).To(Equal(1)) + + // Different request + r, _ = http.NewRequest("GET", ts.URL, nil) + resp, err = chc.Do(r) + Expect(err).To(BeNil()) + body, err = ioutil.ReadAll(resp.Body) + Expect(err).To(BeNil()) + Expect(string(body)).To(Equal("Hello, []")) + Expect(requestsReceived).To(Equal(2)) + }) + + It("expires responses after TTL", func() { + requestsReceived = 0 + chc = NewCachedHTTPClient(http.DefaultClient, 10*time.Millisecond) + + r, _ := http.NewRequest("GET", ts.URL+"?name=doe", nil) + _, err := chc.Do(r) + Expect(err).To(BeNil()) + Expect(requestsReceived).To(Equal(1)) + + // Wait more than the TTL + time.Sleep(50 * time.Millisecond) + + // Same request + r, _ = http.NewRequest("GET", ts.URL+"?name=doe", nil) + _, err = chc.Do(r) + Expect(err).To(BeNil()) + Expect(requestsReceived).To(Equal(2)) + }) + }) + +}) diff --git a/core/agents/agents.go b/core/agents/interfaces.go similarity index 100% rename from core/agents/agents.go rename to core/agents/interfaces.go diff --git a/core/agents/lastfm.go b/core/agents/lastfm.go index 881eed080..aae714742 100644 --- a/core/agents/lastfm.go +++ b/core/agents/lastfm.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/lastfm" "github.com/navidrome/navidrome/log" ) @@ -26,7 +27,8 @@ func lastFMConstructor(ctx context.Context) Interface { apiKey: conf.Server.LastFM.ApiKey, lang: conf.Server.LastFM.Language, } - l.client = lastfm.NewClient(l.apiKey, l.lang, http.DefaultClient) + hc := NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL) + l.client = lastfm.NewClient(l.apiKey, l.lang, hc) return l } @@ -103,7 +105,7 @@ func (l *lastfmAgent) GetTopSongs(artistName, mbid string, count int) ([]Track, func (l *lastfmAgent) callArtistGetInfo(name string, mbid string) (*lastfm.Artist, error) { a, err := l.client.ArtistGetInfo(l.ctx, name) if err != nil { - log.Error(l.ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid) + log.Error(l.ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid, err) return nil, err } return a, nil @@ -112,7 +114,7 @@ func (l *lastfmAgent) callArtistGetInfo(name string, mbid string) (*lastfm.Artis func (l *lastfmAgent) callArtistGetSimilar(name string, mbid string, limit int) ([]lastfm.Artist, error) { s, err := l.client.ArtistGetSimilar(l.ctx, name, limit) if err != nil { - log.Error(l.ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid) + log.Error(l.ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid, err) return nil, err } return s, nil @@ -121,7 +123,7 @@ func (l *lastfmAgent) callArtistGetSimilar(name string, mbid string, limit int) func (l *lastfmAgent) callArtistGetTopTracks(artistName, mbid string, count int) ([]lastfm.Track, error) { t, err := l.client.ArtistGetTopTracks(l.ctx, artistName, count) if err != nil { - log.Error(l.ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid) + log.Error(l.ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid, err) return nil, err } return t, nil diff --git a/core/agents/spotify.go b/core/agents/spotify.go index f5fa330c4..7587cf78f 100644 --- a/core/agents/spotify.go +++ b/core/agents/spotify.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/spotify" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -31,7 +32,8 @@ func spotifyConstructor(ctx context.Context) Interface { id: conf.Server.Spotify.ID, secret: conf.Server.Spotify.Secret, } - l.client = spotify.NewClient(l.id, l.secret, http.DefaultClient) + hc := NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL) + l.client = spotify.NewClient(l.id, l.secret, hc) return l } diff --git a/core/lastfm/client.go b/core/lastfm/client.go index d0df3d5a1..a4bb5d1ac 100644 --- a/core/lastfm/client.go +++ b/core/lastfm/client.go @@ -14,18 +14,18 @@ const ( apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" ) -type HttpClient interface { +type httpDoer interface { Do(req *http.Request) (*http.Response, error) } -func NewClient(apiKey string, lang string, hc HttpClient) *Client { +func NewClient(apiKey string, lang string, hc httpDoer) *Client { return &Client{apiKey, lang, hc} } type Client struct { apiKey string lang string - hc HttpClient + hc httpDoer } func (c *Client) makeRequest(params url.Values) (*Response, error) { diff --git a/core/spotify/client.go b/core/spotify/client.go index 60f3cb92d..f1e22fa8b 100644 --- a/core/spotify/client.go +++ b/core/spotify/client.go @@ -21,18 +21,18 @@ var ( ErrNotFound = errors.New("spotify: not found") ) -type HttpClient interface { +type httpDoer interface { Do(req *http.Request) (*http.Response, error) } -func NewClient(id, secret string, hc HttpClient) *Client { +func NewClient(id, secret string, hc httpDoer) *Client { return &Client{id, secret, hc} } type Client struct { id string secret string - hc HttpClient + hc httpDoer } func (c *Client) SearchArtists(ctx context.Context, name string, limit int) ([]Artist, error) { @@ -66,9 +66,10 @@ func (c *Client) authorize(ctx context.Context) (string, error) { payload := url.Values{} payload.Add("grant_type", "client_credentials") - req, _ := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(payload.Encode())) + encodePayload := payload.Encode() + req, _ := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload)) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(payload.Encode()))) + req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload))) auth := c.id + ":" + c.secret req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) diff --git a/go.mod b/go.mod index 875d475bc..f9d3aa808 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee github.com/ClickHouse/clickhouse-go v1.4.3 // indirect github.com/Masterminds/squirrel v1.5.0 + github.com/ReneKroon/ttlcache/v2 v2.3.0 github.com/astaxie/beego v1.12.3 github.com/bradleyjkemp/cupaloy v2.3.0+incompatible github.com/cespare/reflex v0.3.0 @@ -47,7 +48,7 @@ require ( github.com/ziutek/mymysql v1.5.4 // indirect golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb - golang.org/x/tools v0.0.0-20210105210202-9ed45478a130 + golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064 google.golang.org/protobuf v1.25.0 // indirect gopkg.in/djherbis/atime.v1 v1.0.0 gopkg.in/djherbis/stream.v1 v1.3.1 diff --git a/go.sum b/go.sum index 45f7a2c96..0092ded43 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/Masterminds/squirrel v1.5.0/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OpenPeeDeeP/depguard v1.0.1 h1:VlW4R6jmBIv3/u1JNlawEvJMM4J+dPORPaZasQee8Us= github.com/OpenPeeDeeP/depguard v1.0.1/go.mod h1:xsIw86fROiiwelg+jB2uM9PiKihMMmUx/1V+TNhjQvM= +github.com/ReneKroon/ttlcache/v2 v2.3.0 h1:qZnUjRKIrbKHH6vF5T7Y9Izn5ObfTZfyYpGhvz2BKPo= +github.com/ReneKroon/ttlcache/v2 v2.3.0/go.mod h1:zbo6Pv/28e21Z8CzzqgYRArQYGYtjONRxaAKGxzQvG4= github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -37,6 +39,7 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk= +github.com/alvaroloes/enumer v1.1.2/go.mod h1:FxrjvuXoDAx9isTJrv4c+T410zFi0DtXIT0m65DJ+Wo= github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= @@ -443,6 +446,7 @@ github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuK github.com/onsi/gomega v1.10.5 h1:7n6FEkpFmfCoo2t+YYqXH0evK+a9ICQz0xcAy9dYcaQ= github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/name v0.0.0-20180628100202-0fd16699aae1/go.mod h1:eD5JxqMiuNYyFNmyY9rkJ/slN8y59oEu4Ei7F8OoKWQ= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.0.1/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= @@ -621,6 +625,8 @@ go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -650,6 +656,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= @@ -766,6 +774,7 @@ golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524210228-3d17549cdc6b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= @@ -779,6 +788,7 @@ golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200117220505-0cba7a3a9ee9/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -806,6 +816,8 @@ golang.org/x/tools v0.0.0-20210101214203-2dba1e4ea05c/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210102185154-773b96fafca2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105210202-9ed45478a130 h1:8qSBr5nyKsEgkP918Pu5FFDZpTtLIjXSo6mrtdVOFfk= golang.org/x/tools v0.0.0-20210105210202-9ed45478a130/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064 h1:BmCFkEH4nJrYcAc2L08yX5RhYGD4j58PTMkEUDkpz2I= +golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=