├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── mix │ └── tasks │ │ ├── unbrella.gen.assets.ex │ │ ├── unbrella.migrate.ex │ │ ├── unbrella.new.ex │ │ ├── unbrella.rollback.ex │ │ └── unbrella.seed.ex ├── unbrella.ex └── unbrella │ ├── hooks.ex │ ├── plugin.ex │ ├── plugin │ ├── router.ex │ └── schema.ex │ ├── router.ex │ ├── schema.ex │ └── utils.ex ├── mix.exs ├── mix.lock ├── priv └── priv │ └── templates │ └── unbrella.new │ ├── README.md │ ├── config │ └── config.exs │ ├── lib │ └── otp_app.ex │ └── mix.exs └── test ├── lib ├── router_test.exs ├── unbrella_test.exs └── utils_test.exs ├── mix_helpers.exs ├── support ├── config.ex └── my_app │ └── plugins │ └── plugin1 │ ├── plugin.ex │ ├── plugin1.ex │ ├── router.ex │ └── user.ex └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | .vscode 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 E-MetroTel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unbrella 2 | 3 | Unbrella is a library to help Phoenix designers build an application that can be extended with plugins. 4 | 5 | Elixir's umbrella apps work will to package independent apps in a common project. Once way dependencies work wll with umbrella apps. However, if you need to share code bidirectionaly between two apps, you need a different solution. 6 | 7 | Unbrella is designed to allow plugins to extend the schema of a model defined in the main project. It allow allows inserting routers and plugin configuration. 8 | 9 | > This project is a work in progress. I'm using it in a project that I have not completed yet. 10 | 11 | ## Installation 12 | 13 | ```elixir 14 | def deps do 15 | [{:unbrella, github: "smpallen99/unbrella"}] 16 | end 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### Project Structure 22 | 23 | Plugs are located in the plugins folder under your project's root folder. This allows them to be compiled with your project so code can be shared between the main app and the plugis. 24 | 25 | ``` 26 | my_app 27 | ├── config 28 | ├── lib 29 | ├── plugins 30 | │   ├── my_plugin 31 | │   │   ├── config 32 | │   │   │   └── config.exs 33 | │   │   └── lib 34 | │   ├── another_plugin 35 | # ... 36 | ``` 37 | 38 | ### Anatomy of a Plugin 39 | 40 | A plug uses a very simpilar project structure to an other Elixir or Phoenix app. 41 | 42 | Plugin configuration is done through a `plugins/my_plugin/config/config.exs` file. 43 | 44 | TBD: Finish this page. 45 | 46 | ## License 47 | 48 | `unbrella` is Copyright (c) 2017 E-MetroTel 49 | 50 | The source is released under the MIT License. 51 | 52 | Check [LICENSE](LICENSE) for more information. 53 | 54 | -------------------------------------------------------------------------------- /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 | config :phoenix, :json_library, Jason 6 | 7 | # This configuration is loaded before any dependency and is restricted 8 | # to this project. If another project depends on this project, this 9 | # file won't be loaded nor affect the parent project. For this reason, 10 | # if you want to provide default values for your application for 11 | # 3rd-party users, it should be done in your "mix.exs" file. 12 | 13 | # You can configure for your application as: 14 | # 15 | # config :unbrella, key: :value 16 | # 17 | # And access this configuration in your application as: 18 | # 19 | # Application.get_env(:unbrella, :key) 20 | # 21 | # Or configure a 3rd-party app: 22 | # 23 | # config :logger, level: :info 24 | # 25 | 26 | # It is also possible to import configuration files, relative to this 27 | # directory. For example, you can emulate configuration per environment 28 | # by uncommenting the line below and defining dev.exs, test.exs and such. 29 | # Configuration from the imported file will override the ones defined 30 | # here (which is why it is important to import them last). 31 | # 32 | # import_config "#{Mix.env}.exs" 33 | -------------------------------------------------------------------------------- /lib/mix/tasks/unbrella.gen.assets.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Unbrella.Gen.Assets do 2 | use Mix.Task 3 | import Unbrella.Utils 4 | 5 | @shortdoc "Installs Plug-in Assets" 6 | @recursive true 7 | 8 | @moduledoc """ 9 | Installs Plug-in Assets. 10 | 11 | ## Options 12 | 13 | `--all` - Generate assets for all configured plug-ins 14 | 15 | TODO: Add capability to specify individual plug-ins. 16 | 17 | ## Usage 18 | 19 | mix unbrella.gen.assets --all 20 | """ 21 | 22 | @doc false 23 | def run(["--all"]) do 24 | get_assets_paths() 25 | |> Enum.each(fn map -> 26 | map 27 | |> test_source! 28 | |> mk_destination! 29 | |> rm_destination! 30 | |> cp_files! 31 | Mix.shell.info "#{map[:name]} #{map[:src]} files copied." 32 | end) 33 | end 34 | 35 | def run(_) do 36 | Mix.shell.info "Usage: mix unbrella.gen.assets --all" 37 | end 38 | 39 | defp test_source!(map) do 40 | unless File.exists? map[:source_path] do 41 | Mix.raise "Cannot find path #{map[:source_path]}" 42 | end 43 | map 44 | end 45 | 46 | defp mk_destination!(map) do 47 | case File.mkdir_p(map.destination_path) do 48 | :ok -> map 49 | _ -> 50 | Mix.raise "Could not create destination folder #{map[:destination_path]}" 51 | end 52 | end 53 | 54 | defp rm_destination!(map) do 55 | case File.rm_rf(map.destination_path) do 56 | {:ok, _} -> map 57 | _ -> 58 | Mix.raise "Could not remove destination folder #{map[:destination_path]}" 59 | end 60 | end 61 | 62 | defp cp_files!(map) do 63 | case File.cp_r map.source_path, map.destination_path do 64 | {:ok, _files} -> map 65 | _ -> 66 | Mix.raise "Could not copy files from folder #{map[:source_path]} to #{map[:destination_path]}" 67 | end 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /lib/mix/tasks/unbrella.migrate.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Unbrella.Migrate do 2 | use Mix.Task 3 | import Mix.Ecto 4 | import Unbrella.Utils 5 | 6 | @shortdoc "Runs the repository migrations" 7 | @recursive true 8 | 9 | @moduledoc """ 10 | Runs the pending migrations for the given repository. 11 | 12 | The repository must be set under `:ecto_repos` in the 13 | current app configuration or given via the `-r` option. 14 | 15 | By default, migrations are expected at "priv/YOUR_REPO/migrations" 16 | directory of the current application but it can be configured 17 | to be any subdirectory of `priv` by specifying the `:priv` key 18 | under the repository configuration. 19 | 20 | Runs all pending migrations by default. To migrate up 21 | to a version number, supply `--to version_number`. 22 | To migrate up a specific number of times, use `--step n`. 23 | 24 | If the repository has not been started yet, one will be 25 | started outside our application supervision tree and shutdown 26 | afterwards. 27 | 28 | ## Examples 29 | 30 | mix unbrella.migrate 31 | mix unbrella.migrate -r Custom.Repo 32 | 33 | mix unbrella.migrate -n 3 34 | mix unbrella.migrate --step 3 35 | 36 | mix unbrella.migrate -v 20080906120000 37 | mix unbrella.migrate --to 20080906120000 38 | 39 | ## Command line options 40 | 41 | * `-r`, `--repo` - the repo to migrate 42 | * `--all` - run all pending migrations 43 | * `--step` / `-n` - run n number of pending migrations 44 | * `--to` / `-v` - run all migrations up to and including version 45 | * `--quiet` - do not log migration commands 46 | * `--prefix` - the prefix to run migrations on 47 | * `--pool-size` - the pool size if the repository is started only for the task (defaults to 1) 48 | 49 | """ 50 | 51 | @doc false 52 | def run(args, migrator \\ &Ecto.Migrator.run/4) do 53 | repos = parse_repo(args) 54 | 55 | {opts, _, _} = 56 | OptionParser.parse( 57 | args, 58 | switches: [ 59 | all: :boolean, 60 | step: :integer, 61 | to: :integer, 62 | quiet: :boolean, 63 | prefix: :string, 64 | pool_size: :integer 65 | ], 66 | aliases: [n: :step, v: :to] 67 | ) 68 | 69 | opts = 70 | if opts[:to] || opts[:step] || opts[:all], 71 | do: opts, 72 | else: Keyword.put(opts, :all, true) 73 | 74 | opts = 75 | if opts[:quiet], 76 | do: Keyword.put(opts, :log, false), 77 | else: opts 78 | 79 | Enum.each(repos, fn repo -> 80 | ensure_repo(repo, args) 81 | ensure_migrations_path(repo) 82 | {:ok, pid, apps} = ensure_started(repo, opts) 83 | sandbox? = repo.config[:pool] == Ecto.Adapters.SQL.Sandbox 84 | 85 | # If the pool is Ecto.Adapters.SQL.Sandbox, 86 | # let's make sure we get a connection outside of a sandbox. 87 | if sandbox? do 88 | Ecto.Adapters.SQL.Sandbox.checkin(repo) 89 | Ecto.Adapters.SQL.Sandbox.checkout(repo, sandbox: false) 90 | end 91 | 92 | migrated = try_migrating(repo, migrator, sandbox?, opts) 93 | 94 | pid && repo.stop(pid) 95 | restart_apps_if_migrated(apps, migrated) 96 | end) 97 | end 98 | 99 | defp try_migrating(repo, migrator, sandbox?, opts) do 100 | try do 101 | migrator.(repo, get_migrations(repo), :up, opts) 102 | after 103 | sandbox? && Ecto.Adapters.SQL.Sandbox.checkin(repo) 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/mix/tasks/unbrella.new.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Unbrella.New do 2 | @moduledoc """ 3 | Create a new plugin. 4 | """ 5 | use Mix.Task 6 | 7 | @boolean_options ~w()a 8 | 9 | # complete list of supported options 10 | @switches [ 11 | ] ++ Enum.map(@boolean_options, &({&1, :boolean})) 12 | 13 | def run(args) do 14 | {opts, parsed, _unknown} = OptionParser.parse(args, switches: @switches) 15 | 16 | opts 17 | |> parse_options(parsed) 18 | |> do_config(args) 19 | |> do_run 20 | end 21 | 22 | def do_run(config) do 23 | IO.inspect config.name, label: "Name" 24 | 25 | end 26 | 27 | defp do_config({_bin_opts, _opts, parsed}, _raw_args) do 28 | name = 29 | case parsed do 30 | [name] -> 31 | name 32 | [] -> 33 | Mix.raise "Must provide a name" 34 | other -> 35 | Mix.raise "Invalid arguments #{inspect other}" 36 | end 37 | 38 | %{ 39 | name: name, 40 | } 41 | end 42 | 43 | defp parse_options([], parsed) do 44 | {[], [], parsed} 45 | end 46 | 47 | defp parse_options(opts, parsed) do 48 | bin_opts = Enum.filter(opts, fn {k,_v} -> k in @boolean_options end) 49 | {bin_opts, opts -- bin_opts, parsed} 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/mix/tasks/unbrella.rollback.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Unbrella.Rollback do 2 | use Mix.Task 3 | import Mix.Ecto 4 | import Unbrella.Utils 5 | 6 | @shortdoc "Rolls back the repository migrations" 7 | @recursive true 8 | 9 | @moduledoc """ 10 | Reverts applied migrations in the given repository. 11 | 12 | The repository must be set under `:ecto_repos` in the 13 | current app configuration or given via the `-r` option. 14 | 15 | By default, migrations are expected at "priv/YOUR_REPO/migrations" 16 | directory of the current application but it can be configured 17 | by specifying the `:priv` key under the repository configuration. 18 | 19 | Runs the latest applied migration by default. To roll back to 20 | to a version number, supply `--to version_number`. 21 | To roll back a specific number of times, use `--step n`. 22 | To undo all applied migrations, provide `--all`. 23 | 24 | If the repository has not been started yet, one will be 25 | started outside our application supervision tree and shutdown 26 | afterwards. 27 | 28 | ## Examples 29 | 30 | mix unbrella.rollback 31 | mix unbrella.rollback -r Custom.Repo 32 | 33 | mix unbrella.rollback -n 3 34 | mix unbrella.rollback --step 3 35 | 36 | mix unbrella.rollback -v 20080906120000 37 | mix unbrella.rollback --to 20080906120000 38 | 39 | ## Command line options 40 | 41 | * `-r`, `--repo` - the repo to rollback 42 | * `--all` - revert all applied migrations 43 | * `--step` / `-n` - revert n number of applied migrations 44 | * `--to` / `-v` - revert all migrations down to and including version 45 | * `--quiet` - do not log migration commands 46 | * `--prefix` - the prefix to run migrations on 47 | * `--pool-size` - the pool size if the repository is started only for the task (defaults to 1) 48 | 49 | """ 50 | 51 | @doc false 52 | def run(args, migrator \\ &Ecto.Migrator.run/4) do 53 | repos = parse_repo(args) 54 | 55 | {opts, _, _} = 56 | OptionParser.parse( 57 | args, 58 | switches: [ 59 | all: :boolean, 60 | step: :integer, 61 | to: :integer, 62 | start: :boolean, 63 | quiet: :boolean, 64 | prefix: :string, 65 | pool_size: :integer 66 | ], 67 | aliases: [n: :step, v: :to] 68 | ) 69 | 70 | opts = 71 | if opts[:to] || opts[:step] || opts[:all], 72 | do: opts, 73 | else: Keyword.put(opts, :step, 1) 74 | 75 | opts = 76 | if opts[:quiet], 77 | do: Keyword.put(opts, :log, false), 78 | else: opts 79 | 80 | Enum.each(repos, fn repo -> 81 | ensure_repo(repo, args) 82 | ensure_migrations_path(repo) 83 | {:ok, pid, apps} = ensure_started(repo, opts) 84 | 85 | sandbox? = repo.config[:pool] == Ecto.Adapters.SQL.Sandbox 86 | 87 | # If the pool is Ecto.Adapters.SQL.Sandbox, 88 | # let's make sure we get a connection outside of a sandbox. 89 | if sandbox? do 90 | Ecto.Adapters.SQL.Sandbox.checkin(repo) 91 | Ecto.Adapters.SQL.Sandbox.checkout(repo, sandbox: false) 92 | end 93 | 94 | migrated = try_migrating(repo, migrator, sandbox?, opts) 95 | 96 | pid && repo.stop(pid) 97 | restart_apps_if_migrated(apps, migrated) 98 | end) 99 | end 100 | 101 | defp try_migrating(repo, migrator, sandbox?, opts) do 102 | try do 103 | migrator.(repo, get_migrations(repo), :down, opts) 104 | after 105 | sandbox? && Ecto.Adapters.SQL.Sandbox.checkin(repo) 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/mix/tasks/unbrella.seed.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Unbrella.Seed do 2 | use Mix.Task 3 | import Unbrella.Utils 4 | 5 | @shortdoc "Runs the project and plugin seeds" 6 | @recursive true 7 | 8 | @moduledoc """ 9 | Runs seeds for the given repository. 10 | 11 | 12 | """ 13 | 14 | @doc false 15 | def run(_args) do 16 | # repos = parse_repo(args) 17 | # app = Mix.Project.config[:app] 18 | 19 | Enum.each ["priv/repo/seeds.exs" | get_seeds_paths()], fn path -> 20 | Mix.Tasks.Run.run [path] 21 | end 22 | 23 | end 24 | 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/unbrella.ex: -------------------------------------------------------------------------------- 1 | defmodule Unbrella do 2 | @moduledoc """ 3 | Unbrella is a library to help Phoenix designers build an 4 | application that can be extended with plugins. 5 | 6 | Elixir's umbrella apps work will to package independent apps 7 | in a common project. Once way dependencies work wll with umbrella 8 | apps. However, if you need to share code bidirectionaly between two apps, you 9 | need a different solution. 10 | 11 | Unbrella is designed to allow plugins to extend the schema of a model 12 | defined in the main project. It allow allows inserting routers and 13 | plugin configuration. 14 | 15 | """ 16 | 17 | require Logger 18 | 19 | @doc """ 20 | Return list of the startup children for all plugins 21 | 22 | ## Examples 23 | 24 | iex> UnbrellaTest.Config.set_config! 25 | iex> Unbrella.application_children() 26 | [{Plugin1, :children, []}] 27 | """ 28 | def application_children do 29 | :unbrella 30 | |> Application.get_env(:plugins) 31 | |> Enum.reduce([], fn {_plugin, list}, acc -> 32 | case list[:application] do 33 | nil -> 34 | acc 35 | 36 | module -> 37 | Code.ensure_compiled(module) 38 | 39 | if function_exported?(module, :children, 0) do 40 | [{module, :children, []} | acc] 41 | else 42 | acc 43 | end 44 | end 45 | end) 46 | end 47 | 48 | @doc """ 49 | Allow access to plugin config by name 50 | 51 | i.e. Application.get_env(:ucc_ucx, :router) 52 | 53 | ## Examples 54 | 55 | iex> UnbrellaTest.Config.set_config! 56 | iex> Application.get_env(:unbrella, :plugins) 57 | [ 58 | plugin1: [module: Plugin1, schemas: [Plugin1.User], router: Plugin1.Web.Router, 59 | plugin: Plugin1.Plugin, application: Plugin1], plugin2: [] 60 | ] 61 | 62 | iex> UnbrellaTest.Config.set_config! 63 | iex> Unbrella.apply_plugin_config() 64 | iex> Application.get_all_env(:plugin1) |> Keyword.equal?([ 65 | ...> plugin: Plugin1.Plugin, schemas: [Plugin1.User], module: Plugin1, 66 | ...> router: Plugin1.Web.Router, application: Plugin1]) 67 | true 68 | """ 69 | def apply_plugin_config do 70 | :unbrella 71 | |> Application.get_env(:plugins) 72 | |> Enum.each(fn {p, l} -> 73 | Enum.each(l, &Application.put_env(p, elem(&1, 0), elem(&1, 1))) 74 | end) 75 | end 76 | 77 | @doc """ 78 | Run the start/2 function for each plugin. 79 | 80 | Runs the start/2 function for all plugins that have defined an :application 81 | modules which exports :start/2. 82 | """ 83 | def start(type, args) do 84 | :unbrella 85 | |> Application.get_env(:plugins) 86 | |> Enum.each(fn {_plugin, list} -> 87 | case list[:application] do 88 | nil -> 89 | nil 90 | 91 | module -> 92 | Code.ensure_compiled(module) 93 | 94 | if function_exported?(module, :start, 2) do 95 | apply(module, :start, [type, args]) 96 | end 97 | end 98 | end) 99 | end 100 | 101 | @doc """ 102 | Get all config values for a given key. 103 | 104 | Returns a list of `{plugin_name, value}` for each plugin that has the 105 | given config key with a non nil value. 106 | 107 | ## Examples 108 | 109 | iex> UnbrellaTest.Config.set_config! 110 | iex> Unbrella.config_items(:module) 111 | [plugin1: Plugin1] 112 | 113 | iex> UnbrellaTest.Config.set_config! 114 | iex> Unbrella.config_items(:invalid) 115 | [] 116 | """ 117 | def config_items(key) do 118 | :unbrella 119 | |> Application.get_env(:plugins) 120 | |> Enum.reduce([], fn {plugin, list}, acc -> 121 | case Keyword.get(list, key) do 122 | nil -> acc 123 | item -> [{plugin, item} | acc] 124 | end 125 | end) 126 | end 127 | 128 | @doc false 129 | def js_plugins do 130 | :unbrella 131 | |> Application.get_env(:plugins) 132 | |> Enum.reduce([], fn {plugin, _}, acc -> 133 | plugin = to_string(plugin) 134 | 135 | if File.exists?(Path.join(["plugins", plugin, "package.json"])) do 136 | [to_string(plugin) | acc] 137 | else 138 | acc 139 | end 140 | end) 141 | end 142 | 143 | @doc false 144 | def set_js_plugins(otp_app) do 145 | Application.put_env(otp_app, :js_plugins, js_plugins()) 146 | end 147 | 148 | @doc """ 149 | Get the list of plugin modules. 150 | 151 | Returns a list of all the plugin modules. 152 | 153 | ## Examples 154 | 155 | iex> UnbrellaTest.Config.set_config! 156 | iex> Unbrella.modules() 157 | [Plugin1] 158 | """ 159 | def modules do 160 | :unbrella 161 | |> Application.get_env(:plugins) 162 | |> Enum.reduce([], fn {_, list}, acc -> 163 | if module = list[:module] do 164 | [module | acc] 165 | else 166 | acc 167 | end 168 | end) 169 | end 170 | 171 | @doc """ 172 | Get the list of plugins that have defined a plugin configuration module. 173 | 174 | Filter the list of plugin modules for those plugins that have the `plugin` 175 | key set in their config. 176 | 177 | ## Examples 178 | 179 | iex> UnbrellaTest.Config.set_config! 180 | iex> Unbrella.plugin_modules() 181 | [Plugin1.Plugin] 182 | """ 183 | def plugin_modules do 184 | :unbrella 185 | |> Application.get_env(:plugins, []) 186 | |> Enum.reduce([], fn {_, list}, acc -> 187 | if plugin_module = list[:plugin] do 188 | [plugin_module | acc] 189 | else 190 | acc 191 | end 192 | end) 193 | end 194 | 195 | @option_keys ~w(only_truthy only_results)a 196 | 197 | @doc """ 198 | Return the results of calling an arity 0 function on all plugin_modules. 199 | 200 | For each modules defining a Plugin Module, call the given function, returning 201 | a list of {result, plugin_module} tuples if the result of the calling the 202 | function is truthy. 203 | 204 | ## Options 205 | 206 | * :only_truthy (true) - Include only those results that are not nil or false 207 | * :only_results (false) - When set, only the results are returned, and not the 208 | {results, module_name} 209 | 210 | ## Examples 211 | 212 | iex> UnbrellaTest.Config.set_config! 213 | iex> Unbrella.call_plugin_modules(:test1) 214 | [{:implemented, Plugin1.Plugin}] 215 | 216 | iex> UnbrellaTest.Config.set_config! 217 | iex> Unbrella.call_plugin_modules(:test2) 218 | [] 219 | 220 | iex> UnbrellaTest.Config.set_config! 221 | iex> Unbrella.call_plugin_modules(:test2, only_truthy: false) 222 | [{nil, Plugin1.Plugin}] 223 | 224 | iex> UnbrellaTest.Config.set_config! 225 | iex> Unbrella.call_plugin_modules(:test1, only_results: true) 226 | [:implemented] 227 | 228 | iex> UnbrellaTest.Config.set_config! 229 | iex> Unbrella.call_plugin_modules(:test2, only_truthy: false, only_results: true) 230 | [nil] 231 | 232 | # with args 233 | iex> UnbrellaTest.Config.set_config! 234 | iex> Unbrella.call_plugin_modules(:test_args, [1, 2], only_truthy: false, only_results: true) 235 | [3] 236 | """ 237 | def call_plugin_modules(function) do 238 | call_plugin_modules(function, [], []) 239 | end 240 | 241 | def call_plugin_modules(function, args) do 242 | if Keyword.keyword?(args) and Enum.any?(@option_keys, &Keyword.has_key?(args, &1)) do 243 | call_plugin_modules(function, [], args) 244 | else 245 | call_plugin_modules(function, args, []) 246 | end 247 | end 248 | 249 | def call_plugin_modules(function, args, opts) do 250 | (plugin_modules() || []) 251 | |> Enum.reduce([], fn mod, acc -> 252 | try do 253 | case {apply(mod, function, args), opts[:only_truthy]} do 254 | {result, false} -> 255 | [get_result(result, mod, opts[:only_results]) | acc] 256 | 257 | {result, _} when result not in [nil, false] -> 258 | [get_result(result, mod, opts[:only_results]) | acc] 259 | 260 | _ -> 261 | acc 262 | end 263 | rescue 264 | _ -> acc 265 | end 266 | end) 267 | end 268 | 269 | @doc """ 270 | Call the given function/0 on the given plugin. 271 | 272 | Calls the given function on the plugin's module if one exists. Returns 273 | :error if the plugin module is found but the function is not exported. 274 | 275 | ## Examples: 276 | 277 | iex> UnbrellaTest.Config.set_config! 278 | iex> Unbrella.call_plugin_module(:plugin1, :test1) 279 | :implemented 280 | 281 | iex> UnbrellaTest.Config.set_config! 282 | iex> Unbrella.call_plugin_module(:plugin1, :test2) 283 | nil 284 | 285 | iex> UnbrellaTest.Config.set_config! 286 | iex> Unbrella.call_plugin_module(:plugin1, :test3) 287 | :error 288 | 289 | iex> UnbrellaTest.Config.set_config! 290 | iex> Unbrella.call_plugin_module(:plugin2, :test3) 291 | nil 292 | iex> Unbrella.call_plugin_module(:plugin3, :test3) 293 | nil 294 | """ 295 | def call_plugin_module(name, function, args \\ []) do 296 | with plugins <- Application.get_env(:unbrella, :plugins, []), 297 | config when not is_nil(config) <- plugins[name], 298 | plugin_module when not is_nil(plugin_module) <- config[:plugin], 299 | true <- Code.ensure_compiled?(plugin_module) do 300 | try do 301 | apply(plugin_module, function, args) 302 | rescue 303 | _ -> :error 304 | end 305 | else 306 | {false, :exported} -> :error 307 | _ -> nil 308 | end 309 | end 310 | 311 | @doc """ 312 | Call the given function/0 on the given plugin. 313 | 314 | Same as `Unbrella.call_plugin_module/3' except it raises if the function 315 | is not exported. 316 | 317 | See `Unbrella.call_plugin_module/3' for more details. 318 | """ 319 | def call_plugin_module!(name, function, args \\ []) do 320 | arity = length(args) 321 | 322 | case call_plugin_module(name, function, args) do 323 | :error -> raise("#{inspect(function)}/#{arity} is not exported") 324 | other -> other 325 | end 326 | end 327 | 328 | defp get_result(result, _mod, true), do: result 329 | defp get_result(result, mod, _), do: {result, mod} 330 | 331 | @doc """ 332 | Get the hooks for each plugin exporting a hooks module. 333 | """ 334 | def hooks do 335 | Enum.reduce(modules(), [], fn module, acc -> 336 | module = Module.concat(module, Hooks) 337 | 338 | if function_exported?(module, :hooks, 0) do 339 | module 340 | |> apply(:hooks, []) 341 | |> Enum.reduce(acc, fn {key, value}, acc -> 342 | update_in(acc, [key], fn 343 | nil -> [value] 344 | entry -> [value | entry] 345 | end) 346 | end) 347 | else 348 | acc 349 | end 350 | end) 351 | end 352 | end 353 | -------------------------------------------------------------------------------- /lib/unbrella/hooks.ex: -------------------------------------------------------------------------------- 1 | defmodule Unbrella.Hooks do 2 | @moduledoc """ 3 | API for creating hook functions. 4 | 5 | """ 6 | 7 | @doc false 8 | defmacro __using__(:defhooks) do 9 | quote do 10 | @before_compile unquote(__MODULE__) 11 | 12 | import unquote(__MODULE__) 13 | 14 | Module.register_attribute(__MODULE__, :defhooks, persist: true, accumulate: true) 15 | Module.register_attribute(__MODULE__, :docs, persist: true, accumulate: true) 16 | 17 | Enum.each(Unbrella.modules(), fn module -> 18 | Code.ensure_compiled?(module) 19 | end) 20 | end 21 | end 22 | 23 | @doc false 24 | defmacro __using__(:add_hooks) do 25 | quote do 26 | @before_compile {unquote(__MODULE__), :__add_hooks_compile__} 27 | 28 | import unquote(__MODULE__) 29 | 30 | Module.register_attribute(__MODULE__, :hooks, persist: true, accumulate: true) 31 | end 32 | end 33 | 34 | @doc """ 35 | Add a hook. 36 | 37 | ## Examples 38 | 39 | defmodule UcxUcc.Hooks do 40 | use UcxUcc.Hooks.Api 41 | 42 | add_hook :post_fetch_user, 1, doc; \""" 43 | Called with a list of users after fetched from the database. 44 | 45 | Can be used to add post processing or filtering to the list. 46 | \""" 47 | """ 48 | defmacro defhook(name, arity, opts \\ []) do 49 | quote do 50 | Module.put_attribute(__MODULE__, :defhooks, {unquote(name), unquote(arity)}) 51 | Module.put_attribute(__MODULE__, :docs, {unquote(name), unquote(opts)[:doc]}) 52 | end 53 | end 54 | 55 | @doc """ 56 | Create a hook function. 57 | 58 | Create a hook function in the current module. 59 | 60 | ## Examples 61 | 62 | add_hook :preload_user, [:user, :preload] do 63 | Repo.preload user, [:extension | preload] 64 | end 65 | """ 66 | defmacro add_hook(name, args, do: block) do 67 | contents = Macro.escape(quote(do: unquote(block)), unquote: true) 68 | 69 | quote bind_quoted: [name: name, args: args, contents: contents] do 70 | Module.put_attribute(__MODULE__, :hooks, {name, {__MODULE__, name}}) 71 | args = Enum.map(args, &Macro.var(&1, nil)) 72 | 73 | def unquote(name)(unquote_splicing(args)) do 74 | unquote(contents) 75 | end 76 | end 77 | end 78 | 79 | @doc """ 80 | Add an existing module hook. 81 | 82 | Given an existing hook function, add it to the hooks list. 83 | 84 | ## Examples 85 | 86 | add_hook :hook_function, MyHander, :hook_handler 87 | """ 88 | defmacro add_hook(hook, module, name) do 89 | quote do 90 | Module.put_attribute(__MODULE__, :hooks, {unquote(hook), {unquote(module), unquote(name)}}) 91 | end 92 | end 93 | 94 | @doc """ 95 | Add an existing module hook. 96 | 97 | Given an existing hook function, add it to the hooks list. 98 | 99 | ## Examples 100 | 101 | add_hook MyHander, :hook_function 102 | """ 103 | defmacro add_hook(module, name) do 104 | quote do 105 | Module.put_attribute(__MODULE__, :hooks, {unquote(name), {unquote(module), unquote(name)}}) 106 | end 107 | end 108 | 109 | @doc false 110 | defmacro __before_compile__(_) do 111 | quote unquote: false do 112 | # require Logger 113 | @hook_list Unbrella.hooks() 114 | 115 | # Logger.info "compiling #{inspect __MODULE__}, hook_list: #{inspect @hook_list}" 116 | 117 | def defhooks, do: @defhooks 118 | def hook_list, do: @hook_list 119 | 120 | Enum.each(@defhooks, fn {hook, arity} -> 121 | @plugins @hook_list[hook] || [] 122 | 123 | args = 124 | if arity == 0 do 125 | nil 126 | else 127 | for num <- 1..arity, do: Macro.var(String.to_atom("arg_#{num}"), Elixir) 128 | end 129 | 130 | if doc = @docs[hook] do 131 | @doc doc 132 | end 133 | 134 | if args do 135 | def unquote(hook)(unquote_splicing(args)) do 136 | [h | t] = unquote(args) 137 | 138 | Enum.reduce_while(@plugins, h, fn {module, fun}, acc -> 139 | case apply(module, fun, [acc | t]) do 140 | {:halt, acc} = res -> res 141 | {:cont, acc} = res -> res 142 | other -> {:cont, other} 143 | end 144 | end) 145 | end 146 | else 147 | def unquote(hook)() do 148 | Enum.reduce_while(@plugins, :ok, fn {module, fun}, acc -> 149 | case apply(module, fun, []) do 150 | {:halt, acc} = res -> res 151 | {:cont, acc} = res -> res 152 | :halt -> {:halt, :abort} 153 | :cont -> {:cont, :ok} 154 | other -> {:cont, other} 155 | end 156 | end) 157 | end 158 | end 159 | end) 160 | end 161 | end 162 | 163 | @doc false 164 | defmacro __add_hooks_compile__(_) do 165 | quote unquote: false do 166 | def hooks, do: @hooks 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/unbrella/plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule Unbrella.Plugin do 2 | end 3 | -------------------------------------------------------------------------------- /lib/unbrella/plugin/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Unbrella.Plugin.Router do 2 | @moduledoc """ 3 | Macro to bring plugin routers into your apps router. 4 | 5 | Imports the routes form each plugin into your main router. This is 6 | a simple helper that forwards "/" to each plugin's router.' 7 | 8 | If a plugin has a defined router, then you must add the router 9 | to your plugin's config file. 10 | 11 | # plugins/plugin1/config/config.exs 12 | use Mix.Config 13 | 14 | config :unbrella, :plugins, plugin1: [ 15 | router: Plugin1.Web.Router 16 | ] 17 | 18 | You can then use this module like 19 | 20 | # lib/my_app/web/router.ex 21 | defmodule MyApp.Web.Router do 22 | use MyApp.Web, :router 23 | # ... 24 | scope "/", MyApp.Web do 25 | pipe_through :public 26 | get "/", HomeController, :index 27 | end 28 | 29 | # forward to each plugin 30 | use Unbrella.Plugin.Router 31 | end 32 | 33 | """ 34 | 35 | @doc false 36 | defmacro __using__(_) do 37 | routers = routers() 38 | 39 | quote do 40 | require unquote(__MODULE__) 41 | 42 | for mod <- unquote(routers) do 43 | for scope <- apply(mod, :get_scopes, []) do 44 | {path, options} = 45 | case scope.scope do 46 | list when is_list(list) -> 47 | Keyword.pop(list, :path) 48 | 49 | tuple when is_tuple(tuple) -> 50 | tuple 51 | end 52 | 53 | scope path, options do 54 | Enum.map(scope.pipe_through, &pipe_through/1) 55 | 56 | for {verb, path, plug, plug_opts, options} <- scope.matches do 57 | match(verb, path, plug, plug_opts, options) 58 | end 59 | 60 | for resources <- scope.resources do 61 | Unbrella.Plugin.Router.do_resources(resources) 62 | end 63 | end 64 | end 65 | end 66 | end 67 | end 68 | 69 | defmacro do_resources({path, module, options}) do 70 | quote do 71 | resources(unquote(path), unquote(module), unquote(options)) 72 | end 73 | end 74 | 75 | defmacro do_resources({path, module, options, nested}) do 76 | quote do 77 | resources unquote(path), unquote(module), unquote(options) do 78 | for resources <- unquote(nested) do 79 | do_resources(resources) 80 | end 81 | end 82 | end 83 | end 84 | 85 | @doc """ 86 | Add the plugin routers to the main application router. 87 | 88 | Place this call at the end of your router to include any 89 | plugin routers that are configured in your plugin's config file 90 | 91 | 92 | ## Usage 93 | 94 | Add the call as the bottom of your main router. 95 | 96 | # lib/my_app/web/router.ex 97 | defmodule MyApp.Web.Router do 98 | use MyApp.Web, :router 99 | # ... 100 | scope "/", MyApp.Web do 101 | pipe_through :public 102 | get "/", HomeController, :index 103 | end 104 | 105 | # forward to each plugin 106 | use Unbrella.Plugin.Router 107 | end 108 | 109 | This will pickup the router configured in your plugin. 110 | 111 | defmodule UccChat.Web.Router do 112 | use Plugin1.Web, :router 113 | 114 | pipeline :browser do 115 | # ... 116 | end 117 | 118 | scope "/", Plugin1.Web do 119 | pipe_through :browser 120 | get "/avatar/:username", AvatarController, :show 121 | end 122 | end 123 | """ 124 | def routers do 125 | :unbrella 126 | |> Application.get_env(:plugins) 127 | |> Enum.reduce([], fn {_, list}, acc -> 128 | if router = list[:router], do: [router | acc], else: acc 129 | end) 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/unbrella/plugin/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Unbrella.Plugin.Schema do 2 | @moduledoc """ 3 | Macros to extend a schema from a plugin. 4 | """ 5 | 6 | @doc false 7 | defmacro __using__(schema) do 8 | quote do 9 | import unquote(__MODULE__) 10 | use Ecto.Schema, except: [field: 3] 11 | import Ecto.Changeset 12 | 13 | Module.register_attribute(__MODULE__, :schema_fields, accumulate: true, persist: true) 14 | Module.register_attribute(__MODULE__, :schema_module, persist: true) 15 | 16 | def __target_schema__ do 17 | unquote(schema) 18 | end 19 | end 20 | end 21 | 22 | @doc """ 23 | Add fields to a main apps schmea. 24 | """ 25 | defmacro extend_schema(mod, do: contents) do 26 | quote do 27 | module = unquote(mod) 28 | Module.put_attribute(__MODULE__, :schema_module, module) 29 | unquote(contents) 30 | # IO.puts "schema_fields: #{inspect @schema_fields}" 31 | # IO.puts "schema_fields2: #{inspect hd(@schema_fields)}" 32 | def schema_fields do 33 | {@schema_module, @schema_fields} 34 | end 35 | end 36 | end 37 | 38 | Enum.map(~w(field has_many belongs_to has_one many_to_many embeds_one embeds_many)a, fn field -> 39 | defmacro unquote(field)(name, type, opts \\ []) do 40 | field = unquote(field) 41 | 42 | quote bind_quoted: [name: name, type: type, opts: opts, field: field] do 43 | Module.put_attribute(__MODULE__, :schema_fields, {field, name, type, opts}) 44 | end 45 | end 46 | end) 47 | end 48 | -------------------------------------------------------------------------------- /lib/unbrella/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Unbrella.Router do 2 | @moduledoc """ 3 | Router macros for defining plugin routes. 4 | 5 | This module provides the a subset of the Phoenix Router API for defining routes 6 | for an Unbrella plugin. 7 | 8 | ## Examples 9 | 10 | defmodule MyPluginWeb.Router do 11 | use Unbrella.Router 12 | 13 | scope "/", MyPluginWeb do 14 | pipe_through :browser 15 | 16 | get "/test", TestController, :index 17 | get "/test/:id", TestController, :show 18 | post "/test", TestController, :create 19 | end 20 | end 21 | 22 | defmodule MyUnbrellaAppWeb.Router do 23 | use MyUnbrellaAppWeb, :router 24 | 25 | pipeline :browser do 26 | plug(:accepts, ["html", "md"]) 27 | plug(:fetch_session) 28 | plug(:fetch_flash) 29 | plug(:protect_from_forgery) 30 | plug(:put_secure_browser_headers) 31 | end 32 | 33 | pipeline :api do 34 | plug(:accepts, ["json"]) 35 | end 36 | 37 | scope "/", MyUnbrellaAppWeb do 38 | pipe_through :browser 39 | 40 | get "/", HomeController, :index 41 | end 42 | 43 | # include all the plugins that have defined a router 44 | use Unbrella.Plugin.Router 45 | end 46 | 47 | # will provide the following helpers 48 | 49 | * MyUnbrellaAppWeb.Router.Helpers: 50 | * `home_path`, `home_url` 51 | * `test_path`, `test_path` 52 | """ 53 | 54 | @http_methods [:get, :post, :put, :patch, :delete, :options, :connect, :trace, :head] 55 | 56 | defmacro __using__(_) do 57 | quote do 58 | @before_compile unquote(__MODULE__) 59 | 60 | import unquote(__MODULE__) 61 | 62 | Module.register_attribute(__MODULE__, :scopes, persist: true, accumulate: true) 63 | Module.register_attribute(__MODULE__, :pipes, persist: false, accumulate: false) 64 | Module.register_attribute(__MODULE__, :matches, persist: false, accumulate: false) 65 | Module.register_attribute(__MODULE__, :resources, persist: false, accumulate: false) 66 | Module.put_attribute(__MODULE__, :pipes, []) 67 | Module.put_attribute(__MODULE__, :matches, []) 68 | Module.put_attribute(__MODULE__, :resources, []) 69 | end 70 | end 71 | 72 | defmacro scope(path, options, do: block) do 73 | add_scope(path, options, block) 74 | end 75 | 76 | defmacro scope(options, do: block) do 77 | add_scope(nil, options, block) 78 | end 79 | 80 | defmacro scope(path, alias, options, do: block) do 81 | options = 82 | quote do 83 | unquote(options) 84 | |> Keyword.put(:path, unquote(path)) 85 | |> Keyword.put(:alias, unquote(alias)) 86 | end 87 | 88 | add_scope(nil, options, block) 89 | end 90 | 91 | defp add_scope(path, options, block) do 92 | quote location: :keep do 93 | unquote(block) 94 | pipes = Module.get_attribute(__MODULE__, :pipes) |> Enum.reverse() 95 | matches = Module.get_attribute(__MODULE__, :matches) |> Enum.reverse() 96 | resources = Module.get_attribute(__MODULE__, :resources) |> Enum.reverse() 97 | Module.put_attribute(__MODULE__, :pipes, []) 98 | Module.put_attribute(__MODULE__, :matches, []) 99 | Module.put_attribute(__MODULE__, :resources, []) 100 | path = unquote(path) 101 | options = unquote(options) 102 | scope_value = if is_nil(path), do: options, else: {path, options} 103 | 104 | scope = %{ 105 | scope: scope_value, 106 | pipe_through: pipes, 107 | matches: matches, 108 | resources: resources 109 | } 110 | 111 | Module.put_attribute(__MODULE__, :scopes, scope) 112 | end 113 | end 114 | 115 | defmacro pipe_through(options) do 116 | quote do 117 | Module.put_attribute(__MODULE__, :pipes, [ 118 | unquote(options) | Module.get_attribute(__MODULE__, :pipes) 119 | ]) 120 | end 121 | end 122 | 123 | defmacro resources(path, controller) do 124 | add_resources(path, controller, []) 125 | end 126 | 127 | defmacro resources(path, controller, do: block) do 128 | add_resources(path, controller, [], block) 129 | end 130 | 131 | defmacro resources(path, controller, options) do 132 | add_resources(path, controller, options) 133 | end 134 | 135 | defmacro resources(path, controller, options, do: block) do 136 | add_resources(path, controller, options, block) 137 | end 138 | 139 | defp add_resources(path, controller, options) do 140 | quote do 141 | Module.put_attribute(__MODULE__, :resources, [ 142 | {unquote(path), unquote(controller), unquote(options)} 143 | | Module.get_attribute(__MODULE__, :resources) 144 | ]) 145 | end 146 | end 147 | 148 | defp add_resources(path, controller, options, block) do 149 | quote do 150 | resources1 = Module.get_attribute(__MODULE__, :resources) 151 | Module.put_attribute(__MODULE__, :resources, []) 152 | unquote(block) 153 | resources2 = Module.get_attribute(__MODULE__, :resources) |> Enum.reverse() 154 | 155 | Module.put_attribute(__MODULE__, :resources, [ 156 | {unquote(path), unquote(controller), unquote(options), resources2} | resources1 157 | ]) 158 | end 159 | end 160 | 161 | defmacro match(verb, path, plug, plug_opts, options \\ []) do 162 | add_match(verb, path, plug, plug_opts, options) 163 | end 164 | 165 | for verb <- @http_methods do 166 | defmacro unquote(verb)(path, plug, plug_opts, options \\ []) do 167 | add_match(unquote(verb), path, plug, plug_opts, options) 168 | end 169 | end 170 | 171 | defp add_match(verb, path, plug, plug_opts, options) do 172 | quote do 173 | match = {unquote(verb), unquote(path), unquote(plug), unquote(plug_opts), unquote(options)} 174 | 175 | Module.put_attribute(__MODULE__, :matches, [ 176 | match | Module.get_attribute(__MODULE__, :matches) 177 | ]) 178 | end 179 | end 180 | 181 | defmacro __before_compile__(_) do 182 | quote unquote: false do 183 | def get_scopes, do: Enum.reverse(@scopes) 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/unbrella/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Unbrella.Schema do 2 | @moduledoc """ 3 | Support extending schmea from plugins. 4 | 5 | Use the moduile in your main project's schmea file to all plugins 6 | to extend the schmea and add changeset callbacks. By using this module, 7 | you are overriding the `Ecto.Schema.schema` macros. This allows us 8 | to append the plugin's schema extensions during compile time.' 9 | 10 | ## Usage 11 | 12 | # lib/my_app/accounts/account.ex 13 | defmodule MyApp.Accounts.Account do 14 | use Unbrella.Schema 15 | import Ecto.Changeset 16 | 17 | schema "accounts_accounts" do 18 | belongs_to :user, User 19 | timestamps(type: :utc_datetime) 20 | end 21 | 22 | def changeset(%Account{} = account, attrs \\ %{}) do 23 | account 24 | |> cast(attrs, [:user_id]) 25 | |> validate_required([:user_id]) 26 | |> plugin_changesets(attrs, Account) 27 | end 28 | end 29 | 30 | Now you can extend the account schema in your plugin. 31 | 32 | # plugins/plugin1/lib/plugin1/accounts/account.ex 33 | defmodule Plugin1.Accounts.Account do 34 | use Unbrella.Plugin.Schema, MyApp.Accounts.Account 35 | import Ecto.Query 36 | 37 | extend_schema MyApp.Accounts.Account do 38 | field :language, :string, default: "on" 39 | field :notification_enabled, :boolean, default: true 40 | many_to_many :notifications, MyApp.Notification, join_through: MyApp.AccountNotification 41 | end 42 | 43 | def changeset(changeset, params \\ %{}) do 44 | changeset 45 | |> cast(params, [:language, :notification_enabled]) 46 | |> validate_required([:language, :notification_enabled]) 47 | end 48 | end 49 | """ 50 | 51 | import Unbrella.Utils 52 | 53 | @doc false 54 | defmacro __using__(_) do 55 | quote do 56 | import unquote(__MODULE__) 57 | use Ecto.Schema 58 | import Ecto.Schema, except: [schema: 1, schema: 2] 59 | end 60 | end 61 | 62 | @doc """ 63 | Macro to append all the plugins schema extensions. 64 | 65 | Appends each plugins' `extend_schema` fields to the main schema. 66 | 67 | To use this macro, simply replace the `use Ecto.Schema` with 68 | `use Unbrella.Schema`. That will remove the normally imported 69 | `Ecto.Schema.schema` macro. 70 | """ 71 | defmacro schema(table, do: block) do 72 | calling_mod = __CALLER__.module 73 | 74 | modules = 75 | calling_mod 76 | |> get_modules 77 | |> List.flatten() 78 | |> Macro.escape() 79 | 80 | quote do 81 | Ecto.Schema.schema unquote(table) do 82 | unquote(block) 83 | require Ecto.Schema 84 | 85 | Enum.map(unquote(modules), fn {fun, name, mod, opts} = abc -> 86 | case fun do 87 | :has_many -> 88 | Ecto.Schema.has_many(name, mod, opts) 89 | 90 | :field -> 91 | Ecto.Schema.field(name, mod, opts) 92 | 93 | :has_one -> 94 | Ecto.Schema.has_one(name, mod, opts) 95 | 96 | :belongs_to -> 97 | Ecto.Schema.belongs_to(name, mod, opts) 98 | 99 | :many_to_many -> 100 | Ecto.Schema.many_to_many(name, mod, opts) 101 | 102 | :embeds_many -> 103 | Ecto.Schema.embeds_many(name, mod, opts) 104 | 105 | :embeds_one -> 106 | Ecto.Schema.embeds_one(name, mod, opts) 107 | end 108 | end) 109 | end 110 | end 111 | end 112 | 113 | @doc """ 114 | Call each plugins' `changeset/2` function. 115 | 116 | ## Usage 117 | 118 | # lib/my_app/accounts/account.ex 119 | defmodule MyApp.Accounts.Account do 120 | use Unbrella.Schema 121 | import Ecto.Changeset 122 | # ... 123 | def changeset(%Account{} = account, attrs \\ %{}) do 124 | account 125 | |> cast(attrs, [:user_id]) 126 | |> validate_required([:user_id]) 127 | |> plugin_changesets(attrs, Account) 128 | end 129 | end 130 | """ 131 | defmacro plugin_changesets(changeset, attrs, schema) do 132 | changesets = 133 | Enum.reduce(get_schemas(), [], fn mod, acc -> 134 | if function_exported?(mod, :changeset, 2) do 135 | [mod | acc] 136 | else 137 | acc 138 | end 139 | end) 140 | 141 | quote bind_quoted: [ 142 | changeset: changeset, 143 | attrs: attrs, 144 | schema: schema, 145 | changesets: changesets 146 | ] do 147 | Enum.reduce(changesets, changeset, fn mod, acc -> 148 | if mod.__target_schema__() == schema do 149 | apply(mod, :changeset, [acc, attrs]) 150 | else 151 | acc 152 | end 153 | end) 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/unbrella/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Unbrella.Utils do 2 | @moduledoc false 3 | import Mix.Ecto 4 | 5 | @doc false 6 | @spec get_modules(atom) :: List.t() 7 | def get_modules(calling_mod) do 8 | get_schemas() 9 | |> Enum.map(fn mod -> 10 | Code.ensure_compiled(mod) 11 | mod 12 | end) 13 | |> Enum.reduce([], fn mod, acc -> 14 | case mod.schema_fields() do 15 | {^calling_mod, entry} -> [entry | acc] 16 | _ -> acc 17 | end 18 | end) 19 | end 20 | 21 | @doc false 22 | @spec get_schemas() :: List.t() 23 | def get_schemas do 24 | :unbrella 25 | |> Application.get_env(:plugins) 26 | |> Enum.reduce([], fn {_, list}, acc -> 27 | if mods = list[:schemas], do: acc ++ mods, else: acc 28 | end) 29 | end 30 | 31 | @doc false 32 | @spec get_migration_paths() :: [String.t()] 33 | def get_migration_paths do 34 | get_plugin_paths(~w(priv repo migrations)) 35 | end 36 | 37 | @doc false 38 | @spec get_seeds_paths() :: [String.t()] 39 | def get_seeds_paths do 40 | get_plugin_paths(~w(priv repo seeds.exs)) 41 | end 42 | 43 | @spec get_plugin_paths([String.t()]) :: [String.t()] 44 | def get_plugin_paths(paths \\ [""]) do 45 | :unbrella 46 | |> Application.get_env(:plugins) 47 | |> Enum.reduce([], fn {plugin, list}, acc -> 48 | path = Path.join(["plugins", list[:path] || to_string(plugin) | paths]) 49 | 50 | if File.exists?(path), do: [path | acc], else: acc 51 | end) 52 | end 53 | 54 | def get_plugins do 55 | Application.get_env(:unbrella, :plugins) 56 | end 57 | 58 | def get_assets_paths do 59 | Enum.reduce(get_plugins(), [], fn {name, config}, acc -> 60 | case config[:assets] do 61 | nil -> 62 | acc 63 | 64 | assets -> 65 | path = Path.join(["plugins", config[:path] || to_string(name), "assets"]) 66 | 67 | Enum.map(assets, fn {src, dest} -> 68 | %{ 69 | src: src, 70 | name: name, 71 | destination_path: Path.join(["assets", to_string(src), to_string(dest)]), 72 | source_path: Path.join([path, to_string(src)]) 73 | } 74 | end) ++ acc 75 | end 76 | end) 77 | end 78 | 79 | def get_migrations(repo, _args \\ []) do 80 | priv_migrations_path = Path.join([source_repo_priv(repo), "migrations", "*"]) 81 | 82 | base_paths = 83 | priv_migrations_path 84 | |> Path.wildcard() 85 | |> Enum.filter(&(Path.extname(&1) == ".exs")) 86 | 87 | plugin_paths = 88 | ["plugins", "*", priv_migrations_path] 89 | |> Path.join() 90 | |> Path.wildcard() 91 | |> Enum.filter(&(Path.extname(&1) == ".exs")) 92 | 93 | build_migrate_files(base_paths ++ plugin_paths) 94 | end 95 | 96 | defp build_migrate_files(paths) do 97 | paths 98 | |> Enum.map(fn path -> 99 | [_, num] = Regex.run(~r/^([0-9]+)/, Path.basename(path)) 100 | {num, path} 101 | end) 102 | |> Enum.sort(&(elem(&1, 0) <= elem(&2, 0))) 103 | |> List.foldr([], fn {num, path}, acc -> 104 | case Code.eval_file(path) do 105 | {{:module, mod, _, _}, _} -> 106 | [{String.to_integer(num), mod} | acc] 107 | 108 | other -> 109 | IO.puts("error for #{path}: " <> inspect(other)) 110 | acc 111 | end 112 | end) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Unbrella.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :unbrella, 7 | version: "1.0.2", 8 | elixir: "~> 1.4", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | dialyzer: [plt_add_apps: [:mix]], 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | test_coverage: [tool: ExCoveralls], 14 | preferred_cli_env: [ 15 | coveralls: :test, 16 | "coveralls.detail": :test, 17 | "coveralls.post": :test, 18 | "coveralls.html": :test 19 | ], 20 | aliases: aliases(), 21 | deps: deps() 22 | ] 23 | end 24 | 25 | def application do 26 | # Specify extra applications you'll use from Erlang/Elixir 27 | [extra_applications: [:logger]] 28 | end 29 | 30 | defp elixirc_paths(:test), do: ["lib", "test/support"] 31 | defp elixirc_paths(_), do: ["lib"] 32 | 33 | defp aliases do 34 | [commit: ["deps.get --only #{Mix.env()}", "dialyzer", "credo --strict"]] 35 | end 36 | 37 | defp deps do 38 | [ 39 | {:phoenix, "~> 1.3"}, 40 | {:phoenix_ecto, "~> 3.2"}, 41 | {:dialyxir, "~> 0.0", only: [:dev], runtime: false}, 42 | {:excoveralls, "~> 0.10", only: :test}, 43 | {:credo, "~> 1.0", only: [:dev, :test], runtime: false} 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "credo": {:hex, :credo, "1.0.4", "d2214d4cc88c07f54004ffd5a2a27408208841be5eca9f5a72ce9e8e835f7ede", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"}, 6 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 7 | "ecto": {:hex, :ecto, "2.2.11", "4bb8f11718b72ba97a2696f65d247a379e739a0ecabf6a13ad1face79844791c", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 8 | "excoveralls": {:hex, :excoveralls, "0.10.6", "e2b9718c9d8e3ef90bc22278c3f76c850a9f9116faf4ebe9678063310742edc2", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 13 | "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [:mix, :rebar3], [], "hexpm"}, 14 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 15 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 16 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, 17 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 18 | "phoenix": {:hex, :phoenix, "1.4.2", "3a1250f22010daeee265923bae02f10b5434b569b999c1b18100b5da05834d93", [: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"}, 19 | "phoenix_ecto": {:hex, :phoenix_ecto, "3.6.0", "d65dbcedd6af568d8582dcd7da516c3051016bad51f9953e5337fea40bcd8a9d", [:mix], [{:ecto, "~> 2.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 20 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, 21 | "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 23 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 24 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, 25 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, 26 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 27 | } 28 | -------------------------------------------------------------------------------- /priv/priv/templates/unbrella.new/README.md: -------------------------------------------------------------------------------- 1 | # <%= module %> 2 | 3 | A plug-in for <%= parent_module %> 4 | -------------------------------------------------------------------------------- /priv/priv/templates/unbrella.new/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :unbrella, :plugins, <%= otp_app %>: [ 4 | module: <%= module %> 5 | ] 6 | -------------------------------------------------------------------------------- /priv/priv/templates/unbrella.new/lib/otp_app.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= module %> do 2 | @doc """ 3 | The main application file for <%= otp_app %>. 4 | """ 5 | 6 | def children do 7 | import Supervisor.Spec, warn: false 8 | 9 | [ 10 | # Enter services to start for this plug-in 11 | # worker(<%= module %>.Server, []) 12 | ] 13 | end 14 | 15 | def start(_type, _args) do 16 | @doc """ 17 | Add initialize code below. 18 | """ 19 | nil 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /priv/priv/templates/unbrella.new/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule <%= module %>.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :<%= otp_app %>, 7 | version: "0.1.0" 8 | ] 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/lib/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Unbrella.RouterTest do 2 | use ExUnit.Case 3 | 4 | defmodule TestWeb.Router do 5 | use Unbrella.Router 6 | 7 | scope "/", TestWeb do 8 | pipe_through(:browser) 9 | get("/", HomeController, :index) 10 | get("/:id", HomeController, :show, option: :first) 11 | end 12 | 13 | scope "/one", TestWeb do 14 | pipe_through([:browser, :api]) 15 | pipe_through(:other) 16 | get("/", OneController, :index) 17 | get("/:id", OneController, :show) 18 | post("/", OneController, :create) 19 | put("/:id", OneController, :update) 20 | patch("/:id", OneController, :update) 21 | delete("/:id", OneController, :delete) 22 | end 23 | 24 | scope "/", TestWeb do 25 | pipe_through(:browser) 26 | resources("/account", AccountController) 27 | resources("/user", UserController, only: [:show], singleton: true) 28 | end 29 | 30 | scope "/top", TestWeb do 31 | pipe_through(:other) 32 | 33 | resources "/", TopController, only: [:index, :show] do 34 | resources("/nested", NestedController, only: [:show]) 35 | 36 | resources "/three", ThreeController do 37 | resources("/four", FourController) 38 | end 39 | end 40 | end 41 | 42 | scope path: "/api/v1", as: :api_v1, alias: API.V1 do 43 | get("/pages/:id", PageController, :show) 44 | end 45 | 46 | scope "/api/v1", API.V1, as: :api_v1 do 47 | get("/pages/:id", PageController, :show) 48 | end 49 | end 50 | 51 | test "supports root scope" do 52 | expected = %{ 53 | scope: {"/", TestWeb}, 54 | pipe_through: [:browser], 55 | matches: [ 56 | {:get, "/", HomeController, :index, []}, 57 | {:get, "/:id", HomeController, :show, [option: :first]} 58 | ], 59 | resources: [] 60 | } 61 | 62 | [scope1 | _] = TestWeb.Router.get_scopes() 63 | assert scope1 == expected 64 | end 65 | 66 | test "supports non root scope" do 67 | expected = %{ 68 | scope: {"/one", TestWeb}, 69 | pipe_through: [[:browser, :api], :other], 70 | matches: [ 71 | {:get, "/", OneController, :index, []}, 72 | {:get, "/:id", OneController, :show, []}, 73 | {:post, "/", OneController, :create, []}, 74 | {:put, "/:id", OneController, :update, []}, 75 | {:patch, "/:id", OneController, :update, []}, 76 | {:delete, "/:id", OneController, :delete, []} 77 | ], 78 | resources: [] 79 | } 80 | 81 | [_, scope2 | _] = TestWeb.Router.get_scopes() 82 | assert scope2 == expected 83 | end 84 | 85 | test "supports resources" do 86 | expected = %{ 87 | scope: {"/", TestWeb}, 88 | pipe_through: [:browser], 89 | matches: [], 90 | resources: [ 91 | {"/account", AccountController, []}, 92 | {"/user", UserController, [only: [:show], singleton: true]} 93 | ] 94 | } 95 | 96 | [_, _, scope3 | _] = TestWeb.Router.get_scopes() 97 | assert scope3 == expected 98 | end 99 | 100 | test "nested resources" do 101 | expected = %{ 102 | scope: {"/top", TestWeb}, 103 | pipe_through: [:other], 104 | matches: [], 105 | resources: [ 106 | {"/", TopController, [only: [:index, :show]], 107 | [ 108 | {"/nested", NestedController, [only: [:show]]}, 109 | {"/three", ThreeController, [], 110 | [ 111 | {"/four", FourController, []} 112 | ]} 113 | ]} 114 | ] 115 | } 116 | 117 | [_, _, _, scope4 | _] = TestWeb.Router.get_scopes() 118 | assert scope4 == expected 119 | end 120 | 121 | # scope path: "/api/v1", as: :api_v1, alias: API.V1 do 122 | # get "/pages/:id", PageController, :show 123 | # end 124 | test "scope with options only" do 125 | expected = %{ 126 | scope: [path: "/api/v1", as: :api_v1, alias: API.V1], 127 | pipe_through: [], 128 | matches: [{:get, "/pages/:id", PageController, :show, []}], 129 | resources: [] 130 | } 131 | 132 | [_, _, _, _, scope5 | _] = TestWeb.Router.get_scopes() 133 | assert scope5 == expected 134 | end 135 | 136 | # scope "/api/v1", API.V1, as: :api_v1 do 137 | # get "/pages/:id", PageController, :show 138 | # end 139 | test "scope path alias options" do 140 | expected = %{ 141 | scope: [alias: API.V1, path: "/api/v1", as: :api_v1], 142 | pipe_through: [], 143 | matches: [{:get, "/pages/:id", PageController, :show, []}], 144 | resources: [] 145 | } 146 | 147 | [_, _, _, _, _, scope6 | _] = TestWeb.Router.get_scopes() 148 | assert scope6 == expected 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /test/lib/unbrella_test.exs: -------------------------------------------------------------------------------- 1 | defmodule UnbrellaTest do 2 | use ExUnit.Case 3 | doctest Unbrella 4 | 5 | setup do 6 | UnbrellaTest.Config.set_config!() 7 | :ok 8 | end 9 | 10 | test "call_plugin_modules undefined function" do 11 | assert Unbrella.call_plugin_modules(:undefined, only_results: true) == [] 12 | end 13 | 14 | test "call_plugin_module undefined function" do 15 | assert Unbrella.call_plugin_module(:plugin1, :undefined, only_results: true) == :error 16 | end 17 | 18 | test "call_plugin_module! undefined function" do 19 | assert_raise RuntimeError, fn -> 20 | Unbrella.call_plugin_module!(:plugin1, :undefined) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/lib/utils_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../mix_helpers.exs", __DIR__) 2 | 3 | defmodule UnbrellaTest.Utils do 4 | use ExUnit.Case 5 | doctest Unbrella.Utils 6 | 7 | import MixHelper 8 | alias Unbrella.Utils 9 | 10 | setup do 11 | Application.put_env(:unbrella, :plugins, plugin_one: [], plugin_two: []) 12 | :ok 13 | end 14 | 15 | test "get_plugin_paths" do 16 | in_tmp("get_plugin_paths", fn -> 17 | mk_plugsins() 18 | assert Utils.get_plugin_paths() == ~w(plugins/plugin_two plugins/plugin_one) 19 | 20 | assert Utils.get_plugin_paths(~w(config)) == 21 | ~w(plugins/plugin_two/config plugins/plugin_one/config) 22 | end) 23 | end 24 | 25 | test "get_migration_paths" do 26 | in_tmp("get_migration_paths", fn -> 27 | mk_plugsins() 28 | 29 | assert Utils.get_migration_paths() == 30 | ~w(plugins/plugin_two/priv/repo/migrations plugins/plugin_one/priv/repo/migrations) 31 | end) 32 | end 33 | 34 | test "get_seeds_paths" do 35 | in_tmp("get_plugin_paths", fn -> 36 | mk_plugsins() 37 | File.touch("plugins/plugin_two/priv/repo/seeds.exs") 38 | assert Utils.get_seeds_paths() == ~w(plugins/plugin_two/priv/repo/seeds.exs) 39 | end) 40 | end 41 | 42 | defp mk_plugsins do 43 | :unbrella 44 | |> Application.get_env(:plugins) 45 | |> Enum.each(fn {plugin, _} -> 46 | Enum.each(~w(config test priv/repo/migrations), fn path -> 47 | File.mkdir_p!(Path.join(["plugins", to_string(plugin), path])) 48 | end) 49 | end) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/mix_helpers.exs: -------------------------------------------------------------------------------- 1 | # Get Mix output sent to the current 2 | # process to avoid polluting tests. 3 | Mix.shell(Mix.Shell.Process) 4 | 5 | defmodule Phoenix.LiveReloader do 6 | def init(opts), do: opts 7 | def call(conn, _), do: conn 8 | end 9 | 10 | defmodule MixHelper do 11 | import ExUnit.Assertions 12 | import ExUnit.CaptureIO 13 | 14 | def tmp_path do 15 | Path.expand("../../tmp", __DIR__) 16 | end 17 | 18 | def in_tmp(which, function) do 19 | path = Path.join(tmp_path(), which) 20 | File.rm_rf!(path) 21 | File.mkdir_p!(path) 22 | File.cd!(path, function) 23 | end 24 | 25 | def in_project(app, path, fun) do 26 | %{name: name, file: file} = Mix.Project.pop() 27 | 28 | try do 29 | capture_io(:stderr, fn -> 30 | Mix.Project.in_project(app, path, [], fun) 31 | end) 32 | after 33 | Mix.Project.push(name, file) 34 | end 35 | end 36 | 37 | def assert_file(file) do 38 | assert File.regular?(file), "Expected #{file} to exist, but does not" 39 | end 40 | 41 | def refute_file(file) do 42 | refute File.regular?(file), "Expected #{file} to not exist, but it does" 43 | end 44 | 45 | def assert_file(file, match) do 46 | cond do 47 | is_list(match) -> 48 | assert_file(file, &Enum.each(match, fn m -> assert &1 =~ m end)) 49 | 50 | is_binary(match) or Regex.regex?(match) -> 51 | assert_file(file, &assert(&1 =~ match)) 52 | 53 | is_function(match, 1) -> 54 | assert_file(file) 55 | match.(File.read!(file)) 56 | end 57 | end 58 | 59 | def with_generator_env(new_env, fun) do 60 | old = Application.get_env(:phoenix, :generators) 61 | Application.put_env(:phoenix, :generators, new_env) 62 | 63 | try do 64 | fun.() 65 | after 66 | Application.put_env(:phoenix, :generators, old) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/support/config.ex: -------------------------------------------------------------------------------- 1 | defmodule UnbrellaTest.Config do 2 | def set_config! do 3 | Application.put_env( 4 | :unbrella, 5 | :plugins, 6 | plugin1: [ 7 | module: Plugin1, 8 | schemas: [Plugin1.User], 9 | router: Plugin1.Web.Router, 10 | plugin: Plugin1.Plugin, 11 | application: Plugin1 12 | ], 13 | plugin2: [] 14 | ) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/my_app/plugins/plugin1/plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugin1.Plugin do 2 | def test1, do: :implemented 3 | def test2, do: nil 4 | def test_args(arg1, arg2), do: arg1 + arg2 5 | end 6 | -------------------------------------------------------------------------------- /test/support/my_app/plugins/plugin1/plugin1.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugin1 do 2 | 3 | def children do 4 | [ 5 | Plugin1.Service1, 6 | Plugin1.Service2 7 | ] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/support/my_app/plugins/plugin1/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugin1.Web.Router do 2 | end 3 | -------------------------------------------------------------------------------- /test/support/my_app/plugins/plugin1/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugin1.User do 2 | use Unbrella.Plugin.Schema, MyApp.User 3 | 4 | extend_schema MyApp.User do 5 | field(:test, :string, default: "test") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:unbrella) 2 | ExUnit.start() 3 | 4 | --------------------------------------------------------------------------------