Building a Twitter Clone with Neo4j – Part Three

In part two we defined our API and got registering a user, checking a user and getting a user profile. A social network of unconnected people doesn’t live up to its name, so let’s go ahead and build the ability to follow people.

Still in our Users class we’ll add an endpoint that is another POST request. The method createFollows takes two parameters (the usernames of the user following and the username of the user being followed). In a transaction, we find both users, make sure the follower is not being blocked by the followed and if so create the new FOLLOWS relationship with a time property.

    @POST
    @Path("/{username}/follows/{username2}")
    public Response createFollows(@PathParam("username") final String username,
                                 @PathParam("username2") final String username2,
                                 @Context GraphDatabaseService db) throws IOException {
        Map<String, Object> results;
        try (Transaction tx = db.beginTx()) {
            Node user = findUser(username, db);
            Node user2 = findUser(username2, db);

            HashSet<Node> blocked = new HashSet<>();
            for (Relationship r1 : user2.getRelationships(Direction.OUTGOING,
                                                          RelationshipTypes.BLOCKS)) {
                blocked.add(r1.getEndNode());
            }

            if ( blocked.contains(user)) {
                throw UserExceptions.userBlocked;
            }

            Relationship follows =  user.createRelationshipTo(user2, RelationshipTypes.FOLLOWS);
            LocalDateTime dateTime = LocalDateTime.now(utc);
            follows.setProperty(TIME, dateTime.toEpochSecond(ZoneOffset.UTC));
            results = user2.getAllProperties();
            results.remove(EMAIL);
            results.remove(PASSWORD);
            tx.success();
        }
        return Response.ok().entity(objectMapper.writeValueAsString(results)).build();
    }

Checking for the potential BLOCKS relationship between the users is important to prevent trolls and abuse. We don’t want to build a nightmare service.

We can test that our method doesn’t create a follows relationship to a user blocking the follower with a test. In our fixture we are pre-populating the BLOCKS relationship since I haven’t built a way to create it yet. Our test expects an 400 status response denying the request:


    @Rule
    public Neo4jRule neo4j = new Neo4jRule()
            .withFixture(FIXTURE)
            .withExtension("/v1", Users.class);

    @Test
    public void shouldNotCreateFollowsBlocked() {
        HTTP.POST(neo4j.httpURI().resolve("/v1/schema/create").toString());

        HTTP.Response response = HTTP.POST(neo4j.httpURI()
                                     .resolve("/v1/users/maxdemarzi/follows/laexample").toString());
        HashMap actual  = response.content();
        Assert.assertEquals(400, response.status());
        Assert.assertEquals("Cannot follow blocked User.", actual.get("error"));
    }

    private static final String FIXTURE =
            "CREATE (max:User {username:'maxdemarzi', " +
                    "email: 'max@neo4j.com', " +
                    "name: 'Max De Marzi'," +
                    "password: 'swordfish'})" +
            "CREATE (laeg:User {username:'laexample', " +
                    "email: 'luke@neo4j.com', " +
                    "hash: 'hash', " +
                    "name: 'Luke Gannon'," +
                    "password: 'cuddlefish'})" +
            "CREATE (laeg)-[:BLOCKS {time:1490140299}]->(max)";

The other thing we need to do is to stop following users. This should be a fairly rare operation but sometimes you unfollow people who post too much, post things primarily in a language you don’t understand or you tend to disagree with. This last one has the potential of creating filter bubbles where your world view is constantly reinforced but naturally brittle and unrealistic.

Our removeFollows method is a DELETE request that starts out similar to createFollows. Before we can delete the relationship between the two users, we need to find it. If we did this the naive way, we would look at all the FOLLOWS relationships of the follower looking for the followed. However what if the follower has 3000 outgoing FOLLOWS relationships and the followed only a handful of incoming FOLLOWS relationships? What should we do?

Right, traverse the path with the least relationships. Since Neo4j doesn’t care which side of a relationship you traverse, we can figure out which is the cheaper option and take it. I mentioned this in a previous post, the getDegree result is pre-computed when the new relationship is created, so we can use this to pick the smallest number of relationships to traverse.

    @DELETE
    @Path("/{username}/follows/{username2}")
    public Response removeFollows(@PathParam("username") final String username,
                                  @PathParam("username2") final String username2,
                                  @Context GraphDatabaseService db) throws IOException {
        Map<String, Object> results;
        try (Transaction tx = db.beginTx()) {
            Node user = findUser(username, db);
            Node user2 = findUser(username2, db);

            if (user.getDegree(RelationshipTypes.FOLLOWS, Direction.OUTGOING)
                    < user2.getDegree(RelationshipTypes.FOLLOWS, Direction.INCOMING)) {
                for (Relationship r1: user.getRelationships(Direction.OUTGOING,
                                                            RelationshipTypes.FOLLOWS) ) {
                    if (r1.getEndNode().equals(user2)) {
                        r1.delete();
                    }
                }
            } else {
                for (Relationship r1 : user2.getRelationships(Direction.INCOMING,
                                                              RelationshipTypes.FOLLOWS)) {
                    if (r1.getStartNode().equals(user)) {
                        r1.delete();
                    }
                }
            }

            tx.success();
        }
        return Response.noContent().build();
    }

