Skip to content

Commit

Permalink
Merge pull request #295 from maxmind/horgh/api
Browse files Browse the repository at this point in the history
Create simpler package API
  • Loading branch information
ugexe authored Mar 27, 2024
2 parents 617df5c + 3c3df8f commit 5789549
Show file tree
Hide file tree
Showing 34 changed files with 885 additions and 736 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
build:
strategy:
matrix:
go-version: [1.20.x, 1.21.x]
go-version: [1.21.x, 1.22.x]
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
name: "Build ${{ matrix.go-version }} test on ${{ matrix.platform }}"
Expand Down
12 changes: 9 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

* `geoipupdate` now supports retrying on more types of errors
such as HTTP2 INTERNAL_ERROR.
* `HTTPReader` no longer retries on HTTP errors and therefore
`retryFor` was removed from `NewHTTPReader`.
* Now `geoipupdate` doesn't requires the user to specify the config file
even if all the other arguments are set via the environment variables.
Reported by jsf84ksnf. GitHub #284.
Expand All @@ -15,9 +13,17 @@
a database edition.
* `/geoip/databases/{edition-id}/download` which is responsible for downloading
the content of a database edition. This new endpoint redirects downloads to R2
presigned URLs, so systems running geoipupdate need to be able to reach
presigned URLs, so systems running `geoipupdate` need to be able to
reach
`mm-prod-geoip-databases.a2649acb697e2c09b632799562c076f2.r2.cloudflarestorage.com`
in addition to `updates.maxmind.com`.
* BREAKING CHANGE: The public package API has been redesigned. The previous
API was not easy to use and had become a maintenance burden. We now
expose a `Client` at `github.com/maxmind/geoipupdate/client` with a
`Download()` method. The intention is to expose less of the `geoipupdate`
internals and provide a simpler and easier to use package. Many
previously exposed methods and types are now either internal only or have
been removed.

## 6.1.0 (2024-01-09)

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Please see our [Docker documentation](doc/docker.md).

### Installation from source or Git

You need the Go compiler (1.20+). You can get it at the [Go
You need the Go compiler (1.21+). You can get it at the [Go
website](https://golang.org).

The easiest way is via `go install`:
Expand Down Expand Up @@ -137,7 +137,7 @@ tracker](https://github.com/maxmind/geoipupdate/issues).

# Copyright and License

This software is Copyright (c) 2018 - 2023 by MaxMind, Inc.
This software is Copyright (c) 2018 - 2024 by MaxMind, Inc.

This is free software, licensed under the [Apache License, Version
2.0](LICENSE-APACHE) or the [MIT License](LICENSE-MIT), at your option.
65 changes: 65 additions & 0 deletions client/client.go
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
}
211 changes: 211 additions & 0 deletions client/download.go
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
}
Loading

0 comments on commit 5789549

Please sign in to comment.