TA Review Session
Introduction
In this review session, we will be creating an app, Friendbook, that shows the history of a user’s friendships.
In this app, all friendships are bidirectional and only require one person to initiate a friendship. This means, there will be no friend requests. If User A adds User B as a friend, then User B is now a part of User A’s list of friends and User A is now a part of User B’s list of friends. User B does not have to confirm a friend request to make the relation valid.
Additionally, whenever a new friendship is made, a new post is created on both user’s profiles to celebrate the start of their new beautiful life-long relationship.
We will go over the generic Friending concept on a high level first, looking at its purpose, OP, states, and actions. We will discuss how the generic Friending concept gets instantiated in our app. Using the app-level instantiation, we create our dependency diagram and data model diagram. Now, we can also begin discussing app-level synchronizations on a high level. We will then transtition to implementation and show how we can implement our concepts in the backend routes using a RESTful design. The backend will be directly correlated with the concept states and app-level syncs we define. From there, we will show how to use the backend routes we define in the frontend to create our app. If time is available, we will have a Q&A session at the very end.
You can find the review-session repo we will be using here. Please feel free to follow along during the backend and frontend components of this review, but it is by no means necessary.
Concepts
Let’s define Friending as a concept. In general,
- purpose: quick description in layman’s term of what the concept fulfills
- operational principle: defines how a concept is typically used and how it fulfills its purpose
- states: define what the concept remembers at runtime AKA the concept’s memory
- a series of variable declarations
- each variable is defined by a set or relation
- relations may map one set to another (binary relation) or associate elements of more than two sets (multirelation)
- all states of a concept should be defined in a set or relation
- actions: capture what the user does to use the concept
Applying this to Friending gives,
concept Friending[User]
purpose: allowing two users to establish a relationship
operational principle: after indicating a user, the addFriend action will add the user to the current user's
list of friends and similarly remove friends with removeFriend
state:
friends: set Friendship
user1, user2: Friendship -> User
actions:
addFriend(u1, u2: User):
(u2, u1) not in friendships && (u1, u2) not in friendships
friendships |= (u1, u2)
removeFriend(u1, u2: User):
(u1, u2) in friendships
friendships -= (u1, u2)
(u2, u1) in friendships
friendships -= (u2, u1)
getFriends(user: User, out friends: set User):
for (u1, u2) in friendships
u1 == user
friends += u2
u2 == user
friends += u1
We maintain genericity for Friending by including the type parameter User. Types of objects that are not specific to the concept (and are generated by it) should be represented by type parameters, making the concept polymorphic or generic. Right now, User is a type variable that that will be provided when the concept is used.
Dependency diagram vs abstract data models
- Dependency diagrams represent external dependencies between your concept. We draw a graph with concepts as nodes and an edge from one concept x to another concept y if any member of the program family that includes x must also include y.
- Abstract data models represent sets and relations in the state. See more details in A4 Abstract data models.
- Adding multiplicities to indicate how many of the object at the end of the arrow.
- ≥ 0 set, the default
- ≥ 1 some, +
- ≤ 1 opt, lone, ?
- = 1 one, !
- Adding multiplicities to indicate how many of the object at the end of the arrow.
In our app, we have the following dependency diagram. Sessioning depends on Authenticating because we cannot maintain a user’s session if there is no way for a user to register and login in the app. Friending depends on Authenticating because a friendship is defined as a relationship between two users. Somewhat confusingly here, Authenticating also depends on Friending because without Friending, there is no point to registering and logging in users. Therefore, any app that we make with our app’s purpose of showing the history of a user’s friendships should not have Authenticating without also having Friending. Similarly, any app with Friending will also have Posting since we automatically create a post every time a new friendship is created. Lastly, we can have an app without Friending where we just have Sessioning, Authenticating, and Posting, so there is an arrow from Posting to Authenticating.
Our app also has the following data model diagram taken from the concept states. Important things to note here are the multipliciies and how we define a Friendship. We create the set of Friendship objects and define that each Friendship object has a user1 and a user2 which are both User instances. This is the same notation that is used in the mapping from User to string where we have one string for each user’s username and one string for each user’s password.
App level instantiation
This is when you can fill in the type parameters to be specific to the object. In our app, this is:
app Friendbook
include Authenticating, Sessioning[Authenticating.User], Posting[Authenticating.User], Friending[Authenticating.User]
We want to keep the concept definition generic by type parameter, but during app instantiation, we specify those parameters based on our concepts. In this case, we repaced the generic User type with Authenticating.User
.
Syncs
We know we will need to sync Posting and Friending in our routes to achieve desired functionality. Let’s talk about this at a high level!
We know that we will have some function to create posts in Posting. We also have the actions to add and remove friends in Friending. This means, we can have syncs between the two concepts. In the same route, we will want to (1) get the current user, (2) establish a new friendship between the current user and the friend input, and (3) create a post declaring your friendship. For these actions to happen atomically, we will want to sync them together in our backend! This is how we would write this out at the high level:
sync addFriend(u1, u2: User, out: Post)
when: Friending.addFriend(u1, u2)
Posting.create(u1, "{u1} is now friends with {u2}!")
Posting.create(u2, "{u2} is now friends with {u1}!")
Backend
How should we define a schema for Friending
? We need to keep track of friendships
and which users these frienships belong to. Thus we first define a DocCollection
that stores all the friendships
. Each friendship will be in the form of a FriendshipDoc
which shows which users each friendship belongs to.
state:
friends: set Friendship
user1, user2: Friendship -> User
constructor(collectionName: string) {
this.friends = new DocCollection<FriendshipDoc>(collectionName);
}
export interface FriendshipDoc extends BaseDoc {
user1: ObjectId;
user2: ObjectId;
}
Then we want to write these actions as methods. By writing out explicit definitions modify or retrieve states, we can easily transfer that into code.
actions:
addFriend(u1, u2: User):
(u2, u1) not in friendships && (u1, u2) not in friendships
friendships |= (u1, u2)
removeFriend(u1, u2: User):
(u1, u2) in friendships
friendships -= (u1, u2)
(u2, u1) in friendships
friendships -= (u2, u1)
getFriends(user: User, out friends: set User):
for (u1, u2) in friendships
u1 == user
friends += u2
u2 == user
friends += u1
async addFriend(user1: ObjectId, user2: ObjectId) {
await this.assertNotFriends(user1, user2);
await this.friends.createOne({ user1, user2 });
return { msg: "Friended!" };
}
async removeFriend(user1: ObjectId, user2: ObjectId) {
const friendship = await this.friends.popOne({
$or: [
{ user1: user1, user2: user2 },
{ user1: user2, user2: user1 },
],
});
if (friendship === null) {
throw new FriendNotFoundError(user1, user2);
}
return { msg: "Unfriended!" };
}
async getFriends(user: ObjectId) {
const friendships = await this.friends.readMany({
$or: [{ user1: user }, { user2: user }],
});
// Making sure to compare ObjectId using toString()
return friendships.map((friendship) => (friendship.user1.toString() === user.toString() ? friendship.user2 : friendship.user1));
}
In addition to action methods, we also need validators like assertNotFriends
to ensure that an action is valid. We can call these validators inside of the concept or in synchronizations. We also have specific errors extended from errors in the errors.ts
file. This allows our error messages to be more specific. In this case, our error message inlcudes the exact ObjectId
s of users, making it easier to debug.
private async assertNotFriends(u1: ObjectId, u2: ObjectId) {
const friendship = await this.friends.readOne({
$or: [
{ user1: u1, user2: u2 },
{ user1: u2, user2: u1 },
],
});
if (friendship !== null || u1.toString() === u2.toString()) {
throw new AlreadyFriendsError(u1, u2);
}
}
export class FriendNotFoundError extends NotFoundError {
constructor(
public readonly user1: ObjectId,
public readonly user2: ObjectId,
) {
super("Friendship between {0} and {1} does not exist!", user1, user2);
}
}
export class AlreadyFriendsError extends NotAllowedError {
constructor(
public readonly user1: ObjectId,
public readonly user2: ObjectId,
) {
super("{0} and {1} are already friends!", user1, user2);
}
}
Next we move on to the syncs. We can see that syncs like getFriends
or removeFriends
really only require a session. However, sync for addFriend
is more sophisticated as it involves the Friending
concept and the Posting
concept. When user adds a friend, a post will be made between both users to announce the friendship.
@Router.get("/friends")
async getFriends(session: SessionDoc) {
const user = Sessioning.getUser(session);
return await Authing.idsToUsernames(await Friending.getFriends(user));
}
@Router.post("/friends/:friend")
async addFriend(session: SessionDoc, friend: string) {
const oid = Sessioning.getUser(session);
const friendOid = (await Authing.getUserByUsername(friend))._id;
const usrn = await Authing.getUserById(oid);
const friend_usrn = await Authing.getUserById(friendOid);
await Friending.addFriend(oid, friendOid); // throws an Error if already friends
// If Friending is successful, then we can add post to keep actions atomic
await Posting.create(oid, `I just became friends with ${friend_usrn.username}!`);
await Posting.create(friendOid, `I just became friends with ${usrn.username}!`);
return { msg: "Friended!" };
}
@Router.delete("/friends/:friend")
async removeFriend(session: SessionDoc, friend: string) {
const user = Sessioning.getUser(session);
const friendOid = (await Authing.getUserByUsername(friend))._id;
return await Friending.removeFriend(user, friendOid);
}
To define these, we first define the route by outlining the HTTP method and the path. Based on route functionality, we choose methods like @Router.post
, @Router.get
, @Router.delete
, @Router.put
, @Router.patch
, etc. Make sure you choose the correct method to remain RESTful.
Your path should also adhere to RESTful api design. Advice for avoiding common mistakes include:
- Try not to have verbs in your routes (but this is not a steadfast rule). Generally, you want to name the url path by the objects that the functionality is acting upon.
i.e. instead of
@Router.post("/friends/add/:friend")
, we keep it simple with@Router.post("/friends/:friend")
because the use of the HTTPpost
method indicates that we will be creating a new friendship relationship, implicitly showing that friends are being added by this route. - Don’t include too many levels of hierarchy in your path.
- Generally you are acting on one resource per api route.
Frontend (Vue)
We will be going through the frontend code. For general information, check out the Guide to Vue we provided for Prep 7!
We access our server by using the routes we defined in the backend and fetchy
. For example,
If we want to see all the friendships the logged in user has, we can use the route:
@Router.get("/friends")
async getFriends(session: SessionDoc) {
...
}
by calling it in a frontend component (FriendView.vue
) with fetchy:
let friends = ref<Array<Record<string, string>>>([]);
async function getFriends() {
let friendsResults;
try {
friendsResults = await fetchy("/api/friends", "GET");
} catch (_) {
return;
}
friends.value = friendsResults;
}
...
;
If we want to create a new friendship, we can use the route:
@Router.post("/friends/:friend")
async addFriend(session: SessionDoc, friend: string) {
...
}
by calling it in a frontend component (CreateFriendForm.vue
) with fetchy:
const createFriend = async () => {
try {
await fetchy(`/api/friends/${friend.value}`, "POST", {
body: { friend: friend.value },
});
} catch (_) {
return;
}
emit("refreshFriends");
emptyForm();
};
Here we pass in the value of our ref friend
as a parameter to the route. This parameter eventually becomes user2 in our MongoDb collection and we store (<current_logged_in_user>, <friend>). We put fetchy into an async function so that we only access the route when we want to and not on every render.
Every time a new friend is created, it creates an emit (refreshFriends
), which the (FriendView.vue
) listens for so as to update the displayed friends.
<section class="friends" v-if="loaded && friends.length !== 0">
<article v-for="friend in friends" :key="friend._id">
<FriendComponent :friend="friend" @refreshFriends="getFriends" />
</article>
</section>