Let's set up the service to return JSON responses. We'll need to define the data types for our posts, users, and followers, and then write functions to serialize and deserialize these data types to and from JSON.
Expected JSON request and response bodies
Take a look at the expected request and response bodies; you'll notice that many of the requests and response blobs across resources are similar to each other. They also follow the common pattern of returning the ID of the created resource to the client, which can be useful for subsequent requests.
POST /api/user/:handle — Create a new user | |
Example request JSON | Example response JSON |
|
|
GET /api/user/:handle/posts — Get all blog posts from a user | |
Example response JSON | |
|
|
POST /api/posts — Create a new blog post | |
Example request JSON | Example response JSON |
|
|
GET /api/posts/:id — Get a blog post by its id | |
Example response JSON | |
|
|
POST /api/follow/:handle — Follow another user | |
Example request JSON | Example response JSON |
|
|
GET /api/feed/:handle — See posts from the other users the given user in the route is following | |
Example response JSON | |
|
Using the JSON library
You may want to define some client-facing models for your service, for example, something that renders a blog Post
or accepts a new User
:
type client.User = {
userHandle: Text,
name: Text,
avatar: Optional Text
}
type client.UserId = UserId Text
These "client-facing" models may diverge from what you'll eventually need in the database, so while it's possible to directly save them in Cloud storage, it's a good practice to decouple layers of the application that contain different data or evolve at different rates.
Once you have your target data types, you'll need to use the Json library to serialize and deserialize them.
Serializing Unison to JSON
Turning a Unison datatype into JSON typically takes the form of a pattern match on the data type, where the fields of the data type are first translated into JSON types using functions like Json.text : Text -> Json
, and then translated into objects with functions like Json.object : [(Text, Json)] -> Json
, which takes a list of named pairs.
client.User.toJson : client.User -> Json
client.User.toJson = cases
client.User.User userHandle name avatar ->
maybeAvatar = Optional.map (uri -> ("avatar", Json.text uri)) avatar
jsonFields = [("userHandle", Json.text userHandle), ("name", Json.text name)]
optionalJsonFields = Optional.toList maybeAvatar
Json.object (jsonFields List.++ optionalJsonFields)
client.UserId.toJson : client.UserId -> Json
client.UserId.toJson = cases
client.UserId.UserId id -> Json.object [("userId", Json.text id)]
Deserializing JSON to Unison
We use the Decoder
ability to transform JSON back into Unison types. You can think of a Decoder
as a function that helps read a JSON tree without passing around the data structure itself.
At the leaves of the tree, reading JSON into Unison involves functions like Decoder.text : '{Decoder} Text
which decode basic types. Functions like Decoder.object.at! : Text -> '{g, Decoder} a ->{g, Decoder} a
accept other decoders. This one looks at the name of a key in a JSON object and tries to run the given decoder for the value.
client.User.fromJson : '{Decoder} client.User
client.User.fromJson = do
userHandle = Decoder.object.at! "userHandle" Decoder.text
name = Decoder.object.at! "name" Decoder.text
avatar = Decoder.optional! (Decoder.object.at "avatar" Decoder.text)
client.User.User userHandle name avatar
Incorporating JSON in routes
Once you've written your decoders and encoders, the Routes library provides some helper functions to make it easy to use them in your API.
Decode an HTTP request body by giving your new decoder functions to request.body.decodeJson
and return JSON in the response with ok.json
:
api.createUser : '{Route, Exception} ()
api.createUser = do
userHandle = Route.route POST (s "api" / s "user" / Parser.text)
user = request.body.decodeJson client.User.fromJson
ok.json (UserId.toJson <| UserId.UserId userHandle)
📝 Instructions
Update your service to expect and produce the JSON data blobs above. You can supply stub values for the JSON responses for now. Take a look at the examples to get a sense of the expected types for the required keys. (👹 We won't do anything tricky, like pass malformed timestamps or invalid user handles.)
When you're ready, update
your codebase and run your solution.
cloud-start/main> run submit.ex3_microblog.pt2
You can name your client data types and add fields as you see fit, but they should conform to the JSON table above at minimum
Solution
POST /api/posts
Let's start with the endpoint for creating a new blog post, we'll need a type and functions to serialize and deserialize the data:
type client.CreatePost = CreatePost Text Text
type db.PostId = PostId Text
client.CreatePost.fromJson : '{Decoder} CreatePost
client.CreatePost.fromJson = do
body = at! "body" text
userId = at! "userId" text
CreatePost body userId
client.PostId.toJson : PostId -> Json
client.PostId.toJson = cases
PostId id -> Json.object [("postId", Json.text id)]
The endpoint changes to:
api.createPost : '{Route, Exception} ()
api.createPost = do
use Parser / s
Route.noCapture POST (s "api" / s "posts")
createPostBody = request.body.decodeJson CreatePost.fromJson
ok.json (PostId.toJson (PostId "IAmAPostId"))
GET /api/posts/:id
To get a blog post by its id, we need a type for rendering the post:
type client.Post = {
body: Text,
name : Text,
userHandle : Text,
timestamp: Text
}
client.Post.toJson : Post -> Json
client.Post.toJson = cases
client.Post.Post body name userHandle timestamp ->
Json.object [
("body", Json.text body),
("name", Json.text name),
("userHandle", Json.text userHandle),
("timestamp", Json.text timestamp)
]
client.Post.fromJson : '{Decoder} Post
client.Post.fromJson = do
body = Decoder.object.at! "body" Decoder.text
name = Decoder.object.at! "name" Decoder.text
userHandle = Decoder.object.at! "userHandle" Decoder.text
timestamp = Decoder.object.at! "timestamp" Decoder.text
client.Post.Post body name userHandle timestamp
We can create a stub Post and update the endpoint:
client.Post.example =
Post.Post
"blog post body"
"Ada Lovelace"
"@ada"
"2021-01-01T00:00:00Z"
api.getPostById : '{Route, Exception} ()
api.getPostById = do
use Parser / s
postId =
PostId
(Route.route
GET (s "api" / s "posts" / Parser.text))
ok.json (client.Post.toJson client.Post.example)
GET /api/user/:handle/posts
The Post
type is reused in a few endpoints. Let's use the Post.toJson
function in an encoder for a list of posts:
client.Posts.toJson : [Post] -> Json
client.Posts.toJson list =
List.map Post.toJson list |> Json.array
The endpoint changes to:
api.getUserPosts : '{Route} ()
api.getUserPosts = do
userHandle = Route.route GET (s "api" / s "user" / Parser.text / s "posts")
ok.json (client.Posts.toJson [Post.example])
POST /api/follow/:handle
Creating a "follower" relationship requires some new types. Here we're using a tuple to represent the follower and target user:
type db.FollowingId = FollowingId Text
client.FollowRequest.fromJson : '{Decoder} (Text, Text)
client.FollowRequest.fromJson = do
follower = object.at! "follower" Decoder.text
target = object.at! "target" Decoder.text
(follower, target)
client.FollowingId.toJson = cases
FollowingId id -> Json.object [("followingId", Json.text id)]
api.followUser : '{Route, Exception} ()
api.followUser = do
userHandle = Route.route POST (s "api" / s "follow" / Parser.text)
followRequest = request.body.decodeJson client.FollowRequest.fromJson
ok.json (FollowingId.toJson <| FollowingId "IAmAFollowingId")
GET /api/feed/:handle
Similar to getting all blog posts by a user, getting a user's "feed" is a list of posts:
api.getFeed : '{Route} ()
api.getFeed = do
userHandle = Route.route GET (s "api" / s "feed" / Parser.text)
ok.json (client.Posts.toJson [Post.example])