uk.co.maxant.j2me.core.ServerCaller.java Source code

Java tutorial

Introduction

Here is the source code for uk.co.maxant.j2me.core.ServerCaller.java

Source

/*  
 * 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;
    }

}