From 65c47b720ba344627e27dd17a2b7aefe04ce9d1c Mon Sep 17 00:00:00 2001 From: Artem Sukhodolskyi Date: Mon, 20 Oct 2025 21:18:09 +0200 Subject: [PATCH] initial commit --- go.mod | 3 + main.go | 387 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 go.mod create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6dff292 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/artemvang/finger_rain + +go 1.23.3 diff --git a/main.go b/main.go new file mode 100644 index 0000000..d818cbb --- /dev/null +++ b/main.go @@ -0,0 +1,387 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +const ( + defaultPort = "79" + weatherAPIURL = "http://api.worldweatheronline.com/premium/v1/weather.ashx" + readTimeout = 30 * time.Second + writeTimeout = 30 * time.Second + httpTimeout = 15 * time.Second + forecastDays = 3 + hourlyInterval = 6 + separatorLength = 60 +) + +// Config holds server configuration +type Config struct { + Port string + APIKey string +} + +// Server represents the weather finger server +type Server struct { + config *Config + client *http.Client + logger *log.Logger + shutdown chan struct{} + wg sync.WaitGroup +} + +// Weather API response structures +type WeatherResponse struct { + Data WeatherData `json:"data"` + Error []WeatherError `json:"error,omitempty"` +} + +type WeatherData struct { + Request []RequestInfo `json:"request"` + NearestArea []NearestArea `json:"nearest_area"` + CurrentCondition []CurrentCondition `json:"current_condition"` + Weather []DailyWeather `json:"weather"` +} + +type RequestInfo struct { + Query string `json:"query"` + Type string `json:"type"` +} + +type NearestArea struct { + AreaName []NameValue `json:"areaName"` + Country []NameValue `json:"country"` +} + +type NameValue struct { + Value string `json:"value"` +} + +type CurrentCondition struct { + TempC string `json:"temp_C"` + TempF string `json:"temp_F"` + WeatherDesc []NameValue `json:"weatherDesc"` + Humidity string `json:"humidity"` + Visibility string `json:"visibility"` + Pressure string `json:"pressure"` + CloudCover string `json:"cloudcover"` + FeelsLikeC string `json:"FeelsLikeC"` + FeelsLikeF string `json:"FeelsLikeF"` + WindSpeedKmph string `json:"windspeedKmph"` + WindSpeedMiles string `json:"windspeedMiles"` + WindDir16Point string `json:"winddir16Point"` + ObservationTime string `json:"observation_time"` +} + +type DailyWeather struct { + Date string `json:"date"` + MaxTempC string `json:"maxtempC"` + MaxTempF string `json:"maxtempF"` + MinTempC string `json:"mintempC"` + MinTempF string `json:"mintempF"` + Hourly []HourlyWeather `json:"hourly"` +} + +type HourlyWeather struct { + Time string `json:"time"` + TempC string `json:"tempC"` + TempF string `json:"tempF"` + WeatherDesc []NameValue `json:"weatherDesc"` + ChanceOfRain string `json:"chanceofrain"` + Humidity string `json:"humidity"` +} + +type WeatherError struct { + Msg string `json:"msg"` +} + +func main() { + config := &Config{ + Port: getEnvOrDefault("WEATHER_PORT", defaultPort), + APIKey: os.Getenv("WEATHER_API_KEY"), + } + + if config.APIKey == "" { + log.Fatal("WEATHER_API_KEY environment variable is not set") + } + + server := NewServer(config) + if err := server.Start(); err != nil { + log.Fatalf("Server failed to start: %v", err) + } +} + +// NewServer creates a new weather finger server instance +func NewServer(config *Config) *Server { + return &Server{ + config: config, + client: &http.Client{ + Timeout: httpTimeout, + }, + logger: log.New(os.Stdout, "[WeatherServer] ", log.LstdFlags), + shutdown: make(chan struct{}), + } +} + +// Start begins listening for connections +func (s *Server) Start() error { + listener, err := net.Listen("tcp", ":"+s.config.Port) + if err != nil { + return fmt.Errorf("failed to start listener: %w", err) + } + defer listener.Close() + + s.logger.Printf("Weather Finger Server started on port %s", s.config.Port) + + for { + select { + case <-s.shutdown: + s.logger.Println("Shutting down server...") + s.wg.Wait() + return nil + default: + conn, err := listener.Accept() + if err != nil { + s.logger.Printf("Failed to accept connection: %v", err) + continue + } + + s.wg.Add(1) + go s.handleConnection(conn) + } + } +} + +// Stop gracefully shuts down the server +func (s *Server) Stop() { + close(s.shutdown) +} + +// handleConnection processes a single client connection +func (s *Server) handleConnection(conn net.Conn) { + defer s.wg.Done() + defer conn.Close() + + if err := s.processRequest(conn); err != nil { + s.logger.Printf("Error processing request: %v", err) + fmt.Fprintf(conn, "Error: %v\n", err) + } +} + +// processRequest reads the query and writes the weather response +func (s *Server) processRequest(conn net.Conn) error { + // Set timeouts + conn.SetReadDeadline(time.Now().Add(readTimeout)) + conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + + // Read request + reader := bufio.NewReader(conn) + request, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + return fmt.Errorf("client disconnected") + } + return fmt.Errorf("failed to read request: %w", err) + } + + query := strings.TrimSpace(request) + if query == "" { + return fmt.Errorf("empty query") + } + + clientAddr := conn.RemoteAddr().String() + s.logger.Printf("Request from %s: '%s'", clientAddr, query) + + // Get weather data + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + forecast, err := s.getWeatherForecast(ctx, query) + if err != nil { + return fmt.Errorf("failed to get weather: %w", err) + } + + // Format and send response + response := formatWeatherResponse(forecast) + _, err = conn.Write([]byte(response)) + return err +} + +// getWeatherForecast fetches weather data from the API +func (s *Server) getWeatherForecast(ctx context.Context, query string) (*WeatherResponse, error) { + params := url.Values{ + "key": {s.config.APIKey}, + "q": {query}, + "format": {"json"}, + "num_of_days": {fmt.Sprintf("%d", forecastDays)}, + "tp": {fmt.Sprintf("%d", hourlyInterval)}, + "includelocation": {"yes"}, + } + + apiURL := fmt.Sprintf("%s?%s", weatherAPIURL, params.Encode()) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var weatherData WeatherResponse + if err := json.Unmarshal(body, &weatherData); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if len(weatherData.Error) > 0 { + return nil, fmt.Errorf("API error: %s", weatherData.Error[0].Msg) + } + + return &weatherData, nil +} + +// formatWeatherResponse creates a human-readable weather report +func formatWeatherResponse(response *WeatherResponse) string { + var sb strings.Builder + data := &response.Data + + // Write header + writeHeader(&sb, data) + + // Write current conditions + writeCurrentConditions(&sb, data) + + // Write forecast + writeForecast(&sb, data) + + return sb.String() +} + +// writeHeader writes the location header +func writeHeader(sb *strings.Builder, data *WeatherData) { + if len(data.NearestArea) > 0 { + area := data.NearestArea[0] + areaName := getFirstValue(area.AreaName) + countryName := getFirstValue(area.Country) + + sb.WriteString(fmt.Sprintf("Weather forecast for: %s, %s\n", areaName, countryName)) + } else { + sb.WriteString("Weather forecast\n") + } + sb.WriteString(strings.Repeat("=", separatorLength)) + sb.WriteString("\n") +} + +// writeCurrentConditions writes the current weather conditions +func writeCurrentConditions(sb *strings.Builder, data *WeatherData) { + if len(data.CurrentCondition) == 0 { + sb.WriteString("CURRENT CONDITIONS: Not available\n\n") + return + } + + current := data.CurrentCondition[0] + desc := getFirstValue(current.WeatherDesc) + + sb.WriteString("CURRENT CONDITIONS:\n") + fmt.Fprintf(sb, " Temperature: %s°C (%s°F) - feels like %s°C (%s°F)\n", + current.TempC, current.TempF, current.FeelsLikeC, current.FeelsLikeF) + fmt.Fprintf(sb, " Conditions: %s\n", desc) + fmt.Fprintf(sb, " Humidity: %s%%\n", current.Humidity) + fmt.Fprintf(sb, " Wind: %s km/h %s (%s mph)\n", + current.WindSpeedKmph, current.WindDir16Point, current.WindSpeedMiles) + fmt.Fprintf(sb, " Pressure: %s mb\n", current.Pressure) + fmt.Fprintf(sb, " Visibility: %s km\n", current.Visibility) + fmt.Fprintf(sb, " Cloud Cover: %s%%\n", current.CloudCover) + fmt.Fprintf(sb, " Observed: %s\n", current.ObservationTime) + sb.WriteString("\n") +} + +// writeForecast writes the daily forecast +func writeForecast(sb *strings.Builder, data *WeatherData) { + if len(data.Weather) == 0 { + sb.WriteString("FORECAST: Not available\n") + return + } + + fmt.Fprintf(sb, "%d-DAY FORECAST:\n", len(data.Weather)) + + for _, day := range data.Weather { + dateStr := formatDate(day.Date) + + fmt.Fprintf(sb, " %s:\n", dateStr) + fmt.Fprintf(sb, " High: %s°C (%s°F) Low: %s°C (%s°F)\n", + day.MaxTempC, day.MaxTempF, day.MinTempC, day.MinTempF) + + if len(day.Hourly) > 0 { + sb.WriteString(" Hourly:\n") + for _, hour := range day.Hourly { + timeStr := formatTime(hour.Time) + desc := getFirstValue(hour.WeatherDesc) + + fmt.Fprintf(sb, " %s: %s°C (%s°F) %s (rain: %s%%, humidity: %s%%)\n", + timeStr, hour.TempC, hour.TempF, desc, hour.ChanceOfRain, hour.Humidity) + } + } + } +} + +// Helper functions + +// getFirstValue safely extracts the first value from a NameValue slice +func getFirstValue(values []NameValue) string { + if len(values) > 0 { + return values[0].Value + } + return "N/A" +} + +// formatDate formats a date string for display +func formatDate(dateStr string) string { + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return dateStr + } + return date.Format("Mon, Jan 2") +} + +// formatTime formats API time values to HH:MM format +func formatTime(timeStr string) string { + // Normalize to 4-digit format + padded := fmt.Sprintf("%04s", timeStr) + if len(padded) >= 4 { + return padded[:2] + ":" + padded[2:4] + } + return timeStr +} + +// getEnvOrDefault returns environment variable value or default +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +}