Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 79 additions & 18 deletions esignet-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A production-grade Go HTTP service with PostgreSQL and Redis integration, struct
- [Overview](#overview)
- [Project Structure](#project-structure)
- [Prerequisites](#prerequisites)
- [Go modules: ThunderID dependency](#go-modules-thunderid-dependency)
- [Quickstart](#quickstart)
- [Configuration](#configuration)
- [API Reference](#api-reference)
Expand All @@ -20,14 +21,16 @@ A production-grade Go HTTP service with PostgreSQL and Redis integration, struct

## Overview

`esignet-service` is a Go HTTP service running on port **8088**. It provides:
`esignet-service` is a Go HTTP service running on port **8088** (configurable via `PORT`). It provides:

- Structured JSON logging via `log/slog`
- PostgreSQL connection pool via `pgx/v5`
- Redis client via `go-redis/v9`
- Chi router with request-ID injection, access logging, panic recovery, and gzip compression
- **Two HTTP modes**
- **Standalone** (default): Chi router with request-ID injection, access logging, panic recovery, gzip compression, and the routes below.
- **Thunder embed** (optional): If `THUNDER_HOME` is set, the process serves `/ping` and `/health` on a `net/http` mux and registers ThunderID OAuth and flow routes via [`embed.WireThunder`](https://github.com/anushasunkada/thunder/tree/public-package/backend/pkg/embed) from the pinned Thunder module (see [Go modules: ThunderID dependency](#go-modules-thunderid-dependency)).
- A deep health endpoint that concurrently pings all backing services
- Graceful shutdown on `SIGINT`/`SIGTERM`
- Graceful shutdown on `SIGINT`/`SIGTERM` (cleanup runs on all exit paths; `main` delegates to `run()` which returns an exit code instead of calling `os.Exit` before defers)
- Multi-stage Docker build with a minimal Alpine production image

---
Expand All @@ -38,28 +41,33 @@ A production-grade Go HTTP service with PostgreSQL and Redis integration, struct
.
├── cmd/
│ └── esignet/
│ └── main.go # Entrypoint — wires config, logger, DB, Redis, server
│ └── main.go # Entrypoint — config, logger, DB, Redis, std server or Thunder mux
├── internal/
│ ├── authn/
│ │ └── provider.go # Authn provider passed to Thunder embed.WireThunder
│ ├── config/
│ │ └── config.go # Env-var configuration (envconfig)
│ │ └── config.go # Env-var configuration (envconfig), incl. optional Thunder
│ ├── db/
│ │ └── postgres.go # pgx/v5 connection pool + Ping
│ ├── cache/
│ │ └── redis.go # go-redis/v9 client + Ping
│ ├── middleware/
│ │ └── middleware.go # RequestID · Logger · Recoverer
│ │ └── middleware.go # RequestID · Logger · Recoverer (standalone stack)
│ ├── handler/
│ │ └── health.go # GET /health — concurrent dependency checks
│ └── server/
│ └── server.go # Chi router, route registration, graceful shutdown
│ ├── server/
│ │ └── server.go # Chi router, route registration, graceful shutdown
│ └── thunderembed/
│ └── server.go # Optional ThunderID mux + WireThunder when THUNDER_HOME is set
├── pkg/
│ └── logger/
│ └── logger.go # slog JSON/text handler factory
├── Dockerfile # Multi-stage: dev → builder → production
├── compose.yaml # Full local stack: app + postgres + redis
├── Makefile # Developer targets
├── go.mod
└── .env.example # All supported environment variables with defaults
├── go.mod # Pins ThunderID; see Thunder dependency section
├── config.yaml.example # Optional YAML template for local/docs (env vars are canonical)
└── .env.example # Supported environment variables with defaults
```

---
Expand All @@ -68,12 +76,55 @@ A production-grade Go HTTP service with PostgreSQL and Redis integration, struct

| Tool | Version | Purpose |
|---|---|---|
| Go | 1.23+ | Build and run the service |
| Go | 1.26+ | Build and run the service (matches `go.mod`) |
| Docker + Compose | v2 | Local backing services and container builds |
| Make | any | Convenience targets |

---

## Go modules: ThunderID dependency

The service imports ThunderID as **`github.com/thunder-id/thunderid`** (for example `pkg/embed` in Thunder embed mode). Upstream development often tracks a **fork** that hosts the embed-friendly `backend/` tree on branch **`public-package`**:

- Web UI (for context only — not pasted into `go.mod`): [github.com/anushasunkada/thunder/tree/public-package/backend/pkg](https://github.com/anushasunkada/thunder/tree/public-package/backend/pkg)

### What goes in `go.mod`

Go does **not** support raw `https://github.com/.../tree/...` URLs in `go.mod`. Use a **module path** and **version** (or `replace`):

1. **`require`** — pin the logical module `github.com/thunder-id/thunderid` to a **pseudo-version** resolved from the fork (commit time + short hash).
2. **`replace`** — map that module to the fork’s **`backend`** subdirectory module, which Go fetches as `github.com/anushasunkada/thunder/backend` (repository `thunder`, subdir `backend`, same `module github.com/thunder-id/thunderid` line in `backend/go.mod`).

Example shape (exact versions change when you refresh the pin):

```go
require github.com/thunder-id/thunderid v0.0.0-20260514111244-7975af7f6646

replace github.com/thunder-id/thunderid => github.com/anushasunkada/thunder/backend v0.0.0-20260514111244-7975af7f6646
```

### Move the pin to the latest `public-package` commit

```bash
cd esignet-service
go get github.com/anushasunkada/thunder/backend@public-package
go mod tidy
```

Commit the updated `go.mod` and `go.sum` when you intentionally upgrade Thunder.

### Work against a local Thunder checkout

Temporarily point `replace` at your machine (path must reach the directory that contains Thunder’s `go.mod`, usually `backend/`):

```go
replace github.com/thunder-id/thunderid => /absolute/or/relative/path/to/thunder/backend
```

Remove or swap the `replace` before pushing if CI should use the remote fork instead.

---

## Quickstart

```bash
Expand All @@ -84,13 +135,15 @@ cd esignet-service
# 2. Copy the example env file
cp .env.example .env

# 3. Pull Go dependencies
# 3. Pull Go dependencies (downloads the pinned Thunder fork; see [Go modules: ThunderID dependency](#go-modules-thunderid-dependency))
go mod tidy

# 4a. Start everything with Docker Compose (app + postgres + redis)
# 4. Optional: Thunder embed — set THUNDER_HOME in .env to a Thunder deployment directory (see Configuration)

# 5a. Start everything with Docker Compose (app + postgres + redis)
make up

# 4b. OR start only the backing services and run the server locally
# 5b. OR start only the backing services and run the server locally
make db redis # starts postgres + redis in Docker
make dev # go run ./cmd/esignet
```
Expand Down Expand Up @@ -144,10 +197,20 @@ All configuration is supplied through environment variables. Copy `.env.example`
| `LOG_LEVEL` | `info` | `debug` · `info` · `warn` · `error` |
| `LOG_FORMAT` | `json` | `json` · `text` |

### Thunder embed (optional)

When **`THUNDER_HOME`** is non-empty after trimming whitespace, the binary uses **`internal/thunderembed`**: a `net/http.ServeMux` with the same `/ping` and `/health` handlers as standalone mode, plus Thunder routes from **`embed.WireThunder`**. Point `THUNDER_HOME` at a Thunder **deployment directory** on disk (the layout Thunder expects for config and assets — see ThunderID / fork docs under `pkg/embed`).

| Variable | Default | Description |
|---|---|---|
| `THUNDER_HOME` | *(empty)* | If set, enables Thunder embed mode; if empty, the standalone Chi stack is used |

---

## API Reference

In **standalone** mode, the routes below are the primary application surface. In **Thunder embed** mode, **`GET /ping`** and **`GET /health`** behave the same; additional OAuth, flow, and related paths are registered by Thunder (`embed.WireThunder`). Consult the ThunderID `pkg/` README on your pinned fork for those surfaces.

### `GET /ping`

Lightweight liveness probe. No database or cache calls are made. Use this for high-frequency load-balancer checks.
Expand Down Expand Up @@ -187,9 +250,7 @@ Deep readiness probe. Pings PostgreSQL and Redis concurrently within a 5-second
}
```

Every response includes `X-Request-ID` in the response headers.

---
In **standalone** mode, responses from the Chi stack include `X-Request-ID` (middleware). In **Thunder embed** mode, `/ping` and `/health` do not go through that Chi middleware; Thunder-registered routes follow Thunder’s own HTTP behavior.

## Development

Expand All @@ -200,7 +261,7 @@ make dev # go run ./cmd/esignet (fastest inner loop)
make build # compile → bin/esignet
make run # build + run the binary
make test # go test -race -cover ./...
make lint # golangci-lint run
make lint # golangci-lint (via `go run …`; needs network on first run)
make format # go fmt ./...
make tidy # go mod tidy
make clean # remove bin/
Expand Down
65 changes: 51 additions & 14 deletions esignet-service/cmd/esignet/main.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Command esignet is the esignet HTTP service entrypoint.
package main

import (
Expand All @@ -6,22 +7,29 @@ import (
"log/slog"
"os"
"os/signal"
"strings"
"syscall"

"github.com/mosip/esignet/internal/cache"
"github.com/mosip/esignet/internal/config"
"github.com/mosip/esignet/internal/db"
"github.com/mosip/esignet/internal/handler"
"github.com/mosip/esignet/internal/server"
"github.com/mosip/esignet/internal/thunderembed"
"github.com/mosip/esignet/pkg/logger"
)

func main() {
os.Exit(run())
}

func run() int {
// ── 1. Configuration ─────────────────────────────────────────────────────
cfg, err := config.Load()
if err != nil {
// slog default is not yet initialised; fall back to stdlib.
slog.Error("failed to load config", slog.String("error", err.Error()))
os.Exit(1)
return 1
}

// ── 2. Logger ────────────────────────────────────────────────────────────
Expand All @@ -41,51 +49,80 @@ func main() {
postgres, err := db.NewPostgres(ctx, cfg.Postgres, log)
if err != nil {
log.Error("postgres init failed", slog.String("error", err.Error()))
os.Exit(1)
return 1
}
defer postgres.Close()

// ── 5. Redis ─────────────────────────────────────────────────────────────
redisClient, err := cache.NewRedis(ctx, cfg.Redis, log)
if err != nil {
log.Error("redis init failed", slog.String("error", err.Error()))
os.Exit(1)
return 1
}
defer func() {
if err := redisClient.Close(); err != nil {
log.Warn("redis close error", slog.String("error", err.Error()))
}
}()

pingers := map[string]handler.Pinger{
"postgres": postgres,
"redis": redisClient,
}
Comment on lines +68 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider defensive initialization ordering.

The pingers map is constructed before verifying that dependencies are fully initialized for health checks. If either postgres.Ping() or redisClient.Ping() would fail during health checks due to post-initialization issues, the map still references them.

This is acceptable given that initialization errors are already caught at lines 49-66, but consider documenting that pingers assumes successful initialization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@esignet-service/cmd/esignet/main.go` around lines 68 - 71, The pingers map is
created before confirming that dependencies are fully initialized; update the
code so the map is populated only after successful initialization checks (or add
an inline comment documenting the assumption). Specifically, either move the
construction of pingers (variable pingers of type map[string]handler.Pinger) to
after the existing initialization/validation of postgres and redisClient
(references: postgres, redisClient, handler.Pinger) or add a clear comment next
to the pingers declaration stating it assumes postgres and redisClient were
successfully initialized and that any runtime ping failures will be surfaced by
the health check logic.


// ── 6. HTTP Server ───────────────────────────────────────────────────────
srv := server.New(cfg.Server, server.Dependencies{
DB: postgres,
Cache: redisClient,
}, log)
thunderHome := strings.TrimSpace(cfg.Thunder.Home)
var (
srvStd *server.Server
srvEmb *thunderembed.Server
)
if thunderHome != "" {
srvEmb, err = thunderembed.NewServer(cfg, log, pingers)
if err != nil {
log.Error("thunder embed init failed", slog.String("error", err.Error()))
return 1
}
} else {
srvStd = server.New(cfg.Server, server.Dependencies{
DB: postgres,
Cache: redisClient,
}, log)
}

// Start server in a goroutine so we can listen for shutdown signals below.
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.Start()
if srvEmb != nil {
srvErr <- srvEmb.Start()
return
}
srvErr <- srvStd.Start()
}()

// ── 7. Block until signal or server error ────────────────────────────────
select {
case err := <-srvErr:
if err != nil && !errors.Is(err, context.Canceled) {
log.Error("server exited with error", slog.String("error", err.Error()))
os.Exit(1)
return 1
}
case <-ctx.Done():
log.Info("shutdown signal received", slog.String("signal", ctx.Err().Error()))
}

// ── 8. Graceful shutdown ─────────────────────────────────────────────────
// Use a fresh background context – the parent ctx is already cancelled.
if err := srv.Shutdown(context.Background()); err != nil {
log.Error("graceful shutdown failed", slog.String("error", err.Error()))
os.Exit(1)
shutCtx := context.Background()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Document the shutdown context choice.

Using context.Background() here means graceful shutdown cannot be externally cancelled (e.g., a second SIGTERM won't interrupt the drain). This appears intentional to ensure the full ShutdownTimeout window is honored, but the choice is subtle.

Consider adding a comment explaining why a fresh context is used rather than deriving from the signal-cancelled ctx.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@esignet-service/cmd/esignet/main.go` at line 113, The code creates a fresh
shutdown context using shutCtx := context.Background() which prevents external
cancellation during the ShutdownTimeout window; add a concise comment next to
shutCtx that documents this intentional choice (reference shutCtx,
context.Background(), and ShutdownTimeout) explaining that we intentionally
avoid deriving from the signal-cancelled ctx so a second SIGTERM won't shorten
the graceful shutdown period and to make the rationale explicit for future
readers.

if srvEmb != nil {
if err := srvEmb.Shutdown(shutCtx); err != nil {
log.Error("graceful shutdown failed", slog.String("error", err.Error()))
return 1
}
} else {
if err := srvStd.Shutdown(shutCtx); err != nil {
log.Error("graceful shutdown failed", slog.String("error", err.Error()))
return 1
}
}

log.Info("esignet service stopped cleanly")
return 0
}
52 changes: 52 additions & 0 deletions esignet-service/config.yaml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ─────────────────────────────────────────────────────────────────────────────
# esignet-service YAML configuration
#
# Copy this file to config.yaml (or any path) and set CONFIG_FILE to point at
# it. Values here are defaults; any matching environment variable always wins.
#
# Load order (lowest → highest priority):
# 1. Struct defaults (hard-coded in the binary)
# 2. This YAML file
# 3. Environment variables
#
# Usage:
# CONFIG_FILE=/etc/esignet/config.yaml ./esignet
# CONFIG_FILE=config.yaml make dev
#
# Tip: commit a config.yaml for local development; never commit secrets.
# ─────────────────────────────────────────────────────────────────────────────
Comment on lines +1 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Remove or clarify misleading YAML loading documentation.

The header comments suggest using CONFIG_FILE environment variable and describe a YAML load order (lines 4, 7-10, 13-14), but config.go:Load() only calls envconfig.Process(), which does not load YAML files. The service reads configuration exclusively from environment variables.

This will confuse operators who expect CONFIG_FILE=config.yaml to work.

Options to fix:

  1. Remove YAML support claims (simplest): Delete references to CONFIG_FILE and YAML loading; document this as a reference template showing default values in YAML format for human readability only.

  2. Implement YAML loading (if actually desired): Add a YAML parsing library (e.g., gopkg.in/yaml.v3) and load the file referenced by CONFIG_FILE before calling envconfig.Process(), allowing environment variables to override YAML values.

📝 Option 1: Clarify as reference-only
 # ─────────────────────────────────────────────────────────────────────────────
 # esignet-service YAML configuration
 #
-# Copy this file to config.yaml (or any path) and set CONFIG_FILE to point at
-# it.  Values here are defaults; any matching environment variable always wins.
+# This file documents the configuration structure and default values for
+# reference. The service reads configuration from ENVIRONMENT VARIABLES only.
 #
-# Load order (lowest → highest priority):
-#   1. Struct defaults  (hard-coded in the binary)
-#   2. This YAML file
-#   3. Environment variables
+# Use .env.example to see the environment variable names. Each YAML key below
+# corresponds to an environment variable (e.g., server.port → PORT).
 #
-# Usage:
-#   CONFIG_FILE=/etc/esignet/config.yaml ./esignet
-#   CONFIG_FILE=config.yaml make dev
-#
-# Tip: commit a config.yaml for local development; never commit secrets.
+# Tip: use this as a reference when setting environment variables.
 # ─────────────────────────────────────────────────────────────────────────────
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# ─────────────────────────────────────────────────────────────────────────────
# esignet-service YAML configuration
#
# Copy this file to config.yaml (or any path) and set CONFIG_FILE to point at
# it. Values here are defaults; any matching environment variable always wins.
#
# Load order (lowest → highest priority):
# 1. Struct defaults (hard-coded in the binary)
# 2. This YAML file
# 3. Environment variables
#
# Usage:
# CONFIG_FILE=/etc/esignet/config.yaml ./esignet
# CONFIG_FILE=config.yaml make dev
#
# Tip: commit a config.yaml for local development; never commit secrets.
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# esignet-service YAML configuration
#
# This file documents the configuration structure and default values for
# reference. The service reads configuration from ENVIRONMENT VARIABLES only.
#
# Use .env.example to see the environment variable names. Each YAML key below
# corresponds to an environment variable (e.g., server.port → PORT).
#
# Tip: use this as a reference when setting environment variables.
# ─────────────────────────────────────────────────────────────────────────────
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@esignet-service/config.yaml.example` around lines 1 - 17, The README header
wrongly implies YAML file loading via CONFIG_FILE but the actual loader is
config.go:Load(), which only calls envconfig.Process() and never reads files;
either remove/clarify the CONFIG_FILE/YAML references in the example config so
it’s clearly a human-readable reference (no file support), or implement YAML
loading before envconfig.Process() by reading CONFIG_FILE (e.g., use
gopkg.in/yaml.v3 to unmarshal into the same config struct) and then let
envconfig.Process() override values from the environment; update the header
comments to match the chosen approach and mention CONFIG_FILE only if YAML
support is implemented.


# ── HTTP Server ───────────────────────────────────────────────────────────────
server:
port: 8088
read_timeout: 15s
write_timeout: 15s
idle_timeout: 60s
shutdown_timeout: 30s

# ── PostgreSQL ────────────────────────────────────────────────────────────────
postgres:
url: postgres://postgres:postgres@localhost:5432/app?sslmode=disable
max_conns: 10
min_conns: 2
max_conn_lifetime: 1h
max_conn_idle_time: 30m
health_timeout: 5s

# ── Redis ─────────────────────────────────────────────────────────────────────
redis:
addr: localhost:6379
password: ""
db: 0
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
pool_size: 10
health_timeout: 5s

# ── Logging ───────────────────────────────────────────────────────────────────
log:
# debug | info | warn | error
level: info
# json | text
format: json
Loading
Loading