diff --git a/.github/workflows/golang-ci.yml b/.github/workflows/golang-ci.yml new file mode 100644 index 0000000..96f1a6a --- /dev/null +++ b/.github/workflows/golang-ci.yml @@ -0,0 +1,25 @@ +name: Go Test + +on: push + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: "1.22.4" + + - name: Check code formatting + uses: Jerome1337/gofmt-action@v1.0.5 + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6.0.1 + + - name: Test + run: go test -v ./... diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..86c99f9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/dnephin/pre-commit-golang + rev: master + hooks: + - id: go-fmt + - id: golangci-lint + - id: go-unit-tests diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc2cd5a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Eyad Hussein + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1c8c6b5..c5fa60e 100644 --- a/README.md +++ b/README.md @@ -1 +1,90 @@ -# datetime-client-eyadhussein \ No newline at end of file + +# DateTime Client + +This package provides a simple HTTP client for interacting with a datetime server. It's designed to retrieve the current date and time from a specified server endpoint. + +## Features + +- Easy-to-use client for fetching current date and time +- Configurable base URL and port +- Built-in retry mechanism with backoff strategy +- Timeout handling + +## Installation + +To use this package in your Go project, you can install it using: + +``` +go get github.com/codescalersinternships/datetime-client-eyadhussein +``` + +## Usage + +Here's a basic example of how to use the DateTime Client: + +```go +package main + +import ( + "fmt" + "log" + "time" + "github.com/codescalersinternships/datetime-client-eyadhussein/pkg/datetimeclient" +) + +func main() { + // Create a new client + client := datetimeclient.NewRealClient("http://localhost", "8080", 10*time.Second) + + // Get the current date and time + dateTime, err := client.GetCurrentDateTime() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Current Date and Time: %q\n", string(dateTime)) +} +``` + +If environment variables are defined, just create a new client with empty string arguments: +```go +package main + +import ( + "fmt" + "log" + "time" + "github.com/codescalersinternships/datetime-client-eyadhussein/pkg/datetimeclient" +) + +func main() { + // Create a new client + client := datetimeclient.NewRealClient("", "", 10*time.Second) + + // Get the current date and time + dateTime, err := client.GetCurrentDateTime() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Current Date and Time: %q\n", string(dateTime)) +} +``` + +In the terminal, run: +```bash +SERVER_URL=http://localhost PORT=8080 go run main.go +``` + +Terminal output: +```bash +2024-07-04 15:11:44 +``` + +# How to Test + +Run + +```bash +go test -v ./... +``` diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..1b02002 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "log" + "time" + + datetimeclient "github.com/codescalersinternships/datetime-client-eyadhussein/pkg/client" +) + +func main() { + c := datetimeclient.NewRealClient("http://localhost", "8080", time.Duration(1)*time.Second) + + data, err := c.GetCurrentDateTime() + + if err != nil { + log.Fatal(err) + } + + log.Println(string(data)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e160e56 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/codescalersinternships/datetime-client-eyadhussein + +go 1.22.5 diff --git a/pkg/backoff/backoff.go b/pkg/backoff/backoff.go new file mode 100644 index 0000000..ee1f6e1 --- /dev/null +++ b/pkg/backoff/backoff.go @@ -0,0 +1,44 @@ +// Package backoff provides functionality for retrying operations with a simple backoff strategy. +package backoff + +import ( + "errors" + "net/http" + "time" +) + +// Operation represents a function that returns an HTTP response and an error. +type Operation func() (*http.Response, error) + +// BackOff interface defines the method for retrying a request for number of times before failure. +type BackOff interface { + Retry(operation Operation) (*http.Response, error) +} + +// RealBackOff implements a simple backoff strategy with a fixed duration between retries. +type RealBackOff struct { + Duration time.Duration // time to wait between retry attempts. + MaxRetry int // maximum number of retry attempts +} + +// NewRealBackOff creates and returns a new RealBackOff instance with the specified duration and maximum retries. +func NewRealBackOff(duration time.Duration, maxRetry int) *RealBackOff { + return &RealBackOff{ + Duration: duration, + MaxRetry: maxRetry, + } +} + +// Retry attempts to execute the given operation, retrying up to MaxRetry times with a fixed Duration between attempts. +// It returns the successful HTTP response or an error if all attempts fail. +func (b *RealBackOff) Retry(operation Operation) (*http.Response, error) { + for i := 0; i < b.MaxRetry; i++ { + resp, err := operation() + + if err == nil { + return resp, nil + } + time.Sleep(b.Duration) + } + return &http.Response{}, errors.New("reached maximum retries with no established connection") +} diff --git a/pkg/backoff/backoff_test.go b/pkg/backoff/backoff_test.go new file mode 100644 index 0000000..72b2a08 --- /dev/null +++ b/pkg/backoff/backoff_test.go @@ -0,0 +1,134 @@ +package backoff + +import ( + "errors" + "io" + "log" + "net/http" + "testing" + "time" +) + +const maxRetries = 3 +const validRetries = 2 + +type SpySleeper struct { + Calls int +} + +func (s *SpySleeper) Sleep(d time.Duration) { + s.Calls++ +} + +type MockBackOff struct { + Duration time.Duration + MaxRetry int + sleeper *SpySleeper +} + +func NewMockBackOff(duration time.Duration, maxRetry int) *MockBackOff { + return &MockBackOff{ + Duration: duration, + MaxRetry: maxRetry, + sleeper: &SpySleeper{}, + } +} + +func (b *MockBackOff) Retry(operation Operation) (*http.Response, error) { + for i := 0; i < b.MaxRetry; i++ { + resp, err := operation() + + if err == nil { + return resp, nil + } + b.sleeper.Sleep(b.Duration) + } + return &http.Response{}, errors.New("reached maximum retries with no established connection") +} + +func TestBackOff_Retry(t *testing.T) { + t.Run("successful on first try", func(t *testing.T) { + b := NewMockBackOff(1*time.Second, maxRetries) + operation := func() (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK}, nil + } + + resp, err := b.Retry(operation) + + assertNoError(t, err) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected success code but got %d", resp.StatusCode) + } + + if b.sleeper.Calls != 0 { + t.Errorf("expected no sleep calls but got %d", b.sleeper.Calls) + } + }) + + t.Run("successful after retries", func(t *testing.T) { + b := NewMockBackOff(1*time.Second, maxRetries) + callCount := 0 + operation := func() (*http.Response, error) { + callCount++ + if callCount < validRetries { + return nil, errors.New("temporary error") + } + return &http.Response{StatusCode: http.StatusOK}, nil + } + + resp, err := b.Retry(operation) + + assertNoError(t, err) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected success code but got %d", resp.StatusCode) + } + + if b.sleeper.Calls != validRetries-1 { + t.Errorf("expected sleep to be called 1 time but was called %d times", b.sleeper.Calls) + } + }) + + t.Run("failure after max retries", func(t *testing.T) { + b := NewMockBackOff(1*time.Second, maxRetries) + operation := func() (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable}, errors.New("service unavailable try again later") + } + + _, err := b.Retry(operation) + + if err == nil { + t.Error("expected error but got nil") + } + }) +} + +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Errorf("expected nil but got %v", err) + } +} + +func ExampleBackOff_Retry() { + backoff := NewRealBackOff(1*time.Second, 3) + operation := func() (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK}, nil + } + + resp, err := backoff.Retry(operation) + + if err != nil { + log.Fatal(err) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + + log.Println(string(body)) +} diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..02db131 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,75 @@ +// Package datetimeclient provides a client for interacting with a datetime server. +package datetimeclient + +import ( + "io" + "net/http" + "os" + "time" + + "github.com/codescalersinternships/datetime-client-eyadhussein/pkg/backoff" +) + +// Client interface defines the method for getting the current date and time. +type Client interface { + GetCurrentDateTime() ([]byte, error) +} + +// RealClient implements Client and uses a http client for interacting with the datetime server. +type RealClient struct { + baseUrl string + port string + client *http.Client +} + +// NewRealClient creates and returns a new RealClient instance. +// It uses environment variables for baseUrl and port if not provided. +func NewRealClient(baseUrl, port string, timeout time.Duration) *RealClient { + if baseUrl == "" { + baseUrl = os.Getenv("SERVER_URL") + } + if port == "" { + port = os.Getenv("PORT") + } + + port = ":" + port + + return &RealClient{ + baseUrl: baseUrl, + port: port, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +// GetCurrentDateTime sends a request to the datetime server and returns the current date and time. +// It uses a backoff strategy for retrying the request in case of failures. +// Returns the response body as a byte slice and any error encountered. +func (c *RealClient) GetCurrentDateTime() ([]byte, error) { + backoff := backoff.NewRealBackOff(1, 3) + req, err := http.NewRequest(http.MethodGet, c.baseUrl+c.port+"/datetime", nil) + if err != nil { + return nil, err + } + + req.Header.Add("Accept", "text/plain;charset=UTF-8, application/json") + + resp, err := backoff.Retry(func() (*http.Response, error) { + resp, err := c.client.Do(req) + return resp, err + }) + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return body, nil + +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..a8dc7cc --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,195 @@ +package datetimeclient + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +const ( + testTimeout = 3 * time.Second + + validTimeout = testTimeout - 1*time.Second + inValidTimeout = testTimeout + 1*time.Second +) + +type MockHttpClient struct { + Client struct { + Timeout time.Duration + } + SleepDuration time.Duration +} + +func (m *MockHttpClient) Do(req *http.Request) (*http.Response, error) { + if m.SleepDuration > m.Client.Timeout { + return &http.Response{StatusCode: http.StatusRequestTimeout}, context.DeadlineExceeded + } + return &http.Response{StatusCode: http.StatusOK}, nil +} + +type MockClient struct { + url string + client *MockHttpClient +} + +func (m *MockClient) GetCurrentDateTime() ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, m.url+"/datetime", nil) + if err != nil { + return nil, err + } + _, err = m.client.Do(req) + if err != nil { + return nil, err + } + return []byte("2024-07-04 15:11:44"), nil +} + +func NewMockClient(url string, timeout time.Duration, sleepDuration time.Duration) *MockClient { + return &MockClient{ + url: url, + client: &MockHttpClient{ + Client: struct{ Timeout time.Duration }{ + Timeout: timeout, + }, + SleepDuration: sleepDuration, + }, + } +} +func TestClient_GetCurrentDateTime(t *testing.T) { + t.Run("handle json format", func(t *testing.T) { + expected := "2024-07-07 02:39:09" + jsonExpected, _ := json.Marshal(expected) + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jsonExpected) + })) + + defer mockServer.Close() + + client := NewRealClient( + mockServer.URL, + "", + 1*time.Second, + ) + + data, err := client.GetCurrentDateTime() + assertNoError(t, err) + + var resParsed string + err = json.Unmarshal(data, &resParsed) + + assertNoError(t, err) + assertEqual(t, resParsed, expected) + }) + + t.Run("handle text/plain format", func(t *testing.T) { + expected := "2024-07-07 02:39:09" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, expected) + })) + + defer mockServer.Close() + + client := NewRealClient( + mockServer.URL, + "", + 1*time.Second, + ) + + data, err := client.GetCurrentDateTime() + + assertNoError(t, err) + assertEqual(t, string(data), expected) + }) + + t.Run("valid timeout", func(t *testing.T) { + client := NewMockClient( + "", testTimeout, validTimeout, + ) + _, err := client.GetCurrentDateTime() + + assertNoError(t, err) + }) + + t.Run("invalid timeout", func(t *testing.T) { + + client := NewMockClient( + "", testTimeout, inValidTimeout, + ) + _, err := client.GetCurrentDateTime() + + assertError(t, err) + }) + + t.Run("correct endpoint", func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/datetime" { + t.Errorf("expected path /datetime, got %s", r.URL.Path) + } + fmt.Fprint(w, "2024-07-07 02:39:09") + })) + defer mockServer.Close() + + client := NewRealClient(mockServer.URL, "", 1*time.Second) + _, err := client.GetCurrentDateTime() + + assertNoError(t, err) + }) + + t.Run("correct Accept header", func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accept := r.Header.Get("Accept") + expected := "text/plain;charset=UTF-8, application/json" + if accept != expected { + t.Errorf("expected Accept header %s, got %s", expected, accept) + } + fmt.Fprint(w, "2024-07-07 02:39:09") + })) + defer mockServer.Close() + + client := NewRealClient(mockServer.URL, "", 1*time.Second) + _, err := client.GetCurrentDateTime() + + assertNoError(t, err) + + }) +} + +func assertEqual(t *testing.T, got, want any) { + t.Helper() + if got != want { + t.Errorf("got %v want %v", got, want) + } +} + +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Errorf("expected nil but got %v", err) + } +} + +func assertError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Error("expected error but got nil") + } +} + +func ExampleClient_GetCurrentDateTime() { + client := NewRealClient("http://localhost", "8080", 1*time.Second) + + resp, err := client.GetCurrentDateTime() + + if err != nil { + log.Fatal(err) + } + + log.Println(string(resp)) +}