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 LegacyLocationProvider LegacyLocationProvider } 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) } type LegacyLocationProvider interface { SearchLocations(ctx context.Context, query string) ([]prayer.LegacyLocation, error) GetLocationByID(ctx context.Context, id int) (prayer.LegacyLocation, 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 params struct { LocationID string `query:"location_id"` Latitude *float64 `query:"latitude"` Longitude *float64 `query:"longitude"` UTC bool `query:"utc"` } if err := ctx.Bind().Query(¶ms); err != nil { return fmt.Errorf("failed to bind prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) } locationID := strings.TrimSpace(params.LocationID) utc := params.UTC var ( result prayer.TimesResult err error ) switch { case locationID != "": result, err = services.PrayerProvider.Get(ctx.Context(), locationID) case params.Latitude != nil && params.Longitude != nil: result, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ Latitude: *params.Latitude, Longitude: *params.Longitude, }) 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 params struct { Text *string `query:"query"` Latitude *float64 `query:"latitude"` Longitude *float64 `query:"longitude"` } if err := ctx.Bind().Query(¶ms); err != nil { return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) } var ( locations []prayer.Location err error ) if params.Text != nil { trimmed := strings.TrimSpace(*params.Text) locations, err = services.LocationProvider.SearchLocations(ctx.Context(), trimmed) } else if params.Latitude != nil && params.Longitude != nil { locations, err = services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{ Latitude: *params.Latitude, Longitude: *params.Longitude, }) } else { 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.Get("/api/diyanet/search", func(ctx fiber.Ctx) error { setDeprecationHeaders(ctx, "/api/v1/diyanet/location") var params struct { Query string `query:"q"` } if err := ctx.Bind().Query(¶ms); err != nil { return fmt.Errorf("failed to bind legacy location search query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) } params.Query = strings.TrimSpace(params.Query) if params.Query == "" { return fmt.Errorf("missing query parameter q: %w", fiber.ErrBadRequest) } locations, err := services.LegacyLocationProvider.SearchLocations(ctx.Context(), params.Query) if err != nil { return fmt.Errorf("failed to search legacy diyanet locations: %w", err) } resp := make([]fiber.Map, 0, len(locations)) for _, location := range locations { resp = append(resp, fiber.Map{ "id": location.ID, "country": location.Country, "city": location.City, "region": location.Region, }) } ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400") return ctx.JSON(resp) }) app.Get("/api/diyanet/countries", func(ctx fiber.Ctx) error { return respondDeprecatedEndpoint(ctx, "/api/diyanet/search?q=") }) app.Get("/api/diyanet/countries/:country/cities", func(ctx fiber.Ctx) error { return respondDeprecatedEndpoint(ctx, "/api/diyanet/search?q=") }) app.Get("/api/diyanet/locations", func(ctx fiber.Ctx) error { return respondDeprecatedEndpoint(ctx, "/api/diyanet/search?q=") }) app.Get("/api/diyanet/prayertimes", func(ctx fiber.Ctx) error { setDeprecationHeaders(ctx, "/api/v1/diyanet/prayertimes") var params struct { LocationID int `query:"location_id"` } if err := ctx.Bind().Query(¶ms); err != nil { return fmt.Errorf("failed to bind legacy prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) } if params.LocationID <= 0 { return fmt.Errorf("missing query parameter location_id: %w", fiber.ErrBadRequest) } legacyLocation, err := services.LegacyLocationProvider.GetLocationByID(ctx.Context(), params.LocationID) if err != nil { return fmt.Errorf("failed to resolve location by location_id: %w", errors.Join(fiber.ErrNotFound, err)) } result, err := services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ Latitude: legacyLocation.Latitude, Longitude: legacyLocation.Longitude, }) if err != nil { return fmt.Errorf("failed to fetch prayer times for location_id: %w", err) } location := result.Location location.ID = legacyLocation.ID if strings.TrimSpace(legacyLocation.Timezone) != "" { location.Timezone = legacyLocation.Timezone } mapped, err := mapPrayerTimesForLegacyResponse(result.Times, location) if err != nil { return fmt.Errorf("failed to map legacy prayer times response: %w", err) } ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400") return ctx.JSON(mapped) }) 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 } func mapPrayerTimesForLegacyResponse(times []prayer.Times, location prayer.Location) ([]fiber.Map, error) { 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 } out := make([]fiber.Map, 0, len(times)) for _, item := range times { out = append(out, fiber.Map{ "date": item.Date.In(tz).Format(time.RFC3339), "fajr": formatHHMM(item.Fajr, tz), "sun": formatHHMM(item.Sunrise, tz), "dhuhr": formatHHMM(item.Dhuhr, tz), "asr": formatHHMM(item.Asr, tz), "maghrib": formatHHMM(item.Maghrib, tz), "isha": formatHHMM(item.Isha, tz), }) } return out, nil } func respondDeprecatedEndpoint(ctx fiber.Ctx, replacementPath string) error { setDeprecationHeaders(ctx, replacementPath) return ctx.Status(http.StatusGone).JSON(httpError{ Message: "This endpoint has been removed. Use " + replacementPath, }) } func setDeprecationHeaders(ctx fiber.Ctx, replacementPath string) { ctx.Response().Header.Set("Deprecation", "true") ctx.Response().Header.Set("Sunset", "Wed, 30 Sep 2026 23:59:59 GMT") ctx.Response().Header.Set("Link", "<"+replacementPath+">; rel=\"successor-version\"") }