-
Notifications
You must be signed in to change notification settings - Fork 144
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #295 from maxmind/horgh/api
Create simpler package API
- Loading branch information
Showing
34 changed files
with
885 additions
and
736 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
// Package client is a client for downloading GeoIP2 and GeoLite2 MMDB | ||
// databases. | ||
package client | ||
|
||
import ( | ||
"fmt" | ||
"net/http" | ||
) | ||
|
||
// Client downloads GeoIP2 and GeoLite2 MMDB databases. | ||
// | ||
// After creation, it is valid for concurrent use. | ||
type Client struct { | ||
accountID int | ||
endpoint string | ||
httpClient *http.Client | ||
licenseKey string | ||
} | ||
|
||
// Option is an option for configuring Client. | ||
type Option func(*Client) | ||
|
||
// WithEndpoint sets the base endpoint to use. By default we use | ||
// https://updates.maxmind.com. | ||
func WithEndpoint(endpoint string) Option { | ||
return func(c *Client) { | ||
c.endpoint = endpoint | ||
} | ||
} | ||
|
||
// WithHTTPClient sets the HTTP client to use. By default we use | ||
// http.DefaultClient. | ||
func WithHTTPClient(httpClient *http.Client) Option { | ||
return func(c *Client) { | ||
c.httpClient = httpClient | ||
} | ||
} | ||
|
||
// New creates a Client. | ||
func New( | ||
accountID int, | ||
licenseKey string, | ||
options ...Option, | ||
) (Client, error) { | ||
if accountID <= 0 { | ||
return Client{}, fmt.Errorf("invalid account ID: %d", accountID) | ||
} | ||
|
||
if licenseKey == "" { | ||
return Client{}, fmt.Errorf("invalid license key: %s", licenseKey) | ||
} | ||
|
||
c := Client{ | ||
accountID: accountID, | ||
endpoint: "https://updates.maxmind.com", | ||
httpClient: http.DefaultClient, | ||
licenseKey: licenseKey, | ||
} | ||
|
||
for _, opt := range options { | ||
opt(&c) | ||
} | ||
|
||
return c, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,211 @@ | ||
package client | ||
|
||
import ( | ||
"archive/tar" | ||
"compress/gzip" | ||
"context" | ||
"errors" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/url" | ||
"strconv" | ||
"strings" | ||
"time" | ||
|
||
"github.com/maxmind/geoipupdate/v6/internal" | ||
"github.com/maxmind/geoipupdate/v6/internal/vars" | ||
) | ||
|
||
// DownloadResponse describes the result of a Download call. | ||
type DownloadResponse struct { | ||
// LastModified is the date that the database was last modified. It will | ||
// only be set if UpdateAvailable is true. | ||
LastModified time.Time | ||
|
||
// MD5 is the string representation of the new database. It will only be set | ||
// if UpdateAvailable is true. | ||
MD5 string | ||
|
||
// Reader can be read to access the database itself. It will only contain a | ||
// database if UpdateAvailable is true. | ||
// | ||
// If the Download call does not return an error, Reader will always be | ||
// non-nil. | ||
// | ||
// If UpdateAvailable is true, the caller must read Reader to completion and | ||
// close it. | ||
Reader io.ReadCloser | ||
|
||
// UpdateAvailable is true if there is an update available for download. It | ||
// will be false if the MD5 used in the Download call matches what the server | ||
// currently has. | ||
UpdateAvailable bool | ||
} | ||
|
||
// Download attempts to download the edition. | ||
// | ||
// The editionID parameter is a valid database edition ID, such as | ||
// "GeoIP2-City". | ||
// | ||
// The MD5 parameter is a string representation of the MD5 sum of the database | ||
// MMDB file you have previously downloaded. If you don't yet have one | ||
// downloaded, this can be "". This is used to know if an update is available | ||
// and avoid consuming resources if there is not. | ||
// | ||
// If the current MD5 checksum matches what the server currently has, no | ||
// download is performed. | ||
func (c Client) Download( | ||
ctx context.Context, | ||
editionID, | ||
md5 string, | ||
) (DownloadResponse, error) { | ||
metadata, err := c.getMetadata(ctx, editionID) | ||
if err != nil { | ||
return DownloadResponse{}, err | ||
} | ||
|
||
if metadata.MD5 == md5 { | ||
return DownloadResponse{ | ||
Reader: io.NopCloser(strings.NewReader("")), | ||
UpdateAvailable: false, | ||
}, nil | ||
} | ||
|
||
reader, modifiedTime, err := c.download(ctx, editionID, metadata.Date) | ||
if err != nil { | ||
return DownloadResponse{}, err | ||
} | ||
|
||
return DownloadResponse{ | ||
LastModified: modifiedTime, | ||
MD5: metadata.MD5, | ||
Reader: reader, | ||
UpdateAvailable: true, | ||
}, nil | ||
} | ||
|
||
const downloadEndpoint = "%s/geoip/databases/%s/download?" | ||
|
||
func (c *Client) download( | ||
ctx context.Context, | ||
editionID, | ||
date string, | ||
) (io.ReadCloser, time.Time, error) { | ||
date = strings.ReplaceAll(date, "-", "") | ||
|
||
params := url.Values{} | ||
params.Add("date", date) | ||
params.Add("suffix", "tar.gz") | ||
|
||
escapedEdition := url.PathEscape(editionID) | ||
requestURL := fmt.Sprintf(downloadEndpoint, c.endpoint, escapedEdition) + params.Encode() | ||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) | ||
if err != nil { | ||
return nil, time.Time{}, fmt.Errorf("creating download request: %w", err) | ||
} | ||
req.Header.Add("User-Agent", "geoipupdate/"+vars.Version) | ||
req.SetBasicAuth(strconv.Itoa(c.accountID), c.licenseKey) | ||
|
||
response, err := c.httpClient.Do(req) | ||
if err != nil { | ||
return nil, time.Time{}, fmt.Errorf("performing download request: %w", err) | ||
} | ||
// It is safe to close the response body reader as it wouldn't be | ||
// consumed in case this function returns an error. | ||
defer func() { | ||
if err != nil { | ||
// TODO(horgh): Should we fully consume the body? | ||
response.Body.Close() | ||
} | ||
}() | ||
|
||
if response.StatusCode != http.StatusOK { | ||
// TODO(horgh): Should we fully consume the body? | ||
//nolint:errcheck // we are already returning an error. | ||
buf, _ := io.ReadAll(io.LimitReader(response.Body, 256)) | ||
httpErr := internal.HTTPError{ | ||
Body: string(buf), | ||
StatusCode: response.StatusCode, | ||
} | ||
return nil, time.Time{}, fmt.Errorf("unexpected HTTP status code: %w", httpErr) | ||
} | ||
|
||
gzReader, err := gzip.NewReader(response.Body) | ||
if err != nil { | ||
return nil, time.Time{}, fmt.Errorf("encountered an error creating GZIP reader: %w", err) | ||
} | ||
defer func() { | ||
if err != nil { | ||
gzReader.Close() | ||
} | ||
}() | ||
|
||
tarReader := tar.NewReader(gzReader) | ||
|
||
// iterate through the tar archive to extract the mmdb file | ||
for { | ||
header, err := tarReader.Next() | ||
if err == io.EOF { | ||
return nil, time.Time{}, errors.New("tar archive does not contain an mmdb file") | ||
} | ||
if err != nil { | ||
return nil, time.Time{}, fmt.Errorf("reading tar archive: %w", err) | ||
} | ||
|
||
if strings.HasSuffix(header.Name, ".mmdb") { | ||
break | ||
} | ||
} | ||
|
||
lastModified, err := parseTime(response.Header.Get("Last-Modified")) | ||
if err != nil { | ||
return nil, time.Time{}, fmt.Errorf("reading Last-Modified header: %w", err) | ||
} | ||
|
||
return editionReader{ | ||
Reader: tarReader, | ||
gzCloser: gzReader, | ||
responseCloser: response.Body, | ||
}, | ||
lastModified, | ||
nil | ||
} | ||
|
||
// parseTime parses a string representation of a time into time.Time according to the | ||
// RFC1123 format. | ||
func parseTime(s string) (time.Time, error) { | ||
t, err := time.ParseInLocation(time.RFC1123, s, time.UTC) | ||
if err != nil { | ||
return time.Time{}, fmt.Errorf("parsing time: %w", err) | ||
} | ||
|
||
return t, nil | ||
} | ||
|
||
// editionReader embeds a tar.Reader and holds references to other readers to close. | ||
type editionReader struct { | ||
*tar.Reader | ||
gzCloser io.Closer | ||
responseCloser io.Closer | ||
} | ||
|
||
// Close closes the additional referenced readers. | ||
func (e editionReader) Close() error { | ||
var err error | ||
if e.gzCloser != nil { | ||
gzErr := e.gzCloser.Close() | ||
if gzErr != nil { | ||
err = errors.Join(err, gzErr) | ||
} | ||
} | ||
|
||
if e.responseCloser != nil { | ||
responseErr := e.responseCloser.Close() | ||
if responseErr != nil { | ||
err = errors.Join(err, responseErr) | ||
} | ||
} | ||
return err | ||
} |
Oops, something went wrong.