feat: add dependency shutdown and configurable service endpoints

This commit is contained in:
2026-09-24 18:33:24 +08:00
parent 8a348951eb
commit f0991d3ff2
51 changed files with 1433 additions and 73 deletions

View File

@@ -30,6 +30,7 @@ type Config struct {
Transport string `json:"transport"`
TLS TLSConfig `json:"tls"`
HTTP HTTPConfig `json:"http"`
Endpoints EndpointsConfig `json:"endpoints"`
RequestIDHeader string `json:"requestIdHeader"`
EnableLogger bool `json:"enableLogger"`
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
@@ -47,12 +48,16 @@ func Default() Config {
Transport: TransportNetHTTP,
RequestIDHeader: defaultRequestIDHeader,
ShutdownTimeout: 10 * time.Second,
Endpoints: DefaultEndpoints(),
Log: logx.Default(),
}
}
// Validate checks configuration values before an application starts.
func (c Config) Validate() error {
if err := c.Endpoints.Validate(); err != nil {
return err
}
if err := c.Errors.Validate(); err != nil {
return err
}

119
config/endpoints.go Normal file
View File

@@ -0,0 +1,119 @@
package config
import (
"fmt"
"strings"
"unicode"
)
// EndpointsConfig controls routes installed by a generated service. A manually
// assembled App still registers its own routes explicitly.
type EndpointsConfig struct {
Liveness HealthEndpointConfig `json:"liveness"`
Readiness HealthEndpointConfig `json:"readiness"`
OpenAPI EndpointConfig `json:"openapi"`
}
type EndpointConfig struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
}
type HealthEndpointConfig struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
ResponseMode string `json:"responseMode"` // app (default) or raw
}
func DefaultEndpoints() EndpointsConfig {
return EndpointsConfig{
Liveness: HealthEndpointConfig{Enabled: true, Path: "/health/live", ResponseMode: "app"},
Readiness: HealthEndpointConfig{Enabled: true, Path: "/health/ready", ResponseMode: "app"},
OpenAPI: EndpointConfig{Enabled: true, Path: "/openapi.json"},
}
}
func (c EndpointsConfig) enabled() []struct{ name, path string } {
var result []struct{ name, path string }
for _, endpoint := range []struct {
name string
enabled bool
path string
}{
{"liveness", c.Liveness.Enabled, c.Liveness.Path},
{"readiness", c.Readiness.Enabled, c.Readiness.Path},
{"openapi", c.OpenAPI.Enabled, c.OpenAPI.Path},
} {
if endpoint.enabled {
result = append(result, struct{ name, path string }{endpoint.name, endpoint.path})
}
}
return result
}
func (c EndpointsConfig) Validate() error {
for _, mode := range []string{c.Liveness.ResponseMode, c.Readiness.ResponseMode} {
if mode != "" && mode != "app" && mode != "raw" {
return fmt.Errorf("endpoints: responseMode must be app or raw")
}
}
paths := map[string]string{}
for _, endpoint := range c.enabled() {
p := endpoint.path
if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, ":*?#%\\{}") ||
strings.IndexFunc(p, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
return fmt.Errorf("endpoints.%s.path must be an absolute literal URL path", endpoint.name)
}
parts := strings.Split(p[1:], "/")
for i, part := range parts {
if part == "." || part == ".." || (part == "" && i < len(parts)-1) {
return fmt.Errorf("endpoints.%s.path contains an invalid segment", endpoint.name)
}
}
if previous := paths[p]; previous != "" {
return fmt.Errorf("endpoints.%s and endpoints.%s share path %s", previous, endpoint.name, p)
}
paths[p] = endpoint.name
}
return nil
}
// EndpointRoute describes an already validated business route. Only GET
// patterns can overlap these built-in GET endpoints.
type EndpointRoute struct{ Method, Path string }
// ValidateRoutes rejects a built-in route that would shadow a business route
// or conflict with its catch-all. Disabled endpoints reserve no paths.
func (c EndpointsConfig) ValidateRoutes(routes ...EndpointRoute) error {
if err := c.Validate(); err != nil {
return err
}
for _, endpoint := range c.enabled() {
for _, route := range routes {
if strings.EqualFold(route.Method, "GET") && endpointOverlap(route.Path, endpoint.path) {
return fmt.Errorf("endpoints.%s path %s overlaps business route GET %s; change its path or disable it", endpoint.name, endpoint.path, route.Path)
}
}
}
return nil
}
func endpointOverlap(pattern, path string) bool {
a, b := strings.Split(strings.TrimPrefix(pattern, "/"), "/"), strings.Split(path[1:], "/")
for i, part := range a {
if i >= len(b) {
return false
}
if strings.HasPrefix(part, "*") {
return true
}
if strings.HasPrefix(part, ":") {
if b[i] == "" {
return false
}
} else if part != b[i] {
return false
}
}
return len(a) == len(b)
}

