Feat: Backend scaffolding and local dev stack
This commit is contained in:
85
Backend/internal/bootstrap/bootstrap.go
Normal file
85
Backend/internal/bootstrap/bootstrap.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Path: Backend/internal/bootstrap/bootstrap.go
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"moku-backend/internal/buildinfo"
|
||||
"moku-backend/internal/cache"
|
||||
"moku-backend/internal/config"
|
||||
"moku-backend/internal/database"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
ServiceName string
|
||||
Config *config.Config
|
||||
Logger *slog.Logger
|
||||
BuildInfo buildinfo.Info
|
||||
Database *database.DB
|
||||
Cache *cache.Client
|
||||
}
|
||||
|
||||
func New(serviceName string) (*App, error) {
|
||||
cfg := config.Load()
|
||||
logger := newLogger(cfg)
|
||||
|
||||
db, err := database.NewPostgres(cfg.PostgresURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
valkey, err := cache.NewValkey(cfg.ValkeyURL)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &App{
|
||||
ServiceName: serviceName,
|
||||
Config: cfg,
|
||||
Logger: logger.With("service", serviceName),
|
||||
BuildInfo: buildinfo.Current(),
|
||||
Database: db,
|
||||
Cache: valkey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
var closeErr error
|
||||
|
||||
if a.Cache != nil {
|
||||
if err := a.Cache.Close(); err != nil {
|
||||
closeErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if a.Database != nil {
|
||||
a.Database.Close()
|
||||
}
|
||||
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func newLogger(cfg *config.Config) *slog.Logger {
|
||||
level := slog.LevelInfo
|
||||
|
||||
switch strings.ToLower(cfg.LogLevel) {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
|
||||
options := &slog.HandlerOptions{Level: level}
|
||||
|
||||
if cfg.IsDevelopment() {
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, options))
|
||||
}
|
||||
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, options))
|
||||
}
|
||||
23
Backend/internal/buildinfo/buildinfo.go
Normal file
23
Backend/internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Path: Backend/internal/buildinfo/buildinfo.go
|
||||
|
||||
package buildinfo
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
BuiltAt = "unknown"
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuiltAt string `json:"builtAt"`
|
||||
}
|
||||
|
||||
func Current() Info {
|
||||
return Info{
|
||||
Version: Version,
|
||||
Commit: Commit,
|
||||
BuiltAt: BuiltAt,
|
||||
}
|
||||
}
|
||||
40
Backend/internal/cache/valkey.go
vendored
Normal file
40
Backend/internal/cache/valkey.go
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
// Path: Backend/internal/cache/valkey.go
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Redis *redis.Client
|
||||
}
|
||||
|
||||
func NewValkey(valkeyURL string) (*Client, error) {
|
||||
options, err := redis.ParseURL(valkeyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := redis.NewClient(options)
|
||||
|
||||
return &Client{Redis: client}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Health(ctx context.Context) error {
|
||||
if c == nil || c.Redis == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.Redis.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
if c == nil || c.Redis == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.Redis.Close()
|
||||
}
|
||||
79
Backend/internal/config/config.go
Normal file
79
Backend/internal/config/config.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Path: Backend/internal/config/config.go
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppName string
|
||||
Environment string
|
||||
LogLevel string
|
||||
WebPort string
|
||||
APIPort string
|
||||
WorkerPort string
|
||||
PostgresURL string
|
||||
ValkeyURL string
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
AppName: getEnv("APP_NAME", "moku"),
|
||||
Environment: getEnv("GO_ENV", "development"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "debug"),
|
||||
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
|
||||
APIPort: getEnv("BACKEND_API_PORT", "8081"),
|
||||
WorkerPort: getEnv("BACKEND_WORKER_PORT", "8082"),
|
||||
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
|
||||
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
|
||||
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Address(serviceName string) string {
|
||||
var port string
|
||||
|
||||
switch strings.ToLower(serviceName) {
|
||||
case "web":
|
||||
port = c.WebPort
|
||||
case "api":
|
||||
port = c.APIPort
|
||||
case "worker":
|
||||
port = c.WorkerPort
|
||||
default:
|
||||
port = c.WebPort
|
||||
}
|
||||
|
||||
return fmt.Sprintf(":%s", port)
|
||||
}
|
||||
|
||||
func (c *Config) IsDevelopment() bool {
|
||||
return strings.EqualFold(c.Environment, "development")
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getDurationEnv(key string, fallback time.Duration) time.Duration {
|
||||
value, exists := os.LookupEnv(key)
|
||||
if !exists {
|
||||
return fallback
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
95
Backend/internal/database/postgres.go
Normal file
95
Backend/internal/database/postgres.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Path: Backend/internal/database/postgres.go
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"moku-backend/db"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPostgres(databaseURL string) (*DB, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.MaxConns = 25
|
||||
config.MinConns = 5
|
||||
config.MaxConnLifetime = time.Hour
|
||||
config.MaxConnIdleTime = 30 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DB{Pool: pool}, nil
|
||||
}
|
||||
|
||||
func (d *DB) MigrateUp() error {
|
||||
return d.runGoose(goose.Up)
|
||||
}
|
||||
|
||||
func (d *DB) MigrateDown() error {
|
||||
return d.runGoose(goose.Down)
|
||||
}
|
||||
|
||||
func (d *DB) MigrateReset() error {
|
||||
return d.runGoose(goose.Reset)
|
||||
}
|
||||
|
||||
func (d *DB) MigrateStatus() error {
|
||||
return d.runGoose(goose.Status)
|
||||
}
|
||||
|
||||
func (d *DB) runGoose(command func(*sql.DB, string, ...goose.OptionsFunc) error) error {
|
||||
if d == nil || d.Pool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sqlDB := stdlib.OpenDBFromPool(d.Pool)
|
||||
defer sqlDB.Close()
|
||||
|
||||
goose.SetBaseFS(db.Migrations)
|
||||
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return command(sqlDB, "migrations")
|
||||
}
|
||||
|
||||
func (d *DB) Health(ctx context.Context) error {
|
||||
if d == nil || d.Pool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.Pool.Ping(ctx)
|
||||
}
|
||||
|
||||
func (d *DB) Close() {
|
||||
if d == nil || d.Pool == nil {
|
||||
return
|
||||
}
|
||||
|
||||
d.Pool.Close()
|
||||
}
|
||||
53
Backend/internal/httpx/api_routes.go
Normal file
53
Backend/internal/httpx/api_routes.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Path: Backend/internal/httpx/api_routes.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type apiRoutes struct {
|
||||
cfg RouterConfig
|
||||
}
|
||||
|
||||
func newAPIRoutes(cfg RouterConfig) routeRegistrar {
|
||||
return apiRoutes{cfg: cfg}
|
||||
}
|
||||
|
||||
func (routes apiRoutes) Register(router chi.Router) {
|
||||
router.Route("/v1", func(apiRouter chi.Router) {
|
||||
apiRouter.Get("/", routes.handleIndex)
|
||||
apiRouter.Get("/organizations", routes.handleOrganizations)
|
||||
apiRouter.Get("/workspaces", routes.handleWorkspaces)
|
||||
})
|
||||
}
|
||||
|
||||
func (routes apiRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"version": "v1",
|
||||
"status": "scaffolded",
|
||||
})
|
||||
}
|
||||
|
||||
func (routes apiRoutes) handleOrganizations(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"data": []any{},
|
||||
"meta": map[string]any{
|
||||
"resource": "organizations",
|
||||
"count": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes apiRoutes) handleWorkspaces(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"data": []any{},
|
||||
"meta": map[string]any{
|
||||
"resource": "workspaces",
|
||||
"count": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
80
Backend/internal/httpx/middleware.go
Normal file
80
Backend/internal/httpx/middleware.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Path: Backend/internal/httpx/middleware.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const requestIDContextKey contextKey = "requestID"
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(statusCode int) {
|
||||
r.statusCode = statusCode
|
||||
r.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := uuid.NewString()
|
||||
ctx := context.WithValue(r.Context(), requestIDContextKey, requestID)
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
requestID := RequestIDFromContext(r.Context())
|
||||
logger.Error("panic recovered", "request_id", requestID, "panic", recovered, "path", r.URL.Path)
|
||||
WriteError(w, http.StatusInternalServerError, requestID, "internal_error", "An unexpected error occurred.")
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(recorder, r)
|
||||
|
||||
logger.Info(
|
||||
"http request",
|
||||
"request_id", RequestIDFromContext(r.Context()),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", recorder.statusCode,
|
||||
"duration", time.Since(startedAt).String(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequestIDFromContext(ctx context.Context) string {
|
||||
requestID, ok := ctx.Value(requestIDContextKey).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return requestID
|
||||
}
|
||||
33
Backend/internal/httpx/response.go
Normal file
33
Backend/internal/httpx/response.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Path: Backend/internal/httpx/response.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if payload == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, status int, requestID, code, message string) {
|
||||
WriteJSON(w, status, ErrorResponse{
|
||||
Error: code,
|
||||
Message: message,
|
||||
RequestID: requestID,
|
||||
})
|
||||
}
|
||||
70
Backend/internal/httpx/router.go
Normal file
70
Backend/internal/httpx/router.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Path: Backend/internal/httpx/router.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"moku-backend/internal/buildinfo"
|
||||
"moku-backend/internal/cache"
|
||||
"moku-backend/internal/config"
|
||||
"moku-backend/internal/database"
|
||||
)
|
||||
|
||||
type RouterConfig struct {
|
||||
ServiceName string
|
||||
Config *config.Config
|
||||
Logger *slog.Logger
|
||||
BuildInfo buildinfo.Info
|
||||
Database *database.DB
|
||||
Cache *cache.Client
|
||||
}
|
||||
|
||||
type routeRegistrar interface {
|
||||
Register(chi.Router)
|
||||
}
|
||||
|
||||
func NewRouter(cfg RouterConfig) http.Handler {
|
||||
router := chi.NewRouter()
|
||||
|
||||
registerMiddleware(router, cfg)
|
||||
registerRoutes(router, routesForService(cfg)...)
|
||||
router.NotFound(notFoundHandler)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func registerMiddleware(router chi.Router, cfg RouterConfig) {
|
||||
router.Use(chimiddleware.RealIP)
|
||||
router.Use(RequestID)
|
||||
router.Use(Recoverer(cfg.Logger))
|
||||
router.Use(RequestLogger(cfg.Logger))
|
||||
}
|
||||
|
||||
func registerRoutes(router chi.Router, registrars ...routeRegistrar) {
|
||||
for _, registrar := range registrars {
|
||||
registrar.Register(router)
|
||||
}
|
||||
}
|
||||
|
||||
func routesForService(cfg RouterConfig) []routeRegistrar {
|
||||
registrars := []routeRegistrar{newSharedRoutes(cfg)}
|
||||
|
||||
switch strings.ToLower(cfg.ServiceName) {
|
||||
case "web":
|
||||
registrars = append(registrars, newWebRoutes(cfg))
|
||||
case "api":
|
||||
registrars = append(registrars, newAPIRoutes(cfg))
|
||||
}
|
||||
|
||||
return registrars
|
||||
}
|
||||
|
||||
func notFoundHandler(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.")
|
||||
}
|
||||
69
Backend/internal/httpx/shared_routes.go
Normal file
69
Backend/internal/httpx/shared_routes.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Path: Backend/internal/httpx/shared_routes.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type sharedRoutes struct {
|
||||
cfg RouterConfig
|
||||
}
|
||||
|
||||
func newSharedRoutes(cfg RouterConfig) routeRegistrar {
|
||||
return sharedRoutes{cfg: cfg}
|
||||
}
|
||||
|
||||
func (routes sharedRoutes) Register(router chi.Router) {
|
||||
router.Get("/", routes.handleIndex)
|
||||
router.Get("/health", routes.handleHealth)
|
||||
router.Get("/version", routes.handleVersion)
|
||||
}
|
||||
|
||||
func (routes sharedRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func (routes sharedRoutes) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
databaseStatus := "ok"
|
||||
if err := routes.cfg.Database.Health(ctx); err != nil {
|
||||
databaseStatus = err.Error()
|
||||
}
|
||||
|
||||
cacheStatus := "ok"
|
||||
if err := routes.cfg.Cache.Health(ctx); err != nil {
|
||||
cacheStatus = err.Error()
|
||||
}
|
||||
|
||||
statusCode := http.StatusOK
|
||||
if databaseStatus != "ok" || cacheStatus != "ok" {
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
WriteJSON(w, statusCode, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"status": map[string]string{
|
||||
"database": databaseStatus,
|
||||
"cache": cacheStatus,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes sharedRoutes) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"app": routes.cfg.Config.AppName,
|
||||
"environment": routes.cfg.Config.Environment,
|
||||
"build": routes.cfg.BuildInfo,
|
||||
})
|
||||
}
|
||||
51
Backend/internal/httpx/web_routes.go
Normal file
51
Backend/internal/httpx/web_routes.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Path: Backend/internal/httpx/web_routes.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type webRoutes struct {
|
||||
cfg RouterConfig
|
||||
}
|
||||
|
||||
func newWebRoutes(cfg RouterConfig) routeRegistrar {
|
||||
return webRoutes{cfg: cfg}
|
||||
}
|
||||
|
||||
func (routes webRoutes) Register(router chi.Router) {
|
||||
router.Get("/session", routes.handleSession)
|
||||
router.Get("/bootstrap", routes.handleBootstrap)
|
||||
router.Get("/me", routes.handleCurrentUser)
|
||||
}
|
||||
|
||||
func (routes webRoutes) handleSession(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"session": map[string]any{
|
||||
"authenticated": false,
|
||||
"mode": "cookie",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes webRoutes) handleBootstrap(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"app": map[string]any{
|
||||
"name": routes.cfg.Config.AppName,
|
||||
"environment": routes.cfg.Config.Environment,
|
||||
},
|
||||
"features": map[string]bool{
|
||||
"auth": false,
|
||||
"workspaces": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes webRoutes) handleCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusNotImplemented, RequestIDFromContext(r.Context()), "not_implemented", "The current user endpoint is scaffolded but not implemented yet.")
|
||||
}
|
||||
65
Backend/internal/process/process.go
Normal file
65
Backend/internal/process/process.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Path: Backend/internal/process/process.go
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RunHTTPServer(serviceName, address string, handler http.Handler, logger *slog.Logger, shutdownTimeout time.Duration) error {
|
||||
server := &http.Server{
|
||||
Addr: address,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
logger.Info("http server starting", "service", serviceName, "address", address)
|
||||
serverErr <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case <-signalCtx.Done():
|
||||
logger.Info("shutdown requested", "service", serviceName)
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown %s server: %w", serviceName, err)
|
||||
}
|
||||
|
||||
logger.Info("http server stopped", "service", serviceName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WaitForShutdown(serviceName string, logger *slog.Logger) error {
|
||||
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger.Info("process running", "service", serviceName)
|
||||
<-signalCtx.Done()
|
||||
logger.Info("shutdown requested", "service", serviceName)
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user