001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.preferences.server;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Component;
007import java.io.BufferedReader;
008import java.io.IOException;
009import java.io.InputStreamReader;
010import java.net.HttpURLConnection;
011import java.net.MalformedURLException;
012import java.net.URL;
013import java.nio.charset.StandardCharsets;
014
015import javax.swing.JOptionPane;
016
017import org.openstreetmap.josm.Main;
018import org.openstreetmap.josm.gui.HelpAwareOptionPane;
019import org.openstreetmap.josm.gui.PleaseWaitRunnable;
020import org.openstreetmap.josm.gui.help.HelpUtil;
021import org.openstreetmap.josm.io.OsmTransferException;
022import org.openstreetmap.josm.tools.CheckParameterUtil;
023import org.openstreetmap.josm.tools.Utils;
024import org.xml.sax.SAXException;
025
026/**
027 * This is an asynchronous task for testing whether an URL points to an OSM API server.
028 * It tries to retrieve a list of changesets from the given URL. If it succeeds, the method
029 * {@link #isSuccess()} replies true, otherwise false.
030 *
031 * Note: it fetches a list of changesets instead of the much smaller capabilities because - strangely enough -
032 * an OSM server "https://x.y.y/api/0.6" not only responds to  "https://x.y.y/api/0.6/capabilities" but also
033 * to "https://x.y.y/api/0/capabilities" or "https://x.y.y/a/capabilities" with valid capabilities. If we get
034 * valid capabilities with an URL we therefore can't be sure that the base URL is valid API URL.
035 *
036 */
037public class ApiUrlTestTask extends PleaseWaitRunnable{
038
039    private String url;
040    private boolean canceled;
041    private boolean success;
042    private Component parent;
043    private HttpURLConnection connection;
044
045    /**
046     * Creates the task
047     *
048     * @param parent the parent component relative to which the {@link PleaseWaitRunnable}-Dialog is displayed
049     * @param url the url. Must not be null.
050     * @throws IllegalArgumentException thrown if url is null.
051     */
052    public ApiUrlTestTask(Component parent, String url) throws IllegalArgumentException {
053        super(parent, tr("Testing OSM API URL ''{0}''", url), false /* don't ignore exceptions */);
054        CheckParameterUtil.ensureParameterNotNull(url,"url");
055        this.parent = parent;
056        this.url = url;
057    }
058
059    protected void alertInvalidUrl(String url) {
060        HelpAwareOptionPane.showOptionDialog(
061                parent,
062                tr("<html>"
063                        + "''{0}'' is not a valid OSM API URL.<br>"
064                        + "Please check the spelling and validate again."
065                        + "</html>",
066                        url
067                ),
068                tr("Invalid API URL"),
069                JOptionPane.ERROR_MESSAGE,
070                HelpUtil.ht("/Preferences/Connection#InvalidAPIUrl")
071        );
072    }
073
074    protected void alertInvalidChangesetUrl(String url) {
075        HelpAwareOptionPane.showOptionDialog(
076                parent,
077                tr("<html>"
078                        + "Failed to build URL ''{0}'' for validating the OSM API server.<br>"
079                        + "Please check the spelling of ''{1}'' and validate again."
080                        +"</html>",
081                        url,
082                        getNormalizedApiUrl()
083                ),
084                tr("Invalid API URL"),
085                JOptionPane.ERROR_MESSAGE,
086                HelpUtil.ht("/Preferences/Connection#InvalidAPIGetChangesetsUrl")
087        );
088    }
089
090    protected void alertConnectionFailed() {
091        HelpAwareOptionPane.showOptionDialog(
092                parent,
093                tr("<html>"
094                        + "Failed to connect to the URL ''{0}''.<br>"
095                        + "Please check the spelling of ''{1}'' and your Internet connection and validate again."
096                        +"</html>",
097                        url,
098                        getNormalizedApiUrl()
099                ),
100                tr("Connection to API failed"),
101                JOptionPane.ERROR_MESSAGE,
102                HelpUtil.ht("/Preferences/Connection#ConnectionToAPIFailed")
103        );
104    }
105
106    protected void alertInvalidServerResult(int retCode) {
107        HelpAwareOptionPane.showOptionDialog(
108                parent,
109                tr("<html>"
110                        + "Failed to retrieve a list of changesets from the OSM API server at<br>"
111                        + "''{1}''. The server responded with the return code {0} instead of 200.<br>"
112                        + "Please check the spelling of ''{1}'' and validate again."
113                        + "</html>",
114                        retCode,
115                        getNormalizedApiUrl()
116                ),
117                tr("Connection to API failed"),
118                JOptionPane.ERROR_MESSAGE,
119                HelpUtil.ht("/Preferences/Connection#InvalidServerResult")
120        );
121    }
122
123    protected void alertInvalidChangesetList() {
124        HelpAwareOptionPane.showOptionDialog(
125                parent,
126                tr("<html>"
127                        + "The OSM API server at ''{0}'' did not return a valid response.<br>"
128                        + "It is likely that ''{0}'' is not an OSM API server.<br>"
129                        + "Please check the spelling of ''{0}'' and validate again."
130                        + "</html>",
131                        getNormalizedApiUrl()
132                ),
133                tr("Connection to API failed"),
134                JOptionPane.ERROR_MESSAGE,
135                HelpUtil.ht("/Preferences/Connection#InvalidSettings")
136        );
137    }
138
139    @Override
140    protected void cancel() {
141        canceled = true;
142        synchronized(this) {
143            if (connection != null) {
144                connection.disconnect();
145            }
146        }
147    }
148
149    @Override
150    protected void finish() {}
151
152    /**
153     * Removes leading and trailing whitespace from the API URL and removes trailing
154     * '/'.
155     *
156     * @return the normalized API URL
157     */
158    protected String getNormalizedApiUrl() {
159        String apiUrl = url.trim();
160        while(apiUrl.endsWith("/")) {
161            apiUrl = apiUrl.substring(0, apiUrl.lastIndexOf('/'));
162        }
163        return apiUrl;
164    }
165
166    @Override
167    protected void realRun() throws SAXException, IOException, OsmTransferException {
168        try {
169            try {
170                new URL(getNormalizedApiUrl());
171            } catch(MalformedURLException e) {
172                alertInvalidUrl(getNormalizedApiUrl());
173                return;
174            }
175            URL capabilitiesUrl;
176            String getChangesetsUrl = getNormalizedApiUrl() + "/0.6/changesets";
177            try {
178                capabilitiesUrl = new URL(getChangesetsUrl);
179            } catch(MalformedURLException e) {
180                alertInvalidChangesetUrl(getChangesetsUrl);
181                return;
182            }
183
184            synchronized(this) {
185                connection = Utils.openHttpConnection(capabilitiesUrl);
186            }
187            connection.setDoInput(true);
188            connection.setDoOutput(false);
189            connection.setRequestMethod("GET");
190            connection.connect();
191
192            if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
193                alertInvalidServerResult(connection.getResponseCode());
194                return;
195            }
196            StringBuilder changesets = new StringBuilder();
197            try (BufferedReader bin = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
198                String line;
199                while ((line = bin.readLine()) != null) {
200                    changesets.append(line).append("\n");
201                }
202            }
203            if (! (changesets.toString().contains("<osm") && changesets.toString().contains("</osm>"))) {
204                // heuristic: if there isn't an opening and closing "<osm>" tag in the returned content,
205                // then we didn't get a list of changesets in return. Could be replaced by explicitly parsing
206                // the result but currently not worth the effort.
207                alertInvalidChangesetList();
208                return;
209            }
210            success = true;
211        } catch(IOException e) {
212            if (canceled)
213                // ignore exceptions
214                return;
215            Main.error(e);
216            alertConnectionFailed();
217            return;
218        }
219    }
220
221    /**
222     * Determines if the test has been canceled.
223     * @return {@code true} if canceled, {@code false} otherwise
224     */
225    public boolean isCanceled() {
226        return canceled;
227    }
228
229    /**
230     * Determines if the test has succeeded.
231     * @return {@code true} if success, {@code false} otherwise
232     */
233    public boolean isSuccess() {
234        return success;
235    }
236}