212 lines
6.6 KiB
Go
212 lines
6.6 KiB
Go
// Package app assembles the application and owns process-level lifecycles.
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"fire-safety-ymd/internal/config"
|
|
"fire-safety-ymd/internal/domain"
|
|
"fire-safety-ymd/internal/handler"
|
|
"fire-safety-ymd/internal/integration/superagent"
|
|
"fire-safety-ymd/internal/repository"
|
|
"fire-safety-ymd/internal/service"
|
|
)
|
|
|
|
const (
|
|
readHeaderTimeout = 5 * time.Second
|
|
readTimeout = 15 * time.Second
|
|
writeTimeout = 35 * time.Second
|
|
idleTimeout = 60 * time.Second
|
|
shutdownTimeout = 10 * time.Second
|
|
readinessTimeout = 30 * time.Second
|
|
)
|
|
|
|
// Application owns the HTTP server and coordinates graceful shutdown.
|
|
type Application struct {
|
|
server *http.Server
|
|
close func()
|
|
}
|
|
|
|
// New wires the application's current dependencies.
|
|
func New(ctx context.Context, cfg config.Config) (*Application, error) {
|
|
routerOptions := handler.RouterOptions{}
|
|
closeDependencies := func() {}
|
|
|
|
if cfg.Chat.PageEnabled {
|
|
if !cfg.Chat.Enabled {
|
|
return nil, fmt.Errorf("initialize chat page: chat must be enabled")
|
|
}
|
|
if cfg.Chat.CompatAppID == "" {
|
|
return nil, fmt.Errorf("initialize chat page: compatibility app ID is required")
|
|
}
|
|
if len(cfg.Chat.AllowedOrigins) == 0 {
|
|
return nil, fmt.Errorf("initialize chat page: at least one allowed origin is required")
|
|
}
|
|
}
|
|
|
|
if cfg.Chat.Enabled {
|
|
if !cfg.SuperAgent.Enabled {
|
|
return nil, fmt.Errorf("initialize chat: SuperAgent must be enabled")
|
|
}
|
|
if cfg.Chat.AllowLegacyShortToken {
|
|
log.Printf("security warning: legacy short chat auth token compatibility is enabled")
|
|
}
|
|
superAgentClient, err := superagent.NewHTTPClient(superagent.Config{
|
|
Enabled: cfg.SuperAgent.Enabled,
|
|
BaseURL: cfg.SuperAgent.BaseURL,
|
|
APIKey: cfg.SuperAgent.OpenAPIKey,
|
|
IncludeTrace: cfg.SuperAgent.IncludeTrace,
|
|
ConnectTimeout: cfg.SuperAgent.ConnectTimeout,
|
|
RecoveryMaxAttempts: cfg.SuperAgent.RecoveryMaxAttempts,
|
|
RecoveryInitialBackoff: cfg.SuperAgent.RecoveryInitialBackoff,
|
|
MaxMessageBytes: cfg.SuperAgent.MaxMessageBytes,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("initialize SuperAgent client: %w", err)
|
|
}
|
|
chatAgent, err := superagent.NewChatAgentAdapter(superAgentClient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("initialize SuperAgent chat adapter: %w", err)
|
|
}
|
|
chatService, err := service.NewChatService(chatAgent, service.ChatOptions{
|
|
ExternalSubjectID: cfg.Chat.SubjectID,
|
|
MaxMessageBytes: cfg.SuperAgent.MaxMessageBytes,
|
|
SessionTTL: cfg.Chat.SessionTTL,
|
|
MaxSessions: cfg.Chat.MaxSessions,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("initialize chat service: %w", err)
|
|
}
|
|
chatHandler, err := handler.NewChatHandler(chatService, handler.ChatOptions{
|
|
AuthToken: cfg.Chat.AuthToken,
|
|
AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken,
|
|
AllowedOrigins: cfg.Chat.AllowedOrigins,
|
|
MaxBodyBytes: cfg.Chat.MaxBodyBytes,
|
|
RunTimeout: cfg.Chat.RunTimeout,
|
|
Logger: log.Default(),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("initialize chat handler: %w", err)
|
|
}
|
|
routerOptions.Chat = chatHandler
|
|
if cfg.Chat.CompatAppID != "" {
|
|
dashScopeChatHandler, err := handler.NewDashScopeChatHandler(chatService, handler.DashScopeChatOptions{
|
|
AppID: cfg.Chat.CompatAppID,
|
|
AuthToken: cfg.Chat.AuthToken,
|
|
AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken,
|
|
AllowedOrigins: cfg.Chat.AllowedOrigins,
|
|
MaxBodyBytes: cfg.Chat.MaxBodyBytes,
|
|
RunTimeout: cfg.Chat.RunTimeout,
|
|
Logger: log.Default(),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("initialize DashScope-compatible chat handler: %w", err)
|
|
}
|
|
routerOptions.DashScopeChat = dashScopeChatHandler
|
|
}
|
|
if cfg.Chat.PageEnabled {
|
|
chatPageHandler, err := handler.NewChatPageHandler(handler.ChatPageOptions{
|
|
AppID: cfg.Chat.CompatAppID,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("initialize chat page handler: %w", err)
|
|
}
|
|
routerOptions.ChatPage = chatPageHandler
|
|
}
|
|
}
|
|
|
|
if cfg.MCP.Enabled {
|
|
log.Printf("MCP data scope mode=%s", cfg.MCP.ScopeMode)
|
|
postGIS, err := repository.OpenPostGIS(ctx, repository.PostGISOptions{
|
|
DSN: cfg.PostGIS.DSN,
|
|
MaxConns: cfg.PostGIS.MaxConns,
|
|
ConnectTimeout: cfg.PostGIS.ConnectTimeout,
|
|
QueryTimeout: cfg.PostGIS.QueryTimeout,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("initialize PostGIS: %w", err)
|
|
}
|
|
closeDependencies = postGIS.Close
|
|
|
|
readinessCtx, cancel := context.WithTimeout(ctx, readinessTimeout)
|
|
report, err := postGIS.ValidateForMCP(readinessCtx, cfg.PostGIS.ExpectedSRID)
|
|
cancel()
|
|
if err != nil {
|
|
postGIS.Close()
|
|
return nil, fmt.Errorf("validate PostGIS for MCP: %w", err)
|
|
}
|
|
for _, warning := range report.Warnings {
|
|
log.Printf("PostGIS readiness warning=%s", warning)
|
|
}
|
|
|
|
spatialService, err := service.NewSpatialService(postGIS, domain.SpatialScope{
|
|
AllTowns: cfg.MCP.ScopeMode == config.MCPScopeModeAll,
|
|
AllowedTowns: cfg.MCP.AllowedTowns,
|
|
}, cfg.PostGIS.QueryTimeout)
|
|
if err != nil {
|
|
postGIS.Close()
|
|
return nil, fmt.Errorf("initialize spatial service: %w", err)
|
|
}
|
|
mcpHandler, err := handler.NewMCPHandler(spatialService, handler.MCPOptions{
|
|
AuthToken: cfg.MCP.AuthToken,
|
|
MaxBodyBytes: cfg.MCP.MaxBodyBytes,
|
|
ToolTimeout: cfg.MCP.ToolTimeout,
|
|
Logger: log.Default(),
|
|
})
|
|
if err != nil {
|
|
postGIS.Close()
|
|
return nil, fmt.Errorf("initialize MCP handler: %w", err)
|
|
}
|
|
routerOptions.MCP = mcpHandler
|
|
}
|
|
|
|
return &Application{
|
|
server: &http.Server{
|
|
Addr: cfg.HTTPAddress,
|
|
Handler: handler.NewRouter(routerOptions),
|
|
ReadHeaderTimeout: readHeaderTimeout,
|
|
ReadTimeout: readTimeout,
|
|
WriteTimeout: writeTimeout,
|
|
IdleTimeout: idleTimeout,
|
|
},
|
|
close: closeDependencies,
|
|
}, nil
|
|
}
|
|
|
|
// Run starts the HTTP server and shuts it down when ctx is canceled.
|
|
func (a *Application) Run(ctx context.Context) error {
|
|
defer a.close()
|
|
|
|
serverErr := make(chan error, 1)
|
|
go func() {
|
|
serverErr <- a.server.ListenAndServe()
|
|
}()
|
|
|
|
select {
|
|
case err := <-serverErr:
|
|
return normalizeServerError(err)
|
|
case <-ctx.Done():
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
defer cancel()
|
|
|
|
if err := a.server.Shutdown(shutdownCtx); err != nil {
|
|
return fmt.Errorf("shut down HTTP server: %w", err)
|
|
}
|
|
|
|
return normalizeServerError(<-serverErr)
|
|
}
|
|
}
|
|
|
|
func normalizeServerError(err error) error {
|
|
if err == nil || errors.Is(err, http.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("serve HTTP: %w", err)
|
|
}
|