Objectives

Extend the pacemaker project to include a simple command line facility

Setup

This lab assumes you have completed the previous lab - this is the complete archive here:

This lab will attach a command line interface to pacemaker. We will use this library here:

It has been ported to github here:

For our purposes, we can just use this jar file:

Download the jar, copy it to the lib folder of the pacemaker project and add it to the build

Main

You already have a class called Main in the controllers package. Replace the contents of this class with the following:

package controllers;

import java.io.File;
import utils.Serializer;
import utils.XMLSerializer;

import asg.cliche.Shell;
import asg.cliche.ShellFactory;


public class Main
{
  public PacemakerAPI paceApi;

  public Main() throws Exception
  {
    File datastore = new File("datastore.xml");
    Serializer serializer = new XMLSerializer(datastore);

    paceApi = new PacemakerAPI(serializer);
    if (datastore.isFile())
    {
      paceApi.load();
    }
  }

  public static void main(String[] args) throws Exception
  {
    Main main = new Main();

    Shell shell = ShellFactory.createConsoleShell("pm", "Welcome to pacemaker-console - ?help for instructions", main);
    shell.commandLoop();

    main.paceApi.store();
  }
}

Run the application, and you should see a command line console:

Welcome to pacemaker-console - ?help for instructions
pm>

Enter ?help to see what commands you have:

