StreetsideURL.java

// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.plugins.streetside.utils;

import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.openstreetmap.josm.data.Bounds;
import org.openstreetmap.josm.plugins.streetside.cubemap.CubemapUtils;
import org.openstreetmap.josm.tools.I18n;
import org.openstreetmap.josm.tools.Logging;

public final class StreetsideURL {
	/** Base URL of the Bing Bubble API. */
	private static final String STREETSIDE_BASE_URL = "https://dev.virtualearth.net/mapcontrol/HumanScaleServices/GetBubbles.ashx";
	private static final String BASE_API_V2_URL = "https://a.mapillary.com/v2/";
	private static final String CLIENT_ID = "T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz";
	private static final String BING_MAPS_KEY = "AuftgJsO0Xs8Ts4M1xZUQJQXJNsvmh3IV8DkNieCiy3tCwCUMq76-WpkrBtNAuEm";
	private static final String TEST_BUBBLE_ID = "80848005";

	private static final String STREETSIDE_PRIVACY_URL = "https://www.bing.com/maps/privacyreport/streetsideprivacyreport?bubbleid=";

	private static final int OSM_BBOX_NORTH = 3;
	private static final int OSM_BBOX_SOUTH = 1;
	private static final int OSM_BBOXEAST = 2;
	private static final int OSM_BBOX_WEST = 0;

	public static final class APIv3 {
		private static final String BASE_URL = "https://a.mapillary.com/v3/";

		private APIv3() {
			// Private constructor to avoid instantiation
		}

		public static URL getUser(String key) {
			return StreetsideURL.string2URL(APIv3.BASE_URL, "users/", key, StreetsideURL.queryString(null));
		}

		/**
		 * @return the URL where you can create, get and approve changesets
		 */
		public static URL submitChangeset() {
			return StreetsideURL.string2URL(APIv3.BASE_URL, "changesets", APIv3.queryString(null));
		}

		public static URL searchDetections(Bounds bounds) {
			return StreetsideURL.string2URL(APIv3.BASE_URL, "detections", APIv3.queryString(bounds));
		}

		public static URL searchImages(Bounds bounds) {
			return StreetsideURL.string2URL(APIv3.BASE_URL, "images", APIv3.queryStreetsideString(bounds));
		}

		public static URL searchStreetsideImages(Bounds bounds) {
			return StreetsideURL.string2URL(StreetsideURL.STREETSIDE_BASE_URL, APIv3.queryStreetsideString(bounds));
		}

		public static URL searchMapObjects(final Bounds bounds) {
			return StreetsideURL.string2URL(APIv3.BASE_URL, "objects", APIv3.queryString(bounds));
		}

		public static URL searchStreetsideSequences(final Bounds bounds) {
			return StreetsideURL.string2URL(StreetsideURL.STREETSIDE_BASE_URL, APIv3.queryStreetsideString(bounds));
		}

		/**
		 * The APIv3 returns a Link header for each request. It contains a URL for requesting more results.
		 * If you supply the value of the Link header, this method returns the next URL,
		 * if such a URL is defined in the header.
		 * @param value the value of the HTTP-header with key "Link"
		 * @return the {@link URL} for the next result page, or <code>null</code> if no such URL could be found
		 */
		public static URL parseNextFromLinkHeaderValue(String value) {
			if (value != null) {
				// Iterate over the different entries of the Link header
				for (final String link : value.split(",", Integer.MAX_VALUE)) {
					boolean isNext = false;
					URL url = null;
					// Iterate over the parts of each entry (typically it's one `rel="‹linkType›"` and one like `<https://URL>`)
					for (String linkPart : link.split(";", Integer.MAX_VALUE)) {
						linkPart = linkPart.trim();
						isNext |= linkPart.matches("rel\\s*=\\s*\"next\"");
						if (linkPart.length() >= 1 && linkPart.charAt(0) == '<' && linkPart.endsWith(">")) {
							try {
								url = new URL(linkPart.substring(1, linkPart.length() - 1));
							} catch (final MalformedURLException e) {
								Logging.log(Logging.LEVEL_WARN, "Mapillary API v3 returns a malformed URL in the Link header.", e);
							}
						}
					}
					// If both a URL and the rel=next attribute are present, return the URL. Otherwise null is returned
					if (url != null && isNext) {
						return url;
					}
				}
			}
			return null;
		}

		public static String queryString(final Bounds bounds) {
			if (bounds != null) {
				final Map<String, String> parts = new HashMap<>();
				parts.put("bbox", bounds.toBBox().toStringCSV(","));
				return StreetsideURL.queryString(parts);
			}
			return StreetsideURL.queryString(null);
		}

		public static String queryStreetsideString(final Bounds bounds) {
			if (bounds != null) {
				final Map<String, String> parts = new HashMap<>();
				parts.put("bbox", bounds.toBBox().toStringCSV(","));
				return StreetsideURL.queryStreetsideBoundsString(parts);
			}
			return StreetsideURL.queryStreetsideBoundsString(null);
		}

