├── .dialyzer_ignore.exs ├── .formatter.exs ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── guides ├── Configuration.md ├── Routing.md └── Your First Aggregate.md ├── integration_test ├── eventstore │ ├── eventstore_adapter_test.exs │ └── test_helper.exs └── support │ ├── adapter_cases.exs │ ├── aggregates.exs │ ├── assertions.exs │ ├── events.exs │ └── journals.exs ├── lib ├── helios.ex ├── helios │ ├── aggregate.ex │ ├── aggregate │ │ ├── identity.ex │ │ ├── server.ex │ │ ├── snapshot_offer.ex │ │ └── supervisor.ex │ ├── code_reloader │ │ ├── proxy.ex │ │ └── server.ex │ ├── config.ex │ ├── context.ex │ ├── endpoint.ex │ ├── endpoint │ │ ├── facade.ex │ │ ├── handler.ex │ │ ├── instrument.ex │ │ └── supervisor.ex │ ├── event_journal.ex │ ├── event_journal │ │ ├── adapter.ex │ │ ├── adapters │ │ │ ├── eventstore.ex │ │ │ └── memory.ex │ │ └── messages.ex │ ├── logger.ex │ ├── naming.ex │ ├── param.ex │ ├── pipeline.ex │ ├── pipeline │ │ ├── adapter.ex │ │ ├── adapter │ │ │ └── test.ex │ │ ├── builder.ex │ │ ├── errors.ex │ │ ├── plug.ex │ │ └── test.ex │ ├── plugs │ │ └── helios_logger.ex │ ├── registry.ex │ ├── registry │ │ ├── distribution │ │ │ ├── ring.ex │ │ │ ├── static_quorum_ring.ex │ │ │ └── strategy.ex │ │ ├── tracker.ex │ │ └── tracker │ │ │ ├── entry.ex │ │ │ └── interval_tree_clock.ex │ ├── router.ex │ └── router │ │ ├── aggregate.ex │ │ ├── console_formatter.ex │ │ ├── route.ex │ │ ├── scope.ex │ │ ├── subscription.ex │ │ └── utils.ex └── mix │ ├── helios.ex │ └── tasks │ ├── helios.routes.ex │ └── helios.server.ex ├── mix.exs ├── mix.lock ├── test ├── helios │ ├── aggregate_server_test.exs │ ├── aggregate_test.exs │ ├── endpoint │ │ └── endpoint_test.exs │ ├── integration_test.exs │ ├── router │ │ ├── route_test.exs │ │ └── routing_test.exs │ └── test_helper.exs └── support │ └── router_helper.exs └── test_all.sh /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | {":0:unknown_function Function Helios.Param.Float.__impl__/1 does not exist."}, 3 | {":0:unknown_function Function Helios.Param.Function.__impl__/1 does not exist."}, 4 | {":0:unknown_function Function Helios.Param.List.__impl__/1 does not exist."}, 5 | {":0:unknown_function Function Helios.Param.PID.__impl__/1 does not exist."}, 6 | {":0:unknown_function Function Helios.Param.Port.__impl__/1 does not exist."}, 7 | {":0:unknown_function Function Helios.Param.Reference.__impl__/1 does not exist."}, 8 | {":0:unknown_function Function Helios.Param.Tuple.__impl__/1 does not exist."} 9 | ] 10 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.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 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | helios-*.tar 24 | 25 | .elixir_ls/ 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | elixir: 4 | - 1.8.1 5 | 6 | otp_release: 7 | - 21.1 8 | 9 | env: 10 | - MIX_ENV=test 11 | 12 | script: mix coveralls.travis -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.1.0 2 | 3 | ## Features 4 | - Aggregate Behaviour 5 | - Aggregate Server and supervisior 6 | - Aggregate Registry and distributed Tracker based on swarm 7 | - Endpoint routing for commands. Endpoint must be started in 8 | application supervision three, e.g. `{MyApp.Endpoint, []}` 9 | - Autogenerated facade per aggregate during compile time. If router module 10 | namespace is `MyApp.Router`, facade is compiled in `MyApp.Facade` module 11 | where submodules will be named by aggregate name, e.g. 12 | `MyApp.Aggregates.UserAggregate`, facade for this aggregate is 13 | generated as `MyApp.Facade.User` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Hex version badge](https://img.shields.io/hexpm/v/helios.svg)](https://hex.pm/packages/helios) 2 | [![Build Status](https://travis-ci.org/exponentially/helios.svg?branch=master)](https://travis-ci.org/exponentially/helios) 3 | [![Coverage Status](https://coveralls.io/repos/github/exponentially/helios/badge.svg?branch=master)](https://coveralls.io/github/exponentially/helios?branch=master) 4 | 5 | # Helios 6 | 7 | A building blocks for elixir CQRS segregated applications. 8 | 9 | ## Installation 10 | 11 | The package can be installed by adding `helios` to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:helios, "~> 0.2"} 17 | ] 18 | end 19 | ``` 20 | 21 | Example application can be seen [here](https://github.com/exponentially/helios_example) 22 | 23 | ## Configuration 24 | 25 | It is important to knwo that there is minimum configuration that has to be explicitly 26 | configured in your application, without it application will not work or start. 27 | 28 | ### Default Event Journal 29 | ```elixir 30 | use Mix.Config 31 | 32 | config :your_app, :default_journal, 33 | YourApp.DefaultJournal 34 | 35 | # you also need to configure that journal 36 | config :your_app, YourApp.DefaultJournal, 37 | adapter: Helios.EventJournal.Adapter.Memory # ETS journal 38 | adapter_config: [] 39 | ``` 40 | 41 | or, if you need to persist events over application restarts 42 | 43 | ```elixir 44 | use Mix.Config 45 | 46 | config :your_app, :default_journal, 47 | YourApp.DefaultJournal 48 | 49 | config :your_app, YourApp.DefaultJournal, 50 | adapter: Helios.EventJournal.Adapter.Eventstore 51 | adapter_config: [ 52 | db_type: :node, 53 | host: "localhost", 54 | port: 1113, 55 | username: "admin", 56 | password: "changeit", 57 | connection_name: "your_app", 58 | max_attempts: 10 59 | ] 60 | ``` 61 | 62 | don't forget to add [extreme](https://github.com/exponentially/extreme) dependency to 63 | your project `{:extreme, "~> 0.13"}` 64 | 65 | ## Guides 66 | 67 | * [Helios Configuration](guides/Configuration.md) 68 | * [Your First Aggregate Behaviour](guides/Your%20First%20Aggregate.md) 69 | * [Routing](guides/Routing.md) 70 | 71 | 72 | -------------------------------------------------------------------------------- /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 your application as: 12 | # 13 | # config :helios, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:helios, :key) 18 | # 19 | # You can also 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 | -------------------------------------------------------------------------------- /guides/Configuration.md: -------------------------------------------------------------------------------- 1 | # Helios Configuration 2 | 3 | Below are defaults that are used. You can override any of this configuration in your aplication. 4 | 5 | ## Global Helios Configuration 6 | 7 | Default journal to use in any endpoint started in application 8 | ```elixir 9 | config :helios, :default_journal, MyApp.DefaultJournal 10 | ``` 11 | 12 | The way plugs are added in piepeline 13 | * `:compile` - during application compilation (cannot be changed later) 14 | * `:runtime` - during runtime, before pipeline is executed 15 | 16 | ```elixir 17 | config :helios, :plug_init_mode, :compile 18 | ``` 19 | 20 | Tell endpoint to start aggregates supervison tree in endpoint. 21 | 22 | Supported values are: 23 | * `true` - run aggregates on local node 24 | * `false` - don't run any aggregates on local node 25 | 26 | NOTE: Proxying is not yet done 27 | 28 | ```elixir 29 | config :helios, :serve_endpoints, true 30 | ``` 31 | 32 | While aggregate command is executing, in some places there are default loggers that will log parameters sent in command, to avoid disclosure of values use filters 33 | 34 | Retract only specified field values 35 | ```elixir 36 | config :helios, :filter_parameters, 37 | [:password, "password", :credit_card_number] 38 | 39 | ``` 40 | 41 | Retract all values except for specified keys 42 | ```elixir 43 | config :helios, :filter_parameters, 44 | {:keep, [:email, "email", :full_name]} 45 | ``` 46 | 47 | Then, some log levels can be suppresed by configuring how verbose helios should be 48 | 49 | Valid values are: 50 | * `:debug` - includes debug, info, warn and error messages 51 | * `:info` - includes info, wanrn and error messages 52 | * `:warn` - only want and error messages 53 | * `:error` - only reder error messages 54 | 55 | ```elixir 56 | config :helios, :log_level, :debug 57 | ``` 58 | 59 | ## EventJournal Configuration 60 | 61 | Each journal need adpater, so please either use built in or create 62 | yours by implementing `Helios.EventJounral.Adapter` behaviour 63 | 64 | If you need to test your aggregates, below should be best adater 65 | configuration for such case 66 | 67 | ```elixir 68 | config :my_app, MyApp.Journals.UserJournal, 69 | adapter: Helios.EventJournal.Adapter.Memory 70 | addater_config: [] 71 | ``` 72 | 73 | For production, use `Helios.EventJournal.Adapter.Eventstore` or 74 | create your implementation of `Helios.EventJounral.Adapter` 75 | behaviour. To use eventstore builtin adapter you need to include 76 | `{:extreme, "~> 0.13"}` in deps in your `mix.exs` file and then compile again helios 77 | 78 | in mix.exs file 79 | ```elixir 80 | 81 | 82 | # ...snip... 83 | 84 | def application do 85 | [ 86 | extra_applications: [:logger, :extreme, :helios], 87 | mod: {MyApp, []} 88 | ] 89 | end 90 | 91 | def deps do 92 | [ 93 | {:extreme, "~> 0.13"}, 94 | {:helios, "~> 0.1"}, 95 | # ... other deps ... 96 | ] 97 | end 98 | 99 | # ...end_snip... 100 | ``` 101 | 102 | in bash shell 103 | 104 | ```bash 105 | $ mix deps.compile helios 106 | ``` 107 | 108 | in config/prod.exs 109 | ```elixir 110 | config :my_app, MyApp.Journals.UserJournal, 111 | adapter: Helios.EventJournal.Adapter.Eventstore 112 | addater_config: [ 113 | db_type: :node, 114 | host: "localhost", 115 | port: 1113, 116 | username: "admin", 117 | password: "changeit", 118 | connection_name: "my_app", 119 | max_attempts: 10 120 | ] 121 | ``` 122 | 123 | ## Helios Endpoint specific configuration 124 | 125 | 126 | ```elixir 127 | config :my_app, MyApp.Endpoint, 128 | [ 129 | journal: MyApp.OtherJournal 130 | registry: [ 131 | sync_nodes_timeout: 5_000, 132 | strategy: Helios.Registry.Strategy.Ring 133 | ] 134 | 135 | ] 136 | ``` -------------------------------------------------------------------------------- /guides/Routing.md: -------------------------------------------------------------------------------- 1 | # Helios Routing 2 | 3 | There are two types of processes that messages are touted to. Aggregates should 4 | receive commands and Subscriptions should receive events and other messages from 5 | outer world but they are pushed to your endpoint. 6 | 7 | Below is simple routing module. 8 | 9 | ```elixir 10 | defmodule MyApp.Router do 11 | use Helios.Router 12 | 13 | pipeline :secured do 14 | plug MyApp.Plugs.Authotization 15 | end 16 | 17 | aggregate "/users", MyApp.Aggregates.UserAggregate, only: [ 18 | :create_user, 19 | :confirm_email, 20 | :set_password 21 | ] 22 | 23 | aggregate "/registrations", MyApp.Aggregates.Registration, only: [ 24 | :create, 25 | :confirm_email 26 | ] 27 | 28 | subscribe MyApp.ProcessManagers.UserRegistration, to: [ 29 | {MyApp.Events.UserCreated, :user_id}, 30 | {MyApp.Events.RegistrationStarted, :registration_id}, 31 | {MyApp.Events.EmailConfirmed, :registration_id}, 32 | {MyApp.Events.LoginEnabled, :correlation_id} 33 | {MyApp.Events.PasswordInitialized, :correlation_id} 34 | ] 35 | end 36 | 37 | scope "/", MyApp.Aggregates do 38 | pipe_through :secured 39 | 40 | aggregate "/users", UserAggregate, only: [ 41 | :reset_password, 42 | :change_profile 43 | ] 44 | 45 | end 46 | 47 | end 48 | ``` -------------------------------------------------------------------------------- /guides/Your First Aggregate.md: -------------------------------------------------------------------------------- 1 | # Your First Aggregate 2 | 3 | To build first aggregate, you need to implement `Helios.Aggregate` behaviour that 4 | providies extendable facility for aggregate command pipeline. 5 | 6 | ## Helios Installation 7 | 8 | [Available in Hex](https://hex.pm/packages/helios_aggregate), the package can be installed 9 | by adding `helios_aggregate` to your list of dependencies in `mix.exs`: 10 | 11 | ```elixir 12 | def deps do 13 | [ 14 | {:helios, "~> 0.1"} 15 | ] 16 | end 17 | ``` 18 | 19 | ## Defining Domain Events 20 | 21 | ```elixir 22 | defmodule CustomerCreated do 23 | defstruct [:customer_id, :first_name, :last_name] 24 | end 25 | 26 | defmodule CustomerContactCreated do 27 | defstruct [:customer_id, :email] 28 | end 29 | ``` 30 | 31 | ## Customer Aggregate Example 32 | 33 | ```elixir 34 | defmodule CustomerAggregate do 35 | use Helios.Aggregate 36 | 37 | # Aggregate state 38 | defstruct [:id, :first_name, :last_name, :email] 39 | 40 | def create_customer(ctx, %{id: id, first_name: fname, last_name: lname, email: email}) do 41 | if id == ctx.aggregate.id do 42 | raise RuntimeError, "Already created" 43 | end 44 | 45 | ctx 46 | |> emit(%CustomerCreated{ 47 | customer_id: id, 48 | first_name: fname, 49 | last_name: lname 50 | }) 51 | |> emit(%CustomerContactCreated{ 52 | customer_id: id, 53 | email: email 54 | }) 55 | |> ok(%{status: :created, payload: %{id: id}}) 56 | end 57 | 58 | def apply_event(%CustomerCreated{}=event, customer) do 59 | %{customer 60 | | id: event.id, 61 | first_name: event.first_name, 62 | last_name: event.last_name 63 | } 64 | end 65 | 66 | def apply_event(%CustomerContactCreated{email: email}, customer) do 67 | %{customer| email: email} 68 | end 69 | 70 | # sometimes we generate event but it is not needed in recovery and it is safe to 71 | # skip it 72 | def apply_event(_, agg), do: agg 73 | end 74 | ``` 75 | 76 | ## Message Handler Sample Code 77 | 78 | ```elixir 79 | 80 | ctx = %Helios.Context{ 81 | aggregate: %CustomerAggregate{}, 82 | aggregate_module: CustomerAggregate, 83 | correlation_id: "1234567890", 84 | command: :create_customer, 85 | peer: self(), 86 | params: %{first_name: "Jhon", last_name: "Doe", email: "jhon.doe@gmail.com"} 87 | } 88 | 89 | ctx = Cusomer.call(ctx, :create_customer) 90 | 91 | ``` 92 | 93 | ## Pipeline Logger configuration 94 | 95 | We built logger which should log each command sent to aggregate. Since commands 96 | can cary some confidential information, or you have to be PCI DSS compliant, 97 | we expsed configuration like below where you could configure which filed values 98 | in command should be retracted. 99 | 100 | Below is example where list of field names are given. Please note, the logger 101 | plug will not try to conver string to atom or other way round, if you have both 102 | case please state them in list as both, string and atom. 103 | ```elixir 104 | use Mix.Config 105 | 106 | # filter only specified 107 | config :helios, 108 | :filter_parameters, [:password, "password", :credit_card_number] 109 | 110 | # filter all but keep original values 111 | config :helios, 112 | :filter_parameters, {:keep, [:email, "email", :full_name]} 113 | 114 | # retract only specified field values 115 | config :helios, 116 | :filter_parameters, [:password, "password", :credit_card_number] 117 | 118 | 119 | ``` -------------------------------------------------------------------------------- /integration_test/eventstore/eventstore_adapter_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../support/adapter_cases.exs", __DIR__) 2 | -------------------------------------------------------------------------------- /integration_test/eventstore/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.configure(level: :info) 2 | 3 | # defmodule EventstorePort do 4 | # use GenServer 5 | # require Logger 6 | 7 | # def stop() do 8 | # GenServer.call(__MODULE__, :stop) 9 | # end 10 | 11 | # def start_link(args \\ [], opts \\ []) do 12 | # GenServer.start_link(__MODULE__, args, Keyword.put(opts, :name, __MODULE__)) 13 | # end 14 | 15 | # def init(args) do 16 | # command = ["eventstore" | args] |> Enum.join(" ") 17 | 18 | # port = 19 | # Port.open({:spawn, command}, [ 20 | # :binary, 21 | # :exit_status 22 | # ]) 23 | 24 | # {:ok, %{latest_output: nil, exit_status: nil, port: port}} 25 | # end 26 | 27 | # def handle_call(:stop, _, %{port: port} = state) do 28 | # {:os_pid, pid} = Port.info(port, :os_pid) 29 | # {_, 0} = System.cmd("kill", ["-9", "#{pid}"], into: IO.stream(:stdio, :line)) 30 | # Port.close(port) 31 | # {:reply, :ok, state} 32 | # end 33 | 34 | # # This callback handles data incoming from the command's STDOUT 35 | # def handle_info({_port, {:data, text_line}}, state) do 36 | # latest_output = text_line |> String.trim() 37 | 38 | # # Logger.info("Latest output: #{latest_output}") 39 | 40 | # {:noreply, %{state | latest_output: latest_output}} 41 | # end 42 | 43 | # # This callback tells us when the process exits 44 | # def handle_info({_port, {:exit_status, status}}, state) do 45 | # Logger.info("External exit: :exit_status: #{status}") 46 | 47 | # new_state = %{state | exit_status: status} 48 | # {:noreply, %{state | exit_status: status}} 49 | # end 50 | 51 | # # no-op catch-all callback for unhandled messages 52 | # def handle_info(_msg, state), do: {:noreply, state} 53 | # end 54 | 55 | # {:ok, _} = EventstorePort.start_link([ 56 | # "--mem-db", 57 | # "--run-projections=All", 58 | # "--start-standard-projections=True" 59 | # ]) 60 | 61 | # Process.sleep(2000) 62 | 63 | # ExUnit.after_suite(fn _ -> 64 | # :ok = EventstorePort.stop() 65 | # end) 66 | 67 | ExUnit.start() 68 | 69 | alias Helios.EventJournal.Adapter.Eventstore 70 | alias Helios.Integration.TestJournal 71 | 72 | eventstore_conn = [ 73 | db_type: :node, 74 | host: "localhost", 75 | port: 1113, 76 | username: "admin", 77 | password: "changeit", 78 | reconnect_delay: 2_000, 79 | connection_name: "helios_test", 80 | max_attempts: 10 81 | ] 82 | 83 | Application.put_env(:extreme, :protocol_version, 4) 84 | Application.put_env(:helios, TestJournal, adapter: Eventstore, adapter_config: eventstore_conn) 85 | 86 | # Load support files 87 | Code.require_file("../support/journals.exs", __DIR__) 88 | Code.require_file("../support/events.exs", __DIR__) 89 | 90 | {:ok, _pid} = TestJournal.start_link() 91 | 92 | Process.flag(:trap_exit, true) 93 | -------------------------------------------------------------------------------- /integration_test/support/adapter_cases.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.Integration.AdapterCasesTest do 2 | use ExUnit.Case, async: false 3 | alias Helios.EventJournal.Messages 4 | alias Helios.Integration.TestJournal, as: Journal 5 | alias Helios.Integration.Events.UserCreated 6 | 7 | @tag :event_journal 8 | @tag :event_journal_write 9 | test "should write to stream in journal and return correct event version" do 10 | stream = "journal_test_write_stream-#{UUID.uuid4()}" 11 | 12 | event = 13 | Messages.EventData.new(Extreme.Tools.gen_uuid(), UserCreated, %{ 14 | first_name: "firstname 1", 15 | last_name: "lastname 2" 16 | }) 17 | 18 | assert {:ok, 0} == Journal.append_to_stream(stream, [event], -1) 19 | assert {:ok, 1} == Journal.append_to_stream(stream, [event], 0) 20 | assert {:ok, 3} == Journal.append_to_stream(stream, [event, event], 1) 21 | end 22 | 23 | @tag :event_journal 24 | @tag :event_journal_read_single 25 | test "should read single event at given position" do 26 | stream = "journal_test_read_single_event-#{UUID.uuid4()}" 27 | 28 | events = 29 | Enum.map( 30 | 0..3, 31 | &Messages.EventData.new( 32 | Extreme.Tools.gen_uuid(), 33 | UserCreated, 34 | %UserCreated{first_name: "Test #{&1}", last_name: "Lastname #{&1}"}, 35 | %{emitted_at: DateTime.utc_now()} 36 | ) 37 | ) 38 | 39 | assert {:ok, 3} == Journal.append_to_stream(stream, events, -1) 40 | {event, _} = List.pop_at(events, 2) 41 | 42 | {:ok, persisted} = Journal.read_event(stream, 2, true) 43 | 44 | assert persisted.stream_id == stream 45 | assert persisted.event_number == 2 46 | assert persisted.event_id != nil 47 | assert persisted.event_type == Atom.to_string(UserCreated) 48 | assert persisted.data == event.data 49 | assert %{emitted_at: _} = persisted.metadata 50 | assert %DateTime{} = persisted.created 51 | end 52 | 53 | @tag :event_journal 54 | @tag :event_journal_read 55 | test "should read events from given stream, from given position, forward" do 56 | stream = "journal_test_read_forward_stream-#{UUID.uuid4()}" 57 | number_of_events = 20 58 | 59 | events = 60 | Enum.map( 61 | 0..number_of_events, 62 | &Messages.EventData.new(Extreme.Tools.gen_uuid(), UserCreated, %UserCreated{ 63 | first_name: "Test #{&1}", 64 | last_name: "Lastname #{&1}" 65 | }) 66 | ) 67 | 68 | assert {:ok, ^number_of_events} = Journal.append_to_stream(stream, events, -1) 69 | 70 | {event_5, _} = List.pop_at(events, 5) 71 | {event_14, _} = List.pop_at(events, 14) 72 | 73 | {:ok, persisted} = Journal.read_stream_events_forward(stream, 0, 10, true) 74 | {:ok, persisted} = Journal.read_stream_events_forward(stream, 5, 10, true) 75 | 76 | assert length(persisted.events) == 10 77 | {first, _} = List.pop_at(persisted.events, 0) 78 | {last, _} = List.pop_at(persisted.events, 9) 79 | assert event_5.data == first.data 80 | assert event_14.data == last.data 81 | end 82 | 83 | @tag :event_journal 84 | @tag :event_journal_read 85 | test "should read events from given stream, from given position, backward" do 86 | stream = "journal_test_read_stream_backward-#{UUID.uuid4()}" 87 | number_of_events = 20 88 | 89 | events = 90 | Enum.map( 91 | 0..number_of_events, 92 | &Messages.EventData.new(Extreme.Tools.gen_uuid(), UserCreated, %UserCreated{ 93 | first_name: "Test #{&1}", 94 | last_name: "Lastname #{&1}" 95 | }) 96 | ) 97 | 98 | assert {:ok, ^number_of_events} = Journal.append_to_stream(stream, events, -1) 99 | 100 | {event_6, _} = List.pop_at(events, 6) 101 | {event_10, _} = List.pop_at(events, 10) 102 | 103 | {:ok, persisted} = Journal.read_stream_events_backward(stream, 10, 5, true) 104 | 105 | assert length(persisted.events) == 5 106 | {first, _} = List.pop_at(persisted.events, 0) 107 | {last, _} = List.pop_at(persisted.events, 4) 108 | assert event_10.data == first.data 109 | assert event_6.data == last.data 110 | end 111 | 112 | @tag :event_journal 113 | @tag :event_journal_read_all 114 | test "should read all events from event store forward" do 115 | stream = "journal_test-#{UUID.uuid4()}" 116 | number_of_events = 500 117 | 118 | events = 119 | Enum.map( 120 | 0..number_of_events, 121 | &Messages.EventData.new(Extreme.Tools.gen_uuid(), UserCreated, %UserCreated{ 122 | first_name: "Test #{&1}", 123 | last_name: "Lastname #{&1}" 124 | }) 125 | ) 126 | 127 | assert {:ok, ^number_of_events} = Journal.append_to_stream(stream, events, -1) 128 | 129 | {:ok, resp} = Journal.read_all_events_forward({0, 0}, 50, true) 130 | assert length(resp.events) == 50 131 | 132 | {:ok, resp} = Journal.read_all_events_forward({-1, -1}, 50, true) 133 | assert length(resp.events) == 0 134 | 135 | {:ok, resp} = Journal.read_all_events_backward({0, 0}, 50, true) 136 | assert length(resp.events) == 0 137 | 138 | {:ok, resp} = Journal.read_all_events_backward({-1, -1}, 50, true) 139 | assert length(resp.events) == 50 140 | end 141 | 142 | @tag :event_journal 143 | @tag :event_journal_delete_stream 144 | test "should soft delete stream from event journal" do 145 | stream = "journal_test-#{UUID.uuid4()}" 146 | number_of_events = 20 147 | 148 | events = 149 | Enum.map( 150 | 0..number_of_events, 151 | &Messages.EventData.new(Extreme.Tools.gen_uuid(), UserCreated, %UserCreated{ 152 | first_name: "Test #{&1}", 153 | last_name: "Lastname #{&1}" 154 | }) 155 | ) 156 | 157 | assert {:ok, number_of_events} == Journal.append_to_stream(stream, events, -1) 158 | 159 | assert {:ok, %{commit_position: cp, prepare_position: pp}} = 160 | Journal.delete_stream(stream, number_of_events) 161 | 162 | assert cp > 0 and pp > 0 163 | 164 | assert {:error, :no_stream} == Journal.read_event(stream, number_of_events) 165 | 166 | events = 167 | Enum.map( 168 | 0..number_of_events, 169 | &Messages.EventData.new(Extreme.Tools.gen_uuid(), UserCreated, %UserCreated{ 170 | first_name: "Test #{&1}", 171 | last_name: "Lastname #{&1}" 172 | }) 173 | ) 174 | 175 | assert {:error, :wrong_expected_version} == Journal.append_to_stream(stream, events, 0) 176 | 177 | assert {:ok, 2 * number_of_events + 1} == 178 | Journal.append_to_stream(stream, events, number_of_events) 179 | end 180 | 181 | @tag :event_journal 182 | @tag :event_journal_delete_stream 183 | test "should hard delete stream from event journal" do 184 | stream = "journal_test-#{UUID.uuid4()}" 185 | number_of_events = 20 186 | 187 | events = 188 | Enum.map( 189 | 0..number_of_events, 190 | &Messages.EventData.new(Extreme.Tools.gen_uuid(), UserCreated, %UserCreated{ 191 | first_name: "Test #{&1}", 192 | last_name: "Lastname #{&1}" 193 | }) 194 | ) 195 | 196 | assert {:ok, number_of_events} == Journal.append_to_stream(stream, events, -1) 197 | 198 | assert {:ok, %{commit_position: cp, prepare_position: pp}} = 199 | Journal.delete_stream(stream, number_of_events, true) 200 | 201 | assert cp > 0 and pp > 0 202 | 203 | assert {:error, :stream_deleted} == Journal.read_event(stream, number_of_events) 204 | 205 | events = 206 | Enum.map( 207 | 0..number_of_events, 208 | &Messages.EventData.new(Extreme.Tools.gen_uuid(), UserCreated, %UserCreated{ 209 | first_name: "Test #{&1}", 210 | last_name: "Lastname #{&1}" 211 | }) 212 | ) 213 | 214 | assert {:error, :stream_deleted} == Journal.append_to_stream(stream, events, 0) 215 | assert {:error, :stream_deleted} == Journal.append_to_stream(stream, events, number_of_events) 216 | end 217 | 218 | @tag :event_journal 219 | @tag :event_journal_stream_metadata 220 | test "should write and read stream metadata to and from event journal" do 221 | stream = "journal_test_write_metadata-#{UUID.uuid4()}" 222 | number_of_events = 20 223 | 224 | metadata = %{ 225 | # 2 seconds 226 | "$maxAge" => 2 227 | } 228 | 229 | assert {:ok, 0} == Journal.set_stream_metadata(stream, metadata, -1) 230 | 231 | events = 232 | Enum.map( 233 | 0..number_of_events, 234 | &Messages.EventData.new(Extreme.Tools.gen_uuid(), UserCreated, %UserCreated{ 235 | first_name: "Test #{&1}", 236 | last_name: "Lastname #{&1}" 237 | }) 238 | ) 239 | 240 | assert {:ok, number_of_events} == Journal.append_to_stream(stream, events, -1) 241 | 242 | Process.sleep(3000) 243 | 244 | assert 0 == 245 | Journal.read_stream_events_forward(stream, 0, 50) 246 | |> elem(1) 247 | |> Map.get(:events) 248 | |> length() 249 | 250 | assert {:ok, %{metadata: _metadata, meta_version: 0}} = Journal.get_stream_metadata(stream) 251 | 252 | # assert {:ok, 1} == Journal.set_stream_metadata(stream, %{}, 0) # this requires scavage to be run in eventstore before executing below lines 253 | 254 | assert {:ok, 2 * number_of_events + 1} == 255 | Journal.append_to_stream(stream, events, number_of_events) 256 | 257 | assert number_of_events + 1 == 258 | Journal.read_stream_events_forward(stream, 0, 50) 259 | |> elem(1) 260 | |> Map.get(:events) 261 | |> length() 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /integration_test/support/aggregates.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.Integration.UserAggregate do 2 | use Helios.Aggregate 3 | alias Helios.Integration.Events.UserCreated 4 | alias Helios.Integration.Events.UserEmailChanged 5 | require Logger 6 | 7 | # Aggregate State 8 | defstruct [:id, :first_name, :last_name, :email, :password] 9 | 10 | # Plugs for command context pipeline 11 | plug(Helios.Plugs.Logger, log_level: :debug) 12 | 13 | def new(_args) do 14 | {:ok, struct!(__MODULE__, [])} 15 | end 16 | 17 | def persistance_id(id) do 18 | "users-#{id}" 19 | end 20 | 21 | def create_user(ctx, %{"id" => id, "first_name" => first_name, "last_name" => last_name, "email" => email}) do 22 | aggregate = state(ctx) 23 | if aggregate.id == id do 24 | raise RuntimeError, "Already Created" 25 | end 26 | 27 | ctx 28 | |> emit(%UserCreated{user_id: id, first_name: first_name, last_name: last_name}) 29 | |> emit(%UserEmailChanged{user_id: id, old_email: aggregate.email, new_email: email}) 30 | |> ok(:created) 31 | end 32 | 33 | def apply_event(%UserCreated{} = event, agg) do 34 | %{ 35 | agg 36 | | id: event.user_id, 37 | first_name: event.first_name, 38 | last_name: event.last_name 39 | } 40 | end 41 | 42 | def apply_event(%UserEmailChanged{} = event, agg) do 43 | %{agg | email: event.new_email} 44 | end 45 | 46 | def apply_event(_, agg), do: agg 47 | end 48 | -------------------------------------------------------------------------------- /integration_test/support/assertions.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.Integration.Assertions do 2 | @moduledoc """ 3 | Assertions macros that should make testing aggregates easier. 4 | """ 5 | alias Helios.Context 6 | 7 | @doc """ 8 | Asserts is given event/events are emmitted during pipeline execution 9 | """ 10 | @spec assert_emitted(Context.t(), map() | [map()]) :: Context.t() 11 | defmacro assert_emitted(ctx, event) do 12 | quote do 13 | require ExUnit.Assertions 14 | events = List.wrap(unquote(event)) 15 | 16 | emitted_events = List.wrap(unquote(ctx).events) 17 | 18 | events_length = length(events) 19 | emmited_length = length(emitted_events) 20 | 21 | ExUnit.Assertions.assert( 22 | events_length == emmited_length, 23 | "number of emmited events [#{emmited_length}] mismatch expected length [#{events_length}]" 24 | ) 25 | 26 | Enum.zip([events, emitted_events]) 27 | |> Enum.each(fn {event, emitted} -> 28 | ExUnit.Assertions.assert( 29 | event.type == emitted.type, 30 | "Expected event type `#{event.type}` do not match emitted `#{emitted.type}`" 31 | ) 32 | 33 | ExUnit.Assertions.assert( 34 | event.data == emitted.data, 35 | "Expected event type `#{event.type}` do not match emitted `#{emitted.type}`" 36 | ) 37 | end) 38 | end 39 | end 40 | 41 | @doc """ 42 | Asserts if given response is set during pipeline execution 43 | """ 44 | @spec assert_response(ctx :: Context.t(), event :: struct()) :: Context.t() 45 | defmacro assert_response(ctx, response) do 46 | quote do 47 | require ExUnit.Assertions 48 | 49 | ExUnit.Assertions.assert(%{response: unquote(response)} = unquote(ctx)) 50 | end 51 | end 52 | 53 | @doc """ 54 | If `is_haleted` argument is set to true it will assert if given context is halted, 55 | otherwise it will pass if given context is not halted 56 | """ 57 | @spec assert_halted(ctx :: Context.t(), is_halted :: boolean) :: Context.t() 58 | defmacro assert_halted(ctx, is_halted \\ true) do 59 | quote do 60 | require ExUnit.Assertions 61 | 62 | ExUnit.Assertions.assert(unquote(ctx).halted == unquote(is_halted)) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /integration_test/support/events.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.Integration.Events do 2 | defmodule UserCreated do 3 | defstruct [:user_id, :first_name, :last_name] 4 | end 5 | 6 | defmodule UserEmailChanged do 7 | defstruct [:user_id, :old_email, :new_email] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /integration_test/support/journals.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.Integration.TestJournal do 2 | use Helios.EventJournal, otp_app: :helios 3 | end 4 | -------------------------------------------------------------------------------- /lib/helios.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios do 2 | @moduledoc """ 3 | Helios application 4 | 5 | ## Dependencies 6 | 7 | * [elixir_uuid](https://hexdocs.pm/elixir_uuid) 8 | * [libring](https://hexdocs.pm/libring) 9 | * [gen_state_machine](https://hex.pm/packages/gen_state_machine) 10 | * [poolboy](https://hex.pm/packages/poolboy) 11 | * [extreme](https://hexdocs.pm/extreme) - OPTIONAL, requred if you use Eventstore as event journal database 12 | """ 13 | 14 | use Application 15 | 16 | @doc false 17 | def start(_type, _args) do 18 | 19 | # Configure proper system flags from Helios only 20 | if stacktrace_depth = Application.get_env(:helios, :stacktrace_depth) do 21 | :erlang.system_flag(:backtrace_depth, stacktrace_depth) 22 | end 23 | 24 | 25 | children = [ 26 | {Task.Supervisor, name: Helios.Registry.TaskSupervisor}, 27 | {Helios.CodeReloader.Server, []} 28 | ] 29 | 30 | opts = [strategy: :one_for_one, name: Helios.Supervisor] 31 | Supervisor.start_link(children, opts) 32 | end 33 | 34 | @doc false 35 | def plug_init_mode() do 36 | Application.get_env(:helios, :plug_init_mode, :compile) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/helios/aggregate.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Aggregate do 2 | @moduledoc """ 3 | Aggregate behaviour. 4 | 5 | Once implemented add command mapping to router `Helios.Router`. 6 | """ 7 | 8 | alias Helios.Context 9 | 10 | @typedoc """ 11 | The unique aggregate id. 12 | """ 13 | @type aggregate_id :: String.t() 14 | 15 | @type aggregate :: struct() 16 | 17 | @type from :: {pid(), tag :: term()} 18 | 19 | @type init_args :: [otp_app: atom, id: aggregate_id] 20 | 21 | @type t :: struct 22 | 23 | @doc """ 24 | Returns unique identifier for stream to which events will be persisted. 25 | 26 | This persistance id will be used while persisting events emitted by aggregate into 27 | its own stream of the events. 28 | """ 29 | @callback persistance_id(id :: term) :: String.t() 30 | 31 | @doc """ 32 | Handles execution of the command once command is handed from router to 33 | `Helios.Aggregate.Server`. 34 | """ 35 | @callback handle(ctx :: Context.t(), params :: Context.params()) :: Context.t() 36 | 37 | @doc """ 38 | Constructs new instance of aggregate struct. Override to set defaults or if your 39 | struct is defined in different module. 40 | """ 41 | @callback new(args :: init_args) :: 42 | {:ok, aggregate} 43 | | {:stop, term} 44 | | :ignore 45 | 46 | @doc """ 47 | Applies single event to aggregate when replied or after `handle_exec/3` is executed. 48 | 49 | Must return `{:ok, state}` if event is aplied or raise an error if failed. 50 | Note that events should not be validated here, they must be respected since handle_execute/3 51 | generated event and already validate it. Also, error has to bi risen in for some odd reason event cannot 52 | be applied to aggregate. 53 | """ 54 | @callback apply_event(event :: any, aggregate) :: aggregate | no_return 55 | 56 | @doc """ 57 | Optional cllback, when implemented it should treansform offered snapshot into 58 | aggregate model. 59 | 60 | Return `{:ok, aggregate}` if snapshot is applied to aggregate, or 61 | `{:ignored, aggregate}` if you want ignore snapshot and apply all events from 62 | beginning of the aggregate stream 63 | """ 64 | @callback from_snapshot(snapshot_offer :: SnapshotOffer.t(), aggregate) :: 65 | {:ok, aggregate} 66 | | {:skip, aggregate} 67 | 68 | @doc """ 69 | Optional callback, when implmented it should return snapshot of given aggregate. 70 | 71 | When snapshot is stored it should record among aggregate state, sequence number 72 | (or last_event_number) at which snapshot was taken. 73 | """ 74 | @callback to_snapshot(aggregate) :: any 75 | 76 | @optional_callbacks [ 77 | # Aggregate 78 | from_snapshot: 2, 79 | to_snapshot: 1 80 | ] 81 | 82 | defmacro __using__(opts) do 83 | quote bind_quoted: [opts: opts], location: :keep do 84 | @behaviour Helios.Aggregate 85 | import Helios.Aggregate 86 | import Helios.Context 87 | 88 | use Helios.Pipeline, opts 89 | 90 | @doc "create new aggregate struct with all defaults" 91 | def new(_), do: {:ok, struct!(__MODULE__, [])} 92 | 93 | defoverridable(new: 1) 94 | end 95 | end 96 | 97 | @doc """ 98 | Returns aggregate state from context. 99 | 100 | State can be only accessed once pipeline enters in aggregate process. 101 | """ 102 | @spec state(Helios.Context.t()) :: t | nil 103 | def state(%Context{assigns: %{aggregate: aggregate}}), do: aggregate 104 | 105 | def state(_ctx), do: nil 106 | 107 | @change_key :helios_aggregate_change 108 | 109 | @spec get_change(Helios.Context.t()) :: struct 110 | def get_change(ctx) do 111 | Map.get(ctx.private, @change_key, state(ctx)) 112 | end 113 | 114 | @spec put_change(Context.t(), struct) :: Context.t() 115 | def put_change(ctx, change) do 116 | private = Map.put(ctx.private, @change_key, change) 117 | %{ctx | private: private} 118 | end 119 | 120 | 121 | 122 | # 123 | # Validators 124 | # 125 | 126 | @doc """ 127 | Validates required parameters 128 | 129 | 130 | """ 131 | def validate_required(ctx, fields, opts \\ []) do 132 | fields = 133 | fields 134 | |> List.wrap() 135 | |> Enum.map(fn key -> 136 | case key do 137 | k when is_atom(k) -> Atom.to_string(k) 138 | k -> k 139 | end 140 | end) 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/helios/aggregate/identity.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Aggregate.Identity do 2 | @doc """ 3 | When implemented, it should generate unique identity for aggregate 4 | """ 5 | @callback generate(opts :: keyword) :: term 6 | 7 | defmacro __using__(_) do 8 | quote do 9 | @behaviour Helios.Aggregate.Identity 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/helios/aggregate/snapshot_offer.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Aggregate.SnapshotOffer do 2 | alias Helios.EventJournal.Messages.PersistedEvent 3 | 4 | @moduledoc """ 5 | Represents saved snapshot. Offered once aggregate started for the first time 6 | """ 7 | 8 | defstruct [:stream, :event_number, :payload, :snapshot_number, :snapshot_date] 9 | 10 | @type t :: %__MODULE__{ 11 | stream: String.t(), 12 | event_number: integer, 13 | payload: any, 14 | snapshot_number: integer, 15 | snapshot_date: DateTime.t() | nil 16 | } 17 | 18 | @doc false 19 | def from_journal(%PersistedEvent{metadata: metadata} = event) do 20 | struct!(__MODULE__, 21 | payload: event.data, 22 | stream: event.stream_id, 23 | snapshot_number: event.event_number, 24 | event_number: metadata.from_event_number, 25 | snapshot_date: event.created 26 | ) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/helios/aggregate/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Aggregate.Supervisor do 2 | @moduledoc false 3 | use Supervisor 4 | 5 | def child_spec(otp_app, endpoint) do 6 | %{ 7 | id: __MODULE__, 8 | start: 9 | {__MODULE__, :start_link, [otp_app, endpoint]}, 10 | type: :supervisor 11 | } 12 | end 13 | 14 | @spec supervisor_name(atom) :: atom 15 | def supervisor_name(endpoint) when is_atom(endpoint) do 16 | Module.concat(endpoint, AggregateSupervisor) 17 | end 18 | 19 | def start_link(otp_app, endpoint) do 20 | opts = [name: supervisor_name(endpoint)] 21 | Supervisor.start_link(__MODULE__, [otp_app, endpoint], opts) 22 | end 23 | 24 | def init([_, _]) do 25 | children = [ 26 | worker(Helios.Aggregate.Server, [], restart: :temporary) 27 | ] 28 | 29 | supervise(children, strategy: :simple_one_for_one) 30 | end 31 | 32 | @doc """ 33 | Starts new plug server 34 | """ 35 | def register(endpoint, plug, id) do 36 | server = supervisor_name(endpoint) 37 | {:ok, _pid} = Supervisor.start_child(server, [endpoint.__app__(), {plug, id}]) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/helios/code_reloader/proxy.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.CodeReloader.Proxy do 2 | @moduledoc false 3 | use GenServer 4 | 5 | def start() do 6 | GenServer.start(__MODULE__, :ok) 7 | end 8 | 9 | def stop(proxy) do 10 | GenServer.call(proxy, :stop) 11 | end 12 | 13 | ## Callbacks 14 | 15 | def init(:ok) do 16 | {:ok, ""} 17 | end 18 | 19 | def handle_call(:stop, _from, output) do 20 | {:stop, :normal, output, output} 21 | end 22 | 23 | def handle_info(msg, output) do 24 | case msg do 25 | {:io_request, from, reply, {:put_chars, chars}} -> 26 | put_chars(from, reply, chars, output) 27 | 28 | {:io_request, from, reply, {:put_chars, m, f, as}} -> 29 | put_chars(from, reply, apply(m, f, as), output) 30 | 31 | {:io_request, from, reply, {:put_chars, _encoding, chars}} -> 32 | put_chars(from, reply, chars, output) 33 | 34 | {:io_request, from, reply, {:put_chars, _encoding, m, f, as}} -> 35 | put_chars(from, reply, apply(m, f, as), output) 36 | 37 | {:io_request, _from, _reply, _request} = msg -> 38 | send(Process.group_leader, msg) 39 | {:noreply, output} 40 | 41 | _ -> 42 | {:noreply, output} 43 | end 44 | end 45 | 46 | defp put_chars(from, reply, chars, output) do 47 | send(Process.group_leader, {:io_request, from, reply, {:put_chars, chars}}) 48 | {:noreply, output <> IO.chardata_to_string(chars)} 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/helios/code_reloader/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.CodeReloader.Server do 2 | @moduledoc false 3 | use GenServer 4 | 5 | require Logger 6 | alias Helios.CodeReloader.Proxy 7 | 8 | def start_link(opts \\ []) do 9 | opts = Keyword.put(opts, :name, __MODULE__) 10 | GenServer.start_link(__MODULE__, false, opts) 11 | end 12 | 13 | def check_symlinks do 14 | GenServer.call(__MODULE__, :check_symlinks, :infinity) 15 | end 16 | 17 | def reload!(endpoint) do 18 | GenServer.call(__MODULE__, {:reload!, endpoint}, :infinity) 19 | end 20 | 21 | ## Callbacks 22 | 23 | def init(false) do 24 | {:ok, false} 25 | end 26 | 27 | def handle_call(:check_symlinks, _from, checked?) do 28 | if not checked? and Code.ensure_loaded?(Mix.Project) and not Mix.Project.umbrella? do 29 | priv_path = "#{Mix.Project.app_path}/priv" 30 | 31 | case :file.read_link(priv_path) do 32 | {:ok, _} -> 33 | :ok 34 | 35 | {:error, _} -> 36 | if can_symlink?() do 37 | File.rm_rf(priv_path) 38 | Mix.Project.build_structure 39 | else 40 | Logger.warn "Helios is unable to create symlinks. Helios' code reloader will run " <> 41 | "considerably faster if symlinks are allowed." <> os_symlink(:os.type) 42 | end 43 | end 44 | end 45 | 46 | {:reply, :ok, true} 47 | end 48 | 49 | def handle_call({:reload!, endpoint}, from, state) do 50 | compilers = endpoint.config(:reloadable_compilers) 51 | reloadable_apps = endpoint.config(:reloadable_apps) || default_reloadable_apps() 52 | backup = load_backup(endpoint) 53 | froms = all_waiting([from], endpoint) 54 | 55 | {res, out} = 56 | proxy_io(fn -> 57 | try do 58 | mix_compile(Code.ensure_loaded(Mix.Task), compilers, reloadable_apps) 59 | catch 60 | :exit, {:shutdown, 1} -> 61 | :error 62 | kind, reason -> 63 | IO.puts Exception.format(kind, reason, System.stacktrace) 64 | :error 65 | end 66 | end) 67 | 68 | reply = 69 | case res do 70 | :ok -> 71 | :ok 72 | :error -> 73 | write_backup(backup) 74 | {:error, out} 75 | end 76 | 77 | Enum.each(froms, &GenServer.reply(&1, reply)) 78 | {:noreply, state} 79 | end 80 | 81 | defp default_reloadable_apps() do 82 | if Mix.Project.umbrella? do 83 | Enum.map(Mix.Dep.Umbrella.cached, &(&1.app)) 84 | else 85 | [Mix.Project.config()[:app]] 86 | end 87 | end 88 | 89 | def handle_info(_, state) do 90 | {:noreply, state} 91 | end 92 | 93 | defp os_symlink({:win32, _}), 94 | do: " On Windows, the lack of symlinks may even cause empty assets to be served. " <> 95 | "Luckily, you can address this issue by starting your Windows terminal at least " <> 96 | "once with \"Run as Administrator\" and then running your Helios application." 97 | defp os_symlink(_), 98 | do: "" 99 | 100 | defp can_symlink?() do 101 | build_path = Mix.Project.build_path() 102 | symlink = Path.join(Path.dirname(build_path), "__helios__") 103 | 104 | case File.ln_s(build_path, symlink) do 105 | :ok -> 106 | File.rm_rf(symlink) 107 | true 108 | 109 | {:error, :eexist} -> 110 | File.rm_rf(symlink) 111 | true 112 | 113 | {:error, _} -> 114 | false 115 | end 116 | end 117 | 118 | defp load_backup(mod) do 119 | mod 120 | |> :code.which() 121 | |> read_backup() 122 | end 123 | defp read_backup(path) when is_list(path) do 124 | case File.read(path) do 125 | {:ok, binary} -> {:ok, path, binary} 126 | _ -> :error 127 | end 128 | end 129 | defp read_backup(_path), do: :error 130 | 131 | defp write_backup({:ok, path, file}), do: File.write!(path, file) 132 | defp write_backup(:error), do: :ok 133 | 134 | defp all_waiting(acc, endpoint) do 135 | receive do 136 | {:"$gen_call", from, {:reload!, ^endpoint}} -> all_waiting([from | acc], endpoint) 137 | after 138 | 0 -> acc 139 | end 140 | end 141 | 142 | defp mix_compile({:module, Mix.Task}, compilers, apps_to_reload) do 143 | mix_compile_deps(Mix.Dep.cached, apps_to_reload, compilers) 144 | mix_compile_project(Mix.Project.config()[:app], apps_to_reload, compilers) 145 | end 146 | defp mix_compile({:error, _reason}, _, _) do 147 | raise "the Code Reloader is enabled but Mix is not available. If you want to " <> 148 | "use the Code Reloader in production or inside an escript, you must add " <> 149 | ":mix to your applications list. Otherwise, you must disable code reloading " <> 150 | "in such environments" 151 | end 152 | 153 | defp mix_compile_deps(deps, apps_to_reload, compilers) do 154 | for dep <- deps, dep.app in apps_to_reload do 155 | Mix.Dep.in_dependency dep, fn _ -> 156 | mix_compile_unless_stale_config(compilers) 157 | end 158 | end 159 | 160 | :ok 161 | end 162 | 163 | defp mix_compile_project(nil, _, _), do: :ok 164 | defp mix_compile_project(app, apps_to_reload, compilers) do 165 | if app in apps_to_reload do 166 | mix_compile_unless_stale_config(compilers) 167 | end 168 | 169 | :ok 170 | end 171 | 172 | defp mix_compile_unless_stale_config(compilers) do 173 | manifests = Mix.Tasks.Compile.Elixir.manifests 174 | configs = Mix.Project.config_files 175 | 176 | case Mix.Utils.extract_stale(configs, manifests) do 177 | [] -> 178 | mix_compile(compilers) 179 | 180 | files -> 181 | raise """ 182 | could not compile application: #{Mix.Project.config[:app]}. 183 | 184 | You must restart your server after changing the following config or lib files: 185 | 186 | * #{Enum.map_join(files, "\n * ", &Path.relative_to_cwd/1)} 187 | 188 | """ 189 | end 190 | end 191 | 192 | defp mix_compile(compilers) do 193 | all = Mix.Project.config[:compilers] || Mix.compilers 194 | 195 | compilers = 196 | for compiler <- compilers, compiler in all do 197 | Mix.Task.reenable("compile.#{compiler}") 198 | compiler 199 | end 200 | 201 | # We call build_structure mostly for Windows so new 202 | # assets in priv are copied to the build directory. 203 | Mix.Project.build_structure 204 | results = Enum.map(compilers, &Mix.Task.run("compile.#{&1}", [])) 205 | 206 | # Results are either {:ok, _} | {:error, _}, {:noop, _} or 207 | # :ok | :error | :noop. So we use proplists to do the unwraping. 208 | cond do 209 | :proplists.get_value(:error, results, false) -> 210 | exit({:shutdown, 1}) 211 | 212 | :proplists.get_value(:ok, results, false) && consolidate_protocols?() -> 213 | Mix.Task.reenable("compile.protocols") 214 | Mix.Task.run("compile.protocols", []) 215 | :ok 216 | 217 | true -> 218 | :ok 219 | end 220 | end 221 | 222 | defp consolidate_protocols? do 223 | Mix.Project.config[:consolidate_protocols] 224 | end 225 | 226 | defp proxy_io(fun) do 227 | original_gl = Process.group_leader 228 | {:ok, proxy_gl} = Proxy.start() 229 | Process.group_leader(self(), proxy_gl) 230 | 231 | try do 232 | {fun.(), Proxy.stop(proxy_gl)} 233 | after 234 | Process.group_leader(self(), original_gl) 235 | Process.exit(proxy_gl, :kill) 236 | end 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /lib/helios/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Config do 2 | # Helios configuration server. 3 | @moduledoc false 4 | 5 | use GenServer 6 | 7 | def start_link(module, config, defaults, opts \\ []) do 8 | GenServer.start_link(__MODULE__, {module, config, defaults}, opts) 9 | end 10 | 11 | def stop(module) do 12 | [__config__: pid] = :ets.lookup(module, :__config__) 13 | GenServer.call(pid, :stop) 14 | end 15 | 16 | def merge(config1, config2) do 17 | Keyword.merge(config1, config2, &merger/3) 18 | end 19 | 20 | @doc "Cache a value" 21 | @spec cache(module, term, (module -> {:cache | :nocache, term})) :: term 22 | def cache(module, key, fun) do 23 | case :ets.lookup(module, key) do 24 | [{^key, :cache, val}] -> 25 | val 26 | 27 | [] -> 28 | case fun.(module) do 29 | {:cache, val} -> 30 | :ets.insert(module, {key, :cache, val}) 31 | val 32 | 33 | {:nocache, val} -> 34 | val 35 | end 36 | end 37 | end 38 | 39 | @doc """ 40 | Clear cache 41 | """ 42 | @spec clear_cache(module) :: :ok 43 | def clear_cache(module) do 44 | :ets.match_delete(module, {:_, :cache, :_}) 45 | :ok 46 | end 47 | 48 | @doc """ 49 | Read cache 50 | """ 51 | def from_env(otp_app, module, defaults) do 52 | merge(defaults, fetch_config(otp_app, module)) 53 | end 54 | 55 | defp fetch_config(otp_app, module) do 56 | case Application.fetch_env(otp_app, module) do 57 | {:ok, conf} -> 58 | conf 59 | 60 | :error -> 61 | IO.puts( 62 | :stderr, 63 | "warning: no configuration found for otp_app " <> 64 | "#{inspect(otp_app)} and module #{inspect(module)}" 65 | ) 66 | 67 | [] 68 | end 69 | end 70 | 71 | def config_change(module, changed, removed) do 72 | pid = :ets.lookup_element(module, :__config__, 2) 73 | GenServer.call(pid, {:config_change, changed, removed}) 74 | end 75 | 76 | def init({module, config, defaults}) do 77 | :ets.new(module, [:named_table, :public, read_concurrency: true]) 78 | :ets.insert(module, __config__: self()) 79 | update(module, config) 80 | {:ok, {module, defaults}} 81 | end 82 | 83 | def handle_call({:config_change, changed, removed}, _from, {module, defaults}) do 84 | cond do 85 | changed = changed[module] -> 86 | update(module, merge(defaults, changed)) 87 | {:reply, :ok, {module, defaults}} 88 | 89 | module in removed -> 90 | stop(module, defaults) 91 | 92 | true -> 93 | {:reply, :ok, {module, defaults}} 94 | end 95 | end 96 | 97 | def handle_call(:stop, _from, {module, defaults}) do 98 | stop(module, defaults) 99 | end 100 | 101 | # Helpers 102 | 103 | defp merger(_k, v1, v2) do 104 | if Keyword.keyword?(v1) and Keyword.keyword?(v2) do 105 | Keyword.merge(v1, v2, &merger/3) 106 | else 107 | v2 108 | end 109 | end 110 | 111 | defp update(module, config) do 112 | old_keys = keys(:ets.tab2list(module)) 113 | new_keys = [:__config__ | keys(config)] 114 | Enum.each(old_keys -- new_keys, &:ets.delete(module, &1)) 115 | :ets.insert(module, config) 116 | end 117 | 118 | defp keys(data) do 119 | Enum.map(data, &elem(&1, 0)) 120 | end 121 | 122 | defp stop(module, defaults) do 123 | :ets.delete(module) 124 | {:stop, :normal, :ok, {module, defaults}} 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/helios/context.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Context do 2 | @moduledoc """ 3 | Represent execution context for single message/command sent to helios endpoint. 4 | """ 5 | 6 | defmodule NotSentError do 7 | defexception message: "a response was neither set nor sent from the connection" 8 | 9 | @moduledoc """ 10 | Error raised when no response is sent in a request 11 | """ 12 | end 13 | 14 | defmodule AlreadySentError do 15 | defexception message: "the response was already sent" 16 | 17 | @moduledoc """ 18 | Error raised when trying to modify or send an already sent response 19 | """ 20 | end 21 | 22 | alias Helios.Context 23 | alias Helios.EventJournal.Messages.EventData 24 | 25 | @type opts :: binary | tuple | atom | integer | float | [opts] | %{opts => opts} 26 | 27 | # module that implements Aggregate behaviour 28 | @type aggregate_module :: module 29 | # user shared assigns 30 | @type assigns :: %{atom => any} 31 | # id that uniquely identifies request from which context originates 32 | @type request_id :: String.t() 33 | # id with which command correlates 34 | @type correlation_id :: String.t() | nil 35 | # single event generated by aggregate 36 | @type event :: struct() 37 | # events generated by command execution 38 | @type events :: nil | event | [event] 39 | # indicates that processing pipeline halted and none of other plugs that waiths for execution will be excuted 40 | @type halted :: boolean 41 | # pid to which reposponse should be reply-ed 42 | @type owner :: pid 43 | # command parameters 44 | @type params :: map | struct 45 | # remote caller pid 46 | @type peer :: pid 47 | # plugin private assigns 48 | @type private :: map 49 | # status of current context 50 | @type status :: :init | :executing | :executed | :commiting | :success | :failed 51 | 52 | @type response :: any 53 | 54 | @type t :: %Context{ 55 | adapter: {module, term}, 56 | assigns: assigns, 57 | method: atom, 58 | before_send: list(), 59 | path_info: [binary], 60 | request_id: request_id, 61 | correlation_id: correlation_id, 62 | events: events, 63 | halted: halted, 64 | owner: owner, 65 | params: params, 66 | path_params: params, 67 | peer: peer, 68 | private: private, 69 | retry: integer, 70 | state: :unset | :set | :sent, 71 | status: status, 72 | response: response 73 | } 74 | 75 | defstruct adapter: nil, 76 | assigns: %{}, 77 | before_send: [], 78 | path_info: [], 79 | request_id: nil, 80 | correlation_id: nil, 81 | method: nil, 82 | events: nil, 83 | halted: false, 84 | handler: nil, 85 | owner: nil, 86 | params: %{}, 87 | path_params: %{}, 88 | peer: nil, 89 | private: %{}, 90 | retry: 0, 91 | state: :unset, 92 | status: :init, 93 | response: nil 94 | 95 | @already_sent {:plug_ctx, :sent} 96 | @unsent [:unset, :set] 97 | @doc """ 98 | Assigns a value to a key in the context. 99 | 100 | ## Examples 101 | 102 | iex> ctx.assigns[:hello] 103 | nil 104 | iex> ctx = assign(ctx, :hello, :world) 105 | iex> ctx.assigns[:hello] 106 | :world 107 | 108 | """ 109 | @spec assign(Context.t(), atom, any) :: Context.t() 110 | def assign(%Context{assigns: assigns} = ctx, key, value) when is_atom(key) do 111 | %{ctx | assigns: Map.put(assigns, key, value)} 112 | end 113 | 114 | @doc """ 115 | Assigns multiple values to keys in the context. 116 | 117 | Equivalent to multiple calls to `assign/3`. 118 | 119 | ## Examples 120 | 121 | iex> ctx.assigns[:hello] 122 | nil 123 | iex> ctx = merge_assigns(ctx, hello: :world) 124 | iex> ctx.assigns[:hello] 125 | :world 126 | 127 | """ 128 | @spec merge_assigns(Context.t(), Keyword.t()) :: Context.t() 129 | def merge_assigns(%Context{assigns: assigns} = ctx, keyword) when is_list(keyword) do 130 | %{ctx | assigns: Enum.into(keyword, assigns)} 131 | end 132 | 133 | @doc """ 134 | Assigns a new **private** key and value in the context. 135 | 136 | This storage is meant to be used by libraries and frameworks to avoid writing 137 | to the user storage (the `:assigns` field). It is recommended for 138 | libraries/frameworks to prefix the keys with the library name. 139 | 140 | For example, if some plug needs to store a `:hello` key, it 141 | should do so as `:plug_hello`: 142 | 143 | iex> ctx.private[:plug_hello] 144 | nil 145 | iex> ctx = put_private(ctx, :plug_hello, :world) 146 | iex> ctx.private[:plug_hello] 147 | :world 148 | 149 | """ 150 | @spec put_private(Context.t(), atom, term) :: Context.t() 151 | def put_private(%Context{private: private} = ctx, key, value) when is_atom(key) do 152 | %{ctx | private: Map.put(private, key, value)} 153 | end 154 | 155 | @doc """ 156 | Assigns multiple **private** keys and values in the context. 157 | 158 | Equivalent to multiple `put_private/3` calls. 159 | 160 | ## Examples 161 | 162 | iex> ctx.private[:plug_hello] 163 | nil 164 | iex> ctx = merge_private(ctx, plug_hello: :world) 165 | iex> ctx.private[:plug_hello] 166 | :world 167 | """ 168 | @spec merge_private(Context.t(), Keyword.t()) :: Context.t() 169 | def merge_private(%Context{private: private} = ctx, keyword) when is_list(keyword) do 170 | %{ctx | private: Enum.into(keyword, private)} 171 | end 172 | 173 | @doc """ 174 | Halts the Context pipeline by preventing further plugs downstream from being 175 | invoked. See the docs for `Helios.Context.Builder` for more information on halting a 176 | command context pipeline. 177 | """ 178 | @spec halt(Context.t()) :: Context.t() 179 | def halt(%Context{} = ctx) do 180 | %{ctx | halted: true} 181 | end 182 | 183 | @doc """ 184 | Emits given event or events. If events is equal to nil, then it will ignore call. 185 | """ 186 | @spec emit(ctx :: Context.t(), Context.events()) :: Context.t() 187 | def emit(%Context{events: events} = ctx, to_emit) do 188 | events = List.wrap(events) 189 | 190 | to_emit = 191 | to_emit 192 | |> List.wrap() 193 | |> Enum.map(fn event -> 194 | metadata = 195 | [ 196 | # todo: Metadata builder as plug??!?! 197 | correlation_id: ctx.correlation_id, 198 | causation_id: ctx.request_id, 199 | emitted_at: DateTime.utc_now() 200 | ] 201 | |> Enum.filter(fn {_, v} -> v != nil end) 202 | 203 | %EventData{ 204 | id: Keyword.get(UUID.info!(UUID.uuid4()), :binary, :undefined), 205 | type: Atom.to_string(event.__struct__), 206 | data: event, 207 | metadata: Enum.into(metadata, %{}) 208 | } 209 | end) 210 | 211 | %{ctx | events: events ++ to_emit} 212 | end 213 | 214 | @doc """ 215 | Set response to context. Response is success 216 | """ 217 | @spec ok(Context.t(), any()) :: Context.t() 218 | def ok(ctx, message \\ :ok) 219 | 220 | def ok(%Context{state: :set}, _message) do 221 | raise RuntimeError, "Response already set!" 222 | end 223 | 224 | def ok(%Context{} = ctx, message) do 225 | %{ctx | response: message, state: :set, status: :executed} 226 | end 227 | 228 | @spec error(Context.t(), any()) :: Context.t() 229 | def error(%Context{} = ctx, reason) do 230 | %{ctx | halted: true, response: reason, state: :set, status: :failed} 231 | end 232 | 233 | @spec send_resp(t) :: t | no_return 234 | def send_resp(ctx) 235 | 236 | def send_resp(%Context{state: :unset}) do 237 | raise ArgumentError, "cannot send a response that was not set" 238 | end 239 | 240 | def send_resp(%Context{adapter: {adapter, payload}, state: :set, owner: owner} = ctx) do 241 | ctx = run_before_send(ctx, :set) 242 | 243 | {:ok, body, payload} = adapter.send_resp(payload, ctx.status, ctx.response) 244 | 245 | send(owner, @already_sent) 246 | %{ctx | adapter: {adapter, payload}, response: body, state: :sent} 247 | end 248 | 249 | def send_resp(%Context{}) do 250 | raise AlreadySentError 251 | end 252 | 253 | @doc """ 254 | Registers a callback to be invoked before the response is sent. 255 | 256 | Callbacks are invoked in the reverse order they are defined (callbacks 257 | defined first are invoked last). 258 | """ 259 | @spec register_before_send(t, (t -> t)) :: t 260 | def register_before_send(%Context{state: state}, _callback) 261 | when not (state in @unsent) do 262 | raise AlreadySentError 263 | end 264 | 265 | def register_before_send(%Context{before_send: before_send} = ctx, callback) 266 | when is_function(callback, 1) do 267 | %{ctx | before_send: [callback | before_send]} 268 | end 269 | 270 | defp run_before_send(%Context{before_send: before_send} = ctx, new) do 271 | ctx = Enum.reduce(before_send, %{ctx | state: new}, & &1.(&2)) 272 | 273 | if ctx.state != new do 274 | raise ArgumentError, "cannot send/change response from run_before_send callback" 275 | end 276 | 277 | ctx 278 | end 279 | end 280 | 281 | defimpl Inspect, for: Helios.Aggregate.Context do 282 | def inspect(ctx, opts) do 283 | ctx = 284 | if opts.limit == :infinity do 285 | ctx 286 | else 287 | update_in(ctx.events, fn {events, data} -> 288 | event_types = 289 | Enum.map(data, fn %{__struct__: event_module} -> 290 | "##{inspect(event_module)}<...>" 291 | end) 292 | 293 | {events, event_types} 294 | end) 295 | end 296 | 297 | Inspect.Any.inspect(ctx, opts) 298 | end 299 | end 300 | 301 | defimpl Collectable, for: Helios.Context do 302 | def into(data) do 303 | {data, 304 | fn data, _ -> 305 | data 306 | end} 307 | end 308 | end 309 | -------------------------------------------------------------------------------- /lib/helios/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Endpoint do 2 | @moduledoc """ 3 | Entry point for domain. 4 | 5 | It holds 6 | 7 | ## Example 8 | 9 | defmodule MyApp.Endpoint do 10 | use Helios.Endpoint, otp_app: :my_app 11 | 12 | # add any plugs you need since endpoint behavise like pipeline 13 | # plug MyApp.SomePlug 14 | 15 | plug MyApp.Router 16 | end 17 | """ 18 | 19 | @type topic :: String.t() 20 | @type event :: String.t() 21 | @type msg :: map 22 | 23 | @doc """ 24 | Starts endpoint supervision tree. 25 | """ 26 | @callback start_link() :: Supervisor.on_start() 27 | 28 | @doc """ 29 | Get configuration for given endpoint. 30 | """ 31 | @callback config(key :: atom, default :: term) :: term 32 | 33 | @doc """ 34 | Initializes endpoint configuration 35 | """ 36 | @callback init(:supervisor, config :: Keyword.t()) :: {:ok, Keyword.t()} 37 | 38 | # @doc """ 39 | # Subscribes the caller to the given topic. 40 | # """ 41 | # @callback subscribe(topic :: __MODULE__.topic(), opts :: Keyword.t()) :: :ok | {:error, term} 42 | 43 | # @doc """ 44 | # Unsubscribes the caller from the given topic. 45 | # """ 46 | # @callback unsubscribe(topic :: __MODULE__.topic()) :: :ok | {:error, term} 47 | 48 | # @doc """ 49 | # Broadcasts a `msg` as `event` in the given `topic`. 50 | # """ 51 | # @callback broadcast( 52 | # topic :: __MODULE__.topic(), 53 | # event :: __MODULE__.event(), 54 | # msg :: __MODULE__.msg() 55 | # ) :: :ok | {:error, term} 56 | 57 | # @doc """ 58 | # Broadcasts a `msg` as `event` in the given `topic`. 59 | 60 | # Raises in case of failures. 61 | # """ 62 | # @callback broadcast!( 63 | # topic :: __MODULE__.topic(), 64 | # event :: __MODULE__.event(), 65 | # msg :: __MODULE__.msg() 66 | # ) :: :ok | no_return 67 | 68 | # @doc """ 69 | # Broadcasts a `msg` from the given `from` as `event` in the given `topic`. 70 | # """ 71 | # @callback broadcast_from( 72 | # from :: pid, 73 | # topic :: __MODULE__.topic(), 74 | # event :: __MODULE__.event(), 75 | # msg :: __MODULE__.msg() 76 | # ) :: :ok | {:error, term} 77 | 78 | # @doc """ 79 | # Broadcasts a `msg` from the given `from` as `event` in the given `topic`. 80 | 81 | # Raises in case of failures. 82 | # """ 83 | # @callback broadcast_from!( 84 | # from :: pid, 85 | # topic :: __MODULE__.topic(), 86 | # event :: __MODULE__.event(), 87 | # msg :: __MODULE__.msg() 88 | # ) :: :ok | no_return 89 | 90 | # Instrumentation 91 | 92 | @doc """ 93 | Allows instrumenting operation defined by `function`. 94 | 95 | `runtime_metadata` may be omitted and defaults to `nil`. 96 | 97 | Read more about instrumentation in the "Instrumentation" section. 98 | """ 99 | @macrocallback instrument( 100 | instrument_event :: Macro.t(), 101 | runtime_metadata :: Macro.t(), 102 | funcion :: Macro.t() 103 | ) :: Macro.t() 104 | 105 | @doc false 106 | defmacro __using__(opts) do 107 | quote do 108 | @behaviour Helios.Endpoint 109 | 110 | unquote(config(opts)) 111 | # unquote(pubsub()) 112 | unquote(plug()) 113 | unquote(server()) 114 | end 115 | end 116 | 117 | defp config(opts) do 118 | quote do 119 | @otp_app unquote(opts)[:otp_app] || raise("endpoint expects :otp_app to be given") 120 | var!(config) = Helios.Endpoint.Supervisor.config(@otp_app, __MODULE__) 121 | 122 | def __app__, do: @otp_app 123 | 124 | @doc """ 125 | Callback invoked on endpoint initialization. 126 | """ 127 | @impl Helios.Endpoint 128 | def init(_key, config) do 129 | {:ok, config} 130 | end 131 | 132 | defoverridable init: 2 133 | end 134 | end 135 | 136 | defp plug() do 137 | quote location: :keep do 138 | use Helios.Pipeline.Builder 139 | import Helios.Endpoint 140 | 141 | # Compile after the debugger so we properly wrap it. 142 | @before_compile Helios.Endpoint 143 | @helios_render_errors var!(config)[:render_errors] 144 | end 145 | end 146 | 147 | defp server() do 148 | quote location: :keep, unquote: false do 149 | @doc false 150 | def child_spec(opts) do 151 | %{ 152 | id: __MODULE__, 153 | start: {__MODULE__, :start_link, [opts]}, 154 | type: :supervisor 155 | } 156 | end 157 | 158 | defoverridable child_spec: 1 159 | 160 | @doc """ 161 | Starts the endpoint supervision tree. 162 | """ 163 | @impl Helios.Endpoint 164 | def start_link(_opts \\ []) do 165 | Helios.Endpoint.Supervisor.start_link(@otp_app, __MODULE__) 166 | end 167 | 168 | @doc """ 169 | Returns the endpoint configuration for `key` 170 | 171 | Returns `default` if the key does not exist. 172 | """ 173 | @impl Helios.Endpoint 174 | def config(key, default \\ nil) do 175 | case :ets.lookup(__MODULE__, key) do 176 | [{^key, val}] -> val 177 | [] -> default 178 | end 179 | end 180 | 181 | @doc """ 182 | Reloads the configuration given the application environment changes. 183 | """ 184 | def config_change(changed, removed) do 185 | Helios.Endpoint.Supervisor.config_change(__MODULE__, changed, removed) 186 | end 187 | 188 | def path(path) when is_nil(path) or path == "", do: "/" 189 | def path(path), do: path 190 | end 191 | end 192 | 193 | defmacro __before_compile__(%{module: module}) do 194 | otp_app = Module.get_attribute(module, :otp_app) 195 | instrumentation = Helios.Endpoint.Instrument.definstrument(otp_app, module) 196 | 197 | quote do 198 | defoverridable call: 2 199 | 200 | # Inline render errors so we set the endpoint before calling it. 201 | def call(ctx, opts) do 202 | ctx = 203 | ctx 204 | |> Helios.Context.put_private(:helios_endpoint, __MODULE__) 205 | |> Helios.Context.put_private(:helios_timeout, Keyword.get(opts, :timeout, 5_000)) 206 | 207 | try do 208 | super(ctx, opts) 209 | rescue 210 | e in Helios.Pipeline.WrapperError -> 211 | %{context: ctx, kind: kind, reason: reason, stack: stack} = e 212 | reraise e, System.stacktrace() 213 | catch 214 | kind, reason -> 215 | _stack = System.stacktrace() 216 | {kind, reason} 217 | #Helios.Endpoint.RespondError.__catch__(ctx, kind, reason, stack, @helios_render_errors) 218 | end 219 | end 220 | 221 | @doc false 222 | def __dispatch__(path, opts) 223 | # unquote(dispatches) 224 | def __dispatch__(_, opts), do: {:plug, __MODULE__, opts} 225 | 226 | unquote(instrumentation) 227 | end 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /lib/helios/endpoint/facade.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Endpoint.Facade do 2 | @moduledoc """ 3 | Implements `Helios.Pipeline.Adapter` so it can dispatch message to endpoint and 4 | receive response from it. 5 | 6 | Note that owner process pid is used to receive response, meaning that it will 7 | await response or {:error, :timout} 8 | """ 9 | alias Helios.Context 10 | require Logger 11 | @behaviour Helios.Pipeline.Adapter 12 | 13 | @impl Helios.Pipeline.Adapter 14 | @spec send_resp(%{owner: any, ref: any}, any, any) :: {:ok, any, %{owner: any, ref: any}} 15 | def send_resp(%{owner: _owner, ref: _ref} = state, _status, response) do 16 | {:ok, response, state} 17 | end 18 | 19 | @doc false 20 | def ctx(%{path: path, params: params} = args) do 21 | ref = make_ref() 22 | 23 | req = %{ 24 | owner: self(), 25 | ref: ref, 26 | path: path, 27 | params: params 28 | } 29 | 30 | %Context{ 31 | adapter: {__MODULE__, req}, 32 | method: :execute, 33 | owner: self(), 34 | path_info: split_path(path), 35 | params: params, 36 | assigns: Map.get(args, :assigns, %{}), 37 | private: Map.get(args, :private, %{}), 38 | request_id: Map.get(args, :request_id), 39 | correlation_id: Map.get(args, :correlation_id) 40 | } 41 | end 42 | 43 | @doc "Executes command at given proxy and endpoint" 44 | @spec execute(module, String.t(), term, atom | String.t(), map(), Keyword.t()) :: 45 | {:ok, any} 46 | | {:error, :not_found} 47 | | {:error, :timeout} 48 | | {:error, :server_error} 49 | | {:error, any} 50 | def execute(endpoint, proxy, id, command, params \\ %{}, opts \\ []) 51 | 52 | def execute(endpoint, proxy, id, command, params, opts) when is_atom(command) do 53 | execute(endpoint, proxy, id, Atom.to_string(command), params, opts) 54 | end 55 | 56 | def execute(endpoint, proxy, id, command, params, opts) do 57 | proxy_id = to_param(id) 58 | opts = Keyword.put_new(opts, :timeout, 5_000) 59 | path = 60 | [proxy, proxy_id, command] 61 | |> Path.join() 62 | 63 | %{path_info: path_info} = 64 | ctx = 65 | ctx(%{ 66 | path: path, 67 | params: params, 68 | assigns: Keyword.get(opts, :assigns, %{}), 69 | private: Keyword.get(opts, :private, %{}), 70 | requiest_id: Keyword.get(opts, :request_id), 71 | correlation_id: Keyword.get(opts, :correlation_id) 72 | }) 73 | 74 | try do 75 | case endpoint.__dispatch__(path_info, opts) do 76 | {:plug, handler, opts} -> 77 | ctx 78 | |> handler.call(opts) 79 | |> maybe_respond() 80 | end 81 | catch 82 | :error, value -> 83 | stack = System.stacktrace() 84 | exception = Exception.normalize(:error, value, stack) 85 | exit({{exception, stack}, {endpoint, :call, [ctx, opts]}}) 86 | 87 | :throw, value -> 88 | stack = System.stacktrace() 89 | exit({{{:nocatch, value}, stack}, {endpoint, :call, [ctx, opts]}}) 90 | 91 | :exit, value -> 92 | exit({value, {endpoint, :call, [ctx, opts]}}) 93 | end 94 | end 95 | 96 | def define(env, routes) do 97 | routes 98 | |> Enum.filter(fn {route, _exprs} -> 99 | not is_nil(route.proxy) and not (route.kind == :forward) and route.verb == :execute 100 | end) 101 | |> Enum.group_by(fn {%{proxy: proxy}, _} -> 102 | Module.concat([base(env.module), "Facade", Helios.Naming.camelize(proxy)]) 103 | end) 104 | |> Enum.each(&defproxy(env, &1)) 105 | end 106 | 107 | @anno (if :erlang.system_info(:otp_release) >= '19' do 108 | [generated: true] 109 | else 110 | [line: -1] 111 | end) 112 | 113 | def defproxy(env, {proxy_module, routes}) do 114 | proxy_ast = 115 | Enum.map(routes, fn {r, exprs} -> 116 | proxy_call(base(env.module), r, exprs) 117 | end) 118 | 119 | code = 120 | quote @anno do 121 | unquote(proxy_ast) 122 | end 123 | 124 | Module.create(proxy_module, code, line: env.line, file: env.file) 125 | end 126 | 127 | def proxy_call(base_name, route, %{path: path}) do 128 | path = Enum.take_while(path, fn e -> not is_tuple(e) end) 129 | path = Path.join(path) 130 | endpoint = Module.concat([base_name, "Endpoint"]) 131 | 132 | quote @anno do 133 | @doc "Executes `#{inspect(unquote(route.opts))}` on given endpoint by calling `#{ 134 | unquote(route.plug) |> Atom.to_string() |> String.replace("Elixir.", "") 135 | }`" 136 | @spec unquote(route.opts)(id :: term, params :: map(), Keyword.t()) :: {:ok | :error, term} 137 | def unquote(route.opts)(id, params, opts \\ []) do 138 | Helios.Endpoint.Facade.execute( 139 | unquote(endpoint), 140 | unquote(path), 141 | id, 142 | unquote(route.opts), 143 | params, 144 | opts 145 | ) 146 | end 147 | end 148 | end 149 | 150 | defp base(module) do 151 | module 152 | |> Module.split() 153 | |> List.pop_at(-1) 154 | |> elem(1) 155 | |> Module.concat() 156 | end 157 | 158 | defp split_path(path) do 159 | segments = :binary.split(path, "/", [:global]) 160 | for segment <- segments, segment != "", do: segment 161 | end 162 | 163 | def to_param(int) when is_integer(int), do: Integer.to_string(int) 164 | def to_param(bin) when is_binary(bin), do: bin 165 | def to_param(false), do: "false" 166 | def to_param(true), do: "true" 167 | def to_param(data), do: Helios.Param.to_param(data) 168 | 169 | defp maybe_respond({:error, %Helios.Router.NoRouteError{} = payload}) do 170 | Logger.error(Exception.format(:error, payload, :erlang.get_stacktrace())) 171 | {:error, :not_found} 172 | end 173 | 174 | defp maybe_respond({:error, :undef}) do 175 | {:error, :not_found} 176 | end 177 | 178 | defp maybe_respond({:exit, {:timeout, _}}) do 179 | {:error, :timeout} 180 | end 181 | 182 | defp maybe_respond({:exit, reason}) do 183 | Logger.error(fn -> "Received :exit signal with reson \n#{inspect(reason)}" end) 184 | {:error, :server_error} 185 | end 186 | 187 | defp maybe_respond(%Context{status: :failed, response: resp}) do 188 | if is_tuple(resp) do 189 | resp 190 | else 191 | {:error, resp} 192 | end 193 | end 194 | 195 | defp maybe_respond(%Context{status: :success, response: resp}) do 196 | {:ok, resp} 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /lib/helios/endpoint/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Endpoint.Handler do 2 | @moduledoc false 3 | use GenServer 4 | use Helios.Logger 5 | 6 | def child_spec(otp_app, endpoint) do 7 | %{ 8 | id: __MODULE__, 9 | start: {__MODULE__, :start_link, [otp_app, endpoint]}, 10 | type: :worker 11 | } 12 | end 13 | 14 | def handler_name(endpoint) do 15 | Module.concat(endpoint, Handler) 16 | end 17 | 18 | def start_link(otp_app, endpoint, opts \\ []) do 19 | opts = Keyword.put(opts, :name, handler_name(endpoint)) 20 | GenServer.start_link(__MODULE__, [otp_app, endpoint], opts) 21 | end 22 | 23 | def init([otp_app, endpoint]) do 24 | info(fn -> "[endpoint: #{inspect(endpoint)}] Starting message handler" end) 25 | {:ok, %{endpoint: endpoint, otp_app: otp_app}} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/helios/endpoint/instrument.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Endpoint.Instrument do 2 | @moduledoc false 3 | 4 | # This is the arity that event callbacks in the instrumenter modules must 5 | # have. 6 | @event_callback_arity 3 7 | 8 | @doc false 9 | def definstrument(otp_app, endpoint) do 10 | app_instrumenters = app_instrumenters(otp_app, endpoint) 11 | 12 | quote bind_quoted: [app_instrumenters: app_instrumenters] do 13 | require Logger 14 | 15 | @doc """ 16 | Instruments the given function. 17 | 18 | `event` is the event identifier (usually an atom) that specifies which 19 | instrumenting function to call in the instrumenter modules. `runtime` is 20 | metadata to be associated with the event at runtime (e.g., the query being 21 | issued if the event to instrument is a DB query). 22 | 23 | 24 | 25 | """ 26 | @impl Helios.Endpoint 27 | defmacro instrument(event, runtime \\ Macro.escape(%{}), fun) do 28 | compile = Macro.escape(Helios.Endpoint.Instrument.strip_caller(__CALLER__)) 29 | 30 | quote do 31 | unquote(__MODULE__).instrument( 32 | unquote(event), 33 | unquote(compile), 34 | unquote(runtime), 35 | unquote(fun) 36 | ) 37 | end 38 | end 39 | 40 | # For each event in any of the instrumenters, we must generate a 41 | # clause of the `instrument/4` function. It'll look like this: 42 | # 43 | # def instrument(:my_event, compile, runtime, fun) do 44 | # res0 = Inst0.my_event(:start, compile, runtime) 45 | # ... 46 | # 47 | # start = :erlang.monotonic_time 48 | # try do 49 | # fun.() 50 | # after 51 | # diff = ... 52 | # Inst0.my_event(:stop, diff, res0) 53 | # ... 54 | # end 55 | # end 56 | # 57 | @doc false 58 | def instrument(event, compile, runtime, fun) 59 | 60 | for {event, instrumenters} <- app_instrumenters do 61 | def instrument(unquote(event), var!(compile), var!(runtime), fun) 62 | when is_map(var!(compile)) and is_map(var!(runtime)) and is_function(fun, 0) do 63 | unquote(Helios.Endpoint.Instrument.compile_start_callbacks(event, instrumenters)) 64 | start = :erlang.monotonic_time 65 | try do 66 | fun.() 67 | after 68 | var!(diff) = :erlang.monotonic_time - start 69 | unquote(Helios.Endpoint.Instrument.compile_stop_callbacks(event, instrumenters)) 70 | end 71 | end 72 | end 73 | 74 | # Catch-all clause 75 | def instrument(event, compile, runtime, fun) 76 | when is_atom(event) and is_map(compile) and is_map(runtime) and is_function(fun, 0) do 77 | fun.() 78 | end 79 | end 80 | end 81 | 82 | # Reads a list of the instrumenters from the config of `otp_app` and finds all 83 | # events in those instrumenters. The return value is a list of `{event, 84 | # instrumenters}` tuples, one for each event defined by any instrumenters 85 | # (with no duplicated events); `instrumenters` is the list of instrumenters 86 | # interested in `event`. 87 | defp app_instrumenters(otp_app, endpoint) do 88 | config = Application.get_env(otp_app, endpoint, []) 89 | instrumenters = config[:instrumenters] || [] 90 | 91 | unless is_list(instrumenters) and Enum.all?(instrumenters, &is_atom/1) do 92 | raise ":instrumenters must be a list of instrumenter modules" 93 | end 94 | 95 | events_to_instrumenters([Helios.Plugs.Logger | instrumenters]) 96 | end 97 | 98 | # Strips a `Macro.Env` struct, leaving only interesting compile-time metadata. 99 | @doc false 100 | @spec strip_caller(Macro.Env.t) :: map() 101 | def strip_caller(%Macro.Env{module: mod, function: fun, file: file, line: line}) do 102 | caller = %{module: mod, function: form_fa(fun), file: file, line: line} 103 | 104 | if app = Application.get_env(:logger, :compile_time_application) do 105 | Map.put(caller, :application, app) 106 | else 107 | caller 108 | end 109 | end 110 | 111 | defp form_fa({name, arity}), do: Atom.to_string(name) <> "/" <> Integer.to_string(arity) 112 | defp form_fa(nil), do: nil 113 | 114 | # called by Helios.Endpoint.instrument/4, see docs there 115 | @doc false 116 | @spec extract_endpoint(Helios.Context.t | module) :: module | nil 117 | def extract_endpoint(endpoint_or_ctx) do 118 | case endpoint_or_ctx do 119 | %Helios.Context{private: %{helios_endpoint: endpoint}} -> endpoint 120 | %{__struct__: struct} when struct in [Helios.Context] -> nil 121 | endpoint -> endpoint 122 | end 123 | end 124 | 125 | # Returns the AST for all the calls to the "start event" callbacks in the given 126 | # list of `instrumenters`. 127 | # Each function call looks like this: 128 | # 129 | # res0 = Instr0.my_event(:start, compile, runtime) 130 | # 131 | @doc false 132 | @spec compile_start_callbacks(term, [module]) :: Macro.t 133 | def compile_start_callbacks(event, instrumenters) do 134 | Enum.map Enum.with_index(instrumenters), fn {inst, index} -> 135 | error_prefix = "Instrumenter #{inspect inst}.#{event}/3 failed.\n" 136 | quote do 137 | unquote(build_result_variable(index)) = 138 | try do 139 | unquote(inst).unquote(event)(:start, var!(compile), var!(runtime)) 140 | catch 141 | kind, error -> 142 | Logger.error [unquote(error_prefix), Exception.format(kind, error, System.stacktrace())] 143 | end 144 | end 145 | end 146 | end 147 | 148 | # Returns the AST for all the calls to the "stop event" callbacks in the given 149 | # list of `instrumenters`. 150 | # Each function call looks like this: 151 | # 152 | # Instr0.my_event(:stop, diff, res0) 153 | # 154 | @doc false 155 | @spec compile_stop_callbacks(term, [module]) :: Macro.t 156 | def compile_stop_callbacks(event, instrumenters) do 157 | Enum.map Enum.with_index(instrumenters), fn {inst, index} -> 158 | error_prefix = "Instrumenter #{inspect inst}.#{event}/3 failed.\n" 159 | quote do 160 | try do 161 | unquote(inst).unquote(event)(:stop, var!(diff), unquote(build_result_variable(index))) 162 | catch 163 | kind, error -> 164 | Logger.error unquote(error_prefix) <> Exception.format(kind, error) 165 | end 166 | end 167 | end 168 | end 169 | 170 | # Takes a list of instrumenter modules and returns a list of `{event, 171 | # instrumenters}` tuples where each tuple represents an event and all the 172 | # modules interested in that event. 173 | defp events_to_instrumenters(instrumenters) do 174 | instrumenters # [Ins1, Ins2, ...] 175 | |> instrumenters_and_events() # [{Ins1, e1}, {Ins2, e1}, ...] 176 | |> Enum.group_by(fn {_inst, e} -> e end) # %{e1 => [{Ins1, e1}, ...], ...} 177 | |> Enum.map(fn {e, insts} -> {e, strip_events(insts)} end) # [{e1, [Ins1, Ins2]}, ...] 178 | end 179 | 180 | defp instrumenters_and_events(instrumenters) do 181 | # We're only interested in functions (events) with the given arity. 182 | for inst <- instrumenters, 183 | {event, @event_callback_arity} <- inst.__info__(:functions), 184 | do: {inst, event} 185 | end 186 | 187 | defp strip_events(instrumenters) do 188 | for {inst, _evt} <- instrumenters, do: inst 189 | end 190 | 191 | defp build_result_variable(index) when is_integer(index) do 192 | "res#{index}" |> String.to_atom() |> Macro.var(nil) 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /lib/helios/endpoint/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Endpoint.Supervisor do 2 | @moduledoc false 3 | require Logger 4 | use Supervisor 5 | 6 | @doc false 7 | def start_link(otp_app, module) do 8 | case Supervisor.start_link(__MODULE__, {otp_app, module}, name: module) do 9 | {:ok, _} = ok -> 10 | warmup(module) 11 | ok 12 | 13 | {:error, _} = error -> 14 | error 15 | end 16 | end 17 | 18 | @doc false 19 | def init({otp_app, endpoint}) do 20 | id = 21 | 16 22 | |> :crypto.strong_rand_bytes() 23 | |> Base.encode64() 24 | 25 | conf = 26 | case endpoint.init(:supervisor, [endpoint_id: id] ++ config(otp_app, endpoint)) do 27 | {:ok, conf} -> 28 | conf 29 | 30 | other -> 31 | raise ArgumentError, 32 | "expected init/2 callback to return {:ok, config}, got: #{inspect(other)}" 33 | end 34 | 35 | server? = server?(conf) 36 | 37 | if server? and conf[:code_reloader] do 38 | Helios.CodeReloader.Server.check_symlinks() 39 | end 40 | 41 | children = 42 | [] 43 | |> Kernel.++(config_children(endpoint, conf, otp_app)) 44 | |> Kernel.++(server_children(endpoint, conf, server?)) 45 | 46 | Supervisor.init(children, strategy: :one_for_one) 47 | end 48 | 49 | @doc """ 50 | The endpoint configuration used at compile time. 51 | """ 52 | def config(otp_app, endpoint) do 53 | Helios.Config.from_env(otp_app, endpoint, defaults(otp_app, endpoint)) 54 | end 55 | 56 | @doc """ 57 | Callback that changes the configuration from the app callback. 58 | """ 59 | def config_change(endpoint, changed, removed) do 60 | res = Helios.Config.config_change(endpoint, changed, removed) 61 | warmup(endpoint) 62 | res 63 | end 64 | 65 | defp config_children(endpoint, conf, otp_app) do 66 | args = [ 67 | endpoint, 68 | conf, 69 | defaults(otp_app, endpoint), 70 | [name: Module.concat(endpoint, "Config")] 71 | ] 72 | 73 | [worker(Helios.Config, args)] 74 | end 75 | 76 | defp server_children(endpoint, conf, server?) do 77 | if server? do 78 | otp_app = conf[:otp_app] 79 | 80 | [ 81 | Helios.Endpoint.Handler.child_spec(otp_app, endpoint), 82 | Helios.Aggregate.Supervisor.child_spec(otp_app, endpoint), 83 | Helios.Registry.child_spec(otp_app, endpoint), 84 | Helios.Registry.Tracker.child_spec(otp_app, endpoint) 85 | ] 86 | else 87 | [] 88 | end 89 | end 90 | 91 | defp defaults(otp_app, _module) do 92 | [ 93 | otp_app: otp_app, 94 | adapter: Helios.Endpoint.RpcAdapter, 95 | 96 | # Compile-time config 97 | code_reloader: false, 98 | 99 | # Supervisor config 100 | pubsub: [pool_size: 1], 101 | watchers: [] 102 | ] 103 | end 104 | 105 | @doc """ 106 | Checks if Endpoint's web server has been configured to start. 107 | """ 108 | def server?(otp_app, endpoint) when is_atom(otp_app) and is_atom(endpoint) do 109 | otp_app 110 | |> config(endpoint) 111 | |> server?() 112 | end 113 | 114 | def server?(conf) when is_list(conf) do 115 | Keyword.get(conf, :server, Application.get_env(:helios, :serve_endpoints, false)) 116 | end 117 | 118 | defp warmup(endpoint) do 119 | endpoint.path("/") 120 | :ok 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/helios/event_journal.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.EventJournal do 2 | @moduledoc """ 3 | EventJournal 4 | 5 | ## Config Example 6 | use Mix.Config 7 | 8 | config :my_app, MyApp.EventJournal, 9 | adapter: Helios.EventJournal.Adapter.Eventstore 10 | adapter_config: [ 11 | db_type: :node, 12 | host: "localhost", 13 | port: 1113, 14 | username: "admin", 15 | password: "changeit", 16 | reconnect_delay: 2_000, 17 | connection_name: "my_connection_name", 18 | max_attempts: :infinity 19 | ] 20 | 21 | ## Example Code 22 | defmodule MyApp.EventJournal do 23 | use Helios.EventJournal.EventJournal, ot_app: :my_app 24 | end 25 | 26 | defmodule MyApp.Events.UserCreated do 27 | defstruct [:id, :email] 28 | end 29 | 30 | alias MyApp.EventJournal, as: Journal 31 | alias MyApp.Events.UserCreated 32 | alias Helios.EventJournal.Messages.EventData 33 | 34 | stream = "users-1234" 35 | events = [EventData.new(Extreme.Tools.gen_uuid(), MyApp.Events.UserCreated, %UserCreated{id: 1234, email: "example@example.com"})] 36 | metadata = %{} 37 | expected_version = -2 38 | 39 | {:ok, last_event_number} = Journal.append_to_stream(stream, events, expected_version) 40 | 41 | """ 42 | alias Helios.EventJournal.Messages 43 | 44 | def stream_end(), do: 9_223_372_036_854_775_807 45 | def stream_start(), do: -1 46 | 47 | defmacro __using__(opts) do 48 | quote do 49 | @behaviour Helios.EventJournal 50 | 51 | {otp_app, adapter, behaviours, config} = 52 | Helios.EventJournal.compile_config(__MODULE__, unquote(opts[:otp_app]), unquote(opts[:adapter])) 53 | 54 | @otp_app otp_app 55 | @adapter adapter 56 | @config config 57 | 58 | def config do 59 | opts = Application.get_env(@otp_app, __MODULE__, []) 60 | config = opts[:adapter_config] 61 | config 62 | end 63 | 64 | def __adapter__ do 65 | @adapter 66 | end 67 | 68 | def child_spec(opts) do 69 | %{ 70 | id: __MODULE__, 71 | start: {__MODULE__, :start_link, [opts]}, 72 | type: :supervisor 73 | } 74 | end 75 | 76 | def start_link(opts \\ []) do 77 | __adapter__().start_link(__MODULE__, @config, opts) 78 | end 79 | 80 | def stop(timeout \\ 5000) do 81 | Supervisor.stop(__MODULE__, :normal, timeout) 82 | end 83 | 84 | @impl true 85 | def append_to_stream(stream, events, expexted_version \\ -1) do 86 | __adapter__().append_to_stream(__MODULE__, stream, events, expexted_version) 87 | end 88 | 89 | @impl true 90 | @doc "Reads single event from stream" 91 | def read_event(stream, event_number, resolve_links \\ false) do 92 | __adapter__().read_event(__MODULE__, stream, event_number, resolve_links) 93 | end 94 | 95 | @impl true 96 | def read_stream_events_forward(stream, event_number, max_events, resolve_links \\ false) do 97 | __adapter__().read_stream_events_forward( 98 | __MODULE__, 99 | stream, 100 | event_number, 101 | max_events, 102 | resolve_links 103 | ) 104 | end 105 | 106 | @impl true 107 | def read_stream_events_backward(stream, event_number, max_events, resolve_links \\ false) do 108 | __adapter__().read_stream_events_backward( 109 | __MODULE__, 110 | stream, 111 | event_number, 112 | max_events, 113 | resolve_links 114 | ) 115 | end 116 | 117 | @impl true 118 | def read_all_events_forward(position, max_events, resolve_links \\ false) do 119 | __adapter__().read_all_events_forward( 120 | __MODULE__, 121 | position, 122 | max_events, 123 | resolve_links 124 | ) 125 | end 126 | 127 | @impl true 128 | def read_all_events_backward(position, max_events, resolve_links \\ false) do 129 | __adapter__().read_all_events_backward( 130 | __MODULE__, 131 | position, 132 | max_events, 133 | resolve_links 134 | ) 135 | end 136 | 137 | @impl true 138 | def delete_stream(stream, expected_version, hard_delete? \\ false) do 139 | __adapter__().delete_stream(__MODULE__, stream, expected_version, hard_delete?) 140 | end 141 | 142 | @impl true 143 | def get_stream_metadata(stream) do 144 | __adapter__().get_stream_metadata(__MODULE__, stream) 145 | end 146 | 147 | @impl true 148 | def set_stream_metadata(stream, expected_version, metadata) do 149 | __adapter__().set_stream_metadata(__MODULE__, stream, expected_version, metadata) 150 | end 151 | end 152 | end 153 | 154 | @type stream_name :: String.t() 155 | @type event_number :: integer 156 | 157 | @type append_error :: 158 | :wrong_expected_version 159 | | :stream_deleted 160 | | :access_denied 161 | 162 | @doc "Append events to stream if given expected version matches to last written event in journals database" 163 | @callback append_to_stream( 164 | stream :: stream_name(), 165 | events :: list(struct), 166 | expexted_version :: event_number() 167 | ) :: 168 | {:ok, event_number} 169 | | {:error, append_error} 170 | 171 | @doc "Reads single event from stream" 172 | @callback read_event( 173 | stream :: stream_name(), 174 | event_number :: event_number(), 175 | resolve_links :: boolean 176 | ) :: 177 | {:ok, Messages.PersistedEvent.t()} 178 | | {:error, Messages.PersistedEvent.read_error()} 179 | 180 | @doc "Reads forward `max_events` events from journal from given position" 181 | @callback read_stream_events_forward( 182 | stream :: stream_name(), 183 | event_number :: event_number(), 184 | max_events :: integer, 185 | resolve_links :: boolean 186 | ) :: 187 | {:ok, list(Messages.ReadStreamEventsResponse.t())} 188 | | {:error, Messages.ReadStreamEventsResponse.read_error()} 189 | 190 | @doc "Reads `max_events` events from journal from given position backward until max_events or begining of stream is reached" 191 | @callback read_stream_events_backward( 192 | stream :: stream_name(), 193 | event_number :: event_number(), 194 | max_events :: integer, 195 | resolve_links :: boolean 196 | ) :: 197 | {:ok, list(Messages.ReadStreamEventsResponse.t())} 198 | | {:error, Messages.ReadStreamEventsResponse.read_error()} 199 | 200 | @callback read_all_events_forward( 201 | position :: {integer, integer}, 202 | max_events :: integer, 203 | resolve_links :: boolean 204 | ) :: 205 | {:ok, list(Messages.ReadAllEventsResponse.t())} 206 | | {:error, Messages.ReadAllEventsResponse.read_error()} 207 | 208 | @callback read_all_events_backward( 209 | position :: {integer, integer}, 210 | max_events :: integer, 211 | resolve_links :: boolean 212 | ) :: 213 | {:ok, list(Messages.ReadAllEventsResponse.t())} 214 | | {:error, Messages.ReadAllEventsResponse.read_error()} 215 | 216 | @callback delete_stream( 217 | stream :: String.t(), 218 | expected_version :: integer, 219 | hard_delete? :: boolean 220 | ) :: 221 | {:ok, Messages.Position.t()} 222 | | {:error, :wrong_expected_version} 223 | | {:error, :stream_deleted} 224 | | {:error, :access_denied} 225 | | {:error, any} 226 | 227 | @callback get_stream_metadata(stream :: String.t()) :: 228 | {:ok, Messages.StreamMetadataResponse.t()} 229 | | {:error, :stream_deleted} 230 | | {:error, :no_stream} 231 | | {:error, :not_found} 232 | | {:error, any} 233 | 234 | @callback set_stream_metadata( 235 | stream :: String.t(), 236 | expected_version :: event_number, 237 | metadata :: map 238 | ) :: 239 | {:ok, event_number} 240 | | {:error, :stream_deleted} 241 | | {:error, :no_stream} 242 | | {:error, :not_found} 243 | | {:error, :access_denied} 244 | | {:ettot, any} 245 | 246 | def compile_config(repo, otp_app, adapter) do 247 | opts = Application.get_env(otp_app, repo, []) 248 | adapter = opts[:adapter] || adapter 249 | config = opts[:adapter_config] 250 | 251 | unless adapter do 252 | raise ArgumentError, 253 | "missing :adapter config option on `use Helios.EventJournal`" 254 | end 255 | 256 | unless Code.ensure_loaded?(adapter) do 257 | raise ArgumentError, 258 | "adapter #{inspect(adapter)} was not compiled, " <> 259 | "ensure it is correct and it is included as a project dependency" 260 | end 261 | 262 | behaviours = 263 | for {:behaviour, behaviours} <- adapter.__info__(:attributes), 264 | behaviour <- behaviours, 265 | do: behaviour 266 | 267 | unless Helios.EventJournal.Adapter in behaviours do 268 | raise ArgumentError, 269 | "expected :adapter option given to Helios.EventJournal to list " <> 270 | "Helios.EventJournal.Adapter as a behaviour" 271 | end 272 | 273 | {otp_app, adapter, behaviours, config} 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /lib/helios/event_journal/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.EventJournal.Adapter do 2 | @moduledoc """ 3 | Defines behaviour for EventJournal adapter. 4 | """ 5 | alias Helios.EventJournal.Messages 6 | 7 | @type stream_name :: String.t() 8 | @type event_number :: integer 9 | 10 | @type append_error :: 11 | :wrong_expected_version 12 | | :stream_deleted 13 | | :access_denied 14 | 15 | @callback start_link(module :: module(), config :: keyword(), opts :: keyword()) :: 16 | :ignore | {:error, any()} | {:ok, pid()} 17 | 18 | @doc "Append events to stream if given expected version matches to last written event in journals database" 19 | @callback append_to_stream( 20 | server :: module, 21 | stream :: stream_name(), 22 | events :: list(Messages.EventData.t()), 23 | expexted_version :: event_number() 24 | ) :: 25 | {:ok, event_number} 26 | | {:error, append_error} 27 | 28 | @doc "Reads single event from stream" 29 | @callback read_event( 30 | server :: module, 31 | stream :: stream_name(), 32 | event_number :: event_number(), 33 | resolve_links :: boolean 34 | ) :: 35 | {:ok, Messages.PersistedEvent.t()} 36 | | {:error, Messages.PersistedEvent.read_error()} 37 | 38 | @doc "Reads forward `max_events` events from journal from given position" 39 | @callback read_stream_events_forward( 40 | server :: module, 41 | stream :: stream_name(), 42 | event_number :: event_number(), 43 | max_events :: integer, 44 | resolve_links :: boolean 45 | ) :: 46 | {:ok, Messages.ReadStreamEventsResponse.t()} 47 | | {:error, Messages.ReadStreamEventsResponse.read_error()} 48 | 49 | @doc "Reads `max_events` events from journal from given position backward until max_events or begining of stream is reached" 50 | @callback read_stream_events_backward( 51 | server :: module, 52 | stream :: stream_name(), 53 | event_number :: event_number(), 54 | max_events :: integer, 55 | resolve_links :: boolean 56 | ) :: 57 | {:ok, Messages.ReadStreamEventsResponse.t()} 58 | | {:error, Messages.ReadStreamEventsResponse.read_error()} 59 | 60 | @callback read_all_events_forward( 61 | server :: module, 62 | position :: {integer, integer}, 63 | max_events :: integer, 64 | resolve_links :: boolean 65 | ) :: 66 | {:ok, Messages.ReadAllEventsResponse.t()} 67 | | {:error, Messages.ReadAllEventsResponse.read_error()} 68 | 69 | @callback read_all_events_backward( 70 | server :: module, 71 | position :: {integer, integer}, 72 | max_events :: integer, 73 | resolve_links :: boolean 74 | ) :: 75 | {:ok, Messages.ReadAllEventsResponse.t()} 76 | | {:error, Messages.ReadAllEventsResponse.read_error()} 77 | 78 | @callback delete_stream( 79 | server :: module, 80 | stream :: String.t(), 81 | expected_version :: integer, 82 | hard_delete :: boolean 83 | ) :: 84 | {:ok, list(Messages.Position.t())} 85 | | {:error, Messages.ReadAllEventsResponse.read_error()} 86 | 87 | @callback set_stream_metadata( 88 | server :: module, 89 | stream :: stream_name(), 90 | metadata :: map, 91 | expexted_version :: event_number() 92 | ) :: 93 | {:ok, event_number} 94 | | {:error, append_error} 95 | 96 | @callback get_stream_metadata(server :: module, stream :: stream_name()) :: 97 | {:ok, Messages.StreamMetadataResponse.t()} | {:error, append_error} 98 | end 99 | -------------------------------------------------------------------------------- /lib/helios/event_journal/messages.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.EventJournal.Messages do 2 | @moduledoc false 3 | defmodule ReadStreamEventsResponse do 4 | @moduledoc """ 5 | Represents response got from event journal 6 | """ 7 | @type read_error :: :stream_not_found | :stream_deleted | String.t() 8 | @type t :: %__MODULE__{ 9 | events: list(Helios.EventJournal.Messages.PersistedEvent.t()), 10 | next_event_number: integer, 11 | last_event_number: integer, 12 | is_end_of_stream: boolean, 13 | last_commit_position: integer 14 | } 15 | defstruct events: [], 16 | next_event_number: nil, 17 | last_event_number: nil, 18 | is_end_of_stream: false, 19 | last_commit_position: nil 20 | end 21 | 22 | defmodule Position do 23 | @moduledoc """ 24 | Represents position in event journal 25 | """ 26 | @type t :: %__MODULE__{ 27 | commit_position: integer, 28 | prepare_position: integer 29 | } 30 | 31 | defstruct commit_position: 0, prepare_position: 0 32 | 33 | def journal_start() do 34 | struct(__MODULE__, commit_position: 0, prepare_position: 0) 35 | end 36 | 37 | def journal_end() do 38 | struct(__MODULE__, commit_position: -1, prepare_position: -1) 39 | end 40 | 41 | def new(commit_position, prepare_position) do 42 | struct(__MODULE__, commit_position: commit_position, prepare_position: prepare_position) 43 | end 44 | end 45 | 46 | defmodule EventData do 47 | @moduledoc """ 48 | Holds metadata and event data of emitted event. 49 | 50 | Use this structure when you want to store event in event journal. 51 | """ 52 | @type t :: %__MODULE__{ 53 | id: String.t(), 54 | type: String.t(), 55 | data: struct | map, 56 | metadata: nil | map 57 | } 58 | defstruct [:id, :type, :data, :metadata] 59 | 60 | @doc """ 61 | Creates new `EventData` 62 | """ 63 | def new(id, type, data, metadata \\ %{}) 64 | 65 | def new(id, type, data, metadata) when is_atom(type), 66 | do: new(id, Atom.to_string(type), data, metadata) 67 | 68 | def new(id, type, data, metadata) when is_binary(type) do 69 | struct( 70 | __MODULE__, 71 | id: id, 72 | type: type, 73 | data: data, 74 | metadata: metadata || %{} 75 | ) 76 | end 77 | end 78 | 79 | defmodule PersistedEvent do 80 | @moduledoc """ 81 | Represents persisted event. 82 | """ 83 | @type read_error :: :not_found | :no_stream | :stream_deleted | String.t() 84 | @type t :: %__MODULE__{ 85 | stream_id: String.t(), 86 | event_number: integer, 87 | event_id: String.t(), 88 | event_type: String.t(), 89 | data: nil | map | struct, 90 | metadata: nil | map, 91 | created: DateTime.t(), 92 | position: nil | Position.t() 93 | } 94 | 95 | defstruct stream_id: nil, 96 | event_number: nil, 97 | event_id: nil, 98 | event_type: 1, 99 | data: nil, 100 | metadata: %{}, 101 | created: nil, 102 | position: nil 103 | end 104 | 105 | defmodule ReadAllEventsResponse do 106 | @moduledoc """ 107 | Holds read result when all events are read from event journal. 108 | """ 109 | @type read_error :: String.t() 110 | @type t :: %__MODULE__{ 111 | commit_position: integer, 112 | prepare_position: integer, 113 | events: list(Helios.EventJournal.Messages.PersistedEvent.t()), 114 | next_commit_position: integer, 115 | next_prepare_position: integer 116 | } 117 | 118 | defstruct commit_position: nil, 119 | prepare_position: nil, 120 | events: [], 121 | next_commit_position: nil, 122 | next_prepare_position: nil 123 | end 124 | 125 | defmodule StreamMetadataResponse do 126 | @moduledoc """ 127 | Holds stream metadata. 128 | """ 129 | @type t :: %__MODULE__{ 130 | stream: String.t(), 131 | is_deleted: boolean, 132 | meta_version: integer, 133 | metadata: map 134 | } 135 | 136 | defstruct stream: nil, is_deleted: false, meta_version: -1, metadata: %{} 137 | 138 | @spec new(String.t(), boolean, integer, map) :: __MODULE__.t() 139 | def new(stream, is_deleted, meta_version, metadata) do 140 | %__MODULE__{ 141 | stream: stream, 142 | is_deleted: is_deleted, 143 | meta_version: meta_version, 144 | metadata: metadata || %{} 145 | } 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/helios/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Logger do 2 | @moduledoc false 3 | require Logger 4 | 5 | defmacro __using__(_) do 6 | quote do 7 | require Logger 8 | import Helios.Logger 9 | 10 | end 11 | end 12 | 13 | defmacro debug(msg) do 14 | {fun, arity} = __CALLER__.function 15 | mod = __CALLER__.module 16 | 17 | quote do 18 | if Application.get_env(:helios, :log_level, :warn) in [:debug] do 19 | Logger.debug(fn -> 20 | msg = unquote(msg) 21 | msg = if is_function(msg), do: msg.(), else: msg 22 | "[#{inspect unquote(mod)}:#{unquote(fun)}/#{unquote(arity)}] #{msg}" 23 | end) 24 | end 25 | end 26 | end 27 | 28 | defmacro info(msg) do 29 | quote do 30 | if Application.get_env(:helios, :log_level, :warn) in [:debug, :info] do 31 | Logger.info(unquote(msg)) 32 | end 33 | end 34 | end 35 | 36 | defmacro warn(msg) do 37 | 38 | quote do 39 | if Application.get_env(:helios, :log_level, :warn) in [:debug, :info, :warn] do 40 | Logger.warn(unquote(msg)) 41 | end 42 | end 43 | end 44 | 45 | defmacro error(msg) do 46 | quote do 47 | if Application.get_env(:helios, :log_level, :warn) in [:debug, :info, :warn, :error] do 48 | Logger.error(unquote(msg)) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/helios/naming.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Naming do 2 | @moduledoc """ 3 | Naming convention helpers. 4 | """ 5 | 6 | @doc """ 7 | Extracts the aggregate or process manager name from an alias. 8 | 9 | ## Examples 10 | 11 | iex> Helios.Naming.process_name(MyApp.User) 12 | "user" 13 | 14 | iex> Helios.Naming.process_name(MyApp.UserAggregate) 15 | "user" 16 | 17 | """ 18 | @spec process_name(atom(), String.t()) :: String.t() 19 | def process_name(alias, suffix \\ "") do 20 | alias 21 | |> to_string() 22 | |> Module.split() 23 | |> List.last() 24 | |> unsuffix(suffix) 25 | |> underscore() 26 | end 27 | 28 | @doc """ 29 | Removes the given suffix from the name if it exists. 30 | 31 | ## Examples 32 | 33 | iex> Helios.Naming.unsuffix("MyApp.User", "Aggregate") 34 | "MyApp.User" 35 | 36 | iex> Helios.Naming.unsuffix("MyApp.UserAggregate", "Aggregate") 37 | "MyApp.User" 38 | 39 | """ 40 | @spec unsuffix(String.t(), String.t()) :: String.t() 41 | def unsuffix(value, suffix) do 42 | string = to_string(value) 43 | suffix_size = byte_size(suffix) 44 | prefix_size = byte_size(string) - suffix_size 45 | 46 | case string do 47 | <> -> prefix 48 | _ -> string 49 | end 50 | end 51 | 52 | @doc """ 53 | Converts String to underscore case. 54 | 55 | ## Examples 56 | 57 | iex> Helios.Naming.underscore("MyApp") 58 | "my_app" 59 | 60 | In general, `underscore` can be thought of as the reverse of 61 | `camelize`, however, in some cases formatting may be lost: 62 | 63 | Helios.Naming.underscore "SAPExample" #=> "sap_example" 64 | Helios.Naming.camelize "sap_example" #=> "SapExample" 65 | 66 | """ 67 | @spec underscore(String.t()) :: String.t() 68 | 69 | def underscore(value), do: Macro.underscore(value) 70 | 71 | defp to_lower_char(char) when char in ?A..?Z, do: char + 32 72 | defp to_lower_char(char), do: char 73 | 74 | @doc """ 75 | Converts String to camel case. 76 | 77 | Takes an optional `:lower` option to return lowerCamelCase. 78 | 79 | ## Examples 80 | 81 | iex> Helios.Naming.camelize("my_app") 82 | "MyApp" 83 | 84 | iex> Helios.Naming.camelize("my_app", :lower) 85 | "myApp" 86 | 87 | In general, `camelize` can be thought of as the reverse of 88 | `underscore`, however, in some cases formatting may be lost: 89 | 90 | Helios.Naming.underscore "SAPExample" #=> "sap_example" 91 | Helios.Naming.camelize "sap_example" #=> "SapExample" 92 | 93 | """ 94 | @spec camelize(String.t()) :: String.t() 95 | def camelize(value), do: Macro.camelize(value) 96 | 97 | @spec camelize(String.t(), :lower) :: String.t() 98 | def camelize("", :lower), do: "" 99 | 100 | def camelize(<>, :lower) do 101 | camelize(t, :lower) 102 | end 103 | 104 | def camelize(<> = value, :lower) do 105 | <<_first, rest::binary>> = camelize(value) 106 | <> <> rest 107 | end 108 | 109 | @doc """ 110 | Converts an attribute/form field into its humanize version. 111 | 112 | iex> Helios.Naming.humanize(:username) 113 | "Username" 114 | iex> Helios.Naming.humanize(:created_at) 115 | "Created at" 116 | iex> Helios.Naming.humanize("user_id") 117 | "User" 118 | """ 119 | @spec humanize(atom | String.t()) :: String.t() 120 | def humanize(atom) when is_atom(atom), 121 | do: humanize(Atom.to_string(atom)) 122 | 123 | def humanize(bin) when is_binary(bin) do 124 | bin = 125 | if String.ends_with?(bin, "_id") do 126 | binary_part(bin, 0, byte_size(bin) - 3) 127 | else 128 | bin 129 | end 130 | 131 | bin |> String.replace("_", " ") |> String.capitalize() 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/helios/param.ex: -------------------------------------------------------------------------------- 1 | defprotocol Helios.Param do 2 | @moduledoc """ 3 | A protocol that converts data structures into URI parameters. 4 | 5 | This protocol is used by URL helpers and other parts of the 6 | Helios stack. For example, if you call facade with: 7 | 8 | YourApp.Facade.User.create_user(1, %{"first_name" => "Foo", last_name => "Bar", "email" => "foo@example.com"}) 9 | 10 | Helios knows how to extract the `:id` from first parameter thanks 11 | to this protocol. Since underneeth it will build path that will be used for routing 12 | 13 | By default, Helios implements this protocol for integers, binaries, atoms, 14 | and structs. For structs, a key `:id` is assumed, but you may provide a 15 | specific implementation. 16 | 17 | Nil values cannot be converted to param. 18 | 19 | ## Custom parameters 20 | 21 | In order to customize the parameter for any struct, 22 | one can simply implement this protocol. 23 | 24 | However, for convenience, this protocol can also be 25 | derivable. For example: 26 | 27 | defmodule CreateUser do 28 | @derive Helios.Param 29 | defstruct [:id, :username] 30 | end 31 | 32 | By default, the derived implementation will also use 33 | the `:id` key. In case the user does not contain an 34 | `:id` key, the key can be specified with an option: 35 | 36 | defmodule CreateUser do 37 | @derive {Helios.Param, key: :username} 38 | defstruct [:username] 39 | end 40 | 41 | will automatically use `:username` in URLs. 42 | 43 | """ 44 | 45 | @fallback_to_any true 46 | 47 | @spec to_param(term) :: String.t 48 | def to_param(term) 49 | end 50 | 51 | defimpl Helios.Param, for: Integer do 52 | def to_param(int), do: Integer.to_string(int) 53 | end 54 | 55 | defimpl Helios.Param, for: BitString do 56 | def to_param(bin) when is_binary(bin), do: bin 57 | end 58 | 59 | defimpl Helios.Param, for: Atom do 60 | def to_param(nil) do 61 | raise ArgumentError, "cannot convert nil to param" 62 | end 63 | 64 | def to_param(atom) do 65 | Atom.to_string(atom) 66 | end 67 | end 68 | 69 | defimpl Helios.Param, for: Map do 70 | def to_param(map) do 71 | raise ArgumentError, 72 | "maps cannot be converted to_param. A struct was expected, got: #{inspect map}" 73 | end 74 | end 75 | 76 | defimpl Helios.Param, for: Any do 77 | defmacro __deriving__(module, struct, options) do 78 | key = Keyword.get(options, :key, :id) 79 | 80 | unless Map.has_key?(struct, key) do 81 | raise ArgumentError, "cannot derive Helios.Param for struct #{inspect module} " <> 82 | "because it does not have key #{inspect key}. Please pass " <> 83 | "the :key option when deriving" 84 | end 85 | 86 | quote do 87 | defimpl Helios.Param, for: unquote(module) do 88 | def to_param(%{unquote(key) => nil}) do 89 | raise ArgumentError, "cannot convert #{inspect unquote(module)} to param, " <> 90 | "key #{inspect unquote(key)} contains a nil value" 91 | end 92 | 93 | def to_param(%{unquote(key) => key}) when is_integer(key), do: Integer.to_string(key) 94 | def to_param(%{unquote(key) => key}) when is_binary(key), do: key 95 | def to_param(%{unquote(key) => key}), do: Helios.Param.to_param(key) 96 | end 97 | end 98 | end 99 | 100 | def to_param(%{"id" => nil}) do 101 | raise ArgumentError, "cannot convert struct to param, key :id contains a nil value" 102 | end 103 | def to_param(%{"id" => id}) when is_integer(id), do: Integer.to_string(id) 104 | def to_param(%{"id" => id}) when is_binary(id), do: id 105 | def to_param(%{"id" => id}), do: Helios.Param.to_param(id) 106 | 107 | def to_param(%{id: nil}) do 108 | raise ArgumentError, "cannot convert struct to param, key :id contains a nil value" 109 | end 110 | def to_param(%{id: id}) when is_integer(id), do: Integer.to_string(id) 111 | def to_param(%{id: id}) when is_binary(id), do: id 112 | def to_param(%{id: id}), do: Helios.Param.to_param(id) 113 | 114 | def to_param(map) when is_map(map) do 115 | raise ArgumentError, 116 | "structs expect an :id key when converting to_param or a custom implementation " <> 117 | "of the Helios.Param protocol (read Helios.Param docs for more information), " <> 118 | "got: #{inspect map}" 119 | end 120 | 121 | def to_param(data) do 122 | raise Protocol.UndefinedError, protocol: @protocol, value: data 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/helios/pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Pipeline do 2 | @moduledoc false 3 | 4 | alias Helios.Context 5 | 6 | @doc false 7 | defmacro __using__(opts) do 8 | quote bind_quoted: [opts: opts] do 9 | @behaviour Helios.Pipeline.Plug 10 | 11 | import Helios.Pipeline 12 | alias Helios.Context 13 | 14 | Module.register_attribute(__MODULE__, :plugs, accumulate: true) 15 | @before_compile Helios.Pipeline 16 | @helios_log_level Keyword.get(opts, :log, :debug) 17 | 18 | @doc false 19 | def init(opts), do: opts 20 | 21 | @doc false 22 | def call(ctx, handler) when is_atom(handler) do 23 | ctx = 24 | ctx.private 25 | |> update_in(fn private -> 26 | private 27 | |> Map.put(:helios_plug, __MODULE__) 28 | |> Map.put(:helios_plug_handler, handler) 29 | end) 30 | |> Map.put(:status, :executing) 31 | 32 | helios_plug_pipeline(ctx, handler) 33 | end 34 | 35 | @doc false 36 | def handle(%Context{private: %{helios_plug_handler: handler}} = ctx, _ops) do 37 | if function_exported?(__MODULE__, handler, 2) do 38 | apply(__MODULE__, handler, [ctx, ctx.params]) 39 | else 40 | raise Helios.Pipeline.MessageHandlerClauseError, 41 | plug: __MODULE__, 42 | handler: handler, 43 | params: ctx.params 44 | end 45 | end 46 | 47 | defoverridable init: 1, call: 2, handle: 2 48 | end 49 | end 50 | 51 | @doc false 52 | defmacro __before_compile__(env) do 53 | handler = {:handle, [], true} 54 | plugs = [handler | Module.get_attribute(env.module, :plugs)] 55 | 56 | {ctx, body} = 57 | Helios.Pipeline.Builder.compile( 58 | env, 59 | plugs, 60 | log_on_halt: :debug, 61 | init_mode: Helios.plug_init_mode() 62 | ) 63 | 64 | quote location: :keep do 65 | defoverridable handle: 2 66 | 67 | def handle(var!(ctx_before), opts) do 68 | try do 69 | # var!(ctx_after) = super(var!(ctx_before), opts) 70 | super(var!(ctx_before), opts) 71 | catch 72 | :error, reason -> 73 | Helios.Pipeline.__catch__( 74 | var!(ctx_before), 75 | reason, 76 | __MODULE__, 77 | var!(ctx_before).private.helios_plug_handler, 78 | __STACKTRACE__ 79 | ) 80 | end 81 | end 82 | 83 | defp helios_plug_pipeline(unquote(ctx), var!(handler)) do 84 | var!(ctx) = unquote(ctx) 85 | var!(plug) = __MODULE__ 86 | _ = var!(ctx) 87 | _ = var!(plug) 88 | _ = var!(handler) 89 | 90 | unquote(body) 91 | end 92 | end 93 | end 94 | 95 | @doc false 96 | def __catch__( 97 | %Helios.Context{}, 98 | :function_clause, 99 | plug, 100 | handler, 101 | [{plug, handler, [%Helios.Context{} = ctx | _], _loc} | _] = stack 102 | ) do 103 | args = [plug: plug, handler: handler, params: ctx.params] 104 | reraise Helios.Pipeline.MessageHandlerClauseError, args, stack 105 | end 106 | 107 | def __catch__(%Context{} = ctx, reason, _aggregate, _handler, stack) do 108 | Helios.Pipeline.WrapperError.reraise(ctx, :error, reason, stack) 109 | end 110 | 111 | @doc """ 112 | Stores a plug to be executed as part of the command pipeline. 113 | """ 114 | defmacro plug(plug) 115 | 116 | defmacro plug({:when, _, [plug, guards]}), do: plug(plug, [], guards) 117 | 118 | defmacro plug(plug), do: plug(plug, [], true) 119 | 120 | @doc """ 121 | Stores a plug with the given options to be executed as part of 122 | the command pipeline. 123 | """ 124 | defmacro plug(plug, opts) 125 | 126 | defmacro plug(plug, {:when, _, [opts, guards]}), do: plug(plug, opts, guards) 127 | 128 | defmacro plug(plug, opts), do: plug(plug, opts, true) 129 | 130 | defp plug(plug, opts, guards) do 131 | quote do 132 | @plugs {unquote(plug), unquote(opts), unquote(Macro.escape(guards))} 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/helios/pipeline/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Pipeline.Adapter do 2 | @moduledoc """ 3 | Each message that comes from outer world needs to be adopted to message context. 4 | By implementing `send_resp/3` adpater should return response body that is sent to 5 | caller. 6 | """ 7 | alias Helios.Context 8 | 9 | @type payload :: term 10 | @type status :: Context.status() 11 | @type response :: Context.response() 12 | 13 | @type sent_body :: term | nil 14 | 15 | @callback send_resp(payload, status, response) :: {:ok, sent_body, payload} 16 | end 17 | -------------------------------------------------------------------------------- /lib/helios/pipeline/adapter/test.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Pipeline.Adapter.Test do 2 | @moduledoc false 3 | @behaviour Helios.Pipeline.Adapter 4 | alias Helios.Context 5 | 6 | @already_sent {:plug_ctx, :sent} 7 | 8 | def ctx(%Context{} = ctx, method, uri, params) when method in [:execute, :trace, :process] do 9 | maybe_flush() 10 | 11 | uri = URI.parse(uri) 12 | owner = self() 13 | 14 | state = %{ 15 | method: method, 16 | params: params, 17 | ref: make_ref(), 18 | owner: owner 19 | } 20 | 21 | %{ 22 | ctx 23 | | adapter: {__MODULE__, state}, 24 | method: method, 25 | params: params, 26 | path_info: split_path(uri.path) 27 | } 28 | end 29 | 30 | def send_resp(%{owner: owner, ref: ref} = state, status, payload) do 31 | send(owner, {ref, status, payload}) 32 | {:ok, payload, state} 33 | end 34 | 35 | defp maybe_flush() do 36 | receive do 37 | @already_sent -> :ok 38 | after 39 | 0 -> :ok 40 | end 41 | end 42 | 43 | defp split_path(nil), do: [] 44 | 45 | defp split_path(path) do 46 | segments = :binary.split(path, "/", [:global]) 47 | for segment <- segments, segment != "", do: segment 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/helios/pipeline/builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Pipeline.Builder do 2 | @type plug :: module | atom 3 | 4 | @moduledoc """ 5 | Builder for Helios pipeline 6 | """ 7 | 8 | defmacro __using__(opts) do 9 | quote do 10 | @behaviour Helios.Pipeline.Plug 11 | @plug_builder_opts unquote(opts) 12 | 13 | def init(opts), do: opts 14 | 15 | def call(ctx, opts) do 16 | pipe_builder_call(ctx, opts) 17 | end 18 | 19 | defoverridable init: 1, call: 2 20 | 21 | import Helios.Context 22 | import Helios.Pipeline.Builder, only: [plug: 1, plug: 2] 23 | 24 | Module.register_attribute(__MODULE__, :plugs, accumulate: true) 25 | @before_compile Helios.Pipeline.Builder 26 | end 27 | end 28 | 29 | defmacro __before_compile__(env) do 30 | plugs = Module.get_attribute(env.module, :plugs) 31 | builder_opts = Module.get_attribute(env.module, :plug_builder_opts) 32 | 33 | {ctx, body} = Helios.Pipeline.Builder.compile(env, plugs, builder_opts) 34 | 35 | quote do 36 | defp pipe_builder_call(unquote(ctx), _), do: unquote(body) 37 | end 38 | end 39 | 40 | @doc """ 41 | A macro that stores a new plug into pipeline. `opts` will be passed unchanged to the new 42 | plug. 43 | """ 44 | defmacro plug(plug, opts \\ []) do 45 | quote do 46 | @plugs {unquote(plug), unquote(opts), true} 47 | end 48 | end 49 | 50 | @doc false 51 | @spec compile(Macro.Env.t(), [{plug, Helios.Pipeline.Plug.opts(), Macro.t()}], Keyword.t()) :: 52 | {Macro.t(), Macro.t()} 53 | def compile(env, pipeline, builder_opts) do 54 | data = quote do: data 55 | {data, Enum.reduce(pipeline, data, "e_plug(init_plug(&1), &2, env, builder_opts))} 56 | end 57 | 58 | defp init_plug({plug, opts, guards}) do 59 | case Atom.to_charlist(plug) do 60 | 'Elixir.' ++ _ -> init_module_plug(plug, opts, guards) 61 | _ -> init_fun_plug(plug, opts, guards) 62 | end 63 | end 64 | 65 | defp init_module_plug(plug, opts, guards) do 66 | initialized_opts = plug.init(opts) 67 | 68 | if function_exported?(plug, :call, 2) do 69 | {:module, plug, initialized_opts, guards} 70 | else 71 | raise ArgumentError, message: "#{inspect(plug)} plug must implement call/2" 72 | end 73 | end 74 | 75 | defp init_fun_plug(plug, opts, guards) do 76 | {:function, plug, opts, guards} 77 | end 78 | 79 | defp quote_plug({plug_type, plug, opts, guards}, acc, env, builder_opts) do 80 | call = quote_plug_call(plug_type, plug, opts) 81 | 82 | error_message = 83 | case plug_type do 84 | :module -> "expected #{inspect(plug)}.call/2 to return a Helios.Context" 85 | :function -> "expected #{plug}/2 to return a Helios.Context" 86 | end <> ", all plugs must receive context and return context" 87 | 88 | quote do 89 | case unquote(compile_guards(call, guards)) do 90 | %Helios.Context{halted: true} = data -> 91 | unquote(log_halt(plug_type, plug, env, builder_opts)) 92 | data 93 | 94 | %Helios.Context{} = data -> 95 | unquote(acc) 96 | 97 | _ -> 98 | raise unquote(error_message) 99 | end 100 | end 101 | end 102 | 103 | defp quote_plug_call(:function, plug, opts) do 104 | quote do: unquote(plug)(data, unquote(Macro.escape(opts))) 105 | end 106 | 107 | defp quote_plug_call(:module, plug, opts) do 108 | quote do: unquote(plug).call(data, unquote(Macro.escape(opts))) 109 | end 110 | 111 | defp compile_guards(call, true) do 112 | call 113 | end 114 | 115 | defp compile_guards(call, guards) do 116 | quote do 117 | case true do 118 | true when unquote(guards) -> unquote(call) 119 | true -> data 120 | end 121 | end 122 | end 123 | 124 | defp log_halt(plug_type, plug, env, builder_opts) do 125 | if level = builder_opts[:log_on_halt] do 126 | message = 127 | case plug_type do 128 | :module -> "#{inspect(env.module)} halted in #{inspect(plug)}.call/2" 129 | :function -> "#{inspect(env.module)} halted in #{inspect(plug)}/2" 130 | end 131 | 132 | quote do 133 | require Logger 134 | Logger.unquote(level)(unquote(message)) 135 | end 136 | else 137 | nil 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/helios/pipeline/errors.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Pipeline.MessageHandlerClauseError do 2 | @moduledoc """ 3 | Indicates that command handler is not implemented. 4 | 5 | This is runtime exception. 6 | """ 7 | defexception message: nil, plug_status: :missing_command_hendler 8 | 9 | def exception(opts) do 10 | plug = Keyword.fetch!(opts, :plug) 11 | handler = Keyword.fetch!(opts, :handler) 12 | params = Keyword.fetch!(opts, :params) 13 | 14 | msg = """ 15 | could not find a matching #{inspect(plug)}.#{handler} clause 16 | to execute command. This typically happens when there is a 17 | parameter mismatch but may also happen when any of the other 18 | handler arguments do not match. The command parameters are: 19 | 20 | #{inspect(params)} 21 | """ 22 | 23 | %__MODULE__{message: msg} 24 | end 25 | end 26 | 27 | defmodule Helios.Pipeline.WrapperError do 28 | @moduledoc """ 29 | Wraps catched excpetions in aggregate pipeline and rearises it so path of execution 30 | can easily be spotted in error log. 31 | 32 | This is runtime exception. 33 | """ 34 | defexception [:context, :kind, :reason, :stack] 35 | 36 | def message(%{kind: kind, reason: reason, stack: stack}) do 37 | Exception.format_banner(kind, reason, stack) 38 | end 39 | 40 | @doc """ 41 | Reraises an error or a wrapped one. 42 | """ 43 | def reraise(%__MODULE__{stack: stack} = reason) do 44 | :erlang.raise(:error, reason, stack) 45 | end 46 | 47 | def reraise(_ctx, :error, %__MODULE__{stack: stack} = reason, _stack) do 48 | :erlang.raise(:error, reason, stack) 49 | end 50 | 51 | def reraise(ctx, :error, reason, stack) do 52 | wrapper = %__MODULE__{context: ctx, kind: :error, reason: reason, stack: stack} 53 | :erlang.raise(:error, wrapper, stack) 54 | end 55 | 56 | def reraise(_ctx, kind, reason, stack) do 57 | :erlang.raise(kind, reason, stack) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/helios/pipeline/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Pipeline.Plug do 2 | @moduledoc """ 3 | Behaviour, when implmented, such module can be used in message context pipeline 4 | """ 5 | alias Helios.Context 6 | 7 | @typedoc """ 8 | Module that implements #{__MODULE__} behaviour 9 | """ 10 | @type t :: module() | atom() 11 | 12 | @type opts :: Keyword.t() | atom() 13 | 14 | @callback init(opts) :: opts 15 | 16 | @callback call(ctx :: Context.t(), opts :: __MODULE__.opts()) :: Context.t() 17 | end 18 | -------------------------------------------------------------------------------- /lib/helios/pipeline/test.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Pipeline.Test do 2 | @moduledoc false 3 | defmacro __using__(_) do 4 | quote do 5 | import Helios.Pipeline.Test 6 | import Helios.Context 7 | end 8 | end 9 | 10 | alias Helios.Context 11 | 12 | @spec ctx(atom(), binary(), map()) :: Context.t() 13 | def ctx(method, path, params \\ %{}) do 14 | Helios.Pipeline.Adapter.Test.ctx(%Context{}, method, path, params) 15 | end 16 | 17 | @doc """ 18 | Returns the sent response. 19 | 20 | This function is useful when the code being invoked crashes and 21 | there is a need to verify a particular response was sent even with 22 | the crash. It returns a tuple with `{status, headers, payload}`. 23 | """ 24 | def sent_resp(%Context{adapter: {Helios.Pipeline.Adapter.Test, %{ref: ref}}}) do 25 | case receive_resp(ref) do 26 | :no_resp -> 27 | raise "no sent response available for the given connection. " <> 28 | "Maybe the application did not send anything?" 29 | 30 | response -> 31 | case receive_resp(ref) do 32 | :no_resp -> 33 | send(self(), {ref, response}) 34 | response 35 | 36 | _otherwise -> 37 | raise "a response for the given connection has been sent more than once" 38 | end 39 | end 40 | end 41 | 42 | defp receive_resp(ref) do 43 | receive do 44 | {^ref, response} -> response 45 | after 46 | 0 -> :no_resp 47 | end 48 | end 49 | 50 | 51 | end 52 | -------------------------------------------------------------------------------- /lib/helios/plugs/helios_logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Plugs.Logger do 2 | @moduledoc """ 3 | Logs execution request to logger. 4 | """ 5 | require Logger 6 | @behaviour Helios.Pipeline.Plug 7 | 8 | def init(opts), 9 | do: [ 10 | log_level: Keyword.get(opts, :log_level, :debug) 11 | ] 12 | 13 | def call(ctx, opts) do 14 | log_level = Keyword.get(opts, :log_level) 15 | 16 | Elixir.Logger.log(log_level, fn -> 17 | "Executing #{inspect(ctx.private.helios_plug_handler)} at " <> 18 | "#{inspect(ctx.private.helios_plug)} with params: #{params(ctx.params)}" 19 | end) 20 | 21 | ctx 22 | end 23 | 24 | @doc false 25 | def filter_values(values, {:discard, params}), do: discard_values(values, List.wrap(params)) 26 | def filter_values(values, {:keep, params}), do: keep_values(values, List.wrap(params)) 27 | def filter_values(values, params), do: discard_values(values, List.wrap(params)) 28 | 29 | defp discard_values(%{__struct__: mod} = struct, _params) when is_atom(mod) do 30 | struct 31 | end 32 | 33 | defp discard_values(%{} = map, params) do 34 | Enum.into(map, %{}, fn {k, v} -> 35 | if (is_atom(k) or is_binary(k)) and k in params do 36 | {k, "[FILTERED]"} 37 | else 38 | {k, discard_values(v, params)} 39 | end 40 | end) 41 | end 42 | 43 | defp discard_values([_ | _] = list, params) do 44 | Enum.map(list, &discard_values(&1, params)) 45 | end 46 | 47 | defp discard_values(other, _params), do: other 48 | 49 | defp keep_values(%{__struct__: mod}, _params) when is_atom(mod), do: "[FILTERED]" 50 | 51 | defp keep_values(%{} = map, params) do 52 | Enum.into(map, %{}, fn {k, v} -> 53 | if (is_atom(k) or is_binary(k)) and k in params do 54 | {k, discard_values(v, [])} 55 | else 56 | {k, keep_values(v, params)} 57 | end 58 | end) 59 | end 60 | 61 | defp keep_values([_ | _] = list, params) do 62 | Enum.map(list, &keep_values(&1, params)) 63 | end 64 | 65 | defp keep_values(_other, _params), do: "[FILTERED]" 66 | 67 | defp params(params) do 68 | filter_parameters = Application.get_env(:helios, :filter_parameters) 69 | 70 | params 71 | |> filter_values(filter_parameters) 72 | |> inspect() 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/helios/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Registry do 2 | @moduledoc false 3 | use GenServer 4 | alias Helios.Registry.Tracker 5 | import Helios.Registry.Tracker.Entry 6 | alias Helios.Registry.Tracker.Entry 7 | 8 | def child_spec(otp_app, endpoint) do 9 | %{ 10 | id: __MODULE__, 11 | start: {__MODULE__, :start_link, [otp_app, endpoint]}, 12 | type: :worker 13 | } 14 | end 15 | 16 | def registry_name(endpoint) do 17 | Module.concat(endpoint, Registry) 18 | end 19 | 20 | ## Public API 21 | 22 | defdelegate register(endpoint, name, pid), to: Tracker, as: :track 23 | defdelegate register(endpoint, name, module, fun, args, timeout), to: Tracker, as: :track 24 | 25 | @spec unregister(module, term) :: :ok 26 | def unregister(endpoint, name) do 27 | case get_by_name(endpoint, name) do 28 | :undefined -> :ok 29 | entry(pid: pid) when is_pid(pid) -> Tracker.untrack(endpoint, pid) 30 | end 31 | end 32 | 33 | @spec whereis(module, term) :: :undefined | pid 34 | def whereis(endpoint, name) do 35 | case get_by_name(endpoint, name) do 36 | :undefined -> 37 | Tracker.whereis(endpoint, name) 38 | 39 | entry(pid: pid) when is_pid(pid) -> 40 | pid 41 | end 42 | end 43 | 44 | @spec whereis_or_register(module, term, atom(), atom(), [term]) :: {:ok, pid} | {:error, term} 45 | def whereis_or_register(endpoint, name, m, f, a, timeout \\ :infinity) 46 | 47 | @spec whereis_or_register(module, term, atom(), atom(), [term], non_neg_integer() | :infinity) :: 48 | {:ok, pid} | {:error, term} 49 | def whereis_or_register(endpoint, name, module, fun, args, timeout) do 50 | with :undefined <- whereis(endpoint, name), 51 | {:ok, pid} <- register(endpoint, name, module, fun, args, timeout) do 52 | {:ok, pid} 53 | else 54 | pid when is_pid(pid) -> 55 | {:ok, pid} 56 | 57 | {:error, {:already_registered, pid}} -> 58 | {:ok, pid} 59 | 60 | {:error, _} = err -> 61 | err 62 | end 63 | end 64 | 65 | @spec join(module(), term(), pid()) :: :ok 66 | def join(endpoint, group, pid), do: Tracker.add_meta(endpoint, group, true, pid) 67 | 68 | @spec leave(module(), term(), pid()) :: :ok 69 | defdelegate leave(endpoint, group, pid), to: Tracker, as: :remove_meta 70 | 71 | @spec members(endpoint :: module, group :: term) :: [pid] 72 | def members(endpoint, group) do 73 | endpoint 74 | |> registry_name() 75 | |> :ets.select([ 76 | {entry(name: :"$1", pid: :"$2", ref: :"$3", meta: %{group => :"$4"}, clock: :"$5"), [], 77 | [:"$_"]} 78 | ]) 79 | |> Enum.map(fn entry(pid: pid) -> pid end) 80 | |> Enum.uniq() 81 | end 82 | 83 | @spec registered(endpoint :: module) :: [{name :: term, pid}] 84 | defdelegate registered(endpoint), to: __MODULE__, as: :all 85 | 86 | @spec publish(module, term, term) :: :ok 87 | def publish(endpoint, group, msg) do 88 | for pid <- members(endpoint, group), do: Kernel.send(pid, msg) 89 | :ok 90 | end 91 | 92 | @spec multi_call(module, term, term, pos_integer) :: [term] 93 | def multi_call(endpoint, group, msg, timeout \\ 5_000) do 94 | endpoint 95 | |> members(group) 96 | |> Enum.map(fn member -> 97 | Task.Supervisor.async_nolink(Helios.TaskSupervisor, fn -> 98 | GenServer.call(member, msg, timeout) 99 | end) 100 | end) 101 | |> Enum.map(&Task.await(&1, :infinity)) 102 | end 103 | 104 | @spec send(module, name :: term, msg :: term) :: :ok 105 | def send(endpoint, name, msg) do 106 | case whereis(endpoint, name) do 107 | :undefined -> 108 | :ok 109 | 110 | pid when is_pid(pid) -> 111 | Kernel.send(pid, msg) 112 | end 113 | end 114 | 115 | ### Low-level ETS manipulation functions 116 | 117 | @spec all(module) :: [{name :: term(), pid()}] 118 | def all(endpoint) do 119 | endpoint 120 | |> registry_name() 121 | |> :ets.tab2list() 122 | |> Enum.map(fn entry(name: name, pid: pid) -> {name, pid} end) 123 | end 124 | 125 | @spec snapshot(module) :: [Entry.entry()] 126 | def snapshot(endpoint) do 127 | :ets.tab2list(registry_name(endpoint)) 128 | end 129 | 130 | @doc """ 131 | Inserts a new registration, and returns true if successful, or false if not 132 | """ 133 | @spec new(module, Entry.entry()) :: boolean 134 | def new(endpoint, entry() = reg) do 135 | :ets.insert_new(registry_name(endpoint), reg) 136 | end 137 | 138 | @doc """ 139 | Like `new/1`, but raises if the insertion fails. 140 | """ 141 | @spec new!(module, Entry.entry()) :: true | no_return 142 | def new!(endpoint, entry() = reg) do 143 | true = :ets.insert_new(registry_name(endpoint), reg) 144 | end 145 | 146 | @spec remove(module, Entry.entry()) :: true 147 | def remove(endpoint, entry() = reg) do 148 | :ets.delete_object(registry_name(endpoint), reg) 149 | end 150 | 151 | @spec remove_by_pid(module, pid) :: true 152 | def remove_by_pid(endpoint, pid) when is_pid(pid) do 153 | case get_by_pid(endpoint, pid) do 154 | :undefined -> 155 | true 156 | 157 | entries when is_list(entries) -> 158 | Enum.each(entries, &:ets.delete_object(registry_name(endpoint), &1)) 159 | true 160 | end 161 | end 162 | 163 | @spec get_by_name(module, term()) :: :undefined | Entry.entry() 164 | def get_by_name(endpoint, name) do 165 | case :ets.lookup(registry_name(endpoint), name) do 166 | [] -> :undefined 167 | [obj] -> obj 168 | end 169 | end 170 | 171 | @spec get_by_pid(module, pid) :: :undefined | [Entry.entry()] 172 | def get_by_pid(endpoint, pid) do 173 | case :ets.match_object( 174 | registry_name(endpoint), 175 | entry(name: :"$1", pid: pid, ref: :"$2", meta: :"$3", clock: :"$4") 176 | ) do 177 | [] -> :undefined 178 | list when is_list(list) -> list 179 | end 180 | end 181 | 182 | @spec get_by_pid_and_name(module(), pid(), term()) :: :undefined | Entry.entry() 183 | def get_by_pid_and_name(endpoint, pid, name) do 184 | case :ets.match_object( 185 | registry_name(endpoint), 186 | entry(name: name, pid: pid, ref: :"$1", meta: :"$2", clock: :"$3") 187 | ) do 188 | [] -> :undefined 189 | [obj] -> obj 190 | end 191 | end 192 | 193 | @spec get_by_ref(module(), reference()) :: :undefined | Entry.entry() 194 | def get_by_ref(endpoint, ref) do 195 | case :ets.match_object( 196 | registry_name(endpoint), 197 | entry(name: :"$1", pid: :"$2", ref: ref, meta: :"$3", clock: :"$4") 198 | ) do 199 | [] -> :undefined 200 | [obj] -> obj 201 | end 202 | end 203 | 204 | @spec get_by_meta(module(), term()) :: :undefined | [Entry.entry()] 205 | def get_by_meta(endpoint, key) do 206 | case :ets.match_object( 207 | registry_name(endpoint), 208 | entry(name: :"$1", pid: :"$2", ref: :"$3", meta: %{key => :"$4"}, clock: :"$5") 209 | ) do 210 | [] -> :undefined 211 | list when is_list(list) -> list 212 | end 213 | end 214 | 215 | @spec get_by_meta(module(), term(), term()) :: :undefined | [Entry.entry()] 216 | def get_by_meta(endpoint, key, value) do 217 | case :ets.match_object( 218 | registry_name(endpoint), 219 | entry(name: :"$1", pid: :"$2", ref: :"$3", meta: %{key => value}, clock: :"$4") 220 | ) do 221 | [] -> :undefined 222 | list when is_list(list) -> list 223 | end 224 | end 225 | 226 | @spec reduce(module(), term(), (Entry.entry(), term() -> term())) :: term() 227 | def reduce(endpoint, acc, fun) when is_function(fun, 2) do 228 | :ets.foldl(fun, acc, registry_name(endpoint)) 229 | end 230 | 231 | @spec update(module(), term(), Keyword.t()) :: term() 232 | defmacro update(endpoint, key, updates) do 233 | fields = Enum.map(updates, fn {k, v} -> {Entry.index(k) + 1, v} end) 234 | 235 | quote bind_quoted: [endpoint: endpoint, key: key, fields: fields] do 236 | :ets.update_element(Helios.Registry.registry_name(endpoint), key, fields) 237 | end 238 | end 239 | 240 | ## GenServer Implementation 241 | 242 | def start_link(otp_app, endpoint) do 243 | opts = [name: registry_name(endpoint)] 244 | GenServer.start_link(__MODULE__, [otp_app, endpoint], opts) 245 | end 246 | 247 | def init([otp_app, endpoint]) do 248 | table_name = registry_name(endpoint) 249 | 250 | table = 251 | :ets.new(table_name, [ 252 | :set, 253 | :named_table, 254 | :public, 255 | keypos: 2, 256 | read_concurrency: true, 257 | write_concurrency: true 258 | ]) 259 | 260 | {:ok, %{table_name: table_name, endpoint: endpoint, otp_app: otp_app, registry: table}} 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /lib/helios/registry/distribution/ring.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Registry.Distribution.Ring do 2 | @moduledoc """ 3 | Default Helios.Registry distribution strategy. 4 | """ 5 | @behaviour Helios.Registry.Distribution.Strategy 6 | 7 | def init(), do: HashRing.new() 8 | def add_node(ring, node), do: HashRing.add_node(ring, node) 9 | def add_node(ring, node, weight), do: HashRing.add_node(ring, node, weight) 10 | def add_nodes(ring, nodes), do: HashRing.add_nodes(ring, nodes) 11 | def remove_node(ring, node), do: HashRing.remove_node(ring, node) 12 | def key_to_node(ring, key), do: HashRing.key_to_node(ring, key) 13 | end 14 | -------------------------------------------------------------------------------- /lib/helios/registry/distribution/static_quorum_ring.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Registry.Distribution.StaticQuorumRing do 2 | @moduledoc """ 3 | A quorum is the minimum number of nodes that a distributed cluster has to 4 | obtain in order to be allowed to perform an operation. This can be used to 5 | enforce consistent operation in a distributed system. 6 | 7 | ## Quorum size 8 | 9 | You must configure this distribution strategy and specify its minimum quorum 10 | size: 11 | 12 | config :my_app, MyApp.Endpoint, 13 | registry:[ 14 | distribution_strategy: { 15 | Helios.Registry.Distribution.StaticQuorumRing, 16 | :init, 17 | [5] # default static quorum size is 2 18 | } 19 | ] 20 | 21 | 22 | It defines the minimum number of nodes that must be connected in the cluster 23 | to allow process registration and distribution. 24 | 25 | If there are fewer nodes currently available than the quorum size, any calls 26 | to `Helios.Registry.register_name/6` will return `{:error, :no_node_available}` 27 | until enough nodes have started. 28 | 29 | You can configure the `:kernel` application to wait for cluster formation 30 | before starting your application during node start up. The 31 | `sync_nodes_optional` configuration specifies which nodes to attempt to 32 | connect to within the `sync_nodes_timeout` window, defined in milliseconds, 33 | before continuing with startup. There is also a `sync_nodes_mandatory` setting 34 | which can be used to enforce all nodes are connected within the timeout window 35 | or else the node terminates. 36 | 37 | config :kernel, 38 | sync_nodes_optional: [:"node1@192.168.1.1", :"node2@192.168.1.2"], 39 | sync_nodes_timeout: 60_000 40 | 41 | The `sync_nodes_timeout` can be configured as `:infinity` to wait indefinitely 42 | for all nodes to connect. All involved nodes must have the same value for 43 | `sync_nodes_timeout`. 44 | 45 | ### Example 46 | 47 | In a 9 node cluster you would configure the `:static_quorum_size` as 5. During 48 | a network split of 4 and 5 nodes, processes on the side with 5 nodes 49 | will continue running, whereas processes on the other 4 nodes will be stopped. 50 | 51 | Be aware that in the running 5 node cluster, no more failures can be handled 52 | because the remaining cluster size would be less than the required 5 node 53 | minimum. All running processes would be stopped in the case of another single 54 | node failure. 55 | """ 56 | 57 | @behaviour Helios.Registry.Distribution.Strategy 58 | 59 | alias Helios.Registry.Distribution.StaticQuorumRing 60 | 61 | defstruct [:static_quorum_size, :ring] 62 | 63 | def init(static_quorum_size \\ 2) do 64 | %StaticQuorumRing{ 65 | static_quorum_size: static_quorum_size, 66 | ring: HashRing.new() 67 | } 68 | end 69 | 70 | def add_node(quorum, node) do 71 | %StaticQuorumRing{quorum | ring: HashRing.add_node(quorum.ring, node)} 72 | end 73 | 74 | def add_node(quorum, node, weight) do 75 | %StaticQuorumRing{quorum | ring: HashRing.add_node(quorum.ring, node, weight)} 76 | end 77 | 78 | def add_nodes(quorum, nodes) do 79 | %StaticQuorumRing{quorum | ring: HashRing.add_nodes(quorum.ring, nodes)} 80 | end 81 | 82 | def remove_node(quorum, node) do 83 | %StaticQuorumRing{quorum | ring: HashRing.remove_node(quorum.ring, node)} 84 | end 85 | 86 | @doc """ 87 | Maps a key to a specific node via the current distribution strategy. 88 | 89 | If the available nodes in the cluster are fewer than the minimum node count it returns `:undefined`. 90 | """ 91 | def key_to_node(%StaticQuorumRing{static_quorum_size: static_quorum_size, ring: ring}, key) do 92 | case length(ring.nodes) do 93 | node_count when node_count < static_quorum_size -> :undefined 94 | _ -> HashRing.key_to_node(ring, key) 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/helios/registry/distribution/strategy.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Registry.Distribution.Strategy do 2 | @moduledoc """ 3 | This module implements the interface for custom distribution strategies. 4 | The default strategy used by Helios.Registry is a consistent hash ring implemented 5 | via the `libring` library. 6 | 7 | Custom strategies are expected to return a datastructure or pid which will be 8 | passed along to any functions which need to manipulate the current distribution state. 9 | This can be either a plain datastructure (as is the case with the libring-based strategy), 10 | or a pid which your strategy module then uses to call a process in your own supervision tree. 11 | 12 | For efficiency reasons, it is highly recommended to use plain datastructures rather than a 13 | process for storing the distribution state, because it has the potential to become a bottleneck otherwise, 14 | however this is really up to the needs of your situation, just know that you can go either way. 15 | 16 | Strategy can be set in configuration, like so: 17 | 18 | ## Config example 19 | 20 | config :my_app, MyApp.Endpoint, 21 | registry:[ 22 | distribution_strategy: {Helios.Registry.Distribution.Ring, :init, [ 23 | nodes: [~r/my_node@/] 24 | ]} 25 | ] 26 | 27 | where `distibution_strategy` is requres {m, f, a} triplet that will be called using `Kernel.apply/3` 28 | during registry startup 29 | """ 30 | 31 | @type reason :: String.t() 32 | @type strategy :: term 33 | @type weight :: pos_integer 34 | @type nodelist :: [node() | {node(), weight}] 35 | @type key :: term 36 | 37 | @type t :: strategy 38 | 39 | @doc """ 40 | Adds a node to the state of the current distribution strategy. 41 | """ 42 | @callback add_node(strategy, node) :: strategy | {:error, reason} 43 | 44 | @doc """ 45 | Adds a node to the state of the current distribution strategy, 46 | and give it a specific weighting relative to other nodes. 47 | """ 48 | @callback add_node(strategy, node, weight) :: strategy | {:error, reason} 49 | 50 | @doc """ 51 | Adds a list of nodes to the state of the current distribution strategy. 52 | The node list can be composed of both node names (atoms) or tuples containing 53 | a node name and a weight for that node. 54 | """ 55 | @callback add_nodes(strategy, nodelist) :: strategy | {:error, reason} 56 | 57 | @doc """ 58 | Removes a node from the state of the current distribution strategy. 59 | """ 60 | @callback remove_node(strategy, node) :: strategy | {:error, reason} 61 | 62 | @doc """ 63 | Maps a key to a specific node via the current distribution strategy. 64 | """ 65 | @callback key_to_node(strategy, key) :: node() | :undefined 66 | end 67 | -------------------------------------------------------------------------------- /lib/helios/registry/tracker/entry.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Registry.Tracker.Entry do 2 | @moduledoc false 3 | alias Helios.Registry.Tracker.IntervalTreeClock, as: ITC 4 | 5 | @fields [name: nil, pid: nil, ref: nil, meta: %{}, clock: nil] 6 | 7 | require Record 8 | Record.defrecord(:entry, @fields) 9 | 10 | @type entry :: 11 | record( 12 | :entry, 13 | name: term, 14 | pid: pid, 15 | ref: reference, 16 | meta: nil | map, 17 | clock: nil | ITC.t() 18 | ) 19 | 20 | def index(field) when is_atom(field) do 21 | Record.__access__(:entry, @fields, field, Helios.Registry.Entry) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/helios/registry/tracker/interval_tree_clock.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Registry.Tracker.IntervalTreeClock do 2 | @moduledoc """ 3 | This is an implementation of an Interval Clock Tree, ported from 4 | the implementation in Erlang written by Paulo Sergio Almeida 5 | found [here](https://github.com/ricardobcl/Interval-Tree-Clocks/blob/master/erlang/itc.erl). 6 | """ 7 | use Bitwise 8 | import Kernel, except: [max: 2, min: 2] 9 | @compile {:inline, [min: 2, max: 2, drop: 2, lift: 2, base: 1, height: 1]} 10 | 11 | @type int_tuple :: {non_neg_integer, non_neg_integer} 12 | @type t :: 13 | int_tuple 14 | | {int_tuple, non_neg_integer} 15 | | {non_neg_integer, int_tuple} 16 | | {int_tuple, int_tuple} 17 | 18 | @doc """ 19 | Creates a new interval tree clock 20 | """ 21 | @spec seed() :: __MODULE__.t() 22 | def seed(), do: {1, 0} 23 | 24 | @doc """ 25 | Joins two forked clocks into a single clock with both causal histories, 26 | used for retiring a replica. 27 | """ 28 | @spec join(__MODULE__.t(), __MODULE__.t()) :: __MODULE__.t() 29 | def join({i1, e1}, {i2, e2}), do: {sum(i1, i2), join_ev(e1, e2)} 30 | 31 | @doc """ 32 | Forks a clock containing a shared causal history, used for creating new replicas. 33 | """ 34 | @spec fork(__MODULE__.t()) :: __MODULE__.t() 35 | def fork({i, e}) do 36 | {i1, i2} = split(i) 37 | {{i1, e}, {i2, e}} 38 | end 39 | 40 | @doc """ 41 | Gets a snapshot of a clock without its identity. Useful for sending the clock with messages, 42 | but cannot be used to track events. 43 | """ 44 | @spec peek(__MODULE__.t()) :: __MODULE__.t() 45 | def peek({_i, e}), do: {0, e} 46 | 47 | @doc """ 48 | Records an event on the given clock 49 | """ 50 | @spec event(__MODULE__.t()) :: __MODULE__.t() 51 | def event({i, e}) do 52 | case fill(i, e) do 53 | ^e -> 54 | {_, e1} = grow(i, e) 55 | {i, e1} 56 | 57 | e1 -> 58 | {i, e1} 59 | end 60 | end 61 | 62 | @doc """ 63 | Determines if the left-hand clock is causally dominated by the right-hand clock. 64 | If the left-hand clock is LEQ than the right-hand clock, and vice-versa, then they are 65 | causally equivalent. 66 | """ 67 | @spec leq(__MODULE__.t(), __MODULE__.t()) :: boolean 68 | def leq({_, e1}, {_, e2}), do: leq_ev(e1, e2) 69 | 70 | @doc """ 71 | Compares two clocks. 72 | If :eq is returned, the two clocks are causally equivalent 73 | If :lt is returned, the first clock is causally dominated by the second 74 | If :gt is returned, the second clock is causally dominated by the first 75 | If :concurrent is returned, the two clocks are concurrent (conflicting) 76 | """ 77 | @spec compare(__MODULE__.t(), __MODULE__.t()) :: :lt | :gt | :eq | :concurrent 78 | def compare(a, b) do 79 | a_leq = leq(a, b) 80 | b_leq = leq(b, a) 81 | 82 | cond do 83 | a_leq and b_leq -> :eq 84 | a_leq -> :lt 85 | b_leq -> :gt 86 | :else -> :concurrent 87 | end 88 | end 89 | 90 | @doc """ 91 | Encodes the clock as a binary 92 | """ 93 | @spec encode(__MODULE__.t()) :: binary 94 | def encode({i, e}), do: :erlang.term_to_binary({i, e}) 95 | 96 | @doc """ 97 | Decodes the clock from a binary 98 | """ 99 | @spec decode(binary) :: {:ok, __MODULE__.t()} | {:error, {:invalid_clock, term}} 100 | def decode(b) when is_binary(b) do 101 | case :erlang.binary_to_term(b) do 102 | {_i, _e} = clock -> 103 | clock 104 | 105 | other -> 106 | {:error, {:invalid_clock, other}} 107 | end 108 | end 109 | 110 | @doc """ 111 | Returns the length of the encoded binary representation of the clock 112 | """ 113 | @spec len(__MODULE__.t()) :: non_neg_integer 114 | def len(d), do: :erlang.size(encode(d)) 115 | 116 | ## Private API 117 | 118 | defp leq_ev({n1, l1, r1}, {n2, l2, r2}) do 119 | n1 <= n2 and leq_ev(lift(n1, l1), lift(n2, l2)) and leq_ev(lift(n1, r1), lift(n2, r2)) 120 | end 121 | 122 | defp leq_ev({n1, l1, r1}, n2) do 123 | n1 <= n2 and leq_ev(lift(n1, l1), n2) and leq_ev(lift(n1, r1), n2) 124 | end 125 | 126 | defp leq_ev(n1, {n2, _, _}), do: n1 <= n2 127 | defp leq_ev(n1, n2), do: n1 <= n2 128 | 129 | defp norm_id({0, 0}), do: 0 130 | defp norm_id({1, 1}), do: 1 131 | defp norm_id(x), do: x 132 | 133 | defp norm_ev({n, m, m}) when is_integer(m), do: n + m 134 | 135 | defp norm_ev({n, l, r}) do 136 | m = min(base(l), base(r)) 137 | {n + m, drop(m, l), drop(m, r)} 138 | end 139 | 140 | defp sum(0, x), do: x 141 | defp sum(x, 0), do: x 142 | defp sum({l1, r1}, {l2, r2}), do: norm_id({sum(l1, l2), sum(r1, r2)}) 143 | 144 | defp split(0), do: {0, 0} 145 | defp split(1), do: {{1, 0}, {0, 1}} 146 | 147 | defp split({0, i}) do 148 | {i1, i2} = split(i) 149 | {{0, i1}, {0, i2}} 150 | end 151 | 152 | defp split({i, 0}) do 153 | {i1, i2} = split(i) 154 | {{i1, 0}, {i2, 0}} 155 | end 156 | 157 | defp split({i1, i2}), do: {{i1, 0}, {0, i2}} 158 | 159 | defp join_ev({n1, _, _} = e1, {n2, _, _} = e2) when n1 > n2, do: join_ev(e2, e1) 160 | 161 | defp join_ev({n1, l1, r1}, {n2, l2, r2}) when n1 <= n2 do 162 | d = n2 - n1 163 | norm_ev({n1, join_ev(l1, lift(d, l2)), join_ev(r1, lift(d, r2))}) 164 | end 165 | 166 | defp join_ev(n1, {n2, l2, r2}), do: join_ev({n1, 0, 0}, {n2, l2, r2}) 167 | defp join_ev({n1, l1, r1}, n2), do: join_ev({n1, l1, r1}, {n2, 0, 0}) 168 | defp join_ev(n1, n2), do: max(n1, n2) 169 | 170 | defp fill(0, e), do: e 171 | defp fill(1, {_, _, _} = e), do: height(e) 172 | defp fill(_, n) when is_integer(n), do: n 173 | 174 | defp fill({1, r}, {n, el, er}) do 175 | er1 = fill(r, er) 176 | d = max(height(el), base(er1)) 177 | norm_ev({n, d, er1}) 178 | end 179 | 180 | defp fill({l, 1}, {n, el, er}) do 181 | el1 = fill(l, el) 182 | d = max(height(er), base(el1)) 183 | norm_ev({n, el1, d}) 184 | end 185 | 186 | defp fill({l, r}, {n, el, er}) do 187 | norm_ev({n, fill(l, el), fill(r, er)}) 188 | end 189 | 190 | defp grow(1, n) when is_integer(n), do: {0, n + 1} 191 | 192 | defp grow({0, i}, {n, l, r}) do 193 | {h, e1} = grow(i, r) 194 | {h + 1, {n, l, e1}} 195 | end 196 | 197 | defp grow({i, 0}, {n, l, r}) do 198 | {h, e1} = grow(i, l) 199 | {h + 1, {n, e1, r}} 200 | end 201 | 202 | defp grow({il, ir}, {n, l, r}) do 203 | {hl, el} = grow(il, l) 204 | {hr, er} = grow(ir, r) 205 | 206 | if hl < hr, do: {hl + 1, {n, el, r}}, else: {hr + 1, {n, l, er}} 207 | end 208 | 209 | defp grow(i, n) when is_integer(n) do 210 | {h, e} = grow(i, {n, 0, 0}) 211 | {h + 100_000, e} 212 | end 213 | 214 | defp height({n, l, r}), do: n + max(height(l), height(r)) 215 | defp height(n), do: n 216 | 217 | defp base({n, _, _}), do: n 218 | defp base(n), do: n 219 | 220 | defp lift(m, {n, l, r}), do: {n + m, l, r} 221 | defp lift(m, n), do: n + m 222 | 223 | defp drop(m, {n, l, r}) when m <= n, do: {n - m, l, r} 224 | defp drop(m, n) when m <= n, do: n - m 225 | 226 | defp max(x, y) when x <= y, do: y 227 | defp max(x, _), do: x 228 | 229 | defp min(x, y) when x <= y, do: x 230 | defp min(_, y), do: y 231 | 232 | def str({i, e}), 233 | do: List.to_string(List.flatten([List.flatten(stri(i)), List.flatten(stre(e))])) 234 | 235 | defp stri(0), do: '0' 236 | defp stri(1), do: '' 237 | defp stri({0, i}), do: 'R' ++ stri(i) 238 | defp stri({i, 0}), do: 'L' ++ stri(i) 239 | defp stri({l, r}), do: ['(L' ++ stri(l), '+', 'R' ++ stri(r), ')'] 240 | 241 | defp stre({n, l, 0}), do: [stre(n), 'L', stre(l)] 242 | defp stre({n, 0, r}), do: [stre(n), 'R', stre(r)] 243 | defp stre({n, l, r}), do: [stre(n), '(L', stre(l), '+R', stre(r), ')'] 244 | defp stre(n) when n > 0, do: :erlang.integer_to_list(n) 245 | defp stre(_), do: '' 246 | end 247 | -------------------------------------------------------------------------------- /lib/helios/router/aggregate.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Router.Aggregate do 2 | @moduledoc false 3 | alias Helios.Router.Aggregate 4 | @default_param_key "id" 5 | 6 | @doc """ 7 | The `Phoenix.Router.Resource` struct. It stores: 8 | 9 | * `:path` - the path as string (not normalized) 10 | * `:commands` - the commands to which only this aggregate should repspond to 11 | * `:param` - the param to be used in routes (not normalized) 12 | * `:route` - the context for aggregate routes 13 | * `:aggregate` - the aggregate as an atom 14 | * `:singleton` - if only one single sinstance of aggregate should be ever created 15 | 16 | """ 17 | defstruct [:path, :commands, :param, :route, :aggregate, :singleton, :member, :collection] 18 | 19 | @type t :: %Aggregate{ 20 | path: String.t(), 21 | commands: list(), 22 | param: String.t(), 23 | route: keyword, 24 | aggregate: atom(), 25 | singleton: boolean, 26 | member: keyword, 27 | collection: keyword 28 | } 29 | 30 | @doc """ 31 | Builds a aggregate struct. 32 | """ 33 | def build(path, aggregate, options) when is_atom(aggregate) and is_list(options) do 34 | path = Helios.Router.Scope.validate_path(path) 35 | alias = Keyword.get(options, :alias) 36 | param = Keyword.get(options, :param, @default_param_key) 37 | name = Keyword.get(options, :name, Helios.Naming.process_name(aggregate, "Aggregate")) 38 | as = Keyword.get(options, :as, name) 39 | private = Keyword.get(options, :private, %{helios_plug_key: param}) 40 | assigns = Keyword.get(options, :assigns, %{}) 41 | 42 | # TODO: this is not used currently but should work when set to true and 43 | # distributed `IdentityServer` is imeplemented 44 | singleton = Keyword.get(options, :singleton, false) 45 | commands = extract_commands(options, singleton) 46 | 47 | route = [as: as, private: private, assigns: assigns] 48 | collection = [path: path, as: as, private: private, assigns: assigns] 49 | member_path = if singleton, do: path, else: Path.join(path, ":#{param}") 50 | member = [path: member_path, as: as, alias: alias, private: private, assigns: assigns] 51 | 52 | %Aggregate{ 53 | path: path, 54 | commands: commands, 55 | param: param, 56 | route: route, 57 | aggregate: aggregate, 58 | singleton: singleton, 59 | member: member, 60 | collection: collection 61 | } 62 | end 63 | 64 | defp extract_commands(opts, _singleton?) do 65 | only = Keyword.get(opts, :only) 66 | except = Keyword.get(opts, :except) 67 | 68 | cond do 69 | only -> only 70 | except -> except 71 | true -> [:_] 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/helios/router/console_formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Router.ConsoleFormatter do 2 | @moduledoc false 3 | alias Helios.Router.Route 4 | 5 | @doc """ 6 | Format the routes for printing. 7 | """ 8 | def format(router) do 9 | routes = router.__routes__ 10 | column_widths = calculate_column_widths(routes) 11 | Enum.map_join(routes, "", &format_route(&1, column_widths)) 12 | end 13 | 14 | defp calculate_column_widths(routes) do 15 | Enum.reduce routes, {0, 0, 0}, fn(route, acc) -> 16 | %Route{verb: verb, path: path, proxy: helper} = route 17 | verb = verb_name(verb) 18 | {verb_len, path_len, route_name_len} = acc 19 | route_name = route_name(helper) 20 | 21 | {max(verb_len, String.length(verb)), 22 | max(path_len, String.length(path)), 23 | max(route_name_len, String.length(route_name))} 24 | end 25 | end 26 | 27 | defp format_route(route, column_widths) do 28 | %Route{verb: verb, path: path, plug: plug, 29 | opts: opts, proxy: helper} = route 30 | verb = verb_name(verb) 31 | route_name = route_name(helper) 32 | {verb_len, path_len, route_name_len} = column_widths 33 | 34 | String.pad_leading(route_name, route_name_len) <> " " <> 35 | String.pad_trailing(verb, verb_len) <> " " <> 36 | String.pad_trailing(path, path_len) <> " " <> 37 | "#{inspect(plug)} #{inspect(opts)}\n" 38 | end 39 | 40 | defp route_name(nil), do: "" 41 | defp route_name(name), do: name <> "_path" 42 | 43 | defp verb_name(verb), do: verb |> to_string() |> String.upcase() 44 | end 45 | -------------------------------------------------------------------------------- /lib/helios/router/route.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Router.Route do 2 | @moduledoc false 3 | alias Helios.Router.Route 4 | 5 | @type verb :: :execute | :process | :trace 6 | @type line :: non_neg_integer() 7 | @type kind :: :match | :forward 8 | @type path :: String.t() 9 | @type plug :: module() | atom() 10 | @type opts :: list() 11 | @type proxy :: {atom(), atom()} | atom() | nil 12 | @type private :: map() 13 | @type assigns :: map() 14 | @type pipe_through :: list(atom()) 15 | @type t :: %Route{ 16 | verb: verb, 17 | line: line, 18 | kind: kind, 19 | path: path, 20 | plug: plug, 21 | opts: opts, 22 | proxy: proxy, 23 | private: private, 24 | assigns: assigns, 25 | pipe_through: pipe_through 26 | } 27 | 28 | @doc """ 29 | The `Helios.Router.Route` struct stores: 30 | 31 | * `:type` - message type as an atom 32 | * `:line` - the line the route was defined in source code 33 | * `:kind` - the kind of route, one of `:match`, `:forward` 34 | * `:path` - the normalized path as string 35 | * `:plug` - the plug module 36 | * `:opts` - the plug options 37 | * `:proxy` - the name of the proxy helper as a {atom, atom}, atom or may be nil 38 | * `:private` - the private route info 39 | * `:assigns` - the route info 40 | * `:pipe_through` - the pipeline names as a list of atoms 41 | 42 | """ 43 | defstruct [:verb, :line, :kind, :path, :plug, :opts, :proxy, :private, :assigns, :pipe_through] 44 | 45 | @doc "Used as a plug on forwarding" 46 | def init(opts), do: opts 47 | 48 | @doc "Used as a plug on forwarding" 49 | def call(%{path_info: path} = ctx, {fwd_segments, plug, opts}) do 50 | new_path = path -- fwd_segments 51 | {_, ^new_path} = Enum.split(path, length(path) - length(new_path)) 52 | ctx = %{ctx | path_info: new_path} 53 | ctx = plug.call(ctx, plug.init(opts)) 54 | %{ctx | path_info: path} 55 | end 56 | 57 | @doc """ 58 | Receives the verb, path, plug, options and proxy helper 59 | and returns a `Helios.Router.Route` struct. 60 | """ 61 | @spec build(line, kind, verb, path | nil, plug, opts, proxy, pipe_through | nil, map, map) :: t 62 | def build(line, kind, verb, path, plug, opts, proxy, pipe_through, private, assigns) 63 | when is_atom(verb) and is_atom(plug) and (is_nil(proxy) or is_binary(proxy)) and 64 | is_list(pipe_through) and is_map(private) and is_map(assigns) and 65 | kind in [:match, :forward] do 66 | 67 | %Route{ 68 | verb: verb, 69 | line: line, 70 | kind: kind, 71 | path: path, 72 | plug: plug, 73 | opts: opts, 74 | proxy: proxy, 75 | private: private, 76 | assigns: assigns, 77 | pipe_through: pipe_through 78 | } 79 | end 80 | 81 | @doc """ 82 | Builds the compiled expressions used by the route. 83 | """ 84 | def exprs(route) do 85 | {path, binding} = build_path_and_binding(route) 86 | 87 | %{ 88 | path: path, 89 | verb_match: verb_match(route.verb), 90 | binding: binding, 91 | prepare: build_prepare(route, binding), 92 | dispatch: build_dispatch(route) 93 | } 94 | end 95 | 96 | defp verb_match(:*), do: Macro.var(:_verb, nil) 97 | defp verb_match(verb), do: verb 98 | 99 | defp build_path_and_binding(%Route{path: path} = route) do 100 | {params, segments} = 101 | case route.kind do 102 | :forward -> build_path_match(path <> "/*_forward_path_info") 103 | :match -> build_path_match(path) 104 | end 105 | 106 | binding = 107 | for var <- params, var != :_forward_path_info do 108 | {Atom.to_string(var), Macro.var(var, nil)} 109 | end 110 | 111 | {segments, binding} 112 | end 113 | 114 | defp build_prepare(route, binding) do 115 | {static_data, match_params, merge_params} = build_params(binding) 116 | {match_private, merge_private} = build_prepare_expr(:private, route.private) 117 | {match_assigns, merge_assigns} = build_prepare_expr(:assigns, route.assigns) 118 | 119 | match_all = match_params ++ match_private ++ match_assigns 120 | merge_all = merge_params ++ merge_private ++ merge_assigns 121 | 122 | if merge_all != [] do 123 | quote do 124 | unquote_splicing(static_data) 125 | %{unquote_splicing(match_all)} = var!(ctx) 126 | %{var!(ctx) | unquote_splicing(merge_all)} 127 | end 128 | else 129 | quote do 130 | var!(ctx) 131 | end 132 | end 133 | end 134 | 135 | defp build_dispatch(%Route{kind: :forward} = route) do 136 | {_params, fwd_segments} = build_path_match(route.path) 137 | 138 | quote do 139 | {Helios.Router.Route, {unquote(fwd_segments), unquote(route.plug), unquote(route.opts)}} 140 | end 141 | end 142 | 143 | defp build_dispatch(%Route{} = route) do 144 | quote do 145 | {unquote(route.plug), unquote(route.opts)} 146 | end 147 | end 148 | 149 | defp build_prepare_expr(_key, data) when data == %{}, do: {[], []} 150 | 151 | defp build_prepare_expr(key, data) do 152 | var = Macro.var(key, :ctx) 153 | merge = quote(do: Map.merge(unquote(var), unquote(Macro.escape(data)))) 154 | {[{key, var}], [{key, merge}]} 155 | end 156 | 157 | defp build_params([]), do: {[], [], []} 158 | 159 | defp build_params(binding) do 160 | params = Macro.var(:params, :ctx) 161 | path_params = Macro.var(:path_params, :ctx) 162 | merge_params = quote(do: Map.merge(unquote(params), unquote(path_params))) 163 | { 164 | [quote(do: unquote(path_params) = %{unquote_splicing(binding)})], 165 | [{:params, params}], 166 | [{:params, merge_params}, {:path_params, path_params}] 167 | } 168 | end 169 | 170 | @doc """ 171 | Validates and returns the list of forward path segments. 172 | 173 | Raises `RuntimeError` if the `plug` is already forwarded or the 174 | `path` contains a dynamic segment. 175 | """ 176 | def forward_path_segments(path, plug, helios_forwards) do 177 | case build_path_match(path) do 178 | {[], path_segments} -> 179 | if helios_forwards[plug] do 180 | raise ArgumentError, 181 | "#{inspect(plug)} has already been forwarded to. A module can only be forwarded a single time." 182 | end 183 | 184 | path_segments 185 | 186 | _ -> 187 | raise ArgumentError, 188 | "dynamic segment \"#{path}\" not allowed when forwarding. Use a static path instead." 189 | end 190 | end 191 | 192 | defdelegate build_path_match(path), to: Helios.Router.Utils 193 | end 194 | -------------------------------------------------------------------------------- /lib/helios/router/scope.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Router.Scope do 2 | alias Helios.Router.Scope 3 | alias Helios.Router.Route 4 | @moduledoc false 5 | 6 | @stack :helios_router_scopes 7 | @pipes :helios_pipeline_scopes 8 | 9 | @type t :: %Scope{ 10 | path: String.t(), 11 | alias: atom(), 12 | as: String.t(), 13 | pipes: list(), 14 | private: map(), 15 | assigns: map() 16 | } 17 | 18 | defstruct path: nil, alias: nil, as: nil, pipes: [], private: %{}, assigns: %{} 19 | 20 | @doc """ 21 | Initializes the scope. 22 | """ 23 | def init(module) do 24 | Module.put_attribute(module, @stack, [%Scope{}]) 25 | Module.put_attribute(module, @pipes, MapSet.new()) 26 | end 27 | 28 | @doc """ 29 | Builds a route based on the top of the stack. 30 | 31 | If `:as` in options is not given in `plug_opts`, it will try to extract process name 32 | from `plug` atom using `Helios.Naming.process_name/2` function, where it will assume 33 | in case of `aggregate` sufix "Aggregate". 34 | """ 35 | @spec route( 36 | non_neg_integer(), 37 | module(), 38 | Route.kind(), 39 | Route.verb(), 40 | Route.path(), 41 | Route.plug(), 42 | Route.opts(), 43 | Keyword.t() 44 | ) :: Route.t() | no_return 45 | def route(line, module, kind, verb, path, plug, plug_opts, opts) do 46 | path = validate_path(path) 47 | private = Keyword.get(opts, :private, %{}) 48 | assigns = Keyword.get(opts, :assigns, %{}) 49 | as = Keyword.get(opts, :as, Helios.Naming.process_name(plug, "Aggregate")) 50 | 51 | # {String.t(), atom(), String.t(), list(atom()), map() | nil, map() | nil} 52 | {path, path_alias, as, pipes, private, assigns} = join(module, path, plug, as, private, assigns) 53 | 54 | Route.build(line, kind, verb, path, path_alias, plug_opts, as, pipes, private, assigns) 55 | end 56 | 57 | @doc """ 58 | Validates a path is a string and contains a leading prefix. 59 | """ 60 | @spec validate_path(path :: String.t()) :: String.t() 61 | def validate_path("/" <> _ = path), do: path 62 | 63 | def validate_path(path) when is_binary(path) do 64 | IO.warn(""" 65 | router paths should begin with a forward slash, got: #{inspect(path)} 66 | #{Exception.format_stacktrace()} 67 | """) 68 | 69 | "/" <> path 70 | end 71 | 72 | def validate_path(path) do 73 | raise ArgumentError, "router paths must be strings, got: #{inspect(path)}" 74 | end 75 | 76 | @doc """ 77 | Defines the given pipeline. 78 | """ 79 | @spec pipeline(module :: module(), pipe :: module() | atom() | list(atom())) :: :ok 80 | def pipeline(module, pipe) when is_atom(pipe) do 81 | update_pipes(module, &MapSet.put(&1, pipe)) 82 | end 83 | 84 | @doc """ 85 | Appends the given pipes to the current scope pipe through. 86 | """ 87 | def pipe_through(module, new_pipes) do 88 | new_pipes = List.wrap(new_pipes) 89 | 90 | stack_pipes = 91 | module 92 | |> get_stack() 93 | |> Enum.flat_map(fn scope -> scope.pipes end) 94 | 95 | update_stack(module, fn [scope | stack] -> 96 | pipes = collect_pipes(new_pipes, stack_pipes, scope.pipes) 97 | [put_in(scope.pipes, pipes) | stack] 98 | end) 99 | end 100 | 101 | defp collect_pipes([] = _new_pipes, _stack_pipes, acc), do: acc 102 | 103 | defp collect_pipes([pipe | new_pipes], stack_pipes, acc) do 104 | if pipe in new_pipes or pipe in stack_pipes do 105 | raise ArgumentError, """ 106 | duplicate pipe_through for #{inspect(pipe)}. 107 | A plug may only be used once inside a scoped pipe_through 108 | """ 109 | end 110 | 111 | collect_pipes(new_pipes, stack_pipes, acc ++ [pipe]) 112 | end 113 | 114 | @doc """ 115 | Pushes a scope into the module stack. 116 | """ 117 | @spec push(module, String.t() | keyword()) :: :ok 118 | def push(module, path) when is_binary(path) do 119 | push(module, path: path) 120 | end 121 | 122 | def push(module, opts) when is_list(opts) do 123 | path = 124 | with path when not is_nil(path) <- Keyword.get(opts, :path), 125 | path <- validate_path(path), 126 | do: String.split(path, "/", trim: true) 127 | 128 | alias = Keyword.get(opts, :alias) 129 | alias = alias && Atom.to_string(alias) 130 | 131 | scope = %Scope{ 132 | path: path, 133 | alias: alias, 134 | as: Keyword.get(opts, :as), 135 | pipes: [], 136 | private: Keyword.get(opts, :private, %{}), 137 | assigns: Keyword.get(opts, :assigns, %{}) 138 | } 139 | 140 | update_stack(module, fn stack -> [scope | stack] end) 141 | end 142 | 143 | @doc """ 144 | Pops a scope from the module stack. 145 | """ 146 | @spec pop(module) :: :ok 147 | def pop(module) do 148 | update_stack(module, fn [_ | stack] -> stack end) 149 | end 150 | 151 | @doc """ 152 | Returns true if the module's definition is currently within a scope block. 153 | """ 154 | @spec inside_scope?(module) :: boolean() | no_return() 155 | def inside_scope?(module) do 156 | module 157 | |> get_stack() 158 | |> List.wrap() 159 | |> length() 160 | |> Kernel.>(1) 161 | end 162 | 163 | @doc """ 164 | Add a forward to the router. 165 | """ 166 | @spec register_forwards(module, String.t(), atom()) :: atom() 167 | def register_forwards(module, path, plug) when is_atom(plug) do 168 | plug = expand_alias(module, plug) 169 | helios_forwards = Module.get_attribute(module, :helios_forwards) 170 | path_segments = Route.forward_path_segments(path, plug, helios_forwards) 171 | helios_forwards = Map.put(helios_forwards, plug, path_segments) 172 | Module.put_attribute(module, :helios_forwards, helios_forwards) 173 | plug 174 | end 175 | 176 | def register_forwards(_, _, plug) do 177 | raise ArgumentError, "forward expects a module as the second argument, #{inspect(plug)} given" 178 | end 179 | 180 | defp expand_alias(module, alias) do 181 | if inside_scope?(module) do 182 | module 183 | |> get_stack() 184 | |> join_alias(alias) 185 | else 186 | alias 187 | end 188 | end 189 | 190 | defp join(module, path, alias, as, private, assigns) do 191 | stack = get_stack(module) 192 | 193 | {join_path(stack, path), join_alias(stack, alias), join_as(stack, as), 194 | join_pipe_through(stack), join_private(stack, private), join_assigns(stack, assigns)} 195 | end 196 | 197 | defp join_path(stack, path) do 198 | "/" <> 199 | ([String.split(path, "/", trim: true) | extract(stack, :path)] 200 | |> Enum.reverse() 201 | |> Enum.concat() 202 | |> Enum.join("/")) 203 | end 204 | 205 | defp join_alias(stack, alias) when is_atom(alias) do 206 | [alias | extract(stack, :alias)] 207 | |> Enum.reverse() 208 | |> Module.concat() 209 | end 210 | 211 | defp join_as(_stack, nil), do: nil 212 | 213 | defp join_as(stack, as) when is_atom(as) or is_binary(as) do 214 | [as | extract(stack, :as)] 215 | |> Enum.reverse() 216 | |> Enum.join("_") 217 | end 218 | 219 | defp join_private(stack, private) do 220 | Enum.reduce(stack, private, &Map.merge(&1.private, &2)) 221 | end 222 | 223 | defp join_assigns(stack, assigns) do 224 | Enum.reduce(stack, assigns, &Map.merge(&1.assigns, &2)) 225 | end 226 | 227 | defp join_pipe_through(stack) do 228 | for scope <- Enum.reverse(stack), 229 | item <- scope.pipes, 230 | do: item 231 | end 232 | 233 | defp extract(stack, attr) do 234 | for scope <- stack, item = Map.fetch!(scope, attr), do: item 235 | end 236 | 237 | defp get_stack(module) do 238 | get_attribute(module, @stack) 239 | end 240 | 241 | defp update_stack(module, fun) do 242 | update_attribute(module, @stack, fun) 243 | end 244 | 245 | defp update_pipes(module, fun) do 246 | update_attribute(module, @pipes, fun) 247 | end 248 | 249 | defp get_attribute(module, attr) do 250 | Module.get_attribute(module, attr) || raise "Helios router scope was not initialized" 251 | end 252 | 253 | defp update_attribute(module, attr, fun) do 254 | Module.put_attribute(module, attr, fun.(get_attribute(module, attr))) 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /lib/helios/router/subscription.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Router.Subscription do 2 | @moduledoc false 3 | alias Helios.Router.Subscription 4 | @default_param_key "id" 5 | @path_prefix "/@" 6 | 7 | def __prefix__(), do: @path_prefix 8 | 9 | @doc """ 10 | The `Phoenix.Router.Resource` struct. It stores: 11 | 12 | * `:path` - the path as string (not normalized) and it is allways equal to `"#{@path_prefix}"` 13 | * `:messages` - the messages to which only this subscriber should repspond to with attribute name that tells how to extract id from messasge 14 | * `:param` - the param to be used in routes (not normalized) 15 | * `:route` - the context for resource routes 16 | * `:subscriber` - the subscriber as an atom 17 | 18 | """ 19 | defstruct [:path, :messages, :param, :route, :subscriber, :singleton, :member, :collection] 20 | 21 | @type t :: %Subscription{ 22 | path: String.t(), 23 | messages: list(), 24 | param: String.t(), 25 | route: keyword, 26 | subscriber: atom(), 27 | singleton: boolean, 28 | member: keyword, 29 | collection: keyword 30 | } 31 | 32 | @doc """ 33 | Builds a Subscription struct. 34 | """ 35 | def build(path, subscriber, options) when is_atom(subscriber) and is_list(options) do 36 | path = Path.join(__prefix__(), path) 37 | alias = Keyword.get(options, :alias) 38 | param = Keyword.get(options, :param, @default_param_key) 39 | name = Keyword.get(options, :name, Helios.Naming.process_name(subscriber, "Subscription")) 40 | as = Keyword.get(options, :as, name) 41 | private = Keyword.get(options, :private, %{}) 42 | assigns = Keyword.get(options, :assigns, %{}) 43 | 44 | # TODO: this is not used currently but should work when set to true and 45 | # distributed `IdentityServer` is imeplemented 46 | singleton = Keyword.get(options, :singleton, false) 47 | messages = extract_messages(options, singleton) 48 | 49 | route = [as: as, private: private, assigns: assigns] 50 | collection = [path: path, as: as, private: private, assigns: assigns] 51 | member_path = if singleton, do: path, else: Path.join(path, ":#{param}") 52 | member = [path: member_path, as: as, alias: alias, private: private, assigns: assigns] 53 | 54 | %Subscription{ 55 | path: path, 56 | messages: messages, 57 | param: param, 58 | route: route, 59 | subscriber: subscriber, 60 | singleton: singleton, 61 | member: member, 62 | collection: collection 63 | } 64 | end 65 | 66 | defp extract_messages(opts, _singleton?) do 67 | only = Keyword.get(opts, :to) 68 | except = Keyword.get(opts, :except) 69 | 70 | cond do 71 | only -> only 72 | except -> except 73 | true -> [:_] 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/helios/router/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Helios.Router.InvalidSpecError do 2 | defexception message: "invalid route specification" 3 | end 4 | 5 | defmodule Helios.Router.Utils do 6 | 7 | @moduledoc false 8 | 9 | @doc """ 10 | Generates a representation that will only match routes 11 | according to the given `spec`. 12 | 13 | If a non-binary spec is given, it is assumed to be 14 | custom match arguments and they are simply returned. 15 | 16 | ## Examples 17 | 18 | iex> Helios.Router.Utils.build_path_match("/foo/:id") 19 | {[:id], ["foo", {:id, [], nil}]} 20 | 21 | """ 22 | def build_path_match(spec, context \\ nil) when is_binary(spec) do 23 | build_path_match(split(spec), context, [], []) 24 | end 25 | 26 | @doc """ 27 | Builds a list of path param names and var match pairs that can bind 28 | to dynamic path segment values. Excludes params with underscores; 29 | otherwise, the compiler will warn about used underscored variables 30 | when they are unquoted in the macro. 31 | 32 | ## Examples 33 | 34 | iex> Helios.Router.Utils.build_path_params_match([:id]) 35 | [{"id", {:id, [], nil}}] 36 | """ 37 | def build_path_params_match(vars) do 38 | vars 39 | |> Enum.map(fn v -> {Atom.to_string(v), Macro.var(v, nil)} end) 40 | |> Enum.reject(fn v -> match?({"_" <> _var, _macro}, v) end) 41 | end 42 | 43 | @doc """ 44 | Splits the given path into several segments. 45 | It ignores both leading and trailing slashes in the path. 46 | 47 | ## Examples 48 | 49 | iex> Helios.Router.Utils.split("/foo/bar") 50 | ["foo", "bar"] 51 | 52 | iex> Helios.Router.Utils.split("/:id/*") 53 | [":id", "*"] 54 | 55 | iex> Helios.Router.Utils.split("/foo//*_bar") 56 | ["foo", "*_bar"] 57 | 58 | """ 59 | def split(bin) when is_binary(bin) do 60 | for segment when segment != "" <- String.split(bin, "/"), do: segment 61 | end 62 | 63 | defp build_path_match([h | t], context, vars, acc) do 64 | handle_segment_match(segment_match(h, "", context), t, context, vars, acc) 65 | end 66 | 67 | defp build_path_match([], _context, vars, acc) do 68 | {vars |> Enum.uniq() |> Enum.reverse(), Enum.reverse(acc)} 69 | end 70 | 71 | # Handle each segment match. They can either be a 72 | # :literal ("foo"), an :identifier (":bar") or a :glob ("*path") 73 | 74 | defp handle_segment_match({:literal, literal}, t, context, vars, acc) do 75 | build_path_match(t, context, vars, [literal | acc]) 76 | end 77 | 78 | defp handle_segment_match({:identifier, identifier, expr}, t, context, vars, acc) do 79 | build_path_match(t, context, [identifier | vars], [expr | acc]) 80 | end 81 | 82 | defp handle_segment_match({:glob, _identifier, _expr}, t, _context, _vars, _acc) when t != [] do 83 | raise Helios.Router.InvalidSpecError, message: "cannot have a *glob followed by other segments" 84 | end 85 | 86 | defp handle_segment_match({:glob, identifier, expr}, _t, context, vars, [hs | ts]) do 87 | acc = [{:|, [], [hs, expr]} | ts] 88 | build_path_match([], context, [identifier | vars], acc) 89 | end 90 | 91 | defp handle_segment_match({:glob, identifier, expr}, _t, context, vars, _) do 92 | {vars, expr} = build_path_match([], context, [identifier | vars], [expr]) 93 | {vars, hd(expr)} 94 | end 95 | 96 | # In a given segment, checks if there is a match. 97 | 98 | defp segment_match(":" <> argument, buffer, context) do 99 | identifier = binary_to_identifier(":", argument) 100 | 101 | expr = 102 | quote_if_buffer(identifier, buffer, context, fn var -> 103 | quote do: unquote(buffer) <> unquote(var) 104 | end) 105 | 106 | {:identifier, identifier, expr} 107 | end 108 | 109 | defp segment_match("*" <> argument, buffer, context) do 110 | underscore = {:_, [], context} 111 | identifier = binary_to_identifier("*", argument) 112 | 113 | expr = 114 | quote_if_buffer(identifier, buffer, context, fn var -> 115 | quote do: [unquote(buffer) <> unquote(underscore) | unquote(underscore)] = unquote(var) 116 | end) 117 | 118 | {:glob, identifier, expr} 119 | end 120 | 121 | defp segment_match(<>, buffer, context) do 122 | segment_match(t, buffer <> <>, context) 123 | end 124 | 125 | defp segment_match(<<>>, buffer, _context) do 126 | {:literal, buffer} 127 | end 128 | 129 | defp quote_if_buffer(identifier, "", context, _fun) do 130 | {identifier, [], context} 131 | end 132 | 133 | defp quote_if_buffer(identifier, _buffer, context, fun) do 134 | fun.({identifier, [], context}) 135 | end 136 | 137 | defp binary_to_identifier(prefix, <> = binary) 138 | when letter in ?a..?z or letter == ?_ do 139 | if binary =~ ~r/^\w+$/ do 140 | String.to_atom(binary) 141 | else 142 | raise Helios.Router.InvalidSpecError, 143 | message: "#{prefix}identifier in routes must be made of letters, numbers and underscores" 144 | end 145 | end 146 | 147 | defp binary_to_identifier(prefix, _) do 148 | raise Helios.Router.InvalidSpecError, 149 | message: "#{prefix} in routes must be followed by lowercase letters or underscore" 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/mix/helios.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Helios do 2 | @moduledoc false 3 | def base() do 4 | app_base(otp_app()) 5 | end 6 | 7 | 8 | defp app_base(app) do 9 | case Application.get_env(app, :namespace, app) do 10 | ^app -> app |> to_string |> Helios.Naming.camelize() 11 | mod -> mod |> inspect() 12 | end 13 | end 14 | 15 | @doc """ 16 | Returns the OTP app from the Mix project configuration. 17 | """ 18 | def otp_app do 19 | Mix.Project.config() |> Keyword.fetch!(:app) 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /lib/mix/tasks/helios.routes.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Helios.Routes do 2 | @moduledoc false 3 | @shortdoc "Prints all defined routes" 4 | use Mix.Task 5 | alias Helios.Router.ConsoleFormatter 6 | 7 | def run(args, base \\ Mix.Helios.base()) do 8 | Mix.Task.run "compile", args 9 | 10 | args 11 | |> Enum.at(0) 12 | |> router(base) 13 | |> ConsoleFormatter.format() 14 | |> Mix.shell.info() 15 | end 16 | 17 | defp router(nil, base) do 18 | if Mix.Project.umbrella?() do 19 | Mix.raise "umbrella applications require an explicit router to be given to phx.routes" 20 | end 21 | web_router = web_mod(base, "Router") 22 | old_router = app_mod(base, "Router") 23 | 24 | loaded(web_router) || loaded(old_router) || Mix.raise """ 25 | no router found at #{inspect web_router} or #{inspect old_router}. 26 | An explicit router module may be given to phx.routes. 27 | """ 28 | end 29 | defp router(router_name, _base) do 30 | arg_router = Module.concat("Elixir", router_name) 31 | loaded(arg_router) || Mix.raise("the provided router, #{inspect arg_router}, does not exist") 32 | end 33 | 34 | defp loaded(module), do: Code.ensure_loaded?(module) && module 35 | 36 | defp app_mod(base, name), do: Module.concat([base, name]) 37 | 38 | defp web_mod(base, name), do: Module.concat(["#{base}Web", name]) 39 | end 40 | -------------------------------------------------------------------------------- /lib/mix/tasks/helios.server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Helios.Server do 2 | use Mix.Task 3 | 4 | @shortdoc "Starts applications and their servers" 5 | 6 | @moduledoc """ 7 | Starts the application by configuring all endpoints servers to run. 8 | 9 | ## Command line options 10 | 11 | This task accepts the same command-line arguments as `run`. 12 | For additional information, refer to the documentation for 13 | `Mix.Tasks.Run`. 14 | 15 | For example, to run `helios.server` without recompiling: 16 | 17 | mix helios.server --no-compile 18 | 19 | The `--no-halt` flag is automatically added. 20 | 21 | Note that the `--no-deps-check` flag cannot be used this way, 22 | because Mix needs to check dependencies to find `helios.server`. 23 | 24 | To run `helios.server` without checking dependencies, you can run: 25 | 26 | mix do deps.loadpaths --no-deps-check, helios.server 27 | """ 28 | 29 | @doc false 30 | def run(args) do 31 | Application.put_env(:phoenix, :serve_endpoints, true, persistent: true) 32 | Mix.Tasks.Run.run run_args() ++ args 33 | end 34 | 35 | defp run_args do 36 | if iex_running?(), do: [], else: ["--no-halt"] 37 | end 38 | 39 | defp iex_running? do 40 | Code.ensure_loaded?(IEx) and apply(IEx, :started?, []) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.2.3" 5 | @journal_adapters ["eventstore"] 6 | 7 | def project do 8 | [ 9 | app: :helios, 10 | version: @version, 11 | elixir: "~> 1.6", 12 | start_permanent: Mix.env() == :prod, 13 | test_paths: test_paths(System.get_env("JOURNAL_ADAPTER")), 14 | build_per_environment: false, 15 | deps: deps(), 16 | 17 | aliases: ["test.all": ["test", "test.adapters"], 18 | "test.adapters": &test_adapters/1], 19 | 20 | # Hex 21 | description: "A building blocks for CQRS segregated applications", 22 | package: package(), 23 | name: "Helios", 24 | docs: docs(), 25 | dialyzer: [ plt_add_apps: [:mix] , 26 | ignore_warnings: ".dialyzer_ignore.exs", 27 | list_unused_filters: true 28 | ], 29 | 30 | test_coverage: [tool: ExCoveralls], 31 | referred_cli_env: [coveralls: :test, 32 | "coveralls.detail": :test, 33 | "coveralls.post": :test, 34 | "coveralls.html": :test] 35 | ] 36 | end 37 | 38 | # Run "mix help compile.app" to learn about applications. 39 | def application do 40 | [ 41 | extra_applications: [:logger, :crypto], 42 | mod: {Helios, []} 43 | ] 44 | end 45 | 46 | # Run "mix help deps" to learn about dependencies. 47 | defp deps do 48 | [ 49 | {:ex_doc, ">= 0.0.0", only: [:test, :dev]}, 50 | {:elixir_uuid, "~> 1.2"}, 51 | {:libring, "~> 1.0"}, 52 | {:gen_state_machine, "~> 2.0"}, 53 | {:extreme, "~> 0.13", optinal: true}, 54 | {:dialyxir, "~> 1.0.0-rc.4", only: [:dev, :test], runtime: false, optinal: true}, 55 | {:credo, "~> 0.10.0", only: [:dev, :test], runtime: false}, 56 | {:poolboy, "~> 1.5"}, 57 | {:excoveralls, "~> 0.10", only: :test} 58 | ] 59 | end 60 | 61 | defp test_paths(journal_adapter) when journal_adapter in @journal_adapters, 62 | do: ["integration_test/#{journal_adapter}"] 63 | 64 | defp test_paths(_), do: ["test/helios"] 65 | 66 | defp test_adapters(args) do 67 | for env <- @journal_adapters, do: test_run(env, args) 68 | end 69 | 70 | defp test_run(adapter, args) do 71 | args = if IO.ANSI.enabled?, do: ["--color"|args], else: ["--no-color"|args] 72 | 73 | IO.puts "==> Running tests for adapter #{adapter} mix test" 74 | {_, res} = System.cmd "mix", ["test" | args], 75 | into: IO.binstream(:stdio, :line), 76 | env: [{"MIX_ENV", "test"}, {"JOURNAL_ADAPTER", adapter}] 77 | 78 | if res > 0 do 79 | System.at_exit(fn _ -> exit({:shutdown, 1}) end) 80 | end 81 | end 82 | 83 | defp package() do 84 | [ 85 | maintainers: ["Milan Jarić"], 86 | licenses: ["Apache 2.0"], 87 | links: %{"GitHub" => "https://github.com/exponentially/helios"}, 88 | files: 89 | ~w(.formatter.exs mix.exs README.md CHANGELOG.md lib) ++ 90 | ~w(integration_test/support) 91 | ] 92 | end 93 | 94 | defp docs() do 95 | [ 96 | main: "your-first-aggregate", 97 | source_ref: "v#{@version}", 98 | canonical: "http://hexdocs.pm/helios", 99 | # logo: "guides/images/e.png", 100 | source_url: "https://github.com/exponentially/helios", 101 | extras: [ 102 | "guides/Your First Aggregate.md", 103 | "guides/Configuration.md", 104 | "guides/Routing.md" 105 | ], 106 | groups_for_modules: [ 107 | "Aggregate": [ 108 | Helios.Aggregate, 109 | Helios.Aggregate.Server, 110 | Helios.Aggregate.Supervisor 111 | ], 112 | "Pipeline": [ 113 | Helios.Context, 114 | Helios.Pipeline, 115 | Helios.Pipeline.Adapter, 116 | Helios.Pipeline.Builder, 117 | Helios.Pipeline.Plug, 118 | Helios.Plugs.Logger 119 | ], 120 | "Event Journal": [ 121 | Helios.EventJournal, 122 | Helios.EventJournal.Adapter, 123 | Helios.EventJournal.Messages.EventData, 124 | Helios.EventJournal.Messages.PersistedEvent, 125 | Helios.EventJournal.Messages.Position, 126 | Helios.EventJournal.Messages.ReadAllEventsResponse, 127 | Helios.EventJournal.Messages.ReadStreamEventsResponse, 128 | Helios.EventJournal.Messages.StreamMetadataResponse, 129 | ], 130 | "Testing": [ 131 | Helios.Pipeline.Test, 132 | ], 133 | "Endpoint": [ 134 | Helios.Endpoint, 135 | Helios.Endpoint.Facade, 136 | Helios.Endpoint.Supervisor, 137 | 138 | ] 139 | ] 140 | ] 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "credo": {:hex, :credo, "0.10.2", "03ad3a1eff79a16664ed42fc2975b5e5d0ce243d69318060c626c34720a49512", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "dialyxir": {:hex, :dialyxir, "1.0.0-rc.4", "71b42f5ee1b7628f3e3a6565f4617dfb02d127a0499ab3e72750455e986df001", [:mix], [{:erlex, "~> 0.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "earmark": {:hex, :earmark, "1.2.6", "b6da42b3831458d3ecc57314dff3051b080b9b2be88c2e5aa41cd642a5b044ed", [:mix], [], "hexpm"}, 7 | "elixir_uuid": {:hex, :elixir_uuid, "1.2.0", "ff26e938f95830b1db152cb6e594d711c10c02c6391236900ddd070a6b01271d", [:mix], [], "hexpm"}, 8 | "erlex": {:hex, :erlex, "0.2.0", "80349ebd58553dbd63489937380bfa7d906be3266b91bbd9d2bd6b71f1e8c07d", [:mix], [], "hexpm"}, 9 | "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "exprotobuf": {:hex, :exprotobuf, "1.2.9", "f3cac1b0d0444da3c72cdfe80e394d721275dc80b1d7703ead9dad9267e93822", [:mix], [{:gpb, "~> 3.24", [hex: :gpb, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "extreme": {:hex, :extreme, "0.13.3", "447183c4ffecfb608bec3583f3798ad20bc241783b097e9d3a38858ec20b9577", [:mix], [{:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:exprotobuf, "~> 1.2", [hex: :exprotobuf, repo: "hexpm", optional: false]}, {:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, 14 | "gpb": {:hex, :gpb, "3.28.1", "6849b2f0004dc4e7644f4f67e7cdd18e893f0ab87eb7ad82b9cb1483ce60eed0", [:make, :rebar], [], "hexpm"}, 15 | "hackney": {:hex, :hackney, "1.14.0", "66e29e78feba52176c3a4213d42b29bdc4baff93a18cfe480f73b04677139dee", [:rebar3], [{:certifi, "2.4.2", [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.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "httpoison": {:hex, :httpoison, "1.3.1", "7ac607311f5f706b44e8b3fab736d0737f2f62a31910ccd9afe7227b43edb7f0", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 18 | "jason": {:hex, :jason, "1.1.1", "d3ccb840dfb06f2f90a6d335b536dd074db748b3e7f5b11ab61d239506585eb2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 19 | "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, 20 | "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 21 | "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 23 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 24 | "nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], [], "hexpm"}, 25 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 26 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"}, 27 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 28 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, 29 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 30 | } 31 | -------------------------------------------------------------------------------- /test/helios/aggregate_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.AggregateServerTest do 2 | use ExUnit.Case, async: true 3 | alias Helios.Aggregate.Server 4 | alias Helios.Context 5 | alias Helios.Integration.UserAggregate 6 | alias Helios.Integration.Events.UserCreated 7 | alias Helios.Integration.Events.UserEmailChanged 8 | alias Helios.Integration.TestJournal 9 | 10 | use Helios.Pipeline.Test 11 | 12 | # defmodule Endpoint do 13 | # use Helios.Endpoint, otp_app: :helios 14 | # end 15 | 16 | setup do 17 | id = UUID.uuid4() 18 | {:ok, pid} = Server.start_link(:helios, {UserAggregate, id}) 19 | 20 | stream_name = UserAggregate.persistance_id(id) 21 | 22 | [ 23 | id: id, 24 | pid: pid, 25 | stream_name: stream_name 26 | ] 27 | end 28 | 29 | @tag :capture_log 30 | @tag :aggregate_server 31 | test "should persist UserCreated in event journal", args do 32 | # Task.start(fn -> :sys.trace(pid, true) end) 33 | 34 | params = %{"id" => 1, "first_name" => "Jhon", "last_name" => "Doe", "email" => "jhon.doe@gmail.com"} 35 | path = "/users/#{args.id}/create_user" 36 | 37 | ctx = 38 | ctx(:execute, path, params) 39 | |> Context.put_private(:helios_plug, UserAggregate) 40 | |> Context.put_private(:helios_plug_key, args.id) 41 | |> Context.put_private(:helios_plug_handler, :create_user) 42 | 43 | 44 | assert %Context{} = GenServer.call(args.pid, {:execute, ctx}) 45 | 46 | assert {:ok, 47 | %{ 48 | events: [ 49 | %{data: %{__struct__: UserCreated}}, 50 | %{data: %{__struct__: UserEmailChanged}} 51 | ] 52 | }} = TestJournal.read_stream_events_forward(args.stream_name, -1, 2) 53 | 54 | GenServer.stop(args.pid) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/helios/aggregate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.AggregateTest do 2 | use ExUnit.Case, async: true 3 | alias Helios.Integration.UserAggregate 4 | alias Helios.Context 5 | 6 | doctest Helios.Aggregate 7 | 8 | setup do 9 | ctx = %Context{ 10 | peer: self(), 11 | private: %{ 12 | helios_plug: UserAggregate 13 | } 14 | } 15 | 16 | [ctx: ctx] 17 | end 18 | 19 | test "should execute logger in aggregate pipeline", args do 20 | request_id = UUID.uuid4() 21 | params = %{ 22 | "id" => 1, 23 | "first_name" => "Jhon", 24 | "last_name" => "Doe", 25 | "email" => "jhon.doe@gmail.com" 26 | } 27 | 28 | {:ok, aggregate} = UserAggregate.new([id: 1234, otp_app: :dummy_app]) 29 | 30 | ctx_before = 31 | args.ctx 32 | |> Map.put(:request_id, request_id) 33 | |> Map.put(:params, params) 34 | |> Context.assign(:aggregate, aggregate) 35 | |> Context.put_private(:helios_plug_key, "id") 36 | |> Context.put_private(:helios_plug_handler, :create_user) 37 | 38 | 39 | 40 | ctx_after = UserAggregate.handle(ctx_before, params) 41 | 42 | assert not ctx_after.halted 43 | 44 | 45 | assert 2 == Enum.count(ctx_after.events) 46 | assert [e1, e2] = ctx_after.events 47 | 48 | assert e1.data.first_name == "Jhon" 49 | assert e1.data.last_name == "Doe" 50 | assert e1.data.user_id == 1 51 | assert e1.type == "Elixir.Helios.Integration.Events.UserCreated" 52 | assert e1.metadata.causation_id == request_id 53 | 54 | assert e2.data.new_email == "jhon.doe@gmail.com" 55 | assert e2.data.old_email == nil 56 | assert e2.data.user_id == 1 57 | assert e2.type == "Elixir.Helios.Integration.Events.UserEmailChanged" 58 | assert e2.metadata.causation_id == request_id 59 | 60 | assert :set == ctx_after.state 61 | assert ctx_after.response == :created 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/helios/endpoint/endpoint_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.Endpoint.EndpointTest do 2 | use ExUnit.Case, async: true 3 | # use RouterHelper 4 | end 5 | -------------------------------------------------------------------------------- /test/helios/integration_test.exs: -------------------------------------------------------------------------------- 1 | # Code.require_file("../../integration_test/support/adapter_cases.exs", __DIR__) 2 | -------------------------------------------------------------------------------- /test/helios/router/route_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Helios.Router.RouteTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Helios.Router.Route 5 | 6 | @tag :router_route 7 | test "builds a route based on verb, path, plug, plug options and proxy helper" do 8 | line = 1 9 | kind = :match 10 | verb = :execute 11 | path = "/users/:id/create_user" 12 | plug = UserAggregate 13 | opts = :create_user 14 | proxy = "users_create_user" 15 | pipe_through = [:pipeline1, :pipeline2] 16 | private = %{foo: "bar"} 17 | assigns = %{bar: "baz"} 18 | 19 | route = 20 | Route.build(line, kind, verb, path, plug, opts, proxy, pipe_through, private, assigns) 21 | 22 | assert route.kind == kind 23 | assert route.verb == verb 24 | assert route.path == path 25 | assert route.line == line 26 | 27 | assert route.plug == plug 28 | assert route.opts == opts 29 | assert route.proxy == proxy 30 | assert route.pipe_through == pipe_through 31 | assert route.private == private 32 | assert route.assigns == assigns 33 | end 34 | 35 | @tag :router_route 36 | test "builds expressions based on the route with :id param" do 37 | 38 | route = Route.build( 39 | 1, 40 | :match, 41 | :execute, 42 | "/users/:id/create_user", 43 | UserAggregate, 44 | :create_user, 45 | "users_create_user", 46 | [:pipeline1, :pipeline2], 47 | %{foo: "bar"}, 48 | %{bar: "baz"} 49 | ) 50 | exprs = Route.exprs(route) 51 | 52 | assert exprs.verb_match == :execute 53 | assert exprs.path == ["users", {:id, [], nil}, "create_user"] 54 | assert exprs.binding == [{"id", {:id, [], nil}}] 55 | assert exprs.dispatch == {UserAggregate, :create_user} 56 | end 57 | 58 | @tag :router_route 59 | test "builds expressions based on the route without :id param" do 60 | 61 | route = Route.build( 62 | 1, 63 | :match, 64 | :execute, 65 | "/users/create_user", 66 | UserAggregate, 67 | :create_user, 68 | "users_create_user", 69 | [:pipeline1, :pipeline2], 70 | %{foo: "bar"}, 71 | %{bar: "baz"} 72 | ) 73 | exprs = Route.exprs(route) 74 | 75 | assert exprs.verb_match == :execute 76 | assert exprs.path == ["users", "create_user"] 77 | assert exprs.binding == [] 78 | assert exprs.dispatch == {UserAggregate, :create_user} 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/helios/router/routing_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("router_helper.exs", "test/support") 2 | 3 | defmodule Helios.RoutingTest.MyRouter do 4 | use Helios.Router 5 | require Logger 6 | 7 | pipeline :test do 8 | plug :log_hello 9 | end 10 | 11 | scope "/", Helios.Integration do 12 | pipe_through(:test) 13 | aggregate("/users", UserAggregate, only: [:create_user]) 14 | end 15 | 16 | def log_hello(ctx, _) do 17 | Logger.info("hello") 18 | ctx 19 | end 20 | end 21 | 22 | 23 | 24 | defmodule Helios.RoutingTest do 25 | use ExUnit.Case, async: true 26 | # alias Helios.RoutingTest.MyRouter 27 | 28 | test "should" do 29 | %{ 30 | "id" => 1, 31 | "first_name" => "Jhon", 32 | "last_name" => "Doe", 33 | "email" => "jhon.doe@gmail.com" 34 | } 35 | 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/helios/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Application.put_env( 4 | :helios, 5 | Helios.Integration.TestJournal, 6 | adapter: Helios.EventJournal.Adapter.Memory, 7 | adapter_config: [] 8 | ) 9 | 10 | Application.put_env(:helios, :default_journal, Helios.Integration.TestJournal) 11 | 12 | Code.require_file("../../integration_test/support/events.exs", __DIR__) 13 | Code.require_file("../../integration_test/support/aggregates.exs", __DIR__) 14 | Code.require_file("../../integration_test/support/assertions.exs", __DIR__) 15 | Code.require_file("../../integration_test/support/journals.exs", __DIR__) 16 | 17 | {:ok, _} = Helios.Integration.TestJournal.start_link() 18 | -------------------------------------------------------------------------------- /test/support/router_helper.exs: -------------------------------------------------------------------------------- 1 | defmodule RouterHelper do 2 | @moduledoc """ 3 | Conveniences for testing routers and aggregates. 4 | """ 5 | 6 | import Helios.Pipeline.Test 7 | 8 | defmacro __using__(_) do 9 | quote do 10 | use Helios.Pipeline.Test 11 | import RouterHelper 12 | end 13 | end 14 | 15 | def call(router, verb, path, params \\ nil, script_name \\ []) do 16 | verb 17 | |> ctx(path, params) 18 | |> Map.put(:script_name, script_name) 19 | |> router.call(router.init([])) 20 | end 21 | 22 | def execute(plug, handler, params \\ nil) do 23 | :execute 24 | |> ctx("/", params) 25 | |> Helios.Context.put_private(:helios_plug, plug) 26 | |> Helios.Context.put_private(:helios_plug_handler, handler) 27 | |> plug.call(plug.init(handler)) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test_all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | MIX_ENV=test mix test.all 4 | --------------------------------------------------------------------------------