You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
396 lines
12 KiB
Go
396 lines
12 KiB
Go
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 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.Get("/api/diyanet/search", func(ctx fiber.Ctx) error {
|
|
q := strings.TrimSpace(ctx.Query("q"))
|
|
if q == "" {
|
|
return fmt.Errorf("missing query parameter q: %w", fiber.ErrBadRequest)
|
|
}
|
|
|
|
locations, err := services.LegacyLocationProvider.SearchLocations(ctx.Context(), q)
|
|
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/prayertimes", func(ctx fiber.Ctx) error {
|
|
if services.LegacyLocationProvider == nil {
|
|
return fmt.Errorf("legacy location provider is not configured: %w", fiber.ErrNotFound)
|
|
}
|
|
|
|
locationIDText := strings.TrimSpace(ctx.Query("location_id"))
|
|
if locationIDText == "" {
|
|
return fmt.Errorf("missing query parameter location_id: %w", fiber.ErrBadRequest)
|
|
}
|
|
|
|
locationID, err := strconv.Atoi(locationIDText)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse location_id query parameter: %w", errors.Join(fiber.ErrBadRequest, err))
|
|
}
|
|
|
|
legacyLocation, err := services.LegacyLocationProvider.GetLocationByID(ctx.Context(), 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
|
|
}
|