Feat: Backend scaffolding and local dev stack
This commit is contained in:
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.")
|
||||
}
|
||||
Reference in New Issue
Block a user