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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions example/lookup-plus/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"fmt"
"log"
"net"
"os"

"github.com/ipinfo/go/v2/ipinfo"
)

func main() {
client := ipinfo.NewPlusClient(nil, nil, os.Getenv("IPINFO_TOKEN"))

ip := net.ParseIP("8.8.8.8")
info, err := client.GetIPInfo(ip)
if err != nil {
log.Fatal(err)
}

fmt.Printf("IP: %s\n", info.IP)
fmt.Printf("Hostname: %s\n", info.Hostname)
if info.Geo != nil {
fmt.Printf("City: %s\n", info.Geo.City)
fmt.Printf("Region: %s (%s)\n", info.Geo.Region, info.Geo.RegionCode)
fmt.Printf("Country: %s (%s)\n", info.Geo.Country, info.Geo.CountryCode)
fmt.Printf("Continent: %s (%s)\n", info.Geo.Continent, info.Geo.ContinentCode)
fmt.Printf("Location: %f, %f\n", info.Geo.Latitude, info.Geo.Longitude)
fmt.Printf("Timezone: %s\n", info.Geo.Timezone)
fmt.Printf("Postal Code: %s\n", info.Geo.PostalCode)
}
if info.AS != nil {
fmt.Printf("ASN: %s\n", info.AS.ASN)
fmt.Printf("AS Name: %s\n", info.AS.Name)
fmt.Printf("AS Domain: %s\n", info.AS.Domain)
fmt.Printf("AS Type: %s\n", info.AS.Type)
}
if info.Mobile != nil {
fmt.Printf("Mobile - Name: %s, MCC: %s, MNC: %s\n",
info.Mobile.Name, info.Mobile.MCC, info.Mobile.MNC)
}
if info.Anonymous != nil {
fmt.Printf("Anonymous - Proxy: %v, Relay: %v, Tor: %v, VPN: %v\n",
info.Anonymous.IsProxy, info.Anonymous.IsRelay, info.Anonymous.IsTor, info.Anonymous.IsVPN)
}
fmt.Printf("Anonymous: %v\n", info.IsAnonymous)
fmt.Printf("Anycast: %v\n", info.IsAnycast)
fmt.Printf("Hosting: %v\n", info.IsHosting)
fmt.Printf("Mobile: %v\n", info.IsMobile)
fmt.Printf("Satellite: %v\n", info.IsSatellite)
if info.Abuse != nil {
fmt.Printf("Abuse Contact - Email: %s, Name: %s\n", info.Abuse.Email, info.Abuse.Name)
}
if info.Company != nil {
fmt.Printf("Company - Name: %s, Domain: %s, Type: %s\n",
info.Company.Name, info.Company.Domain, info.Company.Type)
}
if info.Privacy != nil {
fmt.Printf("Privacy - VPN: %v, Proxy: %v, Tor: %v, Relay: %v, Hosting: %v\n",
info.Privacy.VPN, info.Privacy.Proxy, info.Privacy.Tor, info.Privacy.Relay, info.Privacy.Hosting)
}
if info.Domains != nil {
fmt.Printf("Domains - Total: %d\n", info.Domains.Total)
if len(info.Domains.Domains) > 0 {
maxDomains := 3
if len(info.Domains.Domains) < maxDomains {
maxDomains = len(info.Domains.Domains)
}
fmt.Printf("Sample Domains: %v\n", info.Domains.Domains[:maxDomains])
}
}
}
4 changes: 4 additions & 0 deletions ipinfo/ipinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ var DefaultLiteClient *LiteClient
// Package-level Core bundle client
var DefaultCoreClient *CoreClient

// Package-level Plus bundle client
var DefaultPlusClient *PlusClient

func init() {
// Create global clients for legacy, Lite, Core APIs
DefaultClient = NewClient(nil, nil, "")
DefaultLiteClient = NewLiteClient(nil, nil, "")
DefaultCoreClient = NewCoreClient(nil, nil, "")
DefaultPlusClient = NewPlusClient(nil, nil, "")
}
290 changes: 290 additions & 0 deletions ipinfo/plus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
package ipinfo

import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
)