Testing our removeFollows method is a little weird for two reasons. First, the Neo4j test help client has GET and POST, but no DELETE, instead we have to call it using “request”. Second, the client doesn’t seem to handle a 204 status, “noContent()” response and it throws up an Exception. So to make sure it works I have to expect the exception to be thrown. I tested this manually using Debug in IntelliJ to make sure it does what it is supposed to. Maybe there is a better way of testing this?

    @Rule
    public ExpectedException thrown = ExpectedException.none();

    @Test
    public void shouldRemoveFollows() {
        HTTP.POST(neo4j.httpURI().resolve("/v1/schema/create").toString());
        thrown.expect(UniformInterfaceException.class);
        HTTP.request("DELETE", neo4j.httpURI().resolve("/v1/users/maxdemarzi/follows/jexp").toString(), null);
    }

Alright, so we can follow and unfollow people. How do I see my followers? We need a getFollowers method. Now I don’t want to see a few thousand people all at once, we need some kind of pagination mechanism built in. We’re going to add two query parameters to our HTTP endpoint, “limit” and “since”. With these in place we can paginate through our followers. We will return the list by the most recent followers which is what I would expect our users to want.

    @GET
    @Path("/{username}/followers")
    public Response getFollowers(@PathParam("username") final String username,
                                 @QueryParam("limit") @DefaultValue("25") final Integer limit,
                                 @QueryParam("since") final Long since,
                                 @Context GraphDatabaseService db) throws IOException {
        ArrayList<Map<String, Object>> results = new ArrayList<>();
        // TODO: 4/3/17 Add Recent Array for Users with > 100k Followers
        LocalDateTime dateTime;
        if (since == null) {
            dateTime = LocalDateTime.now(utc);
        } else {
            dateTime = LocalDateTime.ofEpochSecond(since, 0, ZoneOffset.UTC);
        }
        Long latest = dateTime.toEpochSecond(ZoneOffset.UTC);

        try (Transaction tx = db.beginTx()) {
            Node user = findUser(username, db);
            for (Relationship r1: user.getRelationships(Direction.INCOMING, 
                                                        RelationshipTypes.FOLLOWS)) {
                Long time = (Long)r1.getProperty(TIME);
                if(time < latest) {
                    Node follower = r1.getStartNode();
                    Map<String, Object> result = getUserAttributes(follower);
                    result.put(TIME, time);
                    results.add(result);
                }
            }
            tx.success();
        }
        results.sort(Comparator.comparing(m -> (Long) m.get(TIME), reverseOrder()));

        return Response.ok().entity(objectMapper.writeValueAsString(
                results.subList(0, Math.min(results.size(), limit))))
                .build();
    }

The getFollowing method is exactly the same except the relationship direction is OUTGOING instead of INCOMING. In our test we will be sure to check returning all followers, but also using the limit and since parameters in our query:

    @Test
    public void shouldGetFollowersLimited() {
        HTTP.POST(neo4j.httpURI().resolve("/v1/schema/create").toString());

        HTTP.Response response = HTTP.GET(neo4j.httpURI()
                                     .resolve("/v1/users/maxdemarzi/followers?limit=1").toString());
        ArrayList<HashMap> actual  = response.content();
        Assert.assertTrue(actual.size() == 1);
        Assert.assertEquals(expected.get(0), actual.get(0));
    }

    @Test
    public void shouldGetFollowersSince() {
        HTTP.POST(neo4j.httpURI().resolve("/v1/schema/create").toString());

        HTTP.Response response = HTTP.GET(neo4j.httpURI()
                                     .resolve("/v1/users/maxdemarzi/followers?since=1490140300").toString());
        ArrayList<HashMap> actual  = response.content();
        Assert.assertTrue(actual.size() == 1);
        Assert.assertEquals(expected.get(1), actual.get(0));
    }

    private static final String FIXTURE =
            "CREATE (max:User {username:'maxdemarzi', " +
            ...
            "CREATE (jexp:User {username:'jexp', " +
            ...
            "CREATE (laeg:User {username:'laexample', " +
            ...
            "CREATE (max)<-[:FOLLOWS {time:1490140299}]-(jexp)" +
            "CREATE (max)<-[:FOLLOWS {time:1490155000}]-(laeg)";

The tests for GetFollowing are pretty much identical except going in the opposite Direction.

