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.

409 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 params struct {
LocationID string `query:"location_id"`
Latitude *float64 `query:"latitude"`
Longitude *float64 `query:"longitude"`
UTC bool `query:"utc"`
}
if err := ctx.Bind().Query(&params); 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(&params); 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(&params); 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"`
}
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
}
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\"")
}