All Downloads are FREE. Search and download functionalities are using the official Maven repository.

apoc.spatial.Geocode Maven / Gradle / Ivy

There is a newer version: 5.25.1
Show newest version
package apoc.spatial;

import apoc.util.JsonUtil;
import apoc.util.Util;
import org.apache.commons.configuration2.Configuration;
import org.neo4j.graphdb.GraphDatabaseService;
import org.neo4j.logging.Log;
import org.neo4j.procedure.*;

import java.util.AbstractMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

import static apoc.ApocConfig.apocConfig;
import static apoc.util.MapUtil.map;
import static apoc.util.Util.toDouble;
import static apoc.util.Util.toLong;
import static java.lang.String.valueOf;
import static java.lang.System.currentTimeMillis;

public class Geocode {
    public static final int MAX_RESULTS = 100;
    public static final String PREFIX = "apoc.spatial.geocode";
    public static final String GEOCODE_PROVIDER_KEY = "provider";

    @Context
    public GraphDatabaseService db;

    @Context
    public TerminationGuard terminationGuard;

    @Context
    public Log log;

    interface GeocodeSupplier {
        Stream geocode(String params, long maxResults);
        Stream reverseGeocode(Double latitude, Double longitude);
    }

    private static class Throttler {
        private final TerminationGuard terminationGuard;
        private long throttleInMs;
        private static long lastCallTime = 0L;
        private static long DEFAULT_THROTTLE = 5*1000;  // 5 seconds
        private static long MAX_THROTTLE = 60 * 60 * 1000;  // 1 hour

        public Throttler(TerminationGuard terminationGuard, long throttle) {
            this.terminationGuard = terminationGuard;

            throttle = Math.min(throttle, MAX_THROTTLE);
            if (throttle < 0) throttle = DEFAULT_THROTTLE;

            this.throttleInMs = throttle;
        }

        private void waitForThrottle() {
            long msSinceLastCall = currentTimeMillis() - lastCallTime;
            while (msSinceLastCall < throttleInMs) {
                try {
                    terminationGuard.check();
                    long msToWait = throttleInMs - msSinceLastCall;
                    Thread.sleep(Math.min(msToWait, 1000));
                } catch (InterruptedException e) {
                    // ignore
                }
                msSinceLastCall = currentTimeMillis() - lastCallTime;
            }
            lastCallTime = currentTimeMillis();
        }
    }

    private static class SupplierWithKey implements GeocodeSupplier {
        private static final String[] FORMATTED_KEYS = new String[]{"formatted", "formatted_address", "address", "description", "display_name"};
        private static final String[] LAT_KEYS = new String[]{"lat", "latitude"};
        private static final String[] LNG_KEYS = new String[]{"lng", "longitude", "lon"};
        private Throttler throttler;
        private String configBase;
        private String urlTemplate;
        private String urlTemplateReverse;

        public SupplierWithKey(Configuration config, TerminationGuard terminationGuard, String provider) {
            this.configBase = provider;

            if (!config.containsKey(configKey("url"))) {
                throw new IllegalArgumentException("Missing 'url' for geocode provider: " + provider);
            }
            if (!config.containsKey(configKey("reverse.url"))) {
                throw new IllegalArgumentException("Missing 'reverse.url' for reverse-geocode provider: " + provider);
            }
            urlTemplate = config.getString(configKey("url"));
            if (!urlTemplate.contains("PLACE")) throw new IllegalArgumentException("Missing 'PLACE' in url template: " + urlTemplate);

            urlTemplateReverse = config.getString(configKey("reverse.url"));
            if (!urlTemplateReverse.contains("LAT") || !urlTemplateReverse.contains("LNG")) throw new IllegalArgumentException("Missing 'LAT' or 'LNG' in url template: " + urlTemplateReverse);

            if (urlTemplate.contains("KEY") && !config.containsKey(configKey("key"))) {
                throw new IllegalArgumentException("Missing 'key' for geocode provider: " + provider);
            }

            if (urlTemplateReverse.contains("KEY") && !config.containsKey(configKey("key"))) {
                throw new IllegalArgumentException("Missing 'key' for reverse-geocode provider: " + provider);
            }
            String key = config.getString(configKey("key"));
            urlTemplate = urlTemplate.replace("KEY", key);
            urlTemplateReverse = urlTemplateReverse.replace("KEY", key);

            this.throttler = new Throttler(terminationGuard, apocConfig().getInt(configKey("throttle"), (int) Throttler.DEFAULT_THROTTLE));
        }

        @SuppressWarnings("unchecked")
        public Stream geocode(String address, long maxResults) {
            if (address.isEmpty()) {
                return Stream.empty();
            }
            throttler.waitForThrottle();
            String url = urlTemplate.replace("PLACE", Util.encodeUrlComponent(address));
            Object value = JsonUtil.loadJson(url).findFirst().orElse(null);
            if (value instanceof List) {
                return findResults((List>) value, maxResults);
            } else if (value instanceof Map) {
                Object results = ((Map) value).get("results");
                if (results instanceof List) {
                    return findResults((List>) results, maxResults);
                }
            }
            throw new RuntimeException("Can't parse geocoding results " + value);
        }

        @Override
        public Stream reverseGeocode(Double latitude, Double longitude) {
            if (latitude == null || longitude == null) {
                return Stream.empty();
            }
            throttler.waitForThrottle();
            String url = urlTemplateReverse.replace("LAT", latitude.toString()).replace("LNG", longitude.toString());
            Object value = JsonUtil.loadJson(url).findFirst().orElse(null);
            if (value instanceof List) {
                return findResults((List>) value, 1);
            } else if (value instanceof Map) {
                Object results = ((Map) value).get("results");
                if (results instanceof List) {
                    return findResults((List>) results, 1);
                }
            }
            throw new RuntimeException("Can't parse reverse-geocoding results " + value);
        }

        @SuppressWarnings("unchecked")
        private Stream findResults(List> results, long maxResults) {
            return results.stream().limit(maxResults).map(data -> {
                String description = findFirstEntry(data, FORMATTED_KEYS);
                Map location = (Map) data.get("geometry");
                if (location.containsKey("location")) {
                    location = (Map) location.get("location");
                }
                String lat = findFirstEntry(location, LAT_KEYS);
                String lng = findFirstEntry(location, LNG_KEYS);
                return new GeoCodeResult(toDouble(lat), toDouble(lng), description, data);
            });
        }

        private String findFirstEntry(Map map, String[] keys) {
            for (String key : keys) {
                if (map.containsKey(key)) {
                    return valueOf(map.get(key));
                }
            }
            return "";
        }

        private String configKey(String name) {
            return configBase + "." + name;
        }

    }

    private static class OSMSupplier implements GeocodeSupplier {
        public static final String OSM_URL = "https://nominatim.openstreetmap.org";

        private static final String OSM_URL_REVERSE_GEOCODE = OSM_URL + "/reverse?format=jsonv2&";
        private static final String OSM_URL_GEOCODE = OSM_URL + "/search.php?format=json&q=";

        private Throttler throttler;

        public OSMSupplier(Configuration config, TerminationGuard terminationGuard) {
            this.throttler = new Throttler(terminationGuard, toLong(config.getString("osm.throttle", Long.toString(Throttler.DEFAULT_THROTTLE))));
        }

        @SuppressWarnings("unchecked")
        public Stream geocode(String address, long maxResults) {
            if (address.isEmpty()) {
                return Stream.empty();
            }
            throttler.waitForThrottle();
            Object value = JsonUtil.loadJson(OSM_URL_GEOCODE + Util.encodeUrlComponent(address)).findFirst().orElse(null);
            if (value instanceof List) {
                return ((List>) value).stream().limit(maxResults).map(data ->
                        new GeoCodeResult(toDouble(data.get("lat")), toDouble(data.get("lon")), valueOf(data.get("display_name")), data));
            }
            throw new RuntimeException("Can't parse geocoding results " + value);
        }

        @Override
        public Stream reverseGeocode(Double latitude, Double longitude) {
            if (latitude == null || longitude == null) {
                return Stream.empty();
            }
            throttler.waitForThrottle();

            Object value = JsonUtil.loadJson(OSM_URL_REVERSE_GEOCODE + String.format("lat=%s&lon=%s", latitude, longitude)).findFirst().orElse(null);
            if (value instanceof Map) {
                Map data = (Map) value;
                return Stream.of(new GeoCodeResult(toDouble(data.get("lat")), toDouble(data.get("lon")), valueOf(data.get("display_name")), (Map)data.get("address")));
            }
            throw new RuntimeException("Can't parse reverse-geocoding results " + value);
        }
    }

    private static class GoogleSupplier implements GeocodeSupplier {
        private final Throttler throttler;
        private Configuration config;

        private static final String BASE_GOOGLE_API_URL = "https://maps.googleapis.com/maps/api/geocode/json";

        private static final String REVERSE_GEOCODE_URL = BASE_GOOGLE_API_URL + "?%s&latlng=";
        private static final String GEOCODE_URL = BASE_GOOGLE_API_URL + "?%s&address=";


        public GoogleSupplier(Configuration config, TerminationGuard terminationGuard) {
            this.throttler = new Throttler(terminationGuard, toLong(config.getString("google.throttle", Long.toString(Throttler.DEFAULT_THROTTLE))));
            this.config = config;
        }

        private String credentials(Configuration config) {
            if (config.containsKey("google.client") && config.containsKey("google.signature")) {
                return "client=" + config.getString("google.client") + "&signature=" + config.getString("google.signature");
            } else if (config.containsKey("google.key")) {
                return "key=" + config.getString("google.key");
            } else {
                return "auth=free"; // throw new RuntimeException("apoc.spatial.geocode: No google client or key specified in apoc.conf config file");
            }
        }

        @SuppressWarnings("unchecked")
        public Stream geocode(String address, long maxResults) {
            if (address.isEmpty()) {
                return Stream.empty();
            }
            throttler.waitForThrottle();
            Object value = JsonUtil.loadJson(String.format(GEOCODE_URL, credentials(this.config)) + Util.encodeUrlComponent(address)).findFirst().orElse(null);
            if (value instanceof Map) {
                Map map = (Map) value;
                if (map.get("status").equals("OVER_QUERY_LIMIT")) throw new IllegalStateException("QUOTA_EXCEEDED from geocode API: "+map.get("status")+" message: "+map.get("error_message"));
                Object results = map.get("results");
                if (results instanceof List) {
                    return ((List>) results).stream().limit(maxResults).map(data -> {
                        Map location = (Map) ((Map) data.get("geometry")).get("location");
                        return new GeoCodeResult(toDouble(location.get("lat")), toDouble(location.get("lng")), valueOf(data.get("formatted_address")), data);
                    });
                }
            }
            throw new RuntimeException("Can't parse geocoding results " + value);
        }

        @Override
        public Stream reverseGeocode(Double latitude, Double longitude) {
            if (latitude == null || longitude == null) {
                return Stream.empty();
            }
            throttler.waitForThrottle();
            Object value = JsonUtil.loadJson(String.format(REVERSE_GEOCODE_URL, credentials(this.config)) + Util.encodeUrlComponent(latitude+","+longitude)).findFirst().orElse(null);
            if (value instanceof Map) {
                Map map = (Map) value;
                if (map.get("status").equals("OVER_QUERY_LIMIT")) throw new IllegalStateException("QUOTA_EXCEEDED from geocode API: "+map.get("status")+" message: "+map.get("error_message"));
                Object results = map.get("results");
                if (results instanceof List) {
                    return ((List>) results).stream().limit(1).map(data -> {
                        Map location = (Map) ((Map) data.get("geometry")).get("location");
                        return new GeoCodeResult(toDouble(location.get("lat")), toDouble(location.get("lng")), valueOf(data.get("formatted_address")), data);
                    });
                }
            }
            throw new RuntimeException("Can't parse reverse-geocoding results " + value);
        }
    }

    private GeocodeSupplier getSupplier(Map configMap) {
        return getSupplier(configMap, terminationGuard);
    }
    
