Compare commits

..

3 Commits

@ -26,8 +26,9 @@ type httpError struct {
} }
type Services struct { type Services struct {
PrayerProvider PrayerProvider PrayerProvider PrayerProvider
LocationProvider LocationProvider LocationProvider LocationProvider
LegacyLocationProvider LegacyLocationProvider
} }
type PrayerProvider interface { type PrayerProvider interface {
@ -40,6 +41,11 @@ type LocationProvider interface {
SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]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 { func New(services Services) *fiber.App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
Immutable: true, Immutable: true,
@ -68,20 +74,18 @@ func New(services Services) *fiber.App {
}) })
app.Get("/api/v1/diyanet/prayertimes", func(ctx fiber.Ctx) error { app.Get("/api/v1/diyanet/prayertimes", func(ctx fiber.Ctx) error {
var query struct { var params struct {
LocationID string `query:"location_id"` LocationID string `query:"location_id"`
Latitude string `query:"latitude"` Latitude *float64 `query:"latitude"`
Longitude string `query:"longitude"` Longitude *float64 `query:"longitude"`
UTC string `query:"utc"` UTC bool `query:"utc"`
} }
if err := ctx.Bind().Query(&query); err != nil { if err := ctx.Bind().Query(&params); err != nil {
return fmt.Errorf("failed to bind prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) return fmt.Errorf("failed to bind prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
} }
locationID := strings.TrimSpace(query.LocationID) locationID := strings.TrimSpace(params.LocationID)
latitude := strings.TrimSpace(query.Latitude) utc := params.UTC
longitude := strings.TrimSpace(query.Longitude)
utc := strings.TrimSpace(query.UTC) == "1"
var ( var (
result prayer.TimesResult result prayer.TimesResult
@ -91,19 +95,10 @@ func New(services Services) *fiber.App {
switch { switch {
case locationID != "": case locationID != "":
result, err = services.PrayerProvider.Get(ctx.Context(), locationID) result, err = services.PrayerProvider.Get(ctx.Context(), locationID)
case latitude != "" && longitude != "": case params.Latitude != nil && params.Longitude != nil:
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{ result, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
Latitude: lat, Latitude: *params.Latitude,
Longitude: lng, Longitude: *params.Longitude,
}) })
default: default:
return fmt.Errorf("failed to validate prayer times query parameters: %w", fiber.ErrBadRequest) return fmt.Errorf("failed to validate prayer times query parameters: %w", fiber.ErrBadRequest)
@ -144,42 +139,29 @@ func New(services Services) *fiber.App {
}) })
app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error { app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error {
var query struct { var params struct {
Text string `query:"query"` Text *string `query:"query"`
Latitude string `query:"latitude"` Latitude *float64 `query:"latitude"`
Longitude string `query:"longitude"` Longitude *float64 `query:"longitude"`
} }
if err := ctx.Bind().Query(&query); err != nil { if err := ctx.Bind().Query(&params); err != nil {
return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) 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 ( var (
locations []prayer.Location locations []prayer.Location
err error err error
) )
switch { if params.Text != nil {
case query.Text != "" && query.Latitude == "" && query.Longitude == "": trimmed := strings.TrimSpace(*params.Text)
locations, err = services.LocationProvider.SearchLocations(ctx.Context(), query.Text) locations, err = services.LocationProvider.SearchLocations(ctx.Context(), trimmed)
case query.Text == "" && query.Latitude != "" && query.Longitude != "": } else if params.Latitude != nil && params.Longitude != nil {
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{ locations, err = services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
Latitude: lat, Latitude: *params.Latitude,
Longitude: lng, Longitude: *params.Longitude,
}) })
default: } else {
return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest) return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest)
} }
if err != nil { if err != nil {
@ -192,6 +174,93 @@ func New(services Services) *fiber.App {
}) })
}) })
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"`
}
if err := ctx.Bind().Query(&params); 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()})) app.Use("/", static.New("", static.Config{FS: templates.FS()}))
return app return app
@ -293,3 +362,43 @@ func loadTimezone(name string) (*time.Location, error) {
return loc, nil 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\"")
}

@ -1,17 +1,21 @@
package main package main
import ( import (
"database/sql"
"fmt" "fmt"
"os" "os"
"strings" "strings"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
_ "modernc.org/sqlite"
"prayertimes/internal/api" "prayertimes/internal/api"
"prayertimes/internal/net" "prayertimes/internal/net"
"prayertimes/pkg/citydb" "prayertimes/pkg/citydb"
"prayertimes/pkg/diyanetapi" "prayertimes/pkg/diyanetapi"
"prayertimes/pkg/diyanetcalc" "prayertimes/pkg/diyanetcalc"
"prayertimes/pkg/diyanetcitydb"
"prayertimes/pkg/diyanetprovider" "prayertimes/pkg/diyanetprovider"
) )
@ -28,19 +32,23 @@ func run() error {
return fmt.Errorf("DB_PATH is not set") return fmt.Errorf("DB_PATH is not set")
} }
locationProvider, err := citydb.Open(dbPath) conn, err := newDB(dbPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to open city database: %w", err) return fmt.Errorf("failed to initialize database connection: %w", err)
} }
defer locationProvider.Close() defer conn.Close()
locationProvider := citydb.NewWithConn(conn)
legacyLocationProvider := diyanetcitydb.New(conn)
diyanetAPIProvider := diyanetapi.New(net.ReqClient) diyanetAPIProvider := diyanetapi.New(net.ReqClient)
diyanetCalcProvider := diyanetcalc.New() diyanetCalcProvider := diyanetcalc.New()
prayerProvider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider) prayerProvider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider)
services := api.Services{ services := api.Services{
PrayerProvider: prayerProvider, PrayerProvider: prayerProvider,
LocationProvider: locationProvider, LocationProvider: locationProvider,
LegacyLocationProvider: legacyLocationProvider,
} }
app := api.New(services) app := api.New(services)
@ -58,3 +66,17 @@ func getDefaultEnv(name string, defaultValue string) string {
} }
return v return v
} }
func newDB(path string) (*sql.DB, error) {
conn, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("failed to open database connection: %w", err)
}
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
return conn, nil
}

