├── test ├── test_helper.exs └── rest_api_test.exs ├── lib ├── rest_api.ex └── rest_api │ ├── json_utils.ex │ ├── application.ex │ └── router.ex ├── config ├── config.exs ├── dev.env.exs ├── prod.env.exs └── test.env.exs ├── .formatter.exs ├── docker-compose.yml ├── README.md ├── mix.exs ├── .gitignore └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/rest_api.ex: -------------------------------------------------------------------------------- 1 | defmodule RestApi do 2 | end 3 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{config_env()}.env.exs" 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /config/dev.env.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :rest_api, port: 8080 4 | config :rest_api, database: "rest_api_db" 5 | config :rest_api, pool_size: 3 6 | config :rest_api, :basic_auth, username: "user", password: "secret" 7 | -------------------------------------------------------------------------------- /config/prod.env.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :rest_api, port: 80 4 | config :rest_api, database: "rest_api_db" 5 | config :rest_api, pool_size: 3 6 | config :rest_api, :basic_auth, username: "user", password: "secret" 7 | -------------------------------------------------------------------------------- /config/test.env.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :rest_api, port: 8081 4 | config :rest_api, database: "rest_api_db" 5 | config :rest_api, pool_size: 3 6 | config :rest_api, :basic_auth, username: "user", password: "secret" 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | mongodb: 4 | image: mongo 5 | container_name: mongodb 6 | ports: 7 | - 27017:27017 8 | restart: unless-stopped 9 | healthcheck: 10 | test: test $$(echo "rs.initiate({_id:\"rs0\",members:[{_id:0,host:\"localhost:27017\"}]}).ok || rs.status().ok" | mongo --port 27017 --quiet) -eq 1 11 | interval: 10s 12 | start_period: 30s 13 | command: "mongod --bind_ip_all --replSet rs0" -------------------------------------------------------------------------------- /lib/rest_api/json_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule RestApi.JSONUtils do 2 | @moduledoc """ 3 | JSON Utilities 4 | """ 5 | 6 | @doc """ 7 | Extend BSON to encode MongoDB ObjectIds to string 8 | """ 9 | defimpl Jason.Encoder, for: BSON.ObjectId do 10 | def encode(id, options) do 11 | BSON.ObjectId.encode!(id) 12 | |> Jason.Encoder.encode(options) 13 | end 14 | end 15 | 16 | def normaliseMongoId(doc) do 17 | doc 18 | |> Map.put('id', doc["_id"]) 19 | |> Map.delete("_id") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RestApi 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `rest_api` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:rest_api, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RestApi.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :rest_api, 7 | version: "0.1.0", 8 | elixir: "~> 1.13", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger], 18 | mod: {RestApi.Application, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:plug_cowboy, "~> 2.5"}, 26 | {:jason, "~> 1.3"}, 27 | {:mongodb_driver, "~> 0.8"} 28 | ] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/rest_api/application.ex: -------------------------------------------------------------------------------- 1 | defmodule RestApi.Application do 2 | use Application 3 | 4 | @impl true 5 | def start(_type, _args) do 6 | children = [ 7 | { 8 | Plug.Cowboy, 9 | scheme: :http, 10 | plug: RestApi.Router, 11 | options: [port: Application.get_env(:rest_api, :port)] 12 | }, 13 | { 14 | Mongo, 15 | [ 16 | name: :mongo, 17 | database: Application.get_env(:rest_api, :database), 18 | pool_size: Application.get_env(:rest_api, :pool_size) 19 | ] 20 | } 21 | ] 22 | 23 | opts = [strategy: :one_for_one, name: RestApi.Supervisor] 24 | Supervisor.start_link(children, opts) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | rest_api-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /lib/rest_api/router.ex: -------------------------------------------------------------------------------- 1 | defmodule RestApi.Router do 2 | alias RestApi.JSONUtils, as: JSON 3 | 4 | use Plug.Router 5 | import Plug.BasicAuth 6 | 7 | plug(Plug.Logger) 8 | plug(:match) 9 | plug(:basic_auth, Application.get_env(:rest_api, :basic_auth)) 10 | 11 | plug(Plug.Parsers, 12 | parsers: [:json], 13 | pass: ["application/json"], 14 | json_decoder: Jason 15 | ) 16 | 17 | plug(:dispatch) 18 | 19 | get "/" do 20 | send_resp(conn, 200, "OK") 21 | end 22 | 23 | get "/knockknock" do 24 | case Mongo.command(:mongo, ping: 1) do 25 | {:ok, _res} -> send_resp(conn, 200, "Who's there?") 26 | {:error, _err} -> send_resp(conn, 500, "Something went wrong") 27 | end 28 | end 29 | 30 | get "/posts" do 31 | posts = 32 | Mongo.find(:mongo, "Posts", %{}) 33 | |> Enum.map(&JSON.normaliseMongoId/1) 34 | |> Enum.to_list() 35 | |> Jason.encode!() 36 | 37 | conn 38 | |> put_resp_content_type("application/json") 39 | |> send_resp(200, posts) 40 | end 41 | 42 | post "/post" do 43 | case conn.body_params do 44 | %{"name" => name, "content" => content} -> 45 | case Mongo.insert_one(:mongo, "Posts", %{"name" => name, "content" => content}) do 46 | {:ok, user} -> 47 | doc = Mongo.find_one(:mongo, "Posts", %{_id: user.inserted_id}) 48 | 49 | post = 50 | JSON.normaliseMongoId(doc) 51 | |> Jason.encode!() 52 | 53 | conn 54 | |> put_resp_content_type("application/json") 55 | |> send_resp(200, post) 56 | 57 | {:error, _} -> 58 | send_resp(conn, 500, "Something went wrong") 59 | end 60 | 61 | _ -> 62 | send_resp(conn, 400, '') 63 | end 64 | end 65 | 66 | get "/post/:id" do 67 | doc = Mongo.find_one(:mongo, "Posts", %{_id: BSON.ObjectId.decode!(id)}) 68 | 69 | case doc do 70 | nil -> 71 | send_resp(conn, 404, "Not Found") 72 | 73 | %{} -> 74 | post = 75 | JSON.normaliseMongoId(doc) 76 | |> Jason.encode!() 77 | 78 | conn 79 | |> put_resp_content_type("application/json") 80 | |> send_resp(200, post) 81 | 82 | {:error, _} -> 83 | send_resp(conn, 500, "Something went wrong") 84 | end 85 | end 86 | 87 | put "post/:id" do 88 | case Mongo.find_one_and_update( 89 | :mongo, 90 | "Posts", 91 | %{_id: BSON.ObjectId.decode!(id)}, 92 | %{ 93 | "$set": 94 | conn.body_params 95 | |> Map.take(["name", "content"]) 96 | |> Enum.into(%{}, fn {key, value} -> {"#{key}", value} end) 97 | }, 98 | return_document: :after 99 | ) do 100 | {:ok, doc} -> 101 | case doc do 102 | nil -> 103 | send_resp(conn, 404, "Not Found") 104 | 105 | _ -> 106 | post = 107 | JSON.normaliseMongoId(doc) 108 | |> Jason.encode!() 109 | 110 | conn 111 | |> put_resp_content_type("application/json") 112 | |> send_resp(200, post) 113 | end 114 | 115 | {:error, _} -> 116 | send_resp(conn, 500, "Something went wrong") 117 | end 118 | end 119 | 120 | delete "post/:id" do 121 | Mongo.delete_one!(:mongo, "Posts", %{_id: BSON.ObjectId.decode!(id)}) 122 | 123 | conn 124 | |> put_resp_content_type("application/json") 125 | |> send_resp(200, Jason.encode!(%{id: id})) 126 | end 127 | 128 | match _ do 129 | send_resp(conn, 404, "Not Found") 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 3 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 4 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 5 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 6 | "db_connection": {:hex, :db_connection, "2.4.1", "6411f6e23f1a8b68a82fa3a36366d4881f21f47fc79a9efb8c615e62050219da", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea36d226ec5999781a9a8ad64e5d8c4454ecedc7a4d643e4832bf08efca01f00"}, 7 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 8 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 9 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 10 | "mongodb_driver": {:hex, :mongodb_driver, "0.8.2", "810b5675a1f5733c519f1b93d4567360e0b4db781ed73f431eb864552cfac52f", [:mix], [{:db_connection, "~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "a10e34410bd62f947afe9a967b04022e7527da791f0b50b93efcc181011bfeba"}, 11 | "plug": {:hex, :plug, "1.13.1", "5643f8d4ee785019c32bbabfc276899636fd68b887b192b635bb0bbfb7c12575", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc20c180ca0d30cffd5e297e10efaa48d0b05541d813573fb17ccb6123d84246"}, 12 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, 13 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 14 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 15 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 16 | } 17 | -------------------------------------------------------------------------------- /test/rest_api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RestApiTest.Router do 2 | import Plug.BasicAuth 3 | use ExUnit.Case 4 | use Plug.Test 5 | 6 | @opts RestApi.Router.init([]) 7 | 8 | test "GET / should return ok" do 9 | conn = conn(:get, "/") 10 | conn = put_req_header(conn, "authorization", encode_basic_auth("user", "secret")) 11 | 12 | conn = RestApi.Router.call(conn, @opts) 13 | 14 | assert conn.status == 200 15 | assert conn.resp_body == "OK" 16 | end 17 | 18 | describe "Posts" do 19 | # The setup callback is called before each test and the on_exit after each of 20 | # the test is complete 21 | # We will use this hook to list all the mongo db collections and for each of 22 | # the collection to clear out the entire collection. This way for every test 23 | # case we will start from a clean slate 24 | setup do 25 | on_exit fn -> 26 | Mongo.show_collections(:mongo) 27 | |> Enum.each(fn col -> Mongo.delete_many!(:mongo, col, %{}) end) 28 | end 29 | end 30 | 31 | test "POST /post should create a post" do 32 | # Assert that there are no elements in the db 33 | assert Mongo.find(:mongo, "Posts", %{}) |> Enum.count == 0 34 | 35 | # Make an API to call create a post 36 | conn = conn(:post, "/post", %{name: "Post 1", content: "Content of post"}) 37 | conn = put_req_header(conn, "authorization", encode_basic_auth("user", "secret")) 38 | conn = RestApi.Router.call(conn, @opts) 39 | 40 | # Checking that response code was 200 41 | assert conn.status == 200 42 | 43 | # Asserting that response body was what we expected 44 | # Note: We are using pattern matching here to perform the assertion 45 | # The id is autogenerated by mongodb so we have not way of predicting it 46 | # therefore we just use _ to match to anything but expect it to exist 47 | assert %{ 48 | "id" => _, 49 | "content" => "Content of post", 50 | "name" => "Post 1" 51 | } = Jason.decode!(conn.resp_body) 52 | 53 | # Assert that there is something in the db 54 | # Task: It is very naive to check that the post was created. We should ideally 55 | # also check if the content was correct. Go ahead and try it out and post your 56 | # answer in the comment section below. 57 | assert Mongo.find(:mongo, "Posts", %{}) |> Enum.count == 1 58 | end 59 | 60 | def createPosts() do 61 | result = Mongo.insert_many!(:mongo, "Posts",[ 62 | %{name: "Post 1", content: "Content 1"}, 63 | %{name: "Post 2", content: "Content 2"}, 64 | ]) 65 | 66 | result.inserted_ids |> Enum.map(fn id -> BSON.ObjectId.encode!(id) end) 67 | end 68 | 69 | test "GET /posts should fetch all the posts" do 70 | createPosts() 71 | 72 | conn = conn(:get, "/posts") 73 | conn = put_req_header(conn, "authorization", encode_basic_auth("user", "secret")) 74 | conn = RestApi.Router.call(conn, @opts) 75 | 76 | assert conn.status == 200 77 | 78 | resp = Jason.decode!(conn.resp_body); 79 | 80 | assert Enum.count(resp) == 2 81 | 82 | assert %{ 83 | "id" => _, 84 | "content" => "Content 1", 85 | "name" => "Post 1" 86 | } = Enum.at(resp, 0) 87 | 88 | assert %{ 89 | "id" => _, 90 | "content" => "Content 2", 91 | "name" => "Post 2" 92 | } = Enum.at(resp, 1) 93 | 94 | end 95 | 96 | test "GET /post/:id should fetch a single post" do 97 | [id | _] = createPosts() 98 | 99 | conn = conn(:get, "/post/#{id}") 100 | conn = put_req_header(conn, "authorization", encode_basic_auth("user", "secret")) 101 | conn = RestApi.Router.call(conn, @opts) 102 | 103 | assert conn.status == 200 104 | 105 | assert %{ 106 | "id" => _, 107 | "content" => "Content 1", 108 | "name" => "Post 1" 109 | } = Jason.decode!(conn.resp_body) 110 | end 111 | 112 | test "PUT /post/:id should update a post" do 113 | [id | _] = createPosts() 114 | 115 | conn = conn(:put, "/post/#{id}", %{content: "Content 3"}) 116 | conn = put_req_header(conn, "authorization", encode_basic_auth("user", "secret")) 117 | conn = RestApi.Router.call(conn, @opts) 118 | 119 | assert conn.status == 200 120 | 121 | assert %{ 122 | "id" => _, 123 | "content" => "Content 3", 124 | "name" => "Post 1" 125 | } = Jason.decode!(conn.resp_body) 126 | end 127 | 128 | test "DELETE /post/:id should delete a post" do 129 | [id | _] = createPosts() 130 | 131 | assert Mongo.find(:mongo, "Posts", %{}) |> Enum.count == 2 132 | 133 | conn = conn(:delete, "/post/#{id}", %{content: "Content 3"}) 134 | conn = put_req_header(conn, "authorization", encode_basic_auth("user", "secret")) 135 | conn = RestApi.Router.call(conn, @opts) 136 | 137 | assert conn.status == 200 138 | 139 | assert Mongo.find(:mongo, "Posts", %{}) |> Enum.count == 1 140 | end 141 | end 142 | end 143 | --------------------------------------------------------------------------------