    public static GeocodeSupplier getSupplier(Map configMap, TerminationGuard terminationGuard) {
        final AbstractMap.SimpleEntry results = getSupplierEntry(terminationGuard, configMap);
        return results.getKey();
    }
    
    public static AbstractMap.SimpleEntry getSupplierEntry(TerminationGuard terminationGuard, Map configMap) {
        Configuration activeConfig = apocConfig().getConfig().subset(PREFIX);
        // with configMap we overwrite the ApocConfig, if none of these is found, we choose the default one, 'osm'
        final String provider = (String) configMap.getOrDefault(GEOCODE_PROVIDER_KEY,
                activeConfig.getString(GEOCODE_PROVIDER_KEY, "osm"));
        
        configMap.forEach((key, value) -> {
            // we transform e.g. key `reverseUrl` to `reverse.url`, consistently to ApocConfig
            final String dotCase = key.replaceAll("[A-Z][a-z]", ".$0").toLowerCase();
            activeConfig.setProperty(provider  + "." + dotCase, value);
        });

        String supplier = provider.toLowerCase();
        final GeocodeSupplier geocodeSupplier = getGeocodeSupplier(terminationGuard, activeConfig, supplier);
        // we return both GeocodeSupplier for real implementations and String supplier for mock tests
        return new AbstractMap.SimpleEntry<>(geocodeSupplier, supplier);
    }

    private static GeocodeSupplier getGeocodeSupplier(TerminationGuard terminationGuard, Configuration activeConfig, String supplier) {
        switch (supplier) {
            case "google" : return new GoogleSupplier(activeConfig, terminationGuard);
            case "osm" : return new OSMSupplier(activeConfig, terminationGuard);
            default: return new SupplierWithKey(activeConfig, terminationGuard, supplier);
        }
    }

    @Procedure("apoc.spatial.geocodeOnce")
    @Description("Returns the geographic location (latitude, longitude, and description) of the given address using a geocoding service (default: OpenStreetMap).\n" +
            "This procedure returns at most one result.")
    public Stream geocodeOnce(@Name("location") String address, @Name(value="config", defaultValue = "{}") Map config) {
        return geocode(address, 1L, false, config);
    }

    @Procedure("apoc.spatial.geocode")
    @Description("Returns the geographic location (latitude, longitude, and description) of the given address using a geocoding service (default: OpenStreetMap).")
    public Stream geocode(@Name("location") String address, @Name(value = "maxResults",defaultValue = "100") long maxResults, @Name(value = "quotaException",defaultValue = "false") boolean quotaException, @Name(value="config", defaultValue = "{}") Map config) {
        if (address == null || address.isEmpty())
            return Stream.empty();
        else {
            try {
                return getSupplier(config).geocode(address, maxResults == 0 ? MAX_RESULTS : Math.min(Math.max(maxResults, 1), MAX_RESULTS));
            } catch (IllegalStateException re) {
                if (!quotaException && re.getMessage().startsWith("QUOTA_EXCEEDED")) return Stream.empty();
                throw re;
            }
        }
    }

    @Procedure("apoc.spatial.reverseGeocode")
    @Description("Returns a textual address from the given geographic location (latitude, longitude) using a geocoding service (default: OpenStreetMap).\n" +
            "This procedure returns at most one result.")
    public Stream reverseGeocode(@Name("latitude") double latitude, @Name("longitude") double longitude, @Name(value = "quotaException",defaultValue = "false") boolean quotaException, @Name(value="config", defaultValue = "{}") Map config) {
        try {
            return getSupplier(config).reverseGeocode(latitude, longitude);
        } catch(IllegalStateException re) {
            if (!quotaException && re.getMessage().startsWith("QUOTA_EXCEEDED")) return Stream.empty();
            throw re;
        }
    }

    public static class GeoCodeResult {
        public final Map location;
        public final Map data;
        public final Double latitude;
        public final Double longitude;
        public final String description;

        public GeoCodeResult(Double latitude, Double longitude, String description, Map data) {
            this.data = data;
            this.latitude = latitude;
            this.longitude = longitude;
            this.description = description;
            this.location = map("latitude", latitude, "longitude", longitude, "description", description);
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy