├── .gitignore ├── README.md ├── lib ├── crudex.ex └── crudex │ ├── crud_controller.ex │ ├── json_binary.ex │ ├── model.ex │ ├── user.ex │ └── user_controller.ex ├── mix.exs ├── mix.lock └── test ├── crudex_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crudex 2 | 3 | A glue keeping Phoenix and Ecto together. It has the following features: 4 | 5 | * JSON-encoding/decoding of Ecto models 6 | * Dynamic virtual fields for models 7 | * Simplifies creation of CRUD controllers 8 | * User controller for login. 9 | 10 | ## Crudex.Model 11 | 12 | By using the Crudex.Model module, like this: 13 | 14 | ```elixir 15 | defmodule MyModel do 16 | use Crudex.Model 17 | end 18 | ``` 19 | 20 | You make your model JSON-encodable using Poison. You should also use `Ecto.UUID`, `Crudex.JSONBinary`, `Ecto.DateTime` instead of `:uuid`, `:binary`, and `:datetime` in your model respectively. 21 | 22 | The Crudex.Model also provides the following macros: 23 | 24 | * `crudex_schema` => is like `schema` from Ecto.Model but defines ID/foreign keys to be UUID and creates timestamps. You should use this if you want to use the CRUD controller functionalities. It also allows using the two macros below 25 | * `hidden_field` => creates a regular field which will be excluded when encoding 26 | 27 | ### Example 28 | ```elixir 29 | defmodule Example.User do 30 | use Crudex.Model 31 | 32 | crudex_schema "users" do 33 | field :name, :string 34 | field :surname, :string 35 | field :email, :string 36 | hidden_field :salt, Crudex.JSONBinary 37 | hidden_field :password, Crudex.JSONBinary 38 | field :role, :string 39 | end 40 | 41 | ... 42 | end 43 | ``` 44 | 45 | ## Crudex.CrudController 46 | 47 | Allows automatic creation of complete or partial CRUD controllers. It even supports user scoping, assuming your model has a `user_id` field. 48 | 49 | It has two macros 50 | 51 | * `crud_for` => creates CRUD actions for the given model. Optionally you can specify which actions should be created. 52 | * `defcrud` => defines a single CRUD action for the given model. 53 | 54 | ### Example 55 | 56 | ```elixir 57 | defmodule BookController do 58 | use Crudex.CrudController 59 | 60 | plug PlugAuth.Authentication.Token, [source: :params, param: "auth_token"] 61 | plug :action 62 | 63 | @ecto_repo MyRepo 64 | @user_scoped true 65 | crud_for(Book) 66 | end 67 | ``` 68 | 69 | Assuming you have a `Book` model, you will get the `:index, :create, :show, :update, :delete` actions created for you. Since `@user_scoped` is set to `true`, all actions will only be applicable for the user currently logged in. This works best in conjunction with plug_auth. 70 | 71 | ## Crudex.UserController 72 | Provides a macro, `user_controller` to define a simple user controller. Access control should happen in the controller invoking this macro, using for example plug_auth. It is not very flexible for now, and is more there to show you how to do a complex controller with Crudex. 73 | 74 | ## TODO 75 | There is a lot to do, pagination and filtering is one of the first things. Tests are missing although I have tests in the projects where I use this. Documentation in the code is also missing for now. 76 | 77 | ## LICENSE 78 | Copyright (c) 2014, Michele Balistreri 79 | 80 | Permission to use, copy, modify, and/or distribute this software for any 81 | purpose with or without fee is hereby granted, provided that the above 82 | copyright notice and this permission notice appear in all copies. 83 | 84 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 85 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 86 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 87 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 88 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 89 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 90 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 91 | -------------------------------------------------------------------------------- /lib/crudex.ex: -------------------------------------------------------------------------------- 1 | defmodule Crudex do 2 | end 3 | -------------------------------------------------------------------------------- /lib/crudex/crud_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Crudex.CrudController do 2 | use Phoenix.Controller 3 | import Ecto.Query, only: [from: 2] 4 | 5 | defmacro __using__(_) do 6 | quote do 7 | use Phoenix.Controller 8 | import Crudex.CrudController, only: [crud_for: 1, crud_for: 2, defcrud: 2, send_error: 3] 9 | @user_scoped false 10 | end 11 | end 12 | 13 | defmacro defcrud(module, action) do 14 | implementation = String.to_existing_atom("do_#{action}") 15 | 16 | quote do 17 | def unquote(action)(conn, params) do 18 | apply(Crudex.CrudController, unquote(implementation), [conn, @ecto_repo, unquote(module), @user_scoped, params]) 19 | end 20 | end 21 | end 22 | 23 | defmacro crud_for(module, actions \\ [:index, :create, :show, :update, :delete]) do 24 | for action <- actions, do: quote do: defcrud(unquote(module), unquote(action)) 25 | end 26 | 27 | def do_index(conn, repo, module, user_scoped, _) do 28 | json conn, module |> apply_scope(conn, user_scoped) |> repo.all 29 | end 30 | 31 | def do_create(conn, repo, module, user_scoped, %{"data" => data}) do 32 | changeset = module.changeset(struct(module), add_user_id(data, conn, user_scoped)) 33 | 34 | case changeset.valid? do 35 | true -> changeset |> repo.insert |> send_data(conn) 36 | false -> send_error(conn, :bad_request, format_errors(changeset.errors)) 37 | end 38 | end 39 | def do_create(conn, _repo, _module, _user_scoped, _params), do: send_error(conn, :bad_request, %{message: "bad request"}) 40 | 41 | def do_show(conn, repo, module, user_scoped, %{"id" => data_id}) when not is_nil(data_id) do 42 | assocs = module.__schema__(:associations) |> Enum.filter(&filter_assoc(&1, module)) 43 | 44 | case from(r in module, where: r.id == ^data_id, preload: ^assocs) |> apply_scope(conn, user_scoped) |> repo.one do 45 | nil -> send_error(conn, :not_found, %{message: "not found"}) 46 | data -> data |> send_data(conn) 47 | end 48 | end 49 | def do_show(conn, _repo, _module, _user_scoped, _params), do: send_error(conn, :not_found, %{message: "not found"}) 50 | 51 | def do_update(conn, repo, module, user_scoped, %{"id" => data_id, "data" => updated_fields}) when not is_nil(data_id) do 52 | sanitized_fields = sanitize(updated_fields, user_scoped) 53 | case from(r in module, where: r.id == ^data_id) |> apply_scope(conn, user_scoped) |> repo.one do 54 | nil -> send_error(conn, :not_found, %{message: "not found"}) 55 | data -> data |> module.changeset(sanitized_fields) |> _update_data(conn, repo) 56 | end 57 | end 58 | def do_update(conn, _repo, _module, _user_scoped, %{"data" => _updated_fields}), do: send_error(conn, :not_found, %{message: "not found"}) 59 | def do_update(conn, _repo, _module, _user_scoped, _params), do: send_error(conn, :bad_request, %{message: "bad request"}) 60 | 61 | def do_delete(conn, repo, module, user_scoped, %{"id" => data_id}) when not is_nil(data_id) do 62 | case from(r in module, where: r.id == ^data_id) |> apply_scope(conn, user_scoped) |> repo.delete_all do 63 | 1 -> json conn, %{status: "ok"} 64 | 0 -> send_error(conn, :not_found, %{message: "not found"}) 65 | end 66 | end 67 | def do_delete(conn, _repo, _module, _user_scoped, _params), do: send_error(conn, :not_found, %{message: "not found"}) 68 | 69 | def send_error(conn, status, errors) do 70 | conn 71 | |> put_status(status) 72 | |> json %{errors: errors} 73 | end 74 | 75 | def send_data(data, conn) do 76 | json conn, %{data: data} 77 | end 78 | 79 | def apply_user_scope(query, conn) do 80 | apply_scope(query, conn, true) 81 | end 82 | 83 | defp _update_data(changeset, conn, repo) do 84 | case changeset.valid? do 85 | true -> changeset |> repo.update |> send_data(conn) 86 | false -> send_error(conn, :bad_request, format_errors(changeset.errors)) 87 | end 88 | end 89 | 90 | defp sanitize(data, user_scoped), do: data |> Map.delete("id") |> delete_user_info(user_scoped) 91 | defp delete_user_info(data, true), do: Map.delete(data, "user_id") 92 | defp delete_user_info(data, false), do: data 93 | 94 | defp filter_assoc(field, module) do 95 | module.__schema__(:association, field).__struct__ != Ecto.Association.BelongsTo 96 | end 97 | 98 | def get_authenticated_user(conn), do: PlugAuth.Authentication.Utils.get_authenticated_user(conn) |> Map.get(:id) |> Ecto.UUID.cast |> elem(1) 99 | 100 | defp add_user_id(data, conn, true), do: Map.put(data, "user_id", get_authenticated_user(conn)) 101 | defp add_user_id(data, _conn, false), do: data 102 | 103 | defp apply_scope(query, _conn, false), do: query 104 | defp apply_scope(query, conn, true) do 105 | user_id = get_authenticated_user(conn) 106 | from(r in query, where: r.user_id == ^user_id) 107 | end 108 | 109 | defp format_errors(errors), do: Enum.into(errors, Map.new) 110 | end 111 | -------------------------------------------------------------------------------- /lib/crudex/json_binary.ex: -------------------------------------------------------------------------------- 1 | defmodule Crudex.JSONBinary do 2 | def type, do: :binary 3 | 4 | def cast(string) when is_binary(string) do 5 | case Base.url_decode64(string) do 6 | {:ok, binary} -> {:ok, binary} 7 | :error -> {:ok, string} 8 | end 9 | end 10 | def cast(_), do: :error 11 | 12 | def cast!(val) do 13 | case cast(val) do 14 | {:ok, uuid} -> uuid 15 | :error -> raise "Invalid binary format" 16 | end 17 | end 18 | 19 | def encode(binary) when is_binary(binary), do: Base.url_encode64(binary) 20 | 21 | def blank?(nil), do: true 22 | def blank?(<<>>), do: true 23 | def blank?(_), do: false 24 | 25 | def load(bin) when is_binary(bin), do: {:ok, bin} 26 | def load(_), do: :error 27 | 28 | def dump(bin) when is_binary(bin), do: {:ok, bin} 29 | def dump(_), do: :error 30 | end -------------------------------------------------------------------------------- /lib/crudex/model.ex: -------------------------------------------------------------------------------- 1 | defmodule Crudex.Model do 2 | 3 | ## Macros 4 | defmacro __using__(_) do 5 | quote do 6 | use Ecto.Model 7 | import Crudex.Model, only: [crudex_schema: 2, hidden_field: 2] 8 | 9 | Module.register_attribute __MODULE__, :crudex_hidden, accumulate: true, persist: false 10 | 11 | defimpl Poison.Encoder, for: __MODULE__ do 12 | def encode(model, options), do: Crudex.Model.encode(model, @for) |> Poison.Encoder.Map.encode(options) 13 | end 14 | end 15 | end 16 | 17 | defmacro crudex_schema(schema_name, do: block) do 18 | quote do 19 | @primary_key {:id, Ecto.UUID, [read_after_writes: true]} 20 | @foreign_key_type Ecto.UUID 21 | schema unquote(schema_name) do 22 | unquote(block) 23 | timestamps inserted_at: :created_at 24 | end 25 | 26 | def __crudex_hidden__ do 27 | @crudex_hidden 28 | end 29 | end 30 | end 31 | 32 | defmacro hidden_field(name, type) do 33 | quote do 34 | Module.put_attribute __MODULE__, :crudex_hidden, unquote(name) 35 | field unquote(name), unquote(type) 36 | end 37 | end 38 | 39 | ## Public API 40 | def encode(model, module) do 41 | model 42 | |> Map.from_struct 43 | |> Map.delete(:__meta__) 44 | |> filter_hidden(module) 45 | |> encode_model_associations(module) 46 | |> encode_fields(module) 47 | end 48 | 49 | def filter_hidden(model, module) do 50 | Enum.reduce(module.__crudex_hidden__, model, &Map.delete(&2, &1)) 51 | end 52 | 53 | ## Implementation 54 | defp encode_model_associations(model, module) do 55 | reduce_on_associations(model, module, fn field, model -> 56 | {v, model} = Dict.pop(model, field) 57 | Dict.put(model, field, fetch_association(v)) 58 | end) 59 | end 60 | 61 | defp encode_fields(model, module), do: reduce_on_existing_fields(model, module, &encode_field/2) 62 | 63 | defp encode_field(_, nil), do: nil 64 | defp encode_field(Ecto.DateTime, field_val), do: Ecto.DateTime.to_iso8601(field_val) 65 | defp encode_field(Crudex.JSONBinary, field_val), do: Crudex.JSONBinary.encode(field_val) 66 | defp encode_field(_type, field_val), do: field_val 67 | 68 | defp fetch_association(%Ecto.Association.NotLoaded{}), do: nil 69 | defp fetch_association(assoc), do: assoc 70 | 71 | ## Utils 72 | defp reduce_on_associations(model, module, fun), do: Enum.reduce(module.__schema__(:associations), model, fun) 73 | defp reduce_on_fields(model, module, fun), do: Enum.reduce(module.__changeset__, model, fun) 74 | defp reduce_on_existing_fields(model, module, fun), do: reduce_on_fields(model, module, &apply_on_existing_field(&1, &2, fun)) 75 | defp apply_on_existing_field({field, type}, model, fun) do 76 | if Dict.has_key?(model, field) do 77 | decoded_field = fun.(type, model[field]) 78 | Dict.put(model, field, decoded_field) 79 | else 80 | model 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/crudex/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Crudex.User do 2 | use Crudex.Model 3 | 4 | defmacro __using__(_) do 5 | quote do 6 | use Crudex.Model 7 | import Crudex.User, only: [user_schema: 2] 8 | end 9 | end 10 | 11 | defmacro user_schema(name, do: block) do 12 | quote do 13 | crudex_schema unquote(name) do 14 | field :email, :string 15 | hidden_field :salt, Crudex.JSONBinary 16 | hidden_field :password, Crudex.JSONBinary 17 | field :role, :string 18 | unquote(block) 19 | end 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /lib/crudex/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Crudex.UserController do 2 | use Phoenix.Controller 3 | import Ecto.Query, only: [from: 2] 4 | 5 | defmacro __using__(_) do 6 | quote do 7 | use Crudex.CrudController 8 | import Crudex.UserController, only: [user_controller_for: 1] 9 | end 10 | end 11 | 12 | defmacro user_controller_for(model) do 13 | quote do 14 | crud_for(unquote(model), [:index, :delete]) 15 | 16 | def sign_in(conn, %{"email" => email, "password" => password}) do 17 | Crudex.UserController.do_sign_in(conn, @ecto_repo, unquote(model), email, password) 18 | end 19 | 20 | def sign_out(conn, %{"auth_token" => token}) do 21 | Crudex.UserController.do_sign_out(conn, token) 22 | end 23 | 24 | def create(conn, %{"data" => user}) do 25 | Crudex.CrudController.do_create(conn, @ecto_repo, unquote(model), false, %{"data" => Crudex.UserController.convert_user_data(user, conn)}) 26 | end 27 | 28 | def update(conn, params = %{"data" => user}) do 29 | data_id = Crudex.UserController.get_user_id(conn, params) 30 | Crudex.CrudController.do_update(conn, @ecto_repo, unquote(model), false, %{"id" => data_id, "data" => Crudex.UserController.convert_user_data(user, conn)}) 31 | end 32 | 33 | def show(conn, params) do 34 | data_id = Crudex.UserController.get_user_id(conn, params) 35 | Crudex.CrudController.do_show(conn, @ecto_repo, unquote(model), false, %{"id" => data_id}) 36 | end 37 | end 38 | end 39 | 40 | def do_sign_in(conn, repo, model, email, password) do 41 | case repo.all from(u in model, where: u.email == ^email) do 42 | [user | _] -> verify_secret(user.salt, user.password, password) |> perform_sign_in(conn, user) 43 | _ -> perform_sign_in(false, conn, nil) 44 | end 45 | end 46 | 47 | def do_sign_out(conn, token) do 48 | PlugAuth.Authentication.Token.remove_credentials(token) 49 | json conn, %{status: :ok} 50 | end 51 | 52 | defp perform_sign_in(true, conn, user) do 53 | token = PlugAuth.Authentication.Token.generate_token 54 | PlugAuth.Authentication.Token.add_credentials(token, %{id: user.id, role: user.role}) 55 | json conn, %{auth_token: token} 56 | end 57 | defp perform_sign_in(false, conn, _user), do: Crudex.CrudController.send_error(conn, :unauthorized, %{message: "unauthorized"}) 58 | 59 | def get_user_id(%Plug.Conn{assigns: %{authenticated_user: %{role: "admin"}}}, %{"id" => data_id}), do: data_id 60 | def get_user_id(conn, %{"id" => "current"}), do: Crudex.CrudController.get_authenticated_user(conn) 61 | def get_user_id(conn, %{"id" => data_id}) do 62 | case Crudex.CrudController.get_authenticated_user(conn) do 63 | ^data_id -> data_id 64 | _ -> nil 65 | end 66 | end 67 | def get_user_id(conn, _params), do: Crudex.CrudController.get_authenticated_user(conn) 68 | 69 | def verify_secret(salt, key, password) do 70 | Plug.Crypto.KeyGenerator.generate(password, salt) 71 | |> Plug.Crypto.secure_compare(key) 72 | end 73 | 74 | def convert_user_data(user, conn) do 75 | Dict.pop(user, "password") 76 | |> password_to_key 77 | |> sanitize_role(conn) 78 | end 79 | 80 | defp sanitize_role(user, %Plug.Conn{assigns: %{authenticated_user: %{role: "admin"}}}), do: user 81 | defp sanitize_role(user, _conn), do: Dict.delete(user, "role") 82 | 83 | defp password_to_key({nil, user}), do: user 84 | defp password_to_key({pass, user}) do 85 | {salt, key} = generate_key(pass) 86 | 87 | user 88 | |> Dict.put("salt", Crudex.JSONBinary.encode(salt)) 89 | |> Dict.put("password", Crudex.JSONBinary.encode(key)) 90 | end 91 | 92 | defp generate_key(password) do 93 | salt = :crypto.strong_rand_bytes(32) 94 | {salt, Plug.Crypto.KeyGenerator.generate(password, salt)} 95 | end 96 | end -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Crudex.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :crudex, 6 | version: "0.0.2", 7 | elixir: "~> 1.0", 8 | deps: deps, 9 | package: package, 10 | description: description, 11 | docs: [readme: "README.md", main: "README"]] 12 | end 13 | 14 | def application do 15 | [applications: [:phoenix, :ecto, :plug_auth]] 16 | end 17 | 18 | defp deps do 19 | [ 20 | {:phoenix, github: "phoenixframework/phoenix"}, 21 | {:ecto, github: "elixir-lang/ecto"}, 22 | {:plug_auth, ">= 0.0.0"}, 23 | {:earmark, "~> 0.1", only: :docs}, 24 | {:ex_doc, "~> 0.6", only: :docs} 25 | ] 26 | end 27 | 28 | defp description do 29 | "A glue keeping Phoenix and Ecto together" 30 | end 31 | 32 | defp package do 33 | [contributors: ["Michele Balistreri"], 34 | licenses: ["ISC"], 35 | links: %{"GitHub" => "https://github.com/briksoftware/crudex"}] 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"cowboy": {:hex, :cowboy, "1.0.0"}, 2 | "cowlib": {:hex, :cowlib, "1.0.1"}, 3 | "decimal": {:hex, :decimal, "1.1.0"}, 4 | "earmark": {:hex, :earmark, "0.1.12"}, 5 | "ecto": {:git, "git://github.com/elixir-lang/ecto.git", "3291983ba16e13b7691afaf7d685047db162ebdc", []}, 6 | "ex_doc": {:hex, :ex_doc, "0.7.0"}, 7 | "phoenix": {:git, "git://github.com/phoenixframework/phoenix.git", "c2bac8138b11065a5224cb79d2ef397e917f562c", []}, 8 | "plug": {:hex, :plug, "0.10.0"}, 9 | "plug_auth": {:hex, :plug_auth, "0.0.2"}, 10 | "poison": {:hex, :poison, "1.3.0"}, 11 | "poolboy": {:hex, :poolboy, "1.4.2"}, 12 | "postgrex": {:hex, :postgrex, "0.7.0"}, 13 | "ranch": {:hex, :ranch, "1.0.0"}, 14 | "timex": {:hex, :timex, "0.13.3"}} 15 | -------------------------------------------------------------------------------- /test/crudex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CrudexTest do 2 | use ExUnit.Case 3 | 4 | test "the truth" do 5 | assert 1 + 1 == 2 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------