Creating a Multi-Valued Auto-Complete Field Using GWT SuggestBox and REST

by Bess Siegal on July 14, 2010


This is a guest post written by Bess Siegal. Bess and I work together and she recently created an auto-complete field that handles multiple values. She stopped by to show us how it works, provide an open source example, and give you all the details you need to create a multi-value auto-complete field in GWT using REST.

More Than Just Another Type-Ahead Script

Type ahead fields are ubiquitous. Type ja into Google and it prompts you for Jamn 94.5, Java, and Jack Johnson. These single value fields work well for search, but don’t scale to multiple values. Our latest tool allows users to search for multiple users, roles, and other objects using an auto-complete field, and for that we needed something new.

This article shows you how to create a multi-valued auto-complete field using the GWT SuggestBox and REST. Communicating between the client and server with REST gives this example a strong separation between the client and the server. I proved it using a server-side component written as a Java servlet, then swapping it out, thanks to Zack, with a server-side component written in PHP.

This sample uses JavaScript, HTML, CSS, and nothing else. It runs in all the major browsers and all the way back to Internet Explorer 6. It’s also a good introduction to REST and how to use it from a browser.

Try It

The multi-value auto-complete field can handle any type of data. Here’s an example that searches for crayon colors.

Search for blue, mac, or *

A Closer Look

I began by investigating the GWT SuggestBox. It’s not multi-valued, but it does automatically send a request and shows a suggestion list popup when you type into its text field. By default its suggestions come from a MultiWordSuggestOracle, which means all the suggestions reside on the client.

Using the GWT SuggestBox with RPC by Alex Moffat explains how to retrieve suggestions from a remote source using RPC by creating a custom SuggestOracle. It got me started on creating my own SuggestOracle using REST instead of using GWT’s RPC. The jQuery auto-complete plugin by Jörn Zaefferer inspired me to make it multi-valued.

Working Example

Get the
source code

I’ll walk you through the working code example and explain how it:

  • retrieves suggestions using REST
  • allows for multiple selection
  • performs as an auto-completer even for multiple values that have been pasted in
  • allows for browsing through large data sets without having to use a div overflow scrollbar

REST Endpoint

The REST Endpoint can be any HTTP URL that supporst GET and takes the following parameters.

  • q – the query
  • indexFrom – the 0-based starting index
  • indexTo – the last index, inclusive

The endpoint returns JSON in the following format:

{ 
 "TotalSize" : 25,
 "Options" : [
              {"Value" : "#9ACEEB", "DisplayName" : "Cornflower"},
              {"Value" : "#CC6666", "DisplayName" : "Fuzzy Wuzzy"}
             ]
}

“TotalSize” is the total number of results yielded by the query and “Options” is the array of name-value pairs in the results from indexFrom and to indexTo.

This well-defined API allows for a server implementation in any language. The source code includes a Java servlet that serves this purpose and also the endpoint Zack wrote in PHP that drives the live demo in this article.

MultivalueSuggestBox

The MultivalueSuggestBox is a custom GWT widget that utilizes the GWT SuggestBox. It extends Composite with a FlowPanel as its main widget. It encapsulates six inner classes that support server-side data retrieval.

Below is the constructor. Its arguments are the REST endpoint URL and a boolean to specify multi-value.1 A FlowPanel2 is instantiated so that a FormFeedback3 control can be included next to the SuggestBox. The FormFeedback is a custom GWT widget that shows an icon indicating the status of the control. This lets the user know whether the control is loading and more importantly, helps to identify invalid values.

A SuggestBox is instantiated using the RestSuggestOracle4.
The m_valueMap5 is instantiated to hold all the valid values found, keyed by name.

    
/**
 * Constructor.
 * @param the URL for the REST endpoint.  
 * @param isMultivalued - true for allowing multiple values
 */
