Simple Twitter clone to demo building REST and GraphQL APIs with Scala 2.13 and the Typelevel stack.
Run the unit tests with:
sbt test
Run the integration tests with:
sbt it:test
Note that the latter require access to a PostgreSQL database.
Start the server on localhost:8080
(or any other port number configured via the SERVER_PORT env var)
with:
sbt run
Use the provided Postman collection to interact with the server.
The application can be configured via the following environment variables:
name | type | possible values | default |
---|---|---|---|
ENVIRONMENT |
string | local , prod |
local |
SERVER_HOST |
string | Any valid host | 0.0.0.0 |
SERVER_PORT |
int | Any valid port number | 8080 |
ALLOWED_ORIGINS |
string | Comma-separated list of hosts | [localhost] |
The environment can be set to either local
or prod
.
When set to local
, the application will use mutable collections as an in-memory database. This is great for running a
single instance of the application on a laptop during development, but can't be used in production because:
- The data is not persisted to disk and is lost when the application is stopped.
- The application can't be scaled beyond a single instance since there is no mechanism in place for sharing data between multiple instances.
If the environment is set to prod
instead, the application will attempt to connect to a PostgreSQL database.
To run the integration tests or to start the server with ENVIRONMENT
set to prod
, you must provide a PostgreSQL
database for the application to use.
Start by setting the following env vars:
export POSTGRES_DB="postgres"
export POSTGRES_USER="postgres"
export POSTGRES_PASSWORD="postgres"
export POSTGRES_PORT=5432
Then, start a PostgreSQL instance in a Docker container with:
docker run --name postgres-db \
-e POSTGRES_DB=$POSTGRES_DB \
-e POSTGRES_USER=$POSTGRES_USER \
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
-p $POSTGRES_PORT:5432 \
-d postgres:14.4
At then end of a work session, stop and remove the container with:
docker stop postgres-db && docker rm postgres-db
The v1 REST API is implemented using http4s and exposes the following endpoints:
GET /api/v1/tweets/{uuid}
GET /api/v1/tweets?page_size{int}&posted_before={yyyy-mm-ddTHH:MM:SS}
GET /api/v1/tweets?author={uuid}&page_size={int}&posted_before={yyyy-mm-ddTHH:MM:SS}
POST /api/v1/tweets
DELETE /api/v1/tweets/{uuid}
GET /api/v1/comments/{uuid}
GET /api/v1/comments?tweet_id={uuid}&page_size={int}&posted_before={yyyy-mm-ddTHH:MM:SS}
POST /api/v1/comments
DELETE /api/v1/comments/{uuid}
The v2 REST API is implemented using tapir with http4s and Open API interpreters.
When the application is running, the API docs are available at:
http://localhost:8080/api/v2/docs
The GraphQL API is implemented using Sangria and exposes the following endpoint:
POST /api/graphql
The implementation leans heavily on Rob Norris's doobie-http4s-sangria-graphql-example.
Held on July 7th. See resulting PR.
Implement the CommentService
. You can use the TweetService
as an example of how to implement a service and rely on
the CommentServiceSpec
to verify the correctness of your implementation.
Held on July 14th. See resulting PR.
Implement the CommentEndpoints
. You can use the TweetEndpoints
as an example of how to implement endpoints and rely
on the CommentEndpointsSpec
to verify the correctness of your implementation.
Once you are done, connect your endpoints to the Server
router. The routes of your CommentEndpoints
should all be
prefixed with /v1/comments
. Make sure you address any compilation errors resulting from changes made to Server
.
Start the server on localhost:8080
with sbt run
and try issuing some requests using curl,
requests, Postman or another tool of your choice.
Held on July 21st. See resulting PR.
In UserServiceSpec
, provide an implementation for the following test:
The create method, when the specified handle does not exist, should create and return a new user with status 'PendingActivation'
Run the test to verify that it fails.
Then, implement the create
method in UserService
. Run the test again to verify that is passes.
Back in UserServiceSpec
, provide an implementation for the following test:
The create method, when the specified handle already exists, should return an error
Run the test. Unless you thought about checking for the handle uniqueness when you first implemented the create
method
in part 1, the test should fail.
In UserService
, update your implementation of the create
method to make the test pass. You may want to make use of
the exists
method on UserRepository
and of the UserHandleAlreadyExists
service error.
Held on July 28th. See resulting PR.
Following the instructions in this README, configure environment variables and start the PostgreSQL Docker container.
In PostgresCommentRepositorySpec
, provide an implementation for the following test:
The get method should get a comment
Run the test to verify that it fails.
Then, implement the getQuery
method in PostgresCommentRepository
. Run the test again to verify that it passes.
In PostgresCommentRepositorySpec
, provide an implementation for the following test:
The list method should list comments for a given tweet by decreasing 'postedOn' timestamp
Run the test to verify that it fails.
Then, implement the listQuery
method in PostgresCommentRepository
. Run the test again to verify that it passes.
Held on August 4th. See resulting PR.
Try to compile the project with sbt compile
. You should get a compilation error with a message about a missing
implicit instance of EntityDecoder[F, NewUserRequestBody]
in the UserEndpoints
object.
Take a look at the org.http4s.EntityDecoder[F[_], T]
trait and try to get a sense for what it does.
We've defined a new endpoint for creating users in UserEndpoints
, which accepts POST requests with a payload that we
attempt to decode to an instance of the NewUserRequestBody
case class. But how exactly should the raw JSON in a
request payload be decoded into an instance of that case class? Well, that's what's missing from our code, hence the
compiler error.
Provide a companion object for the NewUserRequestBody
case class, and in it define an implicit instance of the
Decoder
typeclass from the Circe library:
import io.circe.Decoder
object NewUserRequestBody {
implicit val decoder: Decoder[NewUserRequestBody] =
Decoder.instance { hCursor =>
???
}
}
Then, return to UserEndpoints
and add the following import at the top of the file:
import org.http4s.circe.CirceEntityCodec.circeEntityDecoder
The code should now compile. Can you work out how our implicit Decoder[NewUserRequestBody]
instance and the
circeEntityDecoder
import work together to provide us with an implicit instance of
EntityDecoder[F, NewUserRequestBody]
?
Are we really going to manually define instances of the Decoder
typeclass for every class we may wish to serialize to
JSON?
No! We'd much rather leave tedious work like this to the compiler. And it turns out the Scala compiler (with the help of some clever libraries such as Shapeless or Magnolia) is pretty good at this, but it needs a few building blocks to get started.
Replace our previous implementation of a Decoder
instance for NewUserRequestBody
with the following:
import io.circe.Decoder
import io.circe.generic.semiauto
object NewUserRequestBody {
implicit val decoder: Decoder[NewUserRequestBody] =
semiauto.deriveDecoder
}
Try to compile the project again. At this point, the error message the compiler will provide you won't help much. But
you should know that in order to derive an instance of Decoder[NewUserRequestBody]
, the compiler must have access to
instances of the Decoder
typeclass for the type of every attribute in NewUserRequestBody
.
Try and add implicit Decoder
instances for the Handle
and Name
case classes:
implicit val handleDecoder: Decoder[Handle] =
???
implicit val nameDecoder: Decoder[Name] =
???
Hint: For simple case classes such as Handle
and Name
that wrap around a single value of type String
or some
other primitive, a Decoder
instance can be constructed starting from the Decoder[String]
(or Decoder[Int]
,
Decoder[Boolean]
, etc) instance provided by Circe and methods such as map
, emap
or emapTry
.
Verify that the code now compiles without any error.
If you're curious about how the Scala compiler is able to derive instances of SomeTypeclass[A]
for a case class A
given instances of SomeTypeclass
for the type of every attribute of A
,
this book provides an excellent introduction to automatic typeclass
derivation using the Shapeless library.