timestamper/src/ts_api.erl
Jonathan Bernard a3c55e918e Bugfixes: session management, new entry creation.
* Changed the cookie Path value to allow the cookie to be reused for the
  domain, not just ths `/ts_api` path. This allows the user to refresh the page
  and reuse their existing session as long as it is not stale.
* Fixed a bug in the `ts_json:ejson_to_record_strict/2` function. It was
  expecting a record out of `ts_json:ejson_to_record/2` but that function
  returns a tuple with the record and extended data. Because of the way
  `ejson_to_record_strict` uses case statements to check for specific values it
  was still passing the parsed record and data through, but all the checks were
  being bypassed.
* Fixed bugs in the `index.yaws` bootstrap code for the case where the user
  already has a valid session.
* Added `urlRoot` functions to the Backbone model definitions.
* Changed the behavior of the new entry creation method. We were trying to
  fetch just updated attributes from the server for the model we created, but
  we were pulling all the entries due to the URL backbone was using. This led
  to the new client-side model having all the previous entry models as
  attributes. Ideally we would fix the fetch so that only the one model is
  requested from the server, but we run into a catch-22 because the lookup id
  is not know by the client as it is generated on the server-side. For now I
  have changed this behavior so that we still pull all entries, but we pull
  them into the collection. The collection is then smart enough to update the
  entries that have changed (namely the new one). The server returns the newly
  created entry attributes in response to the POST request that the client
  makes initially, so when I have more time to work on this I plan to do away
  with the fetch after create, and just pull in the data from the server's
  response.
* Changed formatting.
2013-10-22 15:32:22 +00:00

609 lines
21 KiB
Erlang

