Skip to content

Commit

Permalink
Issue/101 REST API: Chapter Management (#152)
Browse files Browse the repository at this point in the history
* add(api/chapters): create

* add(api/chapters): update

* add(api/chapters): delete

* refactor: contorller only accesses Directory.Editor

* refactor: use :rest_controller

* refactor: extract assign_audio/2 plug

* make chapter start time required

* doc: add chapter api doc

* refactor: shorten halt call

* add fixme comment

* make `[:audio_id, :start]` chapter primary key

* remove "set chapters" mutation

* consolidate list_chapters/3 resolver

* rest: include audio_id in chapter

* add fixme comment for chapter image

* delete previous chapter image on update

* delete chapter deletes chapter image

* rest: add missing `show` action

* refactor: use :assign_chapter plug

* permissions: check directly against audio

* refactor: introduce `apply_action_fallback`
  • Loading branch information
eteubert authored Jul 18, 2019
1 parent 4cf3288 commit 4eacb68
Show file tree
Hide file tree
Showing 18 changed files with 290 additions and 87 deletions.
2 changes: 2 additions & 0 deletions .iex.exs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ alias Radiator.Directory.{Audio, Episode, Podcast, Network, Editor}
alias Radiator.Media
alias Radiator.Media.AudioFile

alias Radiator.AudioMeta.Chapter

alias Radiator.Feed.Builder

alias Radiator.Auth
Expand Down
44 changes: 44 additions & 0 deletions guides/rest_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
- [Parameters for Create](#Parameters-for-Create)
- [Create](#Create-6)
- [Read](#Read-6)
- [Audio Chapters](#Audio-Chapters)
- [Parameters for Create & Update](#Parameters-for-Create--Update-6)
- [Create](#Create-7)
- [Read](#Read-7)
- [Update](#Update-6)
- [Delete](#Delete-6)

## API Usage

Expand Down Expand Up @@ -358,3 +364,41 @@ POST /api/rest/v1/audio_file
```
GET /api/rest/v1/audio_file/:id
```

## Audio Chapters

> ⚠️ A chapter is uniquely identified by its start time and the associated audio. There can only be exactly one chapter per audio with a given start time.
### Parameters for Create & Update

| Name | Type | Description |
| ------------------- | --------- | -------------------------------------------------------------- |
| `chapter[audio_id]` | `integer` | **Required.** Chapter is attached to Audio object of given ID. |
| `chapter[start]` | `integer` | **Required.** chapter start time in milliseconds |
| `chapter[title]` | `string` | chapter title |
| `chapter[link]` | `string` | chapter link |
| `chapter[file]` | `image` | chapter image |

### Create

```
POST /api/rest/v1/audios/:audio_id/chapters
```

### Read

```
GET /api/rest/v1/audios/:audio_id/chapters/:start
```

### Update

```
PATCH /api/rest/v1/audios/:audio_id/chapters/:start
```

### Delete

```
DELETE /api/rest/v1/audios/:audio_id/chapters/:start
```
5 changes: 5 additions & 0 deletions lib/radiator/audio_meta/audio_meta.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ defmodule Radiator.AudioMeta do
|> Repo.all()
end

def delete_chapter(chapter = %Chapter{}) do
delete_chapter_image(chapter)
Repo.delete(chapter)
end

def delete_chapters(%Audio{} = audio) do
audio = Repo.preload(audio, :chapters)

Expand Down
14 changes: 12 additions & 2 deletions lib/radiator/audio_meta/chapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ defmodule Radiator.AudioMeta.Chapter do
alias Radiator.Directory.Audio
alias Radiator.Media

@primary_key false
schema "chapters" do
field :start, :integer
field :start, :integer, primary_key: true
field :title, :string
field :link, :string
field :image, Media.ChapterImage.Type

belongs_to :audio, Audio
belongs_to :audio, Audio, primary_key: true
end

@doc false
Expand All @@ -25,9 +26,18 @@ defmodule Radiator.AudioMeta.Chapter do
:title,
:link
])
|> validate_required([:start])
|> maybe_delete_existing_attachments(attrs)
|> cast_attachments(attrs, [:image], allow_paths: true, allow_urls: true)
end

def maybe_delete_existing_attachments(changeset, %{image: _image}) do
Radiator.AudioMeta.delete_chapter_image(changeset.data)
changeset
end

def maybe_delete_existing_attachments(changeset, _attrs), do: changeset

@doc """
Convenience accessor for image URL.
"""
Expand Down
40 changes: 40 additions & 0 deletions lib/radiator/directory/editor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ defmodule Radiator.Directory.Editor do

alias Radiator.Support
alias Radiator.Repo
alias Radiator.AudioMeta
alias Radiator.AudioMeta.Chapter
alias Radiator.Directory
alias Radiator.Directory.{Network, Podcast, Episode, Editor, Audio, Collaborator}

Expand Down Expand Up @@ -442,6 +444,44 @@ defmodule Radiator.Directory.Editor do
episode
end

def get_chapter(actor = %Auth.User{}, audio = %Audio{}, start) do
case Repo.get_by(Chapter, audio_id: audio.id, start: start) do
nil ->
@not_found_match

chapter = %Chapter{} ->
if has_permission(actor, audio, :readonly) do
{:ok, chapter}
else
@not_authorized_match
end
end
end

def create_chapter(actor = %Auth.User{}, audio, attrs) do
if has_permission(actor, audio, :edit) do
AudioMeta.create_chapter(audio, attrs)
else
@not_authorized_match
end
end

def update_chapter(actor = %Auth.User{}, chapter = %Chapter{}, attrs) do
if has_permission(actor, %Audio{id: chapter.audio_id}, :edit) do
AudioMeta.update_chapter(chapter, attrs)
else
@not_authorized_match
end
end

def delete_chapter(actor = %Auth.User{}, chapter = %Chapter{}) do
if has_permission(actor, %Audio{id: chapter.audio_id}, :own) do
AudioMeta.delete_chapter(chapter)
else
@not_authorized_match
end
end

@spec get_audio(Auth.User.t(), pos_integer()) ::
{:ok, Audio.t()} | {:error, :not_authorized | :not_found}
def get_audio(actor = %Auth.User{}, id) do
Expand Down
1 change: 1 addition & 0 deletions lib/radiator/directory/editor/permission.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule Radiator.Directory.Editor.Permission do
use Radiator.Constants

alias Radiator.Repo
alias Radiator.AudioMeta.Chapter
alias Radiator.Auth
alias Radiator.Perm.Permission
alias Radiator.Directory.{Network, Podcast, Episode, Audio}
Expand Down
1 change: 1 addition & 0 deletions lib/radiator/media/audio_file.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ defmodule Radiator.Media.AudioFile do
# todo: determine byte length and mime type on file change, don't take them as attrs
end

# fixme: auidofile.upload geht bei dom nicht mehr
# not sure if this is the best way
# via https://elixirforum.com/t/ecto-validating-belongs-to-association-is-not-nil/2665/13
defp cast_or_constraint_assoc(changeset, name) do
Expand Down
2 changes: 1 addition & 1 deletion lib/radiator/media/chapter_image.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ defmodule Radiator.Media.ChapterImage do
end

def storage_dir(_version, {_file, chapter}) do
"chapter/#{chapter.id}"
"chapter/#{chapter.audio_id}"
end
end
75 changes: 75 additions & 0 deletions lib/radiator_web/controllers/api/chapters_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule RadiatorWeb.Api.ChaptersController do
use RadiatorWeb, :rest_controller

alias Radiator.Directory.Editor

plug :assign_audio when action in [:show, :create, :update, :delete]
plug :assign_chapter when action in [:show, :update, :delete]

def show(conn, _params) do
render(conn, "show.json")
end

def create(conn, %{"chapter" => chapter_params}) do
with user <- current_user(conn),
{:ok, chapter} <- Editor.create_chapter(user, conn.assigns[:audio], chapter_params) do
conn
|> put_status(:created)
|> put_resp_header(
"location",
Routes.api_audio_chapters_path(conn, :show, conn.assigns[:audio].id, chapter.start)
)
|> assign(:chapter, chapter)
|> render("show.json")
end
end

def update(conn, %{"chapter" => chapter_params}) do
with user <- current_user(conn),
{:ok, chapter} <- Editor.update_chapter(user, conn.assigns[:chapter], chapter_params) do
conn
|> assign(:chapter, chapter)
|> render("show.json")
end
end

def delete(conn, _) do
with user <- current_user(conn),
{:ok, _} <- Editor.delete_chapter(user, conn.assigns[:chapter]) do
send_delete_resp(conn)
else
@not_found_match -> send_delete_resp(conn)
error -> error
end
end

defp assign_audio(conn, _) do
with {:ok, audio} <-
conn
|> current_user()
|> Editor.get_audio(conn.params["audio_id"]) do
conn
|> assign(:audio, audio)
else
response -> apply_action_fallback(conn, response)
end
end

defp assign_chapter(conn, _) do
with {:ok, chapter} <-
conn
|> current_user()
|> Editor.get_chapter(conn.assigns[:audio], conn.params["start"]) do
conn
|> assign(:chapter, chapter)
else
response -> apply_action_fallback(conn, response)
end
end

defp apply_action_fallback(conn, response) do
case @phoenix_fallback do
{:module, module} -> apply(module, :call, [conn, response]) |> halt()
end
end
end
17 changes: 0 additions & 17 deletions lib/radiator_web/graphql/admin/resolvers/editor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -284,16 +284,6 @@ defmodule RadiatorWeb.GraphQL.Admin.Resolvers.Editor do
{:ok, AudioMeta.list_chapters(audio)}
end

def set_episode_chapters(_parent, %{id: id, chapters: chapters, type: type}, %{
context: %{current_user: user}
}) do
with_audio user, id do
fn audio ->
AudioMeta.set_chapters(audio, chapters, String.to_existing_atom(type))
end
end
end

def get_audio_files(audio = %Audio{}, _args, %{context: %{current_user: user}}) do
{:ok, Editor.list_audio_files(user, audio)}
end
Expand All @@ -316,13 +306,6 @@ defmodule RadiatorWeb.GraphQL.Admin.Resolvers.Editor do
end
end

def get_chapters(%Audio{} = audio, _, _) do
chapter_query = Radiator.AudioMeta.Chapter.ordered_query()
audio = Radiator.Repo.preload(audio, chapters: chapter_query)

{:ok, audio.chapters}
end

def get_duration(%Episode{audio: audio}, _, _) do
{:ok, audio.duration}
end
Expand Down
2 changes: 1 addition & 1 deletion lib/radiator_web/graphql/admin/types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ defmodule RadiatorWeb.GraphQL.Admin.Types do
arg :order, type: :sort_order, default_value: :asc

# resolve dataloader(Radiator.AudioMeta, :chapters)
resolve &Resolvers.Editor.get_chapters/3
resolve &Resolvers.Editor.list_chapters/3
end

field :episodes, list_of(:episode) do
Expand Down
10 changes: 0 additions & 10 deletions lib/radiator_web/graphql/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -311,15 +311,5 @@ defmodule RadiatorWeb.GraphQL.Schema do
middleware Middleware.RequireAuthentication
resolve &Admin.Resolvers.Editor.delete_episode/3
end

@desc "Set chapters for an episode"
field :set_chapters, type: :audio do
arg :id, non_null(:id)
arg :chapters, non_null(:string)
arg :type, non_null(:string)

middleware Middleware.RequireAuthentication
resolve &Admin.Resolvers.Editor.set_episode_chapters/3
end
end
end
7 changes: 6 additions & 1 deletion lib/radiator_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@ defmodule RadiatorWeb.Router do
end

resources "/episodes", EpisodeController, only: [:show, :create, :update, :delete]
resources "/audios", AudioController, only: [:show, :create, :update, :delete]

resources "/audios", AudioController, only: [:show, :create, :update, :delete] do
resources "/chapters", ChaptersController,
param: "start",
only: [:show, :create, :update, :delete]
end

# todo: pluralize
resources "/audio_file", AudioFileController, only: [:show, :create]
Expand Down
25 changes: 25 additions & 0 deletions lib/radiator_web/views/api/chapters_view.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule RadiatorWeb.Api.ChaptersView do
use RadiatorWeb, :view

alias HAL.{Document, Link}
alias Radiator.AudioMeta.Chapter

def render("show.json", assigns) do
render(__MODULE__, "chapter.json", assigns)
end

def render("chapter.json", %{conn: conn, chapter: chapter, audio: audio}) do
%Document{}
|> Document.add_link(%Link{
rel: "self",
href: Routes.api_audio_chapters_path(conn, :show, audio.id, chapter.start)
})
|> Document.add_properties(%{
audio_id: chapter.audio_id,
start: chapter.start,
title: chapter.title,
link: chapter.link,
image: Chapter.image_url(chapter)
})
end
end
8 changes: 4 additions & 4 deletions priv/repo/migrations/0600_create_chapters.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ defmodule Radiator.Repo.Migrations.CreateChapters do
use Ecto.Migration

def change do
create table(:chapters) do
add :start, :integer
create table(:chapters, primary_key: false) do
add :audio_id, references(:audios, on_delete: :delete_all), primary_key: true

add :start, :integer, primary_key: true
add :title, :text
add :link, :text
add :image, :string

add :audio_id, references(:audios, on_delete: :delete_all)
end
end
end
Loading

0 comments on commit 4eacb68

Please sign in to comment.