Organize backend into product executable and shared Go modules

This commit is contained in:
Stephan D committed 2026-09-12 14:14:08 +02:00
1 parent 981e84a4b4
commit f0720ec66b
382 files changed
+1944 -6989

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
package api
import (
"context"
"git.sendico.io/tech/sxchnge/product/app/urls"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
)
type API interface {
LifecycleContext() context.Context
Logger() mlogger.Logger
DomainProvider() domainprovider.DomainProvider
Config() *Config
ProfileConfig() ConfigProvider
DBFactory() db.Factory
RuntimeComponent(name string) any
Register() Register
}
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
+16
View File
@@ -0,0 +1,16 @@
package api
import (
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
)
type Config struct {
Mw *mwa.Config `yaml:"middleware"`
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
Verification *verificationconfig.Config `yaml:"verification"`
}
func (c *Config) SharedConfig() *Config {
return c
}
+13
View File
@@ -0,0 +1,13 @@
package api
import (
"git.sendico.io/tech/sxchnge/product/auth/response"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/service"
)
type Register interface {
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
}
+39
View File
@@ -0,0 +1,39 @@
package api
import "git.sendico.io/tech/sxchnge/shared/service"
type ConfigProvider interface {
SharedConfig() *Config
}
type ConfigFactory func() ConfigProvider
type RuntimeComponents map[string]any
type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
type RuntimeFinishFunc func(API) error
type RuntimeConfig struct {
Name string
Services []ServiceFactory
ConfigFactory ConfigFactory
Bootstrap RuntimeBootstrapFunc
Finish RuntimeFinishFunc
}
type ServiceFactory struct {
Name mservice.Type
Factory MicroServiceFactoryT
}
func (cfg RuntimeConfig) RuntimeName() string {
if cfg.Name == "" {
return "edge"
}
return cfg.Name
}
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
if cfg.ConfigFactory != nil {
return cfg.ConfigFactory()
}
return &Config{}
}
+18
View File
@@ -0,0 +1,18 @@
package routers
import (
"git.sendico.io/tech/sxchnge/product/app/health/internal"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
)
type Health interface {
SetStatus(status health.ServiceStatus)
SetStatusWithReason(status health.ServiceStatus, reason string)
Finish()
}
func NewHealthRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Health, error) {
return healthimp.NewRouter(logger, router, endpoint), nil
}
@@ -0,0 +1,76 @@
package healthimp
import (
"net/http"
"strings"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
type Router struct {
logger mlogger.Logger
status *Status
}
func (hr *Router) SetStatus(status health.ServiceStatus) {
hr.SetStatusWithReason(status, defaultReason(status))
}
func (hr *Router) SetStatusWithReason(status health.ServiceStatus, reason string) {
previous, changed := hr.status.transition(status)
if !changed {
return
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = defaultReason(status)
}
hr.logger.Info("Health status changed",
zap.String("previous_status", string(previous)),
zap.String("status", string(status)),
zap.String("reason", reason),
)
}
func (hr *Router) Finish() {
hr.status.Finish()
hr.logger.Debug("Stopped")
}
func (hr *Router) handle(w http.ResponseWriter, r *http.Request) {
hr.status.healthHandler()(w, r)
}
func NewRouter(logger mlogger.Logger, router chi.Router, endpoint string) *Router {
hr := Router{
logger: logger.Named("health_check"),
}
hr.status = StatusHandler(hr.logger)
logger.Debug("Installing healthcheck middleware...")
router.Group(func(r chi.Router) {
ep := endpoint + "/health"
r.Get(ep, hr.handle)
logger.Info("Health handler installed", zap.String("endpoint", ep))
})
return &hr
}
func defaultReason(status health.ServiceStatus) string {
switch status {
case health.SSCreated:
return "service created"
case health.SSStarting:
return "service is starting"
case health.SSRunning:
return "service is ready"
case health.SSTerminating:
return "service is shutting down"
default:
return "service health status changed"
}
}
@@ -0,0 +1,53 @@
package healthimp
import (
"testing"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestSetStatusLogsOnlyTransitionsWithReason(t *testing.T) {
core, observed := observer.New(zap.InfoLevel)
router := NewRouter(zap.New(core), chi.NewRouter(), "")
router.SetStatusWithReason(health.SSStarting, "observability endpoint initialized")
router.SetStatusWithReason(health.SSStarting, "duplicate readiness report")
router.SetStatusWithReason(health.SSRunning, "all configured ingestion drivers are ready")
router.SetStatus(health.SSRunning)
entries := observed.FilterMessage("Health status changed").All()
if len(entries) != 2 {
t.Fatalf("health transition log count = %d, want 2", len(entries))
}
assertTransition(t, entries[0].ContextMap(), "created", "starting", "observability endpoint initialized")
assertTransition(t, entries[1].ContextMap(), "starting", "ok", "all configured ingestion drivers are ready")
}
func TestSetStatusUsesNonEmptyDefaultReason(t *testing.T) {
core, observed := observer.New(zap.InfoLevel)
router := NewRouter(zap.New(core), chi.NewRouter(), "")
router.SetStatusWithReason(health.SSTerminating, " ")
entries := observed.FilterMessage("Health status changed").All()
if len(entries) != 1 {
t.Fatalf("health transition log count = %d, want 1", len(entries))
}
assertTransition(t, entries[0].ContextMap(), "created", "deactivating", "service is shutting down")
}
func assertTransition(t *testing.T, fields map[string]any, previous, status, reason string) {
t.Helper()
for key, want := range map[string]string{
"previous_status": previous,
"status": status,
"reason": reason,
} {
if got, ok := fields[key].(string); !ok || got != want {
t.Fatalf("%s = %v, want %q", key, fields[key], want)
}
}
}
@@ -0,0 +1,50 @@
package healthimp
import (
"net/http"
"sync"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging"
)
type Status struct {
logger mlogger.Logger
mu sync.RWMutex
status health.ServiceStatus
}
func (hs *Status) healthHandler() http.HandlerFunc {
hs.mu.RLock()
status := hs.status
hs.mu.RUnlock()
return response.Ok(hs.logger, struct {
Status health.ServiceStatus `json:"status"`
}{
status,
})
}
func (hr *Status) Finish() {
hr.logger.Info("Finished")
}
func (hs *Status) transition(status health.ServiceStatus) (health.ServiceStatus, bool) {
hs.mu.Lock()
defer hs.mu.Unlock()
previous := hs.status
if previous == status {
return previous, false
}
hs.status = status
return previous, true
}
func StatusHandler(logger mlogger.Logger) *Status {
hs := Status{
status: health.SSCreated,
logger: logger.Named("status"),
}
return &hs
}
@@ -0,0 +1,10 @@
package health
type ServiceStatus string
const (
SSCreated ServiceStatus = "created"
SSStarting ServiceStatus = "starting"
SSRunning ServiceStatus = "ok"
SSTerminating ServiceStatus = "deactivating"
)
+153
View File
@@ -0,0 +1,153 @@
package httptransport
import (
"net/http"
"os"
"path"
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
"git.sendico.io/tech/sxchnge/product/auth/config"
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
"git.sendico.io/tech/sxchnge/product/auth/response"
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
cm "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/metrics"
"go.uber.org/zap"
"moul.io/chizap"
)
type Middleware struct {
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
metrics mr.Metrics
epdispatcher *httprouter.Dispatcher
}
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
mw.epdispatcher.Handler(service, endpoint, method, handler)
}
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) Finish() {
mw.metrics.Finish()
}
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
mw.logger.Debug("Installing middleware stack...")
// Collect metrics for application traffic only. Scrapes and health probes are
// operational traffic and would otherwise distort request and in-flight SLIs.
mw.router.Use(metrics.Collector(metrics.CollectorOpts{
Host: false, // avoid high-cardinality "host" label
Proto: true, // include HTTP protocol label
Skip: func(r *http.Request) bool {
return isObservabilityRequest(mw.apiEndpoint, r.URL.Path)
},
}))
mw.router.Use(cm.RequestID)
if debug {
mw.router.Use(chizap.New(mw.logger.Named("http_trace"), &chizap.Opts{
WithReferer: true,
WithUserAgent: true,
}))
}
mw.router.Use(cors.Handler(cors.Options{
AllowedOrigins: config.CORS.AllowedOrigins,
AllowedMethods: config.CORS.AllowedMethods,
AllowedHeaders: config.CORS.AllowedHeaders,
ExposedHeaders: config.CORS.ExposedHeaders,
AllowCredentials: config.CORS.AllowCredentials,
MaxAge: config.CORS.MaxAge,
OptionsPassthrough: false,
Debug: debug,
}))
mw.router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := browser.ValidateCSRF(r, config.CORS.AllowedOrigins); err != nil {
mw.logger.Info("Rejected browser request with invalid CSRF context",
zap.String("method", r.Method), zap.String("path", r.URL.Path))
response.Forbidden(mw.logger, mservice.Accounts, "csrf_rejected", "browser request failed CSRF validation").ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
})
mw.router.Use(cm.Recoverer)
mw.router.Handle("/metrics", metrics.Handler())
mw.logger.Info("Middleware stack installation complete")
}
func isObservabilityRequest(apiEndpoint, requestPath string) bool {
healthPath := path.Join("/", apiEndpoint, "health")
metricsPath := path.Join("/", apiEndpoint, "metrics")
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
}
func CreateMiddleware(
logger mlogger.Logger,
db db.Factory,
apiRouter *chi.Mux,
config *middleware.Config,
authStorage *middleware.AuthStorageConfig,
debug bool,
) (*Middleware, error) {
if config == nil {
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
}
if err := config.Token.Validate(); err != nil {
return nil, err
}
if err := config.CORS.Validate(); err != nil {
return nil, err
}
loginRateLimits, err := config.ResolveLoginRateLimits()
if err != nil {
logger.Error("Invalid login rate limit configuration", zap.Error(err))
return nil, err
}
p := &Middleware{
logger: logger.Named("middleware"),
router: apiRouter,
apiEndpoint: os.Getenv(config.EndPointEnv),
}
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
p.installMiddleware(config, debug)
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
if err != nil {
return nil, err
}
p.epdispatcher = httprouter.NewDispatcher(
p.logger,
p.router,
stores.Accounts,
stores.Verification,
stores.RefreshTokens,
stores.LoginAttempts,
config,
loginRateLimits,
)
return p, nil
}
+155
View File
@@ -0,0 +1,155 @@
package httptransport
import (
"context"
"net/http"
"net/http/httptest"
"testing"
amr "git.sendico.io/tech/sxchnge/product/app/health"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"github.com/go-chi/chi/v5"
chimetrics "github.com/go-chi/metrics"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"moul.io/chizap"
)
func TestHTTPCollectorDoesNotGatherHealthOrMetricsRequests(t *testing.T) {
router := chi.NewRouter()
router.Use(chimetrics.Collector(chimetrics.CollectorOpts{
Proto: true,
Skip: func(r *http.Request) bool {
return isObservabilityRequest("/api/v1", r.URL.Path)
},
}))
router.Get("/api/v1/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
router.Handle("/metrics", chimetrics.Handler())
router.Handle("/api/v1/metrics", chimetrics.Handler())
router.Get("/api/v1/accounts", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
beforeHealth := gatheredHTTPRequestCount(t, "GET /api/v1/health")
beforeMetrics := gatheredHTTPRequestCount(t, "GET /metrics")
beforeAPIMetrics := gatheredHTTPRequestCount(t, "GET /api/v1/metrics")
beforeApplication := gatheredHTTPRequestCount(t, "GET /api/v1/accounts")
for _, requestPath := range []string{"/api/v1/health", "/metrics", "/api/v1/metrics", "/api/v1/accounts"} {
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, requestPath, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d", requestPath, response.Code)
}
}
if got := gatheredHTTPRequestCount(t, "GET /api/v1/health") - beforeHealth; got != 0 {
t.Fatalf("health probe changed http_requests_total by %v", got)
}
if got := gatheredHTTPRequestCount(t, "GET /metrics") - beforeMetrics; got != 0 {
t.Fatalf("metrics scrape changed http_requests_total by %v", got)
}
if got := gatheredHTTPRequestCount(t, "GET /api/v1/metrics") - beforeAPIMetrics; got != 0 {
t.Fatalf("API metrics scrape changed http_requests_total by %v", got)
}
if got := gatheredHTTPRequestCount(t, "GET /api/v1/accounts") - beforeApplication; got != 1 {
t.Fatalf("application request changed http_requests_total by %v, want 1", got)
}
}
func gatheredHTTPRequestCount(t *testing.T, endpoint string) float64 {
t.Helper()
families, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("gather metrics: %v", err)
}
for _, family := range families {
if family.GetName() != "http_requests_total" {
continue
}
var total float64
for _, metric := range family.GetMetric() {
for _, label := range metric.GetLabel() {
if label.GetName() == "endpoint" && label.GetValue() == endpoint {
total += metric.GetCounter().GetValue()
}
}
}
return total
}
return 0
}
func TestObservabilityRequestsAreExcludedFromHTTPMetrics(t *testing.T) {
tests := []struct {
name string
apiEndpoint string
requestPath string
want bool
}{
{name: "health under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/health", want: true},
{name: "metrics at root", apiEndpoint: "/api/v1", requestPath: "/metrics", want: true},
{name: "metrics under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/metrics", want: true},
{name: "trailing endpoint slash", apiEndpoint: "/api/v1/", requestPath: "/api/v1/health", want: true},
{name: "application endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/accounts", want: false},
{name: "health suffix is not a probe", apiEndpoint: "/api/v1", requestPath: "/api/v1/health/history", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isObservabilityRequest(tt.apiEndpoint, tt.requestPath); got != tt.want {
t.Fatalf("isObservabilityRequest(%q, %q) = %t, want %t", tt.apiEndpoint, tt.requestPath, got, tt.want)
}
})
}
}
func TestHealthMountedOutsideTraceMiddleware(t *testing.T) {
core, observed := observer.New(zapcore.InfoLevel)
logger := zap.New(core)
rootRouter := chi.NewRouter()
apiRouter := chi.NewRouter()
apiRouter.Use(chizap.New(logger.Named("middleware").Named("http_trace"), &chizap.Opts{}))
apiRouter.Get("/api/v1/accounts", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
healthRouter, err := amr.NewHealthRouter(logger.Named("middleware"), rootRouter, "/api/v1")
if err != nil {
t.Fatalf("failed to create health router: %v", err)
}
healthRouter.SetStatus(health.SSRunning)
rootRouter.Mount("/", apiRouter)
healthReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/health", nil)
healthRes := httptest.NewRecorder()
rootRouter.ServeHTTP(healthRes, healthReq)
if healthRes.Code != http.StatusOK {
t.Fatalf("expected health status %d, got %d", http.StatusOK, healthRes.Code)
}
if got := observed.FilterMessage("Served").Len(); got != 0 {
t.Fatalf("expected no trace logs for /health, got %d", got)
}
apiReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/accounts", nil)
apiRes := httptest.NewRecorder()
rootRouter.ServeHTTP(apiRes, apiReq)
if apiRes.Code != http.StatusOK {
t.Fatalf("expected api status %d, got %d", http.StatusOK, apiRes.Code)
}
entries := observed.FilterMessage("Served").All()
if len(entries) != 1 {
t.Fatalf("expected one trace log for non-health request, got %d", len(entries))
}
if got := entries[0].ContextMap()["path"]; got != "/api/v1/accounts" {
t.Fatalf("expected logged path %q, got %#v", "/api/v1/accounts", got)
}
}
+40
View File
@@ -0,0 +1,40 @@
package metrics
import (
"net/http"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
"github.com/go-chi/metrics"
"go.uber.org/zap"
)
type metricsRouter struct {
logger mlogger.Logger
handler http.Handler
}
func (mr *metricsRouter) Finish() {
mr.logger.Debug("Stopped")
}
func (mr *metricsRouter) handle(w http.ResponseWriter, r *http.Request) {
mr.logger.Debug("Serving metrics request...")
mr.handler.ServeHTTP(w, r)
}
func newMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) *metricsRouter {
mr := metricsRouter{
logger: logger.Named("metrics"),
handler: metrics.Handler(),
}
logger.Debug("Installing Prometheus middleware...")
router.Group(func(r chi.Router) {
ep := endpoint + "/metrics"
r.Get(ep, mr.handle)
logger.Info("Prometheus handler installed", zap.String("endpoint", ep))
})
return &mr
}
+14
View File
@@ -0,0 +1,14 @@
package metrics
import (
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
)
type Metrics interface {
Finish()
}
func NewMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Metrics, error) {
return newMetricsRouter(logger, router, endpoint), nil
}
+5
View File
@@ -0,0 +1,5 @@
package server
import "git.sendico.io/tech/sxchnge/shared/logging"
type ServerFactoryT = func(logger mlogger.Logger, file string, debug bool) (Application, error)
@@ -0,0 +1,40 @@
package serverimp
import (
"git.sendico.io/tech/sxchnge/product/app/process"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap"
)
type Instance struct {
srv server.Application
logger mlogger.Logger
file string
debug bool
factory server.ServerFactoryT
}
func (i *Instance) Start() error {
var err error
if i.srv, err = i.factory(i.logger, i.file, i.debug); err != nil {
i.logger.Warn("Failed to create server instance", zap.Error(err))
return err
}
return i.srv.Start()
}
func (i *Instance) Shutdown() {
if i.srv != nil {
i.srv.Shutdown()
}
}
func NewInstance(factory server.ServerFactoryT, logger mlogger.Logger, file string, debug bool) *Instance {
return &Instance{
srv: nil,
logger: logger,
file: file,
debug: debug,
factory: factory,
}
}
@@ -0,0 +1,66 @@
package serverimp
import (
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"git.sendico.io/tech/sxchnge/product/app/process"
"git.sendico.io/tech/sxchnge/shared/logging"
lf "git.sendico.io/tech/sxchnge/shared/logging/factory"
"git.sendico.io/tech/sxchnge/shared/version"
"github.com/google/uuid"
"go.uber.org/zap"
)
var (
configFileFlag = flag.String("config.file", "config.yml", "Path to the configuration file.")
versionFlag = flag.Bool("version", false, "Show version information.")
debugFlag = flag.Bool("debug", false, "Show debug information.")
)
func prepareLogger() mlogger.Logger {
flag.Parse()
return lf.NewLogger(*debugFlag)
}
func RunServer(rootLoggerName string, av version.Printer, factory server.ServerFactoryT) error {
logger := mlogger.Named(prepareLogger(), rootLoggerName)
logger = logger.With(zap.String("instance_id", uuid.NewString()))
defer func() {
_ = logger.Sync()
}()
// Show version information
if *versionFlag {
if _, err := fmt.Fprintln(os.Stdout, av.Print()); err != nil {
logger.Warn("Failed to print version", zap.Error(err))
}
return nil
}
// Create server instance
instance := NewInstance(factory, logger, *configFileFlag, *debugFlag)
// Interrupt handler
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
sig := <-c
logger.Info("Received sigint/segterm signal, shutting down", zap.String("signal", sig.String()))
instance.Shutdown()
}()
// Start server
logger.Info(fmt.Sprintf("Starting %s", av.Program()), zap.String("version", av.Info()))
logger.Info("Build context", zap.String("context", av.Context()))
if err := instance.Start(); err != nil {
logger.Error("Failed to start service", zap.Error(err))
return err
}
logger.Info("Server stopped")
return nil
}
+11
View File
@@ -0,0 +1,11 @@
package server
import (
sd "git.sendico.io/tech/sxchnge/product/app/process"
serverimp "git.sendico.io/tech/sxchnge/product/app/process/internal"
"git.sendico.io/tech/sxchnge/shared/version"
)
func RunServer(rootLoggerName string, av version.Printer, factory sd.ServerFactoryT) error {
return serverimp.RunServer(rootLoggerName, av, factory)
}
+7
View File
@@ -0,0 +1,7 @@
package server
// Typical service interface
type Application interface {
Shutdown()
Start() error
}
+21
View File
@@ -0,0 +1,21 @@
// Package profile selects the locally owned components of the copied BFF.
package profile
import (
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/customers"
)
const Accounts = "accounts"
func Runtime() api.RuntimeConfig {
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap}
}
func bootstrap(a api.API) (api.RuntimeComponents, error) {
accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage)
if err != nil {
return nil, err
}
return api.RuntimeComponents{Accounts: accounts}, nil
}
+69
View File
@@ -0,0 +1,69 @@
package runtime
import (
"context"
"git.sendico.io/tech/sxchnge/product/app/api"
amr "git.sendico.io/tech/sxchnge/product/app/health"
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
"git.sendico.io/tech/sxchnge/product/app/urls"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
)
type Microservices = []mservice.MicroService
// APIImp represents the structure of the APIImp
type APIImp struct {
lifecycleCtx context.Context
logger mlogger.Logger
db db.Factory
domain domainprovider.DomainProvider
config *api.Config
profile api.ConfigProvider
runtime api.RuntimeConfig
components api.RuntimeComponents
services Microservices
health amr.Health
mw *httptransport.Middleware
}
func (a *APIImp) LifecycleContext() context.Context {
return a.lifecycleCtx
}
func (a *APIImp) Logger() mlogger.Logger {
return a.logger
}
func (a *APIImp) Config() *api.Config {
return a.config
}
func (a *APIImp) ProfileConfig() api.ConfigProvider {
return a.profile
}
func (a *APIImp) DBFactory() db.Factory {
return a.db
}
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
return a.domain
}
func (a *APIImp) Register() api.Register {
return a.mw
}
func (a *APIImp) RuntimeComponent(name string) any {
if a == nil || a.components == nil {
return nil
}
return a.components[name]
}
func (a *APIImp) Name() string {
return "api"
}
+107
View File
@@ -0,0 +1,107 @@
package runtime
import (
"context"
"os"
"git.sendico.io/tech/sxchnge/product/app/api"
amr "git.sendico.io/tech/sxchnge/product/app/health"
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
"git.sendico.io/tech/sxchnge/product/app/urls"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
func CreateAPI(
lifecycleCtx context.Context,
logger mlogger.Logger,
config *api.Config,
profileConfig api.ConfigProvider,
db db.Factory,
rootRouter *chi.Mux,
apiRouter *chi.Mux,
debug bool,
runtime api.RuntimeConfig,
) (mservice.MicroService, error) {
p := &APIImp{
lifecycleCtx: lifecycleCtx,
logger: logger.Named("api"),
config: config,
profile: profileConfig,
db: db,
runtime: runtime,
}
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil {
return nil, err
}
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
return nil, err
}
p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName()))
if err := p.installServices(); err != nil {
p.logger.Error("Failed to install a microservice", zap.Error(err))
return nil, err
}
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
return p, nil
}
func (a *APIImp) installRuntimeBasics(
logger mlogger.Logger,
db db.Factory,
rootRouter *chi.Mux,
apiRouter *chi.Mux,
debug bool,
) error {
var err error
if a.domain, err = domainprovider.CreateDomainProvider(
a.logger,
a.config.Mw.DomainEnv,
a.config.Mw.APIProtocolEnv,
a.config.Mw.EndPointEnv,
); err != nil {
a.logger.Error("Failed to initialize domain provider")
return err
}
a.logger.Info("Domain provider installed")
apiEndpoint := os.Getenv(a.config.Mw.EndPointEnv)
if a.health, err = amr.NewHealthRouter(a.logger, rootRouter, apiEndpoint); err != nil {
a.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", apiEndpoint))
return err
}
if a.mw, err = httptransport.CreateMiddleware(
logger,
db,
apiRouter,
a.config.Mw,
a.config.AuthStorage,
debug,
); err != nil {
a.logger.Error("Failed to create middleware", zap.Error(err))
return err
}
a.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
return nil
}
func (a *APIImp) bootstrapConfiguredRuntime(_ *api.Config, _ db.Factory) error {
if a.runtime.Bootstrap != nil {
components, err := a.runtime.Bootstrap(a)
if err != nil {
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName()))
return err
}
a.components = components
a.logger.Info("Runtime profile bootstrap complete",
zap.String("runtime", a.runtime.RuntimeName()),
zap.Int("components", len(components)))
}
return nil
}
@@ -0,0 +1,63 @@
package runtime_test
import (
"context"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/profile"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/shared/database"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
endpoint, err := url.Parse(uri)
require.NoError(t, err)
query := endpoint.Query()
query.Set("directConnection", "true")
endpoint.RawQuery = query.Encode()
uri = endpoint.String()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": uri, "database": "sxchnge_runtime"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
data, err := os.ReadFile("../../config.example.yml")
require.NoError(t, err)
var example struct {
API api.Config `yaml:"api"`
}
require.NoError(t, yaml.Unmarshal(data, &example))
t.Setenv("SXCHNGE_DOMAIN", "localhost")
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
root, routes := chi.NewRouter(), chi.NewRouter()
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
root.Mount("/", routes)
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
require.Equal(t, http.StatusOK, result.Code)
// Runtime auth routes are installed even with zero customers and no seed.
result = httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
require.Equal(t, http.StatusBadRequest, result.Code)
}
+31
View File
@@ -0,0 +1,31 @@
package runtime
import (
"context"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"go.uber.org/zap"
)
func (a *APIImp) Finish(ctx context.Context) error {
a.health.SetStatus(health.SSTerminating)
a.health.Finish()
a.mw.Finish()
var lastError error
for i := len(a.services) - 1; i >= 0; i-- {
if err := (a.services[i]).Finish(ctx); err != nil {
lastError = err
a.logger.Warn("Error occurred when finishing service",
zap.Error(err), zap.String("service_name", (a.services[i]).Name()))
} else {
a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name()))
}
}
if a.runtime.Finish != nil {
if err := a.runtime.Finish(a); err != nil {
lastError = err
a.logger.Warn("Error occurred when finishing runtime profile", zap.String("runtime", a.runtime.RuntimeName()), zap.Error(err))
}
}
return lastError
}
+39
View File
@@ -0,0 +1,39 @@
package runtime
import (
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/service"
"go.uber.org/zap"
)
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
a.services = append(a.services, srv)
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
}
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
srv, err := srvf(a)
if err != nil {
a.logger.Error("Failed to install a microservice", zap.Error(err))
return err
}
a.installMicroservice(srv)
return nil
}
func (a *APIImp) installServices() error {
for _, service := range a.runtime.Services {
if service.Factory == nil {
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
}
if err := a.addMicroservice(service.Factory); err != nil {
return err
}
}
a.health.SetStatus(health.SSRunning)
return nil
}
@@ -0,0 +1,34 @@
package server
import (
"errors"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/shared/errors"
"os"
"testing"
)
func TestIncompleteConfigReturnsProjectError(t *testing.T) {
for _, data := range []string{"api: {}", "api: {middleware: {}}\ndatabase: {driver: mongodb}", "api: {middleware: {}}\ndatabase: {driver: mongodb}\nhttp_server: {}"} {
instance := &Imp{runtime: api.RuntimeConfig{}}
if err := instance.decodeConfig([]byte(data)); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected configuration error, got %v", err)
}
}
}
func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
data, err := os.ReadFile("../../../config.example.yml")
if err != nil {
t.Fatal(err)
}
instance := &Imp{runtime: api.RuntimeConfig{}}
if err := instance.decodeConfig(data); err != nil {
t.Fatal(err)
}
if err := instance.config.API.SharedConfig().Mw.Token.Validate(); err != nil {
t.Fatal(err)
}
if err := instance.config.API.SharedConfig().Mw.CORS.Validate(); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,177 @@
package server
import (
"context"
"errors"
"net/http"
"os"
"time"
ac "git.sendico.io/tech/sxchnge/product/app/api"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/shared/database"
mduration "git.sendico.io/tech/sxchnge/shared/duration"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type httpServerConf struct {
ListenAddress string `yaml:"listen_address"`
ReadHeaderTimeout int `yaml:"read_header_timeout"`
ShutdownTimeout int `yaml:"shutdown_timeout"`
}
// Config represents the server configuration
type Config struct {
API ac.ConfigProvider
DB *db.Config `yaml:"database"`
HTTPServer *httpServerConf `yaml:"http_server"`
}
// Instance represents an instance of the server
type Imp struct {
lifecycleCtx context.Context
lifecycleCancel context.CancelFunc
logger mlogger.Logger
api mservice.MicroService
config *Config
db db.Factory
httpServer *http.Server
runtime ac.RuntimeConfig
debug bool
file string
}
// Shutdown stops the server
func (i *Imp) Shutdown() {
if i.lifecycleCancel != nil {
i.lifecycleCancel()
}
if i.httpServer == nil {
return
}
// Shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), mduration.Param2Duration(i.config.HTTPServer.ShutdownTimeout, time.Second))
i.logger.Info("Shutting HTTP server down...")
if err := i.httpServer.Shutdown(ctx); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
i.logger.Warn("Failed to shutdown HTTP server gracefully", zap.Error(err))
cancel()
os.Exit(1)
}
}
cancel()
}
func (i *Imp) Run() error {
err := i.httpServer.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
i.logger.Info("HTTP Server stopped")
if err := i.api.Finish(context.Background()); err != nil {
i.logger.Warn("Error when finishing service", zap.Error(err))
}
i.db.CloseConnection()
if err != nil {
return merrors.OpaqueWrap(merrors.ErrInternal, err, "HTTP server stopped unexpectedly")
}
return nil
}
// Start starts the server
func (i *Imp) Start() error {
if i.lifecycleCancel != nil {
defer i.lifecycleCancel()
}
i.logger.Info("Starting...", zap.String("config_file", i.file), zap.Bool("debug_mode", i.debug))
// Load configuration file
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not load configuration", zap.Error(err), zap.String("config_file", i.file))
return err
}
if err = i.decodeConfig(data); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return err
}
if i.db, err = db.NewConnection(i.logger, i.config.DB); err != nil {
i.logger.Error("Could not open database connection", zap.Error(err))
return err
}
rootRouter := chi.NewRouter()
apiRouter := chi.NewRouter()
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.config.API, i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
i.db.CloseConnection()
return err
}
rootRouter.Mount("/", apiRouter)
// Startup the HTTP Server in a way that we can gracefully shut it down again
i.httpServer = &http.Server{
Addr: i.config.HTTPServer.ListenAddress,
Handler: rootRouter,
ReadHeaderTimeout: mduration.Param2Duration(i.config.HTTPServer.ReadHeaderTimeout, time.Second),
}
return i.Run()
}
func (i *Imp) decodeConfig(data []byte) error {
var raw struct {
API yaml.Node `yaml:"api"`
DB *db.Config `yaml:"database"`
HTTPServer *httpServerConf `yaml:"http_server"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
return err
}
apiConfig := i.runtime.NewConfig()
if apiConfig == nil {
return merrors.Internal("runtime config factory returned nil api config")
}
if raw.API.Kind == 0 {
return merrors.InvalidArgument("api configuration is required", "api")
}
if err := raw.API.Decode(apiConfig); err != nil {
return err
}
if raw.DB == nil || raw.HTTPServer == nil || apiConfig.SharedConfig() == nil || apiConfig.SharedConfig().Mw == nil {
return merrors.InvalidArgument("database, http_server and api.middleware configuration are required")
}
if raw.HTTPServer.ListenAddress == "" || raw.HTTPServer.ReadHeaderTimeout <= 0 || raw.HTTPServer.ShutdownTimeout <= 0 {
return merrors.InvalidArgument("HTTP address and positive timeouts are required")
}
i.config = &Config{
API: apiConfig,
DB: raw.DB,
HTTPServer: raw.HTTPServer,
}
return nil
}
func Create(logger mlogger.Logger, file string, debug bool, runtime ac.RuntimeConfig) (*Imp, error) {
lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background())
srv := &Imp{
lifecycleCtx: lifecycleCtx,
lifecycleCancel: lifecycleCancel,
logger: logger,
debug: debug,
file: file,
runtime: runtime,
}
return srv, nil
}
@@ -0,0 +1,22 @@
package server
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/product/app/api"
"go.uber.org/zap"
)
func TestShutdownCancelsLifecycleContextBeforeStartupCompletes(t *testing.T) {
instance, err := Create(zap.NewNop(), "config.yml", false, api.RuntimeConfig{})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
instance.Shutdown()
if !errors.Is(instance.lifecycleCtx.Err(), context.Canceled) {
t.Fatalf("lifecycle context error = %v, want context.Canceled", instance.lifecycleCtx.Err())
}
}
+12
View File
@@ -0,0 +1,12 @@
package server
import (
edgeapi "git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/process"
internalserver "git.sendico.io/tech/sxchnge/product/app/server/internal"
"git.sendico.io/tech/sxchnge/shared/logging"
)
func Create(logger mlogger.Logger, file string, debug bool, runtime edgeapi.RuntimeConfig) (server.Application, error) {
return internalserver.Create(logger, file, debug, runtime)
}
@@ -0,0 +1,15 @@
package domainprovider
import (
dimp "git.sendico.io/tech/sxchnge/product/app/urls/internal"
"git.sendico.io/tech/sxchnge/shared/logging"
)
type DomainProvider interface {
GetFullLink(linkElem ...string) (string, error)
GetAPILink(linkElem ...string) (string, error)
}
func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
}
@@ -0,0 +1,35 @@
package domainproviderimp
import (
"net/url"
"os"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap"
)
type DomainProviderImp struct {
domain string
apiEndpoint string
}
func (dp *DomainProviderImp) GetFullLink(linkElem ...string) (string, error) {
return url.JoinPath(dp.domain, linkElem...)
}
func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
return url.JoinPath(dp.apiEndpoint, linkElem...)
}
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPointEnv string) (*DomainProviderImp, error) {
p := new(DomainProviderImp)
p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain)
var err error
apiPath := os.Getenv(apiPointEnv)
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil {
logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
return nil, err
}
logger.Named("domain_provider").Info("Domain name provider is up", zap.String("domain", p.domain))
return p, nil
}
+27
View File
@@ -0,0 +1,27 @@
package appversion
import (
"git.sendico.io/tech/sxchnge/shared/version"
vf "git.sendico.io/tech/sxchnge/shared/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "SXCHNGE BFF",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}