-module(ts_api).
-compile(export_all).
-include("ts_db_records.hrl").
-include("yaws_api.hrl").
out(YArg) ->
% retreive the session data
Session = ts_api_session:get_session(YArg),
%get the app mod data
PathString = YArg#arg.appmoddata,
% split the path
PathElements = case PathString of
undefined -> []; %handle no end slash: /ts_api
_Any -> string:tokens(PathString, "/")
end,
% process the request
case catch dispatch_request(YArg, Session, PathElements) of
{'EXIT', Err} ->
% TODO: log error internally
error_logger:error_report("TimeStamper: ~p", [Err]),
io:format("Error: ~n~p", [Err]),
make_json_500(YArg, Err);
Other -> Other
end.
% ================================== %
% ======== DISPATCH METHODS ======== %
% ================================== %
%% Entry point to the TimeStamper API dispatch system
dispatch_request(YArg, _Session, []) -> make_json_404(YArg, [{"see_docs", "/ts_api_doc"}]);
dispatch_request(YArg, Session, [H|T]) ->
case {Session, H} of
{_, "login"} -> do_login(YArg);
{_, "logout"} -> do_logout(YArg);
{not_logged_in, _} -> make_json_401(YArg);
{session_expired, _} -> make_json_401(YArg, [{"error", "session expired"}]);
{_S, "app"} -> dispatch_app(YArg, Session, T);
{_S, "users"} -> dispatch_user(YArg, Session, T);
{_S, "timelines"} -> dispatch_timeline(YArg, Session, T);
{_S, "entries"} -> dispatch_entry(YArg, Session, T);
{_S, _Other} -> make_json_404(YArg, [{"see_docs", "/ts_api_doc/"}])
end.
% -------- Dispatch for /app -------- %
dispatch_app(YArg, Session, Params) ->
HTTPMethod = (YArg#arg.req)#http_request.method,
case {HTTPMethod, Params} of
{'OPTIONS', ["user_summary", _]} -> make_CORS_options(YArg, "GET");
{'GET', ["user_summary", UsernameStr]} ->
case {Session#ts_api_session.username,
UsernameStr} of
{Username, Username} -> get_user_summary(YArg, Username);
_ -> make_json_401(YArg)
end;
{_BadMethod, ["user_summary", _UsernameStr]} ->
make_json_405(YArg, [{"see_docs", "/ts_api_docs/app.html"}]);
_Other -> make_json_404(YArg, [{"see_docs", "/ts_api_docs/app.html"}])
end.
% -------- Dispatch for /user -------- %
dispatch_user(YArg, Session, []) ->
dispatch_user(YArg, Session, [Session#ts_api_session.username]);
dispatch_user(YArg, Session, [Username]) ->
HTTPMethod = (YArg#arg.req)#http_request.method,
% compare to the logged-in user
case {HTTPMethod, Session#ts_api_session.username} of
{'OPTIONS', Username} -> make_CORS_options(YArg, "GET, PUT");
{'GET', Username} -> get_user(YArg, Username);
{'PUT', Username} -> put_user(YArg, Username);
{_BadMethod, Username} ->
make_json_405(YArg, [{"see_docs", "/ts_api_doc/users.html"}]);
_Other -> make_json_401(YArg, [{"see_docs", "/ts_api_doc/users.html"}])
end.
% -------- Dispatch for /timeline -------- %
dispatch_timeline(YArg, _Session, []) ->
make_json_404(YArg, [{"see_docs", "/ts_api_doc/timelines.html"}]);
dispatch_timeline(YArg, Session, [Username|_T] = PathElements) ->
case Session#ts_api_session.username of
Username -> dispatch_timeline(YArg, PathElements);
_Other -> make_json_404(YArg, [{"see_docs", "/ts_api_doc/users.html"}])
end.
% just username, list timelines
dispatch_timeline(YArg, [Username]) ->
HTTPMethod = (YArg#arg.req)#http_request.method,
case HTTPMethod of
'OPTIONS' -> make_CORS_options(YArg, "GET");
'GET' -> list_timelines(YArg, Username);
_Other -> make_json_405(YArg, [{"see_docs", "/ts_api_doc/timelines.html"}])
end;
dispatch_timeline(YArg, [Username, TimelineId]) ->
HTTPMethod = (YArg#arg.req)#http_request.method,
case HTTPMethod of
'OPTIONS'-> make_CORS_options(YArg, "GET, PUT, DELETE");
'GET' -> get_timeline(YArg, Username, TimelineId);
'PUT' -> put_timeline(YArg, Username, TimelineId);
'DELETE' -> delete_timeline(YArg, Username, TimelineId);
_Other -> make_json_405(YArg, [{"see_docs", "/ts_api_doc/timelines.html"}])
end;
dispatch_timeline(YArg, _Other) ->
make_json_404(YArg, [{"see_docs", "/ts_api_doc/timelines.html"}]).
% -------- Dispatch for /entry -------- %
dispatch_entry(YArg, _Session, []) ->
make_json_404(YArg, [{"see_docs", "/ts_aip_doc/entries.html"}]);
dispatch_entry(YArg, Session, [Username|_T] = PathElements) ->
case Session#ts_api_session.username of
Username -> dispatch_entry(YArg, PathElements);
_Other -> make_json_404(YArg, [{"see_docs", "/ts_api_doc/entries.html"}])
end.
dispatch_entry(YArg, [Username, TimelineId]) ->
HTTPMethod = (YArg#arg.req)#http_request.method,
case HTTPMethod of
'OPTIONS' -> make_CORS_options(YArg, "GET, POST");
'GET' -> list_entries(YArg, Username, TimelineId);
'POST' -> post_entry(YArg, Username, TimelineId);
_Other -> make_json_405(YArg, [{"see_docs", "/ts_api_doc/entries.html"}])
end;
dispatch_entry(YArg, [Username, TimelineId, UrlEntryId]) ->
EntryId = list_to_integer(UrlEntryId), % TODO: catch non-numbers
HTTPMethod = (YArg#arg.req)#http_request.method,
case HTTPMethod of
'OPTIONS'-> make_CORS_options(YArg, "GET, PUT, DELETE");
'GET' -> get_entry(YArg, Username, TimelineId, EntryId);
'PUT' -> put_entry(YArg, Username, TimelineId, EntryId);
'DELETE' -> delete_entry(YArg, Username, TimelineId, EntryId);
_Other -> make_json_405(YArg, [{"see_docs", "/ts_api_doc/entries.html"}])
end;
dispatch_entry(YArg, _Other) ->
make_json_404(YArg, [{"see_docs", "/ts_api_doc/entries.html"}]).
% ============================== %
% ======== IMPLEMENTATION ====== %
% ============================== %
do_login(YArg) ->
EJSON = parse_json_body(YArg),
{struct, Fields} = EJSON,
case {lists:keyfind(username, 1, Fields),
lists:keyfind(password, 1, Fields)} of
% username and password found
{{username, Username}, {password, Password}} ->
% check the uname, password
case ts_user:check_credentials(Username, Password) of
% they are good
true ->
{CookieVal, _Session} = ts_api_session:new(Username),
[{header, {set_cookie, io_lib:format(
"ts_api_session=~s; Path=/",
[CookieVal])}},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]},
{content, "application/json",
json:encode({struct, [{"status", "ok"}]})}];
% they are not good
false -> make_json_401(YArg, [{"error",
"bad username/password combination"}])
end;
_Other -> make_json_400(YArg, [{"see_docs", "/ts_api_doc/login.html"}])
end.
do_logout(YArg) ->
Cookie = (YArg#arg.headers)#headers.cookie,
CookieVal = yaws_api:find_cookie_val("ts_api_session", Cookie),
ts_api_session:logout(CookieVal),
[{status, 200},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].
get_user_summary(YArg, Username) ->
% find user record
case ts_user:lookup(Username) of
% no user record, barf
no_record -> make_json_404(YArg);
% found user record, let us build the return
User ->
% get user extended data properties
UserExtData = ts_ext_data:get_properties(User),
% convert to intermediate JSON form
EJSONUser = ts_json:record_to_ejson(User, UserExtData),
% get the user's timelins
Timelines = ts_timeline:list(Username, 0, 100),
% get each timeline's extended data and convert to EJSON
EJSONTimelines = {array,
lists:map(
fun(Timeline) ->
ts_json:record_to_ejson(Timeline,
ts_ext_data:get_properties(Timeline))
end,
Timelines)},
% write response out
make_json_200(YArg, {struct,
[{user, EJSONUser},
{timelines, EJSONTimelines}
]})
end.
get_user(YArg, Username) ->
% find the user record
case ts_user:lookup(Username) of
% no such user, barf
no_record -> make_json_404(YArg);
% found, return a 200 with the record
User -> make_json_200_record(YArg, User)
end.
put_user(YArg, Username) ->
% parse the POST data
EJSON = parse_json_body(YArg),
{UR, ExtData} =
try ts_json:ejson_to_record_strict(#ts_user{username=Username}, EJSON)
catch throw:{InputError, StackTrace} ->
error_logger:error_report("Bad input in put_user/2: ~p",
[InputError]),
throw(make_json_400(YArg, [{request_error, InputError}]))
end,
% update the record (we do not support creating users via the API right now)
{ok, UpdatedRec} = ts_user:update(UR, ExtData),
% return a 200
make_json_200_record(YArg, UpdatedRec).
list_timelines(YArg, Username) ->
% pull out the POST data
QueryData = yaws_api:parse_query(YArg),
% read or default the Start
Start = case lists:keyfind("start", 1, QueryData) of
{"start", StartVal} -> list_to_integer(StartVal);
false -> 0
end,
% read or default the Length
Length = case lists:keyfind(length, 1, QueryData) of
{"length", LengthVal} ->
erlang:min(list_to_integer(LengthVal), 50);
false -> 50
end,
% list the timelines from the database
Timelines = ts_timeline:list(Username, Start, Length),
% convert them all to their EJSON form, adding in extended data for each
EJSONTimelines = {array, lists:map(
fun (Timeline) ->
ts_json:record_to_ejson(Timeline,
ts_ext_data:get_properties(Timeline))
end,
Timelines)},
% return response
make_json_200(YArg, EJSONTimelines).
get_timeline(YArg, Username, TimelineId) ->
% look for timeline
case ts_timeline:lookup(Username, TimelineId) of
% no such timeline, return 404
no_record -> make_json_404(YArg, [{"error", "no such timeline"}]);
% return the timeline data
Timeline -> make_json_200_record(YArg, Timeline)
end.
put_timeline(YArg, Username, TimelineId) ->
% parse the POST data
EJSON = parse_json_body(YArg),
%{struct, Fields} = EJSON,
% parse into a timeline record
{TR, ExtData} =
try ts_json:ejson_to_record_strict(
#ts_timeline{ref={Username, TimelineId}}, EJSON)
% we can not parse it, tell the user
catch throw:{InputError, _StackTrace} ->
error_logger:error_report("Bad input: ~p", [InputError]),
throw(make_json_400(YArg, [{request_error, InputError}]))
end,
% write the changes.
ts_timeline:write(TR, ExtData),
% return a 200
make_json_200_record(YArg, TR).
delete_timeline(_YArg, _Username, _TimelineId) -> {status, 405}.
list_entries(YArg, Username, TimelineId) ->
% pull out the POST data
QueryData = yaws_api:parse_query(YArg),
% first determine if we are listing by date
case {ts_timeline:lookup(Username, TimelineId),
lists:keyfind("byDate", 1, QueryData)} of
{no_record, _ByDateField} -> make_json_404(
[{"error", "no such timeline"},
{"see_docs", "/ts_api_doc/entries.html#LIST"}]);
% listing by date range
{Timeline, {"byDate", "true"}} ->
% look for the start date; default to the beginning of the timeline
StartDate = case lists:keyfind("startDate", 1, QueryData) of
% TODO: error handling if the date is badly formatted
{"startDate", StartDateVal} -> ts_json:decode_datetime(StartDateVal);
false -> Timeline#ts_timeline.created
end,
% look for end date; default to right now
EndDate = case lists:keyfind("endDate", 1, QueryData) of
% TODO: error handling if the date is badly formatted
{"endDate", EndDateVal} -> ts_json:decode_datetime(EndDateVal);
false -> calendar:now_to_universal_time(erlang:now())
end,
% read sort order and list entries
Entries = case lists:keyfind("order", 1, QueryData) of
% descending sort order
{"order", "desc"} -> ts_entry:list_desc(
{Username, TimelineId}, StartDate, EndDate);
% ascending order--{other, asc}--and default
_Other -> ts_entry:list_asc(
{Username, TimelineId}, StartDate, EndDate)
end,
EJSONEntries = {array, lists:map(
fun (Entry) ->
ts_json:record_to_ejson(Entry,
ts_ext_data:get_properties(Entry))
end,
Entries)},
make_json_200(YArg, EJSONEntries);
% listing by table position
_Other ->
% read or default the Start
Start = case lists:keyfind("start", 1, QueryData) of
{"start", StartVal} -> list_to_integer(StartVal);
false -> 0
end,
% read or default the Length
Length = case lists:keyfind("length", 1, QueryData) of
{"length", LengthVal} ->
erlang:min(list_to_integer(LengthVal), 500);
false -> 50
end,
% read sort order and list entries
Entries = case lists:keyfind("order", 1, QueryData) of
{"order", "desc"} -> ts_entry:list_desc(
{Username, TimelineId}, Start, Length);
_UnknownOrder -> ts_entry:list_asc(
{Username, TimelineId}, Start, Length)
end,
EJSONEntries = {array, lists:map(
fun (Entry) ->
ts_json:record_to_ejson(Entry,
ts_ext_data:get_properties(Entry))
end,
Entries)},
make_json_200(YArg, EJSONEntries)
end.
get_entry(YArg, Username, TimelineId, EntryId) ->
case ts_entry:lookup(Username, TimelineId, EntryId) of
% no such entry
no_record -> make_json_404(YArg, [{"error", "no such entry"}]);
% return the entry data
Entry -> make_json_200_record(YArg, Entry)
end.
post_entry(YArg, Username, TimelineId) ->
% parse the request body
EJSON = parse_json_body(YArg),
% parse into ts_entry record
{ER, ExtData} = try ts_json:ejson_to_record_strict(
#ts_entry{ref = {Username, TimelineId, undefined}}, EJSON)
catch _:InputError ->
error_logger:error_report("Bad input: ~p", [InputError]),
throw(make_json_400(YArg, [{request_error, InputError}]))
end,
case ts_entry:new(ER, ExtData) of
% record created
{ok, CreatedRecord} ->
[{status, 201}, make_json_200_record(YArg, CreatedRecord)];
OtherError ->
error_logger:error_report("Could not create entry: ~p", [OtherError]),
make_json_500(YArg, OtherError)
end.
put_entry(YArg, Username, TimelineId, EntryId) ->
% parse the POST data
EJSON = parse_json_body(YArg),
% parse into ts_entry record
{ER, ExtData} = try ts_json:ejson_to_record_strict(
#ts_entry{ref={Username, TimelineId, EntryId}}, EJSON)
catch _:InputError ->
error_logger:error_report("Bad input: ~p", [InputError]),
throw(make_json_400(YArg))
end,
ts_entry:write(ER, ExtData),
make_json_200_record(YArg, ER).
delete_entry(YArg, Username, TimelineId, EntryId) ->
% find the record to delete
case ts_entry:lookup(Username, TimelineId, EntryId) of
no_record -> make_json_404(YArg);
Record ->
% try to delete
case ts_entry:delete(Record) of
ok -> {status, 200};
Error ->
error_logger:error_report("Error occurred deleting entry record: ~p", [Error]),
make_json_500(YArg, Error)
end
end.
% ============================== %
% ======== UTIL METHODS ======== %
% ============================== %
parse_json_body(YArg) ->
case catch json:decode([], binary_to_list(YArg#arg.clidata)) of
{done, {ok, EJSON}, _} -> EJSON;
Error ->
% TODO: log error internally
error_logger:error_report("Error parsing JSON request body: ~p", [Error]),
throw(make_json_400(YArg))
end.
get_origin_header(YArg) ->
Headers = (YArg#arg.headers)#headers.other,
case lists:keyfind("Origin", 3, Headers) of
false -> "*";
{http_header, 0, "Origin", _, Origin} -> Origin;
_ -> make_json_500(YArg, "Unrecognized Origin header.")
end.
make_CORS_options(YArg, AllowedMethods) ->
[{status, 200},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Methods: ", AllowedMethods]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].
make_CORS_options(_YArg, AllowedOrigins, AllowedMethods) ->
[{status, 200},
{header, ["Access-Control-Allow-Origin: ", AllowedOrigins]},
{header, ["Access-Control-Allow-Methods: ", AllowedMethods]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].
%% Create a JSON 200 response.
make_json_200(YArg, EJSONResponse) ->
JSONResponse = json:encode(EJSONResponse),
[{content, "application/json", JSONResponse},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].
make_json_200_record(YArg, Record) ->
RecordExtData = ts_ext_data:get_properties(Record),
EJSON = ts_json:record_to_ejson(Record, RecordExtData),
JSONResponse = json:encode(EJSON),
[{content, "application/json", JSONResponse},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].
make_json_400(YArg) -> make_json_400(YArg, []).
make_json_400(YArg, Fields) ->
F1 = case lists:keyfind(status, 1, Fields) of
false -> Fields ++ [{"status", "bad request"}];
_Else -> Fields
end,
[{status, 400}, {content, "application/json", json:encode({struct, F1})},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].
make_json_401(YArg) -> make_json_401(YArg, []).
make_json_401(YArg, Fields) ->
% add default status if not provided
F1 = case lists:keyfind(status, 1, Fields) of
false -> Fields ++ [{"status", "unauthorized"}];
_Else -> Fields
end,
[{status, 401},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]},
{content, "application/json", json:encode({struct, F1})}].
%% Create a JSON 404 response.
make_json_404(YArg) -> make_json_404(YArg, []).
make_json_404(YArg, Fields) ->
% add default status if not provided
F1 = case lists:keyfind(status, 1, Fields) of
false -> Fields ++ [{"status", "not found"}];
_Else -> Fields
end,
% add the path they requested
F2 = F1 ++ [{path, element(2, (YArg#arg.req)#http_request.path)}],
[{status, 404}, {content, "application/json", json:encode({struct, F2})},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].
make_json_405(YArg) -> make_json_405(YArg, []).
make_json_405(YArg, Fields) ->
% add default status if not provided
F1 = case lists:keyfind(status, 1, Fields) of
false -> Fields ++ [{"status", "method not allowed"}];
_Else -> Fields
end,
% add the path they requested
% F2 = F1 ++ [{path, io_lib:format("~p", [(YArg#arg.req)#http_request.path])}],
[{status, 405}, {content, "application/json", json:encode({struct, F1})},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].
make_json_500(YArg, Error) ->
io:format("Error: ~n~p", [Error]),
EJSON = {struct, [
{"status", "internal server error"},
{"error", lists:flatten(io_lib:format("~p", [Error]))}]},
[{status, 500}, {content, "application/json", json:encode(EJSON)},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].
make_json_500(YArg) ->
EJSON = {struct, [
{"status", "internal server error"}]},
[{status, 500}, {content, "application/json", json:encode(EJSON)},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]}].