├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── ja_resource.ex └── ja_resource │ ├── attributes.ex │ ├── create.ex │ ├── delete.ex │ ├── index.ex │ ├── model.ex │ ├── plug.ex │ ├── record.ex │ ├── records.ex │ ├── repo.ex │ ├── serializable.ex │ ├── show.ex │ └── update.ex ├── mix.exs ├── mix.lock └── test ├── ja_resource ├── attributes_test.exs ├── create_test.exs ├── delete_test.exs ├── index_test.exs ├── model_test.exs ├── plug_test.exs ├── record_test.exs ├── records_test.exs ├── repo_test.exs ├── serializable_test.exs ├── show_test.exs └── update_test.exs ├── ja_resource_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.2.0 4 | - 1.3.0 5 | otp_release: 6 | - 18.0 7 | after_script: 8 | - mix deps.get --only docs 9 | - MIX_ENV=docs mix inch.report 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Agilion Apps LLC, Alan Peabody, and ja_resource Contributors 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JaResource 2 | 3 | [![Build Status](https://travis-ci.org/vt-elixir/ja_resource.svg?branch=master)](https://travis-ci.org/vt-elixir/ja_resource) 4 | [![Hex Version](https://img.shields.io/hexpm/v/ja_resource.svg)](https://hex.pm/packages/ja_resource) 5 | 6 | A behaviour to reduce boilerplate code in your JSON-API compliant Phoenix 7 | controllers without sacrificing flexibility. 8 | 9 | Exposing a resource becomes as simple as: 10 | 11 | ```elixir 12 | defmodule MyApp.V1.PostController do 13 | use MyApp.Web, :controller 14 | use JaResource # or add to web/web.ex 15 | plug JaResource 16 | end 17 | ``` 18 | 19 | JaResource intercepts requests for index, show, create, update, and delete 20 | actions and dispatches them through behaviour callbacks. Most resources need 21 | only customize a few callbacks. It is a webmachine like approach to building 22 | APIs on top of Phoenix. 23 | 24 | JaResource is built to work in conjunction with sister library 25 | [JaSerializer](https://github.com/vt-elixir/ja_serializer). JaResource 26 | handles the controller side of things while JaSerializer is focused exclusively 27 | on view logic. 28 | 29 | See [Usage](#usage) for more details on customizing and restricting endpoints. 30 | 31 | ## Rationale 32 | 33 | JaResource lets you focus on the data in your APIs, instead of worrying about 34 | response status, rendering validation errors, and inserting changesets. You get 35 | robust patterns and while reducing maintenance overhead. 36 | 37 | At Agilion we value moving quickly while developing quality applications. This 38 | library has come out of our experience building many APIs in a variety of 39 | fields. 40 | 41 | ## Installation 42 | 43 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed by: 44 | 45 | 1. Adding ja_resource to your list of dependencies in `mix.exs`: 46 | 47 | def deps do 48 | [{:ja_resource, "~> 0.1.0"}] 49 | end 50 | 51 | 2. Ensuring ja_resource is started before your application: 52 | 53 | def application do 54 | [applications: [:ja_resource]] 55 | end 56 | 57 | 3. ja_resource can be configured to execute queries on a given repo. While not required, we encourage doing so to preserve clarity: 58 | 59 | config :ja_resource, 60 | repo: MyApp.Repo 61 | 62 | 4. JaSerializer / JSON-API setup. JaResource is built to work with JaSerializer. Please refer to https://github.com/vt-elixir/ja_serializer#phoenix-usage to setup Plug and Phoenix for JaSerializer and JaResource. 63 | 64 | 65 | ## Usage 66 | 67 | For the most simplistic resources JaSerializer lets you replace hundreds of 68 | lines of boilerplate with a simple use and plug statements. 69 | 70 | The JaResource plug intercepts requests for standard actions and queries, 71 | filters, create changesets, applies changesets, responds appropriately and 72 | more all for you. 73 | 74 | Customizing each action just becomes implementing the callback relevant to 75 | what functionality you want to change. 76 | 77 | To expose index, show, update, create, and delete of the `MyApp.Post` model 78 | with no restrictions: 79 | 80 | ```elixir 81 | defmodule MyApp.V1.PostController do 82 | use MyApp.Web, :controller 83 | use JaResource # Optionally put in web/web.ex 84 | plug JaResource 85 | end 86 | ``` 87 | 88 | You can optionally prevent JaResource from intercepting actions completely as 89 | needed: 90 | 91 | ```elixir 92 | defmodule MyApp.V1.PostsController do 93 | use MyApp.Web, :controller 94 | use JaResource 95 | plug JaResource, except: [:delete] 96 | 97 | # Standard Phoenix Delete 98 | def delete(conn, params) do 99 | # Custom delete logic 100 | end 101 | end 102 | ``` 103 | 104 | And because JaResource is just implementing actions, you can still use plug 105 | filters just like in normal Phoenix controllers, however you will want to 106 | call the JaResource plug last. 107 | 108 | ```elixir 109 | defmodule MyApp.V1.PostsController do 110 | use MyApp.Web, :controller 111 | use JaResource 112 | 113 | plug MyApp.Authenticate when action in [:create, :update, :delete] 114 | plug JaResource 115 | end 116 | ``` 117 | 118 | You are also free to define any custom actions in your controller, JaResource 119 | will not interfere with them at all. 120 | 121 | ```elixir 122 | defmodule MyApp.V1.PostsController do 123 | use MyApp.Web, :controller 124 | use JaResource 125 | plug JaResource 126 | 127 | def publish(conn, params) do 128 | # Custom action logic 129 | end 130 | end 131 | ``` 132 | 133 | ### Changing the model exposed 134 | 135 | By default JaResource parses the controller name to determine the model exposed 136 | by the controller. `MyApp.UserController` will expose the `MyApp.User` model, 137 | `MyApp.API.V1.CommentController` will expose the `MyApp.Comment` model. 138 | 139 | This can easily be overridden by defining the `model/0` callback: 140 | 141 | ```elixir 142 | defmodule MyApp.V1.PostsController do 143 | use MyApp.Web, :controller 144 | use JaResource 145 | 146 | def model, do: MyApp.Models.BlogPost 147 | end 148 | ``` 149 | 150 | ### Customizing records returned 151 | 152 | Many applications need to expose only subsets of a resource to a given user, 153 | those they have access to or maybe just models that are not soft deleted. 154 | JaResource allows you to define the `records/1` and `record/2` 155 | 156 | `records/1` is used by index, show, update, and delete requests to get the base 157 | query of records. Many controllers will override this: 158 | 159 | ```elixir 160 | defmodule MyApp.V1.MyPostController do 161 | use MyApp.Web, :controller 162 | use JaResource 163 | 164 | def model, do: MyApp.Post 165 | def records(%Plug.Conn{assigns: %{user_id: user_id}}) do 166 | model 167 | |> where([p], p.author_id == ^user_id) 168 | end 169 | end 170 | ``` 171 | 172 | `record/2` receives the `conn` and the id param and returns a 173 | single record for use in show, update, and delete. 174 | The default implementation calls `records/1` with the `conn`, then narrows the query to find only the record with the expected id. 175 | This is less common to customize, but may be useful if using non-id fields in the url: 176 | 177 | ```elixir 178 | defmodule MyApp.V1.PostController do 179 | use MyApp.Web, :controller 180 | use JaResource 181 | 182 | def record(conn, slug_as_id) do 183 | conn 184 | |> records 185 | |> MyApp.Repo.get_by(slug: slug_as_id) 186 | end 187 | end 188 | ``` 189 | 190 | ### 'Handle' Actions 191 | 192 | Every action not excluded defines a default `handle_` variant which receives 193 | pre-processed data and is expected to return an Ecto query or record. All of 194 | the handle calls may also return a conn (including the result of a render 195 | call). 196 | 197 | An example of customizing the index and show actions (instead of customizing 198 | `records/1` and `record/2`) would look something like this: 199 | 200 | ```elixir 201 | defmodule MyApp.V1.PostController do 202 | use MyApp.Web, :controller 203 | use JaResource 204 | 205 | def handle_index(conn, _params) do 206 | case conn.assigns[:user] do 207 | nil -> where(Post, [p], p.is_published == true) 208 | u -> Post # all posts 209 | end 210 | end 211 | 212 | def handle_show(conn, id) do 213 | Repo.get_by(Post, slug: id) 214 | end 215 | end 216 | ``` 217 | 218 | ### Filtering and Sorting 219 | 220 | The handle_index has complimentary callbacks filter/4 and sort/4. These two 221 | callbacks are called once for each value in the related param. The filtering 222 | and sorting is done on the results of your `handle_index/2` callback (which 223 | defaults to the results of your `records/1` callback). 224 | 225 | For example, given the following request: 226 | 227 | `GET /v1/articles?filter[category]=dogs&filter[favourite-snack]=cheese&sort=-published` 228 | 229 | You would implement the following callbacks: 230 | 231 | ```elixir 232 | defmodule MyApp.ArticleController do 233 | use MyApp.Web, :controller 234 | use JaSerializer 235 | 236 | def filter(_conn, query, "category", category) do 237 | where(query, category: ^category) 238 | end 239 | 240 | def filter(_conn, query, "favourite_snack", snack) do 241 | where(query, favourite_snack: ^favourite_snack) 242 | en 243 | 244 | def sort(_conn, query, "published", direction) do 245 | order_by(query, [{^direction, :inserted_at}]) 246 | end 247 | end 248 | ``` 249 | 250 | Note that in the case of `filter[favourite-snack]` JaResource has already helpfully converted the filter param's name from dasherized to underscore (or from [whatever you configured](https://github.com/vt-elixir/ja_serializer#key-format-for-attribute-relationship-and-query-param) your API to use). 251 | 252 | ### Paginate 253 | 254 | The handle_index_query/2 can be used to apply query params and render_index/3 to serialize meta tag. 255 | 256 | For example, given the following request: 257 | 258 | `GET /v1/articles?page[number]=1&page[size]=10` 259 | 260 | You would implement the following callbacks: 261 | 262 | ```elixir 263 | defmodule MyApp.ArticleController do 264 | use MyApp.Web, :controller 265 | use JaSerializer 266 | 267 | def handle_index_query(%{query_params: params}, query) do 268 | number = String.to_integer(params["page"]["number"]) 269 | size = String.to_integer(params["page"]["size"]) 270 | total = from(t in subquery(query), select: count("*")) |> repo().one() 271 | 272 | records = 273 | query 274 | |> limit(^(number + 1)) 275 | |> offset(^(number * size)) 276 | |> repo().all() 277 | 278 | %{ 279 | page: %{ 280 | number: number, 281 | size: size 282 | }, 283 | total: total, 284 | records: records 285 | } 286 | end 287 | 288 | def render_index(conn, paginated, opts) do 289 | conn 290 | |> Phoenix.Controller.render( 291 | :index, 292 | data: paginated.records, 293 | opts: opts ++ [ 294 | meta: %{ 295 | page: paginated.page, 296 | total: paginated.total 297 | } 298 | ] 299 | ) 300 | end 301 | end 302 | ``` 303 | 304 | ### Creating and Updating 305 | 306 | Like index and show, customizing creating and updating resources can be done 307 | with the `handle_create/2` and `handle_update/3` actions, however if just 308 | customizing what attributes to use, prefer `permitted_attributes/3`. 309 | 310 | For example: 311 | 312 | ```elixir 313 | defmodule MyApp.V1.PostController do 314 | use MyApp.Web, :controller 315 | use JaResource 316 | 317 | def permitted_attributes(conn, attrs, :create) do 318 | attrs 319 | |> Map.take(~w(title body type category_id)) 320 | |> Map.merge("author_id", conn.assigns[:current_user]) 321 | end 322 | 323 | def permitted_attributes(_conn, attrs, :update) do 324 | Map.take(attrs, ~w(title body type category_id)) 325 | end 326 | end 327 | ``` 328 | 329 | Note: The attributes map passed into `permitted_attributes` is a "flattened" 330 | version including the values at `data/attributes`, `data/type` and any 331 | relationship values in `data/relationships/[name]/data/id` as `name_id`. 332 | 333 | #### Create 334 | 335 | Customizing creation can be done with the `handle_create/2` function. 336 | 337 | ```elixir 338 | defmodule MyApp.V1.PostController do 339 | use MyApp.Web, :controller 340 | use JaResource 341 | 342 | def handle_create(conn, attributes) do 343 | Post.publish_changeset(%Post{}, attributes) 344 | end 345 | end 346 | ``` 347 | 348 | The attributes argument is the result of the `permitted_attributes` function. 349 | 350 | If this function returns a changeset it will be inserted and errors rendered if 351 | required. It may also return a model or validation errors for rendering 352 | or a %Plug.Conn{} for total rendering control. 353 | 354 | By default this will call `changeset/2` on the model defined by `model/0`. 355 | 356 | #### Update 357 | 358 | Customizing update can be done with the `handle_update/3` function. 359 | 360 | ```elixir 361 | defmodule MyApp.V1.PostController do 362 | use MyApp.Web, :controller 363 | use JaResource 364 | 365 | def handle_update(conn, post, attributes) do 366 | current_user_id = conn.assigns[:current_user].id 367 | case post.author_id do 368 | ^current_user_id -> {:error, author_id: "you can only edit your own posts"} 369 | _ -> Post.changeset(post, attributes, :update) 370 | end 371 | end 372 | end 373 | ``` 374 | 375 | If this function returns a changeset it will be inserted and errors rendered if 376 | required. It may also return a model or validation errors for rendering 377 | or a %Plug.Conn{} for total rendering control. 378 | 379 | The record argument (`post` in the above example) is the record found by the 380 | `record/3` callback. If `record/3` can not find a record it will be nil. 381 | 382 | The attributes argument is the result of the `permitted_attributes` function. 383 | 384 | By default this will call `changeset/2` on the model defined by `model/0`. 385 | 386 | #### Delete 387 | 388 | Customizing delete can be done with the `handle_delete/2` function. 389 | 390 | ```elixir 391 | def handle_delete(conn, post) do 392 | case conn.assigns[:user] do 393 | %{is_admin: true} -> super(conn, post) 394 | _ -> send_resp(conn, 401, "nope") 395 | end 396 | end 397 | ``` 398 | 399 | The record argument (`post` in the above example) is the record found by the 400 | `record/2` callback. If `record/2` can not find a record it will be nil. 401 | 402 | ### Custom responses 403 | 404 | It is possible to override the default responses for create and update actions 405 | in both the success and invalid cases. 406 | 407 | #### Create 408 | 409 | Customizing the create response can be done with the `render_create/2` and 410 | `handle_invalid_create/2` functions. For example: 411 | 412 | ```elixir 413 | defmodule MyApp.V1.PostController do 414 | use MyApp.Web, :controller 415 | use JaResource 416 | 417 | def render_create(conn, model) do 418 | conn 419 | |> Plug.Conn.put_status(:ok) 420 | |> Phoenix.Controller.render(:show, data: model) 421 | end 422 | 423 | def handle_invalid_create(conn, errors), 424 | conn 425 | |> Plug.Conn.put_status(401) 426 | |> Phoenix.Controller.render(:errors, data: errors) 427 | end 428 | end 429 | ``` 430 | 431 | ### Update 432 | 433 | Customizing the update response can be done with the `render_update/2` and 434 | `handle_invalid_update/2` functions. For example: 435 | 436 | ```elixir 437 | defmodule MyApp.V1.PostController do 438 | use MyApp.Web, :controller 439 | use JaResource 440 | 441 | def render_update(conn, model) do 442 | conn 443 | |> Plug.Conn.put_status(:created) 444 | |> Phoenix.Controller.render(:show, data: model) 445 | end 446 | 447 | def handle_invalid_update(conn, errors) do 448 | conn 449 | |> Plug.Conn.put_status(401) 450 | |> Phoenix.Controller.render(:errors, data: errors) 451 | end 452 | end 453 | ``` 454 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :ja_resource, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:ja_resource, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/ja_resource.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource do 2 | @type record :: map() | Ecto.Schema.t 3 | @type records :: module | Ecto.Query.t | list(record) 4 | @type params :: map() 5 | @type attributes :: map() 6 | @type id :: String.t 7 | @type validation_errors :: {:error, Ecto.Changeset.t} 8 | 9 | @moduledoc """ 10 | When used, includes all restful actions behaviours. Also a plug. 11 | 12 | Example usage in phoenix controller: 13 | 14 | defmodule Example.ArticleController do 15 | use Example.Web, :controller 16 | use JaResource 17 | plug JaResource, except: [:create] 18 | end 19 | 20 | See JaResource.Plug for plug options and documentation. 21 | 22 | See the "action" behaviours for info on customizing each behaviour: 23 | 24 | * JaResource.Index 25 | * JaResource.Show 26 | * JaResource.Create 27 | * JaResource.Update 28 | * JaResource.Delete 29 | 30 | """ 31 | 32 | defmacro __using__(_opts) do 33 | quote do 34 | use JaResource.Index 35 | use JaResource.Show 36 | use JaResource.Create 37 | use JaResource.Update 38 | use JaResource.Delete 39 | end 40 | end 41 | 42 | @behaviour Plug 43 | defdelegate init(opts), to: JaResource.Plug 44 | defdelegate call(conn, opts), to: JaResource.Plug 45 | end 46 | -------------------------------------------------------------------------------- /lib/ja_resource/attributes.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Attributes do 2 | @moduledoc """ 3 | Provides the `permitted_attributes/3` callback used for filtering attributes. 4 | 5 | This behaviour is used by the following JaResource actions: 6 | 7 | * JaResource.Delete 8 | * JaResource.Create 9 | """ 10 | 11 | @doc """ 12 | Used to determine which attributes are permitted during create and update. 13 | 14 | The attributes map (the second argument) is a "flattened" version including 15 | the values at `data/attributes`, `data/type` and any relationship values in 16 | `data/relationships/[name]/data/id` as `name_id`. 17 | 18 | The third argument is the atom of the action being called. 19 | 20 | Example: 21 | 22 | defmodule MyApp.V1.PostController do 23 | use MyApp.Web, :controller 24 | use JaResource 25 | 26 | def permitted_attributes(conn, attrs, :create) do 27 | attrs 28 | |> Map.take(~w(title body type category_id)) 29 | |> Map.merge("author_id", conn.assigns[:current_user]) 30 | end 31 | 32 | def permitted_attributes(_conn, attrs, :update) do 33 | Map.take(attrs, ~w(title body type category_id)) 34 | end 35 | end 36 | 37 | """ 38 | @callback permitted_attributes(Plug.Conn.t, JaResource.attributes, :update | :create) :: JaResource.attributes 39 | 40 | defmacro __using__(_) do 41 | quote do 42 | unless JaResource.Attributes in @behaviour do 43 | @behaviour JaResource.Attributes 44 | 45 | def permitted_attributes(_conn, attrs, _), do: attrs 46 | 47 | defoverridable [permitted_attributes: 3] 48 | end 49 | end 50 | end 51 | 52 | @doc false 53 | def from_params(%{"data" => data}) do 54 | attrs = data["attributes"] || %{} 55 | 56 | data 57 | |> parse_relationships 58 | |> Map.merge(attrs) 59 | |> Map.put_new("type", data["type"]) 60 | end 61 | 62 | defp parse_relationships(%{"relationships" => nil}) do 63 | %{} 64 | end 65 | 66 | defp parse_relationships(%{"relationships" => rels}) do 67 | Enum.reduce rels, %{}, fn 68 | ({name, %{"data" => nil}}, rel) -> 69 | Map.put(rel, "#{name}_id", nil) 70 | ({name, %{"data" => %{"id" => id}}}, rel) -> 71 | Map.put(rel, "#{name}_id", id) 72 | ({name, %{"data" => ids}}, rel) when is_list(ids) -> 73 | Map.put(rel, "#{name}_ids", Enum.map(ids, &(&1["id"]))) 74 | end 75 | end 76 | 77 | defp parse_relationships(_) do 78 | %{} 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/ja_resource/create.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Create do 2 | @moduledoc """ 3 | Defines a behaviour for creating a resource and the function to execute it. 4 | 5 | It relies on (and uses): 6 | 7 | * JaResource.Repo 8 | * JaResource.Model 9 | * JaResource.Attributes 10 | 11 | When used JaResource.Create defines the following overrideable callbacks: 12 | 13 | * handle_create/2 14 | * handle_invalid_create/2 15 | * render_create/2 16 | * JaResource.Attributes.permitted_attributes/3 17 | * JaResource.Repo.repo/1 18 | 19 | """ 20 | 21 | @doc """ 22 | Returns an unpersisted changeset or persisted model of the newly created object. 23 | 24 | Default implementation returns the results of calling 25 | `Model.changeset(%Model{}, attrs)` where Model is the model defined by the 26 | `JaResource.Model.model/0` callback. 27 | 28 | The attributes argument is the result of the `permitted_attributes` function. 29 | 30 | `handle_create/2` can return an %Ecto.Changeset, an Ecto.Schema struct, 31 | a list of errors (`{:error, [email: "is not valid"]}` or a conn with 32 | any response/body. 33 | 34 | Example custom implementation: 35 | 36 | def handle_create(_conn, attributes) do 37 | Post.changeset(%Post{}, attributes, :create_and_publish) 38 | end 39 | 40 | """ 41 | @callback handle_create(Plug.Conn.t, JaResource.attributes) :: Plug.Conn.t | Ecto.Changeset.t | JaResource.record | {:ok, JaResource.record} | {:error, JaResource.validation_errors} 42 | 43 | @doc """ 44 | Returns a `Plug.Conn` in response to errors during create. 45 | 46 | Default implementation sets the status to `:unprocessable_entity` and renders 47 | the error messages provided. 48 | """ 49 | @callback handle_invalid_create(Plug.Conn.t, Ecto.Changeset.t) :: Plug.Conn.t 50 | 51 | @doc """ 52 | Returns a `Plug.Conn` in response to successful create. 53 | 54 | Default implementation sets the status to `:created` and renders the view. 55 | """ 56 | @callback render_create(Plug.Conn.t, JaResource.record) :: Plug.Conn.t 57 | 58 | defmacro __using__(_) do 59 | quote do 60 | @behaviour JaResource.Create 61 | use JaResource.Repo 62 | use JaResource.Attributes 63 | import Plug.Conn 64 | 65 | def handle_create(_conn, attributes) do 66 | __MODULE__.model.changeset(__MODULE__.model.__struct__, attributes) 67 | end 68 | 69 | def handle_invalid_create(conn, errors) do 70 | conn 71 | |> put_status(:unprocessable_entity) 72 | |> Phoenix.Controller.render(:errors, data: errors) 73 | end 74 | 75 | def render_create(conn, model) do 76 | conn 77 | |> put_status(:created) 78 | |> Phoenix.Controller.render(:show, data: model) 79 | end 80 | 81 | defoverridable [handle_create: 2, handle_invalid_create: 2, render_create: 2] 82 | end 83 | end 84 | 85 | @doc """ 86 | Creates a resource given a module using Create and a connection. 87 | 88 | Create.call(ArticleController, conn) 89 | 90 | Dispatched by JaResource.Plug when phoenix action is create. 91 | """ 92 | def call(controller, conn) do 93 | merged = JaResource.Attributes.from_params(conn.params) 94 | attributes = controller.permitted_attributes(conn, merged, :create) 95 | conn 96 | |> controller.handle_create(attributes) 97 | |> JaResource.Create.insert(controller) 98 | |> JaResource.Create.respond(conn, controller) 99 | end 100 | 101 | @doc false 102 | def insert(%Ecto.Changeset{} = changeset, controller) do 103 | controller.repo().insert(changeset) 104 | end 105 | if Code.ensure_loaded?(Ecto.Multi) do 106 | def insert(%Ecto.Multi{} = multi, controller) do 107 | controller.repo().transaction(multi) 108 | end 109 | end 110 | def insert(other, _controller), do: other 111 | 112 | @doc false 113 | def respond(%Plug.Conn{} = conn, _old_conn, _), do: conn 114 | def respond({:error, errors}, conn, controller), do: controller.handle_invalid_create(conn, errors) 115 | def respond({:error, _name, errors, _changes}, conn, controller), do: controller.handle_invalid_create(conn, errors) 116 | def respond({:ok, model}, conn, controller), do: controller.render_create(conn, model) 117 | def respond(model, conn, controller), do: controller.render_create(conn, model) 118 | end 119 | -------------------------------------------------------------------------------- /lib/ja_resource/delete.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Delete do 2 | import Plug.Conn 3 | 4 | @moduledoc """ 5 | Defines a behaviour for deleting a resource and the function to execute it. 6 | 7 | It relies on (and uses): 8 | 9 | * JaResource.Repo 10 | * JaResource.Record 11 | 12 | When used JaResource.Delete defines the `delete/2` action suitable for 13 | handling json-api requests. 14 | 15 | To customize the behaviour of the update action the following callbacks can 16 | be implemented: 17 | 18 | * handle_delete/2 19 | * JaResource.Record.record/2 20 | * JaResource.Repo.repo/0 21 | 22 | """ 23 | 24 | @doc """ 25 | Returns an unpersisted changeset or persisted model representing the newly updated model. 26 | 27 | Receives the conn and the record as found by `record/2`. 28 | 29 | Default implementation returns the results of calling `Repo.delete(record)`. 30 | 31 | Example custom implementation: 32 | 33 | def handle_delete(conn, record) do 34 | case conn.assigns[:user] do 35 | %{is_admin: true} -> super(conn, record) 36 | _ -> send_resp(conn, 401, "nope") 37 | end 38 | end 39 | 40 | """ 41 | @callback handle_delete(Plug.Conn.t, JaResource.record) :: Plug.Conn.t | JaResource.record | nil 42 | 43 | defmacro __using__(_) do 44 | quote do 45 | use JaResource.Repo 46 | use JaResource.Record 47 | @behaviour JaResource.Delete 48 | def handle_delete(conn, nil), do: nil 49 | def handle_delete(conn, model) do 50 | model 51 | |> __MODULE__.model.changeset(%{}) 52 | |> __MODULE__.repo().delete 53 | end 54 | 55 | defoverridable [handle_delete: 2] 56 | end 57 | end 58 | 59 | @doc """ 60 | Execute the delete action on a given module implementing Delete behaviour and conn. 61 | """ 62 | def call(controller, conn) do 63 | model = controller.record(conn, conn.params["id"]) 64 | 65 | conn 66 | |> controller.handle_delete(model) 67 | |> JaResource.Delete.respond(conn) 68 | end 69 | 70 | @doc false 71 | def respond(nil, conn), do: not_found(conn) 72 | def respond(%Plug.Conn{} = conn, _old_conn), do: conn 73 | def respond({:ok, _model}, conn), do: deleted(conn) 74 | def respond({:errors, errors}, conn), do: invalid(conn, errors) 75 | def respond({:error, errors}, conn), do: invalid(conn, errors) 76 | def respond(_model, conn), do: deleted(conn) 77 | 78 | defp not_found(conn) do 79 | conn 80 | |> send_resp(:not_found, "") 81 | end 82 | 83 | defp deleted(conn) do 84 | conn 85 | |> send_resp(:no_content, "") 86 | end 87 | 88 | defp invalid(conn, errors) do 89 | conn 90 | |> put_status(:unprocessable_entity) 91 | |> Phoenix.Controller.render(:errors, data: errors) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/ja_resource/index.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Index do 2 | import Plug.Conn, only: [put_status: 2] 3 | 4 | @moduledoc """ 5 | Provides `handle_index/2`, `filter/4` and `sort/4` callbacks. 6 | 7 | It relies on (and uses): 8 | 9 | * JaResource.Repo 10 | * JaResource.Records 11 | * JaResource.Serializable 12 | 13 | When used JaResource.Index defines the `index/2` action suitable for handling 14 | json-api requests. 15 | 16 | To customize the behaviour of the index action the following callbacks can be implemented: 17 | 18 | * handle_index/2 19 | * render_index/3 20 | * filter/4 21 | * sort/4 22 | * JaResource.Records.records/1 23 | * JaResource.Repo.repo/0 24 | * JaResource.Serializable.serialization_opts/3 25 | 26 | """ 27 | 28 | @doc """ 29 | Returns the models to be represented by this resource. 30 | 31 | Default implementation is the result of the JaResource.Records.records/2 32 | callback. Usually a module or an `%Ecto.Query{}`. 33 | 34 | The results of this callback are passed to the filter and sort callbacks before the query is executed. 35 | 36 | `handle_index/2` can alternatively return a conn with any response/body. 37 | 38 | Example custom implementation: 39 | 40 | def handle_index(conn, _params) do 41 | case conn.assigns[:user] do 42 | nil -> App.Post 43 | user -> User.own_posts(user) 44 | end 45 | end 46 | 47 | In most cases JaResource.Records.records/1, filter/4, and sort/4 are the 48 | better customization hooks. 49 | """ 50 | @callback handle_index(Plug.Conn.t(), map) :: Plug.Conn.t() | JaResource.records() 51 | 52 | @doc """ 53 | Callback executed for each `filter` param. 54 | 55 | For example, if you wanted to optionally filter on an Article's category and 56 | issue, your request url might look like: 57 | 58 | /api/articles?filter[category]=elixir&filter[issue]=12 59 | 60 | You would then want two callbacks: 61 | 62 | def filter(_conn, query, "category", category) do 63 | where(query, category: category) 64 | end 65 | 66 | def filter(_conn, query, "issue", issue_id) do 67 | where(query, issue_id: issue_id) 68 | end 69 | 70 | You can also use guards to whitelist a handeful of attributes: 71 | 72 | @filterable_attrs ~w(title category author_id issue_id) 73 | def filter(_conn, query, attr, val) when attr in @filterable_attrs do 74 | where(query, [{String.to_existing_atom(attr), val}]) 75 | end 76 | 77 | Anything not explicitly matched by your callbacks will be ignored. 78 | """ 79 | @callback filter(Plug.Conn.t(), JaResource.records(), String.t(), String.t()) :: 80 | JaResource.records() 81 | 82 | @doc """ 83 | Callback executed for each value in the sort param. 84 | 85 | Fourth argument is the direction as an atom, either `:asc` or `:desc` based 86 | upon the presence or not of a `-` prefix. 87 | 88 | For example if you wanted to sort by date then title your request url might 89 | look like: 90 | 91 | /api/articles?sort=-created,title 92 | 93 | You would then want two callbacks: 94 | 95 | def sort(_conn, query, "created", direction) do 96 | order_by(query, [{direction, :inserted_at}]) 97 | end 98 | 99 | def sort(_conn, query, "title", direction) do 100 | order_by(query, [{direction, :title}]) 101 | end 102 | 103 | Anything not explicitly matched by your callbacks will be ignored. 104 | """ 105 | @callback sort(Plug.Conn.t(), JaResource.records(), String.t(), :asc | :dsc) :: 106 | JaResource.records() 107 | 108 | @doc """ 109 | Callback executed to query repo. 110 | 111 | By default this just calls `all/2` on the repo. Can be customized for 112 | pagination, monitoring, etc. For example to paginate with Scrivener: 113 | 114 | def handle_index_query(%{query_params: qp}, query) do 115 | repo().paginate(query, qp["page"] || %{}) 116 | end 117 | 118 | """ 119 | @callback handle_index_query(Plug.Conn.t(), Ecto.Query.t() | module) :: any 120 | 121 | @doc """ 122 | Returns a `Plug.Conn` in response to successful update. 123 | 124 | Default implementation renders the view. 125 | """ 126 | @callback render_index(Plug.Conn.t(), JaResource.records(), list) :: Plug.Conn.t() 127 | 128 | @doc """ 129 | Execute the index action on a given module implementing Index behaviour and conn. 130 | """ 131 | def call(controller, conn) do 132 | conn 133 | |> controller.handle_index(conn.params) 134 | |> JaResource.Index.filter(conn, controller) 135 | |> JaResource.Index.sort(conn, controller) 136 | |> JaResource.Index.execute_query(conn, controller) 137 | |> JaResource.Index.respond(conn, controller) 138 | end 139 | 140 | defmacro __using__(_) do 141 | quote do 142 | use JaResource.Repo 143 | use JaResource.Records 144 | use JaResource.Serializable 145 | @behaviour JaResource.Index 146 | @before_compile JaResource.Index 147 | 148 | def handle_index_query(_conn, query), do: repo().all(query) 149 | 150 | def render_index(conn, models, opts) do 151 | conn 152 | |> Phoenix.Controller.render(:index, data: models, opts: opts) 153 | end 154 | 155 | def handle_index(conn, params), do: records(conn) 156 | 157 | defoverridable handle_index: 2, render_index: 3, handle_index_query: 2 158 | end 159 | end 160 | 161 | @doc false 162 | defmacro __before_compile__(_) do 163 | quote do 164 | def filter(_conn, results, _key, _val), do: results 165 | def sort(_conn, results, _key, _dir), do: results 166 | end 167 | end 168 | 169 | @doc false 170 | def filter(results, conn = %{params: %{"filter" => filters}}, resource) do 171 | filters 172 | |> Map.keys() 173 | |> Enum.reduce(results, fn k, acc -> 174 | resource.filter(conn, acc, k, filters[k]) 175 | end) 176 | end 177 | 178 | def filter(results, _conn, _controller), do: results 179 | 180 | @sort_regex ~r/(-?)(\S*)/ 181 | @doc false 182 | def sort(results, conn = %{params: %{"sort" => fields}}, controller) do 183 | fields 184 | |> String.split(",") 185 | |> Enum.reduce(results, fn field, acc -> 186 | case Regex.run(@sort_regex, field) do 187 | [_, "", field] -> controller.sort(conn, acc, field, :asc) 188 | [_, "-", field] -> controller.sort(conn, acc, field, :desc) 189 | end 190 | end) 191 | end 192 | 193 | def sort(results, _conn, _controller), do: results 194 | 195 | @doc false 196 | def execute_query(%Plug.Conn{} = conn, _conn, _controller), do: conn 197 | def execute_query(results, _conn, _controller) when is_list(results), do: results 198 | def execute_query(query, conn, controller), do: controller.handle_index_query(conn, query) 199 | 200 | @doc false 201 | def respond(%Plug.Conn{} = conn, _oldconn, _controller), do: conn 202 | def respond({:error, errors}, conn, _controller), do: error(conn, errors) 203 | 204 | def respond(models, conn, controller) do 205 | opts = controller.serialization_opts(conn, conn.query_params, models) 206 | controller.render_index(conn, models, opts) 207 | end 208 | 209 | defp error(conn, errors) do 210 | conn 211 | |> put_status(:internal_server_error) 212 | |> Phoenix.Controller.render(:errors, data: errors) 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /lib/ja_resource/model.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Model do 2 | @moduledoc """ 3 | Provides the `model/0` callback used to customize the resource served. 4 | 5 | This behaviour is used by all JaResource actions. 6 | """ 7 | 8 | @doc """ 9 | Must return the module implementing `Ecto.Schema` to be represented. 10 | 11 | Example: 12 | 13 | def model, do: MyApp.Models.Post 14 | 15 | Defaults to the name of the controller, for example the controller 16 | `MyApp.V1.PostController` would serve the `MyApp.Post` model. 17 | 18 | Used by the default implementations for `handle_create/2`, `handle_update/3`, 19 | and `records/1`. 20 | """ 21 | @callback model() :: module 22 | 23 | defmacro __using__(_) do 24 | quote do 25 | @behaviour JaResource.Model 26 | 27 | @inferred_model JaResource.Model.model_from_controller(__MODULE__) 28 | def model(), do: @inferred_model 29 | 30 | defoverridable [model: 0] 31 | end 32 | end 33 | 34 | def model_from_controller(module) do 35 | [_elixir, app | rest] = module 36 | |> Atom.to_string 37 | |> String.split(".") 38 | 39 | [controller | _ ] = Enum.reverse(rest) 40 | inferred = String.replace(controller, "Controller", "") 41 | 42 | String.to_atom("Elixir.#{app}.#{inferred}") 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/ja_resource/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Plug do 2 | import Plug.Conn 3 | alias Phoenix.Controller 4 | alias JaResource.{Index,Show,Create,Update,Delete} 5 | @behaviour Plug 6 | 7 | @moduledoc """ 8 | Implements a plug that dispatches Phoenix actions to JaResource action behaviours. 9 | 10 | You can optionally whitelist or blacklist the actions JaResource will respond 11 | to. Any actions outside of the standard index, show, create, update, and 12 | delete are always ignored and dispatched by Phoenix as usual. Any 13 | non-whitelisted or blacklisted actions are likewize passed to Phoenix as usual. 14 | 15 | For example: 16 | 17 | defmodule MyApp.V1.ArticleController do 18 | use MyApp.Web, :controller 19 | use JaResource 20 | plug JaResource, except: [:delete] 21 | # same as: 22 | # plug JaResource, only: [:index, :show, :create, :update] 23 | 24 | # Standard Phoenix Delete 25 | def delete(conn, params) do 26 | # Custom delete logic 27 | end 28 | 29 | # Non restful action 30 | def publish(conn, params) do 31 | # Custom publish logic 32 | end 33 | end 34 | 35 | When dispatching an action you must have implemented the action behaviours 36 | callbacks. This is typically done with `use JaResource` and customized. 37 | Alternatively you can use the individual actions, such as 38 | `use JaResource.Create`. You can even include just the behaviour and define 39 | all the callbacks yourself via `@behaviour JaResource.Create`. 40 | 41 | See the action behaviours to learn how to customize each action. 42 | """ 43 | 44 | @available [:index, :show, :create, :update, :delete] 45 | 46 | def init(opts) do 47 | allowed = cond do 48 | opts[:only] -> opts[:only] -- (opts[:only] -- @available) 49 | opts[:except] -> @available -- opts[:except] 50 | true -> @available 51 | end 52 | 53 | [allowed: allowed] 54 | end 55 | 56 | def call(conn, opts) do 57 | action = Controller.action_name(conn) 58 | controller = Controller.controller_module(conn) 59 | if action in opts[:allowed] do 60 | conn 61 | |> dispatch(controller, action) 62 | |> halt 63 | else 64 | conn 65 | end 66 | end 67 | 68 | defp dispatch(conn, controller, :index), do: Index.call(controller, conn) 69 | defp dispatch(conn, controller, :show), do: Show.call(controller, conn) 70 | defp dispatch(conn, controller, :create), do: Create.call(controller, conn) 71 | defp dispatch(conn, controller, :update), do: Update.call(controller, conn) 72 | defp dispatch(conn, controller, :delete), do: Delete.call(controller, conn) 73 | end 74 | -------------------------------------------------------------------------------- /lib/ja_resource/record.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Record do 2 | @moduledoc """ 3 | This behaviour is used by the following JaResource actions: 4 | 5 | * JaResource.Show 6 | * JaResource.Update 7 | * JaResource.Delete 8 | 9 | It relies on (and uses): 10 | 11 | * JaResource.Records 12 | 13 | """ 14 | 15 | @doc """ 16 | Used to get the subject of the current action 17 | 18 | Many/most controllers will override this: 19 | 20 | def record(%Plug.Conn{assigns: %{user_id: user_id}}, id) do 21 | model() 22 | |> where([p], p.author_id == ^user_id) 23 | |> Repo.get(id) 24 | end 25 | 26 | """ 27 | @callback record(Plug.Conn.t, JaResource.id) :: Plug.Conn.t | JaResource.record 28 | 29 | defmacro __using__(_) do 30 | quote do 31 | unless JaResource.Record in @behaviour do 32 | use JaResource.Records 33 | @behaviour JaResource.Record 34 | 35 | def record(conn, id) do 36 | conn 37 | |> records 38 | |> repo().get(id) 39 | end 40 | 41 | defoverridable [record: 2] 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ja_resource/records.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Records do 2 | @moduledoc """ 3 | Provides the `records/1` callback used for querying records to be served. 4 | 5 | This is typically the base query that you expose, often scoped to the 6 | current user. 7 | 8 | This behaviour is used by the following JaResource actions: 9 | 10 | * JaResource.Index 11 | * JaResource.Show 12 | * JaResource.Update 13 | * JaResource.Delete 14 | 15 | It relies on (and uses): 16 | 17 | * JaResource.Model 18 | 19 | """ 20 | 21 | @doc """ 22 | Used to get the base query of records. 23 | 24 | Many/most controllers will override this: 25 | 26 | def records(%Plug.Conn{assigns: %{user_id: user_id}}) do 27 | model() 28 | |> where([p], p.author_id == ^user_id) 29 | end 30 | 31 | Return value should be %Plug.Conn{} or an %Ecto.Query{}. 32 | """ 33 | @callback records(Plug.Conn.t) :: Plug.Conn.t | JaResource.records 34 | 35 | defmacro __using__(_) do 36 | quote do 37 | unless JaResource.Records in @behaviour do 38 | use JaResource.Model 39 | @behaviour JaResource.Records 40 | 41 | def records(_conn), do: model() 42 | 43 | defoverridable [records: 1] 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/ja_resource/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Repo do 2 | @doc """ 3 | Defines the module `use`-ing `Ecto.Repo` to be used by the controller. 4 | 5 | Defaults to the value set in config if present: 6 | 7 | config :ja_resource, 8 | repo: MyApp.Repo 9 | 10 | Default can be overridden per controller: 11 | 12 | def repo, do: MyApp.SecondaryRepo 13 | 14 | """ 15 | @callback repo() :: module 16 | defmacro __using__(_opts) do 17 | quote do 18 | unless JaResource.Repo in @behaviour do 19 | @behaviour JaResource.Repo 20 | unquote(default_repo()) 21 | end 22 | end 23 | end 24 | 25 | @doc false 26 | def default_repo do 27 | quote do 28 | if Application.get_env(:ja_resource, :repo) do 29 | def repo, do: Application.get_env(:ja_resource, :repo) 30 | defoverridable [repo: 0] 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/ja_resource/serializable.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Serializable do 2 | @moduledoc """ 3 | The `JaResource.Serializable` behavior is used to send serialization options 4 | such as `fields` and `include` to the serializer. 5 | 6 | It is `use`d by the `JaResource.Index` and `JaResource.Show` actions. 7 | 8 | `use` of this module defines a default implementation that passes `fields` 9 | and `include` params through un-touched. This may be overridden in your 10 | controller. For example: 11 | 12 | def serialization_opts(_conn, params, _models) do 13 | [ 14 | fields: params["fields"] || %{"post" => "title,body"} 15 | ] 16 | end 17 | 18 | As another example, the callback could be used to add a meta map to the JSON 19 | payload, such as for pagination info, when using scrivener. 20 | 21 | def serialization_opts(_conn, _params, models) do 22 | [ 23 | meta: %{ 24 | current_page: models.page_number, 25 | page_size: models.page_size, 26 | total_pages: models.total_pages, 27 | total_records: models.total_entries 28 | } 29 | ] 30 | end 31 | 32 | Note that `models` will be a Scrivener page struct, if `handle_index_query` was 33 | overriden for Scrivener pagination. 34 | 35 | """ 36 | 37 | @doc """ 38 | Converts full list of params into serialization opts. 39 | 40 | Typically this callback returns the list of fields and includes that were 41 | optionally requested by the stack. 42 | 43 | It can also be used to add a meta to the payload, 44 | such as for scrivener pagination, on an index endpoint. 45 | 46 | See http://github.com/AgilionApps/ja_serializer for option format. 47 | """ 48 | @callback serialization_opts(Plug.Conn.t, map, struct | list) :: Keyword.t 49 | 50 | defmacro __using__(_) do 51 | quote do 52 | unless JaResource.Serializable in @behaviour do 53 | @behaviour JaResource.Serializable 54 | 55 | def serialization_opts(_conn, %{"fields" => f, "include" => i}, _model_or_models), 56 | do: [include: i, fields: f] 57 | def serialization_opts(_conn, %{"include" => i}, _model_or_models), 58 | do: [include: i] 59 | def serialization_opts(_conn, %{"fields" => f}, _model_or_models), 60 | do: [fields: f] 61 | def serialization_opts(_conn, _params, _model_or_models), 62 | do: [] 63 | 64 | defoverridable [serialization_opts: 3] 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/ja_resource/show.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Show do 2 | import Plug.Conn 3 | 4 | @moduledoc """ 5 | Defines a behaviour for displaying a resource and the function to execute it. 6 | 7 | It relies on (and uses): 8 | 9 | * JaResource.Record 10 | * JaResource.Serializable 11 | 12 | When used JaResource.Show defines the `show/2` action suitable for handling 13 | json-api requests. 14 | 15 | To customize the behaviour of the show action the following callbacks can be implemented: 16 | 17 | * handle_show/2 18 | * render_index/2 19 | * JaResource.Record.record/2 20 | * JaResource.Record.records/1 21 | 22 | """ 23 | 24 | @doc """ 25 | Returns the model to be represented by this resource. 26 | 27 | Default implementation is the result of the JaResource.Record.record/2 28 | callback. 29 | 30 | `handle_show/2` can return nil to send a 404, a conn with any response/body, 31 | or a record to be serialized. 32 | 33 | Example custom implementation: 34 | 35 | def handle_show(conn, id) do 36 | Repo.get_by(Post, slug: id) 37 | end 38 | 39 | In most cases JaResource.Record.record/2 and JaResource.Records.records/1 are 40 | the better customization hooks. 41 | """ 42 | @callback handle_show(Plug.Conn.t(), JaResource.id()) :: Plug.Conn.t() | JaResource.record() 43 | 44 | @doc """ 45 | Returns a `Plug.Conn` in response to successful show. 46 | 47 | Default implementation renders the view. 48 | """ 49 | @callback render_show(Plug.Conn.t(), JaResource.record()) :: Plug.Conn.t() 50 | 51 | defmacro __using__(_) do 52 | quote do 53 | use JaResource.Record 54 | use JaResource.Serializable 55 | @behaviour JaResource.Show 56 | 57 | def handle_show(conn, id), do: record(conn, id) 58 | 59 | def render_show(conn, model) do 60 | conn 61 | |> Phoenix.Controller.render(:show, data: model) 62 | end 63 | 64 | defoverridable handle_show: 2, render_show: 2 65 | end 66 | end 67 | 68 | @doc """ 69 | Execute the show action on a given module implementing Show behaviour and conn. 70 | """ 71 | def call(controller, conn) do 72 | conn 73 | |> controller.handle_show(conn.params["id"]) 74 | |> JaResource.Show.respond(conn, controller) 75 | end 76 | 77 | @doc false 78 | def respond(%Plug.Conn{} = conn, _old_conn, _controller), do: conn 79 | 80 | def respond(nil, conn, _controller) do 81 | conn 82 | |> put_status(:not_found) 83 | |> Phoenix.Controller.render(:errors, 84 | data: %{status: 404, title: "Not Found", detail: "The resource was not found"} 85 | ) 86 | end 87 | 88 | def respond(model, conn, controller), do: controller.render_show(conn, model) 89 | end 90 | -------------------------------------------------------------------------------- /lib/ja_resource/update.ex: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Update do 2 | import Plug.Conn 3 | 4 | @moduledoc """ 5 | Defines a behaviour for updating a resource and the function to execute it. 6 | 7 | It relies on (and uses): 8 | 9 | * JaResource.Record 10 | * JaResource.Attributes 11 | 12 | When used JaResource.Update defines the `update/2` action suitable for 13 | handling json-api requests. 14 | 15 | To customize the behaviour of the update action the following callbacks can 16 | be implemented: 17 | 18 | * JaResource.Record.record/2 19 | * JaResource.Records.records/1 20 | * handle_update/3 21 | * handle_invalid_update/2 22 | * render_update/2 23 | * JaResource.Attributes.permitted_attributes/3 24 | 25 | """ 26 | 27 | @doc """ 28 | Returns an unpersisted changeset or persisted model representing the newly updated model. 29 | 30 | Receives the conn, the model as found by `record/2`, and the attributes 31 | argument from the `permitted_attributes` function. 32 | 33 | Default implementation returns the results of calling 34 | `Model.changeset(model, attrs)`. 35 | 36 | `handle_update/3` can return an %Ecto.Changeset, an Ecto.Schema struct, 37 | a list of errors (`{:error, [email: "is not valid"]}` or a conn with 38 | any response/body. 39 | 40 | Example custom implementation: 41 | 42 | def handle_update(conn, post, attributes) do 43 | current_user_id = conn.assigns[:current_user].id 44 | case post.author_id do 45 | ^current_user_id -> {:error, author_id: "you can only edit your own posts"} 46 | _ -> Post.changeset(post, attributes, :update) 47 | end 48 | end 49 | 50 | """ 51 | @callback handle_update(Plug.Conn.t, JaResource.record, JaResource.attributes) :: Plug.Conn.t | JaResource.record | nil 52 | 53 | @doc """ 54 | Returns a `Plug.Conn` in response to errors during update. 55 | 56 | Default implementation sets the status to `:unprocessable_entity` and renders 57 | the error messages provided. 58 | """ 59 | @callback handle_invalid_update(Plug.Conn.t, Ecto.Changeset.t) :: Plug.Conn.t 60 | 61 | @doc """ 62 | Returns a `Plug.Conn` in response to successful update. 63 | 64 | Default implementation renders the view. 65 | """ 66 | @callback render_update(Plug.Conn.t, JaResource.record) :: Plug.Conn.t 67 | 68 | defmacro __using__(_) do 69 | quote do 70 | use JaResource.Record 71 | use JaResource.Attributes 72 | @behaviour JaResource.Update 73 | 74 | def handle_update(conn, nil, _params), do: nil 75 | def handle_update(_conn, model, attributes) do 76 | __MODULE__.model.changeset(model, attributes) 77 | end 78 | 79 | def handle_invalid_update(conn, errors) do 80 | conn 81 | |> put_status(:unprocessable_entity) 82 | |> Phoenix.Controller.render(:errors, data: errors) 83 | end 84 | 85 | def render_update(conn, model) do 86 | conn 87 | |> Phoenix.Controller.render(:show, data: model) 88 | end 89 | 90 | defoverridable [handle_update: 3, handle_invalid_update: 2, render_update: 2] 91 | end 92 | end 93 | 94 | @doc """ 95 | Execute the update action on a given module implementing Update behaviour and conn. 96 | """ 97 | def call(controller, conn) do 98 | model = controller.record(conn, conn.params["id"]) 99 | merged = JaResource.Attributes.from_params(conn.params) 100 | attributes = controller.permitted_attributes(conn, merged, :update) 101 | 102 | conn 103 | |> controller.handle_update(model, attributes) 104 | |> JaResource.Update.update(controller) 105 | |> JaResource.Update.respond(conn, controller) 106 | end 107 | 108 | @doc false 109 | def update(%Ecto.Changeset{} = changeset, controller) do 110 | controller.repo().update(changeset) 111 | end 112 | if Code.ensure_loaded?(Ecto.Multi) do 113 | def update(%Ecto.Multi{} = multi, controller) do 114 | controller.repo().transaction(multi) 115 | end 116 | end 117 | def update(other, _controller), do: other 118 | 119 | @doc false 120 | def respond(%Plug.Conn{} = conn, _oldconn, _), do: conn 121 | def respond(nil, conn, _), do: send_resp(conn, :not_found, "") 122 | def respond({:error, errors}, conn, controller), do: controller.handle_invalid_update(conn, errors) 123 | def respond({:error, _name, errors, _changes}, conn, controller), do: controller.handle_invalid_update(conn, errors) 124 | def respond({:ok, model}, conn, controller), do: controller.render_update(conn, model) 125 | def respond(model, conn, controller), do: controller.render_update(conn, model) 126 | end 127 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ja_resource, 7 | version: "0.3.2", 8 | elixir: "~> 1.2", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | source_url: "https://github.com/vt-elixir/ja_resource", 12 | package: package(), 13 | description: description(), 14 | deps: deps() 15 | ] 16 | end 17 | 18 | # Configuration for the OTP application 19 | def application do 20 | [applications: [:logger, :phoenix]] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:ecto, "~> 3.0"}, 26 | {:plug, "~> 1.2"}, 27 | {:phoenix, "~> 1.1"}, 28 | {:ja_serializer, "~> 0.9"}, 29 | {:earmark, "~> 1.0.1", only: :dev}, 30 | {:ex_doc, "~> 0.13", only: :dev} 31 | ] 32 | end 33 | 34 | defp package do 35 | [ 36 | licenses: ["Apache 2.0"], 37 | maintainers: ["Alan Peabody", "Pete Brown"], 38 | links: %{ 39 | "GitHub" => "https://github.com/vt-elixir/ja_resource" 40 | } 41 | ] 42 | end 43 | 44 | defp description do 45 | """ 46 | A behaviour for defining JSON-API spec controllers in Phoenix. 47 | 48 | Lets you focus on your data, not on boilerplate controller code. Like Webmachine for Phoenix. 49 | """ 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.0.1", "2c2cd903bfdc3de3f189bd9a8d4569a075b88a8981ded9a0d95672f6e2b63141", [:mix], []}, 4 | "ecto": {:hex, :ecto, "3.0.1", "a26605ee7b243a754e6609d1c23da27bcb22823659b07bf03f9020da92a8e4f4", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.13.0", "aa2f8fe4c6136a2f7cfc0a7e06805f82530e91df00e2bff4b4362002b43ada65", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, 6 | "inflex": {:hex, :inflex, "1.7.0", "4466a34b7d8e871d8164619ba0f3b8410ec782e900f0ae1d3d27a5875a29532e", [:mix], [], "hexpm"}, 7 | "ja_serializer": {:hex, :ja_serializer, "0.11.0", "6c8ded7cfd4cd226812e97445bedd2f6d47e19c5d8b987f58cf552518c98fbd1", [:mix], [{:inflex, "~> 1.4", [repo: "hexpm", hex: :inflex, optional: false]}, {:plug, "> 1.0.0", [repo: "hexpm", hex: :plug, optional: false]}, {:poison, "~> 1.4 or ~> 2.0", [repo: "hexpm", hex: :poison, optional: false]}, {:scrivener, "~> 1.2 or ~> 2.0", [repo: "hexpm", hex: :scrivener, optional: true]}], "hexpm"}, 8 | "mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"}, 9 | "phoenix": {:hex, :phoenix, "1.4.0", "56fe9a809e0e735f3e3b9b31c1b749d4b436e466d8da627b8d82f90eaae714d2", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, 10 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.1", "6668d787e602981f24f17a5fbb69cc98f8ab085114ebfac6cc36e10a90c8e93c", [:mix], [], "hexpm"}, 11 | "plug": {:hex, :plug, "1.7.1", "8516d565fb84a6a8b2ca722e74e2cd25ca0fc9d64f364ec9dbec09d33eb78ccd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 13 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], [], "hexpm"}, 14 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 15 | } 16 | -------------------------------------------------------------------------------- /test/ja_resource/attributes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.AttributesTest do 2 | use ExUnit.Case 3 | 4 | defmodule DefaultController do 5 | use JaResource.Attributes 6 | end 7 | 8 | defmodule CustomController do 9 | use JaResource.Attributes 10 | 11 | def permitted_attributes(_conn, attrs, _) do 12 | Map.take(attrs, ~w(title)) 13 | end 14 | end 15 | 16 | test "permitted attributes default" do 17 | attrs = %{ 18 | "type" => "post", 19 | "title" => "a post", 20 | "category_id" => "1" 21 | } 22 | actual = DefaultController.permitted_attributes(%Plug.Conn{}, attrs, :update) 23 | assert actual == attrs 24 | end 25 | 26 | test "permitted attributes custom" do 27 | attrs = %{ 28 | "type" => "post", 29 | "title" => "a post", 30 | "category_id" => "1" 31 | } 32 | actual = CustomController.permitted_attributes(%Plug.Conn{}, attrs, :update) 33 | assert actual == %{"title" => "a post"} 34 | end 35 | 36 | test "formatting attributes from json-api params with relationships" do 37 | params = %{ 38 | "data" => %{ 39 | "id" => "1", 40 | "type" => "post", 41 | "attributes" => %{ 42 | "title" => "a post" 43 | }, 44 | "relationships" => %{ 45 | "category" => %{ 46 | "data" => %{"type" => "category", "id" => "1"} 47 | }, 48 | "tag" => %{ 49 | "data" => [ 50 | %{"type" => "tag", "id" => "1"}, 51 | %{"type" => "tag", "id" => "2"} 52 | ] 53 | } 54 | } 55 | } 56 | } 57 | merged = %{ 58 | "type" => "post", 59 | "title" => "a post", 60 | "category_id" => "1", 61 | "tag_ids" => ["1", "2"] 62 | } 63 | actual = JaResource.Attributes.from_params(params) 64 | assert actual == merged 65 | end 66 | 67 | test "formatting minimal attributes from json-api params" do 68 | params = %{ 69 | "data" => %{ 70 | "type" => "post", 71 | "attributes" => %{ 72 | "title" => "a post" 73 | } 74 | } 75 | } 76 | merged = %{ 77 | "type" => "post", 78 | "title" => "a post" 79 | } 80 | actual = JaResource.Attributes.from_params(params) 81 | assert actual == merged 82 | end 83 | 84 | test "formatting only relationships from json-api params" do 85 | params = %{ 86 | "data" => %{ 87 | "type" => "post", 88 | "relationships" => %{ 89 | "category" => %{ 90 | "data" => %{"type" => "category", "id" => "1"} 91 | } 92 | } 93 | } 94 | } 95 | merged = %{ 96 | "type" => "post", 97 | "category_id" => "1" 98 | } 99 | actual = JaResource.Attributes.from_params(params) 100 | assert actual == merged 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/ja_resource/create_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.CreateTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | alias JaResource.Create 5 | 6 | defmodule DefaultController do 7 | use Phoenix.Controller 8 | use JaResource.Create 9 | def repo, do: JaResourceTest.Repo 10 | def model, do: JaResourceTest.Post 11 | end 12 | 13 | defmodule ProtectedController do 14 | use Phoenix.Controller 15 | use JaResource.Create 16 | def repo, do: JaResourceTest.Repo 17 | def handle_create(conn, _attrs), do: send_resp(conn, 401, "") 18 | end 19 | 20 | defmodule CustomController do 21 | use Phoenix.Controller 22 | use JaResource.Create 23 | def repo, do: JaResourceTest.Repo 24 | def handle_create(_c, %{"title" => "valid"}), 25 | do: {:ok, %JaResourceTest.Post{title: "valid"}} 26 | def handle_create(_c, %{"title" => "invalid"}), 27 | do: {:error, [title: "is invalid"]} 28 | end 29 | 30 | defmodule CustomResponseController do 31 | use Phoenix.Controller 32 | use JaResource.Create 33 | def repo, do: JaResourceTest.Repo 34 | def model, do: JaResourceTest.Post 35 | def handle_invalid_create(conn, errors), 36 | do: put_status(conn, 401) |> Phoenix.Controller.render(:errors, data: errors) 37 | def render_create(conn, model), 38 | do: put_status(conn, :ok) |> Phoenix.Controller.render(:show, data: model) 39 | end 40 | 41 | defmodule MultiCustomController do 42 | use Phoenix.Controller 43 | use JaResource.Create 44 | def repo, do: JaResourceTest.Repo 45 | def handle_create(_, params) do 46 | changeset = JaResourceTest.Post.changeset(JaResourceTest.Post, params) 47 | Ecto.Multi.new 48 | |> Ecto.Multi.insert(:post, changeset) 49 | end 50 | end 51 | 52 | test "default implementation renders 201 if valid" do 53 | conn = prep_conn(:post, "/posts", ja_attrs(%{"title" => "valid"})) 54 | response = Create.call(DefaultController, conn) 55 | assert response.status == 201 56 | end 57 | 58 | test "default implementation renders 422 if invalid" do 59 | conn = prep_conn(:post, "/posts", ja_attrs(%{"title" => "invalid"})) 60 | response = Create.call(DefaultController, conn) 61 | assert response.status == 422 62 | end 63 | 64 | test "custom implementation accepts cons" do 65 | conn = prep_conn(:post, "/posts", ja_attrs(%{"title" => "valid"})) 66 | response = Create.call(ProtectedController, conn) 67 | assert response.status == 401 68 | end 69 | 70 | test "custom implementation handles {:ok, model}" do 71 | conn = prep_conn(:post, "/posts", ja_attrs(%{"title" => "valid"})) 72 | response = Create.call(CustomController, conn) 73 | assert response.status == 201 74 | end 75 | 76 | test "custom implementation handles {:error, errors}" do 77 | conn = prep_conn(:post, "/posts", ja_attrs(%{"title" => "invalid"})) 78 | response = Create.call(CustomController, conn) 79 | assert response.status == 422 80 | end 81 | 82 | test "custom multi implementation handles valid data" do 83 | conn = prep_conn(:post, "/posts", ja_attrs(%{"title" => "valid"})) 84 | response = Create.call(MultiCustomController, conn) 85 | assert response.status == 201 86 | end 87 | 88 | test "custom multi implementation handles invalid data" do 89 | conn = prep_conn(:post, "/posts", ja_attrs(%{"title" => "invalid"})) 90 | response = Create.call(MultiCustomController, conn) 91 | assert response.status == 422 92 | end 93 | 94 | test "custom implementation renders 200 if valid" do 95 | conn = prep_conn(:post, "/posts", ja_attrs(%{"title" => "valid"})) 96 | response = Create.call(CustomResponseController, conn) 97 | assert response.status == 200 98 | end 99 | 100 | test "custom implementation renders 401 if invalid" do 101 | conn = prep_conn(:post, "/posts", ja_attrs(%{"title" => "invalid"})) 102 | response = Create.call(CustomResponseController, conn) 103 | assert response.status == 401 104 | end 105 | 106 | def prep_conn(method, path, params \\ %{}) do 107 | params = Map.merge(params, %{"_format" => "json"}) 108 | conn(method, path, params) 109 | |> fetch_query_params 110 | |> Phoenix.Controller.put_view(JaResourceTest.PostView) 111 | end 112 | 113 | defp ja_attrs(attrs) do 114 | %{ 115 | "data" => %{ 116 | "attributes" => attrs 117 | } 118 | } 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/ja_resource/delete_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.DeleteTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | alias JaResource.Delete 5 | 6 | defmodule DefaultController do 7 | use Phoenix.Controller 8 | use JaResource.Delete 9 | def repo, do: JaResourceTest.Repo 10 | def model, do: JaResourceTest.Post 11 | end 12 | 13 | defmodule FailingOnDeleteController do 14 | use Phoenix.Controller 15 | use JaResource.Delete 16 | def repo, do: JaResourceTest.Repo 17 | def model, do: JaResourceTest.FailingOnDeletePost 18 | end 19 | 20 | defmodule CustomController do 21 | use Phoenix.Controller 22 | use JaResource.Delete 23 | def repo, do: JaResourceTest.Repo 24 | def model, do: JaResourceTest.Post 25 | def handle_delete(conn, record) do 26 | case conn.assigns[:user] do 27 | %{is_admin: true} -> super(conn, record) 28 | _ -> send_resp(conn, 401, "ah ah ah") 29 | end 30 | end 31 | end 32 | 33 | test "default implementation renders 404 if record not found" do 34 | conn = prep_conn(:delete, "/posts/404", %{"id" => 404}) 35 | response = Delete.call(DefaultController, conn) 36 | assert response.status == 404 37 | end 38 | 39 | test "default implementation returns 204 if record found" do 40 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 200}) 41 | conn = prep_conn(:delete, "/posts/#{post.id}", %{"id" => post.id}) 42 | response = Delete.call(DefaultController, conn) 43 | assert response.status == 204 44 | end 45 | 46 | test "failing on delete returns 422 if model fails on changeset validation" do 47 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.FailingOnDeletePost{id: 422}) 48 | conn = prep_conn(:delete, "/posts/#{post.id}", %{"id" => post.id}) 49 | response = Delete.call(FailingOnDeleteController, conn) 50 | assert response.status == 422 51 | end 52 | 53 | test "custom implementation retuns 401 if not admin" do 54 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 401}) 55 | conn = prep_conn(:delete, "/posts/#{post.id}", %{"id" => post.id}) 56 | response = Delete.call(CustomController, conn) 57 | assert response.status == 401 58 | end 59 | 60 | test "custom implementation retuns 404 if no model" do 61 | conn = prep_conn(:delete, "/posts/404", %{"id" => 404}) 62 | |> assign(:user, %{is_admin: true}) 63 | response = Delete.call(CustomController, conn) 64 | assert response.status == 404 65 | end 66 | 67 | test "custom implementation retuns 204 if record found" do 68 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 200}) 69 | conn = prep_conn(:delete, "/posts/#{post.id}", %{"id" => post.id}) 70 | |> assign(:user, %{is_admin: true}) 71 | response = Delete.call(DefaultController, conn) 72 | assert response.status == 204 73 | end 74 | 75 | def prep_conn(method, path, params \\ %{}) do 76 | params = Map.merge(params, %{"_format" => "json"}) 77 | conn(method, path, params) 78 | |> fetch_query_params 79 | |> Phoenix.Controller.put_view(JaResourceTest.PostView) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/ja_resource/index_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.IndexTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | alias JaResource.Index 5 | 6 | defmodule DefaultController do 7 | use Phoenix.Controller 8 | use JaResource.Index 9 | def repo, do: JaResourceTest.Repo 10 | def model, do: JaResourceTest.Post 11 | end 12 | 13 | defmodule CustomController do 14 | use Phoenix.Controller 15 | use JaResource.Index 16 | def repo, do: JaResourceTest.Repo 17 | def handle_index(conn, _id), do: send_resp(conn, 401, "") 18 | end 19 | 20 | defmodule PaginatedController do 21 | use Phoenix.Controller 22 | use JaResource.Index 23 | def repo, do: JaResourceTest.Repo 24 | 25 | def handle_index_query(%{query_params: params}, query) do 26 | %{ 27 | page: %{ 28 | number: params["page"]["number"], 29 | size: params["page"]["size"] 30 | }, 31 | total: 0, 32 | records: repo().all(query) 33 | } 34 | end 35 | 36 | def render_index(conn, paginated, opts) do 37 | conn 38 | |> Phoenix.Controller.render( 39 | :index, 40 | data: paginated.records, 41 | meta: %{ 42 | page: paginated.page, 43 | total: paginated.total 44 | }, 45 | opts: opts 46 | ) 47 | end 48 | end 49 | 50 | defmodule QueryErrorController do 51 | use Phoenix.Controller 52 | use JaResource.Index 53 | def repo, do: JaResourceTest.Repo 54 | def handle_index_query(_conn, _params), do: {:error, [details: "An error"]} 55 | end 56 | 57 | setup do 58 | JaResourceTest.Repo.reset() 59 | JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 1}) 60 | JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 2}) 61 | :ok 62 | end 63 | 64 | test "default implementation returns all records" do 65 | conn = prep_conn(:get, "/posts/") 66 | response = Index.call(DefaultController, conn) 67 | assert response.status == 200 68 | 69 | # Note, not real json-api spec view 70 | json = Poison.decode!(response.resp_body, keys: :atoms!) 71 | assert [_, _] = json[:data] 72 | end 73 | 74 | test "custom implementation returns 401" do 75 | conn = prep_conn(:get, "/posts") 76 | response = Index.call(CustomController, conn) 77 | assert response.status == 401 78 | end 79 | 80 | test "paginated implementation serialize meta" do 81 | conn = prep_conn(:get, "/posts?page[number]=1&page[size]=10") 82 | response = Index.call(PaginatedController, conn) 83 | 84 | assert response.assigns == %{ 85 | data: [], 86 | layout: false, 87 | meta: %{page: %{number: "1", size: "10"}, total: 0}, 88 | opts: [] 89 | } 90 | end 91 | 92 | test "query errors are handled correctly" do 93 | conn = prep_conn(:get, "/posts") 94 | response = Index.call(QueryErrorController, conn) 95 | json = Poison.decode!(response.resp_body, keys: :atoms!) 96 | assert json[:errors] == %{details: "An error"} 97 | assert response.status == 500 98 | end 99 | 100 | @tag :skip 101 | test "filtering adds conditional to query" 102 | @tag :skip 103 | test "sorting adds order statements to query" 104 | 105 | def prep_conn(method, path, params \\ %{}) do 106 | params = Map.merge(params, %{"_format" => "json"}) 107 | 108 | conn(method, path, params) 109 | |> fetch_query_params 110 | |> Phoenix.Controller.put_view(JaResourceTest.PostView) 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/ja_resource/model_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.ModelTest do 2 | use ExUnit.Case 3 | 4 | import JaResource.Model 5 | 6 | defmodule DefaultController do 7 | use JaResource.Model 8 | end 9 | 10 | defmodule CustomController do 11 | use JaResource.Model 12 | def model, do: Customized 13 | end 14 | 15 | test "model can be determined by the controller name" do 16 | assert model_from_controller(MyApp.SandwichController) == MyApp.Sandwich 17 | assert model_from_controller(MyApp.V1.SaladController) == MyApp.Salad 18 | assert model_from_controller(MyApp.API.V1.CookieController) == MyApp.Cookie 19 | end 20 | 21 | test "model is inferred by default" do 22 | assert DefaultController.model == JaResource.Default 23 | end 24 | 25 | test "model can be overridded" do 26 | assert CustomController.model == Customized 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/ja_resource/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.PlugTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | defmodule Example do 6 | def handle_index(conn, _), do: assign(conn, :handler, :index) 7 | end 8 | 9 | test "init returns all actions by default" do 10 | assert [{:allowed, allowed}] = JaResource.Plug.init([]) 11 | assert allowed == [:index, :show, :create, :update, :delete] 12 | end 13 | 14 | test "init returns only valid whitelisted actions" do 15 | assert [{:allowed, allowed}] = JaResource.Plug.init(only: [:index, :foo]) 16 | assert allowed == [:index] 17 | end 18 | 19 | test "init returns available minus blacklisted actions" do 20 | assert [{:allowed, allowed}] = JaResource.Plug.init(except: [:index, :foo]) 21 | assert allowed == [:show, :create, :update, :delete] 22 | end 23 | 24 | test "it dispatches known, allowed actions to the behaviour" do 25 | conn = %Plug.Conn{ 26 | private: %{ 27 | phoenix_controller: JaResource.PlugTest.Example, 28 | phoenix_action: :index 29 | }, 30 | params: %{} 31 | } 32 | results = JaResource.Plug.call(conn, allowed: [:index]) 33 | assert results.assigns[:handler] == :index 34 | end 35 | 36 | test "it does not dispatch known, unallowed actions to the behaviour" do 37 | conn = %Plug.Conn{ 38 | private: %{ 39 | phoenix_controller: JaResource.PlugTest.Example, 40 | phoenix_action: :index 41 | }, 42 | params: %{} 43 | } 44 | results = JaResource.Plug.call(conn, allowed: [:show]) 45 | refute results.assigns[:handler] 46 | end 47 | 48 | test "it does not dispatch unknown actions to the behaviour" do 49 | conn = %Plug.Conn{ 50 | private: %{ 51 | phoenix_controller: JaResource.PlugTest.Example, 52 | phoenix_action: :foo 53 | }, 54 | params: %{} 55 | } 56 | results = JaResource.Plug.call(conn, allowed: [:index]) 57 | refute results.assigns[:handler] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/ja_resource/record_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.RecordTest do 2 | use ExUnit.Case, async: false 3 | 4 | defmodule Default do 5 | use JaResource.Record 6 | def repo, do: JaResourceTest.Repo 7 | def records(_), do: JaResourceTest.Post 8 | end 9 | 10 | defmodule Custom do 11 | use JaResource.Record 12 | def records(_), do: JaResourceTest.Post 13 | def record(query, id) do 14 | JaResourceTest.Repo.get_by(query, slug: id) 15 | end 16 | end 17 | 18 | test "it should return the model by default" do 19 | JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 1}) 20 | assert Default.record(JaResourceTest.Post, 1) == %JaResourceTest.Post{id: 1} 21 | JaResourceTest.Repo.reset 22 | end 23 | 24 | test "it should be allowed to be overriden" do 25 | record = %JaResourceTest.Post{id: 2, slug: "foo"} 26 | JaResourceTest.Repo.insert(record) 27 | assert Custom.record(JaResourceTest.Post, "foo") == record 28 | JaResourceTest.Repo.reset 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/ja_resource/records_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.RecordsTest do 2 | use ExUnit.Case 3 | 4 | defmodule Default do 5 | use JaResource.Records 6 | def model, do: Model 7 | end 8 | 9 | defmodule Custom do 10 | use JaResource.Records 11 | def records(_), do: CustomModel 12 | end 13 | 14 | test "it should return the model by default" do 15 | assert Default.records(%{}) == Model 16 | end 17 | 18 | test "it should be allowed to be overriden" do 19 | assert Custom.records(%{}) == CustomModel 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/ja_resource/repo_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.RepoTest do 2 | use ExUnit.Case 3 | 4 | Application.put_env(:ja_resource, :repo, MyApp.Repo) 5 | 6 | defmodule ExampleDefaultController do 7 | use JaResource 8 | end 9 | 10 | defmodule ExampleCustomController do 11 | use JaResource 12 | 13 | def repo, do: MyApp.SecondaryRepo 14 | end 15 | 16 | test "Repo should be poplulated from settings by default" do 17 | assert ExampleDefaultController.repo == MyApp.Repo 18 | end 19 | 20 | test "Repo can be overriden" do 21 | assert ExampleCustomController.repo == MyApp.SecondaryRepo 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/ja_resource/serializable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.SerializableTest do 2 | use ExUnit.Case 3 | 4 | defmodule Default do 5 | use JaResource.Serializable 6 | end 7 | 8 | defmodule Override do 9 | use JaResource.Serializable 10 | 11 | def serialization_opts(_conn, params, models) do 12 | [ 13 | fields: %{"article" => params["fields"]["post"]}, 14 | meta: %{total_records: models |> Enum.count} 15 | ] 16 | end 17 | end 18 | 19 | test "default behaviour - no opts" do 20 | conn = %Plug.Conn{} 21 | given = %{} 22 | expected = [] 23 | assert Default.serialization_opts(conn, given, %{}) == expected 24 | end 25 | 26 | test "default behaviour - both opts" do 27 | conn = %Plug.Conn{} 28 | given = %{"fields" => %{"post" => "title,body"}, "include" => "author"} 29 | expected = [include: "author", fields: %{"post" => "title,body"}] 30 | assert Default.serialization_opts(conn, given, %{}) == expected 31 | end 32 | 33 | test "default behaviour - field only" do 34 | conn = %Plug.Conn{} 35 | given = %{"fields" => %{"post" => "title,body"}} 36 | expected = [fields: %{"post" => "title,body"}] 37 | assert Default.serialization_opts(conn, given, %{}) == expected 38 | end 39 | 40 | test "default behaviour - include only" do 41 | conn = %Plug.Conn{} 42 | given = %{"include" => "author"} 43 | expected = [include: "author"] 44 | assert Default.serialization_opts(conn, given, %{}) == expected 45 | end 46 | 47 | test "overridden behaviour" do 48 | conn = %Plug.Conn{} 49 | params = %{"fields" => %{"post" => "title,body"}} 50 | models = [1,2,3] 51 | expected = [ 52 | fields: %{"article" => "title,body"}, 53 | meta: %{total_records: 3} 54 | ] 55 | 56 | assert Override.serialization_opts(conn, params, models) == expected 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/ja_resource/show_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.ShowTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | alias JaResource.Show 5 | 6 | defmodule DefaultController do 7 | use Phoenix.Controller 8 | use JaResource.Show 9 | def repo, do: JaResourceTest.Repo 10 | def model, do: JaResourceTest.Post 11 | end 12 | 13 | defmodule CustomController do 14 | use Phoenix.Controller 15 | use JaResource.Show 16 | def repo, do: JaResourceTest.Repo 17 | def handle_show(conn, _id), do: send_resp(conn, 401, "") 18 | 19 | def render_show(conn, model), 20 | do: put_status(conn, :created) |> Phoenix.Controller.render(:show, data: model) 21 | end 22 | 23 | test "default implementation return 404 if not found" do 24 | conn = prep_conn(:get, "/posts/404", %{"id" => 404}) 25 | response = Show.call(DefaultController, conn) 26 | assert response.status == 404 27 | {:ok, body} = Poison.decode(response.resp_body) 28 | 29 | assert body == %{ 30 | "action" => "errors.json", 31 | "errors" => %{ 32 | "detail" => "The resource was not found", 33 | "status" => 404, 34 | "title" => "Not Found" 35 | } 36 | } 37 | end 38 | 39 | test "default implementation return 200 if found" do 40 | conn = prep_conn(:get, "/posts/200", %{"id" => 200}) 41 | JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 200}) 42 | response = Show.call(DefaultController, conn) 43 | assert response.status == 200 44 | end 45 | 46 | test "custom implementation return 401" do 47 | conn = prep_conn(:get, "/posts/401", %{"id" => 401}) 48 | response = Show.call(CustomController, conn) 49 | assert response.status == 401 50 | end 51 | 52 | def prep_conn(method, path, params \\ %{}) do 53 | params = Map.merge(params, %{"_format" => "json"}) 54 | 55 | conn(method, path, params) 56 | |> fetch_query_params 57 | |> Phoenix.Controller.put_view(JaResourceTest.PostView) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/ja_resource/update_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResource.UpdateTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | alias JaResource.Update 5 | 6 | defmodule DefaultController do 7 | use Phoenix.Controller 8 | use JaResource.Update 9 | def repo, do: JaResourceTest.Repo 10 | def model, do: JaResourceTest.Post 11 | end 12 | 13 | defmodule CustomController do 14 | use Phoenix.Controller 15 | use JaResource.Update 16 | def repo, do: JaResourceTest.Repo 17 | def model, do: JaResourceTest.Post 18 | def handle_update(c, nil, _attrs), do: send_resp(c, 420, "") 19 | def handle_update(_c, _post, %{"title" => "valid"}) do 20 | {:ok, %JaResourceTest.Post{title: "valid"}} 21 | end 22 | def handle_update(_c, _post, %{"title" => "invalid"}) do 23 | {:error, [title: "is invalid"]} 24 | end 25 | end 26 | 27 | defmodule CustomResponseController do 28 | use Phoenix.Controller 29 | use JaResource.Update 30 | def repo, do: JaResourceTest.Repo 31 | def model, do: JaResourceTest.Post 32 | def handle_invalid_update(conn, errors), 33 | do: put_status(conn, 401) |> Phoenix.Controller.render(:errors, data: errors) 34 | def render_update(conn, model), 35 | do: put_status(conn, :created) |> Phoenix.Controller.render(:show, data: model) 36 | end 37 | 38 | defmodule MultiCustomController do 39 | use Phoenix.Controller 40 | use JaResource.Update 41 | def repo, do: JaResourceTest.Repo 42 | def handle_update(_c, _post, params) do 43 | changeset = JaResourceTest.Post.changeset(JaResourceTest.Post, params) 44 | Ecto.Multi.new 45 | |> Ecto.Multi.update(:post, changeset) 46 | end 47 | end 48 | 49 | test "default implementation renders 404 if record not found" do 50 | conn = prep_conn(:put, "/posts/404", ja_attrs(404, %{"title" => "valid"})) 51 | response = Update.call(DefaultController, conn) 52 | assert response.status == 404 53 | end 54 | 55 | test "default implementation renders 200 if valid" do 56 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 200}) 57 | conn = prep_conn(:put, "/posts/#{post.id}", ja_attrs(post.id, %{"title" => "valid"})) 58 | response = Update.call(DefaultController, conn) 59 | assert response.status == 200 60 | end 61 | 62 | test "default implementation renders 422 if invalid" do 63 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 422}) 64 | conn = prep_conn(:put, "/posts/#{post.id}", ja_attrs(post.id, %{"title" => "invalid"})) 65 | response = Update.call(DefaultController, conn) 66 | assert response.status == 422 67 | end 68 | 69 | test "custom implementation renders conn if returned" do 70 | conn = prep_conn(:put, "/posts/420", ja_attrs(420, %{"title" => "valid"})) 71 | response = Update.call(CustomController, conn) 72 | assert response.status == 420 73 | end 74 | 75 | test "custom implementation renders 200 if valid" do 76 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 200}) 77 | conn = prep_conn(:put, "/posts/#{post.id}", ja_attrs(post.id, %{"title" => "valid"})) 78 | response = Update.call(CustomController, conn) 79 | assert response.status == 200 80 | end 81 | 82 | test "custom implementation renders 422 if invalid" do 83 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 422}) 84 | conn = prep_conn(:put, "/posts/#{post.id}", ja_attrs(post.id, %{"title" => "invalid"})) 85 | response = Update.call(CustomController, conn) 86 | assert response.status == 422 87 | end 88 | 89 | test "custom implementation renders 401 if invalid" do 90 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 422}) 91 | conn = prep_conn(:put, "/posts/#{post.id}", ja_attrs(post.id, %{"title" => "invalid"})) 92 | response = Update.call(CustomResponseController, conn) 93 | assert response.status == 401 94 | end 95 | 96 | test "custom implementation renders 201 if valid" do 97 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 200}) 98 | conn = prep_conn(:put, "/posts/#{post.id}", ja_attrs(post.id, %{"title" => "valid"})) 99 | response = Update.call(CustomResponseController, conn) 100 | assert response.status == 201 101 | end 102 | 103 | test "custom multi implementation renders 200 if valid" do 104 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 200}) 105 | conn = prep_conn(:put, "/posts/#{post.id}", ja_attrs(post.id, %{"title" => "valid"})) 106 | response = Update.call(MultiCustomController, conn) 107 | assert response.status == 200 108 | end 109 | 110 | test "custom multi implementation renders 422 if invalid" do 111 | {:ok, post} = JaResourceTest.Repo.insert(%JaResourceTest.Post{id: 422}) 112 | conn = prep_conn(:put, "/posts/#{post.id}", ja_attrs(post.id, %{"title" => "invalid"})) 113 | response = Update.call(MultiCustomController, conn) 114 | assert response.status == 422 115 | end 116 | 117 | def prep_conn(method, path, params \\ %{}) do 118 | params = Map.merge(params, %{"_format" => "json"}) 119 | conn(method, path, params) 120 | |> fetch_query_params 121 | |> Phoenix.Controller.put_view(JaResourceTest.PostView) 122 | end 123 | 124 | defp ja_attrs(id, attrs) do 125 | %{ 126 | "id" => id, 127 | "type" => "post", 128 | "data" => %{ 129 | "attributes" => attrs 130 | } 131 | } 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/ja_resource_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JaResourceTest do 2 | use ExUnit.Case 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Application.put_env(:ja_resource, :repo, JaResourceTest.Repo) 4 | 5 | defmodule JaResourceTest.Repo do 6 | @moduledoc """ 7 | A fake repo implementation that just holds records in an agent. 8 | 9 | Usefull for testing w/o requiring a real repo. 10 | """ 11 | 12 | def start do 13 | Agent.start_link(fn -> MapSet.new end, name: __MODULE__) 14 | end 15 | 16 | def reset do 17 | Agent.update(__MODULE__, fn(_old) -> MapSet.new end) 18 | end 19 | 20 | def one(query) do 21 | Agent.get __MODULE__, fn(state) -> 22 | Enum.find state, fn(record) -> 23 | record.__struct__ == query 24 | end 25 | end 26 | end 27 | 28 | def all(query) do 29 | Agent.get __MODULE__, fn(state) -> 30 | Enum.filter state, fn(record) -> 31 | record.__struct__ == query 32 | end 33 | end 34 | end 35 | 36 | def get_by(query, [{field, val}]) do 37 | Agent.get __MODULE__, fn(state) -> 38 | Enum.find state, fn(record) -> 39 | record.__struct__ == query && Map.get(record, field) == val 40 | end 41 | end 42 | end 43 | 44 | def get(_query, id) do 45 | Agent.get __MODULE__, fn(state) -> 46 | Enum.find state, fn(record) -> 47 | record.id == id 48 | end 49 | end 50 | end 51 | 52 | def insert(%Ecto.Changeset{valid?: true} = changeset) do 53 | insert(changeset.data) 54 | end 55 | 56 | def insert(%Ecto.Changeset{valid?: false} = changeset) do 57 | {:error, changeset} 58 | end 59 | 60 | def insert(record) do 61 | {Agent.update(__MODULE__, &MapSet.put(&1, record)), record} 62 | end 63 | 64 | def update(%Ecto.Changeset{valid?: true} = changeset) do 65 | insert(changeset.data) 66 | end 67 | 68 | def update(%Ecto.Changeset{valid?: false} = changeset) do 69 | {:error, changeset} 70 | end 71 | 72 | def update(new) do 73 | Agent.update __MODULE__, fn(state) -> 74 | old = Enum.find state, fn(record) -> 75 | record.__struct__ == new.__struct__ && record.id == new.id 76 | end 77 | state 78 | |> MapSet.delete(old) 79 | |> MapSet.put(Map.merge(old, new)) 80 | end 81 | end 82 | 83 | def delete(%Ecto.Changeset{valid?: false} = changeset) do 84 | {:error, changeset} 85 | end 86 | 87 | def delete(to_delete) do 88 | Agent.update __MODULE__, fn(state) -> 89 | old = Enum.find state, fn(record) -> 90 | record.__struct__ == to_delete.__struct__ && record.id == to_delete.id 91 | end 92 | MapSet.delete(state, old) 93 | end 94 | end 95 | 96 | def transaction(%Ecto.Multi{operations: [{schema, {:changeset, %Ecto.Changeset{valid?: false} = changeset, _}}]}) do 97 | {:error, schema, changeset, %{}} 98 | end 99 | 100 | def transaction(%Ecto.Multi{operations: [{schema, {:changeset, %Ecto.Changeset{valid?: true, action: :insert} = changeset, _}}]}) do 101 | {:ok, inserted} = insert(changeset.data) 102 | {:ok, %{schema => inserted}} 103 | end 104 | 105 | def transaction(%Ecto.Multi{operations: [{schema, {:changeset, %Ecto.Changeset{valid?: true, action: :update} = changeset, _}}]}) do 106 | {:ok, updated} = update(changeset) 107 | {:ok, %{schema => updated}} 108 | end 109 | end 110 | 111 | # We don't actually need to use Ecto.Schema, just implement it's api. 112 | defmodule JaResourceTest.Post do 113 | defstruct [id: 0, title: "title", body: "body", slug: "slug"] 114 | 115 | def changeset(_model, params) do 116 | model = %__MODULE__{ 117 | title: params["title"], 118 | body: params["body"], 119 | slug: params["slug"] 120 | } 121 | case model.title do 122 | "invalid" -> %Ecto.Changeset{data: model, valid?: false, errors: [title: "is invalid"]} 123 | _ -> %Ecto.Changeset{data: model, valid?: true} 124 | end 125 | end 126 | end 127 | 128 | defmodule JaResourceTest.FailingOnDeletePost do 129 | defstruct [id: 0, title: "title", body: "body", slug: "slug"] 130 | 131 | def changeset(_model, params) do 132 | model = %__MODULE__{ 133 | title: params["title"], 134 | body: params["body"], 135 | slug: params["slug"] 136 | } 137 | %Ecto.Changeset{data: model, valid?: false, errors: [title: "something went wrong"]} 138 | end 139 | end 140 | 141 | defmodule JaResourceTest.PostView do 142 | def render("errors.json", %{data: errors}), 143 | do: %{action: "errors.json", errors: render_errors(errors)} 144 | def render(action, opts), 145 | do: %{action: action, data: opts[:data]} 146 | 147 | defp render_errors(%Ecto.Changeset{errors: errors}), 148 | do: render_errors(errors) 149 | 150 | defp render_errors(errors), 151 | do: Enum.into(errors, %{}) 152 | end 153 | 154 | JaResourceTest.Repo.start 155 | --------------------------------------------------------------------------------