public MultivalueSuggestBox(String restEndpointUrl, boolean isMultivalued)1
{
    m_restEndpointUrl = restEndpointUrl;
    m_isMultivalued = isMultivalued;

    FlowPanel panel = new FlowPanel();2
    TextBoxBase textfield;
    if (isMultivalued) {
        panel.addStyleName("textarearow");
        textfield = new TextArea();
    } else {
        panel.addStyleName("textfieldrow");
        textfield = new TextBox();
    }
        
    //Create our own SuggestOracle that queries REST endpoint
    SuggestOracle oracle = new RestSuggestOracle();4
    //intialize the SuggestBox
    m_field = new SuggestBox(oracle, textfield);
    if (isMultivalued) {
        //have to do this here b/c gwt suggest box wipes 
        //style name if added in previous if
        textfield.addStyleName("multivalue");            
    }
    m_field.addStyleName("wideTextField");
    m_field.addSelectionHandler(this);
        
    panel.add(m_field);
    m_feedback = new FormFeedback();3
    panel.add(m_feedback);        
        
    initWidget(panel);
      
    m_valueMap = new HashMap<String, String>();5
       
    resetPageIndices();        
}

RestSuggestOracle

RestSuggestOracle6, like all the other classes mentioned in the remainder of this article, is an inner class within MultivalueSuggestBox. It extends SuggestOracle implementing requestSuggestions7 and overriding isDisplayStringHTML to return true. It creates a timer8 that is reset whenever the user types9 because we only want to query the server when the user has paused in their typing. This will prevent overloading the server with unnecessary overlapping requests. After the timer has elapsed, getSuggestions10 and conditionally findExactMatches11 are called.

/**
* A custom Suggest Oracle
*/
private class RestSuggestOracle6 extends SuggestOracle
{
private SuggestOracle.Request m_request;
private SuggestOracle.Callback m_callback;
private Timer m_timer;

RestSuggestOracle()
{
    m_timer = new Timer()8 {
        
        @Override
        public void run()
        {
            /*
             * The reason we check for empty string is found at
             * http://development.lombardi.com/?p=39 --
             * paraphrased, if you backspace quickly the contents of the field 
             * are emptied but a query for a single character is still executed.
             * Workaround for this is to check for an empty string field here.
             */
            
            if (!m_field.getText().trim().isEmpty()) {
                if (m_isMultivalued) {
                    //calling this here in case a user is trying to correct the 
                    //"kev" value of Allison Andrews, Kev, Josh Nolan or pasted 
                    //in multiple values
                    findExactMatches();11
                }                    
                getSuggestions();10
            }
        }
    };
}

@Override
public void requestSuggestions(SuggestOracle.Request request, 
			       SuggestOracle.Callback callback)7
{                
    //This is the method that gets called by the SuggestBox whenever typing in the text field            
    m_request = request;
    m_callback = callback;
    
    //reset the indexes (b/c NEXT and PREV call getSuggestions directly)
    resetPageIndices();
    
    //If the user keeps triggering this event (e.g., keeps typing), cancel and restart the timer
    m_timer.cancel();        
    m_timer.schedule(DELAY);9
}

getSuggestions

First we’ll look at getSuggestions12. This method performs the query for the contents of the suggestion list popup. First, it determines the search term. When not multi-valued, the search term is the contents of the suggest box text field. If it is multi-valued, the search term is the part of the string after the last DISPLAY_SEPARATOR, in this case a comma. It then sets the FormFeedback widget to a LOADING state, then calls queryOptions.

private void getSuggestions()12
{
    String query = m_request.getQuery();
    
    //find the last thing entered up to the last separator
    //and use that as the query
    if (m_isMultivalued) {
        int sep = query.lastIndexOf(DISPLAY_SEPARATOR);
        if (sep > 0) {
            query = query.substring(sep + DISPLAY_SEPARATOR.length());                
        }
    }
    query = query.trim();
    
    //do not query if it's just an empty String
    //also do not get suggestions you've already got an exact match for 
    //this string in the m_valueMap
    if (query.length() > 0 && m_valueMap.get(query) == null) {
        updateFormFeedback(FormFeedback.LOADING, null);               
                                    
        queryOptions( 
                query,
                m_indexFrom,
                m_indexTo,
            new RestSuggestCallback(m_request, m_callback, query));
    }
}

queryOptions

The queryOptions13 method sends a GET request to the URL of the REST endpoint passed to the constructor of MultivalueSuggestBox1. It uses the GWT RequestBuilder object. queryOptions converts the response from JSON to type safe Java beans14. The arguments are the query string, the indices of the result set desired, and an OptionQueryCallback15.

/**
 * Retrieve Options (name-value pairs) that are suggested from the REST endpoint
 * @param query - the String search term 
 * @param from - the 0-based begin index int
 * @param to - the end index inclusive int
 * @param callback - the OptionQueryCallback to handle the response
 */
private void queryOptions(final String query, final int from, final int to, 
                          final OptionQueryCallback callback)13
{
    RequestBuilder builder = new RequestBuilder(RequestBuilder.GET, 
        URL.encode(m_restEndpointUrl + "?q=" + query + "&indexFrom=" + 
        from + "&indexTo=" + to));
        
    // Set our headers
    builder.setHeader("Accept", "application/json");
    builder.setHeader("Accept-Charset", "UTF-8");
                
    builder.setCallback(new RequestCallback() {
            
        @Override
        public void onResponseReceived(com.google.gwt.http.client.Request request, 
                                       Response response)
        {
            JSONValue val = JSONParser.parse(response.getText());
            JSONObject obj = val.isObject();
            int totSize = (int) obj.get(OptionResultSet.TOTAL_SIZE).isNumber().doubleValue();
            OptionResultSet options = new OptionResultSet(totSize);14
            JSONArray optionsArray = obj.get(OptionResultSet.OPTIONS).isArray();

            if (options.getTotalSize() > 0 && optionsArray != null) {
                    
                for (int i = 0; i < optionsArray.size(); i++) {                        
                    JSONObject jsonOpt = optionsArray.get(i).isObject();
                    Option option = new Option();
                    option.setName(jsonOpt.get(OptionResultSet.DISPLAY_NAME).isString().stringValue());
                    option.setValue(jsonOpt.get(OptionResultSet.VALUE).isString().stringValue());
                    options.addOption(option);
                }
            }                    
            callback.success(options);
        }

        @Override
        public void onError(com.google.gwt.http.client.Request request, 
                            Throwable exception)
        {
            callback.error(exception);
        }
    });
        
    try {
        builder.send();
    } catch (RequestException e) {
        updateFormFeedback(FormFeedback.ERROR, "Error: " + e.getMessage());
    }
}

OptionQueryCallback

The OptionQueryCallback abstract class handles success and error conditions from the REST call.

/**
 * Handles success and error conditions from the REST call
 */
private abstract class OptionQueryCallback15
{
    abstract void success(OptionResultSet optResults);
    abstract void error(Throwable exception);
}

RestSuggestCallback

RestSuggestCallback16 extends OptionQueryCallback15 and is the object passed to queryOptions13 from getSuggestions12. The RestSuggestCallback constructor takes the SuggestOracle.Request, SuggestOracle.Callback, and the query as arguments.

Once the request succeeds RestSuggestCallback evaluates the response:

