Skip to content

Commit e7d6c50

Browse files
tallysmartinsPragTob
authored andcommitted
Adds Github webhook endpoint (#25)
* Adds Github webhook endpoint * Adds Github namespace to WebHooksController Signed-off-by: Tallys Martins <tallysmartins@gmail.com> * Apply review changes one * Apply review changes 2 - closes #26
1 parent f9ed129 commit e7d6c50

13 files changed

Lines changed: 881 additions & 6 deletions

File tree

lib/elixir_bench/repos/repos.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ defmodule ElixirBench.Repos do
4848
Repo.delete(repo)
4949
end
5050

51+
defp parse_slug(slug, callback) when is_nil(slug), do: {:error, :invalid_slug}
52+
5153
defp parse_slug(slug, callback) do
5254
case String.split(slug, "/", trim: true, parts: 2) do
5355
[owner, name] -> callback.(owner, name)

lib/elixir_bench_web/controllers/fallback_controller.ex

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,27 @@ defmodule ElixirBenchWeb.FallbackController do
1212
|> render(ElixirBenchWeb.ChangesetView, "error.json", changeset: changeset)
1313
end
1414

15+
def call(conn, {:error, :bad_request}) do
16+
conn
17+
|> put_status(:bad_request)
18+
|> render(ElixirBenchWeb.ErrorView, :"400")
19+
end
20+
1521
def call(conn, {:error, :not_found}) do
1622
conn
1723
|> put_status(:not_found)
1824
|> render(ElixirBenchWeb.ErrorView, :"404")
1925
end
26+
27+
def call(conn, {:error, :unprocessable_entity}) do
28+
conn
29+
|> put_status(:unprocessable_entity)
30+
|> render(ElixirBenchWeb.ErrorView, :"422")
31+
end
32+
33+
def call(conn, {:error, :failed_config_fetch}) do
34+
conn
35+
|> put_status(:internal_server_error)
36+
|> render(ElixirBenchWeb.ErrorView, :"500")
37+
end
2038
end
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
defmodule ElixirBenchWeb.Github.WebHooks do
2+
use ElixirBenchWeb, :controller
3+
4+
alias ElixirBench.{Benchmarks, Repos}
5+
6+
action_fallback(ElixirBenchWeb.FallbackController)
7+
@accepted_events ["ping", "push", "pull_request"]
8+
9+
def handle(conn, params) do
10+
with {:ok, payload} <- check_payload_params(params),
11+
{:ok, event_name} <- validate_event_headers(conn) do
12+
event_name
13+
|> extract_job_attrs(payload)
14+
|> process_event(conn)
15+
end
16+
end
17+
18+
defp extract_job_attrs("push", payload) do
19+
slug = get_in(payload, ["repository", "full_name"])
20+
branch_name = get_in(payload, ["ref"])
21+
commit_sha = get_in(payload, ["after"])
22+
23+
%{slug: slug, branch_name: branch_name, commit_sha: commit_sha}
24+
end
25+
26+
defp extract_job_attrs("pull_request", payload) do
27+
# data is fetched from sender's repository refered as "head"
28+
slug = get_in(payload, ["pull_request", "head", "repo", "full_name"])
29+
branch_name = get_in(payload, ["pull_request", "head", "ref"])
30+
commit_sha = get_in(payload, ["pull_request", "head", "sha"])
31+
32+
%{slug: slug, branch_name: branch_name, commit_sha: commit_sha}
33+
end
34+
35+
defp extract_job_attrs(event, _payload), do: event
36+
37+
defp process_event(job_data, conn) when is_map(job_data) do
38+
%{slug: slug, branch_name: branch_name, commit_sha: commit_sha} = job_data
39+
40+
with {:ok, %Repos.Repo{} = repo} <- Repos.fetch_repo_by_slug(slug),
41+
{:ok, job} <-
42+
Benchmarks.create_job(repo, %{branch_name: branch_name, commit_sha: commit_sha}) do
43+
conn
44+
|> render(ElixirBenchWeb.JobView, "show.json", job: job, repo: repo)
45+
else
46+
{:error, error} -> handle_errors({:error, error})
47+
end
48+
end
49+
50+
defp process_event(job_data, conn) do
51+
case job_data do
52+
"ping" ->
53+
json(conn, %{message: "pong"})
54+
55+
_ ->
56+
handle_errors({:error, :unprocessable_entity})
57+
end
58+
end
59+
60+
defp check_payload_params(params) do
61+
if p = params["payload"] do
62+
case Antidote.decode(p) do
63+
{:ok, payload} ->
64+
{:ok, payload}
65+
66+
{:error, reason} ->
67+
handle_errors({:error, reason})
68+
end
69+
else
70+
handle_errors({:error, :bad_request})
71+
end
72+
end
73+
74+
defp validate_event_headers(conn) do
75+
with [event] <- get_req_header(conn, "x-github-event"),
76+
true <- event in @accepted_events do
77+
{:ok, event}
78+
else
79+
_ -> handle_errors({:error, :unprocessable_entity})
80+
end
81+
end
82+
83+
defp handle_errors(errors) do
84+
case errors do
85+
{:error, %Ecto.Changeset{} = changeset} ->
86+
{:error, changeset}
87+
88+
{:error, :unprocessable_entity} ->
89+
{:error, :unprocessable_entity}
90+
91+
_ ->
92+
{:error, :bad_request}
93+
end
94+
end
95+
end

lib/elixir_bench_web/router.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ defmodule ElixirBenchWeb.Router do
1818
put("/jobs/:id", JobController, :update)
1919
end
2020

21+
scope "/hooks", ElixirBenchWeb do
22+
pipe_through [:api]
23+
24+
post("/handle", Github.WebHooks, :handle)
25+
end
26+
2127
scope "/api" do
2228
pipe_through :api
2329

lib/elixir_bench_web/views/error_view.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
defmodule ElixirBenchWeb.ErrorView do
22
use ElixirBenchWeb, :view
33

4+
def render("400.json", _assigns) do
5+
%{errors: %{detail: "Bad request"}}
6+
end
7+
48
def render("404.json", _assigns) do
59
%{errors: %{detail: "Page not found"}}
610
end
711

12+
def render("422.json", _assigns) do
13+
%{errors: %{detail: "Unprocessable entity"}}
14+
end
15+
816
def render("500.json", _assigns) do
917
%{errors: %{detail: "Internal server error"}}
1018
end
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
defmodule ElixirBenchWeb.Github.WebHooksControllerTest do
2+
use ElixirBenchWeb.ConnCase, async: false
3+
4+
import ElixirBenchWeb.TestHelpers
5+
6+
alias ElixirBench.{Repos.Repo, Benchmarks.Job}
7+
8+
@github_repo_attrs %{name: "public-repo", owner: "baxterthehacker"}
9+
10+
describe "handle/2" do
11+
test "create job given pull request event", context do
12+
insert(:repo, @github_repo_attrs)
13+
params = %{"payload" => pull_request_payload()}
14+
15+
assert_difference(Repo, 0) do
16+
assert_difference(Job, 1) do
17+
{:ok, %{"data" => data}} =
18+
context.conn
19+
|> set_headers("pull_request")
20+
|> post("/hooks/handle", params)
21+
|> decode_response_body
22+
end
23+
end
24+
25+
assert %{"branch_name" => "changes", "repo_slug" => "baxterthehacker/public-repo"} = data
26+
end
27+
28+
test "create job given push event", context do
29+
insert(:repo, @github_repo_attrs)
30+
params = %{"payload" => push_payload()}
31+
32+
assert_difference(Repo, 0) do
33+
assert_difference(Job, 1) do
34+
{:ok, %{"data" => data}} =
35+
context.conn
36+
|> set_headers("push")
37+
|> post("/hooks/handle", params)
38+
|> decode_response_body
39+
end
40+
end
41+
42+
assert %{"branch_name" => "changes", "repo_slug" => "baxterthehacker/public-repo"} = data
43+
end
44+
45+
test "respond to ping event", context do
46+
response =
47+
context.conn
48+
|> set_headers("ping")
49+
|> post("/hooks/handle", %{"payload" => ~s({"data": "some data"})})
50+
51+
assert {:ok, %{"message" => "pong"}} = decode_response_body(response)
52+
assert 200 = response.status
53+
end
54+
55+
test "not break given unexpected pull request event payload scheme", context do
56+
insert(:repo, @github_repo_attrs)
57+
params = %{"payload" => ~s({"data": "some data"})}
58+
59+
assert_difference(Repo, 0) do
60+
assert_difference(Job, 0) do
61+
{:ok, %{"errors" => errors}} =
62+
context.conn
63+
|> set_headers("pull_request")
64+
|> post("/hooks/handle", params)
65+
|> decode_response_body
66+
end
67+
end
68+
69+
assert %{"detail" => "Bad request"} = errors
70+
end
71+
72+
test "not break given unexpected push event payload scheme", context do
73+
insert(:repo, @github_repo_attrs)
74+
params = %{"payload" => ~s({"data": "some data"})}
75+
76+
assert_difference(Repo, 0) do
77+
assert_difference(Job, 0) do
78+
{:ok, %{"errors" => errors}} =
79+
context.conn
80+
|> set_headers("push")
81+
|> post("/hooks/handle", params)
82+
|> decode_response_body
83+
end
84+
end
85+
86+
assert %{"detail" => "Bad request"} = errors
87+
end
88+
89+
test "return error given unprocessable event in header", context do
90+
insert(:repo, @github_repo_attrs)
91+
params = %{"payload" => push_payload()}
92+
93+
{:ok, %{"errors" => errors}} =
94+
context.conn
95+
|> set_headers("randomevent")
96+
|> post("/hooks/handle", params)
97+
|> decode_response_body
98+
99+
assert %{"detail" => "Unprocessable entity"} = errors
100+
end
101+
102+
test "return error when event headers not present", context do
103+
insert(:repo, @github_repo_attrs)
104+
params = %{"payload" => push_payload()}
105+
106+
{:ok, %{"errors" => errors}} =
107+
context.conn
108+
|> post("/hooks/handle", params)
109+
|> decode_response_body
110+
111+
assert %{"detail" => "Unprocessable entity"} = errors
112+
end
113+
114+
test "return error when payload missing", context do
115+
insert(:repo, @github_repo_attrs)
116+
117+
{:ok, %{"errors" => errors}} =
118+
context.conn
119+
|> set_headers("pull_request")
120+
|> post("/hooks/handle", %{"payload" => ""})
121+
|> decode_response_body
122+
123+
assert %{"detail" => "Bad request"} = errors
124+
125+
{:ok, %{"errors" => errors}} =
126+
context.conn
127+
|> set_headers("pull_request")
128+
|> post("/hooks/handle")
129+
|> decode_response_body
130+
131+
assert %{"detail" => "Bad request"} = errors
132+
end
133+
end
134+
135+
defp set_headers(conn, event) do
136+
conn
137+
|> put_req_header("x-github-event", event)
138+
end
139+
end

test/elixir_bench_web/schema/schema_test.exs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -458,10 +458,4 @@ defmodule ElixirBenchWeb.SchemaTest do
458458
conn
459459
|> post("/api", AbsintheHelpers.query_skeleton(query))
460460
end
461-
462-
defp decode_response_body(response) do
463-
response
464-
|> Map.get(:resp_body)
465-
|> Antidote.decode()
466-
end
467461
end

test/support/conn_case.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ defmodule ElixirBenchWeb.ConnCase do
2121
use Phoenix.ConnTest
2222
import ElixirBenchWeb.Router.Helpers
2323
import ElixirBench.Factory
24+
import ElixirBench.GithubFactory
2425

2526
# The default endpoint for testing
2627
@endpoint ElixirBenchWeb.Endpoint

test/support/factory.ex renamed to test/support/factory/elixir_bench_factory.ex

File renamed without changes.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
defmodule ElixirBench.GithubFactory do
2+
@fixtures_dir "test/support/fixtures"
3+
4+
# Returns the payload of a pull request activity in a given repository.
5+
# Payload example is read from fixtures/pull_request.json and was copied from
6+
# https://developer.github.com/v3/activity/events/types/#pullrequestevent
7+
def pull_request_payload() do
8+
case read_file("pull_request.json") do
9+
{:ok, data} -> data
10+
{:error, _} -> ""
11+
end
12+
end
13+
14+
# Returns the payload of a push activity in a given repository.
15+
# Payload example is read from fixtures/push.json and was copied from
16+
# https://developer.github.com/v3/activity/events/types/#pushevent
17+
def push_payload() do
18+
case read_file("push.json") do
19+
{:ok, data} -> data
20+
{:error, _} -> ""
21+
end
22+
end
23+
24+
def read_file(file) do
25+
Path.join(@fixtures_dir, file)
26+
|> File.read()
27+
end
28+
end

0 commit comments

Comments
 (0)