From 4d4c4d3508b5cb11cf43e0a33f3b0b09a2b1dd47 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 31 Mar 2019 10:52:56 -0400 Subject: [PATCH] Fervor API implementation --- lib/frenzy/feed.ex | 15 +++ lib/frenzy/group.ex | 12 ++ lib/frenzy/item.ex | 17 +++ .../controllers/fervor/feeds_controller.ex | 120 +++++++++++++++++ .../controllers/fervor/groups_controller.ex | 101 ++++++++++++++ .../controllers/fervor/items_controller.ex | 124 ++++++++++++++++++ .../misc_controller.ex} | 17 ++- .../{ => fervor}/oauth_controller.ex | 2 +- .../controllers/fervor/paginator.ex | 27 ++++ lib/frenzy_web/plug/fervor_authenticate.ex | 49 +++++++ lib/frenzy_web/router.ex | 43 ++++-- 11 files changed, 513 insertions(+), 14 deletions(-) create mode 100644 lib/frenzy_web/controllers/fervor/feeds_controller.ex create mode 100644 lib/frenzy_web/controllers/fervor/groups_controller.ex create mode 100644 lib/frenzy_web/controllers/fervor/items_controller.ex rename lib/frenzy_web/controllers/{fervor_controller.ex => fervor/misc_controller.ex} (70%) rename lib/frenzy_web/controllers/{ => fervor}/oauth_controller.ex (99%) create mode 100644 lib/frenzy_web/controllers/fervor/paginator.ex create mode 100644 lib/frenzy_web/plug/fervor_authenticate.ex diff --git a/lib/frenzy/feed.ex b/lib/frenzy/feed.ex index 3838d2f..3bc812c 100644 --- a/lib/frenzy/feed.ex +++ b/lib/frenzy/feed.ex @@ -1,6 +1,8 @@ defmodule Frenzy.Feed do use Ecto.Schema import Ecto.Changeset + alias FrenzyWeb.Router.Helpers, as: Routes + alias FrenzyWeb.Endpoint def to_fever(feed) do %{ @@ -14,6 +16,19 @@ defmodule Frenzy.Feed do } end + def to_fervor(feed) do + %{ + id: feed.id, + title: feed.title, + url: feed.site_url, + feed_url: feed.feed_url, + service_url: + Application.get_env(:frenzy, :base_url) <> Routes.feed_path(Endpoint, :show, feed.id), + last_updated: DateTime.to_iso8601(feed.last_updated), + group_ids: [feed.group_id] + } + end + schema "feeds" do field :feed_url, :string field :last_updated, :utc_datetime diff --git a/lib/frenzy/group.ex b/lib/frenzy/group.ex index 9f0f7e2..9247274 100644 --- a/lib/frenzy/group.ex +++ b/lib/frenzy/group.ex @@ -1,6 +1,8 @@ defmodule Frenzy.Group do use Ecto.Schema import Ecto.Changeset + alias FrenzyWeb.Router.Helpers, as: Routes + alias FrenzyWeb.Endpoint def to_fever_group(group) do %{ @@ -16,6 +18,16 @@ defmodule Frenzy.Group do } end + def to_fervor(group) do + %{ + id: group.id, + title: group.title, + feed_ids: group.feeds |> Enum.map(fn f -> f.id end), + service_url: + Application.get_env(:frenzy, :base_url) <> Routes.group_path(Endpoint, :show, group.id) + } + end + schema "groups" do field :title, :string diff --git a/lib/frenzy/item.ex b/lib/frenzy/item.ex index b1fb25e..8de62f4 100644 --- a/lib/frenzy/item.ex +++ b/lib/frenzy/item.ex @@ -1,6 +1,8 @@ defmodule Frenzy.Item do use Ecto.Schema import Ecto.Changeset + alias FrenzyWeb.Router.Helpers, as: Routes + alias FrenzyWeb.Endpoint def to_fever(item) do %{ @@ -16,6 +18,21 @@ defmodule Frenzy.Item do } end + def to_fervor(item) do + %{ + id: item.id, + feed_id: item.feed_id, + title: item.title, + author: item.creator, + created_at: DateTime.to_iso8601(item.date), + content: item.content, + url: item.url, + service_url: + Application.get_env(:frenzy, :base_url) <> Routes.item_path(Endpoint, :show, item.id), + read: item.read + } + end + schema "items" do field :content, :string field :date, :utc_datetime diff --git a/lib/frenzy_web/controllers/fervor/feeds_controller.ex b/lib/frenzy_web/controllers/fervor/feeds_controller.ex new file mode 100644 index 0000000..4c0df73 --- /dev/null +++ b/lib/frenzy_web/controllers/fervor/feeds_controller.ex @@ -0,0 +1,120 @@ +defmodule FrenzyWeb.Fervor.FeedsController do + use FrenzyWeb, :controller + alias Frenzy.{Repo, Feed, Filter, Item} + import Ecto.Query + alias FrenzyWeb.Fervor.Paginator + + plug :get_specific_feed + + def get_specific_feed(%Plug.Conn{path_params: %{"id" => id}} = conn, _opts) do + user = conn.assigns[:user] |> Repo.preload(groups: [:feeds]) + {id, _} = Integer.parse(id) + + feeds = Enum.flat_map(user.groups, fn g -> g.feeds end) + + case Enum.find(feeds, fn f -> f.id == id end) do + nil -> + conn + |> put_status(404) + |> json(%{error: "Unknown feed"}) + |> halt() + + feed -> + assign(conn, :feed, feed) + end + end + + def get_specific_feed(conn, _opts), do: conn + + def feeds_list(conn, _params) do + user = conn.assigns[:user] |> Repo.preload(groups: [:feeds]) + + feeds = + user.groups + |> Enum.flat_map(fn g -> g.feeds end) + |> Enum.map(&Feed.to_fervor/1) + + json(conn, feeds) + end + + def specific_feed(conn, _params) do + feed = conn.assigns[:feed] + + json(conn, Feed.to_fervor(feed)) + end + + def specific_feed_items(conn, params) do + feed = conn.assigns[:feed] + feed_id = feed.id + + query = from(i in Item, where: i.feed_id == ^feed_id) + + query = + case Map.get(params, "only") do + "read" -> from(i in query, where: i.read) + "unread" -> from(i in query, where: not i.read) + _ -> query + end + |> Paginator.paginate(params) + |> Paginator.limit(params) + + items = + query + |> Repo.all() + |> Enum.map(&Item.to_fervor/1) + + json(conn, items) + end + + def create(conn, %{"feed_url" => feed_url, "group_ids" => group_ids}) do + case Integer.parse(group_ids) do + {_, rest} when rest != "" -> + conn + |> put_status(400) + |> json(%{error: "Could not create feed. Exactly 1 group must be provided."}) + + {group_id, _} -> + user = conn.assigns[:user] |> Repo.preload(:groups) + + case Enum.find(user.groups, fn g -> g.id == group_id end) do + nil -> + conn + |> put_status(400) + |> json(%{error: "Could not create feed. Invalid group."}) + + group -> + changeset = + Ecto.build_assoc(group, :feeds, %{ + feed_url: feed_url, + filter: %Filter{ + mode: "reject", + score: 0 + } + }) + + {:ok, feed} = Repo.insert(changeset) + + feed = Frenzy.UpdateFeeds.refresh(Frenzy.UpdateFeeds, feed) + + json(conn, Feed.to_fervor(feed)) + end + end + end + + def create(conn, _params) do + conn + |> put_status(400) + |> json(%{ + error: "Could not create feed", + error_description: "feed URL and one group ID must be provided" + }) + end + + def delete(conn, _params) do + feed = conn.assigns[:feed] + + {:ok, _} = Repo.delete(feed) + + send_resp(conn, 204, "") + end +end diff --git a/lib/frenzy_web/controllers/fervor/groups_controller.ex b/lib/frenzy_web/controllers/fervor/groups_controller.ex new file mode 100644 index 0000000..601593d --- /dev/null +++ b/lib/frenzy_web/controllers/fervor/groups_controller.ex @@ -0,0 +1,101 @@ +defmodule FrenzyWeb.Fervor.GroupsController do + use FrenzyWeb, :controller + alias Frenzy.{Repo, Group, Feed, Item} + import Ecto.Query + alias FrenzyWeb.Fervor.Paginator + + plug :get_specific_group + + def get_specific_group(%Plug.Conn{path_params: %{"id" => id}} = conn, _opts) do + user = conn.assigns[:user] |> Repo.preload(groups: [:feeds]) + {id, _} = Integer.parse(id) + + case Enum.find(user.groups, fn g -> g.id == id end) do + nil -> + conn + |> put_status(404) + |> json(%{error: "Unknown group"}) + |> halt() + + group -> + assign(conn, :group, group) + end + end + + def get_specific_group(conn, _opts), do: conn + + def groups_list(conn, _params) do + user = conn.assigns[:user] |> Repo.preload(groups: [:feeds]) + groups = Enum.map(user.groups, &Group.to_fervor/1) + json(conn, groups) + end + + def specific_group(conn, _params) do + group = conn.assigns[:group] + + json(conn, Group.to_fervor(group)) + end + + def specific_group_feeds(conn, _params) do + group = conn.assigns[:group] + + feeds = Enum.map(group.feeds, &Feed.to_fervor/1) + json(conn, feeds) + end + + def specific_group_items(conn, params) do + group = conn.assigns[:group] + + feed_ids = Enum.map(group.feeds, fn f -> f.id end) + + query = from(i in Item, where: i.feed_id in ^feed_ids) + + query = + case Map.get(params, "only") do + "read" -> from(i in query, where: i.read) + "unread" -> from(i in query, where: not i.read) + _ -> query + end + |> Paginator.paginate(params) + |> Paginator.limit(params) + + items = + query + |> Repo.all() + |> Enum.map(&Item.to_fervor/1) + + json(conn, items) + end + + def create(conn, %{"title" => title}) do + user = conn.assigns[:user] + + changeset = + Ecto.build_assoc(user, :groups, %{ + title: title + }) + + {:ok, group} = Repo.insert(changeset) + + group = Repo.preload(group, :feeds) + + json(conn, Group.to_fervor(group)) + end + + def create(conn, _params) do + conn + |> put_status(400) + |> json(%{ + error: "Could not create group", + error_description: "title parameter must be provided" + }) + end + + def delete(conn, _params) do + group = conn.assigns[:group] + + {:ok, _} = Repo.delete(group) + + send_resp(conn, 204, "") + end +end diff --git a/lib/frenzy_web/controllers/fervor/items_controller.ex b/lib/frenzy_web/controllers/fervor/items_controller.ex new file mode 100644 index 0000000..05d0665 --- /dev/null +++ b/lib/frenzy_web/controllers/fervor/items_controller.ex @@ -0,0 +1,124 @@ +defmodule FrenzyWeb.Fervor.ItemsController do + use FrenzyWeb, :controller + alias Frenzy.{Repo, FervorClient, Group, Feed, Filter, Item} + import Ecto.Query + alias FrenzyWeb.Fervor.Paginator + + plug :get_specific_item + + def get_specific_item(%Plug.Conn{path_params: %{"id" => id}} = conn, _opts) do + user = conn.assigns[:user] |> Repo.preload(groups: [:feeds]) + + feeds = Enum.flat_map(user.groups, fn g -> g.feeds end) + + item = Repo.get(Item, id) + + if Enum.any?(feeds, fn f -> f.id == item.feed_id end) do + assign(conn, :item, item) + else + conn + |> put_status(404) + |> json(%{error: "Unknown item"}) + |> halt() + end + end + + def get_specific_item(conn, _opts), do: conn + + def items_list(conn, params) do + user = conn.assigns[:user] |> Repo.preload(groups: [:feeds]) + + feed_ids = + user.groups + |> Enum.flat_map(fn g -> g.feeds end) + |> Enum.map(fn f -> f.id end) + + query = from(i in Item, where: i.feed_id in ^feed_ids) + + query = + case Map.get(params, "only") do + "read" -> from(i in query, where: i.read) + "unread" -> from(i in query, where: not i.read) + nil -> query + end + |> Paginator.paginate(params) + |> Paginator.limit(params) + + items = + query + |> Repo.all() + |> Enum.map(&Item.to_fervor/1) + + json(conn, items) + end + + def specific_item(conn, _params) do + item = conn.assigns[:item] + + json(conn, Item.to_fervor(item)) + end + + def mark_item(conn, changes) do + item = conn.assigns[:item] |> Repo.preload(:feed) + + changeset = Item.changeset(item, changes) + + {:ok, item} = Repo.update(changeset) + + json(conn, Item.to_fervor(item)) + end + + def read_specific_item(conn, _params) do + mark_item(conn, %{read: true}) + end + + def unread_specific_item(conn, _params) do + mark_item(conn, %{read: false}) + end + + def mark_multiple_items(conn, %{"ids" => ids}, changes) do + user = conn.assigns[:user] |> Repo.preload(groups: [:feeds]) + feeds = Enum.flat_map(user.groups, fn g -> g.feeds end) + + read_ids = + ids + |> String.split(",") + |> Enum.map(fn s -> + {id, _} = + s + |> String.trim() + |> Integer.parse() + + Repo.get(Item, id) + end) + |> Enum.filter(fn item -> + Enum.any?(feeds, fn f -> f.id == item.feed_id end) + end) + |> Enum.map(fn item -> + item = Repo.preload(item, :feed) + changeset = Item.changeset(item, changes) + + case Repo.update(changeset) do + {:ok, item} -> item.id + _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + + json(conn, read_ids) + end + + def mark_multiple_items(conn, _params, _changes) do + conn + |> put_status(400) + |> json(%{error: "No items provided."}) + end + + def read_multiple(conn, params) do + mark_multiple_items(conn, params, %{read: true}) + end + + def unread_multiple(conn, params) do + mark_multiple_items(conn, params, %{read: false}) + end +end diff --git a/lib/frenzy_web/controllers/fervor_controller.ex b/lib/frenzy_web/controllers/fervor/misc_controller.ex similarity index 70% rename from lib/frenzy_web/controllers/fervor_controller.ex rename to lib/frenzy_web/controllers/fervor/misc_controller.ex index e55a5c5..713c667 100644 --- a/lib/frenzy_web/controllers/fervor_controller.ex +++ b/lib/frenzy_web/controllers/fervor/misc_controller.ex @@ -1,9 +1,6 @@ -defmodule FrenzyWeb.FervorController do +defmodule FrenzyWeb.Fervor.MiscController do use FrenzyWeb, :controller - alias Frenzy.{Repo, FervorClient, Group, Feed, Filter, Item} - alias FrenzyWeb.Router.Helpers, as: Routes - alias FrenzyWeb.Endpoint - import Ecto.Query + alias Frenzy.{Repo, FervorClient} plug Plug.Parsers, parsers: [:urlencoded, :multipart] @@ -27,4 +24,14 @@ defmodule FrenzyWeb.FervorController do json(conn, FervorClient.to_fervor(client)) end + + def instance(conn, _params) do + json(conn, %{ + name: "Frenzy", + url: Application.get_env(:frenzy, :base_url), + version: "0.1.0", + implementation_name: "Frenzy", + implementation_version: "0.1.0" + }) + end end diff --git a/lib/frenzy_web/controllers/oauth_controller.ex b/lib/frenzy_web/controllers/fervor/oauth_controller.ex similarity index 99% rename from lib/frenzy_web/controllers/oauth_controller.ex rename to lib/frenzy_web/controllers/fervor/oauth_controller.ex index 296d220..565cd49 100644 --- a/lib/frenzy_web/controllers/oauth_controller.ex +++ b/lib/frenzy_web/controllers/fervor/oauth_controller.ex @@ -1,4 +1,4 @@ -defmodule FrenzyWeb.OauthController do +defmodule FrenzyWeb.Fervor.OauthController do use FrenzyWeb, :controller alias Frenzy.{Repo, FervorClient, User, ApprovedClient} alias FrenzyWeb.Router.Helpers, as: Routes diff --git a/lib/frenzy_web/controllers/fervor/paginator.ex b/lib/frenzy_web/controllers/fervor/paginator.ex new file mode 100644 index 0000000..1b0d3ce --- /dev/null +++ b/lib/frenzy_web/controllers/fervor/paginator.ex @@ -0,0 +1,27 @@ +defmodule FrenzyWeb.Fervor.Paginator do + import Ecto.Query + + def paginate(query, %{"max_id" => max_id} = params) do + limit = Map.get(params, "limit", 20) + from(o in query, where: o.id < ^max_id, order_by: [desc: :id]) + end + + def paginate(query, %{"min_id" => min_id} = params) do + limit = Map.get(params, "limit", 20) + from(o in query, where: o.id > ^min_id, order_by: [asc: :id]) + end + + def paginate(query, %{"since_id" => since_id} = params) do + limit = Map.get(params, "limit", 20) + from(o in query, where: o.id > ^since_id, order_by: [desc: :id]) + end + + def paginate(query, _params) do + from(query, order_by: [desc: :id]) + end + + def limit(query, params) do + limit = Map.get(params, "limit", 20) + from(query, limit: ^limit) + end +end diff --git a/lib/frenzy_web/plug/fervor_authenticate.ex b/lib/frenzy_web/plug/fervor_authenticate.ex new file mode 100644 index 0000000..ba225f2 --- /dev/null +++ b/lib/frenzy_web/plug/fervor_authenticate.ex @@ -0,0 +1,49 @@ +defmodule FrenzyWeb.Plug.FervorAuthenticate do + import Plug.Conn + alias Frenzy.{Repo, ApprovedClient, User} + alias FrenzyWeb.Router.Helpers, as: Routes + alias FrenzyWeb.Endpoint + + def init(opts), do: opts + + def call(conn, _opts) do + case get_req_header(conn, "authorization") do + [authorization | _] -> + case authorization do + "Bearer " <> access_token -> + case Repo.get_by(ApprovedClient, access_token: access_token) do + nil -> + conn + |> put_status(401) + |> Phoenix.Controller.json(%{ + error: "Invalid authorization", + error_description: "The provided access token is not valid." + }) + |> halt() + + approved_client -> + assign(conn, :user, Repo.get(User, approved_client.user_id)) + end + + _ -> + conn + |> put_status(401) + |> Phoenix.Controller.json(%{ + error: "Invalid authorization", + error_description: + "The provided Authorization header does notmatc the expected format." + }) + |> halt() + end + + _ -> + conn + |> put_status(401) + |> Phoenix.Controller.json(%{ + error: "Missing authorization", + error_description: "No Authorization header was provided." + }) + |> halt() + end + end +end diff --git a/lib/frenzy_web/router.ex b/lib/frenzy_web/router.ex index 2e97382..5790f86 100644 --- a/lib/frenzy_web/router.ex +++ b/lib/frenzy_web/router.ex @@ -9,7 +9,7 @@ defmodule FrenzyWeb.Router do plug :put_secure_browser_headers end - pipeline :authenticate do + pipeline :browser_authenticate do plug FrenzyWeb.Plug.Authenticate end @@ -17,16 +17,23 @@ defmodule FrenzyWeb.Router do plug :accepts, ["json"] end + pipeline :fervor_authenticate do + plug FrenzyWeb.Plug.FervorAuthenticate + end + scope "/", FrenzyWeb do pipe_through :browser get "/login", LoginController, :login post "/login", LoginController, :login_post + + get "/oauth/authorize", Fervor.OauthController, :authorize_get + post "/oauth/authorize", Fervor.OauthController, :authorize_post end scope "/", FrenzyWeb do pipe_through :browser - pipe_through :authenticate + pipe_through :browser_authenticate get "/", GroupController, :index resources "/groups", GroupController, except: [:edit, :update] @@ -54,18 +61,38 @@ defmodule FrenzyWeb.Router do post "/api/fever.php", FeverController, :post end - scope "/", FrenzyWeb do + scope "/", FrenzyWeb.Fervor do pipe_through :api - post "/api/v1/register", FervorController, :register + post "/api/v1/register", MiscController, :register post "/oauth/token", OauthController, :token + + get "/api/v1/instance", MiscController, :instance end - scope "/", FrenzyWeb do - pipe_through :browser + scope "/", FrenzyWeb.Fervor do + pipe_through :api + pipe_through :fervor_authenticate - get "/oauth/authorize", OauthController, :authorize_get - post "/oauth/authorize", OauthController, :authorize_post + get "/api/v1/groups", GroupsController, :groups_list + get "/api/v1/groups/:id", GroupsController, :specific_group + get "/api/v1/groups/:id/feeds", GroupsController, :specific_group_feeds + get "/api/v1/groups/:id/items", GroupsController, :specific_group_items + post "/api/v1/groups/create", GroupsController, :create + post "/api/v1/groups/:id/delete", GroupsController, :delete + + get "/api/v1/feeds", FeedsController, :feeds_list + get "/api/v1/feeds/:id", FeedsController, :specific_feed + get "/api/v1/feeds/:id/items", FeedsController, :specific_feed_items + post "/api/v1/feeds/create", FeedsController, :create + post "/api/v1/feeds/:id/delete", FeedsController, :delete + + get "/api/v1/items", ItemsController, :items_list + get "/api/v1/items/:id", ItemsController, :specific_item + post "/api/v1/items/:id/read", ItemsController, :read_specific_item + post "/api/v1/items/:id/unread", ItemsController, :unread_specific_item + post "/api/v1/items/read", ItemsController, :read_multiple + post "/api/v1/items/unread", ItemsController, :unread_multiple end end