Compare commits
6 Commits
setup-dock
...
cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e30da54e2 | ||
|
|
f67d57fedf | ||
|
|
700b7ad457 | ||
|
|
749d544165 | ||
|
|
8cfde57cab | ||
|
|
4042ba87f5 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
bin/
|
||||||
|
*.md
|
||||||
|
.idea
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
LOG_LEVEL=info
|
||||||
|
API_PORT=3000
|
||||||
|
API_READ_TIMEOUT=7
|
||||||
|
API_WRITE_TIMEOUT=5
|
||||||
|
API_IDLE_TIMEOUT=5
|
||||||
|
API_TIMEOUT=5
|
||||||
|
|||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates git
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||||
|
-ldflags="-s -w -X main.appName=go-http-server -X main.appVersion=${APP_VERSION}" \
|
||||||
|
-o /out/server \
|
||||||
|
./cmd/app
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates wget
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
|
||||||
|
COPY --from=builder /out/server /app/server
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/server"]
|
||||||
37
Makefile
37
Makefile
@@ -1,28 +1,45 @@
|
|||||||
.PHONY: *
|
.PHONY: *
|
||||||
|
|
||||||
help: ## This help dialog.
|
help:
|
||||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2 | "sort -u"}' $(MAKEFILE_LIST)
|
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2 | "sort -u"}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
run: ## Start the application
|
check-docker: ## Verify Docker CLI available
|
||||||
|
@command -v docker >/dev/null 2>&1 || { \
|
||||||
|
echo "Error: docker not found."; \
|
||||||
|
echo "Install Docker Desktop: https://docs.docker.com/desktop/setup/install/mac-install/"; \
|
||||||
|
echo "Or run without Docker: make setup-local && make run-local"; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
|
||||||
|
run: check-docker ## Start the application with Docker Compose
|
||||||
docker compose up --build --remove-orphans
|
docker compose up --build --remove-orphans
|
||||||
|
|
||||||
down: ## Stop the application
|
run-local: ## Start the application locally (without Docker)
|
||||||
|
go run ./cmd/app
|
||||||
|
|
||||||
|
down: check-docker
|
||||||
docker compose down --remove-orphans
|
docker compose down --remove-orphans
|
||||||
|
|
||||||
setup-local: copy-config install-dependencies run ## Sets up your local environment
|
setup-local: copy-config install-dependencies ## Install config and Go dependencies
|
||||||
|
|
||||||
install-lint: ## Install Go lint
|
setup-docker: setup-local run ## Install dependencies and start with Docker Compose
|
||||||
|
|
||||||
|
install-lint:
|
||||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
|
||||||
install-dependencies: ## Install go project dependencies
|
install-dependencies: ## Install Go project dependencies
|
||||||
go mod download
|
go mod download
|
||||||
go mod vendor
|
|
||||||
|
|
||||||
copy-config: ## Copy config file
|
copy-config:
|
||||||
cp .env.dist .env
|
@test -f .env || cp .env.dist .env
|
||||||
|
|
||||||
lint: install-lint ## Run lint
|
lint: install-lint ## Run lint
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|
||||||
tests: down ## Run tests
|
tests: ## Run tests
|
||||||
go test -v ./...
|
go test -v ./...
|
||||||
|
|
||||||
|
validation: lint tests ## Run lint and tests
|
||||||
|
|
||||||
|
build: ## Build the server binary
|
||||||
|
go build -o bin/server ./cmd/app
|
||||||
|
|||||||
82
README.md
82
README.md
@@ -1,28 +1,80 @@
|
|||||||
# Go boilerplate project
|
# Go HTTP server
|
||||||
|
|
||||||
### A simple Go http server boilerplate project equipped with the essentials.
|
A simple Go HTTP server boilerplate with Echo, structured logging, graceful shutdown, and Docker support.
|
||||||
|
|
||||||
## How to run the application
|
## Prerequisites
|
||||||
1 - Copy the .env.dist to .env (you can edit the values if you want)
|
|
||||||
|
|
||||||
`$ cp .env.dist .env`
|
- Go 1.25+
|
||||||
|
- Docker and Docker Compose (for containerized runs)
|
||||||
|
|
||||||
2 - Run make command, it will show the list of commands we have to make easier to start the application.
|
## Configuration
|
||||||
|
|
||||||
`$ make`
|
Copy the example environment file and adjust values if needed:
|
||||||
|
|
||||||
3 - Start the application
|
```bash
|
||||||
|
cp .env.dist .env
|
||||||
|
```
|
||||||
|
|
||||||
`$ make setup-local`
|
The application reads configuration from process environment variables (via [envconfig](https://github.com/kelseyhightower/envconfig)). Docker Compose loads `.env` automatically. For a local `go run`, export variables or source the file:
|
||||||
|
|
||||||
4 - Run the tests
|
```bash
|
||||||
|
set -a && source .env && set +a
|
||||||
|
```
|
||||||
|
|
||||||
`$ make tests`
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `LOG_LEVEL` | `info` | Zap log level (`debug`, `info`, `warn`, `error`) |
|
||||||
|
| `API_PORT` | `3000` | HTTP listen port |
|
||||||
|
| `API_READ_TIMEOUT` | `7` | Server read timeout (seconds) |
|
||||||
|
| `API_WRITE_TIMEOUT` | `5` | Server write timeout (seconds) |
|
||||||
|
| `API_IDLE_TIMEOUT` | `5` | Server idle timeout (seconds) |
|
||||||
|
| `API_TIMEOUT` | `5` | Reserved for future use |
|
||||||
|
|
||||||
5 - Run the lint
|
Inside Docker, the process always listens on port `3000`; `API_PORT` in `.env` controls the host port mapping.
|
||||||
|
|
||||||
`$ make lint`
|
## Run with Docker (recommended)
|
||||||
|
|
||||||
6 - Run all the validations (lint + tests)
|
```bash
|
||||||
|
make setup-docker # copy .env, download modules, build and start containers
|
||||||
|
```
|
||||||
|
|
||||||
`$ make validation`
|
Or step by step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup-local # copy .env (if missing) and go mod download
|
||||||
|
make run # docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run locally (without Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup-local
|
||||||
|
make run-local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build binary
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
./bin/server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Make targets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
Common targets: `run`, `run-local`, `down`, `setup-local`, `setup-docker`, `build`, `tests`, `lint`.
|
||||||
|
|||||||
BIN
bin/server
Executable file
BIN
bin/server
Executable file
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
package httpjson
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/go-http-server/internal/config"
|
"github.com/kjannette/go_http_server/internal/config"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -31,16 +31,6 @@ func New(logger *zap.Logger) (*echo.Echo, Handler) {
|
|||||||
|
|
||||||
// RunServer runs http server and listens until context is canceled.
|
// RunServer runs http server and listens until context is canceled.
|
||||||
func (h *Handler) RunServer(ctx context.Context, conf *config.Config, e *echo.Echo) error {
|
func (h *Handler) RunServer(ctx context.Context, conf *config.Config, e *echo.Echo) error {
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
|
|
||||||
timeout, cancel := context.WithTimeout(context.Background(), time.Duration(conf.API.ReadTimeout)*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_ = e.Shutdown(timeout) // try gracefully first
|
|
||||||
_ = e.Close() // immediate termination
|
|
||||||
}()
|
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", conf.API.Port),
|
Addr: fmt.Sprintf(":%d", conf.API.Port),
|
||||||
ReadTimeout: time.Duration(conf.API.ReadTimeout) * time.Second,
|
ReadTimeout: time.Duration(conf.API.ReadTimeout) * time.Second,
|
||||||
@@ -48,7 +38,18 @@ func (h *Handler) RunServer(ctx context.Context, conf *config.Config, e *echo.Ec
|
|||||||
IdleTimeout: time.Duration(conf.API.IdleTimeout) * time.Second,
|
IdleTimeout: time.Duration(conf.API.IdleTimeout) * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := e.StartServer(server); !errors.Is(err, http.ErrServerClosed) {
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(conf.API.ReadTimeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
h.logger.Warn("graceful shutdown failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := e.StartServer(server); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
return errors.Wrap(err, "server error")
|
return errors.Wrap(err, "server error")
|
||||||
}
|
}
|
||||||
|
|
||||||
87
cmd/app/handler/handler_test.go
Normal file
87
cmd/app/handler/handler_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/go_http_server/internal/config"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunServer_ShutdownOnCancel(t *testing.T) {
|
||||||
|
e, h := New(zap.NewNop())
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.API.Port = 0
|
||||||
|
cfg.API.ReadTimeout = 2
|
||||||
|
cfg.API.WriteTimeout = 2
|
||||||
|
cfg.API.IdleTimeout = 2
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- h.RunServer(ctx, cfg, e)
|
||||||
|
}()
|
||||||
|
|
||||||
|
healthURL, err := waitForHealthURL(e, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
|
||||||
|
resp, err := client.Get(healthURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /health: %v", err)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK || string(body) != "OK" {
|
||||||
|
t.Fatalf("health check: status=%d body=%q", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunServer() error = %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("RunServer did not exit after context cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForHealthURL(e *echo.Echo, timeout time.Duration) (string, error) {
|
||||||
|
client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if e.Listener != nil {
|
||||||
|
_, port, err := net.SplitHostPort(e.Listener.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("split listener addr: %w", err)
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("http://127.0.0.1:%s/health", port)
|
||||||
|
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("server not ready within %s", timeout)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package httpjson
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
35
cmd/app/handler/health_test.go
Normal file
35
cmd/app/handler/health_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthCheck(t *testing.T) {
|
||||||
|
e, _ := New(zap.NewNop())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(rec.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "OK" {
|
||||||
|
t.Errorf("body = %q, want %q", body, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "text/plain; charset=UTF-8" {
|
||||||
|
t.Errorf("Content-Type = %q, want text/plain; charset=UTF-8", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,17 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/felipecoppola/go-boilerplate/internal/config"
|
"github.com/kjannette/go_http_server/internal/config"
|
||||||
"github.com/felipecoppola/go-boilerplate/internal/infrastructure/os"
|
infrasignal "github.com/kjannette/go_http_server/internal/infrastructure/os"
|
||||||
|
_ "github.com/kjannette/go_http_server/pkg/logger"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Globals that are set at build time.
|
// Globals that are set at build time.
|
||||||
var (
|
var (
|
||||||
appName = ""
|
appName = "go-http-server"
|
||||||
appVersion = ""
|
appVersion = "dev"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -28,15 +29,15 @@ func main() {
|
|||||||
|
|
||||||
cfg, err := config.NewConfig()
|
cfg, err := config.NewConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("error creating config: %w", zap.Error(err))
|
logger.Error("error creating config", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("starting app")
|
logger.Info("starting app")
|
||||||
os.SignalListener(logger, cancel)
|
infrasignal.SignalListener(logger, cancel)
|
||||||
|
|
||||||
if err = run(ctx, cfg); err != nil {
|
if err = run(ctx, cfg); err != nil {
|
||||||
logger.Error("command failed: %w", zap.Error(err))
|
logger.Error("command failed", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/kjannette/go-http-server/cmd/app/handler/httpjson"
|
"github.com/kjannette/go_http_server/cmd/app/handler"
|
||||||
"github.com/kjannnette/go-http-server/internal/config"
|
"github.com/kjannette/go_http_server/internal/config"
|
||||||
"github.com/kjannette/go-http-server/pkg/logger"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
@@ -15,15 +14,14 @@ func run(ctx context.Context, cfg *config.Config) error {
|
|||||||
lg := zap.L().With(zap.String("app_name", appName), zap.String("app_version", appVersion))
|
lg := zap.L().With(zap.String("app_name", appName), zap.String("app_version", appVersion))
|
||||||
|
|
||||||
eg, ctx := errgroup.WithContext(ctx)
|
eg, ctx := errgroup.WithContext(ctx)
|
||||||
app, h := httpjson.New(lg)
|
app, h := handler.New(lg)
|
||||||
|
serverLog := lg.Named("run_server")
|
||||||
|
|
||||||
eg.Go(func(l logger.Logger) func() error {
|
eg.Go(func() error {
|
||||||
return func() error {
|
serverLog.Info("starting server")
|
||||||
l.Info("starting server")
|
defer serverLog.Info("terminated server")
|
||||||
defer l.Info("terminated server")
|
|
||||||
return h.RunServer(ctx, cfg, app)
|
return h.RunServer(ctx, cfg, app)
|
||||||
}
|
})
|
||||||
}(lg.Named("run_server")))
|
|
||||||
|
|
||||||
return eg.Wait()
|
return eg.Wait()
|
||||||
}
|
}
|
||||||
|
|||||||
17
cmd/main.go
17
cmd/main.go
@@ -1,17 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"github.com/kjannette/go-server/internal/config"
|
|
||||||
"github.com/kjannette/go-server/internal/infrastructure/os"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
appName = ""
|
|
||||||
appVarsion = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
defer func() {_ = zap.L().Sync() }()
|
|
||||||
}
|
|
||||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-3000}:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
API_PORT: "3000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:3000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
24
go.mod
Normal file
24
go.mod
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module github.com/kjannette/go_http_server
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0
|
||||||
|
github.com/labstack/echo/v4 v4.15.2
|
||||||
|
github.com/pkg/errors v0.9.1
|
||||||
|
go.uber.org/zap v1.28.0
|
||||||
|
golang.org/x/sync v0.20.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/labstack/gommon v0.5.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
|
golang.org/x/net v0.53.0 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/text v0.36.0 // indirect
|
||||||
|
)
|
||||||
42
go.sum
Normal file
42
go.sum
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||||
|
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
|
||||||
|
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
||||||
|
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||||
|
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
|
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pkg/errors"
|
|
||||||
|
|
||||||
"github.com/kelseyhightower/envconfig"
|
"github.com/kelseyhightower/envconfig"
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the application configuration.
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
API struct {
|
API struct {
|
||||||
Port int `envconfig:"API_PORT" default:"3000"`
|
Port int `envconfig:"API_PORT" default:"3000"`
|
||||||
@@ -17,7 +15,6 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig creates a new initialised application Config.
|
|
||||||
func NewConfig() (*Config, error) {
|
func NewConfig() (*Config, error) {
|
||||||
var config Config
|
var config Config
|
||||||
|
|
||||||
|
|||||||
91
internal/config/config_test.go
Normal file
91
internal/config/config_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var configEnvKeys = []string{
|
||||||
|
"API_PORT",
|
||||||
|
"API_READ_TIMEOUT",
|
||||||
|
"API_WRITE_TIMEOUT",
|
||||||
|
"API_IDLE_TIMEOUT",
|
||||||
|
"API_TIMEOUT",
|
||||||
|
"API_API_PORT",
|
||||||
|
"API_API_READ_TIMEOUT",
|
||||||
|
"API_API_WRITE_TIMEOUT",
|
||||||
|
"API_API_IDLE_TIMEOUT",
|
||||||
|
"API_API_TIMEOUT",
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearConfigEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, key := range configEnvKeys {
|
||||||
|
key := key
|
||||||
|
if prev, ok := os.LookupEnv(key); ok {
|
||||||
|
prev := prev
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Setenv(key, prev)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Unsetenv(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = os.Unsetenv(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewConfig_Defaults(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
|
||||||
|
cfg, err := NewConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.API.Port != 3000 {
|
||||||
|
t.Errorf("API.Port = %d, want 3000", cfg.API.Port)
|
||||||
|
}
|
||||||
|
if cfg.API.ReadTimeout != 7 {
|
||||||
|
t.Errorf("API.ReadTimeout = %d, want 7", cfg.API.ReadTimeout)
|
||||||
|
}
|
||||||
|
if cfg.API.WriteTimeout != 5 {
|
||||||
|
t.Errorf("API.WriteTimeout = %d, want 5", cfg.API.WriteTimeout)
|
||||||
|
}
|
||||||
|
if cfg.API.IdleTimeout != 5 {
|
||||||
|
t.Errorf("API.IdleTimeout = %d, want 5", cfg.API.IdleTimeout)
|
||||||
|
}
|
||||||
|
if cfg.API.Timeout != 5 {
|
||||||
|
t.Errorf("API.Timeout = %d, want 5", cfg.API.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewConfig_FromEnv(t *testing.T) {
|
||||||
|
t.Setenv("API_PORT", "9090")
|
||||||
|
t.Setenv("API_READ_TIMEOUT", "10")
|
||||||
|
t.Setenv("API_WRITE_TIMEOUT", "11")
|
||||||
|
t.Setenv("API_IDLE_TIMEOUT", "12")
|
||||||
|
t.Setenv("API_TIMEOUT", "13")
|
||||||
|
|
||||||
|
cfg, err := NewConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.API.Port != 9090 {
|
||||||
|
t.Errorf("API.Port = %d, want 9090", cfg.API.Port)
|
||||||
|
}
|
||||||
|
if cfg.API.ReadTimeout != 10 {
|
||||||
|
t.Errorf("API.ReadTimeout = %d, want 10", cfg.API.ReadTimeout)
|
||||||
|
}
|
||||||
|
if cfg.API.WriteTimeout != 11 {
|
||||||
|
t.Errorf("API.WriteTimeout = %d, want 11", cfg.API.WriteTimeout)
|
||||||
|
}
|
||||||
|
if cfg.API.IdleTimeout != 12 {
|
||||||
|
t.Errorf("API.IdleTimeout = %d, want 12", cfg.API.IdleTimeout)
|
||||||
|
}
|
||||||
|
if cfg.API.Timeout != 13 {
|
||||||
|
t.Errorf("API.Timeout = %d, want 13", cfg.API.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,22 @@
|
|||||||
package config
|
package os
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pkg/errors"
|
"context"
|
||||||
|
stdlib "os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/kelseyhightower/envconfig"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the application configuration.
|
// SignalListener listens for SIGINT and SIGTERM and cancels the root context.
|
||||||
type Config struct {
|
func SignalListener(logger *zap.Logger, cancel context.CancelFunc) {
|
||||||
API struct {
|
sigCh := make(chan stdlib.Signal, 1)
|
||||||
Port int `envconfig:"API_PORT" default:"3000"`
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
ReadTimeout int `envconfig:"API_READ_TIMEOUT" default:"7"`
|
|
||||||
WriteTimeout int `envconfig:"API_WRITE_TIMEOUT" default:"5"`
|
go func() {
|
||||||
IdleTimeout int `envconfig:"API_IDLE_TIMEOUT" default:"5"`
|
sig := <-sigCh
|
||||||
Timeout int `envconfig:"API_TIMEOUT" default:"5"`
|
logger.Info("received shutdown signal", zap.String("signal", sig.String()))
|
||||||
}
|
cancel()
|
||||||
}
|
}()
|
||||||
|
|
||||||
// NewConfig creates a new initialised application Config.
|
|
||||||
func NewConfig() (*Config, error) {
|
|
||||||
var config Config
|
|
||||||
|
|
||||||
if err := envconfig.Process("", &config); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "error parsing config")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &config, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
38
internal/infrastructure/os/signal_test.go
Normal file
38
internal/infrastructure/os/signal_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package os
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignalListener_CancelsOnSIGTERM(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
canceled := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
close(canceled)
|
||||||
|
}()
|
||||||
|
|
||||||
|
SignalListener(zap.NewNop(), cancel)
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(os.Getpid())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindProcess: %v", err)
|
||||||
|
}
|
||||||
|
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
t.Fatalf("Signal SIGTERM: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-canceled:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("context was not canceled after SIGTERM")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,11 @@ package logger
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.go/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Logger interface {
|
|
||||||
Debug(msg string, fields ...zap.Field)
|
|
||||||
Info(msg string, fields ...zap.Field)
|
|
||||||
Warn(msg string, fields ...zap.Field)
|
|
||||||
Error(msg string, fields ...zap.Field)
|
|
||||||
With(fields ...zap.Field) *zap.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option is a function to provide the Logger creation with extra initialisation options.
|
|
||||||
type Option func(*zap.Config)
|
|
||||||
|
|
||||||
// init initializes zap's global loggers (i.e. zap.L() and zap.S()).
|
|
||||||
// Log level is retrieved from environment variable LOG_LEVEL, defaulting to "info" otherwise.
|
// Log level is retrieved from environment variable LOG_LEVEL, defaulting to "info" otherwise.
|
||||||
// To activate this facility, add a blank import to this package.
|
// To activate this facility, add a blank import to this package.
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
27
pkg/logger/logger_test.go
Normal file
27
pkg/logger/logger_test.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitializeZapGlobals_InfoLevel(t *testing.T) {
|
||||||
|
initializeZapGlobals("info")
|
||||||
|
|
||||||
|
if zap.L().Core().Enabled(zapcore.DebugLevel) {
|
||||||
|
t.Error("debug level should be disabled when LOG_LEVEL is info")
|
||||||
|
}
|
||||||
|
if !zap.L().Core().Enabled(zapcore.InfoLevel) {
|
||||||
|
t.Error("info level should be enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitializeZapGlobals_DebugLevel(t *testing.T) {
|
||||||
|
initializeZapGlobals("debug")
|
||||||
|
|
||||||
|
if !zap.L().Core().Enabled(zapcore.DebugLevel) {
|
||||||
|
t.Error("debug level should be enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user