We've written the HTTP layer of the application; what remains is to implement the storage layer and the basic logic for the microblogging site. We'll give you an example schema and write some database interactions, but so long as the POSTs and GETs to your service return the expected json, the design of the schema and the service implementation is up to you.
📚 If you haven't read our tutorial for how to construct a relational database schema using OrderedTables, you should check that out before starting this exercise.
Schema design best practices and tips
Because we've started with the API layer, we already know the queries we'll be making and we can work backwards to design the database schema.
First, we can write a few types the database will store and retrieve. For example, we might need a UserRow
, PostRow
, and FollowingRow
that store the data for users, posts, and the relationships between users.
{- These types have a `v2` prefix because
the sample schema has changed over time -}
type db.v2.UserHandle = UserHandle Text
type db.v2.FollowingId = FollowingId UID
type db.v2.PostId = PostId UID
type db.v2.UserRow = { userHandle : UserHandle,
name : Text,
avatar : Optional URI }
type db.v2.PostRow = { id : PostId,
createdAt : OffsetDateTime,
body : Text,
userHandle : UserHandle }
type db.v2.FollowingRow = { followingId : FollowingId,
follower : UserHandle,
target : UserHandle,
createdAt : OffsetDateTime }
Questions you might consider when defining tables:
- How much data normalization is preferable for the schema? Should your keys be randomly generated IDs that reference the data in another table, or should an entire data blob be stored directly in the table?
- How can you ensure the uniqueness of a given key in your OrderedTable table? A random ID? Timestamps? Etc? (You can assume that the user's handle is unique.)
- What requests will require keeping multiple tables
in sync? Cloud databases support transactions via the
Transaction
ability, but they have size constraints and too many transactions can lock down your tables.
Many of the tables will likely be OrderedTable's—sorted key-value tables that support tuple key types and querying by ranges. As you're designing your schema consider leveraging both of those features.
For example, if we needed to get a user's most recent posts, rather than storing all the user's posts in a list, we could store the posts in an OrderedTable keyed by the user handle and the timestamp of the post. This way, we can query the table via the handle prefix, to get all the posts in a sorted order:
userPostsTable: OrderedTable (UserHandle, OffsetDateTime) PostRow
userPostsTable = OrderedTable.named database "userPosts" Universal.ordering
getUserPosts =
rangeClosed.prefix userPostsTable OrderedTable.prefixOrdering targetUser targetUser
|> Stream.toList
📝 Instructions
Once you've thought about the set of tables needed to support the app, update your deploy function to create the Database
and tables.
If you'd like, you can use this sample database schema type:
type db.v2.AppStorage = { database : Database,
userHandleToUser : OrderedTable UserHandle UserRow,
userPostsTable : OrderedTable (UserHandle, OffsetDateTime) PostRow,
postsTable : OrderedTable PostId PostRow,
followingTable : OrderedTable (UserHandle, UserHandle) FollowingRow }
It's often handy to have a single type that holds all the tables you'll need to interact with, so you can pass them around as a single argument to functions (like your service routes) that need to interact with the database.
Creating an instance of this type will require creating the database and the tables. This operation is idempotent, so you can incorporate it into the deploy
function, but you should be mindful that changing the types in your database schema can have adverse effects on data you've already written. That's why these table names are suffixed with a version number.
db.createAppStorage : '{Exception, Cloud} AppStorage
db.createAppStorage = do
db = Database.named "microblog-solutions-v2"
Database.assign db exercises.env()
userHandleToUser = OrderedTable.named db "userHandleToUser-v2" Universal.ordering
userPostsTable = OrderedTable.named db "userPostsTable-v2" Universal.ordering
postsTable = OrderedTable.named db "postsTable-v2" Universal.ordering
followingTable = OrderedTable.named db "followingTable-v2" Universal.ordering
AppStorage db userHandleToUser userPostsTable postsTable followingTable
Update the deploy
function to create the tables for the service and pass them to the service API layer.
cloud-start/main> edit ex3_microblog.deploy
Transactions and other abilities in database interactions
Now we can get started writing database interactions. Let's look at an example that creates a blog post. In this database interaction, we have two tables that need to be kept in sync:
- One links a
UserHandle
to aPostRow
- used to retrieve all posts by a user in time order - And another stores the
PostRow
data keyed by itsPostId
- used to get a post by its ID.
instantToOffsetDateTime : '{Remote} OffsetDateTime
instantToOffsetDateTime = do
instantNow = Remote.now!
Instant.atOffset instantNow UTC
db.createPost : AppStorage -> UserHandle -> Text ->{Exception, Storage, Remote, Random} PostRow
db.createPost storage userHandle body =
postId = PostId.PostId (Remote.UID.random())
timeStamp = instantToOffsetDateTime()
postTable = AppStorage.postsTable storage
userPostsTable = AppStorage.userPostsTable storage
transact.random (AppStorage.database storage) do
postRow = PostRow.PostRow postId timeStamp body userHandle
write.tx postTable postId postRow
write.tx userPostsTable (userHandle, timeStamp) postRow
postRow
Cloud services don't have access to arbitrary IO, so generating timestamps or random IDs will require using the Remote
ability. We're using Remote.now!
and Remote.UID.random
instead of IO.
The db.createPost
function also uses the Transaction
ability to perform multiple operations that should fail or succeed together. Most functions defined for updating records in an OrderedTable have a transactional variant, suffixed with .tx
.
Because we're writing to an OrderedTable, the transaction also requires the Random
ability. Depending on what abilities your combined database operations require, run the transaction in one of the following:
transact : Database
-> '{Transaction, Exception} a
->{Exception, Storage} a
transact.random : Database
-> '{Transaction, Exception, Random} a
->{Exception, Storage, Random} a
📝 Instructions
Write the remaining database functions for the microblogging site and call them inside your earlier service route functions to connect the API layer to the database layer.
Your service routes should do the following:
- They should run the necessary database operations to create, read, update, and delete data.
- They should translate the types returned from the database layer into the types expected by the API layer.
We won't test for all of the possible workflows in your app, but we will check if your service can create a new user, have that user create a post, and then retrieve that post by its ID. We'll also check if your app allows a user to follow another user, so they can see a microblog feed of interesting posts.
When you're ready, update
your codebase and submit your solution. Remember, if you're ever stuck, you can run the deploy
function for the service and test your service live by hitting its endpoints!
cloud-start/main> run submit.ex3_microblog.roundTrip
Solution
The source for this service is available in the exercises.solutions.ex3_microblog
namespace in the cloud-start
project. Check it out if you get stuck!
Creating a user
The createUser
endpoint writes the user to the table, keyed by their UserHandle
, which we're also using as the "UserId" return value.
api.createUser : AppStorage -> '{Route, Exception, Storage, Remote, Random} ()
api.createUser storage = do
userHandleText = route POST (s "api" / s "user" / Parser.text)
newUser = decodeJson User.fromJson
avatarURI = Optional.flatMap URI.parseOptional (User.avatar newUser)
userName' = userName newUser
userHandle = UserHandle userHandleText
OrderedTable.write
(userHandleToUser storage)
userHandle
(UserRow.UserRow userHandle userName' avatarURI)
ok.json (UserId.toJson <| client.UserId.UserId userHandleText)
Getting a Post by ID
This endpoint reads the post from the postsTable
and the user's name from the userHandleToUser
table in a single transaction.
api.getPostById : AppStorage -> '{Route, Exception, Storage, Remote} ()
api.getPostById storage = do
postUID =
UID (Text.toUtf8 (route GET (s "api" / s "posts" / Parser.text)))
post = transact (AppStorage.database storage) do
(v2.PostRow.PostRow _ createdAt body userHandle) =
read.tx
(AppStorage.postsTable storage)
(PostId.PostId postUID)
(UserRow.UserRow
(UserHandle userHandleAsText) userName avatar)
=
read.tx (userHandleToUser storage) userHandle
timeTxt =
OffsetDateTime.toText createdAt
Post.toJson (Post body userName userHandleAsText timeTxt)
ok.json post
Following a user
Following a user involves writing a row to the followingTable
table. This one does not need a transaction because it's a single write.
db.followUser :
AppStorage
-> UserHandle
-> UserHandle
->{Exception, Storage, Remote, Random} FollowingRow
db.followUser storage follower target =
followingTable = AppStorage.followingTable storage
followingId = FollowingId.FollowingId UID.random()
timeStamp = Instant.atUTC now!
followingRow =
FollowingRow.FollowingRow followingId follower target timeStamp
OrderedTable.write followingTable (follower, target) followingRow
followingRow
api.followUser : AppStorage -> '{Route, Exception, Storage, Remote, Random} ()
api.followUser storage = do
userHandle = route POST (s "api" / s "follow" / Parser.text)
let
(follower, target) = decodeJson FollowRequest.fromJson
(FollowingRow.FollowingRow id _ _ _) =
db.followUser
storage (UserHandle follower) (UserHandle target)
ok.json (FollowingId.toJson id)
Getting a user's feed
Getting a user's feed involves getting all the handles a user is following from one table, then getting all the posts from those users.
Our table representing follower relationships is keyed by a tuple of (userHandle, targetHandleToFollow)
, so we can use rangeClosed.prefix
to get all the targets's handles. With the UserHandles in hand, we can then query the userPostsTable
to get all the posts from those users.
db.getFeedPosts : AppStorage -> UserHandle ->{Remote} [PostRow]
db.getFeedPosts storage followerHandle =
followerTable : OrderedTable (UserHandle, UserHandle) FollowingRow
followerTable = followingTable storage
targetUsers : '{Remote, Stream UserHandle} ()
targetUsers =
rangeClosed.prefix
followerTable prefixOrdering followerHandle followerHandle
|> map at2
|> map FollowingRow.target
userPosts : OrderedTable (UserHandle, OffsetDateTime) PostRow
userPosts = AppStorage.userPostsTable storage
posts : '{Remote, Stream PostRow} ()
posts =
Stream.flatMap
(uhandle -> rangeClosed.prefix userPosts prefixOrdering uhandle uhandle ())
targetUsers
|> Stream.map at2
Stream.toList posts
Incorporating this into the api.getFeed
route looks like this:
api.getFeed : AppStorage -> '{Route, Exception, State, Remote} ()
api.getFeed storage = do
userHandle = route GET (s "api" / s "feed" / Parser.text)
posts : [PostRow]
posts = getFeedPosts storage (UserHandle userHandle)
postClient = List.map (cases
PostRow.PostRow _ createdAt body uhandle ->
(UserRow.UserRow (UserHandle uh) name _) =
OrderedTable.read (userHandleToUser storage) uhandle
Post body name uh (OffsetDateTime.toText createdAt))
posts
postsJson = Posts.toJson postClient
ok.json postsJson