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.
This commit is contained in:
commit
657b6aeb22
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
PORT=8080
|
||||||
|
SERVICE_NAME=switchbot-api
|
||||||
|
ENV=local
|
||||||
|
LOG_LEVEL=info
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -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"]
|
||||||
79
README.md
Normal file
79
README.md
Normal file
@ -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 ./...
|
||||||
|
```
|
||||||
61
cmd/server/main.go
Normal file
61
cmd/server/main.go
Normal file
@ -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
|
||||||
|
}
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@ -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"
|
||||||
65
go.mod
Normal file
65
go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
158
go.sum
Normal file
158
go.sum
Normal file
@ -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=
|
||||||
109
internal/app/bootstrap/app.go
Normal file
109
internal/app/bootstrap/app.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/app/config/config.go
Normal file
43
internal/app/config/config.go
Normal file
@ -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
|
||||||
|
}
|
||||||
33
internal/app/observability/logger.go
Normal file
33
internal/app/observability/logger.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/app/observability/metrics.go
Normal file
58
internal/app/observability/metrics.go
Normal file
@ -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{})
|
||||||
|
}
|
||||||
59
internal/app/observability/tracing.go
Normal file
59
internal/app/observability/tracing.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
31
internal/domain/health/readiness.go
Normal file
31
internal/domain/health/readiness.go
Normal file
@ -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
|
||||||
|
}
|
||||||
18
internal/domain/hello/usecase.go
Normal file
18
internal/domain/hello/usecase.go
Normal file
@ -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"
|
||||||
|
}
|
||||||
14
internal/transport/http/contextkeys/request_id.go
Normal file
14
internal/transport/http/contextkeys/request_id.go
Normal file
@ -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
|
||||||
|
}
|
||||||
34
internal/transport/http/handlers/health_handler.go
Normal file
34
internal/transport/http/handlers/health_handler.go
Normal file
@ -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"})
|
||||||
|
}
|
||||||
25
internal/transport/http/handlers/hello_handler.go
Normal file
25
internal/transport/http/handlers/hello_handler.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
43
internal/transport/http/middleware/access_log.go
Normal file
43
internal/transport/http/middleware/access_log.go
Normal file
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/transport/http/middleware/metrics.go
Normal file
29
internal/transport/http/middleware/metrics.go
Normal file
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/transport/http/middleware/recovery.go
Normal file
44
internal/transport/http/middleware/recovery.go
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
26
internal/transport/http/middleware/request_id.go
Normal file
26
internal/transport/http/middleware/request_id.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
55
internal/transport/http/middleware/tracing.go
Normal file
55
internal/transport/http/middleware/tracing.go
Normal file
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/transport/http/response/error.go
Normal file
32
internal/transport/http/response/error.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
40
internal/transport/http/router.go
Normal file
40
internal/transport/http/router.go
Normal file
@ -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
|
||||||
|
}
|
||||||
79
internal/transport/http/router_test.go
Normal file
79
internal/transport/http/router_test.go
Normal file
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
18
otel-collector-config.yaml
Normal file
18
otel-collector-config.yaml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
|
||||||
|
processors:
|
||||||
|
batch:
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
logging:
|
||||||
|
loglevel: debug
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [batch]
|
||||||
|
exporters: [logging]
|
||||||
Loading…
x
Reference in New Issue
Block a user