diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c447d43 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.env +bin/ +*.md +.idea +Dockerfile +docker-compose.yml +.dockerignore diff --git a/.env.dist b/.env.dist index e69de29..81998c8 100644 --- a/.env.dist +++ b/.env.dist @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..19d4b92 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile index c82fdfb..038f657 100644 --- a/Makefile +++ b/Makefile @@ -3,26 +3,41 @@ help: ## This help dialog. @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 is 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 -down: ## Stop the application +run-local: ## Start the application locally (without Docker) + go run ./cmd/app + +down: check-docker ## Stop the application 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 + +setup-docker: setup-local run ## Install dependencies and start with Docker Compose install-lint: ## Install Go lint 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 vendor -copy-config: ## Copy config file - cp .env.dist .env +copy-config: ## Copy config file if missing + @test -f .env || cp .env.dist .env lint: install-lint ## Run lint golangci-lint run -tests: down ## Run tests +tests: ## Run tests go test -v ./... + +build: ## Build the server binary + go build -o bin/server ./cmd/app diff --git a/README.md b/README.md index c321434..2e55301 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,80 @@ -# Go skeleton http server +# 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 -1 - Copy the .env.dist to .env (you can edit the values if you want) +## Prerequisites -`$ 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`. diff --git a/bin/server b/bin/server new file mode 100755 index 0000000..4583cc6 Binary files /dev/null and b/bin/server differ diff --git a/cmd/handler/httpjson/handler.go b/cmd/app/handler/httpjson/handler.go similarity index 95% rename from cmd/handler/httpjson/handler.go rename to cmd/app/handler/httpjson/handler.go index 292a8d3..857fd9d 100644 --- a/cmd/handler/httpjson/handler.go +++ b/cmd/app/handler/httpjson/handler.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/kjannette/go-http-server/internal/config" + "github.com/kjannette/go_http_server/internal/config" "github.com/labstack/echo/v4" "github.com/pkg/errors" "go.uber.org/zap" diff --git a/cmd/handler/httpjson/health.go b/cmd/app/handler/httpjson/health.go similarity index 100% rename from cmd/handler/httpjson/health.go rename to cmd/app/handler/httpjson/health.go diff --git a/cmd/app/main.go b/cmd/app/main.go index cc4d365..abbc447 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -3,16 +3,17 @@ package main import ( "context" - "github.com/felipecoppola/go-boilerplate/internal/config" - "github.com/felipecoppola/go-boilerplate/internal/infrastructure/os" + "github.com/kjannette/go_http_server/internal/config" + infrasignal "github.com/kjannette/go_http_server/internal/infrastructure/os" + _ "github.com/kjannette/go_http_server/pkg/logger" "go.uber.org/zap" ) // Globals that are set at build time. var ( - appName = "" - appVersion = "" + appName = "go-http-server" + appVersion = "dev" ) func main() { @@ -28,15 +29,15 @@ func main() { cfg, err := config.NewConfig() if err != nil { - logger.Error("error creating config: %w", zap.Error(err)) + logger.Error("error creating config", zap.Error(err)) return } logger.Info("starting app") - os.SignalListener(logger, cancel) + infrasignal.SignalListener(logger, cancel) if err = run(ctx, cfg); err != nil { - logger.Error("command failed: %w", zap.Error(err)) + logger.Error("command failed", zap.Error(err)) return } diff --git a/cmd/app/service.go b/cmd/app/service.go index 6899674..51850cc 100644 --- a/cmd/app/service.go +++ b/cmd/app/service.go @@ -3,9 +3,8 @@ package main import ( "context" - "github.com/kjannette/go-http-server/cmd/app/handler/httpjson" - "github.com/kjannnette/go-http-server/internal/config" - "github.com/kjannette/go-http-server/pkg/logger" + "github.com/kjannette/go_http_server/cmd/app/handler/httpjson" + "github.com/kjannette/go_http_server/internal/config" "go.uber.org/zap" "golang.org/x/sync/errgroup" @@ -16,14 +15,13 @@ func run(ctx context.Context, cfg *config.Config) error { eg, ctx := errgroup.WithContext(ctx) app, h := httpjson.New(lg) + serverLog := lg.Named("run_server") - eg.Go(func(l logger.Logger) func() error { - return func() error { - l.Info("starting server") - defer l.Info("terminated server") - return h.RunServer(ctx, cfg, app) - } - }(lg.Named("run_server"))) + eg.Go(func() error { + serverLog.Info("starting server") + defer serverLog.Info("terminated server") + return h.RunServer(ctx, cfg, app) + }) return eg.Wait() } diff --git a/cmd/main.go b/cmd/main.go deleted file mode 100644 index de0797e..0000000 --- a/cmd/main.go +++ /dev/null @@ -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() }() -} diff --git a/cmd/service.go b/cmd/service.go deleted file mode 100644 index e69de29..0000000 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d57c3df --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..65facc0 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4ba18c0 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index ee4bdaa..d689e21 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,9 +1,8 @@ package config import ( - "github.com/pkg/errors" - "github.com/kelseyhightower/envconfig" + "github.com/pkg/errors" ) // Config represents the application configuration. diff --git a/internal/infrastructure/os/signal.go b/internal/infrastructure/os/signal.go index ee4bdaa..c25796d 100644 --- a/internal/infrastructure/os/signal.go +++ b/internal/infrastructure/os/signal.go @@ -1,29 +1,22 @@ -package config +package os import ( - "github.com/pkg/errors" + "context" + stdlib "os" + "os/signal" + "syscall" - "github.com/kelseyhightower/envconfig" + "go.uber.org/zap" ) -// Config represents the application configuration. -type Config struct { - API struct { - Port int `envconfig:"API_PORT" default:"3000"` - ReadTimeout int `envconfig:"API_READ_TIMEOUT" default:"7"` - WriteTimeout int `envconfig:"API_WRITE_TIMEOUT" default:"5"` - IdleTimeout int `envconfig:"API_IDLE_TIMEOUT" default:"5"` - Timeout int `envconfig:"API_TIMEOUT" default:"5"` - } -} - -// 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 +// SignalListener listens for SIGINT and SIGTERM and cancels the root context. +func SignalListener(logger *zap.Logger, cancel context.CancelFunc) { + sigCh := make(chan stdlib.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigCh + logger.Info("received shutdown signal", zap.String("signal", sig.String())) + cancel() + }() } diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index c68f87e..1b765f3 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,23 +1,13 @@ package logger import ( - "log" - "os" - "go.uber.org/zap" - "go.uber.go/zap/zapcore" + "log" + "os" + + "go.uber.org/zap" + "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. // To activate this facility, add a blank import to this package.