		/**
		 * @return the URL where you'll find information about the user account as JSON
		 */
		public static URL userURL() {
			return StreetsideURL.string2URL(APIv3.BASE_URL, "me", StreetsideURL.queryString(null));
		}
	}

	public static final class VirtualEarth {
		private static final String BASE_URL_PREFIX = "https://t.ssl.ak.tiles.virtualearth.net/tiles/hs";
		private static final String BASE_URL_SUFFIX = ".jpg?g=6338&n=z";

		private VirtualEarth() {
			// Private constructor to avoid instantiation
		}

		public static URL streetsideTile(String id, boolean thumbnail) {
			if(thumbnail) {
				id = id + "01";
			}
			URL url = StreetsideURL.string2URL(VirtualEarth.BASE_URL_PREFIX + id + VirtualEarth.BASE_URL_SUFFIX);
			Logging.info("Tile task URL {0} invoked.", url.toString());
			return url;
		}
	}

	public static final class MainWebsite {
		private static final String BASE_URL = "https://www.mapillary.com/";

		private MainWebsite() {
			// Private constructor to avoid instantiation
		}

		/**
		 * Gives you the URL for the online viewer of a specific Streetside image.
		 * @param id the id of the image to which you want to link
		 * @return the URL of the online viewer for the image with the given image key
		 * @throws IllegalArgumentException if the image key is <code>null</code>
		 */
		public static URL browseImage(String id) {
			if (id == null) {
				throw new IllegalArgumentException("The image key must not be null!");
			}
			return StreetsideURL.string2URL(VirtualEarth.BASE_URL_PREFIX + id + VirtualEarth.BASE_URL_SUFFIX);
		}

		/**
		 * Gives you the URL for the blur editor of the image with the given key.
		 * @param key the key of the image for which you want to open the blur editor
		 * @return the URL of the blur editor
		 * @throws IllegalArgumentException if the image key is <code>null</code>
		 */
		public static URL blurEditImage(final String key) {
			if (key == null) {
				throw new IllegalArgumentException("The image key must not be null!");
			}
			String urlEncodedKey;
			try {
				urlEncodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8.name());
			} catch (final UnsupportedEncodingException e) {
				Logging.log(Logging.LEVEL_ERROR, "Unsupported encoding when URL encoding", e);
				urlEncodedKey = key;
			}
			return StreetsideURL.string2URL(MainWebsite.BASE_URL, "app/blur?focus=photo&pKey=", urlEncodedKey);
		}

		/**
		 * Gives you the URL for the blur editor of the image with the given key.
		 * @param key the key of the image for which you want to open the blur editor
		 * @return the URL of the blur editor
		 * @throws IllegalArgumentException if the image key is <code>null</code>
		 */
		public static URL streetsidePrivacyLink(final String id) {
			if (id == null) {
				throw new IllegalArgumentException("The image id must not be null!");
			}
			String urlEncodedId;
			try {
				urlEncodedId = URLEncoder.encode(id, StandardCharsets.UTF_8.name());
			} catch (final UnsupportedEncodingException e) {
				Logging.log(Logging.LEVEL_ERROR, "Unsupported encoding when URL encoding", e);
				urlEncodedId = id;
			}
			return StreetsideURL.string2URL(StreetsideURL.STREETSIDE_PRIVACY_URL, urlEncodedId);
		}

		/**
		 * Gives you the URL which the user should visit to initiate the OAuth authentication process
		 * @param redirectURI the URI to which the user will be redirected when the authentication is finished.
		 *        When this is <code>null</code>, it's omitted from the query string.
		 * @return the URL that the user should visit to start the OAuth authentication
		 */
		public static URL connect(String redirectURI) {
			final HashMap<String, String> parts = new HashMap<>();
			if (redirectURI != null && redirectURI.length() >= 1) {
				parts.put("redirect_uri", redirectURI);
			}
			parts.put("response_type", "token");
			parts.put("scope", "user:read public:upload public:write");
			return StreetsideURL.string2URL(MainWebsite.BASE_URL, "connect", StreetsideURL.queryString(parts));
		}