@ -14,11 +14,16 @@ import (
) )
type Provider struct { type Provider struct {
db *goqu.Database db *goqu.Database
own bool
} }
func New(db *goqu.Database) Provider { func New(db *goqu.Database) Provider {
return Provider{db: db} return Provider{db: db, own: false}
}
func NewWithConn(conn *sql.DB) Provider {
return Provider{db: goqu.New("sqlite3", conn), own: false}
} }
func Open(path string) (Provider, error) { func Open(path string) (Provider, error) {
@ -31,10 +36,14 @@ func Open(path string) (Provider, error) {
return Provider{}, fmt.Errorf("failed to connect to cities database: %w", err) return Provider{}, fmt.Errorf("failed to connect to cities database: %w", err)
} }
return New(goqu.New("sqlite3", conn)), nil return Provider{db: goqu.New("sqlite3", conn), own: true}, nil
} }
func (p Provider) Close() error { func (p Provider) Close() error {
if !p.own {
return nil
}
if err := p.db.Db.(*sql.DB).Close(); err != nil { if err := p.db.Db.(*sql.DB).Close(); err != nil {
return fmt.Errorf("failed to close cities database: %w", err) return fmt.Errorf("failed to close cities database: %w", err)
} }

File diff suppressed because it is too large Load Diff

@ -0,0 +1,125 @@
package diyanetcitydb
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/doug-martin/goqu/v9"
"prayertimes/pkg/prayer"
)
var ErrLocationNotFound = errors.New("location not found")
type Provider struct {
db *goqu.Database
}
func New(conn *sql.DB) Provider {
return Provider{db: goqu.New("sqlite3", conn)}
}
type locationRow struct {
ID int `db:"id"`
Country string `db:"country"`
City string `db:"city"`
Region string `db:"region"`
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
Timezone string `db:"timezone"`
}
func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.LegacyLocation, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
pattern := "%" + query + "%"
q := p.db.
From("diyanet_cities").
Select(
goqu.I("id"),
goqu.I("country"),
goqu.I("city"),
goqu.COALESCE(goqu.I("region"), "").As("region"),
goqu.I("latitude"),
goqu.I("longitude"),
goqu.COALESCE(goqu.I("timezone"), "").As("timezone"),
).
Where(
goqu.Or(
goqu.L("country LIKE ? COLLATE NOCASE", pattern),
goqu.L("city LIKE ? COLLATE NOCASE", pattern),
goqu.L("COALESCE(region, '') LIKE ? COLLATE NOCASE", pattern),
),
).
Order(
goqu.I("country").Asc(),
goqu.I("city").Asc(),
goqu.I("region").Asc(),
goqu.I("id").Asc(),
).
Limit(100)
rows := make([]locationRow, 0)
if err := q.ScanStructsContext(ctx, &rows); err != nil {
return nil, fmt.Errorf("failed to query diyanet locations: %w", err)
}
locations := make([]prayer.LegacyLocation, 0, len(rows))
for _, row := range rows {
locations = append(locations, prayer.LegacyLocation{
ID: row.ID,
Country: row.Country,
City: row.City,
Region: row.Region,
Latitude: row.Latitude,
Longitude: row.Longitude,
Timezone: row.Timezone,
})
}
return locations, nil
}
func (p Provider) GetLocationByID(ctx context.Context, id int) (prayer.LegacyLocation, error) {
q := p.db.
From("diyanet_cities").
Select(
goqu.I("id"),
goqu.I("country"),
goqu.I("city"),
goqu.COALESCE(goqu.I("region"), "").As("region"),
goqu.I("latitude"),
goqu.I("longitude"),
goqu.COALESCE(goqu.I("timezone"), "").As("timezone"),
).
Where(goqu.I("id").Eq(id)).
Limit(1)
var rows []locationRow
if err := q.ScanStructsContext(ctx, &rows); err != nil {
return prayer.LegacyLocation{}, fmt.Errorf("failed to query location by id: %w", err)
}
if len(rows) == 0 {
return prayer.LegacyLocation{}, ErrLocationNotFound
}
row := rows[0]
location := prayer.LegacyLocation{
ID: row.ID,
Country: row.Country,
City: row.City,
Region: row.Region,
Latitude: row.Latitude,
Longitude: row.Longitude,
Timezone: row.Timezone,
}
return location, nil
}

@ -39,3 +39,13 @@ type TimesResult struct {
Location Location `json:"location"` Location Location `json:"location"`
Times []Times `json:"prayertimes"` Times []Times `json:"prayertimes"`
} }
type LegacyLocation struct {
ID int
Country string
City string
Region string
Latitude float64
Longitude float64
Timezone string
}

Loading…
Cancel
Save