Java tutorial
/* * Copyright (c) 2010 Ant Kutschera, maxant * * This file is part of the maxant J2ME library. * * This is free software: you can redistribute it and/or modify * it under the terms of the Lesser GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * It is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * Lesser GNU General Public License for more details. * You should have received a copy of the Lesser GNU General Public License * along with Foobar. If not, see <http://www.gnu.org/licenses/>. */ package uk.co.maxant.j2me.core; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Enumeration; import java.util.Hashtable; import java.util.NoSuchElementException; import java.util.Vector; import javax.microedition.io.Connector; import javax.microedition.io.HttpConnection; import org.apache.catalina.util.URLEncoder; import org.apache.commons.codec.binary.Base64; /** * the main class in a framework for making asynchronous (authenticated) http(s) requests to the server. * * override this class and add properties using methods which make sense to the context. * * provides very basic cookie support. eg session IDs are passed back and forwards, maintaining a session * for each instance of this class. * * the result is expected to be a line-break-seperated string surrounded by html and body tags, * with a preceeding "OK" or "ERROR". * * in case of an error the listener is informed directly. in case of an ok response, the {@link #handleSuccess(String)} * method is called which is free to parse the string as required. depending upon the outcome of the result * the listener should be called by the subclass implementing the {@link #handleSuccess(String)} method. * * typically a subclass implements a method like "callServer(Model myModel)" which serialises the model into * the request properties, and calls {@link #doCallAsync()}. before serialising and using the {@link #properties}, * it should clear them, as they may still contain information from a previous request. * * supports both basic authentication as well as automatic form login. in fact if a {@link #credentialsProvider} * exists, then the basic authentication http header is included in all requests (making it very important to * ensure all requests are over SSL otherwise passwords are not encrypted. if the server requires form login * as specified in the Java Servlet specification, the server forwards the client to the login page. This class * parses the response and if it finds the "j_security_check" token, it submits the authentication request * by completing the form and passing the username and password back to the server. this results in the * response to the original request being returned and this class then calls the {@link #handleSuccess(String)} * method in order to parse the successful response. if a login fails, no further attempts are made and the * {@link Listener#onError(int, String)} method of the listener is called passing it * the {@link HttpConnection#HTTP_UNAUTHORIZED} code value, so that the client can direct the user to check * their credentials. * * the response which is returned from the server is expected to be wrapped in <html> and <body> tags, as * some mobile device ISPs force the response to be html. these tags are stripped, and the resulting string * is trimmed, after which it is expected to start with "OK" or "ERROR". in any other case the * {@link Listener#onError(int, String)} method is called. if the resulting string starts with "ERROR", then * that is removed and the resulting trimmed result is passed to the {@link Listener#onError(int, String)} * method. if the string starts with "OK", then this is stripped and the trimmed result is passed to the * {@link #handleSuccess(String)} method, which subclasses must implement. Typically they do so by * using string tokenizers to deserialise the passed string. as such, they do not need to check for * {@link NoSuchElementException}s, and can simply fetch tokens at will. Any badly constructed response * hence results in the {@link Listener#onError(int, String)} being called. Once the subclass has deserialised * the response and built a model, it can be passed to the listeners {@link Listener#onSuccess(Object)} method. * * this class supports very simple cookies. instances may re-use cookies or have their own set of cookies, * depending upon the constructor used. CARE MUST be taken, as the rudimentary implementation of adding * cookies implemented in this class does not care which path the cookies belong to! this my * be improved with later versions of this software. * * since HTTPConnection implementations may use the "Transfer-Encoding: chunked" header, ensure your server * can cope: http://blog.maxant.co.uk/pebble/2010/02/24/1266992820000.html */ public abstract class ServerCaller { private URL url; private String path; private Listener listener; protected Hashtable properties; protected boolean useSharedCookies; private URLEncoder urlEncoder = new URLEncoder(); private CredentialsProvider credentialsProvider; /** VERY basic cookies, if every instance is to share cookies. key = URL, value = vector */ protected static Hashtable cookies = new Hashtable(); /** VERY basic cookies, if each instance is to maintain its own cookies */ protected Vector myCookies = new Vector(); /** * should the result be parsed as an expected result, or just given to the * {@link #handleSuccess(String)} method? */ private boolean dontParseResult = false; /** * uses SHARED cookies. all instances use the same cookies. * @param url eg http://www.myserver.com/ * @param path eg doSomit.jsp * @param credentialsProvider the provider of credentials. can be null if no login is to occur. */ public ServerCaller(CredentialsProvider credentialsProvider, URL url, String path) { this(credentialsProvider, url, path, true); } /** * @param url eg http://www.myserver.com * @param path eg doSomit.jsp * @param useSharedCookies if false, then this instance has its own cookies. otherwise it shares them with * all other instances, statically. * @param credentialsProvider the provider of credentials. can be null if no login is to occur. */ public ServerCaller(CredentialsProvider credentialsProvider, URL url, String path, boolean useSharedCookies) { properties = new Hashtable(); this.url = url; this.path = path; this.useSharedCookies = useSharedCookies; this.credentialsProvider = credentialsProvider; } /** * sets the response listener, since calls to the server are async, this class needs to know where to * send the response. * @param listener the one and only response listener. * * @see Listener */ public void setListener(Listener listener) { this.listener = listener; } /** * should be called by subclasses, after they set properties (request parameters) */ protected void doCallAsync() { if (listener == null) { throw new RuntimeException("must call setListener first!"); } new Thread(new Runnable() { public void run() { doRun(false); } }).start(); } /** * called by the {@link #doCallAsync()} method to make the call to the server on another thread. * @param doFormLogin true if the server requires login. */ private void doRun(boolean doFormLogin) { // A good post looks like this: // // POST /index.jsp HTTP/1.1 // Host: mobilemaxantcouk:8089 // User-Agent: Mozilla/4.0 (maxant J2ME Client) // Accept: text/html,application/xhtml+xml,application/xml // Accept-Language: en-gb,en;q=0.5 // Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7 // Keep-Alive: 300 // Connection: keep-alive // Referer: http://localhost:8090/index.jsp // Cookie: JSESSIONID=61803C4EBD09448F3F9E5A41B55A83C1 // Content-Type: application/x-www-form-urlencoded // Content-Length: 35 // Cache-Control: no-cache // // one=asdf&two=on&threeName=testAPost InputStream is = null; OutputStream os = null; HttpConnection conn = null; try { //build query string String queryString = ""; Enumeration e = null; Hashtable formLoginParams = new Hashtable(); if (doFormLogin && credentialsProvider != null) { formLoginParams.put("j_username", credentialsProvider.getUsername()); formLoginParams.put("j_password", credentialsProvider.getPassword()); e = formLoginParams.keys(); } else { e = properties.keys(); } while (e.hasMoreElements()) { String key = (String) e.nextElement(); String val = (String) (doFormLogin ? formLoginParams : properties).get(key); queryString += urlEncoder.encode(key) + "=" + urlEncoder.encode(val) + "&"; } if (queryString.length() > 0) queryString = queryString.substring(0, queryString.length() - 1); //trim trailing & String userAgent = "Mozilla/4.0 (maxant J2ME Client)" + " Profile/" + System.getProperty("microedition.profiles") + " Configuration/" + System.getProperty("microedition.configuration"); // HTTP Request headers conn = (HttpConnection) Connector.open(url.toString() + (doFormLogin ? "j_security_check" : path)); conn.setRequestMethod(HttpConnection.POST); conn.setRequestProperty("User-Agent", userAgent); conn.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml"); conn.setRequestProperty("Keep-Alive", "300"); conn.setRequestProperty("Cache-Control", "no-cache"); //TODO make configurable? conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); conn.setRequestProperty("Connection", "close"); conn.setRequestProperty("Content-Length", "" + queryString.length()); //chunking: http://forums.sun.com/thread.jspa?threadID=5251115 // http://www.atnan.com/2008/8/8/transfer-encoding-chunked-chunky-http // https://issues.apache.org/bugzilla/show_bug.cgi?id=37794 addAuthentication(conn); //Cookies addCookies(conn); // HTTP Request body os = conn.openOutputStream(); os.write(queryString.getBytes()); // HTTP Response String str; is = conn.openInputStream(); int length = (int) conn.getLength(); if (length != -1) { byte incomingData[] = new byte[length]; is.read(incomingData); str = new String(incomingData); } else { ByteArrayOutputStream baos = new ByteArrayOutputStream(); int ch; while ((ch = is.read()) != -1) { baos.write(ch); } str = new String(baos.toByteArray()).trim(); baos.close(); } //string is wrapped in html body, because otherwise eg blackberry BIS doesnt pass it back, rather //sends an error page back :-( so strip the html... we could have used xml, but we are quite sure //that every platform will support html. int idx = str.indexOf("<body>"); if (idx > -1) { str = str.substring(idx + 6); idx = str.indexOf("</body>"); if (idx > -1) { str = str.substring(0, idx); } else { //well, not expected, but carry on regardless } } else { //well, not expected, but carry on regardless } str = str.trim(); if (conn.getResponseCode() == HttpConnection.HTTP_OK) { handleCookies(conn); //if starts with OK, then alls good. if starts with ERROR, then its an error. //otherwise, unparseable! if (dontParseResult) { try { handleSuccess(str); } catch (NoSuchElementException e2) { listener.onError(HttpConnection.HTTP_INTERNAL_ERROR, "The result from the server was unexpected. Please contact maxant."); } } else if (str.startsWith("OK")) { str = str.substring(2).trim(); try { handleSuccess(str); } catch (NoSuchElementException e2) { listener.onError(HttpConnection.HTTP_INTERNAL_ERROR, "The result from the server was unexpected. Please contact maxant."); } } else if (str.startsWith("ERROR")) { str = str.substring(5).trim(); listener.onError(HttpConnection.HTTP_INTERNAL_ERROR, str); } else if (str.indexOf("j_security_check") > 0) { //need to do a form login! if (doFormLogin) { //already tried it and it didnt work :-( listener.onError(HttpConnection.HTTP_UNAUTHORIZED, "Please check username/password."); } else { doRun(true); } } else { handleUnexpectedResult(str); } } else if (conn.getResponseCode() == HttpConnection.HTTP_UNAUTHORIZED) { if (doFormLogin) { //already tried it and it didnt work :-( listener.onError(HttpConnection.HTTP_UNAUTHORIZED, "Please check username/password."); } else { doRun(true); } } else if (conn.getResponseCode() == HttpConnection.HTTP_MOVED_TEMP) { //happens after form login - so go back and redo original call doRun(false); } else { listener.onError(conn.getResponseCode(), str); } } catch (Throwable t) { handleUnexpectedResult(t.getMessage()); t.printStackTrace(); } finally { if (is != null) { try { is.close(); } catch (Exception error) { /* log error */ } } if (os != null) { try { os.close(); } catch (Exception error) { /* log error */ } } if (conn != null) { try { conn.close(); } catch (Exception error) { /* log error */ } } } } /** * subclasses override this to handle the result from the server. it is expected that this * string will be parsed using a string tokenizer. the subclass need not catch * NoSuchElementException because the caller can deal with it. if the result is satisfactory * then the subclass should call #onSuccess() on the listener. if the result is not satisfactory * then the subclass should call #onError() on the listener. * @param str the success result coming from the server * @throws NoSuchElementException eg if string tokenizer doesnt have the result in the expected form. */ protected abstract void handleSuccess(String str) throws NoSuchElementException; private void addAuthentication(HttpConnection conn) throws IOException { if (credentialsProvider != null && credentialsProvider.getUsername() != null) { String userpass = credentialsProvider.getUsername() + ":" + credentialsProvider.getPassword(); userpass = new String(Base64.encodeBase64(userpass.getBytes())); conn.setRequestProperty("authorization", "Basic " + userpass); } } /** * rudimentary implementation of adding cookies to the request header. which basically * does not care which path the cookies belong to! one day it may... */ private void addCookies(HttpConnection connection) throws IOException { long now = System.currentTimeMillis() / 1000; Enumeration e = getCookies().elements(); if (e.hasMoreElements()) { String s = ""; while (e.hasMoreElements()) { Cookie c = (Cookie) e.nextElement(); if (c.secure) { //only send over HTTPS if (!connection.getURL().toLowerCase().startsWith("https")) { continue; } } //has it expired? if (c.maxAge != null && c.maxAge.longValue() < now) { continue; } //TODO is the path / domain relevant?? s += c.name + "=" + c.value + ";"; //TODO need to encode? } connection.setRequestProperty("Cookie", s); //adds all cookies as one header, semicolon seperated } } private void handleCookies(HttpConnection connection) throws IOException { long now = System.currentTimeMillis() / 1000; //seconds since epoch Vector newCookies = new Vector(); int i = 0; while (true) { String name = connection.getHeaderFieldKey(i); if (name == null || i > 1000 /*safety net*/) { break; } else if (name.toLowerCase().startsWith("set-cookie")) { //handle the cookie! String val = connection.getHeaderField(i); //c2=val2; Domain=domain; Max-Age=0; Path=path; Secure StringTokenizer st = new StringTokenizer(val, ";"); while (st.hasMoreTokens()) { Cookie c = new Cookie(); String token = st.nextToken().trim(); if (token.toLowerCase().startsWith("domain")) { int idx = token.indexOf('='); if (idx >= 0) { c.domain = token.substring(idx + 1); } } else if (token.toLowerCase().startsWith("max-age")) { int idx = token.indexOf('='); if (idx >= 0) { try { c.maxAge = new Long(Long.parseLong(token.substring(idx + 1))); } catch (NumberFormatException e) { //oh well, lets ignore it } } } else if (token.toLowerCase().startsWith("path")) { int idx = token.indexOf('='); if (idx >= 0) { try { c.path = token.substring(idx + 1); } catch (NumberFormatException e) { //oh well, lets ignore it } } } else if (token.toLowerCase().startsWith("secure")) { c.secure = true; } else { //must be the cookie itself int idx = token.indexOf('='); if (idx >= 0) { c.name = token.substring(0, idx); c.value = token.substring(idx + 1); newCookies.addElement(c); } } } } i++; } //ok got them all, now reconcile: //update our list by removing expired ones, and adding new ones and updating existing ones! Vector toRemove = new Vector(); Enumeration existing = getCookies().elements(); while (existing.hasMoreElements()) { Cookie cExist = (Cookie) existing.nextElement(); boolean matched = false; Enumeration newOnes = newCookies.elements(); while (newOnes.hasMoreElements()) { Cookie cNew = (Cookie) newOnes.nextElement(); if (cExist.name.equals(cNew.name)) { //matching... matched = true; if (cNew.maxAge != null && cNew.maxAge.longValue() < now) { //old, remove it toRemove.addElement(cExist); } else { //update it cExist.domain = cNew.domain; cExist.maxAge = cNew.maxAge; cExist.path = cNew.path; cExist.value = cNew.value; } break; } } if (!matched) { //just check it aint exipred if (cExist.maxAge != null && cExist.maxAge.longValue() < now) { toRemove.addElement(cExist); } } } Enumeration rs = toRemove.elements(); while (rs.hasMoreElements()) { Cookie remove = (Cookie) rs.nextElement(); getCookies().removeElement(remove); } //finally add all which are not already in there Enumeration newOnes = newCookies.elements(); while (newOnes.hasMoreElements()) { Cookie cNew = (Cookie) newOnes.nextElement(); existing = getCookies().elements(); boolean found = false; while (existing.hasMoreElements()) { Cookie cExist = (Cookie) existing.nextElement(); if (cExist.name.equals(cNew.name)) { found = true; break; } } if (!found) { if (cNew.maxAge == null || cNew.maxAge.longValue() > now) { //its not expired, and its not in the list, so add it! getCookies().addElement(cNew); } } } } /** * @return the cookies for this instance of the server caller. either shared ones, * or specific ones. either way, they are ALWAYS for the given domain! */ private Vector getCookies() { if (useSharedCookies) { Vector v = (Vector) cookies.get(url.getDomain()); if (v == null) { v = new Vector(); cookies.put(url.getDomain(), v); } return v; } else { return this.myCookies; } } /** * subclasses can call this method if the result they receive in {@link #handleSuccess(String)} is * not what they expected. */ public void handleUnexpectedResult(String str) { listener.onError(HttpConnection.HTTP_INTERNAL_ERROR, "Unexpected result: " + str); } /** * interface for registering for the server response. */ public static interface Listener { /** * called by the server caller if anything goes wrong. * * @param code the HTTP error code * @param result the server response. useful for debugging. or depending * upon the implementation can also be shown to the user. */ public void onError(int code, String result); /** * called by server caller if everything went well. * * @param modelResult implementation specific result. depends what the server gives back and how * its interpreted by the client. */ public void onSuccess(Object modelResult); } /** * encapsulation of a cookie */ private static class Cookie { public boolean secure = false; public String name; public String value; public String path; public String domain; public Long maxAge; } /** * clears cookies for ALL domains, except instance specific ones. */ public static void reset() { cookies.clear(); } /** * clears cookies for this instance. */ public void resetCookies() { myCookies.removeAllElements(); getCookies().removeAllElements(); } /** * @param dontParseResult if true, then an HTTP 200 code results in the * {@link #handleSuccess(String)} method being called. */ public void setDontParseResult(boolean dontParseResult) { this.dontParseResult = dontParseResult; } }