  • if the total size is less than one, it means there were no suggestions returned from the REST endpoint, so the FormFeedback shows an ERROR status.17 The OptionSuggestion ArrayList remains empty.18
  • if the total size is equal to one, then there was only one suggestion. In this case we skip the popup and just show the value in the text field. The name of the Option replaces the string after the last DISPLAY_SEPARATOR 19. The FormFeedback shows a VALID status20. The name and value are added to the value map.21. The OptionSuggestion ArrayList remains empty.18
  • if the total size is greater than 1, the The OptionSuggestion ArrayList is built up22 and these suggestions are sent back to the SuggestOracle.Callback23. We also conditionally add the next and previous options for scrolling in the results.24 The FormFeedback shows an ERROR status until the user selects a valid option.25
/**
 * A custom callback that has the original SuggestOracle.Request 
 * and SuggestOracle.Callback
 */
private class RestSuggestCallback extends OptionQueryCallback16
{
    private SuggestOracle.Request m_request;
    private SuggestOracle.Callback m_callback;
    private String m_query; //this may be different from m_request.getQuery when multivalued it's only the substring after the last delimiter
        
    RestSuggestCallback(Request request, Callback callback, String query)
    {
        m_request = request;
        m_callback = callback;
        m_query = query;
    }

    public void success(OptionResultSet optResults)
    {
        SuggestOracle.Response resp = new SuggestOracle.Response();
        List<OptionSuggestion> suggs = new ArrayList<OptionSuggestion>();18
        int totSize = optResults.getTotalSize();
            
        if (totSize < 1) {16
            //if there were no suggestions, then it's an invalid value
            updateFormFeedback(FormFeedback.ERROR, "Invalid: " + query);17
                
        } else if (totSize == 1) {
            //it's an exact match, so do not bother with showing suggestions, 
            Option o = optResults.getOptions()[0];
            String displ = o.getName();
                
            //remove the last bit up to separator
            m_field.setText(getFullReplaceText(displ, m_request.getQuery()));19
                
            //it's valid!
            updateFormFeedback(FormFeedback.VALID, null);20

            //set the value into the valueMap
            putValue(displ, o.getValue());21

        } else {
            //more than 1 so show the suggestions
                
            //if not at the first page, show PREVIOUS
            if (m_indexFrom > 0) {
                OptionSuggestion prev = new OptionSuggestion(
                    OptionSuggestion.PREVIOUS_VALUE, m_request.getQuery());
                suggs.add(prev);
            }
                
            // show the suggestions
            for (Option o : optResults.getOptions()) {
                OptionSuggestion sugg = new OptionSuggestion(
	            o.getName(), o.getValue(), m_request.getQuery(), m_query);
                suggs.add(sugg);22
            }
                
            //if there are more pages, show NEXT
            if (m_indexTo < totSize) {
                OptionSuggestion next = new OptionSuggestion(
                    OptionSuggestion.NEXT_VALUE, m_request.getQuery());
                suggs.add(next);24
            }
                
            //nothing has been picked yet, so let the feedback show an error (unsaveable)
            updateFormFeedback(FormFeedback.ERROR, "Invalid: " + m_query);25
        }

        //it's ok (and good) to pass an empty suggestion list back to the suggest box's callback method
        //the list is not shown at all if the list is empty.
        resp.setSuggestions(suggs);
        m_callback.onSuggestionsReady(m_request, resp);23
    }

