Building a Dating site with Neo4j – Part Three

We started our back end service in the last blog post and created a schema and the ability to create and fetch users. We are going to flip to the front end to make use of these abilities and work on both side by side. The goal this time is to be able to register and sign in a user. If you are a regular reader you know I’m a fan of the Jooby framework, so we’re going to use that again. After creating a shell application, what I want to do is to be able to connect to the API we’re building, so we’ll be using Retrofit to turn our HTTP API into a Java interface.

 
public interface API {

    @GET("users/{username}")
    Call<User> getUser(@Path("username") String username);

    @POST("users")
    Call<User> createUser(@Body User user);
}

We will plug this interface into our Jooby App and configure our Neo4j credentials for the http client. Retrofit will use this client to connect to Neo4j and make our requests.

 
public class App extends Jooby {
    public static API api;
  {

      // Setup API
      onStart(registry -> {

          Config conf = require(Config.class);

          // Define the interceptor, add authentication headers
          String credentials = Credentials.basic(conf.getString("neo4j.username"), 
                                                 conf.getString("neo4j.password"));
          Interceptor interceptor = chain -> {
              Request newRequest = chain.request().newBuilder()
                                      .addHeader("Authorization", credentials).build();
              return chain.proceed(newRequest);
          };

          // Add the interceptor to OkHttpClient
          OkHttpClient.Builder builder = new OkHttpClient.Builder();
          builder.interceptors().add(interceptor);
          OkHttpClient client = builder.build();

          Retrofit retrofit = new Retrofit.Builder()
                  .client(client)
                  .baseUrl("http://" + conf.getString("neo4j.url") + conf.getString("neo4j.prefix") +  "/")
                  .addConverterFactory(JacksonConverterFactory.create())
                  .build();

          api = retrofit.create(API.class);
      });

That was a bunch of setup code, but we only have to do it once. Next we need to create some pages for our website. A home page that allows logins, and registration page.

 
      // Publicly Accessible
      get("/", index::template);
      get("/register", register::template);

I’m a backend developer not a designer or copywriter, so don’t laugh, but this is what I have for the home page:

I think “normal looking people” may be insulting or not inclusive or maybe it’s fine. Maybe we should just say for “fives and sixes”. Yeah ok, I’ll change it. If you have a better idea for a tagline, please leave me a comment. Anyway, we need a User model for our web app that captures their username, their name, a short bio about them, an email address to send stuff to like forgotten password requests, and a hash of that email to try to get a Gravatar image. Your soulmate may be in the other side of the world, so chances are you’ll never meet them, instead you’ll have to settle for dating people near you, so we’ll add a distance and city property as well. Here we are using Project Lombok to avoid too much Java boilerplate.

 
@Data
public class User {
    private String username;
    private String bio;
    private String name;
    private String email;
    private String password;
    private String hash;
    private String time;
    private Integer distance;
    private String city;
    private String is;
    private List<String> is_looking_for;
}

All this is pretty standard, the hard part is socially acceptable concepts of gender. I decided to be vague in the hopes that it’s inclusive enough to work, but if you have better ideas, please let me know. I’m using an “is” property with the options of “man”, “woman”, “it’s complicated”… and the same for what they are looking for, but a user can be looking for more than one option. With this model in place we can build a registration page to request these properties:

So what happens when the user clicks Register? We need to create the user from the form, change their plaintext password into a proper encrypted password, then submit the user to our API. If the response is successful we redirect them to the home page to log on.

 
      post("/register", (req, rsp) -> {
          User user = req.form(User.class);
          user.setPassword(BCrypt.hashpw(user.getPassword(), BCrypt.gensalt()));
          Response<User> response = api.createUser(user).execute();
          if (response.isSuccessful()) {
              Results.redirect("/");
          } else {
              throw new Err(Status.CONFLICT, "There was a problem with your registration.");
          }
      });

We can check Neo4j and see if our user got created:

We can also go to http://localhost:7474/v1/users/maxdemarzi in our extension and see what it returns… and here we have a bit of a problem. The json returned is making a big mess of our “time” property:

 
time: {
  offset: {
    totalSeconds: 0,
    id: "Z",
    rules: {
      fixedOffset: true,
      transitions: [ ],
      transitionRules: [ ]
    }
  },
  zone: {
    id: "UTC",
    rules: {
      fixedOffset: true,
      transitions: [ ],
      transitionRules: [ ]
    }
  },
  nano: 613000000,
  dayOfWeek: "THURSDAY",
  dayOfYear: 193,
  year: 2018,
  second: 48,
  monthValue: 7,
  month: "JULY",
  dayOfMonth: 12,
  hour: 16,
  minute: 11,
  chronology: {
    id: "ISO",
      calendarType: "iso8601"
    }
  }
}

Ok, we don’t want to deal with that. So in our Extension we are going to add a custom serializer for the ZonedDateTime data type to turn them into easy to digest strings instead. It’s pretty straight forward and looks like this:

 
public class ZonedDateTimeSerializer extends JsonSerializer<ZonedDateTime> {

