421 lines
9.4 KiB
Go
421 lines
9.4 KiB
Go
// backend\address_search.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type AddressSuggestion struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type photonResponse struct {
|
|
Features []photonFeature `json:"features"`
|
|
}
|
|
|
|
type photonFeature struct {
|
|
Properties photonProperties `json:"properties"`
|
|
Geometry photonGeometry `json:"geometry"`
|
|
}
|
|
|
|
type photonProperties struct {
|
|
OSMID int64 `json:"osm_id"`
|
|
OSMType string `json:"osm_type"`
|
|
OSMValue string `json:"osm_value"`
|
|
Name string `json:"name"`
|
|
Street string `json:"street"`
|
|
HouseNumber string `json:"housenumber"`
|
|
Postcode string `json:"postcode"`
|
|
City string `json:"city"`
|
|
District string `json:"district"`
|
|
State string `json:"state"`
|
|
Country string `json:"country"`
|
|
CountryCode string `json:"countrycode"`
|
|
}
|
|
|
|
type photonGeometry struct {
|
|
Coordinates []float64 `json:"coordinates"`
|
|
}
|
|
|
|
type nominatimSearchResult struct {
|
|
PlaceID int64 `json:"place_id"`
|
|
DisplayName string `json:"display_name"`
|
|
Lat string `json:"lat"`
|
|
Lon string `json:"lon"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
|
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
|
|
if len([]rune(query)) < 3 {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"suggestions": []AddressSuggestion{},
|
|
})
|
|
return
|
|
}
|
|
|
|
searchURL := strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_URL"))
|
|
if searchURL == "" {
|
|
writeError(w, http.StatusNotImplemented, "Adresssuche ist nicht konfiguriert")
|
|
return
|
|
}
|
|
|
|
provider := strings.ToLower(strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_PROVIDER")))
|
|
if provider == "" {
|
|
provider = detectAddressSearchProvider(searchURL)
|
|
}
|
|
|
|
switch provider {
|
|
case "photon":
|
|
s.handlePhotonAddressSearch(w, r, searchURL, query)
|
|
case "nominatim":
|
|
s.handleNominatimAddressSearch(w, r, searchURL, query)
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "Adresssuche-Provider ist unbekannt")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handlePhotonAddressSearch(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
searchURL string,
|
|
query string,
|
|
) {
|
|
parsedURL, err := url.Parse(searchURL)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Adresssuche ist falsch konfiguriert")
|
|
return
|
|
}
|
|
|
|
limit := getAddressSearchLimit()
|
|
language := envOrDefault("ADDRESS_SEARCH_LANGUAGE", "de")
|
|
countryCode := strings.ToLower(strings.TrimSpace(envOrDefault("ADDRESS_SEARCH_COUNTRY_CODE", "de")))
|
|
|
|
values := parsedURL.Query()
|
|
values.Set("q", query)
|
|
values.Set("limit", strconv.Itoa(limit))
|
|
|
|
if language != "" {
|
|
values.Set("lang", language)
|
|
}
|
|
|
|
parsedURL.RawQuery = values.Encode()
|
|
|
|
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, parsedURL.String(), nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Adresssuche konnte nicht vorbereitet werden")
|
|
return
|
|
}
|
|
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("User-Agent", addressSearchUserAgent())
|
|
|
|
response, err := httpClient().Do(request)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden")
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden")
|
|
return
|
|
}
|
|
|
|
var result photonResponse
|
|
|
|
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
|
writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht gelesen werden")
|
|
return
|
|
}
|
|
|
|
suggestions := make([]AddressSuggestion, 0, len(result.Features))
|
|
|
|
for _, feature := range result.Features {
|
|
if len(feature.Geometry.Coordinates) < 2 {
|
|
continue
|
|
}
|
|
|
|
if countryCode != "" &&
|
|
feature.Properties.CountryCode != "" &&
|
|
strings.ToLower(feature.Properties.CountryCode) != countryCode {
|
|
continue
|
|
}
|
|
|
|
lon := feature.Geometry.Coordinates[0]
|
|
lat := feature.Geometry.Coordinates[1]
|
|
|
|
label := buildPhotonLabel(feature.Properties)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
|
|
suggestions = append(suggestions, AddressSuggestion{
|
|
ID: buildPhotonID(feature.Properties, label),
|
|
Label: label,
|
|
Lat: lat,
|
|
Lon: lon,
|
|
Type: buildPhotonType(feature.Properties),
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"suggestions": suggestions,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleNominatimAddressSearch(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
searchURL string,
|
|
query string,
|
|
) {
|
|
parsedURL, err := url.Parse(searchURL)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Adresssuche ist falsch konfiguriert")
|
|
return
|
|
}
|
|
|
|
limit := getAddressSearchLimit()
|
|
countryCode := strings.ToLower(strings.TrimSpace(envOrDefault("ADDRESS_SEARCH_COUNTRY_CODE", "de")))
|
|
|
|
values := parsedURL.Query()
|
|
values.Set("q", query)
|
|
values.Set("format", "jsonv2")
|
|
values.Set("addressdetails", "1")
|
|
values.Set("limit", strconv.Itoa(limit))
|
|
|
|
if countryCode != "" && values.Get("countrycodes") == "" {
|
|
values.Set("countrycodes", countryCode)
|
|
}
|
|
|
|
parsedURL.RawQuery = values.Encode()
|
|
|
|
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, parsedURL.String(), nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Adresssuche konnte nicht vorbereitet werden")
|
|
return
|
|
}
|
|
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("User-Agent", addressSearchUserAgent())
|
|
|
|
response, err := httpClient().Do(request)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden")
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden")
|
|
return
|
|
}
|
|
|
|
var results []nominatimSearchResult
|
|
|
|
if err := json.NewDecoder(response.Body).Decode(&results); err != nil {
|
|
writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht gelesen werden")
|
|
return
|
|
}
|
|
|
|
suggestions := make([]AddressSuggestion, 0, len(results))
|
|
|
|
for _, result := range results {
|
|
label := strings.TrimSpace(result.DisplayName)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
|
|
lat, latErr := strconv.ParseFloat(result.Lat, 64)
|
|
lon, lonErr := strconv.ParseFloat(result.Lon, 64)
|
|
|
|
if latErr != nil || lonErr != nil {
|
|
continue
|
|
}
|
|
|
|
suggestions = append(suggestions, AddressSuggestion{
|
|
ID: strconv.FormatInt(result.PlaceID, 10),
|
|
Label: label,
|
|
Lat: lat,
|
|
Lon: lon,
|
|
Type: result.Type,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"suggestions": suggestions,
|
|
})
|
|
}
|
|
|
|
func buildPhotonLabel(properties photonProperties) string {
|
|
parts := []string{}
|
|
|
|
streetLine := strings.TrimSpace(
|
|
strings.Join(
|
|
[]string{
|
|
strings.TrimSpace(properties.Street),
|
|
strings.TrimSpace(properties.HouseNumber),
|
|
},
|
|
" ",
|
|
),
|
|
)
|
|
|
|
if streetLine != "" {
|
|
parts = append(parts, streetLine)
|
|
} else if strings.TrimSpace(properties.Name) != "" {
|
|
parts = append(parts, strings.TrimSpace(properties.Name))
|
|
}
|
|
|
|
cityLine := strings.TrimSpace(
|
|
strings.Join(
|
|
[]string{
|
|
strings.TrimSpace(properties.Postcode),
|
|
firstNonEmpty(properties.City, properties.District),
|
|
},
|
|
" ",
|
|
),
|
|
)
|
|
|
|
if cityLine != "" {
|
|
parts = append(parts, cityLine)
|
|
}
|
|
|
|
if strings.TrimSpace(properties.State) != "" {
|
|
parts = append(parts, strings.TrimSpace(properties.State))
|
|
}
|
|
|
|
if strings.TrimSpace(properties.Country) != "" {
|
|
parts = append(parts, strings.TrimSpace(properties.Country))
|
|
}
|
|
|
|
return strings.Join(uniqueNonEmpty(parts), ", ")
|
|
}
|
|
|
|
func buildPhotonID(properties photonProperties, fallback string) string {
|
|
if properties.OSMID != 0 {
|
|
if properties.OSMType != "" {
|
|
return properties.OSMType + ":" + strconv.FormatInt(properties.OSMID, 10)
|
|
}
|
|
|
|
return strconv.FormatInt(properties.OSMID, 10)
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
func buildPhotonType(properties photonProperties) string {
|
|
if strings.TrimSpace(properties.OSMValue) != "" {
|
|
return strings.TrimSpace(properties.OSMValue)
|
|
}
|
|
|
|
if strings.TrimSpace(properties.OSMType) != "" {
|
|
return strings.TrimSpace(properties.OSMType)
|
|
}
|
|
|
|
return "Adresse"
|
|
}
|
|
|
|
func detectAddressSearchProvider(searchURL string) string {
|
|
lowerURL := strings.ToLower(searchURL)
|
|
|
|
if strings.Contains(lowerURL, "photon") {
|
|
return "photon"
|
|
}
|
|
|
|
if strings.Contains(lowerURL, "nominatim") {
|
|
return "nominatim"
|
|
}
|
|
|
|
return "photon"
|
|
}
|
|
|
|
func getAddressSearchLimit() int {
|
|
rawLimit := strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_LIMIT"))
|
|
if rawLimit == "" {
|
|
return 5
|
|
}
|
|
|
|
limit, err := strconv.Atoi(rawLimit)
|
|
if err != nil {
|
|
return 5
|
|
}
|
|
|
|
if limit < 1 {
|
|
return 1
|
|
}
|
|
|
|
if limit > 10 {
|
|
return 10
|
|
}
|
|
|
|
return limit
|
|
}
|
|
|
|
func addressSearchUserAgent() string {
|
|
return envOrDefault("ADDRESS_SEARCH_USER_AGENT", "TEG-App/1.0")
|
|
}
|
|
|
|
func httpClient() *http.Client {
|
|
return &http.Client{
|
|
Timeout: 8 * time.Second,
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func uniqueNonEmpty(values []string) []string {
|
|
seen := map[string]bool{}
|
|
result := []string{}
|
|
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
|
|
if value == "" {
|
|
continue
|
|
}
|
|
|
|
key := strings.ToLower(value)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
|
|
seen[key] = true
|
|
result = append(result, value)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func envOrDefault(key string, fallback string) string {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
|
|
return value
|
|
}
|