I also need to add the functionality to block users. In our createBlocks method we find our users, and we once again traverse the smallest set of relationships to see if there already exists a BLOCKS relationship between these two users. If not, then it goes ahead and creates one, adding the time property. The last thing we will do is replicate our removeFollows method to make sure the blocked user is no longer following us.

    @POST
    @Path("/{username2}")
    public Response createBlocks(@PathParam("username") final String username,
                                  @PathParam("username2") final String username2,
                                  @Context GraphDatabaseService db) throws IOException {
        Map<String, Object> results;
        try (Transaction tx = db.beginTx()) {
            Node user = findUser(username, db);
            Node user2 = findUser(username2, db);

            if (user.getDegree(RelationshipTypes.BLOCKS, Direction.OUTGOING)
                    < user2.getDegree(RelationshipTypes.BLOCKS, Direction.INCOMING) ) {
                for (Relationship r1 : user.getRelationships(Direction.OUTGOING, RelationshipTypes.BLOCKS) ) {
                    if (r1.getEndNode().equals(user2)) {
                        throw BlockExceptions.alreadyBlockingUser;
                    }
                }
            } else {
                for (Relationship r1 : user2.getRelationships(Direction.INCOMING, RelationshipTypes.BLOCKS)) {
                    if (r1.getStartNode().equals(user)) {
                        throw BlockExceptions.alreadyBlockingUser;
                    }
                }
           }
            
            Relationship blocks = user.createRelationshipTo(user2, RelationshipTypes.BLOCKS);
            LocalDateTime dateTime = LocalDateTime.now(utc);
            blocks.setProperty(TIME, dateTime.toEpochSecond(ZoneOffset.UTC));
            // Code for removing FOLLOWS relationship if it exists.

If we wanted to give our blocked user another chance and unblock them, then we need to add that functionality. We are once again going to take the past least traveled to see if we even have a blocks relationship between these users. If found, we set deleted to true and return with a positive response, otherwise we throw an error.

    @DELETE
    @Path("/{username2}")
    public Response removeBlocks(@PathParam("username") final String username,
                                 @PathParam("username2") final String username2,
                                 @Context GraphDatabaseService db) throws IOException {

            boolean deleted = false;
            if (user.getDegree(RelationshipTypes.BLOCKS, Direction.OUTGOING)
                    < user2.getDegree(RelationshipTypes.BLOCKS, Direction.INCOMING) ) {
                for (Relationship r1 : user.getRelationships(Direction.OUTGOING, RelationshipTypes.BLOCKS) ) {
                    if (r1.getEndNode().equals(user2)) {
                        r1.delete();
                        deleted = true;
                        break;
                    }
                }
            } else {
                for (Relationship r1 : user2.getRelationships(Direction.INCOMING, RelationshipTypes.BLOCKS)) {
                    if (r1.getStartNode().equals(user)) {
                        r1.delete();
                        deleted = true;
                        break;
                    }
                }
            }
        ...
        if (deleted) {
            return Response.noContent().build();
        } else {
            throw BlockExceptions.notBlockingUser;
        }

Finally we need to see a list of the people we blocked so we can stare at them in disapproval. This one is fairly trivial as we simply find our user and traverse the BLOCKS relationship in the OUTGOING direction and sort them by time. I’ve never blocked anybody on Twitter so I didn’t think I would need to paginate through this list, but then I realized I needed to add the limit and since parameters because of trolls and polarizing users.

    @GET
    public Response getBlocks(@PathParam("username") final String username,
                              @QueryParam("limit") @DefaultValue("25") final Integer limit,
                              @QueryParam("since") final Long since,
                              @Context GraphDatabaseService db) throws IOException {
        ArrayList<Map<String, Object>> results = new ArrayList<>();
        LocalDateTime dateTime;
        if (since == null) {
            dateTime = LocalDateTime.now(utc);
        } else {
            dateTime = LocalDateTime.ofEpochSecond(since, 0, ZoneOffset.UTC);
        }
        Long latest = dateTime.toEpochSecond(ZoneOffset.UTC);

        try (Transaction tx = db.beginTx()) {
            Node user = findUser(username, db);
            for (Relationship r1: user.getRelationships(Direction.OUTGOING, RelationshipTypes.BLOCKS)) {
                Node blocked = r1.getEndNode();
                Long time = (Long)r1.getProperty("time");
                if(time < latest) {
                    Map<String, Object> result = getUserAttributes(blocked);
                    result.put(TIME, time);
                    results.add(result);
                }
            }
            tx.success();
        }
        results.sort(Comparator.comparing(m -> (Long) m.get(TIME), reverseOrder()));
        return Response.ok().entity(objectMapper.writeValueAsString(
                results.subList(0, Math.min(results.size(), limit))))
                .build();

    }

We will call it good for today, but stay tuned for more next time as we add the post functionality.

Tagged , , , , , , , ,

2 thoughts on “Building a Twitter Clone with Neo4j – Part Three

  1. […] left off last time having just added the ability to follow people, see who we’ve followed and has followed us, […]

  2. […] to get all the likes of our user. This is going to flow just like our getFollowers method seen in part 3. From our user, we will traverse out the LIKES relationships. The “limit” and […]

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 )

Twitter picture

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

Facebook photo

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

Google+ photo

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

Connecting to %s

%d bloggers like this: