    001// License: GPL. For details, see LICENSE file.
    002package org.openstreetmap.josm.plugins.streetside.utils;
    003
    004import java.io.UnsupportedEncodingException;
    005import java.net.MalformedURLException;
    006import java.net.URL;
    007import java.net.URLEncoder;
    008import java.nio.charset.StandardCharsets;
    009import java.util.ArrayList;
    010import java.util.Arrays;
    011import java.util.EnumSet;
    012import java.util.HashMap;
    013import java.util.List;
    014import java.util.Map;
    015import java.util.Map.Entry;
    016
    017import org.openstreetmap.josm.data.Bounds;
    018import org.openstreetmap.josm.plugins.streetside.cubemap.CubemapUtils;
    019import org.openstreetmap.josm.tools.I18n;
    020import org.openstreetmap.josm.tools.Logging;
    021
    022public final class StreetsideURL {
    023        /** Base URL of the Bing Bubble API. */
    024        private static final String STREETSIDE_BASE_URL = "https://dev.virtualearth.net/mapcontrol/HumanScaleServices/GetBubbles.ashx";
    025        private static final String BASE_API_V2_URL = "https://a.mapillary.com/v2/";
    026        private static final String CLIENT_ID = "T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz";
    027        private static final String BING_MAPS_KEY = "AuftgJsO0Xs8Ts4M1xZUQJQXJNsvmh3IV8DkNieCiy3tCwCUMq76-WpkrBtNAuEm";
    028        private static final String TEST_BUBBLE_ID = "80848005";
    029
    030        private static final String STREETSIDE_PRIVACY_URL = "https://www.bing.com/maps/privacyreport/streetsideprivacyreport?bubbleid=";
    031
    032        private static final int OSM_BBOX_NORTH = 3;
    033        private static final int OSM_BBOX_SOUTH = 1;
    034        private static final int OSM_BBOXEAST = 2;
    035        private static final int OSM_BBOX_WEST = 0;
    036
    037        public static final class APIv3 {
    038                private static final String BASE_URL = "https://a.mapillary.com/v3/";
    039
    040                private APIv3() {
    041                        // Private constructor to avoid instantiation
    042                }
    043
    044                public static URL getUser(String key) {
    045                        return StreetsideURL.string2URL(APIv3.BASE_URL, "users/", key, StreetsideURL.queryString(null));
    046                }
    047
    048                /**
    049                 * @return the URL where you can create, get and approve changesets
    050                 */
    051                public static URL submitChangeset() {
    052                        return StreetsideURL.string2URL(APIv3.BASE_URL, "changesets", APIv3.queryString(null));
    053                }
    054
    055                public static URL searchDetections(Bounds bounds) {
    056                        return StreetsideURL.string2URL(APIv3.BASE_URL, "detections", APIv3.queryString(bounds));
    057                }
    058
    059                public static URL searchImages(Bounds bounds) {
    060                        return StreetsideURL.string2URL(APIv3.BASE_URL, "images", APIv3.queryStreetsideString(bounds));
    061                }
    062
    063                public static URL searchStreetsideImages(Bounds bounds) {
    064                        return StreetsideURL.string2URL(StreetsideURL.STREETSIDE_BASE_URL, APIv3.queryStreetsideString(bounds));
    065                }
    066
    067                public static URL searchMapObjects(final Bounds bounds) {
    068                        return StreetsideURL.string2URL(APIv3.BASE_URL, "objects", APIv3.queryString(bounds));
    069                }
    070
    071                public static URL searchStreetsideSequences(final Bounds bounds) {
    072                        return StreetsideURL.string2URL(StreetsideURL.STREETSIDE_BASE_URL, APIv3.queryStreetsideString(bounds));
    073                }
    074
    075                /**
    076                 * The APIv3 returns a Link header for each request. It contains a URL for requesting more results.
    077                 * If you supply the value of the Link header, this method returns the next URL,
    078                 * if such a URL is defined in the header.
    079                 * @param value the value of the HTTP-header with key "Link"
    080                 * @return the {@link URL} for the next result page, or <code>null</code> if no such URL could be found
    081                 */
    082                public static URL parseNextFromLinkHeaderValue(String value) {
    083                        if (value != null) {
    084                                // Iterate over the different entries of the Link header
    085                                for (final String link : value.split(",", Integer.MAX_VALUE)) {
    086                                        boolean isNext = false;
    087                                        URL url = null;
    088                                        // Iterate over the parts of each entry (typically it's one `rel="‹linkType›"` and one like `<https://URL>`)
    089                                        for (String linkPart : link.split(";", Integer.MAX_VALUE)) {
    090                                                linkPart = linkPart.trim();
    091                                                isNext |= linkPart.matches("rel\\s*=\\s*\"next\"");
    092                                                if (linkPart.length() >= 1 && linkPart.charAt(0) == '<' && linkPart.endsWith(">")) {
    093                                                        try {
    094                                                                url = new URL(linkPart.substring(1, linkPart.length() - 1));
    095                                                        } catch (final MalformedURLException e) {
    096                                                                Logging.log(Logging.LEVEL_WARN, "Mapillary API v3 returns a malformed URL in the Link header.", e);
    097                                                        }
    098                                                }
    099                                        }
    100                                        // If both a URL and the rel=next attribute are present, return the URL. Otherwise null is returned
    101                                        if (url != null && isNext) {
    102                                                return url;
    103                                        }
    104                                }
    105                        }
    106                        return null;
    107                }
    108
    109                public static String queryString(final Bounds bounds) {
    110                        if (bounds != null) {
    111                                final Map<String, String> parts = new HashMap<>();
    112                                parts.put("bbox", bounds.toBBox().toStringCSV(","));
    113                                return StreetsideURL.queryString(parts);
    114                        }
    115                        return StreetsideURL.queryString(null);
    116                }
    117
    118                public static String queryStreetsideString(final Bounds bounds) {
    119                        if (bounds != null) {
    120                                final Map<String, String> parts = new HashMap<>();
    121                                parts.put("bbox", bounds.toBBox().toStringCSV(","));
    122                                return StreetsideURL.queryStreetsideBoundsString(parts);
    123                        }
    124                        return StreetsideURL.queryStreetsideBoundsString(null);
    125                }
    126
    127                /**
    128                 * @return the URL where you'll find information about the user account as JSON
    129                 */
    130                public static URL userURL() {
    131                        return StreetsideURL.string2URL(APIv3.BASE_URL, "me", StreetsideURL.queryString(null));
    132                }
    133        }
    134
    135        public static final class VirtualEarth {
    136                private static final String BASE_URL_PREFIX = "https://t.ssl.ak.tiles.virtualearth.net/tiles/hs";
    137                private static final String BASE_URL_SUFFIX = ".jpg?g=6338&n=z";
    138
    139                private VirtualEarth() {
    140                        // Private constructor to avoid instantiation
    141                }
    142
    143                public static URL streetsideTile(String id, boolean thumbnail) {
    144                        if(thumbnail) {
    145                                id = id + "01";
    146                        }
    147                        URL url = StreetsideURL.string2URL(VirtualEarth.BASE_URL_PREFIX + id + VirtualEarth.BASE_URL_SUFFIX);
    148                        Logging.info("Tile task URL {0} invoked.", url.toString());
    149                        return url;
    150                }
    151        }
    152
    153        public static final class MainWebsite {
    154                private static final String BASE_URL = "https://www.mapillary.com/";
    155
    156                private MainWebsite() {
    157                        // Private constructor to avoid instantiation
    158                }
    159
    160                /**
    161                 * Gives you the URL for the online viewer of a specific Streetside image.
    162                 * @param id the id of the image to which you want to link
    163                 * @return the URL of the online viewer for the image with the given image key
    164                 * @throws IllegalArgumentException if the image key is <code>null</code>
    165                 */
    166                public static URL browseImage(String id) {
    167                        if (id == null) {
    168                                throw new IllegalArgumentException("The image key must not be null!");
    169                        }
    170                        return StreetsideURL.string2URL(VirtualEarth.BASE_URL_PREFIX + id + VirtualEarth.BASE_URL_SUFFIX);
    171                }
    172
    173                /**
    174                 * Gives you the URL for the blur editor of the image with the given key.
    175                 * @param key the key of the image for which you want to open the blur editor
    176                 * @return the URL of the blur editor
    177                 * @throws IllegalArgumentException if the image key is <code>null</code>
    178                 */
    179                public static URL blurEditImage(final String key) {
    180                        if (key == null) {
    181                                throw new IllegalArgumentException("The image key must not be null!");
    182                        }
    183                        String urlEncodedKey;
    184                        try {
    185                                urlEncodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8.name());
    186                        } catch (final UnsupportedEncodingException e) {
    187                                Logging.log(Logging.LEVEL_ERROR, "Unsupported encoding when URL encoding", e);
    188                                urlEncodedKey = key;
    189                        }
    190                        return StreetsideURL.string2URL(MainWebsite.BASE_URL, "app/blur?focus=photo&pKey=", urlEncodedKey);
    191                }
    192
    193                /**
    194                 * Gives you the URL for the blur editor of the image with the given key.
    195                 * @param id the key of the image for which you want to open the blur editor
    196                 * @return the URL of the blur editor
    197                 * @throws IllegalArgumentException if the image key is <code>null</code>
    198                 */
    199                public static URL streetsidePrivacyLink(final String id) {
    200                        if (id == null) {
    201                                throw new IllegalArgumentException("The image id must not be null!");
    202                        }
    203                        String urlEncodedId;
    204                        try {
    205                                urlEncodedId = URLEncoder.encode(id, StandardCharsets.UTF_8.name());
    206                        } catch (final UnsupportedEncodingException e) {
    207                                Logging.log(Logging.LEVEL_ERROR, "Unsupported encoding when URL encoding", e);
    208                                urlEncodedId = id;
    209                        }
    210                        return StreetsideURL.string2URL(StreetsideURL.STREETSIDE_PRIVACY_URL, urlEncodedId);
    211                }
    212
    213                /**
    214                 * Gives you the URL which the user should visit to initiate the OAuth authentication process
    215                 * @param redirectURI the URI to which the user will be redirected when the authentication is finished.
    216                 *        When this is <code>null</code>, it's omitted from the query string.
    217                 * @return the URL that the user should visit to start the OAuth authentication
    218                 */
    219                public static URL connect(String redirectURI) {
    220                        final HashMap<String, String> parts = new HashMap<>();
    221                        if (redirectURI != null && redirectURI.length() >= 1) {
    222                                parts.put("redirect_uri", redirectURI);
    223                        }
    224                        parts.put("response_type", "token");
    225                        parts.put("scope", "user:read public:upload public:write");
    226                        return StreetsideURL.string2URL(MainWebsite.BASE_URL, "connect", StreetsideURL.queryString(parts));
    227                }
    228
    229                public static URL mapObjectIcon(String key) {
    230                        return StreetsideURL.string2URL(MainWebsite.BASE_URL, "developer/api-documentation/images/traffic_sign/" + key + ".png");
    231                }
    232        }
    233
    234        private StreetsideURL() {
    235                // Private constructor to avoid instantiation
    236        }
    237
    238        public static URL[] string2URLs(String baseUrlPrefix, String cubemapImageId, String baseUrlSuffix) {
    239                List<URL> res = new ArrayList<>();
    240
    241                switch (StreetsideProperties.SHOW_HIGH_RES_STREETSIDE_IMAGERY.get() ? 16 : 4) {
    242
    243                case 16:
    244
    245                        EnumSet.allOf(CubemapUtils.CubemapFaces.class).forEach(face -> {
    246                                for (int i = 0; i < 4; i++) {
    247                                        for (int j = 0; j < 4; j++) {
    248                                                try {
    249                                                        final String urlStr = baseUrlPrefix + cubemapImageId
    250                                                                        + CubemapUtils.rowCol2StreetsideCellAddressMap
    251                                                                                        .get(String.valueOf(i) + String.valueOf(j))
    252                                                                        + baseUrlSuffix;
    253                                                        res.add(new URL(urlStr));
    254                                                } catch (final MalformedURLException e) {
    255                                                        Logging.error(I18n.tr("Error creating URL String for cubemap {0}", cubemapImageId));
    256                                                        e.printStackTrace();
    257                                                }
    258
    259                                        }
    260                                }
    261                        });
    262
    263                case 4:
    264                        EnumSet.allOf(CubemapUtils.CubemapFaces.class).forEach(face -> {
    265                                for (int i = 0; i < 4; i++) {
    266
    267                                        try {
    268                                                final String urlStr = baseUrlPrefix + cubemapImageId
    269                                                                + CubemapUtils.rowCol2StreetsideCellAddressMap.get(String.valueOf(i)) + baseUrlSuffix;
    270                                                res.add(new URL(urlStr));
    271                                        } catch (final MalformedURLException e) {
    272                                                Logging.error(I18n.tr("Error creating URL String for cubemap {0}", cubemapImageId));
    273                                                e.printStackTrace();
    274                                        }
    275
    276                                }
    277                        });
    278                        break; // break is optional
    279                default:
    280                        // Statements
    281                }
    282                return res.stream().toArray(URL[]::new);
    283        }
    284
    285        /**
    286         * @return the URL where you'll find the upload secrets as JSON
    287         */
    288        public static URL uploadSecretsURL() {
    289                return StreetsideURL.string2URL(StreetsideURL.BASE_API_V2_URL, "me/uploads/secrets", StreetsideURL.queryString(null));
    290        }
    291
    292        /**
    293         * Builds a query string from it's parts that are supplied as a {@link Map}
    294         * @param parts the parts of the query string
    295         * @return the constructed query string (including a leading ?)
    296         */
    297        static String queryString(Map<String, String> parts) {
    298                final StringBuilder ret = new StringBuilder("?client_id=").append(StreetsideURL.CLIENT_ID);
    299                if (parts != null) {
    300                        for (final Entry<String, String> entry : parts.entrySet()) {
    301                                try {
    302                                        ret.append('&')
    303                                        .append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8.name()))
    304                                        .append('=')
    305                                        .append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8.name()));
    306                                } catch (final UnsupportedEncodingException e) {
    307                                        Logging.error(e); // This should not happen, as the encoding is hard-coded
    308                                }
    309                        }
    310                }
    311                return ret.toString();
    312        }
    313
    314        static String queryStreetsideBoundsString(Map<String, String> parts) {
    315                final StringBuilder ret = new StringBuilder("?n=");
    316                if (parts != null) {
    317                        final List<String> bbox = new ArrayList<>(Arrays.asList(parts.get("bbox").split(",")));
    318                        try {
    319                                ret.append(URLEncoder.encode(bbox.get(StreetsideURL.OSM_BBOX_NORTH), StandardCharsets.UTF_8.name()))
    320                                .append("&s=")
    321                                .append(URLEncoder.encode(bbox.get(StreetsideURL.OSM_BBOX_SOUTH), StandardCharsets.UTF_8.name()))
    322                                .append("&e=")
    323                                .append(URLEncoder.encode(bbox.get(StreetsideURL.OSM_BBOXEAST), StandardCharsets.UTF_8.name()))
    324                                .append("&w=")
    325                                .append(URLEncoder.encode(bbox.get(StreetsideURL.OSM_BBOX_WEST), StandardCharsets.UTF_8.name()))
    326                                .append("&c=1000")
    327                                .append("&appkey=")
    328                                .append(StreetsideURL.BING_MAPS_KEY);
    329                        } catch (final UnsupportedEncodingException e) {
    330                                Logging.error(e); // This should not happen, as the encoding is hard-coded
    331                        }
    332                }
    333                return ret.toString();
    334        }
    335
    336        static String queryByIdString(Map<String, String> parts) {
    337                final StringBuilder ret = new StringBuilder("?id=");
    338                try {
    339                        ret.append(URLEncoder.encode(StreetsideURL.TEST_BUBBLE_ID, StandardCharsets.UTF_8.name()));
    340                        ret.append('&').append(URLEncoder.encode("appkey=", StandardCharsets.UTF_8.name())).append('=')
    341                        .append(URLEncoder.encode(StreetsideURL.BING_MAPS_KEY, StandardCharsets.UTF_8.name()));
    342                } catch (final UnsupportedEncodingException e) {
    343                        Logging.error(e); // This should not happen, as the encoding is hard-coded
    344                }
    345                return ret.toString();
    346        }
    347
    348        /**
    349         * Converts a {@link String} into a {@link URL} without throwing a {@link MalformedURLException}.
    350         * Instead such an exception will lead to an {@link Logging#error(Throwable)}.
    351         * So you should be very confident that your URL is well-formed when calling this method.
    352         * @param strings the Strings describing the URL
    353         * @return the URL that is constructed from the given string
    354         */
    355        static URL string2URL(String... strings) {
    356                final StringBuilder builder = new StringBuilder();
    357                for (int i = 0; strings != null && i < strings.length; i++) {
    358                        builder.append(strings[i]);
    359                }
    360                try {
    361                        return new URL(builder.toString());
    362                } catch (final MalformedURLException e) {
    363                        Logging.log(Logging.LEVEL_ERROR, String.format(
    364                                        "The class '%s' produces malformed URLs like '%s'!",
    365                                        StreetsideURL.class.getName(),
    366                                        builder
    367                                        ), e);
    368                        return null;
    369                }
    370        }
    371}