teg/backend/address_search.go
2026-06-13 10:15:57 +02:00

433 lines
10 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"`
HasHouseNumber bool `json:"hasHouseNumber"`
GeoJSON json.RawMessage `json:"geojson,omitempty"`
}
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"`
Address nominatimReverseAddress `json:"address"`
GeoJSON json.RawMessage `json:"geojson,omitempty"`
}
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),
HasHouseNumber: strings.TrimSpace(feature.Properties.HouseNumber) != "",
})
}
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("polygon_geojson", "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 := formatReverseAddressLabel(nominatimReverseResponse{
DisplayName: result.DisplayName,
Address: result.Address,
})
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,
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
GeoJSON: result.GeoJSON,
})
}
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
}