commit 657b6aeb223adb878677117fee8580db34529461 Author: Nik Afiq Date: Thu Mar 5 21:22:43 2026 +0900 feat: implement initial application structure with health and hello endpoints - Add bootstrap package to initialize application components including logger, tracer, and HTTP server. - Create config package to load runtime settings from environment variables. - Implement observability features including logging, metrics, and tracing. - Add health check and hello use cases with corresponding HTTP handlers. - Introduce middleware for request ID, access logging, metrics, and recovery. - Set up HTTP router with defined routes for health and hello endpoints. - Include tests for health and hello endpoints to ensure proper functionality. - Add OpenTelemetry collector configuration for trace exporting. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfc04b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.gitignore +.env +*.md +docs/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b082fea --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +PORT=8080 +SERVICE_NAME=switchbot-api +ENV=local +LOG_LEVEL=info +OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7f84001 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.25-alpine AS builder +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/server ./cmd/server + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR / +COPY --from=builder /out/server /server + +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..61ea4ee --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# switchbot-api service + +Gin-based Go service with clean-ish layering, OpenTelemetry tracing (OTLP gRPC), Prometheus metrics, structured JSON logging, and graceful shutdown. + +## Project structure + +```text +cmd/server/main.go +internal/app/bootstrap +internal/app/config +internal/app/observability +internal/domain/hello +internal/domain/health +internal/transport/http +``` + +## Endpoints + +- `GET /healthz` -> `200 {"status":"ok"}` +- `GET /readyz` -> `200 {"status":"ok"}` +- `GET /hello` -> `200 {"message":"hello world"}` +- `GET /metrics` -> Prometheus metrics + +## Configuration (environment variables) + +- `PORT` (default: `8080`) +- `SERVICE_NAME` (default: `switchbot-api`) +- `ENV` (default: `local`) +- `LOG_LEVEL` (default: `info`) +- `OTEL_EXPORTER_OTLP_ENDPOINT` (default: empty; tracing export disabled) + +### OTEL example env vars + +```bash +export SERVICE_NAME=switchbot-api +export ENV=local +export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 +``` + +## Run locally (without collector) + +Tracing export is disabled by default when `OTEL_EXPORTER_OTLP_ENDPOINT` is not set. + +```bash +go mod tidy +go run ./cmd/server +``` + +## Run with OpenTelemetry Collector (Docker Compose) + +```bash +docker compose up --build +``` + +The collector prints received spans to stdout via the `logging` exporter. + +## Example requests + +```bash +curl -i http://localhost:8080/healthz +curl -i http://localhost:8080/readyz +curl -i http://localhost:8080/hello +curl -s http://localhost:8080/metrics | head +``` + +## Notes + +- Request ID is propagated using `X-Request-Id` (generated if missing). +- Request logs include `request_id`, `trace_id`, and `span_id`. +- Error responses include `trace_id` and `request_id` when available. +- `/readyz` already has a usecase and checker abstraction with a TODO for future dependency checks. +- Optional tracing backend (Jaeger/Tempo) is not required for this setup. + +## Verify + +```bash +go test ./... +go vet ./... +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..1b3627d --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "switchbot-api/internal/app/bootstrap" + "switchbot-api/internal/app/config" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "fatal: %v\n", err) + os.Exit(1) + } +} + +func run() error { + cfg := config.Load() + ctx := context.Background() + + app, err := bootstrap.New(ctx, cfg) + if err != nil { + return err + } + + errCh := make(chan error, 1) + go func() { + errCh <- app.Start() + }() + + sigCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + select { + case <-sigCtx.Done(): + app.Logger.Info("shutdown signal received") + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := app.Shutdown(shutdownCtx); err != nil { + app.Logger.Error("shutdown failed", slog.String("error", err.Error())) + return err + } + + app.Logger.Info("server stopped cleanly") + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cd0bcd4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + environment: + PORT: "8080" + SERVICE_NAME: "switchbot-api" + ENV: "local" + LOG_LEVEL: "info" + OTEL_EXPORTER_OTLP_ENDPOINT: "otel-collector:4317" + ports: + - "8080:8080" + depends_on: + - otel-collector + + otel-collector: + image: otel/opentelemetry-collector:0.93.0 + command: ["--config=/etc/otelcol/config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro + ports: + - "4317:4317" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cf7110e --- /dev/null +++ b/go.mod @@ -0,0 +1,65 @@ +module switchbot-api + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/google/uuid v1.6.0 + github.com/prometheus/client_golang v1.23.2 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.uber.org/mock v0.5.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..41a2092 --- /dev/null +++ b/go.sum @@ -0,0 +1,158 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +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/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/app/bootstrap/app.go b/internal/app/bootstrap/app.go new file mode 100644 index 0000000..e60bdbd --- /dev/null +++ b/internal/app/bootstrap/app.go @@ -0,0 +1,109 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "switchbot-api/internal/app/config" + "switchbot-api/internal/app/observability" + "switchbot-api/internal/domain/health" + "switchbot-api/internal/domain/hello" + httptransport "switchbot-api/internal/transport/http" + "switchbot-api/internal/transport/http/handlers" +) + +type App struct { + Config config.Config + Logger *slog.Logger + Server *http.Server + tracerShutdown func(ctx context.Context) error +} + +func New(ctx context.Context, cfg config.Config) (*App, error) { + logger, err := observability.NewLogger(cfg.LogLevel) + if err != nil { + return nil, err + } + + setGinMode(cfg.Environment) + + tp, err := observability.NewTracerProvider(ctx, logger, cfg.ServiceName, cfg.Environment, cfg.OTLPEndpoint) + if err != nil { + return nil, fmt.Errorf("initialize tracing: %w", err) + } + tracer := observability.NewTracer(tp, cfg.ServiceName) + + promRegistry := observability.NewRegistry() + httpMetrics := observability.NewHTTPMetrics(promRegistry) + promHandler := observability.NewPrometheusHandler(promRegistry) + + helloUsecase := hello.NewService() + readinessUsecase := health.NewReadinessUsecase(nil) + + healthHandler := handlers.NewHealthHandler(readinessUsecase) + helloHandler := handlers.NewHelloHandler(helloUsecase) + + router := httptransport.NewRouter(httptransport.Dependencies{ + Logger: logger, + Tracer: tracer, + HTTPMetrics: httpMetrics, + PrometheusHandler: promHandler, + HealthHandler: healthHandler, + HelloHandler: helloHandler, + }) + + server := &http.Server{ + Addr: cfg.Addr(), + Handler: router, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return &App{ + Config: cfg, + Logger: logger, + Server: server, + tracerShutdown: func(ctx context.Context) error { + return tp.Shutdown(ctx) + }, + }, nil +} + +func (a *App) Start() error { + a.Logger.Info("http server starting", slog.String("addr", a.Server.Addr), slog.String("service", a.Config.ServiceName), slog.String("env", a.Config.Environment)) + if err := a.Server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +func (a *App) Shutdown(ctx context.Context) error { + var errs []error + + if err := a.Server.Shutdown(ctx); err != nil { + errs = append(errs, fmt.Errorf("shutdown http server: %w", err)) + } + if err := a.tracerShutdown(ctx); err != nil { + errs = append(errs, fmt.Errorf("shutdown tracer provider: %w", err)) + } + + return errors.Join(errs...) +} + +func setGinMode(environment string) { + switch strings.ToLower(strings.TrimSpace(environment)) { + case "local", "dev", "development", "test": + gin.SetMode(gin.DebugMode) + default: + gin.SetMode(gin.ReleaseMode) + } +} diff --git a/internal/app/config/config.go b/internal/app/config/config.go new file mode 100644 index 0000000..a5bf29e --- /dev/null +++ b/internal/app/config/config.go @@ -0,0 +1,43 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +// Config contains all runtime settings sourced from environment variables. +type Config struct { + Port string + ServiceName string + Environment string + LogLevel string + OTLPEndpoint string +} + +func Load() Config { + return Config{ + Port: getEnv("PORT", "8080"), + ServiceName: getEnv("SERVICE_NAME", "switchbot-api"), + Environment: getEnv("ENV", "local"), + LogLevel: getEnv("LOG_LEVEL", "info"), + OTLPEndpoint: strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")), + } +} + +func (c Config) Addr() string { + if strings.HasPrefix(c.Port, ":") { + return c.Port + } + return fmt.Sprintf(":%s", c.Port) +} + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return fallback +} diff --git a/internal/app/observability/logger.go b/internal/app/observability/logger.go new file mode 100644 index 0000000..123721c --- /dev/null +++ b/internal/app/observability/logger.go @@ -0,0 +1,33 @@ +package observability + +import ( + "fmt" + "log/slog" + "os" + "strings" +) + +func NewLogger(level string) (*slog.Logger, error) { + parsed, err := parseLevel(level) + if err != nil { + return nil, err + } + + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parsed}) + return slog.New(handler), nil +} + +func parseLevel(level string) (slog.Level, error) { + switch strings.ToLower(strings.TrimSpace(level)) { + case "debug": + return slog.LevelDebug, nil + case "info", "": + return slog.LevelInfo, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return slog.LevelInfo, fmt.Errorf("unsupported LOG_LEVEL: %s", level) + } +} diff --git a/internal/app/observability/metrics.go b/internal/app/observability/metrics.go new file mode 100644 index 0000000..7a17d3e --- /dev/null +++ b/internal/app/observability/metrics.go @@ -0,0 +1,58 @@ +package observability + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// HTTPMetrics contains metric collectors used by HTTP middleware. +type HTTPMetrics struct { + RequestsTotal *prometheus.CounterVec + RequestDuration *prometheus.HistogramVec + InFlight prometheus.Gauge +} + +func NewRegistry() *prometheus.Registry { + registry := prometheus.NewRegistry() + registry.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) + return registry +} + +func NewHTTPMetrics(registerer prometheus.Registerer) *HTTPMetrics { + factory := promauto.With(registerer) + + return &HTTPMetrics{ + RequestsTotal: factory.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total number of HTTP requests by method, route, and status.", + }, + []string{"method", "route", "status"}, + ), + RequestDuration: factory.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP request duration by method, route, and status.", + Buckets: prometheus.DefBuckets, + }, + []string{"method", "route", "status"}, + ), + InFlight: factory.NewGauge( + prometheus.GaugeOpts{ + Name: "http_in_flight_requests", + Help: "Current number of in-flight HTTP requests.", + }, + ), + } +} + +func NewPrometheusHandler(registry *prometheus.Registry) http.Handler { + return promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) +} diff --git a/internal/app/observability/tracing.go b/internal/app/observability/tracing.go new file mode 100644 index 0000000..a5ce4a5 --- /dev/null +++ b/internal/app/observability/tracing.go @@ -0,0 +1,59 @@ +package observability + +import ( + "context" + "log/slog" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.37.0" + "go.opentelemetry.io/otel/trace" +) + +func NewTracerProvider(ctx context.Context, logger *slog.Logger, serviceName, environment, otlpEndpoint string) (*sdktrace.TracerProvider, error) { + res, err := resource.New( + ctx, + resource.WithAttributes( + semconv.ServiceName(serviceName), + semconv.DeploymentEnvironmentName(environment), + ), + ) + if err != nil { + return nil, err + } + + providerOptions := []sdktrace.TracerProviderOption{sdktrace.WithResource(res)} + endpoint := strings.TrimSpace(otlpEndpoint) + if endpoint != "" { + exporter, err := otlptracegrpc.New( + ctx, + otlptracegrpc.WithEndpoint(endpoint), + otlptracegrpc.WithInsecure(), + ) + if err != nil { + return nil, err + } + providerOptions = append(providerOptions, sdktrace.WithBatcher(exporter)) + logger.Info("otlp tracing exporter enabled", slog.String("endpoint", endpoint)) + } else { + logger.Info("otlp endpoint not set, traces will not be exported") + } + + tp := sdktrace.NewTracerProvider(providerOptions...) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator( + propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), + ) + return tp, nil +} + +func NewTracer(tp *sdktrace.TracerProvider, serviceName string) trace.Tracer { + return tp.Tracer(serviceName) +} diff --git a/internal/domain/health/readiness.go b/internal/domain/health/readiness.go new file mode 100644 index 0000000..5960b6b --- /dev/null +++ b/internal/domain/health/readiness.go @@ -0,0 +1,31 @@ +package health + +import ( + "context" + "fmt" +) + +// Checker allows plugging external dependency checks into readiness. +type Checker interface { + Name() string + Check(ctx context.Context) error +} + +// ReadinessUsecase executes dependency checks used by /readyz. +type ReadinessUsecase struct { + checkers []Checker +} + +func NewReadinessUsecase(checkers []Checker) *ReadinessUsecase { + return &ReadinessUsecase{checkers: checkers} +} + +func (u *ReadinessUsecase) Check(ctx context.Context) error { + // TODO: add concrete dependency checkers (DB, cache, third-party APIs) as needed. + for _, checker := range u.checkers { + if err := checker.Check(ctx); err != nil { + return fmt.Errorf("%s check failed: %w", checker.Name(), err) + } + } + return nil +} diff --git a/internal/domain/hello/usecase.go b/internal/domain/hello/usecase.go new file mode 100644 index 0000000..35b26ae --- /dev/null +++ b/internal/domain/hello/usecase.go @@ -0,0 +1,18 @@ +package hello + +import "context" + +// Usecase defines hello business behavior. +type Usecase interface { + Message(ctx context.Context) string +} + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) Message(context.Context) string { + return "hello world" +} diff --git a/internal/transport/http/contextkeys/request_id.go b/internal/transport/http/contextkeys/request_id.go new file mode 100644 index 0000000..183f29d --- /dev/null +++ b/internal/transport/http/contextkeys/request_id.go @@ -0,0 +1,14 @@ +package contextkeys + +import "context" + +type requestIDContextKey struct{} + +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, requestIDContextKey{}, requestID) +} + +func RequestIDFromContext(ctx context.Context) string { + requestID, _ := ctx.Value(requestIDContextKey{}).(string) + return requestID +} diff --git a/internal/transport/http/handlers/health_handler.go b/internal/transport/http/handlers/health_handler.go new file mode 100644 index 0000000..3d22016 --- /dev/null +++ b/internal/transport/http/handlers/health_handler.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + + "switchbot-api/internal/transport/http/response" +) + +type ReadinessUsecase interface { + Check(ctx context.Context) error +} + +type HealthHandler struct { + readiness ReadinessUsecase +} + +func NewHealthHandler(readiness ReadinessUsecase) *HealthHandler { + return &HealthHandler{readiness: readiness} +} + +func (h *HealthHandler) Healthz(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (h *HealthHandler) Readyz(c *gin.Context) { + if err := h.readiness.Check(c.Request.Context()); err != nil { + response.WriteError(c, http.StatusServiceUnavailable, "service not ready") + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} diff --git a/internal/transport/http/handlers/hello_handler.go b/internal/transport/http/handlers/hello_handler.go new file mode 100644 index 0000000..26e6d1f --- /dev/null +++ b/internal/transport/http/handlers/hello_handler.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" +) + +type HelloUsecase interface { + Message(ctx context.Context) string +} + +type HelloHandler struct { + helloUsecase HelloUsecase +} + +func NewHelloHandler(helloUsecase HelloUsecase) *HelloHandler { + return &HelloHandler{helloUsecase: helloUsecase} +} + +func (h *HelloHandler) Hello(c *gin.Context) { + message := h.helloUsecase.Message(c.Request.Context()) + c.JSON(http.StatusOK, gin.H{"message": message}) +} diff --git a/internal/transport/http/middleware/access_log.go b/internal/transport/http/middleware/access_log.go new file mode 100644 index 0000000..e838f9e --- /dev/null +++ b/internal/transport/http/middleware/access_log.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "log/slog" + "time" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/trace" + + "switchbot-api/internal/transport/http/contextkeys" +) + +func AccessLog(logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + + route := c.FullPath() + if route == "" { + route = "/unknown" + } + + spanCtx := trace.SpanContextFromContext(c.Request.Context()) + traceID := "" + spanID := "" + if spanCtx.IsValid() { + traceID = spanCtx.TraceID().String() + spanID = spanCtx.SpanID().String() + } + + logger.Info( + "http_request", + slog.String("method", c.Request.Method), + slog.String("path", c.Request.URL.Path), + slog.String("route", route), + slog.Int("status", c.Writer.Status()), + slog.Duration("duration", time.Since(start)), + slog.String("request_id", contextkeys.RequestIDFromContext(c.Request.Context())), + slog.String("trace_id", traceID), + slog.String("span_id", spanID), + ) + } +} diff --git a/internal/transport/http/middleware/metrics.go b/internal/transport/http/middleware/metrics.go new file mode 100644 index 0000000..e95a132 --- /dev/null +++ b/internal/transport/http/middleware/metrics.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "switchbot-api/internal/app/observability" +) + +func Metrics(httpMetrics *observability.HTTPMetrics) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + httpMetrics.InFlight.Inc() + defer httpMetrics.InFlight.Dec() + + c.Next() + + route := c.FullPath() + if route == "" { + route = "/unknown" + } + status := strconv.Itoa(c.Writer.Status()) + + httpMetrics.RequestsTotal.WithLabelValues(c.Request.Method, route, status).Inc() + httpMetrics.RequestDuration.WithLabelValues(c.Request.Method, route, status).Observe(time.Since(start).Seconds()) + } +} diff --git a/internal/transport/http/middleware/recovery.go b/internal/transport/http/middleware/recovery.go new file mode 100644 index 0000000..2acedb8 --- /dev/null +++ b/internal/transport/http/middleware/recovery.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "fmt" + "io" + "log/slog" + "runtime/debug" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "switchbot-api/internal/transport/http/contextkeys" + "switchbot-api/internal/transport/http/response" +) + +func Recovery(logger *slog.Logger) gin.HandlerFunc { + return gin.CustomRecoveryWithWriter(io.Discard, func(c *gin.Context, recovered any) { + ctx := c.Request.Context() + span := trace.SpanFromContext(ctx) + span.RecordError(fmt.Errorf("panic: %v", recovered)) + span.SetStatus(codes.Error, "panic recovered") + + spanCtx := trace.SpanContextFromContext(ctx) + traceID := "" + spanID := "" + if spanCtx.IsValid() { + traceID = spanCtx.TraceID().String() + spanID = spanCtx.SpanID().String() + } + + logger.Error( + "panic recovered", + slog.Any("panic", recovered), + slog.String("request_id", contextkeys.RequestIDFromContext(ctx)), + slog.String("trace_id", traceID), + slog.String("span_id", spanID), + slog.String("stack", string(debug.Stack())), + ) + + response.WriteInternalError(c) + c.Abort() + }) +} diff --git a/internal/transport/http/middleware/request_id.go b/internal/transport/http/middleware/request_id.go new file mode 100644 index 0000000..87af561 --- /dev/null +++ b/internal/transport/http/middleware/request_id.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "switchbot-api/internal/transport/http/contextkeys" +) + +const RequestIDHeader = "X-Request-Id" + +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := strings.TrimSpace(c.GetHeader(RequestIDHeader)) + if requestID == "" { + requestID = uuid.NewString() + } + + ctx := contextkeys.WithRequestID(c.Request.Context(), requestID) + c.Request = c.Request.WithContext(ctx) + c.Writer.Header().Set(RequestIDHeader, requestID) + c.Next() + } +} diff --git a/internal/transport/http/middleware/tracing.go b/internal/transport/http/middleware/tracing.go new file mode 100644 index 0000000..f1d448c --- /dev/null +++ b/internal/transport/http/middleware/tracing.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +func Tracing(tracer trace.Tracer) gin.HandlerFunc { + return func(c *gin.Context) { + ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header)) + spanName := fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path) + + ctx, span := tracer.Start( + ctx, + spanName, + trace.WithSpanKind(trace.SpanKindServer), + trace.WithAttributes( + attribute.String("http.request.method", c.Request.Method), + ), + ) + defer span.End() + + c.Request = c.Request.WithContext(ctx) + c.Next() + + route := c.FullPath() + if route == "" { + route = "/unknown" + } + + status := c.Writer.Status() + span.SetName(fmt.Sprintf("%s %s", c.Request.Method, route)) + span.SetAttributes( + attribute.String("http.route", route), + attribute.Int("http.response.status_code", status), + ) + + if status >= http.StatusInternalServerError { + span.SetStatus(codes.Error, http.StatusText(status)) + } + if len(c.Errors) > 0 { + for _, err := range c.Errors { + span.RecordError(err) + } + span.SetStatus(codes.Error, c.Errors.String()) + } + } +} diff --git a/internal/transport/http/response/error.go b/internal/transport/http/response/error.go new file mode 100644 index 0000000..9223582 --- /dev/null +++ b/internal/transport/http/response/error.go @@ -0,0 +1,32 @@ +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/trace" + + "switchbot-api/internal/transport/http/contextkeys" +) + +type ErrorBody struct { + Error string `json:"error"` + TraceID string `json:"trace_id,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +func WriteError(c *gin.Context, status int, message string) { + spanCtx := trace.SpanContextFromContext(c.Request.Context()) + body := ErrorBody{Error: message} + if spanCtx.IsValid() { + body.TraceID = spanCtx.TraceID().String() + } + if requestID := contextkeys.RequestIDFromContext(c.Request.Context()); requestID != "" { + body.RequestID = requestID + } + c.JSON(status, body) +} + +func WriteInternalError(c *gin.Context) { + WriteError(c, http.StatusInternalServerError, "internal server error") +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go new file mode 100644 index 0000000..06f41e4 --- /dev/null +++ b/internal/transport/http/router.go @@ -0,0 +1,40 @@ +package httptransport + +import ( + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/trace" + + "switchbot-api/internal/app/observability" + "switchbot-api/internal/transport/http/handlers" + "switchbot-api/internal/transport/http/middleware" +) + +type Dependencies struct { + Logger *slog.Logger + Tracer trace.Tracer + HTTPMetrics *observability.HTTPMetrics + PrometheusHandler http.Handler + HealthHandler *handlers.HealthHandler + HelloHandler *handlers.HelloHandler +} + +func NewRouter(deps Dependencies) *gin.Engine { + router := gin.New() + router.RedirectTrailingSlash = false + + router.Use(middleware.RequestID()) + router.Use(middleware.Tracing(deps.Tracer)) + router.Use(middleware.Metrics(deps.HTTPMetrics)) + router.Use(middleware.AccessLog(deps.Logger)) + router.Use(middleware.Recovery(deps.Logger)) + + router.GET("/healthz", deps.HealthHandler.Healthz) + router.GET("/readyz", deps.HealthHandler.Readyz) + router.GET("/hello", deps.HelloHandler.Hello) + router.GET("/metrics", gin.WrapH(deps.PrometheusHandler)) + + return router +} diff --git a/internal/transport/http/router_test.go b/internal/transport/http/router_test.go new file mode 100644 index 0000000..296ab24 --- /dev/null +++ b/internal/transport/http/router_test.go @@ -0,0 +1,79 @@ +package httptransport_test + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/trace/noop" + + "switchbot-api/internal/app/observability" + "switchbot-api/internal/domain/health" + "switchbot-api/internal/domain/hello" + httptransport "switchbot-api/internal/transport/http" + "switchbot-api/internal/transport/http/handlers" +) + +func TestHealthz(t *testing.T) { + router := newTestRouter() + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if body["status"] != "ok" { + t.Fatalf("expected status=ok, got %q", body["status"]) + } +} + +func TestHello(t *testing.T) { + router := newTestRouter() + + req := httptest.NewRequest(http.MethodGet, "/hello", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if body["message"] != "hello world" { + t.Fatalf("expected message=hello world, got %q", body["message"]) + } +} + +func newTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + registry := observability.NewRegistry() + + httpMetrics := observability.NewHTTPMetrics(registry) + healthHandler := handlers.NewHealthHandler(health.NewReadinessUsecase(nil)) + helloHandler := handlers.NewHelloHandler(hello.NewService()) + + return httptransport.NewRouter(httptransport.Dependencies{ + Logger: logger, + Tracer: noop.NewTracerProvider().Tracer("test"), + HTTPMetrics: httpMetrics, + PrometheusHandler: observability.NewPrometheusHandler(registry), + HealthHandler: healthHandler, + HelloHandler: helloHandler, + }) +} diff --git a/otel-collector-config.yaml b/otel-collector-config.yaml new file mode 100644 index 0000000..5d86058 --- /dev/null +++ b/otel-collector-config.yaml @@ -0,0 +1,18 @@ +receivers: + otlp: + protocols: + grpc: + +processors: + batch: + +exporters: + logging: + loglevel: debug + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [logging]