    @Override
    public void serialize(ZonedDateTime zonedDateTime, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeString(zonedDateTime.toString());
    }
}

Now in our extension code we need a custom mapper, and we want to reuse this one all over, so we will create a helper class for it:

 
public class CustomObjectMapper extends ObjectMapper {

    private CustomObjectMapper(){}

    private static class CustomObjectMapperHelper {
        private static final SimpleModule module = new SimpleModule("ZonedDateTime", new Version( 1, 0, 0, "" ));
        private static final CustomObjectMapper INSTANCE = new CustomObjectMapper();

        static {
            module.addSerializer(ZonedDateTime.class, new ZonedDateTimeSerializer());
            INSTANCE.registerModule(module);
        }
    }

    public static CustomObjectMapper getInstance() {
        return CustomObjectMapperHelper.INSTANCE;
    }
}

…and then use that everywhere:

 
@Path("/users")
public class Users {

    private static final ObjectMapper objectMapper = CustomObjectMapper.getInstance();

Pretty ugly, maybe we can get the Neo4j developers to build that in to our extension framework so we don’t have to deal with this. Regardless, when we ask for a user now, it returns a pretty json string for time:

 
{
  is_looking_for: [
    "woman"
  ],
  password: "$2a$10$aLm92grAV5RAFWsXQ67/Je2WR91gqPXNXp.kuV7gvNx4kZZQdB86a",
  distance: 40000,
  name: "Max De Marzi",
  bio: "The Creator of this site",
  is: "man",
  time: "2018-07-12T16:35:48.762Z[UTC]",
  hash: "58750f2179edbd650b471280aa66fee5",
  email: "maxdemarzi@hotmail.com",
  username: "maxdemarzi"
}

Alright, now we can register but can we log in? In Jooby you can plug in the Pac4j framework to help us with Authentication. We can plug it in this way, but need to build a Service Authenticator class.

 
use(new Pac4j().client(conf -> new FormClient("/", new ServiceAuthenticator())));

Here we will use our API to getUser and then compare the passed in password with the password stored on the server. Assuming it is good, we create a CommonProfile with some basic attributes and return successfully. If the user is not found or the credentials are wrong, we throw an exception which returns an error message to the user.

 
public class ServiceAuthenticator implements Authenticator<UsernamePasswordCredentials> {
    @Override
    public void validate(UsernamePasswordCredentials credentials, WebContext webContext) throws HttpAction, CredentialsException {
        Response<User> response;
        try {
            response = api.getUser(username).execute();
            User user = response.body();
            if (user == null || !BCrypt.checkpw(credentials.getPassword(), user.getPassword())){
                String message = "Bad credentials for: " + username;
                logger.error(message);
                throw new BadCredentialsException(message);
            } else {
                CommonProfile profile = new CommonProfile();
                profile.addAttribute("username", username);
                profile.setId(username);
                profile.addAttribute("name", user.getName());
                profile.addAttribute("email", user.getEmail());
                credentials.setUserProfile(profile);
            }
        } catch (IOException e) {
            String message = "No account found for: " + username;
            logger.error(message);
            throw new AccountNotFoundException(message);
        }
    }

With this in place, our user can log in and we can display their username on their /home page.

 
      get("/home", req -> {
          CommonProfile profile = require(CommonProfile.class);
          String username = profile.getUsername();
          return views.home.template(username);
      });

Ok, that was a bit painful to setup, but now we can get the ball rolling and start adding real functionality to our dating site. More to come in the next blog post.

Tagged , , , , , , ,

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

w

Connecting to %s

%d bloggers like this: