package api import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/cors" "github.com/gofiber/fiber/v3/middleware/favicon" "github.com/gofiber/fiber/v3/middleware/logger" "github.com/gofiber/fiber/v3/middleware/recover" "github.com/gofiber/fiber/v3/middleware/static" "prayertimes/pkg/hijricalendar" "prayertimes/pkg/prayer" "prayertimes/templates" ) type httpError struct { Message string `json:"message"` } type Services struct { PrayerProvider PrayerProvider LocationProvider LocationProvider } type PrayerProvider interface { Get(ctx context.Context, locationID string) (prayer.TimesResult, error) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) } type LocationProvider interface { SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Location, error) } func New(services Services) *fiber.App { app := fiber.New(fiber.Config{ Immutable: true, ReadTimeout: time.Second * 10, WriteTimeout: time.Second * 30, ErrorHandler: func(ctx fiber.Ctx, err error) error { if errors.Is(err, prayer.ErrInvalidLocation) { return ctx.Status(http.StatusUnprocessableEntity).JSON(httpError{ Message: err.Error(), }) } return fiber.DefaultErrorHandler(ctx, err) }, }) app.Use( favicon.New(), recover.New(recover.Config{EnableStackTrace: true}), logger.New(), cors.New(), ) app.Get("/healthz", func(ctx fiber.Ctx) error { return ctx.SendString("OK") }) app.Get("/api/v1/diyanet/prayertimes", func(ctx fiber.Ctx) error { var query struct { LocationID string `query:"location_id"` Latitude string `query:"latitude"` Longitude string `query:"longitude"` UTC string `query:"utc"` } if err := ctx.Bind().Query(&query); err != nil { return fmt.Errorf("failed to bind prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) } locationID := strings.TrimSpace(query.LocationID) latitude := strings.TrimSpace(query.Latitude) longitude := strings.TrimSpace(query.Longitude) utc := strings.TrimSpace(query.UTC) == "1" var ( result prayer.TimesResult err error ) switch { case locationID != "": result, err = services.PrayerProvider.Get(ctx.Context(), locationID) case latitude != "" && longitude != "": lat, latErr := strconv.ParseFloat(latitude, 64) if latErr != nil { return fmt.Errorf("failed to parse latitude query parameter: %w", errors.Join(fiber.ErrBadRequest, latErr)) } lng, lngErr := strconv.ParseFloat(longitude, 64) if lngErr != nil { return fmt.Errorf("failed to parse longitude query parameter: %w", errors.Join(fiber.ErrBadRequest, lngErr)) } result, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ Latitude: lat, Longitude: lng, }) default: return fmt.Errorf("failed to validate prayer times query parameters: %w", fiber.ErrBadRequest) } if err != nil { return fmt.Errorf("failed to fetch prayer times: %w", err) } location := result.Location if location.Latitude != 0 || location.Longitude != 0 { locations, locErr := services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{ Latitude: location.Latitude, Longitude: location.Longitude, }) if locErr != nil { return fmt.Errorf("failed to enrich prayer times location from database: %w", locErr) } if len(locations) > 0 { dbLocation := locations[0] if strings.TrimSpace(dbLocation.Timezone) == "" { dbLocation.Timezone = location.Timezone } location = dbLocation } } mappedTimes, err := mapPrayerTimesForResponse(result.Times, location, utc) if err != nil { return fmt.Errorf("failed to map prayer times for response: %w", err) } ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400") return ctx.JSON(fiber.Map{ "location": location, "prayertimes": mappedTimes, }) }) app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error { var query struct { Text string `query:"query"` Latitude string `query:"latitude"` Longitude string `query:"longitude"` } if err := ctx.Bind().Query(&query); err != nil { return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) } query.Text = strings.TrimSpace(query.Text) query.Latitude = strings.TrimSpace(query.Latitude) query.Longitude = strings.TrimSpace(query.Longitude) var ( locations []prayer.Location err error ) switch { case query.Text != "" && query.Latitude == "" && query.Longitude == "": locations, err = services.LocationProvider.SearchLocations(ctx.Context(), query.Text) case query.Text == "" && query.Latitude != "" && query.Longitude != "": lat, latErr := strconv.ParseFloat(query.Latitude, 64) if latErr != nil { return fmt.Errorf("failed to parse latitude query parameter: %w", errors.Join(fiber.ErrBadRequest, latErr)) } lng, lngErr := strconv.ParseFloat(query.Longitude, 64) if lngErr != nil { return fmt.Errorf("failed to parse longitude query parameter: %w", errors.Join(fiber.ErrBadRequest, lngErr)) } locations, err = services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{ Latitude: lat, Longitude: lng, }) default: return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest) } if err != nil { return fmt.Errorf("failed to search locations: %w", err) } ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400") return ctx.JSON(fiber.Map{ "locations": locations, }) }) app.Use("/", static.New("", static.Config{FS: templates.FS()})) return app } type prayerTimesResponse struct { Date string `json:"date"` DateHijri string `json:"date_hijri"` Fajr string `json:"fajr"` Sunrise string `json:"sunrise"` Dhuhr string `json:"dhuhr"` Asr string `json:"asr"` Sunset string `json:"sunset,omitempty"` Maghrib string `json:"maghrib"` Isha string `json:"isha"` } func mapPrayerTimesForResponse(times []prayer.Times, location prayer.Location, utc bool) ([]any, error) { if utc { result := make([]any, 0, len(times)) for _, item := range times { dateHijri := item.DateHijri if dateHijri == "" { dateHijri = hijricalendar.ToISODate(item.Date) } result = append(result, prayer.Times{ Date: item.Date.UTC(), DateHijri: dateHijri, Fajr: item.Fajr.UTC(), Sunrise: item.Sunrise.UTC(), Dhuhr: item.Dhuhr.UTC(), Asr: item.Asr.UTC(), Sunset: item.Sunset.UTC(), Maghrib: item.Maghrib.UTC(), Isha: item.Isha.UTC(), }) } return result, nil } tz := time.UTC if strings.TrimSpace(location.Timezone) != "" { loadedTZ, err := loadTimezone(location.Timezone) if err != nil { return nil, fmt.Errorf("failed to load location timezone: %w", err) } tz = loadedTZ } result := make([]any, 0, len(times)) for _, item := range times { dateHijri := item.DateHijri if dateHijri == "" { dateHijri = hijricalendar.ToISODate(item.Date) } result = append(result, prayerTimesResponse{ Date: item.Date.In(tz).Format(time.DateOnly), DateHijri: dateHijri, Fajr: formatHHMM(item.Fajr, tz), Sunrise: formatHHMM(item.Sunrise, tz), Dhuhr: formatHHMM(item.Dhuhr, tz), Asr: formatHHMM(item.Asr, tz), Sunset: formatHHMM(item.Sunset, tz), Maghrib: formatHHMM(item.Maghrib, tz), Isha: formatHHMM(item.Isha, tz), }) } return result, nil } func formatHHMM(value time.Time, tz *time.Location) string { if value.IsZero() { return "" } return value.In(tz).Format("15:04") } func loadTimezone(name string) (*time.Location, error) { if strings.HasPrefix(name, "UTC") { offsetText := strings.TrimPrefix(name, "UTC") if offsetText == "" { return time.UTC, nil } offsetHours, err := strconv.Atoi(offsetText) if err != nil { return nil, fmt.Errorf("failed to parse utc offset timezone: %w", err) } return time.FixedZone(name, offsetHours*3600), nil } loc, err := time.LoadLocation(name) if err != nil { return nil, fmt.Errorf("failed to load iana timezone: %w", err) } return loc, nil }