const (
defaultPlusBaseURL = "https://api.ipinfo.io/lookup/"
)

// PlusClient is a client for the IPinfo Plus API.
type PlusClient struct {
// HTTP client used to communicate with the API.
client *http.Client

// Base URL for API requests.
BaseURL *url.URL

// User agent used when communicating with the IPinfo API.
UserAgent string

// Cache interface implementation to prevent API quota overuse.
Cache *Cache

// The API token used for authorization.
Token string
}

// Plus represents the response from the IPinfo Plus API /lookup endpoint.
type Plus struct {
IP net.IP `json:"ip"`
Hostname string `json:"hostname,omitempty"`
Bogon bool `json:"bogon,omitempty"`
Geo *PlusGeo `json:"geo,omitempty"`
AS *PlusAS `json:"as,omitempty"`
Mobile *PlusMobile `json:"mobile,omitempty"`
Anonymous *PlusAnonymous `json:"anonymous,omitempty"`
IsAnonymous bool `json:"is_anonymous"`
IsAnycast bool `json:"is_anycast"`
IsHosting bool `json:"is_hosting"`
IsMobile bool `json:"is_mobile"`
IsSatellite bool `json:"is_satellite"`
Abuse *PlusAbuse `json:"abuse,omitempty"`
Company *PlusCompany `json:"company,omitempty"`
Privacy *PlusPrivacy `json:"privacy,omitempty"`
Domains *PlusDomains `json:"domains,omitempty"`
}

// PlusGeo represents the geo object in Plus API response.
type PlusGeo struct {
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
RegionCode string `json:"region_code,omitempty"`
Country string `json:"country,omitempty"`
CountryCode string `json:"country_code,omitempty"`
Continent string `json:"continent,omitempty"`
ContinentCode string `json:"continent_code,omitempty"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Timezone string `json:"timezone,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
DMACode string `json:"dma_code,omitempty"`
GeonameID string `json:"geoname_id,omitempty"`
Radius int `json:"radius"`
LastChanged string `json:"last_changed,omitempty"`

// Extended fields using the same country data as legacy Core API
CountryName string `json:"-"`
IsEU bool `json:"-"`
CountryFlag CountryFlag `json:"-"`
CountryFlagURL string `json:"-"`
CountryCurrency CountryCurrency `json:"-"`
ContinentInfo Continent `json:"-"`
}

// PlusAS represents the AS object in Plus API response.
type PlusAS struct {
ASN string `json:"asn"`
Name string `json:"name"`
Domain string `json:"domain"`
Type string `json:"type"`
LastChanged string `json:"last_changed,omitempty"`
}

// PlusMobile represents the mobile object in Plus API response.
type PlusMobile struct {
Name string `json:"name,omitempty"`
MCC string `json:"mcc,omitempty"`
MNC string `json:"mnc,omitempty"`
}

// PlusAnonymous represents the anonymous object in Plus API response.
type PlusAnonymous struct {
IsProxy bool `json:"is_proxy"`
IsRelay bool `json:"is_relay"`
IsTor bool `json:"is_tor"`
IsVPN bool `json:"is_vpn"`
Name string `json:"name,omitempty"`
}

// PlusAbuse represents the abuse object in Plus API response.
type PlusAbuse struct {
Address string `json:"address,omitempty"`
Country string `json:"country,omitempty"`
CountryName string `json:"country_name,omitempty"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Network string `json:"network,omitempty"`
Phone string `json:"phone,omitempty"`
}

// PlusCompany represents the company object in Plus API response.
type PlusCompany struct {
Name string `json:"name,omitempty"`
Domain string `json:"domain,omitempty"`
Type string `json:"type,omitempty"`
}

// PlusPrivacy represents the privacy object in Plus API response.
type PlusPrivacy struct {
VPN bool `json:"vpn"`
Proxy bool `json:"proxy"`
Tor bool `json:"tor"`
Relay bool `json:"relay"`
Hosting bool `json:"hosting"`
Service string `json:"service,omitempty"`
}

// PlusDomains represents the domains object in Plus API response.
type PlusDomains struct {
IP string `json:"ip,omitempty"`
Total uint64 `json:"total"`
Domains []string `json:"domains,omitempty"`
}

func (v *Plus) enrichGeo() {
if v.Geo != nil && v.Geo.CountryCode != "" {
v.Geo.CountryName = GetCountryName(v.Geo.CountryCode)
v.Geo.IsEU = IsEU(v.Geo.CountryCode)
v.Geo.CountryFlag.Emoji = GetCountryFlagEmoji(v.Geo.CountryCode)
v.Geo.CountryFlag.Unicode = GetCountryFlagUnicode(v.Geo.CountryCode)
v.Geo.CountryFlagURL = GetCountryFlagURL(v.Geo.CountryCode)
v.Geo.CountryCurrency.Code = GetCountryCurrencyCode(v.Geo.CountryCode)
v.Geo.CountryCurrency.Symbol = GetCountryCurrencySymbol(v.Geo.CountryCode)
v.Geo.ContinentInfo.Code = GetContinentCode(v.Geo.CountryCode)
v.Geo.ContinentInfo.Name = GetContinentName(v.Geo.CountryCode)
}
if v.Abuse != nil && v.Abuse.Country != "" {
v.Abuse.CountryName = GetCountryName(v.Abuse.Country)
}
}

// NewPlusClient creates a new IPinfo Plus API client.
func NewPlusClient(httpClient *http.Client, cache *Cache, token string) *PlusClient {
if httpClient == nil {
httpClient = http.DefaultClient
}

baseURL, _ := url.Parse(defaultPlusBaseURL)
return &PlusClient{
client: httpClient,
BaseURL: baseURL,
UserAgent: defaultUserAgent,
Cache: cache,
Token: token,
}
}

// GetIPInfo returns the Plus details for the specified IP.
func (c *PlusClient) GetIPInfo(ip net.IP) (*Plus, error) {
if ip != nil && isBogon(netip.MustParseAddr(ip.String())) {
bogonResponse := new(Plus)
bogonResponse.Bogon = true
bogonResponse.IP = ip
return bogonResponse, nil
}
relUrl := ""
if ip != nil {
relUrl = ip.String()
}

if c.Cache != nil {
if res, err := c.Cache.Get(cacheKey(relUrl)); err == nil {
return res.(*Plus), nil
}
}

req, err := c.newRequest(nil, "GET", relUrl, nil)
if err != nil {
return nil, err
}

res := new(Plus)
if _, err := c.do(req, res); err != nil {
return nil, err
}

res.enrichGeo()

if c.Cache != nil {
if err := c.Cache.Set(cacheKey(relUrl), res); err != nil {
return res, err
}
}

return res, nil
}

func (c *PlusClient) newRequest(ctx context.Context,
method string,
urlStr string,
body io.Reader,
) (*http.Request, error) {
if ctx == nil {
ctx = context.Background()
}

u := new(url.URL)
baseURL := c.BaseURL
if rel, err := url.Parse(urlStr); err == nil {
u = baseURL.ResolveReference(rel)
} else if strings.ContainsRune(urlStr, ':') {
// IPv6 strings fail to parse as URLs, so let's add it as a URL Path.
*u = *baseURL
u.Path += urlStr
} else {
return nil, err
}

// get `http` package request object.
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
if err != nil {
return nil, err
}

// set common headers.
req.Header.Set("Accept", "application/json")
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}

return req, nil
}

func (c *PlusClient) do(
req *http.Request,
v interface{},
) (*http.Response, error) {
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

err = checkResponse(resp)
if err != nil {
// even though there was an error, we still return the response
// in case the caller wants to inspect it further
return resp, err
}

if v != nil {
if w, ok := v.(io.Writer); ok {
io.Copy(w, resp.Body)
} else {
err = json.NewDecoder(resp.Body).Decode(v)
if err == io.EOF {
// ignore EOF errors caused by empty response body
err = nil
}
}
}

return resp, err
}

// GetIPInfoPlus returns the Plus details for the specified IP.
func GetIPInfoPlus(ip net.IP) (*Plus, error) {
return DefaultPlusClient.GetIPInfo(ip)
}