    @Override
    public void error(Throwable exception)
    {
        updateFormFeedback(FormFeedback.ERROR, "Invalid: " + m_query);
    }    
}

OptionSuggestion

OptionSuggestion objects are collected into a List and passed to SuggestOracle.Callback.onSuggestionsReady24. It extends SuggestOracle.Suggestion overriding getDisplayString and getReplacementString and adding getValue and getName methods for Option IDs and names. You could add more properties depending on your needs. In this example, the names are the crayon colors and the values are their corresponding hex codes.

OptionSuggestion has two constructors. One is for navigation26 and the other is for an actual option27.

Since RestSuggestOracle.isDisplayStringHTML is true, HTML can be used for the display string. In the body of the first constructor, the HTML returned is a div with a class that is included in the css. It will show an appropriate image to indicate navigation. In the body of the second constructor, HTML bold tags are used to highlight the query search term within each suggestion in the list popup. The value returned by getReplacementString28 is determined by calling getFullReplaceText so that the text box does not lose any previously selected options in the multi-value case, and only the portion of the contents of the text field after the last DISPLAY_SEPARATOR is replaced.

    
/**
 * Constructor for navigation options
 * @param nav - next or previous value
 * @param currentTextValue - the current contents of the text box
 */    
OptionSuggestion(String nav, String currentTextValue)26
{
    if (NEXT_VALUE.equals(nav)) {
        m_display = "<div class=\"autocompleterNext\" title=\"Next\"></div>";
    } else {
        m_display = "<div class=\"autocompleterPrev\" title=\"Previous\"></div>";
    }
    m_replace = currentTextValue;
    m_value = nav;
}
    
/**
 * Constructor for regular options
 * @param displ - the name of the option
 * @param val - the value of the option
 * @param replacePre - the current contents of the text box
 * @param query - the query
 */
OptionSuggestion(String displ, String val, String replacePre, String query)27
{
    m_name = displ;
    int begin = displ.toLowerCase().indexOf(query.toLowerCase());
    if (begin >= 0) {
        int end = begin + query.length();
        String match = displ.substring(begin, end);
        m_display = displ.replaceFirst(match, "<b>" + match + "</b>");
    } else {
        //may not necessarily be a part of the query, for example if "*" was typed.
        m_display = displ;
    }
    m_replace = getFullReplaceText(displ, replacePre);28
    m_value = val;
}    

onSelection

MultivalueSuggestBox implements SelectionHandler<Suggestion>, so onSelection29 is called after the user chooses an option among the items shown in the SuggestBox list popup. If an option is selected, the FormFeedback shows a VALID status and the option’s name and value are put30 into the value map. If the selection is one of the navigation options, the indices are changed and getSuggestions is called again31. Having the next/previous options allows for browsing of the result set within a reasonably sized popup that does not need scrollbars.

@Override
public void onSelection(SelectionEvent<Suggestion> event)29
{
    Suggestion suggestion = event.getSelectedItem();
    if (suggestion instanceof OptionSuggestion) {
        OptionSuggestion osugg = (OptionSuggestion) suggestion;
        //if NEXT or PREVIOUS were selected, requery but bypass the timer
        String value = osugg.getValue();
        if (OptionSuggestion.NEXT_VALUE.equals(value)) {
            m_indexFrom += PAGE_Size;
            m_indexTo += PAGE_Size;
            
            RestSuggestOracle oracle = (RestSuggestOracle) m_field.getSuggestOracle();
            oracle.getSuggestions();31
            
        } else if (OptionSuggestion.PREVIOUS_VALUE.equals(value)) {
            m_indexFrom -= PAGE_Size;
            m_indexTo -= PAGE_Size;
            
            RestSuggestOracle oracle = (RestSuggestOracle) m_field.getSuggestOracle();
            oracle.getSuggestions();
            
        } else {
            //made a valid selection
            updateFormFeedback(FormFeedback.VALID, null);
            
            //add the option's value to the value map            
            putValue(osugg.getName(), value);30
            
            //put the focus back into the textfield so user
            //can enter more
            m_field.setFocus(true);
        }
    }
}

findExactMatches

Now let’s go back and look at findExactMatches32. This is only called if multi-valued was specified, and is useful because the user might want to copy and paste an entire set of names or need to change a name that is in the middle of the text field. If there is more than one name in the text field, a query is executed for every name that does not already have a value in the value map. It resets the member variables m_findExactMatchesTotal, m_findExactMatchesFound, and m_findExactMatchesNot33, then for every non-valued term it calls findExactMatch34.

/**
 * If there is more than one key in the text field,
 * check that every key has a value in the map.
 * For any that do not, try to find its exact match.
 */
private void findExactMatches()32
{
    String text = m_field.getText();
    String[] keys = text.split(DISPLAY_SEPARATOR.trim());
    int len = keys.length;       
    if (len < 2) {
        //do not continue.  if there's 1, it is the last one, and getSuggestions can handle it
        return;
    }

    m_findExactMatchesTotal = 0;
    m_findExactMatchesFound = 0;
    m_findExactMatchesNot.clear();33
    for (int pos = 0; pos < len; pos++) {
        String key = keys[pos].trim();

        if (!key.isEmpty()) {
            String v = m_valueMap.get(key);
            if (null == v) {
                m_findExactMatchesTotal++;
            }
        }
    }
    //then loop through again and try to find them
    /*
     * We may have invalid values due to a multi-value copy-n-paste,
     * or going back and messing with a middle or first key;
     * so for each invalid value, try to find an exact match.
     */
    for (int pos = 0; pos < len; pos++) {
        String key = keys[pos].trim();
        if (!key.isEmpty()) {
            String v = m_valueMap.get(key);
            if (null == v) {
                findExactMatch(key, pos);34
            }
        }
    }        
}

findExactMatch

findExactMatch35 updates the FormFeedback to show a LOADING status. It then calls queryOptions13, but this time the indices are hard-coded from zero to some relatively small amount so the exact match can attempt to be found within the top suggestions.

An anonymous OptionQueryCallback36 evaluates the response:

