diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..a9158fc --- /dev/null +++ b/client/client.go @@ -0,0 +1,38 @@ +package client + +import ( + "net/http" +) + +// HTTPReader is a Reader that uses an HTTP client to retrieve +// databases. +type HTTPReader struct { + // client is an http client responsible of fetching database updates. + client *http.Client + // path is the request path. + path string + // accountID is used for request auth. + accountID int + // licenseKey is used for request auth. + licenseKey string + // verbose turns on/off debug logs. + verbose bool +} + +// NewHTTPReader creates a Reader that downloads database updates via +// HTTP. +func NewHTTPReader( + path string, + accountID int, + licenseKey string, + verbose bool, + httpClient *http.Client, +) *HTTPReader { + return &HTTPReader{ + client: httpClient, + path: path, + accountID: accountID, + licenseKey: licenseKey, + verbose: verbose, + } +} diff --git a/internal/geoipupdate/database/http_reader.go b/client/download.go similarity index 61% rename from internal/geoipupdate/database/http_reader.go rename to client/download.go index 8dd5dc7..1c9db3d 100644 --- a/internal/geoipupdate/database/http_reader.go +++ b/client/download.go @@ -1,18 +1,14 @@ -// Package database provides an abstraction over getting and writing a -// database file. -package database +package client import ( "archive/tar" "compress/gzip" "context" - "encoding/json" "errors" "fmt" "io" "log" "net/http" - "net/url" "strconv" "strings" "time" @@ -21,44 +17,6 @@ import ( "github.com/maxmind/geoipupdate/v6/internal/vars" ) -const ( - metadataEndpoint = "%s/geoip/updates/metadata?" - downloadEndpoint = "%s/geoip/databases/%s/download?" -) - -// HTTPReader is a Reader that uses an HTTP client to retrieve -// databases. -type HTTPReader struct { - // client is an http client responsible of fetching database updates. - client *http.Client - // path is the request path. - path string - // accountID is used for request auth. - accountID int - // licenseKey is used for request auth. - licenseKey string - // verbose turns on/off debug logs. - verbose bool -} - -// NewHTTPReader creates a Reader that downloads database updates via -// HTTP. -func NewHTTPReader( - path string, - accountID int, - licenseKey string, - verbose bool, - httpClient *http.Client, -) *HTTPReader { - return &HTTPReader{ - client: httpClient, - path: path, - accountID: accountID, - licenseKey: licenseKey, - verbose: verbose, - } -} - // Read attempts to fetch database updates for a specific editionID. // It takes an editionID and its previously downloaded hash if available // as arguments and returns a ReadResult struct as a response. @@ -73,6 +31,8 @@ func (r *HTTPReader) Read(ctx context.Context, editionID, hash string) (*ReadRes return result, nil } +const downloadEndpoint = "%s/geoip/databases/%s/download?" + // get makes an http request to fetch updates for a specific editionID if any. func (r *HTTPReader) get( ctx context.Context, @@ -178,67 +138,6 @@ func (r *HTTPReader) get( }, nil } -// metadata represents the metadata content for a certain database returned by the -// metadata endpoint. -type metadata struct { - Date string `json:"date"` - EditionID string `json:"edition_id"` - MD5 string `json:"md5"` -} - -func (r *HTTPReader) getMetadata(ctx context.Context, editionID string) (*metadata, error) { - params := url.Values{} - params.Add("edition_id", editionID) - - metadataRequestURL := fmt.Sprintf(metadataEndpoint, r.path) + params.Encode() - - if r.verbose { - log.Printf("Requesting metadata for %s: %s", editionID, metadataRequestURL) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataRequestURL, nil) - if err != nil { - return nil, fmt.Errorf("creating metadata request: %w", err) - } - req.Header.Add("User-Agent", "geoipupdate/"+vars.Version) - req.SetBasicAuth(strconv.Itoa(r.accountID), r.licenseKey) - - response, err := r.client.Do(req) - if err != nil { - return nil, fmt.Errorf("performing metadata request: %w", err) - } - defer response.Body.Close() - - responseBody, err := io.ReadAll(response.Body) - if err != nil { - return nil, fmt.Errorf("reading metadata response body: %w", err) - } - - if response.StatusCode != http.StatusOK { - httpErr := internal.HTTPError{ - Body: string(responseBody), - StatusCode: response.StatusCode, - } - return nil, fmt.Errorf("unexpected HTTP status code: %w", httpErr) - } - - var metadataResponse struct { - Databases []metadata `json:"databases"` - } - - if err := json.Unmarshal(responseBody, &metadataResponse); err != nil { - return nil, fmt.Errorf("parsing metadata body: %w", err) - } - - if len(metadataResponse.Databases) != 1 { - return nil, fmt.Errorf("response does not contain edition %s", editionID) - } - - edition := metadataResponse.Databases[0] - - return &edition, nil -} - // parseTime parses a string representation of a time into time.Time according to the // RFC1123 format. func parseTime(s string) (time.Time, error) { diff --git a/internal/geoipupdate/database/http_reader_test.go b/client/download_test.go similarity index 75% rename from internal/geoipupdate/database/http_reader_test.go rename to client/download_test.go index 6cb78fa..8dda2d6 100644 --- a/internal/geoipupdate/database/http_reader_test.go +++ b/client/download_test.go @@ -1,11 +1,6 @@ -package database +package client import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "io" "net/http" "net/http/httptest" "strings" @@ -243,77 +238,3 @@ func TestRead(t *testing.T) { }) } } - -// TestGetMetadata checks the metadata fetching functionality. -func TestGetMetadata(t *testing.T) { - tests := []struct { - description string - preserveFileTime bool - server func(t *testing.T) *httptest.Server - checkResult func(t *testing.T, receivedMetadata *metadata, err error) - }{ - { - description: "successful request", - preserveFileTime: false, - server: func(t *testing.T) *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jsonData := ` -{ - "databases": [ - { "edition_id": "edition-1", "md5": "123456", "date": "2024-02-23" } - ] -} -` - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(jsonData)) - require.NoError(t, err) - })) - return server - }, - checkResult: func(t *testing.T, receivedMetadata *metadata, err error) { - require.NoError(t, err) - - expectedMetadata := &metadata{ - EditionID: "edition-1", MD5: "123456", Date: "2024-02-23", - } - require.Equal(t, expectedMetadata, receivedMetadata) - }, - }, - { - description: "server error", - preserveFileTime: false, - server: func(_ *testing.T) *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - return server - }, - checkResult: func(t *testing.T, receivedMetadata *metadata, err error) { - require.Nil(t, receivedMetadata) - require.Error(t, err) - require.Regexp(t, "^unexpected HTTP status code", err.Error()) - }, - }, - } - - ctx := context.Background() - - for _, test := range tests { - t.Run(test.description, func(t *testing.T) { - server := test.server(t) - defer server.Close() - - r := NewHTTPReader( - server.URL, // fixed, as the server is mocked above. - 10, // fixed, as it's not valuable for the purpose of the test. - "license", // fixed, as it's not valuable for the purpose of the test. - false, // verbose - http.DefaultClient, - ) - - result, err := r.getMetadata(ctx, "edition-1") - test.checkResult(t, result, err) - }) - } -} diff --git a/client/metadata.go b/client/metadata.go new file mode 100644 index 0000000..fd507ef --- /dev/null +++ b/client/metadata.go @@ -0,0 +1,78 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strconv" + + "github.com/maxmind/geoipupdate/v6/internal" + "github.com/maxmind/geoipupdate/v6/internal/vars" +) + +const metadataEndpoint = "%s/geoip/updates/metadata?" + +// metadata represents the metadata content for a certain database returned by the +// metadata endpoint. +type metadata struct { + Date string `json:"date"` + EditionID string `json:"edition_id"` + MD5 string `json:"md5"` +} + +func (r *HTTPReader) getMetadata(ctx context.Context, editionID string) (*metadata, error) { + params := url.Values{} + params.Add("edition_id", editionID) + + metadataRequestURL := fmt.Sprintf(metadataEndpoint, r.path) + params.Encode() + + if r.verbose { + log.Printf("Requesting metadata for %s: %s", editionID, metadataRequestURL) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataRequestURL, nil) + if err != nil { + return nil, fmt.Errorf("creating metadata request: %w", err) + } + req.Header.Add("User-Agent", "geoipupdate/"+vars.Version) + req.SetBasicAuth(strconv.Itoa(r.accountID), r.licenseKey) + + response, err := r.client.Do(req) + if err != nil { + return nil, fmt.Errorf("performing metadata request: %w", err) + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("reading metadata response body: %w", err) + } + + if response.StatusCode != http.StatusOK { + httpErr := internal.HTTPError{ + Body: string(responseBody), + StatusCode: response.StatusCode, + } + return nil, fmt.Errorf("unexpected HTTP status code: %w", httpErr) + } + + var metadataResponse struct { + Databases []metadata `json:"databases"` + } + + if err := json.Unmarshal(responseBody, &metadataResponse); err != nil { + return nil, fmt.Errorf("parsing metadata body: %w", err) + } + + if len(metadataResponse.Databases) != 1 { + return nil, fmt.Errorf("response does not contain edition %s", editionID) + } + + edition := metadataResponse.Databases[0] + + return &edition, nil +} diff --git a/client/metadata_test.go b/client/metadata_test.go new file mode 100644 index 0000000..b245801 --- /dev/null +++ b/client/metadata_test.go @@ -0,0 +1,84 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestGetMetadata checks the metadata fetching functionality. +func TestGetMetadata(t *testing.T) { + tests := []struct { + description string + preserveFileTime bool + server func(t *testing.T) *httptest.Server + checkResult func(t *testing.T, receivedMetadata *metadata, err error) + }{ + { + description: "successful request", + preserveFileTime: false, + server: func(t *testing.T) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jsonData := ` +{ + "databases": [ + { "edition_id": "edition-1", "md5": "123456", "date": "2024-02-23" } + ] +} +` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(jsonData)) + require.NoError(t, err) + })) + return server + }, + checkResult: func(t *testing.T, receivedMetadata *metadata, err error) { + require.NoError(t, err) + + expectedMetadata := &metadata{ + EditionID: "edition-1", MD5: "123456", Date: "2024-02-23", + } + require.Equal(t, expectedMetadata, receivedMetadata) + }, + }, + { + description: "server error", + preserveFileTime: false, + server: func(_ *testing.T) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + return server + }, + checkResult: func(t *testing.T, receivedMetadata *metadata, err error) { + require.Nil(t, receivedMetadata) + require.Error(t, err) + require.Regexp(t, "^unexpected HTTP status code", err.Error()) + }, + }, + } + + ctx := context.Background() + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + server := test.server(t) + defer server.Close() + + r := NewHTTPReader( + server.URL, // fixed, as the server is mocked above. + 10, // fixed, as it's not valuable for the purpose of the test. + "license", // fixed, as it's not valuable for the purpose of the test. + false, // verbose + http.DefaultClient, + ) + + result, err := r.getMetadata(ctx, "edition-1") + test.checkResult(t, result, err) + }) + } +}