?help
This is Cliche shell (http://cliche.sourceforge.net).
To list all available commands enter ?list or ?list-all, the latter will also show you system commands. To get detailed info on a command enter ?help command-name
pm>

and '?list-all` for a complete list:

pm> ?list-all
abbrev  name  params
!rs !run-script (filename)
!el !enable-logging (fileName)
!dl !disable-logging  ()
!gle  !get-last-exception ()
!sdt  !set-display-time (do-display-time)
?l  ?list (startsWith)
?l  ?list ()
?h  ?help (command-name)
?h  ?help ()
?ghh  ?generate-HTML-help (file-name, include-prefixed)
?la ?list-all ()
pm>

exit will terminate the application.

Datastore file

You will notice that the main method loads a file called datastore.xml:

    File datastore = new File("datastore.xml");
    Serializer serializer = new XMLSerializer(datastore);

We can generate this from our last unit tests - by running the Persistence unit test with the last line commented out:

  @Test
  public void testXMLSerializer() throws Exception
  { 
    String datastoreFile = "testdatastore.xml";
    //deleteFile (datastoreFile);

    Serializer serializer = new XMLSerializer(new File (datastoreFile));

    pacemaker = new PacemakerAPI(serializer); 
    populate(pacemaker);
    pacemaker.store();

    PacemakerAPI pacemaker2 =  new PacemakerAPI(serializer);
    pacemaker2.load();

    assertEquals (pacemaker.getUsers().size(), pacemaker2.getUsers().size());
    for (User user : pacemaker.getUsers())
    {
      assertTrue (pacemaker2.getUsers().contains(user));
    }
    //deleteFile ("testdatastore.xml");
  }

This leaves a populated file called testdatastore.xml in your project (refresh to see it). Make a copy of this and call it datastore.xml. This will then be loaded when we run the main method.

GetUsers

We cam bring in our first command. Place this method in the main class:

  @Command(description="Get all users details")
  public void getUsers ()
  {
    Collection<User> users = paceApi.getUsers();
    System.out.println(users);
  }

Now run the app again, and type ?list-all:

Welcome to pacemaker-console - ?help for instructions
pm> ?list-all
abbrev  name  params
!el !enable-logging (fileName)
!dl !disable-logging  ()
!rs !run-script (filename)
!sdt  !set-display-time (do-display-time)
!gle  !get-last-exception ()
?l  ?list (startsWith)
?l  ?list ()
?h  ?help (command-name)
?h  ?help ()
?la ?list-all ()
?ghh  ?generate-HTML-help (file-name, include-prefixed)
gu  get-users ()
pm>

Notice we have a new command - get-users - which we just implemented. Try it now:

pm> get-users
[User{4, marge, simpson, secret, marge@simpson.com}, User{5, lisa, simpson, secret, lisa@simpson.com}, User{6, bart, simpson, secret, bart@simpson.com}, User{7, maggie, simpson, secret, maggie@simpson.com}]
pm>

The user experience is minimal - but we can see the users (generated from our test and saved to datastore.xml) are there.

User Commands

We can add in the remaining user commands:

  @Command(description="Create a new User")
  public void createUser (@Param(name="first name") String firstName, @Param(name="last name") String lastName, 
                          @Param(name="email")      String email,     @Param(name="password")  String password)
  {
    paceApi.createUser(firstName, lastName, email, password);
  }

  @Command(description="Get a Users detail")
  public void getUser (@Param(name="email") String email)
  {
    User user = paceApi.getUserByEmail(email);
    System.out.println(user);
  }

  @Command(description="Delete a User")
  public void deleteUser (@Param(name="email") String email)
  {
    Optional<User> user = Optional.fromNullable(paceApi.getUserByEmail(email));
    if (user.isPresent())
    {
      paceApi.deleteUser(user.get().id);
    }
  }

Note carefully how the paramaters are defined. Run the app again and the new commands are visible:

Welcome to pacemaker-console - ?help for instructions
pm> ?list-all
abbrev    name    params
....
gu    get-user    (email)
gu    get-users    ()
cu    create-user    (first name, last name, email, password)
du    delete-user    (email)
pm>

Try these commands out now and verify that the work as expected.

Activities & Locations

These are the Activity & Location commands:

  @Command(description="Add an activity")
  public void addActivity (@Param(name="user-id")  Long   id,       @Param(name="type") String type, 
                           @Param(name="location") String location, @Param(name="distance") double distance)
  {
    Optional<User> user = Optional.fromNullable(paceApi.getUser(id));
    if (user.isPresent())
    {
      paceApi.createActivity(id, type, location, distance);
    }
  }

  @Command(description="Add Location to an activity")
  public void addLocation (@Param(name="activity-id")  Long  id,   
                           @Param(name="latitude")     float latitude, @Param(name="longitude") float longitude)
  {
    Optional<Activity> activity = Optional.fromNullable(paceApi.getActivity(id));
    if (activity.isPresent())
    {
      paceApi.addLocation(activity.get().id, latitude, longitude);
    }
  }

When you incorporate these, and try to add an activity - you will notice that the activities are not listed with the users details. This is because our user models toString method doesn't include the activities list. We can fix this now:

  public String toString()
  {
    return toStringHelper(this).addValue(id)
                               .addValue(firstName)
                               .addValue(lastName)
                               .addValue(password)
                               .addValue(email)   
                               .addValue(activities)
                               .toString();
  }

Enhanced UX

This output of model objects to the console is more or less unreadable - as everything is on one line. We can improve on this but replacing the toString methods with something more readable.

This disucssion here provides the class, compatible with Guava, that will do the trick:

Create this new class in the utils package (from the above discussion):

package utils;

import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.Maps;
import java.lang.reflect.Field;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.Arrays;
import java.util.Map;
import java.util.Map.Entry;

/**
 * Generates a JSON-style representation of an Object. The output is meant to
 * facilitate readability, nothing more. As such, it does not adhere perfectly
 * to the JSON standard.
 * <p/>
 * 
 * @author Gili Tzabari
 */
public final class ToJsonString
{
  /**
   * Styles for opening braces.
   */
  @SuppressWarnings("PublicInnerClass")
  public static enum BraceStyle
  {
    /**
     * Opening brace should go on the same line as the key.
     */
    SAME_LINE,
    /**
     * Opening brace should go on the line after the key.
     */
    NEW_LINE;
  }

  private final StringBuilder builder;
  private BraceStyle braceStyle = BraceStyle.NEW_LINE;
  private int indentSize = 2;
  private final Map<String, Object> map = Maps.newHashMap();

  /**
   * Creates a new ToJsonString.
   * <p/>
   * 
   * @param className
   *          the name of the class we are processing
   * @throws NullPointerException
   *           if className or braceStyle are null
   * @throws IllegalArgumentException
   *           if indentSize is negative
   */
  public ToJsonString(String className)
  {
    Preconditions.checkNotNull(className, "className may not be null");

    this.builder = new StringBuilder(32).append(className);
  }

  /**
   * Creates a new ToJsonString. Uses Reflection to associate the names of all
   * fields with their values. This method does not process superclass
   * properties.
   * <p/>
   * 
   * @param clazz
   *          the class whose fields to process
   * @param obj
   *          the object whose values to read
   * @throws IllegalArgumentException
   *           if the specified object is not an instance of the class or
   *           interface declaring the underlying field (or a subclass or
   *           implementor thereof)
   */
  public ToJsonString(Class<?> clazz, Object obj)
  {
    this(clazz.getName());
    putAllFields(clazz, obj);
  }

  /**
   * Sets the number of characters to use when indenting output.
   * <p/>
   * 
   * @param indentSize
   *          the number of characters to use when indenting output
   * @throws IllegalArgumentException
   *           if indentSize is negative
   * @return this
   */
  public ToJsonString setIndentSize(int indentSize)
  {
    Preconditions.checkArgument(indentSize >= 0, "indentSize must be non-negative");

    this.indentSize = indentSize;
    return this;
  }

  /**
   * Sets the style to use for opening braces.
   * <p/>
   * 
   * @param braceStyle
   *          the style to use for opening braces
   * @return this
   * @throws NullPointerException
   *           if braceStyle is null
   */
  public ToJsonString setBraceStyle(BraceStyle braceStyle)
  {
    Preconditions.checkNotNull(braceStyle, "braceStyle may not be null");

    this.braceStyle = braceStyle;
    return this;
  }

  /**
   * Uses Reflection to associate the names of all fields with their values.
   * This method does not process superclass properties.
   * <p/>
   * 
   * @param clazz
   *          the class whose fields to process
   * @param obj
   *          the object whose values to read
   * @return this
   * @throws NullPointerException
   *           if clazz or obj are null
   * @throws IllegalArgumentException
   *           if the specified object is not an instance of the class or
   *           interface declaring the underlying field (or a subclass or
   *           implementor thereof)
   */
  public ToJsonString putAllFields(final Class<?> clazz, final Object obj)
  {
    Preconditions.checkNotNull(clazz, "clazz may not be null");
    Preconditions.checkNotNull(obj, "obj may not be null");

    if (!clazz.isAssignableFrom(obj.getClass()))
      throw new IllegalArgumentException("obj must be an instance of " + clazz);
    AccessController.doPrivileged(new PrivilegedAction<Void>()
    {
      @Override
      public Void run()
      {
        try
        {
          for (Field field : clazz.getDeclaredFields())
          {
            field.setAccessible(true);
            put(field.getName(), field.get(obj));
          }
        }
        catch (IllegalAccessException e)
        {
          throw new AssertionError(e);
        }
        return null;
      }
    });
    return this;
  }

  /**
   * Associates the specified value with the specified key. If the key was
   * already associated with a value, the old value is replaced by the specified
   * value. If {@code value} is {@code null}, the string {@code "null"} is used.
   * <p/>
   * 
   * @param key
   *          the key
   * @param value
   *          the value
   * @return this
   */
  public ToJsonString put(String key, Object value)
  {
    Preconditions.checkNotNull(key, "key may not be null");

    map.put(key, value);
    return this;
  }

  @Override
  public String toString()
  {
    switch (braceStyle)
    {
    case NEW_LINE:
    {
      this.builder.append("\n{\n");
      break;
    }
    case SAME_LINE:
    {
      this.builder.append(" {\n");
      break;
    }
    default:
      throw new AssertionError("Unexpected braceStyle: " + braceStyle);
    }
    String separator = "";
    String indent = Strings.repeat(" ", indentSize);
    for (Entry<String, Object> entry : map.entrySet())
    {
      builder.append(separator).append(indent).append("\"").append(entry.getKey()).append("\": ");
      Object value = entry.getValue();
      if (value instanceof String)
        builder.append("\"");
      if (value == null)
        builder.append("null");
      else if (value.getClass().isArray())
      {
        String arrayValue;
        if (value instanceof Object[])
          arrayValue = Arrays.toString((Object[]) value);
        else if (value instanceof byte[])
          arrayValue = Arrays.toString((byte[]) value);
        else if (value instanceof short[])
          arrayValue = Arrays.toString((short[]) value);
        else if (value instanceof int[])
          arrayValue = Arrays.toString((int[]) value);
        else if (value instanceof long[])
          arrayValue = Arrays.toString((long[]) value);
        else if (value instanceof char[])
          arrayValue = Arrays.toString((char[]) value);
        else if (value instanceof float[])
          arrayValue = Arrays.toString((float[]) value);
        else if (value instanceof double[])
          arrayValue = Arrays.toString((double[]) value);
        else if (value instanceof boolean[])
          arrayValue = Arrays.toString((boolean[]) value);
        else throw new AssertionError("Unknown array type: " + value.getClass().getName());
        builder.append(arrayValue.replace("\n", "\n" + indent));
      }
      else builder.append(value.toString().replace("\n", "\n" + indent));
      if (value instanceof String)
        builder.append("\"");
      separator = ",\n";
    }
    return builder.append("\n}").toString();
  }
}

Now replace all of the toString methods in the model objects with this version (same one for all classes) :

  public String toString()
  {
    return new ToJsonString(getClass(), this).toString();
  }

Now when you list all users it will be readable in Json Format:

[models.User
{
  "firstName": "marge",
  "lastName": "simpson",
  "password": "secret",
  "activities": {5=models.Activity
  {
    "route": [models.Location
    {
      "latitude": 23.3,
      "counter": 0,
      "id": 4,
      "longitude": 33.3
    }, models.Location
    {
      "latitude": 34.4,
      "counter": 0,
      "id": 5,
      "longitude": 45.2
    }, models.Location
    {
      "latitude": 25.3,
      "counter": 0,
      "id": 6,
      "longitude": 34.3
    }, models.Location
    {
      "latitude": 44.4,
      "counter": 0,
      "id": 7,
      "longitude": 23.3
    }],
    "distance": 0.001,
    "location": "fridge",
    "counter": 0,
    "id": 5,
    "type": "walk"
  }, 6=models.Activity
  {
    "route": [],
    "distance": 1.0,
    "location": "bar",
    "counter": 0,
    "id": 6,
    "type": "walk"
  }},
  "counter": 0,
  "id": 4,
  "email": "marge@simpson.com"
}, models.User
{
  "firstName": "lisa",
  "lastName": "simpson",
  "password": "secret",
  "activities": {7=models.Activity
  {
    "route": [],
    "distance": 2.2,
    "location": "work",
    "counter": 0,
    "id": 7,
    "type": "run"
  }, 8=models.Activity
  {
    "route": [],
    "distance": 2.5,
    "location": "shop",
    "counter": 0,
    "id": 8,
    "type": "walk"
  }},
  "counter": 0,
  "id": 5,
  "email": "lisa@simpson.com"
}, models.User
{
  "firstName": "bart",
  "lastName": "simpson",
  "password": "secret",
  "activities": {},
  "counter": 0,
  "id": 6,
  "email": "bart@simpson.com"
}, models.User
{
  "firstName": "maggie",
  "lastName": "simpson",
  "password": "secret",
  "activities": {},
  "counter": 0,
  "id": 7,
  "email": "maggie@simpson.com"
}]

Solution

Archive of the completed pacemaker application:

Note the commit history:

which includes the complete application from empty project to this final version.

Exercises Scripting

Look again at the complete command list:

Welcome to pacemaker-console - ?help for instructions
pm> ?list-all
abbrev  name  params
!rs !run-script (filename)
!el !enable-logging (fileName)
!dl !disable-logging  ()
!gle  !get-last-exception ()
!sdt  !set-display-time (do-display-time)
?l  ?list (startsWith)
?l  ?list ()
?h  ?help (command-name)
?h  ?help ()
?la ?list-all ()
?ghh  ?generate-HTML-help (file-name, include-prefixed)
gu  get-user  (email)
gu  get-users ()
cu  create-user (first name, last name, email, password)
du  delete-user (email)
aa  add-activity  (user-id, type, location, distance)
al  add-location  (activity-id, latitude, longitude)
pm>

The !rs command will allow you to save a sequence of commands to a file, and the run the lot. Try this now - it might be useful for some simple testing.