Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .credo.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# https://hexdocs.pm/credo/config_file.html
%{
configs: [
%{
name: "default",
checks: %{
enabled: [
{Credo.Check.Readability.Specs, []}
],
disabled: [
# this means that `TabsOrSpaces` will not run
{Credo.Check.Consistency.TabsOrSpaces, []}
]
}
}
]
}
25 changes: 21 additions & 4 deletions lib/files.ex
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
defmodule Server.Files do
@moduledoc """
For processing and handling files
"""

@spec write_file(String.t(), binary()) :: :ok | {:error, File.posix()}
def write_file(filename, content) do
build_path(filename)
|> File.write(content)
|> Path.safe_relative()
|> case do
{:ok, path} ->
File.write(path, content)

_ ->
{:error, :unsafe_or_invalid_path}
end
end

@spec read_file(String.t()) :: {:ok, binary} | {:error, File.posix()}
def read_file(filename) do
build_path(filename)
|> File.read()
|> Path.safe_relative()
|> case do
{:ok, path} ->
File.read(path)

_ ->
{:error, :unsafe_or_invalid_path}
end
end

def build_path(filename) do
# TODO: harden against traversal attacks
defp build_path(filename) do
Path.join([
Application.get_env(:codecrafters_http_server, :directory),
filename
Expand Down
4 changes: 3 additions & 1 deletion lib/handler.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
defmodule Server.Handler do
@doc """
@moduledoc """
A very simple handler that takes a request string and returns a response
"""

@spec handle(String.t()) :: Server.Response.t()
def handle(request) do
request
|> Server.Parser.parse()
Expand Down
48 changes: 29 additions & 19 deletions lib/main.ex
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
defmodule Server do
@moduledoc """
The entrypoint for the main server loop.
"""
use Application
require Logger

@spec start(Application.start_type(), map()) :: {:error, term()} | {:ok, pid()}
def start(_type, _args) do
Logger.info("Starting server on http://127.0.0.1:4221")
Supervisor.start_link([{Task, fn -> Server.listen() end}], strategy: :one_for_one)
end

def listen() do
@spec listen :: ListenSocket
def listen do
{:ok, socket} = :gen_tcp.listen(4221, [:binary, active: false, reuseaddr: true])
loop(socket)
end
Expand All @@ -22,25 +27,29 @@ defmodule Server do

defp handle_client(client_socket) do
# this is the boundary - naive assumption checks of a perfect world.
with {:ok, request} <- :gen_tcp.recv(client_socket, 0) do
# logging - becasue reasons
Logger.debug("#{inspect(request)}")
case :gen_tcp.recv(client_socket, 0) do
{:ok, request} ->
# logging - becasue reasons
Logger.debug("#{inspect(request)}")

# make the respone
response = Server.Handler.handle(request)
# make the respone
response = Server.Handler.handle(request)

# return the reply
reply(response, client_socket)
# return the reply
reply(response, client_socket)

# now close or recurse
if response.close? do
# now close or recurse
if response.close? do
:gen_tcp.close(client_socket)
else
handle_client(client_socket)
end

{:error, :closed} ->
:gen_tcp.close(client_socket)
else
handle_client(client_socket)
end
else
{:error, :closed} -> :gen_tcp.close(client_socket)
{:error, error} -> Logger.error("loop error: #{inspect(error)}")

{:error, error} ->
Logger.error("loop error: #{inspect(error)}")
end
end

Expand All @@ -50,9 +59,10 @@ defmodule Server do
|> then(fn response -> :gen_tcp.send(client_socket, response) end)
end

def main(args) do
# parse the args
{opts, _args, _invalid} = OptionParser.parse(args, strict: [directory: :string])
@spec main(Keyword.t()) :: no_return()
def main(opts) do
# parse the opts
{opts, _args, _invalid} = OptionParser.parse(opts, strict: [directory: :string])
directory = Keyword.get(opts, :directory)

# set the config
Expand Down
4 changes: 4 additions & 0 deletions lib/parser.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
defmodule Server.Parser do
@moduledoc """
Transforms a plain text HTTP request into a Response.
"""
alias Server.Response

@supported_encodings [
"gzip"
]

@spec parse(String.t()) :: Response.t()
def parse(request) do
# split out request body first as that's \r\n\r\n
[request | [request_body]] = String.split(request, "\r\n\r\n")
Expand Down
21 changes: 21 additions & 0 deletions lib/response.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
defmodule Server.Response do
@moduledoc """
The Response struct is the central idea in the http server
"""
@type t :: %__MODULE__{
method: String.t() | nil,
path: String.t() | nil,
params: map(),
headers: map(),
content_length: non_neg_integer() | nil,
content_type: String.t(),
close?: boolean(),
body: iodata() | nil,
status: non_neg_integer() | nil,
request_body: binary() | nil
}
defstruct method: nil,
path: nil,
params: %{},
Expand All @@ -10,10 +25,12 @@ defmodule Server.Response do
status: nil,
request_body: nil

@spec full_status(__MODULE__.t()) :: String.t()
def full_status(%__MODULE__{} = response) do
"#{response.status} #{status_reason(response.status)}"
end

@spec gzip?(__MODULE__.t()) :: bool
def gzip?(%__MODULE__{} = response) do
case Map.get(response.headers, "Content-Encoding") do
"gzip" -> true
Expand All @@ -24,6 +41,7 @@ defmodule Server.Response do
@doc """
If we need to close the connection. Send the header
"""
@spec maybe_connection?(__MODULE__.t()) :: String.t()
def maybe_connection?(response) do
case response.close? do
true -> "Connection: close\r\n"
Expand All @@ -34,6 +52,7 @@ defmodule Server.Response do
@doc """
If there's content encoding, show the header
"""
@spec maybe_content_encoding?(map()) :: String.t()
def maybe_content_encoding?(headers) do
case Map.get(headers, "Content-Encoding") do
nil -> ""
Expand All @@ -55,6 +74,7 @@ defmodule Server.Response do
end

defimpl String.Chars, for: Server.Response do
@spec get_body(Server.Response.t()) :: String.t() | iodata()
def get_body(response) do
# body could be nil, so defend against taht
body = response.body || ""
Expand All @@ -65,6 +85,7 @@ defimpl String.Chars, for: Server.Response do
end
end

@spec to_string(Server.Response.t()) :: String.t()
def to_string(%Server.Response{} = response) do
# some parts needs to be calculated
body = get_body(response)
Expand Down
40 changes: 24 additions & 16 deletions lib/router.ex
Original file line number Diff line number Diff line change
@@ -1,33 +1,41 @@
defmodule Server.Router do
@moduledoc """
Maps http responses through defined routes.

There's a conflict in being the server and the application and having routes does
actually blur the line a little so consider this module as a very crude interface
to validate http behavior through integration tests.

"""
require Logger
alias Server.Response
alias Server.Files
alias Server.Response

@spec route(Response.t()) :: Response.t()
def route(%Response{method: "GET", path: "/"} = response) do
%{response | status: 200}
end

def route(%Response{method: "POST", path: "/files/" <> filename} = response) do
with :ok <- Files.write_file(filename, response.request_body) do
%{response | status: 201}
else
{:error, _} ->
%{response | status: 500}
case Files.write_file(filename, response.request_body) do
:ok -> %{response | status: 201}
_ -> %{response | status: 500}
end
end

def route(%Response{method: "GET", path: "/files/" <> filename} = response) do
# now we are either 200 or 404
with {:ok, content} <- Files.read_file(filename) do
%{
response
| body: content,
status: 200,
content_length: byte_size(content),
content_type: "application/octet-stream"
}
else
{:error, _} ->
case Files.read_file(filename) do
{:ok, content} ->
%{
response
| body: content,
status: 200,
content_length: byte_size(content),
content_type: "application/octet-stream"
}

_ ->
content = "Not Found: #{filename}"

%{
Expand Down
6 changes: 6 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ defmodule App.MixProject do
]
end

def cli do
[
preferred_envs: [check: :test]
]
end

defp aliases do
[
check: ["format --check-formatted", "credo --strict", "dialyzer", "test"]
Expand Down
10 changes: 7 additions & 3 deletions test/server_integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ defmodule Server.IntegrationTest do
response =
request("GET /echo/foo HTTP/1.1\r\nHost: localhost:4221\r\nAccept-Encoding: gzip\r\n\r\n")

assert response =~
<<31, 139, 8, 0, 0, 0, 0, 0, 0, 19, 75, 203, 207, 7, 0, 33, 101, 115, 140, 3, 0, 0,
0>>
[_headers, body] = String.split(response, "\r\n\r\n", parts: 2)

# pattern match to assert gzip magic bytes
assert <<31, 139, _rest::binary>> = body

# decompress and assert actual content
assert :zlib.gunzip(body) == "foo"
end

test "when accept-encoding is present as a series of comma seperate values, we send back a validContent-Encoding" do
Expand Down
Loading