From b894afb6bab744dbcfca10dee87d616eff0a4e1b Mon Sep 17 00:00:00 2001 From: softwarespot Date: Mon, 14 Oct 2024 22:42:05 +0300 Subject: [PATCH] Initial commit --- .env.example | 3 + .github/workflows/go.yml | 23 ++ .gitignore | 30 +++ Dockerfile | 23 ++ LICENSE | 20 ++ Makefile | 22 ++ README.md | 209 ++++++++++++++++++ cmd/execute.go | 28 +++ cmd/help.go | 18 ++ cmd/helpers.go | 40 ++++ cmd/server.go | 76 +++++++ cmd/version.go | 29 +++ go.mod | 3 + internal/date-time/helpers.go | 15 ++ internal/env/get.go | 14 ++ internal/env/load-test/.env.invalid | 5 + internal/env/load-test/.env.valid | 4 + internal/env/load-test/get_test.go | 42 ++++ internal/env/load-test/helpers_test.go | 9 + internal/env/load-test/load_test.go | 57 +++++ internal/env/load.go | 51 +++++ internal/holidays/county_code.go | 16 ++ internal/holidays/handler_dk.go | 28 +++ internal/holidays/handler_fi.go | 35 +++ internal/holidays/handler_is.go | 35 +++ internal/holidays/handler_no.go | 29 +++ internal/holidays/handler_se.go | 36 +++ internal/holidays/helpers.go | 33 +++ internal/holidays/helpers_test.go | 58 +++++ internal/holidays/holiday.go | 15 ++ internal/holidays/manager.go | 35 +++ internal/logging/helpers.go | 33 +++ internal/logging/level.go | 14 ++ internal/logging/logger.go | 10 + internal/logging/memory.go | 25 +++ internal/logging/stdout_logger.go | 45 ++++ internal/service/args.go | 16 ++ internal/service/error.go | 34 +++ .../service/middleware/concurrent_requests.go | 27 +++ internal/service/middleware/metrics.go | 27 +++ internal/service/middleware/panic_recovery.go | 39 ++++ internal/service/response_writer.go | 82 +++++++ internal/service/server.go | 137 ++++++++++++ internal/version/version.go | 15 ++ main.go | 21 ++ test-helpers/assert.go | 30 +++ test-helpers/date-time.go | 16 ++ 47 files changed, 1612 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/go.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/execute.go create mode 100644 cmd/help.go create mode 100644 cmd/helpers.go create mode 100644 cmd/server.go create mode 100644 cmd/version.go create mode 100644 go.mod create mode 100644 internal/date-time/helpers.go create mode 100644 internal/env/get.go create mode 100644 internal/env/load-test/.env.invalid create mode 100644 internal/env/load-test/.env.valid create mode 100644 internal/env/load-test/get_test.go create mode 100644 internal/env/load-test/helpers_test.go create mode 100644 internal/env/load-test/load_test.go create mode 100644 internal/env/load.go create mode 100644 internal/holidays/county_code.go create mode 100644 internal/holidays/handler_dk.go create mode 100644 internal/holidays/handler_fi.go create mode 100644 internal/holidays/handler_is.go create mode 100644 internal/holidays/handler_no.go create mode 100644 internal/holidays/handler_se.go create mode 100644 internal/holidays/helpers.go create mode 100644 internal/holidays/helpers_test.go create mode 100644 internal/holidays/holiday.go create mode 100644 internal/holidays/manager.go create mode 100644 internal/logging/helpers.go create mode 100644 internal/logging/level.go create mode 100644 internal/logging/logger.go create mode 100644 internal/logging/memory.go create mode 100644 internal/logging/stdout_logger.go create mode 100644 internal/service/args.go create mode 100644 internal/service/error.go create mode 100644 internal/service/middleware/concurrent_requests.go create mode 100644 internal/service/middleware/metrics.go create mode 100644 internal/service/middleware/panic_recovery.go create mode 100644 internal/service/response_writer.go create mode 100644 internal/service/server.go create mode 100644 internal/version/version.go create mode 100644 main.go create mode 100644 test-helpers/assert.go create mode 100644 test-helpers/date-time.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b6f9131 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +ENV=production +SERVER_PORT= +SERVER_MAX_CONCURRENCY= diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..a1e466b --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,23 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go +on: [push, pull_request] + +jobs: + test: + strategy: + matrix: + go-version: [1.23.x] + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Test + run: go test -cover -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba0fa3c --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Taken from URL: https://github.com/github/gitignore/blob/main/Go.gitignore +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env +.env.production + +# Misc +bin/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b881128 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# See URL: https://hub.docker.com/_/golang +# Use the Go image to build the binary only +FROM golang:1.23.0 AS builder +ENV CGO_ENABLED=0 +ENV GOOS=linux +WORKDIR /go/src/public-holidays/ +COPY . . + +# Overwrite the development .env with the .env.production +COPY .env.production .env + +RUN make + +# See URL: https://hub.docker.com/_/alpine +# Use this image (~50MB) to run the "public-holidays", as the Go image contains too much bloat, +# which isn't needed for running the application in production and the image which can be uploaded +# to a public/private Docker register is then small +FROM alpine:3.20.3 + +COPY --from=builder /go/src/public-holidays/bin/* ./ +COPY --from=builder /go/src/public-holidays/.env ./ +EXPOSE 10000 +CMD ["./public-holidays"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b2ef48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2024 SoftwareSpot Apps + +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/Makefile b/Makefile new file mode 100644 index 0000000..7d9dd32 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +# If the "VERSION" environment variable is not set, then use this value instead +VERSION?=1.0.0 +TIME=$(shell date +%FT%T%z) +GOVERSION=$(shell go version | awk '{print $$3}' | sed s/go//) + +LDFLAGS=-ldflags "\ + -X github.com/softwarespot/public-holidays/internal/version.Version=${VERSION} \ + -X github.com/softwarespot/public-holidays/internal/version.Time=${TIME} \ + -X github.com/softwarespot/public-holidays/internal/version.User=${USER} \ + -X github.com/softwarespot/public-holidays/internal/version.GoVersion=${GOVERSION} \ + -s \ + -w \ +" + +build: + @echo building to bin/public-holidays + @go build $(LDFLAGS) -o ./bin/public-holidays + +test: + go test -cover -v ./... + +.PHONY: build test diff --git a/README.md b/README.md new file mode 100644 index 0000000..a85e188 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# Public holidays + +![Go Tests](https://github.com/softwarespot/public-holidays/actions/workflows/go.yml/badge.svg) + +This project showcases my proficiency in Go by creating a clear and readable +public holidays API. The primary focus is on writing clean, maintainable code +that effectively demonstrates the logic behind retrieving public holidays for a +country's ISO 3166-1 alpha-2 code. + +## GET /holidays/v1/{country}/{year} + +This endpoint retrieves a list of public holidays for a specified country and +year. By replacing `{country}` with the two-letter ISO 3166-1 alpha-2 code of +the desired country and `{year}` with the target year, users can access detailed +information about each holiday, including dates, local names and English names. +This API is useful for applications that need to display or utilize holiday data +for various purposes, such as scheduling or planning. + +## Hosted by [render.com](https://render.com/) + +This API is available at https://public-holidays.onrender.com. + +**IMPORTANT** +The instance might be down due to inactivity, therefore, wait about 50s for the instance to be started again. + +### Example request + +```bash +curl -s "https://public-holidays.onrender.com/holidays/v1/FI/2024" | jq + +# or locally + +curl -s "http://localhost:10000/holidays/v1/FI/2024" | jq +``` + +### Response format + +The response will be in JSON format and includes an array of holiday objects +with the following details: + +- **date**: The date of the holiday. +- **name**: The name of the holiday in the local language. +- **englishName**: The name of the holiday in English. + +```json +[ + { + "date": "2024-01-01", + "name": "Uudenvuodenpäivä", + "englishName": "New Year's Day" + }, + { + "date": "2024-01-06", + "name": "Loppiainen", + "englishName": "Epiphany" + }, + + ... +] +``` + +## Countries + +Here is a list of the supported countries. + +| Country | ISO 3166-1 alpha-2 | Wikipedia Link | +| ---------- | ------------------ | -------------------------------------------------------------------------------------- | +| 🇩🇰 Denmark | DK | [Public Holidays in Denmark](https://en.wikipedia.org/wiki/Public_holidays_in_Denmark) | +| 🇫🇮 Finland | FI | [Public Holidays in Finland](https://en.wikipedia.org/wiki/Public_holidays_in_Finland) | +| 🇮🇸 Iceland | IS | [Public Holidays in Iceland](https://en.wikipedia.org/wiki/Public_holidays_in_Iceland) | +| 🇳🇴 Norway | NO | [Public Holidays in Norway](https://en.wikipedia.org/wiki/Public_holidays_in_Norway) | +| 🇸🇪 Sweden | SE | [Public Holidays in Sweden](https://en.wikipedia.org/wiki/Public_holidays_in_Sweden) | + +## Prerequisites + +- Go 1.23.0 or above +- make (if you want to use the `Makefile` provided) +- Docker + +## Dependencies + +**IMPORTANT:** No 3rd party dependencies are used. + +I could easily use [Cobra](https://github.com/spf13/cobra) (and usually I do, +because it allows me to write powerful CLIs), but I felt it was too much for +such a tiny project. I only ever use dependencies when it's say an adapter for +an external service e.g. Redis, MySQL or Prometheus. + +## Setup + +1. Create and edit the `.env` (used when developing locally) and `.env.production` (used when deploying to production) + +```bash +cp .env.example .env +cp .env.example .env.production +``` + +## Run not using Docker + +```bash +go run . +``` + +or when using `make` + +```bash +make + +./bin/public-holidays +``` + +### Version + +Display the version of the application and exit. + +```bash +# As text +./bin/public-holidays --version + +# As JSON +./bin/public-holidays --json --version +``` + +### Help + +Display the help text and exit. + +```bash +./bin/public-holidays --help +``` + +## Run using Docker + +1. Build the Docker image with the tag `public-holidays`. + +```bash +docker build -t public-holidays . +``` + +2. Run the Docker image. + +```bash +# Port number should the same as defined in ".env.production" +docker run -p "10000:10000" --rm public-holidays +``` + +### Version + +Display the version of the application and exit. + +```bash +# As text +docker run --rm public-holidays --version + +# As JSON +docker run --rm public-holidays --json --version +``` + +### Help + +Display the help text and exit. + +```bash +docker run --rm public-holidays --help +``` + +## Tests + +Tests are written as [Table-Driven Tests](https://go.dev/wiki/TableDrivenTests). + +```bash +go test -cover -v ./... +``` + +or when using `make` + +```bash +make test +``` + +### Linting + +Docker + +```bash +docker run --rm -v $(pwd):/app -w /app golangci/golangci-lint:latest golangci-lint run -v --tests=false --disable-all -E durationcheck,errorlint,exhaustive,gocritic,gosimple,ineffassign,misspell,predeclared,revive,staticcheck,unparam,unused,whitespace --max-issues-per-linter=10000 --max-same-issues=10000 +``` + +Local + +```bash +golangci-lint run --tests=false --disable-all -E durationcheck,errorlint,exhaustive,gocritic,gosimple,ineffassign,misspell,predeclared,revive,staticcheck,unparam,unused,whitespace --max-issues-per-linter=10000 --max-same-issues=10000 +``` + +## Additional information + +This section documents any additional information which might be deemed important for the reviewer. + +### Decisions made + +- Despite using 1.23.0+ and the `slices` pkg being available, I have opted not + to use it, and instead went for how I've been writing Go code before the + `slices` pkg existed. Although for production code, I have started to use it + where applicable. +- Loosely used https://jsonapi.org/. + +### License + +The code has been licensed under the [MIT](https://opensource.org/license/mit) license. diff --git a/cmd/execute.go b/cmd/execute.go new file mode 100644 index 0000000..c1d2b52 --- /dev/null +++ b/cmd/execute.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "flag" + + "github.com/softwarespot/public-holidays/internal/logging" +) + +func Execute(_ []string, logger logging.Logger) error { + var showHelp bool + flagBoolVarP(&showHelp, "help", "h", false, "Display the help text and exit") + + var showVersion bool + flagBoolVarP(&showVersion, "version", "v", false, "Display the version of the application and exit") + + var asJSON bool + flagBoolVarP(&asJSON, "json", "j", false, "Output the result as JSON") + flag.Parse() + + if showHelp { + cmdHelp() + return nil + } + if showVersion { + return cmdVersion(asJSON) + } + return cmdServer(logger) +} diff --git a/cmd/help.go b/cmd/help.go new file mode 100644 index 0000000..2a0c25f --- /dev/null +++ b/cmd/help.go @@ -0,0 +1,18 @@ +package cmd + +import "fmt" + +func cmdHelp() { + helpText := `Usage: ./public-holidays [OPTIONS] + +Start the public holidays API. + +Options: + -h, --help Show this help text and exit. + -v, --version Display the version of the application and exit. + -j, --json Output the version as JSON. + +Examples: + ./public-holidays` + fmt.Println(helpText) +} diff --git a/cmd/helpers.go b/cmd/helpers.go new file mode 100644 index 0000000..1e278a1 --- /dev/null +++ b/cmd/helpers.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "flag" + "fmt" + "math" + "strconv" +) + +// The naming has been taken from "pflag" i.e. used in "cobra". URL: https://pkg.go.dev/github.com/spf13/pflag#BoolVarP +func flagBoolVarP(p *bool, name, shorthand string, value bool, usage string) { + flag.BoolVar(p, name, value, usage) + flag.BoolVar(p, shorthand, value, usage) +} + +func parseMaxConcurrency(s string) (int32, error) { + v, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid max concurrency of %q. Expected an integer of 32 bits", s) + } + if v < math.MinInt32 || v > math.MaxInt32 { + return 0, fmt.Errorf("invalid max concurrency of %q. Expected an integer of 32 bits", s) + } + return int32(v), nil +} + +func parseYear(s string) (int, error) { + if len(s) != 4 { + return 0, fmt.Errorf("invalid year of %q. Expected a valid 4 digit year", s) + } + + year, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid year of %q. Expected a valid 4 digit year", s) + } + if year <= 0 { + return 0, fmt.Errorf("invalid year of %q. Expected a valid 4 digit year", s) + } + return year, nil +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..65a0225 --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "context" + "errors" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/softwarespot/public-holidays/internal/env" + "github.com/softwarespot/public-holidays/internal/holidays" + "github.com/softwarespot/public-holidays/internal/logging" + "github.com/softwarespot/public-holidays/internal/service" + "github.com/softwarespot/public-holidays/internal/service/middleware" +) + +func cmdServer(logger logging.Logger) error { + port := env.Get("SERVER_PORT", "10000") + maxConcurrency, err := parseMaxConcurrency(env.Get("SERVER_MAX_CONCURRENCY", "500")) + if err != nil { + return err + } + + rw := service.NewResponseWriter(logger) + s := service.NewServer(":"+port, logger) + s.Use( + middleware.NewPanicRecovery(func(w http.ResponseWriter, r *http.Request, err error) { rw.ErrorAsJSON(w, r, err) }, logger), + middleware.NewMetrics(logger), + middleware.NewConcurrentRequests(int32(maxConcurrency), logger), + ) + + hm := holidays.NewManager() + routeHolidays := "GET /holidays/v1/{countryCode}/{year}" + s.HandleFunc(routeHolidays, func(w http.ResponseWriter, r *http.Request) error { + headers := w.Header() + headers.Set("Access-Control-Allow-Origin", "*") + headers.Set("Access-Control-Allow-Methods", "GET") + + // Disable the API from being indexed by search engines e.g. Google. + // See URL: https://developers.google.com/search/reference/robots_meta_tag + headers.Set("X-Robots-Tag", "none") + + code, err := holidays.NewCountryCode(r.PathValue("countryCode")) + if err != nil { + return service.NewError(err, http.StatusBadRequest) + } + + year, err := parseYear(r.PathValue("year")) + if err != nil { + return service.NewError(err, http.StatusBadRequest) + } + + res, err := hm.Get(code, year) + if err != nil { + return service.NewError(err, http.StatusBadRequest) + } + return rw.WriteAsJSON(w, r, res) + }) + s.HandleErrorFunc(routeHolidays, func(w http.ResponseWriter, r *http.Request, err error) { + rw.ErrorAsJSON(w, r, err) + }) + + s.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) error { + if r.URL.Path != "/" { + return service.NewError(errors.New("Not Found"), http.StatusNotFound) + } + + http.Redirect(w, r, "https://github.com/softwarespot/public-holidays", http.StatusMovedPermanently) + return nil + }) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + return s.ListenAndServe(ctx) +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..ff6b83d --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/softwarespot/public-holidays/internal/version" +) + +func cmdVersion(asJSON bool) error { + if !asJSON { + fmt.Printf("Version:\t%s\n", version.Version) + fmt.Printf("Build Time:\t%s\n", version.Time) + fmt.Printf("Build User:\t%s\n", version.User) + fmt.Printf("Go Version:\t%s\n", version.GoVersion) + return nil + } + + // This could be a struct, but it would be a temporary struct in that case. + // Therefore, a map is honestly enough for this + out := map[string]string{ + "version": version.Version, + "buildTime": version.Time, + "buildUser": version.User, + "goVersion": version.GoVersion, + } + return json.NewEncoder(os.Stdout).Encode(out) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..38e24d1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/softwarespot/public-holidays + +go 1.23.0 diff --git a/internal/date-time/helpers.go b/internal/date-time/helpers.go new file mode 100644 index 0000000..cfb3cdc --- /dev/null +++ b/internal/date-time/helpers.go @@ -0,0 +1,15 @@ +package datetime + +import "time" + +const ( + // ISO 8601 date format i.e. YYYY-MM-DD + layoutDateOnly = "2006-01-02" +) + +func ToDateString(t time.Time) string { + if t.IsZero() { + return "0000-00-00" + } + return t.Format(layoutDateOnly) +} diff --git a/internal/env/get.go b/internal/env/get.go new file mode 100644 index 0000000..6f814e5 --- /dev/null +++ b/internal/env/get.go @@ -0,0 +1,14 @@ +package env + +import "os" + +// Get retrieves the value of the environment variable named +// by the key. If the variable is present in the environment the +// value (which may be empty) is returned. +// Otherwise the returned value will be the fallback value +func Get(key, fallback string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return fallback +} diff --git a/internal/env/load-test/.env.invalid b/internal/env/load-test/.env.invalid new file mode 100644 index 0000000..ddd31bc --- /dev/null +++ b/internal/env/load-test/.env.invalid @@ -0,0 +1,5 @@ +TEST_1=variable 1 +# TEST_2 + +Test2 +Test3=1234 diff --git a/internal/env/load-test/.env.valid b/internal/env/load-test/.env.valid new file mode 100644 index 0000000..96d5b6c --- /dev/null +++ b/internal/env/load-test/.env.valid @@ -0,0 +1,4 @@ +TEST_1=variable 1 +# TEST_2=variable 2 + +Test3=1234 diff --git a/internal/env/load-test/get_test.go b/internal/env/load-test/get_test.go new file mode 100644 index 0000000..1fb78ee --- /dev/null +++ b/internal/env/load-test/get_test.go @@ -0,0 +1,42 @@ +package loadtest + +import ( + "os" + "testing" + + "github.com/softwarespot/public-holidays/internal/env" + testhelpers "github.com/softwarespot/public-holidays/test-helpers" +) + +func Test_Get(t *testing.T) { + tests := []struct { + name string + key string + fallback string + want string + }{ + { + name: "exists", + key: "TEST_1", + fallback: "fallback 1", + want: "variable 1", + }, + { + name: "doesn't exist", + key: "TEST_3", + fallback: "fallback 3", + want: "fallback 3", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer envCleanup() + + err := env.Load(os.DirFS("."), ".env.valid") + testhelpers.AssertNoError(t, err) + + got := env.Get(tt.key, tt.fallback) + testhelpers.AssertEqual(t, got, tt.want) + }) + } +} diff --git a/internal/env/load-test/helpers_test.go b/internal/env/load-test/helpers_test.go new file mode 100644 index 0000000..8d2ea9c --- /dev/null +++ b/internal/env/load-test/helpers_test.go @@ -0,0 +1,9 @@ +package loadtest + +import "os" + +func envCleanup() { + os.Unsetenv("TEST_1") + os.Unsetenv("TEST_2") + os.Unsetenv("Test3") +} diff --git a/internal/env/load-test/load_test.go b/internal/env/load-test/load_test.go new file mode 100644 index 0000000..b85ed0b --- /dev/null +++ b/internal/env/load-test/load_test.go @@ -0,0 +1,57 @@ +package loadtest + +import ( + "os" + "testing" + + "github.com/softwarespot/public-holidays/internal/env" + testhelpers "github.com/softwarespot/public-holidays/test-helpers" +) + +func Test_Load(t *testing.T) { + tests := []struct { + name string + envFile string + want map[string]string + wantErr bool + }{ + { + name: "missing .env file", + envFile: ".env.missing", + want: nil, + wantErr: true, + }, + { + name: "invalid .env file", + envFile: ".env.invalid", + want: map[string]string{ + "TEST_1": "variable 1", + }, + wantErr: true, + }, + { + name: "load .env file", + envFile: ".env.valid", + want: map[string]string{ + "TEST_1": "variable 1", + "TEST_2": "", + "Test3": "1234", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer envCleanup() + + err := env.Load(os.DirFS("."), tt.envFile) + testhelpers.AssertEqual(t, (err != nil), tt.wantErr) + + for key, want := range tt.want { + v, ok := os.LookupEnv(key) + testhelpers.AssertEqual(t, v, want) + testhelpers.AssertEqual(t, ok, want != "") + } + }) + } +} diff --git a/internal/env/load.go b/internal/env/load.go new file mode 100644 index 0000000..a208214 --- /dev/null +++ b/internal/env/load.go @@ -0,0 +1,51 @@ +package env + +import ( + "bufio" + "fmt" + "io/fs" + "os" + "strings" +) + +// Load reads environment variables from a specified file and sets them in the current process's environment. +// +// Example usage: +// +// err := Load(os.DirFS("."), ".env") +// if err != nil { +// log.Fatalf("error loading .env: %+v", err) +// } +func Load(fs fs.FS, path string) error { + f, err := fs.Open(path) + if err != nil { + return fmt.Errorf("unable to open the env file: %w", err) + } + defer f.Close() + + lineNo := 0 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + kv := strings.SplitN(line, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid env line format at line %d: %s", lineNo, line) + } + + key := strings.TrimSpace(kv[0]) + value := strings.TrimSpace(kv[1]) + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("unable to set env variable at line %d, key: %s, value: %s: %w", lineNo, key, value, err) + } + } + + if err = scanner.Err(); err != nil { + return fmt.Errorf("unable to scan the env file: %w", err) + } + return nil +} diff --git a/internal/holidays/county_code.go b/internal/holidays/county_code.go new file mode 100644 index 0000000..dc14be3 --- /dev/null +++ b/internal/holidays/county_code.go @@ -0,0 +1,16 @@ +package holidays + +import ( + "fmt" + "strings" +) + +type CountryCode string + +func NewCountryCode(code string) (CountryCode, error) { + code = strings.ToUpper(code) + if len(code) != 2 { + return "", fmt.Errorf("invalid county code of %q. Expected an ISO 3166-1 alpha-2 country code", code) + } + return CountryCode(code), nil +} diff --git a/internal/holidays/handler_dk.go b/internal/holidays/handler_dk.go new file mode 100644 index 0000000..1de0ff3 --- /dev/null +++ b/internal/holidays/handler_dk.go @@ -0,0 +1,28 @@ +package holidays + +import ( + "strconv" + + datetime "github.com/softwarespot/public-holidays/internal/date-time" +) + +func dk(year int) ([]Holiday, error) { + strYear := strconv.Itoa(year) + + easter := calculateCatholicEaster(year) + + // Specification: https://en.wikipedia.org/wiki/Public_holidays_in_Denmark + return []Holiday{ + newHoliday(strYear+"-01-01", "Nytårsdag", "New Year's Day"), + newHoliday(strYear+"-04-18", "Skærtorsdag", "Maundy Thursday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, -2)), "Langfredag", "Good Friday"), + newHoliday(datetime.ToDateString(easter), "Påskedag", "Easter Sunday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 1)), "Anden påskedag", "Easter Monday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 40)), "Kristi himmelfartsdag", "Ascension Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 49)), "Pinsedag", "Whit Sunday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 50)), "Anden pinsedag", "Whit Monday"), + newHoliday(strYear+"-06-05", "Grundlovsdag", "Constitution Day"), + newHoliday(strYear+"-12-25", "Juledag", "Christmas Day"), + newHoliday(strYear+"-12-26", "Anden juledag", "Boxing Day"), + }, nil +} diff --git a/internal/holidays/handler_fi.go b/internal/holidays/handler_fi.go new file mode 100644 index 0000000..c3ee907 --- /dev/null +++ b/internal/holidays/handler_fi.go @@ -0,0 +1,35 @@ +package holidays + +import ( + "strconv" + "time" + + datetime "github.com/softwarespot/public-holidays/internal/date-time" +) + +func fi(year int) ([]Holiday, error) { + strYear := strconv.Itoa(year) + + easter := calculateCatholicEaster(year) + midsummer := findNextWeekday(year, time.June, 20, time.Saturday) + allSaints := findNextWeekday(year, time.October, 31, time.Saturday) + + // Specificiation: https://en.wikipedia.org/wiki/Public_holidays_in_Finland + return []Holiday{ + newHoliday(strYear+"-01-01", "Uudenvuodenpäivä", "New Year's Day"), + newHoliday(strYear+"-01-06", "Loppiainen", "Epiphany"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, -2)), "Pitkäperjantai", "Good Friday"), + newHoliday(datetime.ToDateString(easter), "Pääsiäispäivä", "Easter Sunday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 1)), "2. pääsiäispäivä", "Easter Monday"), + newHoliday(strYear+"-05-01", "Vappu", "May Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 39)), "Helatorstai", "Ascension Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 49)), "Helluntaipäivä", "Whit Sunday"), + newHoliday(datetime.ToDateString(midsummer.AddDate(0, 0, -1)), "Juhannusaatto", "Midsummer Eve"), + newHoliday(datetime.ToDateString(midsummer), "Juhannuspäivä", "Midsummer Day"), + newHoliday(datetime.ToDateString(allSaints), "Pyhäinpäivä", "All Saints' Day"), + newHoliday(strYear+"-12-06", "Itsenäisyyspäivä", "Independence Day"), + newHoliday(strYear+"-12-24", "Jouluaatto", "Christmas Eve"), + newHoliday(strYear+"-12-25", "Joulupäivä", "Christmas Day"), + newHoliday(strYear+"-12-26", "2. joulupäivä", "Boxing Day"), + }, nil +} diff --git a/internal/holidays/handler_is.go b/internal/holidays/handler_is.go new file mode 100644 index 0000000..bb6bee8 --- /dev/null +++ b/internal/holidays/handler_is.go @@ -0,0 +1,35 @@ +package holidays + +import ( + "strconv" + "time" + + datetime "github.com/softwarespot/public-holidays/internal/date-time" +) + +func is(year int) ([]Holiday, error) { + strYear := strconv.Itoa(year) + + easter := calculateCatholicEaster(year) + firstDayOfSummer := findNextWeekday(year, time.April, 19, time.Thursday) + commerceDay := findNextWeekday(year, time.August, 1, time.Monday) + + return []Holiday{ + newHoliday(strYear+"-01-01", "Nýársdagur", "New Year's Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, -3)), "Skírdagur", "Maundy Thursday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, -2)), "Föstudagurinn langi", "Good Friday"), + newHoliday(datetime.ToDateString(easter), "Páskadagur", "Easter Sunday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 1)), "Annar í páskum", "Easter Monday"), + newHoliday(datetime.ToDateString(firstDayOfSummer), "Sumardagurinn fyrsti", "First Day of Summer"), + newHoliday(strYear+"-05-01", "Verkalýðsdagurinn", "May Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 39)), "Uppstigningardagur", "Ascension Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 49)), "Hvítasunnudagur", "Whit Sunday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 50)), "Annar í hvítasunnu", "Whit Monday"), + newHoliday(strYear+"-06-17", "Þjóðhátíðardagurinn", "National Day"), + newHoliday(datetime.ToDateString(commerceDay), "Frídagur verslunarmanna", "Commerce Day"), + newHoliday(strYear+"-12-24", "Aðfangadagur", "Christmas Eve"), + newHoliday(strYear+"-12-25", "Jóladagur", "Christmas Day"), + newHoliday(strYear+"-12-26", "Annar í jólum", "Boxing Day"), + newHoliday(strYear+"-12-31", "Gamlársdagur", "New Year's Eve"), + }, nil +} diff --git a/internal/holidays/handler_no.go b/internal/holidays/handler_no.go new file mode 100644 index 0000000..056469c --- /dev/null +++ b/internal/holidays/handler_no.go @@ -0,0 +1,29 @@ +package holidays + +import ( + "strconv" + + datetime "github.com/softwarespot/public-holidays/internal/date-time" +) + +func no(year int) ([]Holiday, error) { + strYear := strconv.Itoa(year) + + easter := calculateCatholicEaster(year) + + // Specificiation: https://en.wikipedia.org/wiki/Public_holidays_in_Norway + return []Holiday{ + newHoliday(strYear+"-01-01", "Første nyttårsdag", "New Year's Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, -3)), "Skjærtorsdag", "Maundy Thursday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, -2)), "Långfredagen", "Good Friday"), + newHoliday(datetime.ToDateString(easter), "Første påskedag", "Easter Sunday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 1)), "Andre påskedag", "Easter Monday"), + newHoliday(strYear+"-05-01", "Første mai", "May Day"), + newHoliday(strYear+"-05-17", "Grunnlovsdagen", "Constitution Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 39)), "Kristi himmelfartsdag", "Ascension Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 49)), "Første pinsedag", "Whit Sunday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 50)), "Andre pinsedag", "Whit Monday"), + newHoliday(strYear+"-12-25", "Første juledag", "Christmas Day"), + newHoliday(strYear+"-12-26", "Andre juledag", "Boxing Day"), + }, nil +} diff --git a/internal/holidays/handler_se.go b/internal/holidays/handler_se.go new file mode 100644 index 0000000..a0768b7 --- /dev/null +++ b/internal/holidays/handler_se.go @@ -0,0 +1,36 @@ +package holidays + +import ( + "strconv" + "time" + + datetime "github.com/softwarespot/public-holidays/internal/date-time" +) + +func se(year int) ([]Holiday, error) { + strYear := strconv.Itoa(year) + + easter := calculateCatholicEaster(year) + midsummer := findNextWeekday(year, time.June, 20, time.Saturday) + allSaints := findNextWeekday(year, time.October, 31, time.Saturday) + + // Specificiation: https://en.wikipedia.org/wiki/Public_holidays_in_Sweden + return []Holiday{ + newHoliday(strYear+"-01-01", "Nyårsdagen", "New Year's Day"), + newHoliday(strYear+"-01-06", "Trettondedag jul", "Epiphany"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, -2)), "Långfredagen", "Good Friday"), + newHoliday(datetime.ToDateString(easter), "Påskdagen", "Easter Sunday"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 1)), "Annandag påsk", "Easter Monday"), + newHoliday(strYear+"-05-01", "Första Maj", "May Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 39)), "Kristi himmelsfärds dag", "Ascension Day"), + newHoliday(datetime.ToDateString(easter.AddDate(0, 0, 49)), "Pingstdagen", "Whit Sunday"), + newHoliday(strYear+"-06-06", "Sveriges nationaldag", "National Day of Sweden"), + newHoliday(datetime.ToDateString(midsummer.AddDate(0, 0, -1)), "Midsommarafton", "Midsummer Eve"), + newHoliday(datetime.ToDateString(midsummer), "Midsommardagen", "Midsummer Day"), + newHoliday(datetime.ToDateString(allSaints), "Alla helgons dag", "All Saints' Day"), + newHoliday(strYear+"-12-24", "Julafton", "Christmas Eve"), + newHoliday(strYear+"-12-25", "Juldagen", "Christmas Day"), + newHoliday(strYear+"-12-26", "Nyårsafton", "Boxing Day"), + newHoliday(strYear+"-12-31", "Annandag jul", "New Year's Eve"), + }, nil +} diff --git a/internal/holidays/helpers.go b/internal/holidays/helpers.go new file mode 100644 index 0000000..add111c --- /dev/null +++ b/internal/holidays/helpers.go @@ -0,0 +1,33 @@ +package holidays + +import "time" + +type handlerFunc func(year int) ([]Holiday, error) + +// Taken from URL: http://stackoverflow.com/questions/2510383/how-can-i-calculate-what-date-good-friday-falls-on-given-a-year +func calculateCatholicEaster(y int) time.Time { + g := y % 19 + c := y / 100 + h := (c - c/4 - (8*c+13)/25 + 19*g + 15) % 30 + i := h - h/28*(1-h/28*(29/(h+1))*((21-g)/11)) + + day := i - (y+y/4+i+2-c+c/4)%7 + 28 + month := 3 + + if day > 31 { + month++ + day -= 31 + } + return time.Date(y, time.Month(month), day, 0, 0, 0, 0, time.Local) +} + +func findNextWeekday(year int, month time.Month, day int, targetWeekday time.Weekday) time.Time { + start := time.Date(year, month, day, 0, 0, 0, 0, time.Local) + + // Explanation: + // 1. (targetWeekday - start.Weekday()) calculates the difference in days + // 2. +7 ensures the result is positive (handles cases where target is earlier in the week) + // 3. %7 gives us the remainder after dividing by 7, which is always 0-6 days + days := (int(targetWeekday - start.Weekday()) + 7) % 7 + return start.AddDate(0, 0, days) +} diff --git a/internal/holidays/helpers_test.go b/internal/holidays/helpers_test.go new file mode 100644 index 0000000..b0d0f8b --- /dev/null +++ b/internal/holidays/helpers_test.go @@ -0,0 +1,58 @@ +package holidays + +import ( + "testing" + "time" + + testhelpers "github.com/softwarespot/public-holidays/test-helpers" +) + +func Test_findNextWeekday(t *testing.T) { + tests := []struct { + name string + year int + month time.Month + day int + targetWeekday time.Weekday + want time.Time + }{ + { + name: "First Thursday on or after April 19, 2024", + year: 2024, + month: time.April, + day: 19, + targetWeekday: time.Thursday, + want: testhelpers.ParseAsDateTime("2024-04-25 00:00:00"), + }, + { + name: "First Monday in August 2024", + year: 2024, + month: time.August, + day: 1, + targetWeekday: time.Monday, + want: testhelpers.ParseAsDateTime("2024-08-05 00:00:00"), + }, + { + name: "When start date is the target weekday", + year: 2024, + month: time.January, + day: 1, + targetWeekday: time.Monday, + want: testhelpers.ParseAsDateTime("2024-01-01 00:00:00"), + }, + { + name: "When target weekday is earlier in the week", + year: 2024, + month: time.July, + day: 5, + targetWeekday: time.Monday, + want: testhelpers.ParseAsDateTime("2024-07-08 00:00:00"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findNextWeekday(tt.year, tt.month, tt.day, tt.targetWeekday) + testhelpers.AssertEqual(t, got, tt.want) + }) + } +} diff --git a/internal/holidays/holiday.go b/internal/holidays/holiday.go new file mode 100644 index 0000000..63c694c --- /dev/null +++ b/internal/holidays/holiday.go @@ -0,0 +1,15 @@ +package holidays + +type Holiday struct { + Date string `json:"date"` + Name string `json:"name"` + EnglishName string `json:"englishName"` +} + +func newHoliday(date, name, englishName string) Holiday { + return Holiday{ + Date: date, + Name: name, + EnglishName: englishName, + } +} diff --git a/internal/holidays/manager.go b/internal/holidays/manager.go new file mode 100644 index 0000000..bb9119a --- /dev/null +++ b/internal/holidays/manager.go @@ -0,0 +1,35 @@ +package holidays + +import "fmt" + +type Manager struct { + // No mutex is needed, as this is written to only on initialization + handlers map[CountryCode]handlerFunc +} + +func NewManager() *Manager { + m := &Manager{ + handlers: map[CountryCode]handlerFunc{}, + } + + // Registered country code handlers + m.Register("DK", dk) + m.Register("FI", fi) + m.Register("IS", is) + m.Register("NO", no) + m.Register("SE", se) + + return m +} + +func (m *Manager) Register(code CountryCode, handler handlerFunc) { + m.handlers[code] = handler +} + +func (m *Manager) Get(code CountryCode, year int) ([]Holiday, error) { + handlerFn, ok := m.handlers[code] + if !ok { + return nil, fmt.Errorf("unsupported country code of %q", code) + } + return handlerFn(year) +} diff --git a/internal/logging/helpers.go b/internal/logging/helpers.go new file mode 100644 index 0000000..b6cc95d --- /dev/null +++ b/internal/logging/helpers.go @@ -0,0 +1,33 @@ +package logging + +import ( + "os" + "runtime/debug" + "time" +) + +func createEntry(msg string, level Level, args ...any) map[string]any { + entry := map[string]any{} + + if len(args) > 0 && len(args)%2 != 0 { + args = append(args, "%ARGS NOT DIVISIBLE BY 2%") + } + for i := 0; i < len(args); i += 2 { + key, ok := args[i].(string) + if !ok { + // Skip keys which are not a string + continue + } + entry[key] = args[i+1] + } + + entry["@timestamp"] = time.Now().UTC().Format(time.RFC3339) + entry["@message"] = msg + entry["@level"] = level + entry["@env"] = os.Getenv("ENV") + + if level.IsSevere() { + entry["@stack-trace"] = string(debug.Stack()) + } + return entry +} diff --git a/internal/logging/level.go b/internal/logging/level.go new file mode 100644 index 0000000..25b0c5f --- /dev/null +++ b/internal/logging/level.go @@ -0,0 +1,14 @@ +package logging + +type Level string + +const ( + LevelCritical Level = "critical" + LevelError Level = "error" + LevelWarning Level = "warning" + LevelNotice Level = "notice" +) + +func (l Level) IsSevere() bool { + return l == LevelCritical || l == LevelError || l == LevelWarning +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..8ff8978 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,10 @@ +package logging + +// Logger is an interface, as it allows different logging adapters to be created e.g. +// a file logger, a logger which sends to Slack etc. +type Logger interface { + // NOTE: This MUST exit the process + Fatal(err error, code int, args ...any) + LogError(err error, level Level, args ...any) + Log(msg string, level Level, args ...any) +} diff --git a/internal/logging/memory.go b/internal/logging/memory.go new file mode 100644 index 0000000..b23ec8c --- /dev/null +++ b/internal/logging/memory.go @@ -0,0 +1,25 @@ +package logging + +import "runtime" + +// Memory logs memory statistics using the provided logger. +// It retrieves the current memory allocation statistics and logs them +// along with any additional arguments provided +func Memory(logger Logger, msg string, logArgs []any) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + logger.Log( + msg, + LevelNotice, + append( + []any{ + "alloc-mb", m.Alloc / 1024 / 1024, + "total-alloc-mb", m.TotalAlloc / 1024 / 1024, + "sys-mb", m.Sys / 1024 / 1024, + "num-gc", m.NumGC, + }, + logArgs..., + )..., + ) +} diff --git a/internal/logging/stdout_logger.go b/internal/logging/stdout_logger.go new file mode 100644 index 0000000..bf643cf --- /dev/null +++ b/internal/logging/stdout_logger.go @@ -0,0 +1,45 @@ +package logging + +import ( + "encoding/json" + "fmt" + "os" + "sync" +) + +// Ensure interface compatibility +var _ Logger = &StdoutLogger{} + +// StdoutLogger is a logger that writes log entries to standard output in JSON format +type StdoutLogger struct { + enc *json.Encoder + mu sync.Mutex +} + +// NewStdoutLogger creates a new instance of StdoutLogger +func NewStdoutLogger() *StdoutLogger { + return &StdoutLogger{ + enc: json.NewEncoder(os.Stdout), + } +} + +// Fatal logs a critical error message and exits the application with the specified exit code +func (sl *StdoutLogger) Fatal(err error, code int, args ...any) { + sl.LogError(err, LevelCritical, args...) + os.Exit(code) +} + +// LogError logs an error message with the specified log level +func (sl *StdoutLogger) LogError(err error, level Level, args ...any) { + sl.Log(err.Error(), level, args...) +} + +// Log writes a log entry with a message, log level, and optional additional arguments, which must be divisible by 2 +func (sl *StdoutLogger) Log(msg string, level Level, args ...any) { + sl.mu.Lock() + defer sl.mu.Unlock() + + if err := sl.enc.Encode(createEntry(msg, level, args...)); err != nil { + fmt.Fprintf(os.Stderr, "skipped logging entry due to error: %+v. Message: '%s', Level: %v\n", err, msg, level) + } +} diff --git a/internal/service/args.go b/internal/service/args.go new file mode 100644 index 0000000..970a5a6 --- /dev/null +++ b/internal/service/args.go @@ -0,0 +1,16 @@ +package service + +import "net/http" + +func Args(r *http.Request, args ...any) []any { + return append( + []any{ + "url", r.URL.RequestURI(), + "http-method", r.Method, + "ip", r.RemoteAddr, + "real-ip", r.Header.Get("X-Real-IP"), + "forwarded-ip", r.Header.Get("X-Forwarded-For"), + }, + args..., + ) +} diff --git a/internal/service/error.go b/internal/service/error.go new file mode 100644 index 0000000..86a6db8 --- /dev/null +++ b/internal/service/error.go @@ -0,0 +1,34 @@ +package service + +type Error interface { + error + Unwrap() error + Status() int +} + +type statusError struct { + err error + statusCode int +} + +func NewError(err error, statusCode int) error { + if err == nil { + return nil + } + return statusError{ + err: err, + statusCode: statusCode, + } +} + +func (e statusError) Error() string { + return e.err.Error() +} + +func (e statusError) Unwrap() error { + return e.err +} + +func (e statusError) Status() int { + return e.statusCode +} diff --git a/internal/service/middleware/concurrent_requests.go b/internal/service/middleware/concurrent_requests.go new file mode 100644 index 0000000..1741dbc --- /dev/null +++ b/internal/service/middleware/concurrent_requests.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "errors" + "net/http" + "sync/atomic" + + "github.com/softwarespot/public-holidays/internal/logging" + "github.com/softwarespot/public-holidays/internal/service" +) + +func NewConcurrentRequests(maxConcurrency int32, logger logging.Logger) service.MiddlewareFunc { + logger.Log("loaded concurrent requests middleware", logging.LevelNotice, + "max-concurrency", maxConcurrency, + ) + + var concurrency atomic.Int32 + return func(next service.Handler) service.Handler { + return service.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + defer concurrency.Add(-1) + if concurrency.Add(1) > maxConcurrency { + return service.NewError(errors.New(http.StatusText(http.StatusServiceUnavailable)), http.StatusServiceUnavailable) + } + return next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/service/middleware/metrics.go b/internal/service/middleware/metrics.go new file mode 100644 index 0000000..8f396dd --- /dev/null +++ b/internal/service/middleware/metrics.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/softwarespot/public-holidays/internal/logging" + "github.com/softwarespot/public-holidays/internal/service" +) + +func NewMetrics(logger logging.Logger) service.MiddlewareFunc { + logger.Log("loaded metrics middleware", logging.LevelNotice) + + return func(next service.Handler) service.Handler { + return service.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + t0 := time.Now() + err := next.ServeHTTP(w, r) + logging.Memory(logger, "handled request", + service.Args(r, + "took", time.Since(t0).String(), + "error", err, + ), + ) + return err + }) + } +} diff --git a/internal/service/middleware/panic_recovery.go b/internal/service/middleware/panic_recovery.go new file mode 100644 index 0000000..5b2bad2 --- /dev/null +++ b/internal/service/middleware/panic_recovery.go @@ -0,0 +1,39 @@ +package middleware + +import ( + "errors" + "fmt" + "net/http" + + "github.com/softwarespot/public-holidays/internal/logging" + "github.com/softwarespot/public-holidays/internal/service" +) + +type PanicRecoveryFunc func(http.ResponseWriter, *http.Request, error) + +func NewPanicRecovery(fn PanicRecoveryFunc, logger logging.Logger) service.MiddlewareFunc { + logger.Log("loaded panic recovery middleware", logging.LevelNotice) + + return func(next service.Handler) service.Handler { + return service.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + defer panicRecovery(w, r, fn) + return next.ServeHTTP(w, r) + }) + } +} + +func panicRecovery(w http.ResponseWriter, r *http.Request, fn PanicRecoveryFunc) { + if rvr := recover(); rvr != nil { + var err error + switch e := rvr.(type) { + case error: + err = fmt.Errorf("recovered panic: %w", e) + default: + err = fmt.Errorf("%v", e) + } + if err == nil { + err = errors.New("unexpected nil error") + } + fn(w, r, err) + } +} diff --git a/internal/service/response_writer.go b/internal/service/response_writer.go new file mode 100644 index 0000000..0a03aab --- /dev/null +++ b/internal/service/response_writer.go @@ -0,0 +1,82 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/softwarespot/public-holidays/internal/logging" +) + +type ResponseWriter struct { + logger logging.Logger +} + +func NewResponseWriter(logger logging.Logger) *ResponseWriter { + return &ResponseWriter{ + logger: logger, + } +} + +func (rw *ResponseWriter) WriteAsJSON(w http.ResponseWriter, _ *http.Request, res any) error { + // See URL: https://journal.petrausch.info/post/2020/06/golang-json-encoder-http-response-writer/, + // which explains why an error can be returned + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(res); err != nil { + return fmt.Errorf("unable to encode the HTTP application/json response: %w", err) + } + return nil +} + +func (rw *ResponseWriter) ErrorAsJSON(w http.ResponseWriter, r *http.Request, err error, args ...any) { + errMsg, statusCode := getErrorStatus(err) + args = append([]any{"status-code", statusCode}, args...) + rw.logger.LogError(err, logging.LevelError, Args(r, args)...) + + // Similar to "http.Error()" + h := w.Header() + h.Del("Content-Length") + h.Set("Content-Type", "application/json") + h.Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(statusCode) + + res := map[string]any{ + "msg": errMsg, + "code": statusCode, + "url": r.URL.RequestURI(), + } + if err := json.NewEncoder(w).Encode(res); err != nil { + err = fmt.Errorf("unable to encode the HTTP error response: %w", err) + rw.logger.LogError(err, logging.LevelError, Args(r, args)...) + } +} + +func (rw *ResponseWriter) Error(w http.ResponseWriter, r *http.Request, err error, args ...any) { + writeErrorStatusCode(w, r, err, rw.logger, args...) +} + +func writeErrorStatusCode(w http.ResponseWriter, r *http.Request, err error, logger logging.Logger, args ...any) { + _, statusCode := getErrorStatus(err) + args = append( + []any{ + "status-code", statusCode, + }, + args..., + ) + logger.LogError(err, logging.LevelError, Args(r, args)...) + w.WriteHeader(statusCode) +} + +func getErrorStatus(err error) (string, int) { + var ( + errMsg = http.StatusText(http.StatusInternalServerError) + statusCode = http.StatusInternalServerError + e Error + ) + if errors.As(err, &e) { + errMsg = e.Unwrap().Error() + statusCode = e.Status() + } + return errMsg, statusCode +} diff --git a/internal/service/server.go b/internal/service/server.go new file mode 100644 index 0000000..11a9675 --- /dev/null +++ b/internal/service/server.go @@ -0,0 +1,137 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/softwarespot/public-holidays/internal/logging" +) + +type Handler interface { + ServeHTTP(http.ResponseWriter, *http.Request) error +} + +type HandlerFunc func(http.ResponseWriter, *http.Request) error + +func (h HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + return h(w, r) +} + +type MiddlewareFunc func(Handler) Handler + +type Server struct { + logger logging.Logger + server *http.Server + mux *http.ServeMux + middlewares []MiddlewareFunc + errHandlersByPattern map[string]func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewServer(addr string, logger logging.Logger) *Server { + mux := http.NewServeMux() + return &Server{ + logger: logger, + server: &http.Server{ + Addr: addr, + Handler: mux, + + // Fix potential Slowloris Attack because ReadHeaderTimeout is not configured in the http.Server + ReadHeaderTimeout: 10 * time.Second, + }, + mux: mux, + middlewares: nil, + errHandlersByPattern: make(map[string]func(w http.ResponseWriter, r *http.Request, err error)), + } +} + +// Use adds one or more middleware functions to the server. +// Middleware functions are executed in the order they are added, +// wrapping the handler functions registered with HandleFunc. +// This allows for cross-cutting concerns such as logging, authentication, +// and request timing to be handled consistently across all routes +func (s *Server) Use(middleware ...MiddlewareFunc) { + s.middlewares = append(s.middlewares, middleware...) +} + +// Handle registers the handler for the given pattern. +// If the given pattern conflicts, with one that is already registered, Handle +// panics +func (s *Server) Handle(pattern string, handler Handler) { + s.HandleFunc(pattern, handler.ServeHTTP) +} + +// HandleFunc registers the handler function for the given pattern. +// If the given pattern conflicts, with one that is already registered, HandleFunc +// panics +func (s *Server) HandleFunc(pattern string, handler func(w http.ResponseWriter, r *http.Request) error) { + h := s.applyMiddlewareHandlers(HandlerFunc(handler)) + s.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + if err := h.ServeHTTP(w, r); err != nil { + if h, ok := s.errHandlersByPattern[pattern]; ok { + h(w, r, err) + } else { + writeErrorStatusCode(w, r, err, s.logger) + } + } + }) +} + +// HandleErrorFunc registers the error handler function for the given pattern. +// If the given pattern conflicts, with one that is already registered, HandleErrorFunc +// panics +func (s *Server) HandleErrorFunc(pattern string, handler func(w http.ResponseWriter, r *http.Request, err error)) { + if handler == nil { + panic("nil error handler") + } + if _, ok := s.errHandlersByPattern[pattern]; ok { + panic("multiple error handler registrations for " + pattern) + } + s.errHandlersByPattern[pattern] = handler +} + +func (s *Server) applyMiddlewareHandlers(handler Handler) Handler { + // Ensure the middleware handlers are executed in the same order they were registered i.e. FIFO + for i := len(s.middlewares) - 1; i >= 0; i-- { + mwh := s.middlewares[i] + handler = mwh(handler) + } + return handler +} + +// ListenAndServe starts the HTTP server (in a separate go routine) and listens for incoming requests. +// If an error occurs while starting the server, it will return that error. +// The method also handles graceful shutdown when the provided context is done +func (s *Server) ListenAndServe(ctx context.Context) error { + errCh := make(chan error, 1) + go func() { + // Ignore the error if nil or is a server closed error + if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("server unexpectedly closed: %w", err) + } + }() + + s.logger.Log("started listening", logging.LevelNotice, + "address", s.server.Addr, + ) + + // Wait for either the context to be done or a non-closing error from the "ListenAndServe()" function + select { + case <-ctx.Done(): + case err := <-errCh: + return err + } + + s.logger.Log("start graceful server shutdown", logging.LevelNotice, + "address", s.server.Addr, + "context-error", ctx.Err(), + ) + err := s.server.Shutdown(ctx) + s.logger.Log("stop graceful server shutdown", logging.LevelNotice, + "address", s.server.Addr, + "shutdown-error", err, + ) + return err +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..9041340 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,15 @@ +package version + +var ( + // Version defines the version of the application + Version = "0.00" + + // Time defines the timestamp at compilation + Time = "-" + + // User defines the user who compiled the application + User = "-" + + // GoVersion defines the Go version used at compilation + GoVersion = "0.00" +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..06038d5 --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + "github.com/softwarespot/public-holidays/cmd" + "github.com/softwarespot/public-holidays/internal/env" + "github.com/softwarespot/public-holidays/internal/logging" +) + +func main() { + logger := logging.NewStdoutLogger() + if err := env.Load(os.DirFS("."), ".env"); err != nil { + logger.Fatal(err, 1) + } + + // Remove the executable name + if err := cmd.Execute(os.Args[1:], logger); err != nil { + logger.Fatal(err, 1) + } +} diff --git a/test-helpers/assert.go b/test-helpers/assert.go new file mode 100644 index 0000000..664c636 --- /dev/null +++ b/test-helpers/assert.go @@ -0,0 +1,30 @@ +package testhelpers + +import ( + "reflect" + "testing" +) + +// AssertEqual checks if two values are equal. If they are not, it logs using t.Fatalf() +func AssertEqual[T any](t testing.TB, got, correct T) { + t.Helper() + if !reflect.DeepEqual(got, correct) { + t.Fatalf("AssertEqual: expected values to be equal, got:\n%+v\ncorrect:\n%+v", got, correct) + } +} + +// AssertError checks if an error is not nil. If it's nil, it logs using t.Fatalf() +func AssertError(t testing.TB, err error) { + t.Helper() + if err == nil { + t.Fatalf("AssertError: expected an error, got nil") + } +} + +// AssertNoError checks if an error is nil. If it's not nil, it logs using t.Fatalf() +func AssertNoError(t testing.TB, err error) { + t.Helper() + if err != nil { + t.Fatalf("AssertNoError: expected no error, got %+v", err) + } +} diff --git a/test-helpers/date-time.go b/test-helpers/date-time.go new file mode 100644 index 0000000..b8a4787 --- /dev/null +++ b/test-helpers/date-time.go @@ -0,0 +1,16 @@ +package testhelpers + +import "time" + +const layoutDateTime = "2006-01-02 15:04:05" + +// ParseAsDateTime parses a string representation of date and time +// in the format "YYYY-MM-DD HH:MM:SS" into a time.Time value +// based on the local time zone +func ParseAsDateTime(tt string) time.Time { + t, err := time.ParseInLocation(layoutDateTime, tt, time.Local) + if err != nil { + return time.Time{} + } + return t +}