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
{
  "userHandle": "@bob",
  "name": "Bob Bob",
  "avatar": "https://example.com/avatar.jpg"
}
-- Where the avatar field is optional

{
  "userId": "SomeTextUserId"
}
      
GET /api/user/:handle/posts — Get all blog posts from a user
Example response JSON
[
  {
    "body": "Text",
    "name": "Bob Bob",
    "userHandle": "@bob",
    "timestamp": "2021-01-01T00:00:00Z"
  }
]
POST /api/posts — Create a new blog post
Example request JSON Example response JSON
{
  "body": "Blog Text",
  "userId": "SomeTextUserId"
}
{
  "postId": "SomeTextPostId"
}
GET /api/posts/:id — Get a blog post by its id
Example response JSON
{
    "body": "Text",
    "name": "Bob Bob",
    "userHandle": "@bob",
    "timestamp": "2021-01-01T00:00:00Z"
}
POST /api/follow/:handle — Follow another user
Example request JSON Example response JSON
{
  "follower": "SomeTextUserId",
  "target": "SomeTextUserId"
}
{
  "followingId": "SomeTextFollowingId"
}
GET /api/feed/:handle — See posts from the other users the given user in the route is following
Example response JSON
[
  {
    "body": "Text",
    "name": "Bob Bob",
    "userHandle": "@bob",
    "timestamp": "2021-01-01T00:00:00Z"
  }
]

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])