// Package internalhttp provides the trusted internal HTTP listener used by the // runnable Mail Service process. package internalhttp import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net" "net/http" "sync" "time" "galaxy/mail/internal/telemetry" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) const ( // DeliveriesPath is the trusted operator list route reserved by the Stage 6 // runnable skeleton. DeliveriesPath = "/api/v1/internal/deliveries" // DeliveryByIDPath is the trusted operator get-delivery route reserved by // the Stage 6 runnable skeleton. DeliveryByIDPath = "/api/v1/internal/deliveries/{delivery_id}" // DeliveryAttemptsPath is the trusted operator list-attempts route reserved // by the Stage 6 runnable skeleton. DeliveryAttemptsPath = "/api/v1/internal/deliveries/{delivery_id}/attempts" // DeliveryResendPath is the trusted operator resend route reserved by the // Stage 6 runnable skeleton. DeliveryResendPath = "/api/v1/internal/deliveries/{delivery_id}/resend" ) // Config describes the trusted internal HTTP listener owned by Mail Service. type Config struct { // Addr is the TCP listen address used by the trusted internal HTTP server. Addr string // ReadHeaderTimeout bounds how long the listener may spend reading request // headers before the server rejects the connection. ReadHeaderTimeout time.Duration // ReadTimeout bounds how long the listener may spend reading one trusted // internal request. ReadTimeout time.Duration // IdleTimeout bounds how long the listener keeps an idle keep-alive // connection open. IdleTimeout time.Duration } // Validate reports whether cfg contains a usable internal HTTP listener // configuration. func (cfg Config) Validate() error { switch { case cfg.Addr == "": return errors.New("internal HTTP addr must not be empty") case cfg.ReadHeaderTimeout <= 0: return errors.New("internal HTTP read header timeout must be positive") case cfg.ReadTimeout <= 0: return errors.New("internal HTTP read timeout must be positive") case cfg.IdleTimeout <= 0: return errors.New("internal HTTP idle timeout must be positive") default: return nil } } // Dependencies describes the collaborators used by the trusted internal HTTP // transport layer. type Dependencies struct { // Logger writes structured transport logs. When nil, slog.Default is used. Logger *slog.Logger // Telemetry records low-cardinality transport and auth-delivery metrics. Telemetry *telemetry.Runtime // AcceptLoginCodeDelivery handles the dedicated auth-delivery route when // provided. AcceptLoginCodeDelivery AcceptLoginCodeDeliveryUseCase // ListDeliveries handles the trusted operator delivery-list route when // provided. ListDeliveries ListDeliveriesUseCase // GetDelivery handles the trusted operator exact delivery-read route when // provided. GetDelivery GetDeliveryUseCase // ListAttempts handles the trusted operator attempt-history route when // provided. ListAttempts ListAttemptsUseCase // ResendDelivery handles the trusted operator resend route when provided. ResendDelivery ResendDeliveryUseCase // OperatorRequestTimeout bounds one trusted operator use-case execution. OperatorRequestTimeout time.Duration } // Server owns the trusted internal HTTP listener exposed by Mail Service. type Server struct { cfg Config handler http.Handler logger *slog.Logger stateMu sync.RWMutex server *http.Server listener net.Listener } // NewServer constructs one trusted internal HTTP server for cfg and deps. func NewServer(cfg Config, deps Dependencies) (*Server, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("new internal HTTP server: %w", err) } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Server{ cfg: cfg, handler: newHandler(deps), logger: logger.With("component", "internal_http"), }, nil } // Run binds the configured listener and serves the trusted internal HTTP // surface until Shutdown closes the server. func (server *Server) Run(ctx context.Context) error { if ctx == nil { return errors.New("run internal HTTP server: nil context") } if err := ctx.Err(); err != nil { return err } listener, err := net.Listen("tcp", server.cfg.Addr) if err != nil { return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err) } httpServer := &http.Server{ Handler: server.handler, ReadHeaderTimeout: server.cfg.ReadHeaderTimeout, ReadTimeout: server.cfg.ReadTimeout, IdleTimeout: server.cfg.IdleTimeout, } server.stateMu.Lock() server.server = httpServer server.listener = listener server.stateMu.Unlock() server.logger.Info("internal HTTP server started", "addr", listener.Addr().String()) defer func() { server.stateMu.Lock() server.server = nil server.listener = nil server.stateMu.Unlock() }() err = httpServer.Serve(listener) switch { case err == nil: return nil case errors.Is(err, http.ErrServerClosed): server.logger.Info("internal HTTP server stopped") return nil default: return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err) } } // Shutdown gracefully stops the trusted internal HTTP server within ctx. func (server *Server) Shutdown(ctx context.Context) error { if ctx == nil { return errors.New("shutdown internal HTTP server: nil context") } server.stateMu.RLock() httpServer := server.server server.stateMu.RUnlock() if httpServer == nil { return nil } if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("shutdown internal HTTP server: %w", err) } return nil } func newHandler(deps Dependencies) http.Handler { logger := deps.Logger if logger == nil { logger = slog.Default() } mux := http.NewServeMux() loginCodeHandler := http.HandlerFunc(serviceUnavailableHandler) if deps.AcceptLoginCodeDelivery != nil { loginCodeHandler = newAcceptLoginCodeDeliveryHandler(deps.AcceptLoginCodeDelivery) } listDeliveriesHandler := http.HandlerFunc(serviceUnavailableHandler) if deps.ListDeliveries != nil { listDeliveriesHandler = newListDeliveriesHandler(deps.ListDeliveries, deps.OperatorRequestTimeout) } getDeliveryHandler := http.HandlerFunc(serviceUnavailableHandler) if deps.GetDelivery != nil { getDeliveryHandler = newGetDeliveryHandler(deps.GetDelivery, deps.OperatorRequestTimeout) } listAttemptsHandler := http.HandlerFunc(serviceUnavailableHandler) if deps.ListAttempts != nil { listAttemptsHandler = newListAttemptsHandler(deps.ListAttempts, deps.OperatorRequestTimeout) } resendDeliveryHandler := http.HandlerFunc(serviceUnavailableHandler) if deps.ResendDelivery != nil { resendDeliveryHandler = newResendDeliveryHandler(deps.ResendDelivery, deps.OperatorRequestTimeout) } mux.Handle("POST "+LoginCodeDeliveriesPath, wrapObservedRoute(LoginCodeDeliveriesPath, logger, deps.Telemetry, loginCodeHandler)) mux.Handle("GET "+DeliveriesPath, wrapObservedRoute(DeliveriesPath, logger, deps.Telemetry, listDeliveriesHandler)) mux.Handle("GET "+DeliveryByIDPath, wrapObservedRoute(DeliveryByIDPath, logger, deps.Telemetry, getDeliveryHandler)) mux.Handle("GET "+DeliveryAttemptsPath, wrapObservedRoute(DeliveryAttemptsPath, logger, deps.Telemetry, listAttemptsHandler)) mux.Handle("POST "+DeliveryResendPath, wrapObservedRoute(DeliveryResendPath, logger, deps.Telemetry, resendDeliveryHandler)) return mux } func wrapObservedRoute(route string, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, next http.Handler) http.Handler { handler := instrumentRoute(route, logger, telemetryRuntime, next) options := []otelhttp.Option{} if telemetryRuntime != nil { options = append(options, otelhttp.WithTracerProvider(telemetryRuntime.TracerProvider()), otelhttp.WithMeterProvider(telemetryRuntime.MeterProvider()), ) } return otelhttp.NewHandler(handler, route, options...) } func serviceUnavailableHandler(writer http.ResponseWriter, request *http.Request) { _ = request writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable") } func writeErrorResponse(writer http.ResponseWriter, statusCode int, code string, message string) { if recorder, ok := writer.(*observedResponseWriter); ok { recorder.SetErrorCode(code) } payload := ErrorResponse{ Error: ErrorBody{ Code: code, Message: message, }, } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(statusCode) _ = json.NewEncoder(writer).Encode(payload) }