66
config/endpoints_test.go Normal file
View File

@@ -0,0 +1,66 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestEndpointConfiguration(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("endpoints:\n liveness:\n enabled: false\n readiness:\n path: /readyz\n responseMode: raw\n openapi:\n enabled: false\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := DecodeFile(path, Default())
if err != nil {
t.Fatal(err)
}
if cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.OpenAPI.Enabled || !cfg.Endpoints.Readiness.Enabled || cfg.Endpoints.Readiness.ResponseMode != "raw" {
t.Fatal(cfg.Endpoints)
}
t.Setenv("TEST_ENDPOINTS_READINESS_PATH", "/internal/ready")
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "true")
cfg, err = ApplyEnv(cfg, "TEST_")
if err != nil || cfg.Endpoints.Readiness.Path != "/internal/ready" || !cfg.Endpoints.OpenAPI.Enabled {
t.Fatal(cfg.Endpoints, err)
}
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "invalid")
if _, err = ApplyEnv(cfg, "TEST_"); err == nil {
t.Fatal("invalid bool accepted")
}
}
func TestEndpointRouteConflicts(t *testing.T) {
for _, route := range []EndpointRoute{{"GET", "/health/ready"}, {"get", "/health/:name"}, {"GET", "/health/*rest"}, {"GET", "/*rest"}} {
cfg := DefaultEndpoints()
if err := cfg.ValidateRoutes(route); err == nil {
t.Fatal("overlap accepted", route)
}
cfg.Readiness.Enabled = false
cfg.Liveness.Enabled = false
cfg.OpenAPI.Enabled = false
if err := cfg.ValidateRoutes(route); err != nil {
t.Fatal("disabled endpoint reserves path", err)
}
}
if err := DefaultEndpoints().ValidateRoutes(EndpointRoute{"POST", "/health/ready"}, EndpointRoute{"GET", "/other/*rest"}); err != nil {
t.Fatal(err)
}
for _, path := range []string{"", "ready", "//ready", "/a/../ready", "/:ready", "/ready?x=1", "/a%2fb", "/ready\n"} {
cfg := DefaultEndpoints()
cfg.Readiness.Path = path
if err := cfg.Validate(); err == nil {
t.Fatal("invalid path", path)
}
}
cfg := DefaultEndpoints()
cfg.Readiness.Path = cfg.Liveness.Path
if err := cfg.Validate(); err == nil {
t.Fatal("duplicate endpoints accepted")
}
cfg = DefaultEndpoints()
cfg.Readiness.ResponseMode = "unknown"
if err := cfg.Validate(); err == nil {
t.Fatal("invalid mode accepted")
}
}

View File

@@ -49,6 +49,26 @@ func applyEnv(cfg Config, prefix string) (Config, error) {
cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment)
var err error
for _, endpoint := range []struct {
name string
enabled *bool
path *string
mode *string
}{
{"LIVENESS", &cfg.Endpoints.Liveness.Enabled, &cfg.Endpoints.Liveness.Path, &cfg.Endpoints.Liveness.ResponseMode},
{"READINESS", &cfg.Endpoints.Readiness.Enabled, &cfg.Endpoints.Readiness.Path, &cfg.Endpoints.Readiness.ResponseMode},
{"OPENAPI", &cfg.Endpoints.OpenAPI.Enabled, &cfg.Endpoints.OpenAPI.Path, nil},
} {
key := prefix + "ENDPOINTS_" + endpoint.name
*endpoint.enabled, err = envBool(key+"_ENABLED", *endpoint.enabled)
if err != nil {
return Config{}, err
}
*endpoint.path = envString(key+"_PATH", *endpoint.path)
if endpoint.mode != nil {
*endpoint.mode = envString(key+"_RESPONSE_MODE", *endpoint.mode)
}
}
for _, field := range []struct {
name string
value *time.Duration