Compare commits
3
Commits
82f252f32f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
612c6ebde5 | ||
|
|
8ab2de40e0 | ||
|
|
ad1a9364b8 |
+158
-49
@@ -26,8 +26,9 @@ type httpError struct {
|
||||
}
|
||||
|
||||
type Services struct {
|
||||
PrayerProvider PrayerProvider
|
||||
LocationProvider LocationProvider
|
||||
PrayerProvider PrayerProvider
|
||||
LocationProvider LocationProvider
|
||||
LegacyLocationProvider LegacyLocationProvider
|
||||
}
|
||||
|
||||
type PrayerProvider interface {
|
||||
@@ -40,6 +41,11 @@ type LocationProvider interface {
|
||||
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,
|
||||
@@ -68,20 +74,18 @@ func New(services Services) *fiber.App {
|
||||
})
|
||||
|
||||
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"`
|
||||
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(&query); err != nil {
|
||||
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(query.LocationID)
|
||||
latitude := strings.TrimSpace(query.Latitude)
|
||||
longitude := strings.TrimSpace(query.Longitude)
|
||||
utc := strings.TrimSpace(query.UTC) == "1"
|
||||
locationID := strings.TrimSpace(params.LocationID)
|
||||
utc := params.UTC
|
||||
|
||||
var (
|
||||
result prayer.TimesResult
|
||||
@@ -91,19 +95,10 @@ func New(services Services) *fiber.App {
|
||||
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))
|
||||
}
|
||||
|
||||
case params.Latitude != nil && params.Longitude != nil:
|
||||
result, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
|
||||
Latitude: lat,
|
||||
Longitude: lng,
|
||||
Latitude: *params.Latitude,
|
||||
Longitude: *params.Longitude,
|
||||
})
|
||||
default:
|
||||
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 {
|
||||
var query struct {
|
||||
Text string `query:"query"`
|
||||
Latitude string `query:"latitude"`
|
||||
Longitude string `query:"longitude"`
|
||||
var params struct {
|
||||
Text *string `query:"query"`
|
||||
Latitude *float64 `query:"latitude"`
|
||||
Longitude *float64 `query:"longitude"`
|
||||
}
|
||||
if err := ctx.Bind().Query(&query); err != nil {
|
||||
if err := ctx.Bind().Query(¶ms); 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))
|
||||
}
|
||||
|
||||
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: lat,
|
||||
Longitude: lng,
|
||||
Latitude: *params.Latitude,
|
||||
Longitude: *params.Longitude,
|
||||
})
|
||||
default:
|
||||
} else {
|
||||
return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest)
|
||||
}
|
||||
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(¶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
|
||||
@@ -293,3 +362,43 @@ func loadTimezone(name string) (*time.Location, error) {
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"prayertimes/internal/api"
|
||||
"prayertimes/internal/net"
|
||||
"prayertimes/pkg/citydb"
|
||||
"prayertimes/pkg/diyanetapi"
|
||||
"prayertimes/pkg/diyanetcalc"
|
||||
"prayertimes/pkg/diyanetcitydb"
|
||||
"prayertimes/pkg/diyanetprovider"
|
||||
)
|
||||
|
||||
@@ -28,19 +32,23 @@ func run() error {
|
||||
return fmt.Errorf("DB_PATH is not set")
|
||||
}
|
||||
|
||||
locationProvider, err := citydb.Open(dbPath)
|
||||
conn, err := newDB(dbPath)
|
||||
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)
|
||||
diyanetCalcProvider := diyanetcalc.New()
|
||||
prayerProvider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider)
|
||||
|
||||
services := api.Services{
|
||||
PrayerProvider: prayerProvider,
|
||||
LocationProvider: locationProvider,
|
||||
PrayerProvider: prayerProvider,
|
||||
LocationProvider: locationProvider,
|
||||
LegacyLocationProvider: legacyLocationProvider,
|
||||
}
|
||||
|
||||
app := api.New(services)
|
||||
@@ -58,3 +66,17 @@ func getDefaultEnv(name string, defaultValue string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
+12
-3
@@ -14,11 +14,16 @@ import (
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
db *goqu.Database
|
||||
db *goqu.Database
|
||||
own bool
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -31,10 +36,14 @@ func Open(path string) (Provider, error) {
|
||||
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 {
|
||||
if !p.own {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := p.db.Db.(*sql.DB).Close(); err != nil {
|
||||
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"`
|
||||
Times []Times `json:"prayertimes"`
|
||||
}
|
||||
|
||||
type LegacyLocation struct {
|
||||
ID int
|
||||
Country string
|
||||
City string
|
||||
Region string
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
Timezone string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user