  • if the total size is equal to one, then the one option is the match, so the name and value will be placed in the value map and m_findExactMatchesFound is incremented.37
  • if the total size is greater than one, it loops through the result set to find if there is an exact match within those top suggestions, and if so, the name and value will be placed in the value map and m_findExactMatchesFound is incremented.38
  • if an exact match is not found, then the term is added to the m_findExactMatchesNot list.39

Once m_findExactMatchesFound + m_findExactMatchesNot.size() is equal to m_findExactMatchesTotal, the FormFeedback will be updated to either show VALID if all exact matches were found40 or ERROR if any name could not be exactly matched. All names that could not be matched will be shown within the tooltip (i.e., title) of the FormFeedback.41

private void findExactMatch(final String displayValue, final int position)35
{
    updateFormFeedback(FormFeedback.LOADING, null);
    
    queryOptions( 
        displayValue,
            0,
            FIND_EXACT_MATCH_QUERY_LIMIT, 
        new OptionQueryCallback() {36
            
            @Override
            public void error(Throwable exception)
            {
                // an exact match couldn't be found, just increment not found
                m_findExactMatchesNot.add(displayValue);
                finalizeFindExactMatches();
            }

            @Override
            public void success(OptionResultSet optResults)
            {
                int totSize = optResults.getTotalSize();
                if (totSize == 1) {
                    //an exact match was found, so place it in the value map
                    Option option = optResults.getOptions()[0];                        
                    extactMatchFound(position, option);37
                } else {
                    //try to find the exact matches within the results
                    boolean found = false;
                    for (Option option : optResults.getOptions()) {
                        if (displayValue.equalsIgnoreCase(option.getName())) {
                            extactMatchFound(position, option);38
                            found = true;
                            break;
                        }                            
                    }
                    if (!found) {
                        m_findExactMatchesNot.add(displayValue);39
                    }
                }
                finalizeFindExactMatches();                    
            }

            private void extactMatchFound(final int position, Option option)
            {
                putValue(option.getName(), option.getValue());

                //and replace the text
                String text = m_field.getText();
                String[] keys = text.split(DISPLAY_SEPARATOR.trim());
                keys[position] = option.getName();
                String join = "";
                for (String n : keys) {
                    join += n.trim() + DISPLAY_SEPARATOR;
                }
                join = trimLastDelimiter(join, DISPLAY_SEPARATOR);
                m_field.setText(join);
                
                m_findExactMatchesFound++;
            }
            
            private void finalizeFindExactMatches()
            {
                if (m_findExactMatchesFound + m_findExactMatchesNot.size() == 
                        m_findExactMatchesTotal) {
                    //when the found + not = total, we're done
                    if (m_findExactMatchesNot.size() > 0) {
                        String join = "";
                        for (String val : m_findExactMatchesNot) {
                            join += val.trim() + DISPLAY_SEPARATOR;
                        }
                        join = trimLastDelimiter(join, DISPLAY_SEPARATOR);                                
                        updateFormFeedback(FormFeedback.ERROR, "Invalid:" + join);41
                    } else {
                        updateFormFeedback(FormFeedback.VALID, null);40
                    }
                }
            }
        });
}

You can call getValue42 when the values of the selected options are needed. This implementation of getValue returns a concatenated string of all the values delimited by VALUE_DELIM, in this case a semi-colon. If any of the names were found to be invalid, that is, never exactly matched or chosen, it is removed43 and the FormFeedback will show an ERROR status with a tooltip containing the invalid name(s)44. Depending on your needs, you might choose to return the value map or an array of names and values if order is important.

/**
 * Get the value(s) as a concatenated String
 * @return value(s) as a String
 */
public String getValue()42
{
    //String together all the values in the valueMap
    //based on the display values shown in the field
    String text = m_field.getText();
    
    String values = "";
    String invalids = "";
    String newKeys = "";
    if (m_isMultivalued) {
        String[] keys = text.split(DISPLAY_SEPARATOR);
        for (String key : keys) {
            key = key.trim();
            if (!key.isEmpty()) {
                String v = m_valueMap.get(key);
                if (null != v) {
                    values += v + VALUE_DELIM;
                    //rebuild newKeys removing invalids
                    newKeys += key + DISPLAY_SEPARATOR;43
                } else {
                    invalids += key + DISPLAY_SEPARATOR;
                }
            }
        }
        values = trimLastDelimiter(values, VALUE_DELIM);
        //set the new display values
        m_field.setText(newKeys);
    } else {
        values = m_valueMap.get(text);
    }
    
    //if there were any invalid show warning
    if (!invalids.isEmpty()) {
        //trim last separator
        invalids = trimLastDelimiter(invalids, DISPLAY_SEPARATOR);
        updateFormFeedback(FormFeedback.ERROR, "Invalids: " + invalids);44
    }
    return values;
}

The Takeaway

This type-ahead control not only accepts multiple values but also auto-completes for a multi-valued copy and paste. The user-friendly interface doesn’t impose a minimum number of characters before sending a request because it can limit the results shown while still allowing a complete browse of the dataset.

You could do all of that without REST, but we keep the client code completely encapsulated with a REST interface. This sample could be extended with client-side caching to speed up performance.

The code in this example is free and released under the Apache 2.0 license. The other programs needed to run this example are also free, but some of them may use different licenses. Make sure to read and understand each license before using a tool.

We encourage you to use this example in your own applications and we’d love to see the results. Drop us a comment if you do.

Bess Siegal is a software engineer at Novell. She enjoys her one-minute commute so she can spend more time with her husband and 3 daughters.