ga-plugins-cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config-patcher.d.ts +20 -50
- package/dist/config-patcher.d.ts.map +1 -1
- package/dist/config-patcher.js +138 -102
- package/dist/config-patcher.js.map +1 -1
- package/dist/index.js +75 -22
- package/dist/index.js.map +1 -1
- package/dist/installer.d.ts +0 -18
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +19 -39
- package/dist/installer.js.map +1 -1
- package/dist/types.d.ts +10 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/uninstaller.d.ts +0 -23
- package/dist/uninstaller.d.ts.map +1 -1
- package/dist/uninstaller.js +22 -68
- package/dist/uninstaller.js.map +1 -1
- package/package.json +3 -2
- package/plugins/go-reviewer/.claude-plugin/plugin.json +12 -0
- package/plugins/go-reviewer/commands/go-review.md +424 -0
- package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/README.md +236 -0
- package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/main.go +678 -0
- package/plugins/go-scaffolder/.claude-plugin/plugin.json +12 -0
- package/plugins/go-scaffolder/commands/scaffold-service.md +802 -0
- package/plugins/go-scaffolder/reference-service/.env.example +27 -0
- package/plugins/go-scaffolder/reference-service/Dockerfile +55 -0
- package/plugins/go-scaffolder/reference-service/REFERENCE-SERVICE-NOTICE.md +104 -0
- package/plugins/go-scaffolder/reference-service/cmd/server/main.go +266 -0
- package/plugins/go-scaffolder/reference-service/config/config.go +67 -0
- package/plugins/go-scaffolder/reference-service/go.mod +17 -0
- package/plugins/go-scaffolder/reference-service/internal/domain/booking.go +118 -0
- package/plugins/go-scaffolder/reference-service/internal/handler/booking.go +242 -0
- package/plugins/go-scaffolder/reference-service/internal/handler/booking_test.go +451 -0
- package/plugins/go-scaffolder/reference-service/internal/repository/booking_postgres.go +124 -0
- package/plugins/go-scaffolder/reference-service/internal/usecase/booking.go +181 -0
- package/plugins/go-standards/.claude-plugin/plugin.json +22 -0
- package/plugins/go-standards/commands/go-standards-check.md +232 -0
- package/plugins/go-standards/skills/concurrency.md +336 -0
- package/plugins/go-standards/skills/config.md +267 -0
- package/plugins/go-standards/skills/error-handling.md +286 -0
- package/plugins/go-standards/skills/http-chi.md +390 -0
- package/plugins/go-standards/skills/logging-observability.md +340 -0
- package/plugins/go-standards/skills/naming-and-style.md +315 -0
- package/plugins/go-standards/skills/project-layout.md +313 -0
- package/plugins/go-standards/skills/testing.md +366 -0
- package/plugins/java2go-porter/.claude-plugin/plugin.json +21 -0
- package/plugins/java2go-porter/agents/analyzer.md +232 -0
- package/plugins/java2go-porter/agents/reviewer.md +241 -0
- package/plugins/java2go-porter/agents/test-pairer.md +365 -0
- package/plugins/java2go-porter/agents/translator.md +419 -0
- package/plugins/java2go-porter/commands/port-java-service.md +149 -0
- package/plugins/java2go-porter/skills/idiom-mapping.md +75 -0
- package/plugins/migration-safety/.claude-plugin/plugin.json +20 -0
- package/plugins/migration-safety/commands/gen-characterization-test.md +452 -0
- package/plugins/migration-safety/commands/strangler-plan.md +356 -0
- package/plugins/migration-safety/skills/strangler-fig.md +382 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# .env.example — booking-service
|
|
2
|
+
# Copy to .env for local development. Never commit .env.
|
|
3
|
+
# All variables are read once at startup; the process exits with a clear error
|
|
4
|
+
# message if a required variable is absent.
|
|
5
|
+
|
|
6
|
+
# --- Application ---
|
|
7
|
+
PORT=8080 # HTTP listener port
|
|
8
|
+
LOG_LEVEL=info # debug | info | warn | error
|
|
9
|
+
SERVICE_NAME=booking-service # used as Prometheus/Loki/Tempo service label
|
|
10
|
+
SERVICE_VERSION=0.1.0 # reported in traces and metrics
|
|
11
|
+
SHUTDOWN_TIMEOUT=10 # seconds to wait for in-flight requests on SIGTERM
|
|
12
|
+
|
|
13
|
+
# --- OpenTelemetry (Grafana Tempo — gRPC endpoint) ---
|
|
14
|
+
OTLP_ENDPOINT=localhost:4317 # Tempo / OTel Collector gRPC address (no scheme)
|
|
15
|
+
|
|
16
|
+
# --- PostgreSQL ---
|
|
17
|
+
DB_HOST=localhost # required — PostgreSQL 18.4 host
|
|
18
|
+
DB_PORT=5432
|
|
19
|
+
DB_USER=booking_user # required — DB role for this service
|
|
20
|
+
DB_PASS=changeme # required — use a strong random value in production
|
|
21
|
+
DB_NAME=booking_db # required — dedicated database for this service
|
|
22
|
+
DB_SSLMODE=disable # local dev: disable | production: require
|
|
23
|
+
|
|
24
|
+
# --- Redis ---
|
|
25
|
+
REDIS_ADDR=localhost:6379 # Redis 7+ address (host:port)
|
|
26
|
+
REDIS_PASSWORD= # leave empty for no-auth local dev
|
|
27
|
+
REDIS_DB=0 # Redis logical database index (0–15)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
# Multi-stage build for booking-service.
|
|
3
|
+
# Stage 1 (builder): compiles a fully-static binary.
|
|
4
|
+
# Stage 2 (runtime): distroless/static — no shell, no package manager, minimal attack surface.
|
|
5
|
+
|
|
6
|
+
# ============================================================
|
|
7
|
+
# Stage 1 — Build
|
|
8
|
+
# ============================================================
|
|
9
|
+
FROM golang:1.23-alpine AS builder
|
|
10
|
+
|
|
11
|
+
# Install system-level build dependencies.
|
|
12
|
+
RUN apk add --no-cache git ca-certificates tzdata
|
|
13
|
+
|
|
14
|
+
WORKDIR /app
|
|
15
|
+
|
|
16
|
+
# Layer caching: download modules before copying source.
|
|
17
|
+
# This layer is only invalidated when go.mod or go.sum change.
|
|
18
|
+
COPY go.mod go.sum ./
|
|
19
|
+
RUN go mod download
|
|
20
|
+
|
|
21
|
+
# Copy source and build.
|
|
22
|
+
# CGO_ENABLED=0 — fully static binary, no libc dependency
|
|
23
|
+
# -trimpath — strip local file paths from the binary
|
|
24
|
+
# -ldflags "-s -w" — strip debug info and DWARF, reduces binary size ~30%
|
|
25
|
+
COPY . .
|
|
26
|
+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
|
27
|
+
go build \
|
|
28
|
+
-trimpath \
|
|
29
|
+
-ldflags="-s -w" \
|
|
30
|
+
-o /app/server \
|
|
31
|
+
./cmd/server
|
|
32
|
+
|
|
33
|
+
# ============================================================
|
|
34
|
+
# Stage 2 — Runtime
|
|
35
|
+
# ============================================================
|
|
36
|
+
FROM gcr.io/distroless/static:nonroot
|
|
37
|
+
|
|
38
|
+
# Copy the compiled binary from the builder stage.
|
|
39
|
+
COPY --from=builder /app/server /server
|
|
40
|
+
|
|
41
|
+
# Copy TLS CA certificates (needed for outbound HTTPS to external services).
|
|
42
|
+
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
|
43
|
+
|
|
44
|
+
# Copy timezone data (needed if time.LoadLocation is used anywhere in the stack).
|
|
45
|
+
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
|
46
|
+
|
|
47
|
+
# distroless/static:nonroot already sets USER nonroot (uid 65532).
|
|
48
|
+
# Explicitly restating it makes the intent visible in security scanners.
|
|
49
|
+
USER nonroot:nonroot
|
|
50
|
+
|
|
51
|
+
# Document the port this service listens on.
|
|
52
|
+
# The actual port is controlled by the PORT env var at runtime.
|
|
53
|
+
EXPOSE 8080
|
|
54
|
+
|
|
55
|
+
ENTRYPOINT ["/server"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Reference Service Notice
|
|
2
|
+
|
|
3
|
+
## What this is
|
|
4
|
+
|
|
5
|
+
`reference-service/` is the **canonical exemplar** for all Go services generated by the `go-scaffolder` plugin at Garuda Airlines. It demonstrates every architectural pattern the team has agreed on:
|
|
6
|
+
|
|
7
|
+
- Clean layered architecture: `domain` → `repository` → `usecase` → `handler`
|
|
8
|
+
- chi router with the standard middleware stack
|
|
9
|
+
- PostgreSQL via sqlx with correct error mapping (`sql.ErrNoRows` → `domain.ErrBookingNotFound`)
|
|
10
|
+
- Redis client with connection pooling and startup ping
|
|
11
|
+
- Graceful shutdown handling `SIGINT` and `SIGTERM`
|
|
12
|
+
- Structured logging (zap) with secrets redacted
|
|
13
|
+
- Table-driven tests with a hand-rolled mock (no code generation dependency)
|
|
14
|
+
- Multi-stage Dockerfile targeting `gcr.io/distroless/static:nonroot`
|
|
15
|
+
- All configuration from environment variables via `caarlos0/env`
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## WARNING: LOCAL DEV ONLY — replace directive in go.mod
|
|
20
|
+
|
|
21
|
+
This service's `go.mod` contains:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
replace github.com/zokypesch/go-ga-lib => D:/project/asyst/go-ga-lib
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**This is a local development override.** It makes Go resolve `go-ga-lib` from the local filesystem instead of the module proxy. This line must **never** appear in a service that is deployed to any shared environment (staging, production, or CI).
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## How to switch to production mode
|
|
32
|
+
|
|
33
|
+
1. Remove the `replace` directive from `go.mod`.
|
|
34
|
+
2. Update the `require` line to pin the correct release tag:
|
|
35
|
+
```
|
|
36
|
+
require github.com/zokypesch/go-ga-lib v1.2.3
|
|
37
|
+
```
|
|
38
|
+
3. Run `go mod tidy` to fetch the pinned version from the module proxy and update `go.sum`.
|
|
39
|
+
4. Commit both `go.mod` and `go.sum`.
|
|
40
|
+
|
|
41
|
+
The generated `scaffold-service` command handles this automatically when you answer `v1.x.y` (instead of `local`) to the go-ga-lib version question.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## How to tag and publish go-ga-lib
|
|
46
|
+
|
|
47
|
+
After merging changes to the `go-ga-lib` repository:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# From the go-ga-lib repository root:
|
|
51
|
+
git tag v1.0.0
|
|
52
|
+
git push origin v1.0.0
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Semantic versioning rules:
|
|
56
|
+
- `v0.x.y` — pre-stable; breaking changes permitted between minor versions
|
|
57
|
+
- `v1.x.y` and beyond — stable API; breaking changes require a major version bump
|
|
58
|
+
- Never delete or reuse a tag that has been consumed by a published service
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Review checklist — before using as the canonical exemplar
|
|
63
|
+
|
|
64
|
+
The team must verify each item below before declaring this reference service valid for the full 30-service generation run.
|
|
65
|
+
|
|
66
|
+
### Architecture
|
|
67
|
+
- [ ] The `domain` package contains only interfaces, structs, and sentinel errors — no imports of `database/sql`, `net/http`, or any infrastructure package
|
|
68
|
+
- [ ] Handler methods contain no business logic; they decode, call usecase, encode
|
|
69
|
+
- [ ] Usecase methods wrap all repository errors with `fmt.Errorf("usecase.Method: %w", err)`
|
|
70
|
+
- [ ] Repository methods map `sql.ErrNoRows` to `domain.ErrBookingNotFound` (not `domain.ErrNotFound`)
|
|
71
|
+
- [ ] No `os.Getenv` calls outside of `config/config.go`
|
|
72
|
+
|
|
73
|
+
### Compilation
|
|
74
|
+
- [ ] `go build ./...` passes with no errors
|
|
75
|
+
- [ ] `go vet ./...` passes with no warnings
|
|
76
|
+
- [ ] `go test ./...` passes with no failures
|
|
77
|
+
|
|
78
|
+
### Test quality
|
|
79
|
+
- [ ] `booking_test.go` verifies HTTP status codes, not just 2xx
|
|
80
|
+
- [ ] The error body `code` field is verified in every error test case
|
|
81
|
+
- [ ] Internal errors do not leak infrastructure details to the response body (covered by `TestBookingHandler_InternalError_HidesDetails`)
|
|
82
|
+
- [ ] No test imports a real database or Redis — mocks only
|
|
83
|
+
|
|
84
|
+
### Security
|
|
85
|
+
- [ ] `DBPass` and `RedisPassword` are absent from the `Config.String()` output
|
|
86
|
+
- [ ] The `DSN()` method return value is never passed to a logger
|
|
87
|
+
- [ ] `.env` is in `.gitignore` (or a root `.gitignore` covers it)
|
|
88
|
+
- [ ] Dockerfile runtime stage uses `distroless/static:nonroot` (not `alpine`, not `ubuntu`)
|
|
89
|
+
- [ ] `USER nonroot:nonroot` is present in the Dockerfile runtime stage
|
|
90
|
+
|
|
91
|
+
### Observability hooks
|
|
92
|
+
- [ ] The `OTLP_ENDPOINT` env var is present in `.env.example` with a descriptive comment
|
|
93
|
+
- [ ] The `go-ga-lib/observability.Init(...)` call site is present in `main.go` (even if commented out pending local go-ga-lib availability)
|
|
94
|
+
- [ ] `/health` and `/ready` endpoints are registered outside the `/api/v1` auth group
|
|
95
|
+
|
|
96
|
+
### Local dev ergonomics
|
|
97
|
+
- [ ] `go mod tidy` succeeds with the `replace` directive active
|
|
98
|
+
- [ ] The service starts successfully after `cp .env.example .env` + filling real local credentials
|
|
99
|
+
- [ ] `/health` returns `{"status":"ok"}` immediately after startup
|
|
100
|
+
- [ ] `/ready` returns `{"status":"ready"}` after both postgres and redis are accepting connections
|
|
101
|
+
|
|
102
|
+
### go-ga-lib compatibility
|
|
103
|
+
- [ ] Every import path from `go-ga-lib` matches the actual directory structure in `D:/project/asyst/go-ga-lib`
|
|
104
|
+
- [ ] No go-ga-lib subpackage is imported that does not exist in the library (causes build failure for all 30 services)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"log"
|
|
7
|
+
"net/http"
|
|
8
|
+
"os"
|
|
9
|
+
"os/signal"
|
|
10
|
+
"syscall"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/go-chi/chi/v5"
|
|
14
|
+
"github.com/go-chi/chi/v5/middleware"
|
|
15
|
+
"github.com/jmoiron/sqlx"
|
|
16
|
+
_ "github.com/lib/pq" // PostgreSQL driver — imported for side effects only
|
|
17
|
+
goredis "github.com/redis/go-redis/v9"
|
|
18
|
+
"go.uber.org/zap"
|
|
19
|
+
"go.uber.org/zap/zapcore"
|
|
20
|
+
|
|
21
|
+
"github.com/zokypesch/booking-service/config"
|
|
22
|
+
"github.com/zokypesch/booking-service/internal/handler"
|
|
23
|
+
"github.com/zokypesch/booking-service/internal/repository"
|
|
24
|
+
"github.com/zokypesch/booking-service/internal/usecase"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
func main() {
|
|
28
|
+
// -------------------------------------------------------------------------
|
|
29
|
+
// 1. Config — fail fast before any other initialisation
|
|
30
|
+
// -------------------------------------------------------------------------
|
|
31
|
+
cfg, err := config.Load()
|
|
32
|
+
if err != nil {
|
|
33
|
+
log.Fatalf("FATAL: loading config: %v", err)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
// 2. Logger (zap)
|
|
38
|
+
// -------------------------------------------------------------------------
|
|
39
|
+
logger, err := buildLogger(cfg.LogLevel)
|
|
40
|
+
if err != nil {
|
|
41
|
+
log.Fatalf("FATAL: building logger: %v", err)
|
|
42
|
+
}
|
|
43
|
+
defer func() { _ = logger.Sync() }()
|
|
44
|
+
|
|
45
|
+
logger.Info("service starting", zap.String("config", cfg.String()))
|
|
46
|
+
|
|
47
|
+
// -------------------------------------------------------------------------
|
|
48
|
+
// 3. Observability — OpenTelemetry (Grafana Tempo + Prometheus + Loki)
|
|
49
|
+
//
|
|
50
|
+
// Replace the block below with the actual go-ga-lib/observability.Init call
|
|
51
|
+
// once the library is available locally:
|
|
52
|
+
//
|
|
53
|
+
// shutdown, err := observability.Init(ctx, observability.Config{
|
|
54
|
+
// OTLPEndpoint: cfg.OTLPEndpoint,
|
|
55
|
+
// ServiceName: cfg.ServiceName,
|
|
56
|
+
// ServiceVersion: cfg.ServiceVersion,
|
|
57
|
+
// })
|
|
58
|
+
// if err != nil {
|
|
59
|
+
// logger.Fatal("initialising observability", zap.Error(err))
|
|
60
|
+
// }
|
|
61
|
+
// defer shutdown()
|
|
62
|
+
// -------------------------------------------------------------------------
|
|
63
|
+
logger.Info("observability: OTel endpoint configured",
|
|
64
|
+
zap.String("otlp_endpoint", cfg.OTLPEndpoint),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
// 4. PostgreSQL
|
|
69
|
+
// -------------------------------------------------------------------------
|
|
70
|
+
db, err := openPostgres(cfg)
|
|
71
|
+
if err != nil {
|
|
72
|
+
logger.Fatal("opening postgres", zap.Error(err))
|
|
73
|
+
}
|
|
74
|
+
defer func() {
|
|
75
|
+
if err := db.Close(); err != nil {
|
|
76
|
+
logger.Error("closing postgres", zap.Error(err))
|
|
77
|
+
}
|
|
78
|
+
}()
|
|
79
|
+
logger.Info("postgres connected", zap.String("db_host", cfg.DBHost), zap.String("db_name", cfg.DBName))
|
|
80
|
+
|
|
81
|
+
// -------------------------------------------------------------------------
|
|
82
|
+
// 5. Redis
|
|
83
|
+
// -------------------------------------------------------------------------
|
|
84
|
+
rdb := openRedis(cfg)
|
|
85
|
+
defer func() {
|
|
86
|
+
if err := rdb.Close(); err != nil {
|
|
87
|
+
logger.Error("closing redis", zap.Error(err))
|
|
88
|
+
}
|
|
89
|
+
}()
|
|
90
|
+
|
|
91
|
+
// Verify Redis is reachable at startup.
|
|
92
|
+
pingCtx, pingCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
93
|
+
defer pingCancel()
|
|
94
|
+
if err := rdb.Ping(pingCtx).Err(); err != nil {
|
|
95
|
+
logger.Fatal("redis unreachable", zap.Error(err))
|
|
96
|
+
}
|
|
97
|
+
logger.Info("redis connected", zap.String("addr", cfg.RedisAddr))
|
|
98
|
+
|
|
99
|
+
// -------------------------------------------------------------------------
|
|
100
|
+
// 6. Wire application layers
|
|
101
|
+
// -------------------------------------------------------------------------
|
|
102
|
+
bookingRepo := repository.NewPostgresBookingRepo(db)
|
|
103
|
+
bookingUC := usecase.NewBookingUsecase(bookingRepo, logger)
|
|
104
|
+
bookingHandler := handler.NewBookingHandler(bookingUC, logger)
|
|
105
|
+
|
|
106
|
+
// -------------------------------------------------------------------------
|
|
107
|
+
// 7. HTTP router
|
|
108
|
+
// -------------------------------------------------------------------------
|
|
109
|
+
r := chi.NewRouter()
|
|
110
|
+
|
|
111
|
+
// Global middleware — ORDER MATTERS.
|
|
112
|
+
r.Use(middleware.RequestID) // must be first — all other middleware can read request IDs
|
|
113
|
+
r.Use(middleware.RealIP)
|
|
114
|
+
r.Use(middleware.Recoverer) // catches panics, returns 500
|
|
115
|
+
r.Use(middleware.Compress(5))
|
|
116
|
+
|
|
117
|
+
// Infrastructure endpoints — outside auth, outside /api/v1
|
|
118
|
+
r.Get("/health", healthHandler(db))
|
|
119
|
+
r.Get("/ready", readyHandler(db, rdb))
|
|
120
|
+
// r.Handle("/metrics", promhttp.Handler()) // uncomment when go-ga-lib/observability wires prometheus
|
|
121
|
+
|
|
122
|
+
// Business routes
|
|
123
|
+
r.Route("/api/v1", func(r chi.Router) {
|
|
124
|
+
r.Route("/bookings", bookingHandler.Routes)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// -------------------------------------------------------------------------
|
|
128
|
+
// 8. HTTP server with graceful shutdown
|
|
129
|
+
// -------------------------------------------------------------------------
|
|
130
|
+
srv := &http.Server{
|
|
131
|
+
Addr: fmt.Sprintf(":%d", cfg.Port),
|
|
132
|
+
Handler: r,
|
|
133
|
+
ReadTimeout: 15 * time.Second,
|
|
134
|
+
WriteTimeout: 30 * time.Second,
|
|
135
|
+
IdleTimeout: 60 * time.Second,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Start server in background goroutine.
|
|
139
|
+
serverErr := make(chan error, 1)
|
|
140
|
+
go func() {
|
|
141
|
+
logger.Info("HTTP server listening", zap.Int("port", cfg.Port))
|
|
142
|
+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
143
|
+
serverErr <- err
|
|
144
|
+
}
|
|
145
|
+
}()
|
|
146
|
+
|
|
147
|
+
// Block until OS signal or server error.
|
|
148
|
+
quit := make(chan os.Signal, 1)
|
|
149
|
+
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
150
|
+
|
|
151
|
+
select {
|
|
152
|
+
case sig := <-quit:
|
|
153
|
+
logger.Info("shutdown signal received", zap.String("signal", sig.String()))
|
|
154
|
+
case err := <-serverErr:
|
|
155
|
+
logger.Error("server error", zap.Error(err))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Graceful shutdown: give in-flight requests time to complete.
|
|
159
|
+
shutdownCtx, shutdownCancel := context.WithTimeout(
|
|
160
|
+
context.Background(),
|
|
161
|
+
time.Duration(cfg.ShutdownTimeout)*time.Second,
|
|
162
|
+
)
|
|
163
|
+
defer shutdownCancel()
|
|
164
|
+
|
|
165
|
+
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
166
|
+
logger.Error("graceful shutdown error", zap.Error(err))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Remaining resources are closed by deferred calls above (DB, Redis, logger).
|
|
170
|
+
logger.Info("service stopped cleanly")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
// Infrastructure helpers
|
|
175
|
+
// -------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
// openPostgres opens a connection pool to PostgreSQL and verifies connectivity.
|
|
178
|
+
func openPostgres(cfg *config.Config) (*sqlx.DB, error) {
|
|
179
|
+
db, err := sqlx.Open("postgres", cfg.DSN())
|
|
180
|
+
if err != nil {
|
|
181
|
+
return nil, fmt.Errorf("opening postgres connection pool: %w", err)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
db.SetMaxOpenConns(25)
|
|
185
|
+
db.SetMaxIdleConns(5)
|
|
186
|
+
db.SetConnMaxLifetime(5 * time.Minute)
|
|
187
|
+
db.SetConnMaxIdleTime(1 * time.Minute)
|
|
188
|
+
|
|
189
|
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
190
|
+
defer cancel()
|
|
191
|
+
if err := db.PingContext(ctx); err != nil {
|
|
192
|
+
_ = db.Close()
|
|
193
|
+
return nil, fmt.Errorf("pinging postgres: %w", err)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return db, nil
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// openRedis constructs a Redis client from config. Connectivity is verified by the caller.
|
|
200
|
+
func openRedis(cfg *config.Config) *goredis.Client {
|
|
201
|
+
return goredis.NewClient(&goredis.Options{
|
|
202
|
+
Addr: cfg.RedisAddr,
|
|
203
|
+
Password: cfg.RedisPassword,
|
|
204
|
+
DB: cfg.RedisDB,
|
|
205
|
+
DialTimeout: 5 * time.Second,
|
|
206
|
+
ReadTimeout: 3 * time.Second,
|
|
207
|
+
WriteTimeout: 3 * time.Second,
|
|
208
|
+
PoolSize: 10,
|
|
209
|
+
MinIdleConns: 2,
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// healthHandler is the Kubernetes liveness probe.
|
|
214
|
+
// Returns 200 as long as the process is running — no dependency checks.
|
|
215
|
+
func healthHandler(_ *sqlx.DB) http.HandlerFunc {
|
|
216
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
217
|
+
w.Header().Set("Content-Type", "application/json")
|
|
218
|
+
w.WriteHeader(http.StatusOK)
|
|
219
|
+
fmt.Fprint(w, `{"status":"ok"}`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// readyHandler is the Kubernetes readiness probe.
|
|
224
|
+
// Returns 503 if PostgreSQL or Redis is unreachable.
|
|
225
|
+
func readyHandler(db *sqlx.DB, rdb *goredis.Client) http.HandlerFunc {
|
|
226
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
227
|
+
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
228
|
+
defer cancel()
|
|
229
|
+
|
|
230
|
+
if err := db.PingContext(ctx); err != nil {
|
|
231
|
+
w.Header().Set("Content-Type", "application/json")
|
|
232
|
+
w.WriteHeader(http.StatusServiceUnavailable)
|
|
233
|
+
fmt.Fprint(w, `{"status":"not ready","reason":"postgres unreachable"}`)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
if err := rdb.Ping(ctx).Err(); err != nil {
|
|
237
|
+
w.Header().Set("Content-Type", "application/json")
|
|
238
|
+
w.WriteHeader(http.StatusServiceUnavailable)
|
|
239
|
+
fmt.Fprint(w, `{"status":"not ready","reason":"redis unreachable"}`)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
w.Header().Set("Content-Type", "application/json")
|
|
244
|
+
w.WriteHeader(http.StatusOK)
|
|
245
|
+
fmt.Fprint(w, `{"status":"ready"}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// buildLogger constructs a production-grade zap logger with the requested level.
|
|
250
|
+
func buildLogger(level string) (*zap.Logger, error) {
|
|
251
|
+
var zapLevel zapcore.Level
|
|
252
|
+
if err := zapLevel.UnmarshalText([]byte(level)); err != nil {
|
|
253
|
+
zapLevel = zapcore.InfoLevel
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
cfg := zap.NewProductionConfig()
|
|
257
|
+
cfg.Level = zap.NewAtomicLevelAt(zapLevel)
|
|
258
|
+
cfg.EncoderConfig.TimeKey = "ts"
|
|
259
|
+
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
|
260
|
+
|
|
261
|
+
logger, err := cfg.Build()
|
|
262
|
+
if err != nil {
|
|
263
|
+
return nil, fmt.Errorf("building zap logger: %w", err)
|
|
264
|
+
}
|
|
265
|
+
return logger, nil
|
|
266
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package config
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
|
|
6
|
+
"github.com/caarlos0/env/v11"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Config holds all configuration for the booking service.
|
|
10
|
+
// All values are sourced from environment variables — no YAML or TOML files at runtime.
|
|
11
|
+
// Required fields cause Load() to return an error when the variable is unset.
|
|
12
|
+
// Fields with envDefault use that value when the variable is absent.
|
|
13
|
+
type Config struct {
|
|
14
|
+
// --- Application ---
|
|
15
|
+
Port int `env:"PORT" envDefault:"8080"`
|
|
16
|
+
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
|
17
|
+
ServiceName string `env:"SERVICE_NAME" envDefault:"booking-service"`
|
|
18
|
+
ServiceVersion string `env:"SERVICE_VERSION" envDefault:"0.1.0"`
|
|
19
|
+
ShutdownTimeout int `env:"SHUTDOWN_TIMEOUT" envDefault:"10"` // seconds
|
|
20
|
+
|
|
21
|
+
// --- OpenTelemetry (Grafana Tempo gRPC endpoint) ---
|
|
22
|
+
OTLPEndpoint string `env:"OTLP_ENDPOINT" envDefault:"localhost:4317"`
|
|
23
|
+
|
|
24
|
+
// --- PostgreSQL ---
|
|
25
|
+
DBHost string `env:"DB_HOST" required:"true"`
|
|
26
|
+
DBPort string `env:"DB_PORT" envDefault:"5432"`
|
|
27
|
+
DBUser string `env:"DB_USER" required:"true"`
|
|
28
|
+
DBPass string `env:"DB_PASS" required:"true"`
|
|
29
|
+
DBName string `env:"DB_NAME" required:"true"`
|
|
30
|
+
DBSSLMode string `env:"DB_SSLMODE" envDefault:"disable"` // set to 'require' in production
|
|
31
|
+
|
|
32
|
+
// --- Redis ---
|
|
33
|
+
RedisAddr string `env:"REDIS_ADDR" envDefault:"localhost:6379"`
|
|
34
|
+
RedisPassword string `env:"REDIS_PASSWORD" envDefault:""`
|
|
35
|
+
RedisDB int `env:"REDIS_DB" envDefault:"0"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Load parses all environment variables into a Config struct.
|
|
39
|
+
// Returns an error immediately if any required variable is missing or cannot be
|
|
40
|
+
// converted to the declared Go type.
|
|
41
|
+
func Load() (*Config, error) {
|
|
42
|
+
cfg := &Config{}
|
|
43
|
+
if err := env.Parse(cfg); err != nil {
|
|
44
|
+
return nil, fmt.Errorf("loading config: %w", err)
|
|
45
|
+
}
|
|
46
|
+
return cfg, nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// DSN builds a PostgreSQL connection string from the individual DB fields.
|
|
50
|
+
// NEVER log the return value of this method — it contains the database password.
|
|
51
|
+
func (c *Config) DSN() string {
|
|
52
|
+
return fmt.Sprintf(
|
|
53
|
+
"host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
|
|
54
|
+
c.DBHost, c.DBPort, c.DBName, c.DBUser, c.DBPass, c.DBSSLMode,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// String returns a human-readable summary of the config with all secrets redacted.
|
|
59
|
+
// Safe to pass to a logger.
|
|
60
|
+
func (c *Config) String() string {
|
|
61
|
+
return fmt.Sprintf(
|
|
62
|
+
"Config{Port:%d ServiceName:%s ServiceVersion:%s LogLevel:%s DBHost:%s DBName:%s RedisAddr:%s}",
|
|
63
|
+
c.Port, c.ServiceName, c.ServiceVersion, c.LogLevel,
|
|
64
|
+
c.DBHost, c.DBName, c.RedisAddr,
|
|
65
|
+
// DBPass and RedisPassword are intentionally omitted
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module github.com/zokypesch/booking-service
|
|
2
|
+
|
|
3
|
+
go 1.23
|
|
4
|
+
|
|
5
|
+
require (
|
|
6
|
+
github.com/go-chi/chi/v5 v5.2.1
|
|
7
|
+
github.com/google/uuid v1.6.0
|
|
8
|
+
github.com/jmoiron/sqlx v1.4.0
|
|
9
|
+
github.com/lib/pq v1.10.9
|
|
10
|
+
github.com/redis/go-redis/v9 v9.5.1
|
|
11
|
+
github.com/stretchr/testify v1.9.0
|
|
12
|
+
github.com/caarlos0/env/v11 v11.3.1
|
|
13
|
+
github.com/zokypesch/go-ga-lib v0.0.0
|
|
14
|
+
go.uber.org/zap v1.27.0
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
replace github.com/zokypesch/go-ga-lib => D:/project/asyst/go-ga-lib
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package domain
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"time"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Sentinel errors — map these to HTTP status codes in the handler layer.
|
|
10
|
+
// Use errors.Is to check; never compare error strings.
|
|
11
|
+
var (
|
|
12
|
+
ErrBookingNotFound = errors.New("booking not found")
|
|
13
|
+
ErrInvalidStatus = errors.New("invalid status transition")
|
|
14
|
+
ErrAlreadyExists = errors.New("booking already exists")
|
|
15
|
+
ErrInvalidInput = errors.New("invalid input")
|
|
16
|
+
ErrUnauthorized = errors.New("unauthorized")
|
|
17
|
+
ErrForbidden = errors.New("forbidden")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// Status represents the lifecycle state of a booking.
|
|
21
|
+
type Status string
|
|
22
|
+
|
|
23
|
+
const (
|
|
24
|
+
StatusPending Status = "PENDING"
|
|
25
|
+
StatusConfirmed Status = "CONFIRMED"
|
|
26
|
+
StatusCancelled Status = "CANCELLED"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// validTransitions defines which status transitions are allowed.
|
|
30
|
+
// A transition not present in this map is invalid.
|
|
31
|
+
var validTransitions = map[Status][]Status{
|
|
32
|
+
StatusPending: {StatusConfirmed, StatusCancelled},
|
|
33
|
+
StatusConfirmed: {StatusCancelled},
|
|
34
|
+
StatusCancelled: {}, // terminal state
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// CanTransitionTo reports whether transitioning from the current status to next is allowed.
|
|
38
|
+
func (s Status) CanTransitionTo(next Status) bool {
|
|
39
|
+
allowed, ok := validTransitions[s]
|
|
40
|
+
if !ok {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
for _, a := range allowed {
|
|
44
|
+
if a == next {
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Booking is the core domain entity for a flight booking.
|
|
52
|
+
type Booking struct {
|
|
53
|
+
ID string `db:"id" json:"id"`
|
|
54
|
+
CustomerID string `db:"customer_id" json:"customer_id"`
|
|
55
|
+
FlightID string `db:"flight_id" json:"flight_id"`
|
|
56
|
+
Status Status `db:"status" json:"status"`
|
|
57
|
+
SeatCount int `db:"seat_count" json:"seat_count"`
|
|
58
|
+
TotalPrice float64 `db:"total_price" json:"total_price"`
|
|
59
|
+
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
60
|
+
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// BookingRepository defines the data access contract for bookings.
|
|
64
|
+
// Implementations must be safe for concurrent use.
|
|
65
|
+
type BookingRepository interface {
|
|
66
|
+
// Create persists a new booking. entity.ID must be pre-populated by the caller.
|
|
67
|
+
Create(ctx context.Context, booking *Booking) error
|
|
68
|
+
|
|
69
|
+
// GetByID retrieves a booking by its unique ID.
|
|
70
|
+
// Returns ErrBookingNotFound when no row matches.
|
|
71
|
+
GetByID(ctx context.Context, id string) (*Booking, error)
|
|
72
|
+
|
|
73
|
+
// Update replaces all mutable fields of an existing booking.
|
|
74
|
+
// Returns ErrBookingNotFound when no row matches.
|
|
75
|
+
Update(ctx context.Context, booking *Booking) error
|
|
76
|
+
|
|
77
|
+
// List returns a paginated slice of bookings ordered by created_at DESC.
|
|
78
|
+
List(ctx context.Context, limit, offset int) ([]*Booking, error)
|
|
79
|
+
|
|
80
|
+
// Delete permanently removes a booking record.
|
|
81
|
+
// Returns ErrBookingNotFound when no row matches.
|
|
82
|
+
Delete(ctx context.Context, id string) error
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// BookingUsecase defines the business logic contract for bookings.
|
|
86
|
+
// All methods must propagate context for deadline and cancellation support.
|
|
87
|
+
type BookingUsecase interface {
|
|
88
|
+
// CreateBooking validates the input and creates a new PENDING booking.
|
|
89
|
+
CreateBooking(ctx context.Context, input CreateBookingInput) (*Booking, error)
|
|
90
|
+
|
|
91
|
+
// GetBooking retrieves a single booking by ID.
|
|
92
|
+
GetBooking(ctx context.Context, id string) (*Booking, error)
|
|
93
|
+
|
|
94
|
+
// UpdateBooking applies a partial update to a booking's mutable fields.
|
|
95
|
+
UpdateBooking(ctx context.Context, id string, input UpdateBookingInput) (*Booking, error)
|
|
96
|
+
|
|
97
|
+
// ListBookings returns a paginated list of all bookings.
|
|
98
|
+
ListBookings(ctx context.Context, limit, offset int) ([]*Booking, error)
|
|
99
|
+
|
|
100
|
+
// CancelBooking transitions a booking to CANCELLED status.
|
|
101
|
+
// Returns ErrInvalidStatus if the booking is already in a terminal state.
|
|
102
|
+
CancelBooking(ctx context.Context, id string) (*Booking, error)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// CreateBookingInput is the payload for creating a new booking.
|
|
106
|
+
type CreateBookingInput struct {
|
|
107
|
+
CustomerID string `json:"customer_id"`
|
|
108
|
+
FlightID string `json:"flight_id"`
|
|
109
|
+
SeatCount int `json:"seat_count"`
|
|
110
|
+
TotalPrice float64 `json:"total_price"`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// UpdateBookingInput is the payload for updating an existing booking.
|
|
114
|
+
// Pointer fields allow partial updates — nil means "leave unchanged".
|
|
115
|
+
type UpdateBookingInput struct {
|
|
116
|
+
SeatCount *int `json:"seat_count,omitempty"`
|
|
117
|
+
TotalPrice *float64 `json:"total_price,omitempty"`
|
|
118
|
+
}
|