diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bf00e0..5f1e7ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: ['1.21', '1.22', '1.23', '1.24'] + go-version: ['1.21', '1.22', '1.23', '1.24', '1.25', '1.26', '1.27'] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 3cf2825..3f92790 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,35 @@ setup off when an `http.Transport` sets a custom `DialContext` or `TLSClientConfig`, so set `ForceAttemptHTTP2: true` on such a transport (the default transport already does) to keep speaking HTTP/2 to api.ipdata.co. +### Timeouts + +By default the client sets no overall time limit on a request: dialling and the +TLS handshake are bounded (30 and 10 seconds), but waiting for the API's +response and reading it are not. Bound a single call with a context, or give +the client a limit for every request with `WithTimeout`; when both are set, +the earlier deadline wins. + +```go +client, err := ipdata.NewClient("YOUR_API_KEY", ipdata.WithTimeout(10*time.Second)) +if err != nil { + panic(err) +} + +ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) +defer cancel() + +data, err := client.LookupWithContext(ctx, "8.8.8.8") +``` + +The limit covers the whole request, from dialling to the end of reading the +response body, so the body of a `RawLookup` response has to be read within it +as well. A request that exceeds the limit fails with an error whose `Timeout()` +method reports true, and bulk lookups are never replayed after a timeout. +`WithTimeout` also applies to an `*http.Client` passed with `WithHTTPClient`, in +whichever order the options are given: the client then uses a copy of it with +`Timeout` set and leaves the caller's value untouched. `WithTimeout(0)` means +no limit. + ### EU Endpoint (GDPR Compliance) For GDPR compliance, use `NewEUClient` to route all requests through EU data @@ -221,6 +250,8 @@ The `IP` struct includes all fields from the ipdata API response: - `LastSeen` (`string`): last day the address was listed, as `YYYY-MM-DD` (UTC); empty when not reported - `DaysSeen` (`*int`): number of days the address was listed; `nil` when not reported +**`Scores`** (`Threat.Scores`): `VPNScore`, `ProxyScore`, `ThreatScore`, `TrustScore`; each is a number from 0 to 100, and a higher `TrustScore` means a more trustworthy address + ## Contributors - [Tim Heckman](https://github.com/theckman/) - Created the first version of this library diff --git a/client.go b/client.go index 985bcd2..5012ce1 100644 --- a/client.go +++ b/client.go @@ -29,9 +29,10 @@ const Version = "0.8.2" const fqpn = "github.com/ipdata/go" const ( - apiEndpoint = "https://api.ipdata.co/" - euAPIEndpoint = "https://eu-api.ipdata.co/" - apiAuthParam = "api-key" + apiEndpoint = "https://api.ipdata.co/" + euAPIEndpoint = "https://eu-api.ipdata.co/" + apiAuthParam = "api-key" + apiFieldsParam = "fields" ) var userAgent = fmt.Sprintf( @@ -53,6 +54,27 @@ func WithHTTPClient(httpClient *http.Client) Option { } } +// WithTimeout sets a time limit for every request made through the Client, by +// setting Timeout on the *http.Client it uses. The limit covers the whole +// request, from dialling the connection to the end of reading the response +// body, including the body of a response returned by RawLookup or +// RawBulkLookup. Without this option no overall limit is set and a request is +// bounded only by the context passed to the WithContext methods; a context +// deadline and the time limit combine, and the earlier one wins. A request +// that exceeds the limit fails with an error whose Timeout method reports +// true, and a bulk lookup is never replayed after a timeout. +// +// The limit is applied once all options have been processed, so it also +// covers an *http.Client supplied with WithHTTPClient, whatever the order of +// the options: the Client then uses a copy of that *http.Client with Timeout +// set, and the caller's value is left untouched. A zero d means no limit. +func WithTimeout(d time.Duration) Option { + return func(c *Client) { + c.timeout = d + c.hasTimeout = true + } +} + // Client is the struct to represent the functionality presented by the // https://ipdata.co API. // @@ -67,26 +89,17 @@ type Client struct { c *http.Client // http client e string // api endpoint k string // api key + + // timeout is the request time limit given to WithTimeout, and hasTimeout + // records that the option was used at all; newClient applies it to c. + timeout time.Duration + hasTimeout bool } // NewClient takes an API key and optional Options, and returns a Client that // uses the default endpoint (https://api.ipdata.co/). func NewClient(apiKey string, opts ...Option) (Client, error) { - if len(apiKey) == 0 { - return Client{}, errAPIKey - } - - c := Client{ - c: newHTTPClient(), - e: apiEndpoint, - k: apiKey, - } - - for _, opt := range opts { - opt(&c) - } - - return c, nil + return newClient(apiEndpoint, apiKey, opts) } // NewEUClient takes an API key and optional Options, and returns a Client that @@ -94,13 +107,19 @@ func NewClient(apiKey string, opts ...Option) (Client, error) { // requests are routed through EU data centers only (Frankfurt, Paris, Ireland), // which can be useful for GDPR compliance. func NewEUClient(apiKey string, opts ...Option) (Client, error) { + return newClient(euAPIEndpoint, apiKey, opts) +} + +// newClient builds a Client for endpoint, applies the options and then the +// request time limit set with WithTimeout, if any. +func newClient(endpoint, apiKey string, opts []Option) (Client, error) { if len(apiKey) == 0 { return Client{}, errAPIKey } c := Client{ c: newHTTPClient(), - e: euAPIEndpoint, + e: endpoint, k: apiKey, } @@ -108,6 +127,14 @@ func NewEUClient(apiKey string, opts ...Option) (Client, error) { opt(&c) } + if c.hasTimeout && c.c != nil { + // work on a copy: an *http.Client supplied through WithHTTPClient + // may be shared with other users + hc := *c.c + hc.Timeout = c.timeout + c.c = &hc + } + return c, nil } @@ -140,8 +167,16 @@ func (c Client) RawLookup(ip string) (*http.Response, error) { // RawLookupWithContext is a RawLookup that uses a provided context.Context. func (c Client) RawLookupWithContext(ctx context.Context, ip string) (*http.Response, error) { + return c.rawLookup(ctx, ip, nil) +} + +// rawLookup sends the GET request for ip, with params (which may be nil) and +// the API key as the query string, and returns the response when its status is +// 200 OK. Any other status becomes an error, of type Error when the body holds +// the API's JSON message, and the response body is closed. +func (c Client) rawLookup(ctx context.Context, ip string, params url.Values) (*http.Response, error) { // build request - req, err := newGetRequestWithContext(ctx, c.e+ip, c.k) + req, err := newGetRequestWithContext(ctx, c.e+ip, c.k, params) if err != nil { return nil, errors.Wrapf(err, "error building request to look up %s", ip) } @@ -163,7 +198,7 @@ func (c Client) RawLookupWithContext(ctx context.Context, ip string) (*http.Resp body, err := io.ReadAll(resp.Body) if err != nil { - return nil, errors.Wrapf(err, "failed to read body from response with status code %q: %s", resp.Status, err) + return nil, errors.Wrapf(err, "failed to read body from response with status code %q", resp.Status) } var a apiErr @@ -192,7 +227,7 @@ func decodeIP(r io.Reader) (IP, error) { } // Lookup takes an IP address to look up the details for. An empty string means -// you want the information about the current node's pubilc IP address. If an +// you want the information about the current node's public IP address. If an // API error occurs, the error value will be of type Error. func (c Client) Lookup(ip string) (IP, error) { return c.LookupWithContext(context.Background(), ip) @@ -200,22 +235,7 @@ func (c Client) Lookup(ip string) (IP, error) { // LookupWithContext is a Lookup that uses a provided context.Context. func (c Client) LookupWithContext(ctx context.Context, ip string) (IP, error) { - resp, err := c.RawLookupWithContext(ctx, ip) - if err != nil { - return IP{}, err - } - - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - }() - - pip, err := decodeIP(resp.Body) - if err != nil { - return IP{}, err - } - - return pip, nil + return c.lookup(ctx, ip, nil) } // LookupFields takes an IP address and a list of fields to return. Only the @@ -227,19 +247,19 @@ func (c Client) LookupFields(ip string, fields []string) (IP, error) { // LookupFieldsWithContext is a LookupFields that uses a provided context.Context. func (c Client) LookupFieldsWithContext(ctx context.Context, ip string, fields []string) (IP, error) { - reqURL := c.e + ip + var params url.Values if len(fields) > 0 { - reqURL += "?fields=" + strings.Join(fields, ",") + params = url.Values{apiFieldsParam: []string{strings.Join(fields, ",")}} } - req, err := newGetRequestWithContext(ctx, reqURL, c.k) - if err != nil { - return IP{}, errors.Wrapf(err, "error building request to look up %s", ip) - } + return c.lookup(ctx, ip, params) +} - resp, err := c.c.Do(req) +// lookup performs rawLookup and decodes the body of the 200 OK response. +func (c Client) lookup(ctx context.Context, ip string, params url.Values) (IP, error) { + resp, err := c.rawLookup(ctx, ip, params) if err != nil { - return IP{}, errors.Wrapf(err, "http request to %q failed", req.URL.Scheme+"://"+req.URL.Host+req.URL.Path) + return IP{}, err } defer func() { @@ -247,28 +267,18 @@ func (c Client) LookupFieldsWithContext(ctx context.Context, ip string, fields [ _ = resp.Body.Close() }() - switch resp.StatusCode { - case http.StatusOK: - pip, err := decodeIP(resp.Body) - if err != nil { - return IP{}, err - } - return pip, nil - default: - body, err := io.ReadAll(resp.Body) - if err != nil { - return IP{}, errors.Wrapf(err, "failed to read body from response with status code %q: %s", resp.Status, err) - } - - var a apiErr - if err := json.Unmarshal(body, &a); err != nil { - return IP{}, errors.Errorf("request for %q failed (unexpected response): %s: %v", ip, resp.Status, err) - } - return IP{}, newError(a.Message, resp.StatusCode) + pip, err := decodeIP(resp.Body) + if err != nil { + return IP{}, err } + + return pip, nil } -func newGetRequestWithContext(ctx context.Context, urlStr, apiKey string) (*http.Request, error) { +// newGetRequestWithContext builds the GET request for urlStr. Its query string +// is built from params plus the API key, which always takes precedence over a +// value in params; params may be nil and is left unchanged. +func newGetRequestWithContext(ctx context.Context, urlStr, apiKey string, params url.Values) (*http.Request, error) { if len(urlStr) == 0 { return nil, errors.New("url cannot be an empty string") } @@ -285,7 +295,11 @@ func newGetRequestWithContext(ctx context.Context, urlStr, apiKey string) (*http req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "application/json") - q := url.Values{apiAuthParam: []string{apiKey}} + q := make(url.Values, len(params)+1) + for name, values := range params { + q[name] = values + } + q.Set(apiAuthParam, apiKey) req.URL.RawQuery = q.Encode() return req, nil @@ -315,7 +329,7 @@ func (c *Client) RawBulkLookupWithContext(ctx context.Context, ips []string) (*h body, err := io.ReadAll(resp.Body) if err != nil { - return nil, errors.Wrapf(err, "failed to read body from response with status code %q: %s", resp.Status, err) + return nil, errors.Wrapf(err, "failed to read body from response with status code %q", resp.Status) } var a apiErr diff --git a/client_test.go b/client_test.go index 92c37e3..9b78410 100644 --- a/client_test.go +++ b/client_test.go @@ -10,6 +10,7 @@ import ( "crypto/tls" "crypto/x509" "encoding/json" + "errors" "fmt" "io" "net" @@ -585,6 +586,9 @@ func Test_client_Lookup(t *testing.T) { } func Test_client_LookupFields(t *testing.T) { + // query holds the query string of the last request the server received + var query atomic.Pointer[url.Values] + mux := http.NewServeMux() mux.HandleFunc("/76.14.47.42", func(w http.ResponseWriter, r *http.Request) { @@ -593,6 +597,9 @@ func Test_client_LookupFields(t *testing.T) { return } + q := r.URL.Query() + query.Store(&q) + if r.FormValue("api-key") != "testAPIkey" { w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, "API key does not exist.") @@ -618,12 +625,34 @@ func Test_client_LookupFields(t *testing.T) { k: "testAPIkey", } + // lastQuery returns the query string of the last request the server saw + lastQuery := func(t *testing.T) url.Values { + t.Helper() + + q := query.Load() + if q == nil { + t.Fatal("the server received no request") + } + + return *q + } + t.Run("with_fields", func(t *testing.T) { ip, err := c.LookupFields("76.14.47.42", []string{"ip", "country_name"}) if err != nil { t.Fatalf("LookupFields() unexpected error: %v", err) } + q := lastQuery(t) + + if got, want := q.Get("fields"), "ip,country_name"; got != want { + t.Errorf("request query fields = %q, want %q", got, want) + } + + if got, want := q.Get("api-key"), "testAPIkey"; got != want { + t.Errorf("request query api-key = %q, want %q", got, want) + } + if ip.IP != "76.14.47.42" { t.Errorf("ip.IP = %q, want %q", ip.IP, "76.14.47.42") } @@ -631,6 +660,12 @@ func Test_client_LookupFields(t *testing.T) { if ip.CountryName != "United States" { t.Errorf("ip.CountryName = %q, want %q", ip.CountryName, "United States") } + + // the partial response the server sends for a request with fields + // has no city; a city means the full document came back + if ip.City != "" { + t.Errorf("ip.City = %q, want %q (fields were not applied)", ip.City, "") + } }) t.Run("no_fields", func(t *testing.T) { @@ -639,6 +674,16 @@ func Test_client_LookupFields(t *testing.T) { t.Fatalf("LookupFields() unexpected error: %v", err) } + q := lastQuery(t) + + if _, ok := q["fields"]; ok { + t.Errorf("request query fields = %q, want no fields parameter", q.Get("fields")) + } + + if got, want := q.Get("api-key"), "testAPIkey"; got != want { + t.Errorf("request query api-key = %q, want %q", got, want) + } + if ip.IP != "76.14.47.42" { t.Errorf("ip.IP = %q, want %q", ip.IP, "76.14.47.42") } @@ -646,6 +691,10 @@ func Test_client_LookupFields(t *testing.T) { if ip.CountryName != "United States" { t.Errorf("ip.CountryName = %q, want %q", ip.CountryName, "United States") } + + if ip.City != "San Francisco" { + t.Errorf("ip.City = %q, want %q", ip.City, "San Francisco") + } }) } @@ -776,9 +825,10 @@ func mustParseURL(u string) *url.URL { func Test_newGetRequestWithContext(t *testing.T) { tests := []struct { - name string - url string - key string + name string + url string + key string + params url.Values want *http.Request err string @@ -804,11 +854,39 @@ func Test_newGetRequestWithContext(t *testing.T) { URL: mustParseURL("http://localhost/?api-key=abc123"), }, }, + { + name: "url_with_params", + key: "abc123", + url: "http://localhost/", + params: url.Values{"fields": {"ip,country_name"}}, + want: &http.Request{ + Header: map[string][]string{ + "User-Agent": {userAgent}, + "Accept": {"application/json"}, + }, + URL: mustParseURL("http://localhost/?api-key=abc123&fields=ip%2Ccountry_name"), + }, + }, + { + name: "params_do_not_replace_api_key", + key: "abc123", + url: "http://localhost/", + params: url.Values{"api-key": {"other"}, "fields": {"ip"}}, + want: &http.Request{ + Header: map[string][]string{ + "User-Agent": {userAgent}, + "Accept": {"application/json"}, + }, + URL: mustParseURL("http://localhost/?api-key=abc123&fields=ip"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := newGetRequestWithContext(context.Background(), tt.url, tt.key) + before := tt.params.Encode() + + got, err := newGetRequestWithContext(context.Background(), tt.url, tt.key, tt.params) if cont := testErrCheck(t, "newGetRequestWithContext()", tt.err, err); !cont { return } @@ -817,6 +895,14 @@ func Test_newGetRequestWithContext(t *testing.T) { t.Fatalf("got.URL = %q, want %q", gots, wants) } + if gots, wants := got.URL.Query().Get("fields"), tt.params.Get("fields"); gots != wants { + t.Fatalf("fields query parameter = %q, want %q", gots, wants) + } + + if after := tt.params.Encode(); after != before { + t.Fatalf("params = %q after the call, want them unchanged (%q)", after, before) + } + if gots, wants := got.Header.Get("User-Agent"), tt.want.Header.Get("User-Agent"); gots != wants { t.Fatalf("User-Agent = %q, want %q", gots, wants) } @@ -2624,3 +2710,233 @@ func TestClient_BulkLookup_newFields(t *testing.T) { t.Fatalf("IP slice differs: (-want +got)\n%s", diff) } } + +// TestClient_truncatedErrorResponse covers an error response whose body cannot +// be read to the end: the server announces more bytes than it sends and then +// closes the connection. The returned error names the status and carries the +// read error exactly once. +func TestClient_truncatedErrorResponse(t *testing.T) { + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "100") + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"message":`) + } + + mux := http.NewServeMux() + mux.HandleFunc("/8.8.8.8", handler) + mux.HandleFunc("/bulk", handler) + + server := httptest.NewServer(mux) + defer server.Close() + + c := Client{c: newHTTPClient(), e: server.URL + "/", k: "testAPIkey"} + + tests := []struct { + name string + call func() error + }{ + { + name: "Lookup", + call: func() error { + _, err := c.Lookup("8.8.8.8") + return err + }, + }, + { + name: "RawLookup", + call: func() error { + _, err := c.RawLookup("8.8.8.8") + return err + }, + }, + { + name: "BulkLookup", + call: func() error { + _, err := c.BulkLookup([]string{"1.1.1.1", "8.8.8.8"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + if err == nil { + t.Fatalf("%s() returned no error, want a body read error", tt.name) + } + + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("%s() error = %v, want it to wrap io.ErrUnexpectedEOF", tt.name, err) + } + + msg := err.Error() + + if prefix := `failed to read body from response with status code "500 Internal Server Error": `; !strings.HasPrefix(msg, prefix) { + t.Errorf("%s() error = %q, want prefix %q", tt.name, msg, prefix) + } + + if n := strings.Count(msg, io.ErrUnexpectedEOF.Error()); n != 1 { + t.Errorf("%s() error = %q, mentions the read error %d times, want once", tt.name, msg, n) + } + }) + } +} + +func TestNewClient_WithTimeout(t *testing.T) { + t.Run("default_http_client", func(t *testing.T) { + c, err := NewClient("testAPIkey", WithTimeout(5*time.Second)) + if err != nil { + t.Fatalf("NewClient() unexpected error: %v", err) + } + + if c.c == nil { + t.Fatal("c.c should not be nil") + } + + if got, want := c.c.Timeout, 5*time.Second; got != want { + t.Fatalf("c.c.Timeout = %s, want %s", got, want) + } + + if _, ok := c.c.Transport.(*http.Transport); !ok { + t.Fatalf("c.c.Transport is %T, want the *http.Transport of newHTTPClient()", c.c.Transport) + } + }) + + t.Run("custom_http_client", func(t *testing.T) { + rt := &http.Transport{} + custom := &http.Client{Transport: rt, Timeout: time.Minute} + + tests := []struct { + name string + opts []Option + }{ + { + name: "timeout_after_http_client", + opts: []Option{WithHTTPClient(custom), WithTimeout(5 * time.Second)}, + }, + { + name: "timeout_before_http_client", + opts: []Option{WithTimeout(5 * time.Second), WithHTTPClient(custom)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := NewEUClient("testAPIkey", tt.opts...) + if err != nil { + t.Fatalf("NewEUClient() unexpected error: %v", err) + } + + if c.c == custom { + t.Fatal("c.c is the caller's *http.Client, want a copy") + } + + if c.c.Transport != http.RoundTripper(rt) { + t.Fatalf("c.c.Transport = %#v, want the custom transport", c.c.Transport) + } + + if got, want := c.c.Timeout, 5*time.Second; got != want { + t.Fatalf("c.c.Timeout = %s, want %s", got, want) + } + + if got, want := custom.Timeout, time.Minute; got != want { + t.Fatalf("custom.Timeout = %s, want %s: the caller's client was modified", got, want) + } + }) + } + }) + + t.Run("zero_means_no_limit", func(t *testing.T) { + custom := &http.Client{Timeout: time.Minute} + + c, err := NewClient("testAPIkey", WithHTTPClient(custom), WithTimeout(0)) + if err != nil { + t.Fatalf("NewClient() unexpected error: %v", err) + } + + if c.c.Timeout != 0 { + t.Fatalf("c.c.Timeout = %s, want 0", c.c.Timeout) + } + + if got, want := custom.Timeout, time.Minute; got != want { + t.Fatalf("custom.Timeout = %s, want %s: the caller's client was modified", got, want) + } + }) + + t.Run("without_option", func(t *testing.T) { + c, err := NewClient("testAPIkey") + if err != nil { + t.Fatalf("NewClient() unexpected error: %v", err) + } + + if c.c.Timeout != 0 { + t.Fatalf("c.c.Timeout = %s, want 0", c.c.Timeout) + } + }) +} + +// TestClient_WithTimeout_request sends requests to a server that only answers +// once the client has given up, and checks that every lookup fails with a +// timeout error instead of waiting for that answer. +func TestClient_WithTimeout_request(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // reading the body lets the server notice the client going away, and + // cancel r.Context(), while the handler waits + _, _ = io.Copy(io.Discard, r.Body) + + select { + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + _, _ = io.WriteString(w, testJSONValid) + } + })) + defer server.Close() + + c, err := NewClient("testAPIkey", WithTimeout(100*time.Millisecond)) + if err != nil { + t.Fatalf("NewClient() unexpected error: %v", err) + } + + c.e = server.URL + "/" + + tests := []struct { + name string + call func() error + }{ + { + name: "Lookup", + call: func() error { + _, err := c.Lookup("8.8.8.8") + return err + }, + }, + { + name: "LookupFields", + call: func() error { + _, err := c.LookupFields("8.8.8.8", []string{"ip"}) + return err + }, + }, + { + name: "BulkLookup", + call: func() error { + _, err := c.BulkLookup([]string{"1.1.1.1", "8.8.8.8"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + if err == nil { + t.Fatalf("%s() returned no error, want a timeout", tt.name) + } + + var nerr net.Error + if !errors.As(err, &nerr) || !nerr.Timeout() { + t.Fatalf("%s() error = %v, want an error whose Timeout() reports true", tt.name, err) + } + }) + } +} diff --git a/doc.go b/doc.go index 09b21d7..424c12e 100644 --- a/doc.go +++ b/doc.go @@ -9,7 +9,7 @@ // // If you have any problems with this client, please raise an issue on GitHub: // -// * https://github.com/theckman/go-ipdata/issues +// * https://github.com/ipdata/go/issues // // Example usage: // @@ -26,5 +26,7 @@ // server negotiates it) for as long as it lives, so build one Client and reuse // it for every lookup instead of creating one per request. A Client is safe // for concurrent use by multiple goroutines; call CloseIdleConnections at -// shutdown to release the pooled connections. +// shutdown to release the pooled connections. A request has no overall time +// limit unless the Client was built with WithTimeout or a context with a +// deadline is passed to the WithContext methods. package ipdata diff --git a/types.go b/types.go index 4b52b71..010eab0 100644 --- a/types.go +++ b/types.go @@ -112,7 +112,9 @@ type TimeZone struct { } // Threat represents the threat object within the JSON response from the API. -// This provides information about what type of threat this IP may be. +// This provides information about what type of threat this IP may be: the +// boolean fields classify the address, Blocklists lists the feeds that include +// it (see Blocklist) and Scores holds the threat scores (see Scores). type Threat struct { IsTOR bool `json:"is_tor"` IsVPN bool `json:"is_vpn"` @@ -143,6 +145,11 @@ type Blocklist struct { DaysSeen *int `json:"days_seen,omitempty"` } +// Scores represents the scores object within the threat object of the JSON +// response from the API. Each score is a number from 0 to 100: VPNScore, +// ProxyScore and ThreatScore give how likely the IP address is a VPN, a proxy +// or a threat, and TrustScore how trustworthy it is, a higher value meaning a +// more trustworthy address. type Scores struct { VPNScore int `json:"vpn_score"` ProxyScore int `json:"proxy_score"`