		public static URL mapObjectIcon(String key) {
			return StreetsideURL.string2URL(MainWebsite.BASE_URL, "developer/api-documentation/images/traffic_sign/" + key + ".png");
		}
	}

	private StreetsideURL() {
		// Private constructor to avoid instantiation
	}

	public static URL[] string2URLs(String baseUrlPrefix, String cubemapImageId, String baseUrlSuffix) {
		List<URL> res = new ArrayList<>();

		switch (StreetsideProperties.SHOW_HIGH_RES_STREETSIDE_IMAGERY.get() ? 16 : 4) {

		case 16:

			EnumSet.allOf(CubemapUtils.CubemapFaces.class).forEach(face -> {
				for (int i = 0; i < 4; i++) {
					for (int j = 0; j < 4; j++) {
						try {
							final String urlStr = baseUrlPrefix + cubemapImageId
									+ CubemapUtils.rowCol2StreetsideCellAddressMap
											.get(String.valueOf(i) + String.valueOf(j))
									+ baseUrlSuffix;
							res.add(new URL(urlStr));
						} catch (final MalformedURLException e) {
							Logging.error(I18n.tr("Error creating URL String for cubemap {0}", cubemapImageId));
							e.printStackTrace();
						}

					}
				}
			});

		case 4:
			EnumSet.allOf(CubemapUtils.CubemapFaces.class).forEach(face -> {
				for (int i = 0; i < 4; i++) {

					try {
						final String urlStr = baseUrlPrefix + cubemapImageId
								+ CubemapUtils.rowCol2StreetsideCellAddressMap.get(String.valueOf(i)) + baseUrlSuffix;
						res.add(new URL(urlStr));
					} catch (final MalformedURLException e) {
						Logging.error(I18n.tr("Error creating URL String for cubemap {0}", cubemapImageId));
						e.printStackTrace();
					}

				}
			});
			break; // break is optional
		default:
			// Statements
		}
		return res.stream().toArray(URL[]::new);
	}

	/**
	 * @return the URL where you'll find the upload secrets as JSON
	 */
	public static URL uploadSecretsURL() {
		return StreetsideURL.string2URL(StreetsideURL.BASE_API_V2_URL, "me/uploads/secrets", StreetsideURL.queryString(null));
	}

	/**
	 * Builds a query string from it's parts that are supplied as a {@link Map}
	 * @param parts the parts of the query string
	 * @return the constructed query string (including a leading ?)
	 */
	static String queryString(Map<String, String> parts) {
		final StringBuilder ret = new StringBuilder("?client_id=").append(StreetsideURL.CLIENT_ID);
		if (parts != null) {
			for (final Entry<String, String> entry : parts.entrySet()) {
				try {
					ret.append('&')
					.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8.name()))
					.append('=')
					.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8.name()));
				} catch (final UnsupportedEncodingException e) {
					Logging.error(e); // This should not happen, as the encoding is hard-coded
				}
			}
		}
		return ret.toString();
	}

	static String queryStreetsideBoundsString(Map<String, String> parts) {
		final StringBuilder ret = new StringBuilder("?n=");
		if (parts != null) {
			final List<String> bbox = new ArrayList<>(Arrays.asList(parts.get("bbox").split(",")));
			try {
				ret.append(URLEncoder.encode(bbox.get(StreetsideURL.OSM_BBOX_NORTH), StandardCharsets.UTF_8.name()))
				.append("&s=")
				.append(URLEncoder.encode(bbox.get(StreetsideURL.OSM_BBOX_SOUTH), StandardCharsets.UTF_8.name()))
				.append("&e=")
				.append(URLEncoder.encode(bbox.get(StreetsideURL.OSM_BBOXEAST), StandardCharsets.UTF_8.name()))
				.append("&w=")
				.append(URLEncoder.encode(bbox.get(StreetsideURL.OSM_BBOX_WEST), StandardCharsets.UTF_8.name()))
				.append("&c=1000")
				.append("&appkey=")
				.append(StreetsideURL.BING_MAPS_KEY);
			} catch (final UnsupportedEncodingException e) {
				Logging.error(e); // This should not happen, as the encoding is hard-coded
			}
		}
		return ret.toString();
	}

	/**
	 *
	 */
	static String queryByIdString(Map<String, String> parts) {
		final StringBuilder ret = new StringBuilder("?id=");
		try {
			ret.append(URLEncoder.encode(StreetsideURL.TEST_BUBBLE_ID, StandardCharsets.UTF_8.name()));
			ret.append('&').append(URLEncoder.encode("appkey=", StandardCharsets.UTF_8.name())).append('=')
			.append(URLEncoder.encode(StreetsideURL.BING_MAPS_KEY, StandardCharsets.UTF_8.name()));
		} catch (final UnsupportedEncodingException e) {
			Logging.error(e); // This should not happen, as the encoding is hard-coded
		}
		return ret.toString();
	}

	/**
	 * Converts a {@link String} into a {@link URL} without throwing a {@link MalformedURLException}.
	 * Instead such an exception will lead to an {@link Logging#error(Throwable)}.
	 * So you should be very confident that your URL is well-formed when calling this method.
	 * @param strings the Strings describing the URL
	 * @return the URL that is constructed from the given string
	 */
	static URL string2URL(String... strings) {
		final StringBuilder builder = new StringBuilder();
		for (int i = 0; strings != null && i < strings.length; i++) {
			builder.append(strings[i]);
		}
		try {
			return new URL(builder.toString());
		} catch (final MalformedURLException e) {
			Logging.log(Logging.LEVEL_ERROR, String.format(
					"The class '%s' produces malformed URLs like '%s'!",
					StreetsideURL.class.getName(),
					builder
					), e);
			return null;
		}
	}
}