Skip to content

Commit b31f738

Browse files
ning-yindocomsoft
authored andcommitted
Implement Updater as a GenServer (#74)
* Add Updater.get_course_id/1 * Add test for Updater.get_course_id/1 * Add tests for IVLE.api_fetch/2 * Make Updater implement GenServer * Fix invalid token failing silently (announcements) * Add Updater to the supervisor tree * Rename IVLE's api_fetch/2 -> api_call/2 * Update documentation * Add test for new guarded IVLE.api_call/2 * Add test for Updater.get_announcements#1 * Add tests for Updater.init/1 * Add tests for Updater.handle_info/2 * Fix missing cassette in updater_test * Refactor a line with pipe operators * Improve test coverage for Updater * Fix private function being public * Bump test coverage for Updater to 100% * Document interval being milliseconds with comments * Refactor updater * Remove redundant case block * Replace Enum.filter with Enum.find * Refactor tests * Add test for get_api_params/0 * Fix reduced test coverage * Add command line flag to toggle updater
1 parent a8beb34 commit b31f738

22 files changed

+1501
-12
lines changed

config/config.exs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
use Mix.Config
77

88
# General application configuration
9-
config :cadet, ecto_repos: [Cadet.Repo]
9+
config :cadet,
10+
ecto_repos: [Cadet.Repo],
11+
# milliseconds
12+
updater: [interval: 5 * 60 * 1000]
1013

1114
# Configures the endpoint
1215
config :cadet, CadetWeb.Endpoint,

lib/cadet/accounts/ivle.ex

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
defmodule Cadet.Accounts.IVLE do
22
@moduledoc """
3-
Helper functions to IVLE calls. All helper functions are prefixed with fetch
4-
to differentiate them from database helpers, or other 'getters'.
3+
This module provides abstractions for various IVLE API calls.
54
65
This module relies on the environment variable `IVLE_KEY` being set.
76
`IVLE_KEY` should contain the IVLE Lapi key. Obtain the key from
@@ -30,7 +29,7 @@ defmodule Cadet.Accounts.IVLE do
3029
{:ok, "e012345"}
3130
3231
"""
33-
def fetch_nusnet_id(token), do: api_fetch("UserID_Get", Token: token)
32+
def fetch_nusnet_id(token), do: api_call("UserID_Get", Token: token)
3433

3534
@doc """
3635
Get the full name of the user corresponding to this token.
@@ -51,7 +50,7 @@ defmodule Cadet.Accounts.IVLE do
5150
{:ok, "LEE NING YUAN"}
5251
5352
"""
54-
def fetch_name(token), do: api_fetch("UserName_Get", Token: token)
53+
def fetch_name(token), do: api_call("UserName_Get", Token: token)
5554

5655
@doc """
5756
Get the role of the user corresponding to this token.
@@ -64,6 +63,10 @@ defmodule Cadet.Accounts.IVLE do
6463
- {:error, :bad_request} - invalid token, or not taking the module
6564
- {:error, :internal_server_error} - the ivle_key is invalid
6665
66+
## Parameters
67+
68+
- token: String, the IVLE authentication token
69+
6770
This function assumes that inactive modules have an ID of
6871
`"00000000-0000-0000-0000-000000000000"`, and that there is only one active
6972
module with the course code `"CS1101S"`. (So far, these assumptions have been
@@ -82,15 +85,14 @@ defmodule Cadet.Accounts.IVLE do
8285
8386
"""
8487
def fetch_role(token) do
85-
{:ok, modules} = api_fetch("Modules", AuthToken: token, CourseID: "CS1101S")
88+
{:ok, modules} = api_call("Modules", AuthToken: token, CourseID: "CS1101S")
8689

8790
cs1101s =
8891
modules["Results"]
89-
|> Enum.filter(fn module ->
92+
|> Enum.find(fn module ->
9093
module["CourseCode"] == "CS1101S" and
9194
module["ID"] != "00000000-0000-0000-0000-000000000000"
9295
end)
93-
|> List.first()
9496

9597
case cs1101s do
9698
%{"Permission" => "S"} ->
@@ -107,7 +109,60 @@ defmodule Cadet.Accounts.IVLE do
107109
end
108110
end
109111

110-
defp api_fetch(method, queries) do
112+
@doc """
113+
Make an API call to IVLE LAPI.
114+
115+
returns...
116+
117+
- {:ok, body} - valid token
118+
- {:error, :internal_server_error} - Invalid API key
119+
- {:error, :bad_request} - Invalid token
120+
121+
## Parameters
122+
123+
- method: String, the HTTP request method to use
124+
- queries: [Keyword], key-value pair of parameters to send
125+
126+
This method is valid for methods that return with a string "Invalid login!"
127+
in a JSON nested in the body. Refer to the next method `api_call/2` for
128+
methods that return a 200 with an empty string body on invalid tokens.
129+
"""
130+
def api_call(method, queries) when method in ["Announcements"] do
131+
with {:ok, %{status_code: 200, body: body}} <- HTTPoison.get(api_url(method, queries)),
132+
body = Poison.decode!(body),
133+
%{"Comments" => "Valid login!"} <- body do
134+
{:ok, body["Results"]}
135+
else
136+
{:ok, %{status_code: 500}} ->
137+
# IVLE responds with 500 if APIKey is invalid
138+
{:error, :internal_server_error}
139+
140+
%{"Comments" => "Invalid login!"} ->
141+
# IVLE response if AuthToken is invalid
142+
{:error, :bad_request}
143+
end
144+
end
145+
146+
@doc """
147+
Make an API call to IVLE LAPI.
148+
149+
returns...
150+
151+
- {:ok, body} - valid token
152+
- {:error, :internal_server_error} - Invalid API key
153+
- {:error, :bad_request} - Invalid token
154+
155+
## Parameters
156+
157+
- method: String, the HTTP request method to use
158+
- queries: [Keyword], key-value pair of parameters to send
159+
160+
This method is valid for methods that return a 200 with an empty string body
161+
on invalid tokens. For methods that return with string "Invalid login!" in a
162+
JSON nested in the body, refer to the previous method api_call/2 with guard
163+
clause.
164+
"""
165+
def api_call(method, queries) do
111166
case HTTPoison.get(api_url(method, queries)) do
112167
{:ok, %{body: body, status_code: 200}} when body != ~s("") ->
113168
{:ok, Poison.decode!(body)}
@@ -117,14 +172,15 @@ defmodule Cadet.Accounts.IVLE do
117172
{:error, :internal_server_error}
118173

119174
{:ok, %{body: ~s(""), status_code: 200}} ->
120-
# IVLE responsed 200 with body == ~s("") if token is invalid
175+
# IVLE responds 200 with body == ~s("") if token is invalid
121176
{:error, :bad_request}
122177
end
123178
end
124179

125180
# Construct a valid URL with the module attributes, and given params
126-
# token_param_key is specified as some api calls use ...&Token={token},
127-
# but other calls use ...&AuthToken={token}
181+
# The authentication token parameter must be provided explicitly rather than
182+
# provided implicitly by this function as some API calls use ...&Token={token},
183+
# while others use ...&AuthToken={token}
128184
defp api_url(method, queries) do
129185
queries = [APIKey: @api_key] ++ queries
130186

lib/cadet/application.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ defmodule Cadet.Application do
1919
worker(Guardian.DB.Token.SweeperServer, [])
2020
]
2121

22+
# To supply command line args to phx.server, you must use the elixir/iex bin
23+
# $ elixir --erl "--updater" -S mix phx.server
24+
# $ iex --erl "--updater" -S mix phx.server
25+
# In the compiled binary howver, this is much simpler
26+
# $ bin/cadet start --updater
27+
children =
28+
if :init.get_plain_arguments() |> Enum.member?('--updater') do
29+
children ++ [worker(Cadet.Public.Updater, [])]
30+
else
31+
children
32+
end
33+
2234
# See https://hexdocs.pm/elixir/Supervisor.html
2335
# for other strategies and supported options
2436
opts = [strategy: :one_for_one, name: Cadet.Supervisor]

lib/cadet/public/updater.ex

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,113 @@ defmodule Cadet.Public.Updater do
66
77
The credentials for the guest account are defined in the `.env` file.
88
"""
9+
10+
use GenServer
11+
12+
alias Cadet.Accounts.IVLE
13+
14+
require Logger
15+
916
@api_key Dotenv.load().values["IVLE_KEY"]
1017
@api_url "https://ivle.nus.edu.sg"
1118
@api_url_login @api_url |> URI.merge("api/login/?apikey=#{@api_key}&url=_") |> URI.to_string()
19+
@interval :cadet |> Application.fetch_env!(:updater) |> Keyword.get(:interval)
1220
@username Dotenv.load().values["GUEST_USERNAME"]
1321
@password Dotenv.load().values["GUEST_PASSWORD"]
1422

23+
@doc """
24+
Starts the GenServer.
25+
26+
WARNING: The GenServer crashes if the API key is invalid, or not provided.
27+
"""
28+
def start_link() do
29+
GenServer.start_link(__MODULE__, nil)
30+
end
31+
32+
@impl true
33+
@doc """
34+
Callback for the GenServer. This function calls `schedule_work`, which
35+
initiates a recursive call every `@interval` milliseconds. This acts as a
36+
regularly scheduled task (e.g. cronjob).
37+
38+
`start_link/0` -> `init/1` -> `schedule_work/0` -> `handle_info/2` ->
39+
`schedule_work/0` -> `handle_info/2` -> ...
40+
"""
41+
def init(_) do
42+
Logger.info("Running Cadet.Public.Updater...")
43+
api_params = get_api_params()
44+
schedule_work()
45+
{:ok, api_params}
46+
end
47+
48+
@impl true
49+
@doc """
50+
Callback for the GenServer. This function receives the message sent by
51+
`schedule_work/0`, runs and processes `get_announcements/3`, then calls
52+
`schedule_work/0` recursively.
53+
"""
54+
def handle_info(:work, api_params) do
55+
with {:ok, announcements} <- get_announcements(api_params.token, api_params.course_id) do
56+
Logger.info("Updater fetched #{length(announcements)} announcements from IVLE")
57+
# TODO: Act on announcements fetched
58+
schedule_work()
59+
{:noreply, api_params}
60+
else
61+
{:error, :bad_request} ->
62+
# the token has probably expired---get a new one
63+
Logger.info("Updater failed fetching announcements. Refreshing token...")
64+
api_params = get_api_params()
65+
handle_info(:work, api_params)
66+
end
67+
end
68+
69+
@doc """
70+
Get the announcements for CS1101S. Returns a list of announcements.
71+
72+
## Parameters
73+
74+
- token: String, the IVLE authentication token
75+
- course_id: String, the course ID of CS1101S. See `get_course_id/1`
76+
77+
"""
78+
def get_announcements(token, course_id) do
79+
IVLE.api_call("Announcements", AuthToken: token, CourseID: course_id)
80+
end
81+
82+
@doc """
83+
Get the authentication token of the guess account, and the CS1101S courseID
84+
"""
85+
def get_api_params do
86+
token = get_token()
87+
course_id = get_course_id(token)
88+
%{token: token, course_id: course_id}
89+
end
90+
91+
@doc """
92+
Get the course ID of CS1101S. The course ID a required param in the API call
93+
to get announcements/files from IVLE. The course_id is dynamically fetched
94+
instead of hard-coded in so that there are less variables to change, if the
95+
CS1101S module on IVLE changes ID---all that is needed is that the guest
96+
account is in the CS1101S module.
97+
98+
## Parameters
99+
100+
- token: String, the IVLE authentication token
101+
102+
"""
103+
def get_course_id(token) do
104+
{:ok, modules} = IVLE.api_call("Modules", AuthToken: token, CourseID: "CS1101S")
105+
106+
cs1101s =
107+
modules["Results"]
108+
|> Enum.find(fn module ->
109+
module["CourseCode"] == "CS1101S" and
110+
module["ID"] != "00000000-0000-0000-0000-000000000000"
111+
end)
112+
113+
cs1101s["ID"]
114+
end
115+
15116
@doc """
16117
Get an authentication token for the guest account.
17118
@@ -75,4 +176,8 @@ defmodule Cadet.Public.Updater do
75176
|> Enum.into(%{})
76177
|> Map.get("Location")
77178
end
179+
180+
defp schedule_work do
181+
Process.send_after(self(), :work, @interval)
182+
end
78183
end

test/cadet/accounts/ivle_test.exs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,56 @@ defmodule Cadet.Accounts.IVLETest do
2626
HTTPoison.start()
2727
end
2828

29+
describe "Do an API call; methods with empty string for invalid token" do
30+
test "With one parameter token" do
31+
use_cassette "ivle/api_call#1" do
32+
assert {:ok, resp} = IVLE.api_call("UserName_Get", Token: @token)
33+
assert String.length(resp) > 0
34+
end
35+
end
36+
37+
test "With two parameters token, course code" do
38+
use_cassette "ivle/api_call#2" do
39+
assert {:ok, resp} = IVLE.api_call("Modules", AuthToken: @token, CourseID: "CS1101S")
40+
assert %{"Results" => _} = resp
41+
end
42+
end
43+
44+
test "With an invalid api key" do
45+
use_cassette "ivle/api_call#3", custom: true do
46+
assert {:error, :internal_server_error} = IVLE.api_call("UserName_Get", Token: @token)
47+
end
48+
end
49+
50+
test "With an invalid token" do
51+
use_cassette "ivle/api_call#4" do
52+
assert {:error, :bad_request} = IVLE.api_call("UserName_Get", Token: @token <> "Z")
53+
end
54+
end
55+
end
56+
57+
describe ~s(Do an API call; methods with "Invalid token!" for invalid token) do
58+
test "With a valid token" do
59+
use_cassette "ivle/api_call#5" do
60+
assert {:ok, _} = IVLE.api_call("Announcements", AuthToken: @token, CourseID: "")
61+
end
62+
end
63+
64+
test "With an invalid token" do
65+
use_cassette "ivle/api_call#6" do
66+
assert {:error, :bad_request} =
67+
IVLE.api_call("Announcements", AuthToken: @token <> "Z", CourseID: "")
68+
end
69+
end
70+
71+
test "With an invalid key" do
72+
use_cassette "ivle/api_call#7", custom: true do
73+
assert {:error, :internal_server_error} =
74+
IVLE.api_call("Announcements", AuthToken: @token, CourseID: "")
75+
end
76+
end
77+
end
78+
2979
describe "Fetch an NUSNET ID" do
3080
test "Using a valid token" do
3181
use_cassette "ivle/fetch_nusnet_id#1" do

0 commit comments

Comments
 (0)