├── .formatter.exs ├── .gitignore ├── .tool-versions ├── README.md ├── VERSION ├── apps ├── blunt │ ├── .formatter.exs │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── ideas.org │ ├── lib │ │ ├── blunt.ex │ │ ├── blunt │ │ │ ├── command.ex │ │ │ ├── command_handler.ex │ │ │ ├── config.ex │ │ │ ├── dispatch_context.ex │ │ │ ├── dispatch_context │ │ │ │ ├── configuration.ex │ │ │ │ └── default_configuration.ex │ │ │ ├── dispatch_error.ex │ │ │ ├── dispatch_strategy.ex │ │ │ ├── dispatch_strategy │ │ │ │ ├── default.ex │ │ │ │ ├── pipeline_resolver.ex │ │ │ │ └── pipeline_resolver │ │ │ │ │ └── default.ex │ │ │ ├── message.ex │ │ │ ├── message │ │ │ │ ├── changeset.ex │ │ │ │ ├── compilation.ex │ │ │ │ ├── compiler_hooks.ex │ │ │ │ ├── constructor.ex │ │ │ │ ├── dispatch.ex │ │ │ │ ├── documentation.ex │ │ │ │ ├── documentation │ │ │ │ │ ├── field_and_option_docs.ex │ │ │ │ │ └── metadata_docs.ex │ │ │ │ ├── field_importer.ex │ │ │ │ ├── input.ex │ │ │ │ ├── metadata.ex │ │ │ │ ├── options.ex │ │ │ │ ├── options │ │ │ │ │ └── parser.ex │ │ │ │ ├── primary_key.ex │ │ │ │ ├── schema.ex │ │ │ │ ├── schema │ │ │ │ │ ├── built_in_validations.ex │ │ │ │ │ ├── default_field_definition.ex │ │ │ │ │ ├── field_definition.ex │ │ │ │ │ └── fields.ex │ │ │ │ ├── type │ │ │ │ │ ├── atom.ex │ │ │ │ │ ├── multi.ex │ │ │ │ │ └── pid.ex │ │ │ │ ├── type_spec.ex │ │ │ │ ├── type_spec │ │ │ │ │ └── provider.ex │ │ │ │ └── version.ex │ │ │ ├── query.ex │ │ │ ├── query_handler.ex │ │ │ ├── telemetry.ex │ │ │ └── testing │ │ │ │ ├── factories.ex │ │ │ │ └── factories │ │ │ │ ├── builder │ │ │ │ └── blunt_message_builder.ex │ │ │ │ └── dispatch_strategy.ex │ │ └── mix │ │ │ └── tasks │ │ │ └── blunt │ │ │ └── verify.ex │ ├── mix.exs │ └── test │ │ ├── Testing.md │ │ ├── blunt │ │ ├── command_test.exs │ │ ├── custom_dispatch_strategy_test.exs │ │ ├── dispatch_context_test.exs │ │ ├── message │ │ │ ├── changeset_test.exs │ │ │ ├── compiler_hooks_test.exs │ │ │ ├── field_importer_test.exs │ │ │ ├── metadata_test.exs │ │ │ └── schema │ │ │ │ └── built_in_validations_test.exs │ │ ├── message_test.exs │ │ ├── query_test.exs │ │ └── testing │ │ │ ├── factories │ │ │ └── builder │ │ │ │ └── blunt_message_builder_test.exs │ │ │ └── factories_test.exs │ │ ├── shared │ │ ├── field_types │ │ │ ├── email_field.ex │ │ │ └── uuid_field.ex │ │ └── repo.ex │ │ ├── support │ │ ├── command_test_handlers.ex │ │ ├── command_test_messages.ex │ │ ├── compiler_hooks.ex │ │ ├── custom_dispatch_strategy.ex │ │ ├── custom_dispatch_strategy │ │ │ ├── custom_command_handler.ex │ │ │ └── custom_query_handler.ex │ │ ├── message │ │ │ └── schema │ │ │ │ └── email_field_provider.ex │ │ ├── message_test_messages.ex │ │ ├── query_test_handlers.ex │ │ ├── query_test_messages.ex │ │ ├── query_test_read_model.ex │ │ └── testing │ │ │ ├── add_reservation.ex │ │ │ ├── create_person.ex │ │ │ ├── get_person.ex │ │ │ ├── layz_factory_value_messages.ex │ │ │ ├── plain_message.ex │ │ │ └── read_model.ex │ │ └── test_helper.exs ├── blunt_absinthe │ ├── .formatter.exs │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── config │ │ └── config.exs │ ├── lib │ │ └── blunt │ │ │ ├── absinthe.ex │ │ │ └── absinthe │ │ │ ├── absinthe_errors.ex │ │ │ ├── args.ex │ │ │ ├── config.ex │ │ │ ├── dispatch_context │ │ │ ├── configuration.ex │ │ │ └── default_configuration.ex │ │ │ ├── enum.ex │ │ │ ├── field.ex │ │ │ ├── log.ex │ │ │ ├── message.ex │ │ │ ├── middleware.ex │ │ │ ├── mutation.ex │ │ │ ├── mutation_resolver.ex │ │ │ ├── object.ex │ │ │ ├── query.ex │ │ │ └── type.ex │ ├── mix.exs │ └── test │ │ ├── blunt │ │ └── absinthe │ │ │ ├── enum_test.exs │ │ │ ├── mutation_test.exs │ │ │ ├── object_test.exs │ │ │ └── query_test.exs │ │ ├── support │ │ ├── create_person.ex │ │ ├── create_person_handler.ex │ │ ├── dispatch_context_configuration.ex │ │ ├── dog.ex │ │ ├── get_person.ex │ │ ├── get_person_handler.ex │ │ ├── json_type.ex │ │ ├── read_model.ex │ │ ├── schema.ex │ │ ├── schema_types.ex │ │ └── update_person.ex │ │ └── test_helper.exs ├── blunt_absinthe_relay │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── config │ │ └── config.exs │ ├── lib │ │ └── blunt │ │ │ └── absinthe │ │ │ ├── relay.ex │ │ │ └── relay │ │ │ ├── config.ex │ │ │ ├── connection.ex │ │ │ └── connection_field.ex │ ├── mix.exs │ ├── schema.graphql │ └── test │ │ ├── blunt │ │ └── absinthe │ │ │ └── relay │ │ │ └── connection_test.exs │ │ ├── support │ │ ├── create_people.ex │ │ ├── dispatch_context_configuration.ex │ │ ├── list_people.ex │ │ ├── list_people_handler.ex │ │ ├── person.ex │ │ ├── schema.ex │ │ └── schema_types.ex │ │ └── test_helper.exs ├── blunt_data │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── lib │ │ └── blunt │ │ │ ├── behaviour.ex │ │ │ └── data │ │ │ ├── factories.ex │ │ │ ├── factories │ │ │ ├── builder.ex │ │ │ ├── builder │ │ │ │ ├── ecto_schema_builder.ex │ │ │ │ ├── map_builder.ex │ │ │ │ └── struct_builder.ex │ │ │ ├── factory.ex │ │ │ ├── input_configuration.ex │ │ │ ├── input_configuration │ │ │ │ └── default.ex │ │ │ ├── value.ex │ │ │ ├── values.ex │ │ │ └── values │ │ │ │ ├── build.ex │ │ │ │ ├── constant.ex │ │ │ │ ├── data.ex │ │ │ │ ├── defaults.ex │ │ │ │ ├── input.ex │ │ │ │ ├── inspect_props.ex │ │ │ │ ├── mapper.ex │ │ │ │ ├── merge_input.ex │ │ │ │ ├── prop.ex │ │ │ │ ├── remove_prop.ex │ │ │ │ └── required_prop.ex │ │ │ └── factory_error.ex │ ├── mix.exs │ └── test │ │ ├── blunt │ │ └── data │ │ │ ├── factories │ │ │ ├── factory_test.exs │ │ │ └── values │ │ │ │ ├── prop_test.exs │ │ │ │ └── required_prop_test.exs │ │ │ └── factories_test.exs │ │ └── test_helper.exs ├── blunt_ddd │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── config │ │ └── config.exs │ ├── lib │ │ └── blunt │ │ │ ├── aggregate_root.ex │ │ │ ├── bounded_context.ex │ │ │ ├── bounded_context │ │ │ └── proxy.ex │ │ │ ├── command │ │ │ ├── event_derivation.ex │ │ │ └── events.ex │ │ │ ├── ddd.ex │ │ │ ├── ddd │ │ │ └── constructor.ex │ │ │ ├── domain_event.ex │ │ │ ├── entity.ex │ │ │ ├── entity │ │ │ └── identity.ex │ │ │ ├── state.ex │ │ │ ├── testing │ │ │ └── aggregate_case.ex │ │ │ ├── value_object.ex │ │ │ └── value_object │ │ │ └── equality.ex │ ├── mix.exs │ └── test │ │ ├── blunt │ │ ├── bonded_context_test.exs │ │ ├── command │ │ │ └── event_derivation_test.exs │ │ ├── compiler_hooks_test.exs │ │ ├── domain_event_test.exs │ │ ├── entity_test.exs │ │ ├── state_test.exs │ │ ├── testing │ │ │ └── aggregate_case_test.exs │ │ └── value_object_test.exs │ │ ├── support │ │ ├── command │ │ │ └── event_derivation_test │ │ │ │ ├── command_with_event_derivations.ex │ │ │ │ └── command_with_event_derivations_handler.ex │ │ ├── context_test │ │ │ ├── create_person.ex │ │ │ ├── create_person_handler.ex │ │ │ ├── get_person.ex │ │ │ ├── get_person_handler.ex │ │ │ ├── test_read_model.ex │ │ │ ├── users_context.ex │ │ │ └── zero_field_query.ex │ │ ├── domain_event_test │ │ │ ├── default_event.ex │ │ │ ├── event_derived_from_command.ex │ │ │ ├── event_with_decimal_version.ex │ │ │ └── event_with_set_version.ex │ │ ├── entity_test_messages.ex │ │ ├── state_test │ │ │ ├── person_aggregate_root.ex │ │ │ ├── protocol │ │ │ │ ├── person_created.ex │ │ │ │ └── reservation_added.ex │ │ │ └── reservation_entity.ex │ │ └── testing │ │ │ ├── add_reservation.ex │ │ │ ├── create_person.ex │ │ │ ├── get_person.ex │ │ │ ├── person_aggregate.ex │ │ │ ├── read_model.ex │ │ │ └── reservation_entity.ex │ │ └── test_helper.exs ├── blunt_toolkit │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── config │ │ └── config.exs │ ├── lib │ │ ├── blunt │ │ │ └── toolkit │ │ │ │ ├── aggregate_inspector.ex │ │ │ │ └── aggregate_inspector │ │ │ │ ├── cache.ex │ │ │ │ ├── commands.ex │ │ │ │ └── input_handlers.ex │ │ ├── blunt_toolkit.ex │ │ └── mix │ │ │ └── tasks │ │ │ └── cqrs │ │ │ └── inspect.aggregate.ex │ ├── mix.exs │ └── test │ │ ├── mix │ │ └── tasks │ │ │ └── blunt │ │ │ └── inspect.aggregate_test.exs │ │ ├── support │ │ ├── append_to_stream_strategy.ex │ │ ├── event_store_case.ex │ │ ├── person_aggregate.ex │ │ ├── person_created.ex │ │ ├── person_updated.ex │ │ └── test_event_store.ex │ │ └── test_helper.exs └── opentelemetry_blunt │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── config │ └── config.exs │ ├── lib │ └── opentelemetry_blunt.ex │ ├── mix.exs │ └── test │ ├── opentelemetry_blunt_test.exs │ └── test_helper.exs ├── config └── config.exs ├── mix.exs ├── mix.lock ├── publish.sh ├── remove_context_shipper.patch └── tools └── setup-dev-env /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :blunt, :blunt_data, :blunt_ddd, :blunt_absinthe, :blunt_absinthe_relay], 3 | line_length: 120, 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.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 third-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 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | .DS_Store 26 | Thumbs.db 27 | 28 | .elixir_ls 29 | .git-semver 30 | 31 | __VERSION 32 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.14.2-otp-25 2 | erlang 25.1 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blunt 2 | 3 | **TODO: Add description** 4 | 5 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.1.8 2 | -------------------------------------------------------------------------------- /apps/blunt/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals_without_parens = [ 3 | field: 2, 4 | field: 3, 5 | import_fields: 1, 6 | import_fields: 2, 7 | internal_field: 2, 8 | internal_field: 3, 9 | static_field: 2, 10 | static_field: 3, 11 | option: 2, 12 | option: 3, 13 | command: 1, 14 | command: 2, 15 | query: 1, 16 | query: 2, 17 | binding: 2, 18 | value_object: 1, 19 | value_object: 2, 20 | derive_event: 1, 21 | derive_event: 2, 22 | metadata: 2, 23 | require_at_least_one: 1, 24 | require_either: 1, 25 | require_exactly_one: 1 26 | ] 27 | 28 | [ 29 | import_deps: [:ecto, :blunt_data], 30 | locals_without_parens: locals_without_parens, 31 | line_length: 120, 32 | export: [ 33 | locals_without_parens: locals_without_parens 34 | ], 35 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 36 | ] 37 | -------------------------------------------------------------------------------- /apps/blunt/.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 third-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 | blunt-*.tar 24 | -------------------------------------------------------------------------------- /apps/blunt/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Chris Martin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/blunt/README.md: -------------------------------------------------------------------------------- 1 | # Blunt 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `blunt` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:blunt, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /apps/blunt/ideas.org: -------------------------------------------------------------------------------- 1 | * Ideas [0/2] 2 | ** TODO add result_mapper function option to command and query macros 3 | ** IDEA Support desc on enum values for documentation. 4 | 5 | Probably just an option on Field like =value_desc: [value: "description"]= 6 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt do 2 | defmacro __using__(_opts) do 3 | quote do 4 | import Blunt, only: :macros 5 | end 6 | end 7 | 8 | defmacro defcommand(opts \\ [], do: body) do 9 | quote do 10 | use Blunt.Command, unquote(opts) 11 | unquote(body) 12 | end 13 | end 14 | 15 | defmacro defquery(opts \\ [], do: body) do 16 | quote do 17 | use Blunt.Query, unquote(opts) 18 | unquote(body) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/command.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Command do 2 | alias Blunt.Message.{Metadata, Options} 3 | alias Blunt.DispatchContext, as: Context 4 | 5 | defmacro __using__(opts) do 6 | opts = 7 | [require_all_fields?: true, keep_discarded_data: true] 8 | |> Keyword.merge(opts) 9 | |> Keyword.put(:dispatch?, true) 10 | |> Keyword.put(:message_type, :command) 11 | 12 | quote do 13 | require Blunt.Message.Options 14 | 15 | use Blunt.Message, unquote(opts) 16 | 17 | Options.register() 18 | @options [Options.command_return_option()] 19 | 20 | import Blunt.Command, only: :macros 21 | 22 | @before_compile Blunt.Command 23 | end 24 | end 25 | 26 | @spec option(name :: atom(), type :: any(), keyword()) :: any() 27 | defmacro option(name, type, opts \\ []) when is_atom(name) and is_list(opts), 28 | do: Options.record(name, type, opts) 29 | 30 | defmacro __before_compile__(_env) do 31 | quote do 32 | Options.generate() 33 | end 34 | end 35 | 36 | @spec results(Context.command_context()) :: any | nil 37 | defdelegate results(context), to: Context, as: :get_last_pipeline 38 | 39 | @spec private(Context.command_context()) :: map() 40 | defdelegate private(context), to: Context, as: :get_private 41 | 42 | @spec errors(Context.command_context()) :: map() 43 | defdelegate errors(context), to: Context 44 | 45 | @spec user_supplied_fields(Context.command_context()) :: map() 46 | defdelegate user_supplied_fields(context), to: Context 47 | 48 | @spec take_user_supplied_data(Context.command_context()) :: map() 49 | defdelegate take_user_supplied_data(context), to: Context 50 | 51 | @spec get_metadata(Context.command_context(), atom, any) :: any | nil 52 | defdelegate get_metadata(context, key, default \\ nil), to: Context 53 | 54 | @spec options(Context.command_context()) :: list() 55 | def options(%{message_module: module}), do: Metadata.get(module, :options) 56 | end 57 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/command_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.CommandHandler do 2 | @type user :: map() 3 | @type command :: struct() 4 | @type context :: Blunt.DispatchContext.command_context() 5 | 6 | @callback handle_dispatch(command, context) :: any() 7 | 8 | defmacro __using__(_opts) do 9 | quote do 10 | use Blunt.Message.Compilation 11 | @behaviour Blunt.CommandHandler 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/dispatch_context/configuration.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.DispatchContext.Configuration do 2 | @type message_module :: atom() 3 | @type context :: Blunt.DispatchContext 4 | @callback configure(message_module(), context()) :: context() 5 | 6 | def configure(message_module, context) do 7 | configuration = Blunt.Config.dispatch_context_configuration() 8 | configuration.configure(message_module, context) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/dispatch_context/default_configuration.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.DispatchContext.DefaultConfiguration do 2 | @behaviour Blunt.DispatchContext.Configuration 3 | 4 | def configure(_message_module, context), do: context 5 | end 6 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/dispatch_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.DispatchError do 2 | defexception [:message] 3 | end 4 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/dispatch_strategy/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.DispatchStrategy.Default do 2 | @behaviour Blunt.DispatchStrategy 3 | 4 | import Blunt.DispatchStrategy 5 | 6 | alias Blunt.DispatchContext 7 | alias Blunt.DispatchStrategy.PipelineResolver 8 | alias Blunt.{CommandHandler, Query, QueryHandler} 9 | 10 | @type context :: DispatchContext.t() 11 | 12 | @spec dispatch(context()) :: {:ok, context() | any()} | {:error, context()} 13 | 14 | @moduledoc """ 15 | Receives a `DispatchContext`, locates a message pipeline, and runs the pipeline's ...uh pipeline. 16 | 17 | ## CommandHandler Pipeline 18 | 19 | 1. `handle_dispatch` 20 | 21 | ## QueryHandler Pipeline 22 | 23 | 1. `create_query` 24 | 2. `handle_dispatch` 25 | """ 26 | def dispatch(%{message_type: :command, message: command} = context) do 27 | pipeline = PipelineResolver.get_pipeline!(context, CommandHandler) 28 | 29 | case DispatchContext.get_return(context) do 30 | :command_context -> 31 | {:ok, context} 32 | 33 | :command -> 34 | return_final(command, context) 35 | 36 | _ -> 37 | with {:ok, context} <- execute({pipeline, :handle_dispatch, [command, context]}, context) do 38 | return_last_pipeline(context) 39 | end 40 | end 41 | end 42 | 43 | def dispatch(%{message_type: :query} = context) do 44 | bindings = Query.bindings(context) 45 | filter_list = Query.create_filter_list(context) 46 | pipeline = PipelineResolver.get_pipeline!(context, QueryHandler) 47 | 48 | context = 49 | context 50 | |> DispatchContext.put_private(:bindings, bindings) 51 | |> DispatchContext.put_private(:filters, Enum.into(filter_list, %{})) 52 | 53 | with {:ok, context} <- execute({pipeline, :create_query, [filter_list, context]}, context) do 54 | # put the query into the context 55 | query = DispatchContext.get_last_pipeline(context) 56 | context = DispatchContext.put_private(context, :query, query) 57 | 58 | case DispatchContext.get_return(context) do 59 | :query_context -> 60 | {:ok, context} 61 | 62 | :query -> 63 | return_final(query, context) 64 | 65 | _ -> 66 | opts = DispatchContext.options(context) 67 | 68 | with {:ok, context} <- execute({pipeline, :handle_dispatch, [query, context, opts]}, context) do 69 | return_last_pipeline(context) 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/dispatch_strategy/pipeline_resolver.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.DispatchStrategy.PipelineResolver do 2 | alias Blunt.{Behaviour, Config, DispatchContext} 3 | 4 | defmodule Error do 5 | defexception [:message] 6 | end 7 | 8 | @type message_type :: atom() 9 | @type message_module :: atom() 10 | @type pipeline_module :: atom() 11 | @type behaviour_module :: atom() 12 | @type context :: DispatchContext.command_context() | DispatchContext.query_context() 13 | 14 | @callback resolve(message_type, message_module) :: {:ok, pipeline_module} | :error 15 | 16 | @spec get_pipeline!(context, behaviour_module) :: pipeline_module 17 | @spec get_pipeline(context, behaviour_module) :: {:ok, pipeline_module} | {:error, String.t()} | :error 18 | 19 | @doc false 20 | def get_pipeline(%{message_type: type, message_module: message_module}, behaviour_module) do 21 | with {:ok, pipeline_module} <- Config.pipeline_resolver!().resolve(type, message_module) do 22 | Behaviour.validate(pipeline_module, behaviour_module) 23 | end 24 | end 25 | 26 | @doc false 27 | def get_pipeline!(%{message_type: type, message_module: message_module} = context, behaviour_module) do 28 | case get_pipeline(context, behaviour_module) do 29 | {:ok, pipeline} -> pipeline 30 | {:error, reason} -> raise Error, message: reason 31 | :error -> raise Error, message: "No #{inspect(behaviour_module)} found for #{type}: #{inspect(message_module)}" 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/dispatch_strategy/pipeline_resolver/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.DispatchStrategy.PipelineResolver.Default do 2 | @behaviour Blunt.DispatchStrategy.PipelineResolver 3 | 4 | @moduledoc """ 5 | Resolves `CommandHandler`s and `QueryHandler`s by convention. 6 | 7 | Handler modules are meant to be named "Namespace.MessageHandler". 8 | That is, the message module with "Handler" appended to the end. 9 | """ 10 | 11 | @type message_type :: atom() 12 | @type message_module :: atom() 13 | @type pipeline_module :: atom() 14 | 15 | @spec resolve(message_type(), message_module()) :: {:ok, pipeline_module()} | :error 16 | 17 | def resolve(_message_type, message_module) do 18 | {:ok, String.to_existing_atom(to_string(message_module) <> "Handler")} 19 | rescue 20 | _ -> :error 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/compilation.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Compilation do 2 | @moduledoc false 3 | require Logger 4 | 5 | alias Blunt.Message.Compilation 6 | 7 | defmacro __using__(_opts) do 8 | quote do 9 | @after_compile Compilation 10 | @before_compile Compilation 11 | 12 | Module.register_attribute(__MODULE__, :compile_start, persist: true) 13 | end 14 | end 15 | 16 | defmacro __before_compile__(_env) do 17 | quote do 18 | @compile_start DateTime.utc_now() 19 | end 20 | end 21 | 22 | defmacro __after_compile__(%{module: module}, _code) do 23 | compile_start = Compilation.compile_start(module) 24 | elapsed = DateTime.diff(DateTime.utc_now(), compile_start, :millisecond) 25 | 26 | Compilation.log(module, "compiled", elapsed) 27 | end 28 | 29 | def compile_start(message_module) do 30 | :attributes 31 | |> message_module.__info__() 32 | |> Keyword.get(:compile_start) 33 | |> hd() 34 | end 35 | 36 | def log(module, action, elapsed_milliseconds \\ nil) do 37 | cond do 38 | Blunt.Config.log_when_compiling?() == true -> 39 | if elapsed_milliseconds, 40 | do: Logger.info("[blunt] #{action} #{inspect(module)} (#{elapsed_milliseconds} ms)"), 41 | else: Logger.info("[blunt] #{action} #{inspect(module)}") 42 | 43 | nil 44 | 45 | true -> 46 | nil 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/compiler_hooks.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.CompilerHooks do 2 | require Logger 3 | 4 | defmacro __using__(opts) do 5 | quote bind_quoted: [opts: opts] do 6 | {manual_config, opts} = Keyword.pop(opts, :config, []) 7 | 8 | @manual_config manual_config 9 | @message_type Keyword.get(opts, :message_type) 10 | 11 | @before_compile Blunt.Message.CompilerHooks 12 | end 13 | end 14 | 15 | defmacro __before_compile__(%{module: module} = env) do 16 | message_type = Module.get_attribute(module, :message_type) 17 | manual_config = Module.get_attribute(module, :manual_config) 18 | 19 | run_compile_hooks(message_type, manual_config, env) 20 | end 21 | 22 | defp run_compile_hooks(nil, _manual_config, _env), do: nil 23 | 24 | defp run_compile_hooks(message_type, manual_config, env) do 25 | compiler_hooks = 26 | :blunt 27 | |> Application.get_all_env() 28 | |> Keyword.merge(manual_config) 29 | |> Keyword.get(:compiler_hooks, []) 30 | |> Keyword.get(message_type, []) 31 | 32 | case compiler_hooks do 33 | hooks when is_list(hooks) -> 34 | Enum.map(hooks, &run_compile_hook(&1, env)) 35 | 36 | hook -> 37 | run_compile_hook(hook, env) 38 | end 39 | end 40 | 41 | defp run_compile_hook({module, function}, env) do 42 | Code.ensure_compiled!(module) 43 | 44 | case function_exported?(module, function, 1) do 45 | true -> apply(module, function, [env]) 46 | false -> Logger.warning("Compiler hook #{inspect(module)}.#{function}/1 not found") 47 | end 48 | end 49 | 50 | defp run_compile_hook(_hook, _env), do: nil 51 | end 52 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/dispatch.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Dispatch do 2 | @moduledoc false 3 | 4 | alias Blunt.{DispatchContext, DispatchStrategy, Message.Dispatch, Message.Documentation} 5 | 6 | defmacro register(opts) do 7 | quote bind_quoted: [opts: opts] do 8 | dispatch? = Keyword.get(opts, :dispatch?, false) 9 | Module.put_attribute(__MODULE__, :dispatch?, dispatch?) 10 | @metadata dispatchable?: dispatch? 11 | end 12 | end 13 | 14 | def generate do 15 | quote do 16 | if @dispatch? do 17 | @doc Documentation.generate_dispatch_doc() 18 | def dispatch(message, opts \\ []), 19 | do: Dispatch.dispatch(message, opts) 20 | 21 | @doc "Same as `dispatch` but asynchronously" 22 | def dispatch_async(message, opts \\ []), 23 | do: Dispatch.dispatch_async(message, opts) 24 | end 25 | end 26 | end 27 | 28 | def dispatch_async(message, opts), 29 | do: dispatch(message, Keyword.put(opts, :async, true)) 30 | 31 | def dispatch(message, opts) when is_struct(message) do 32 | dispatch({:ok, message}, opts) 33 | end 34 | 35 | def dispatch({:error, error}, _opts), 36 | do: {:error, error} 37 | 38 | def dispatch({:ok, %{__struct__: message_module} = message}, opts) do 39 | with {:ok, context} <- DispatchContext.new(message, opts) do 40 | if DispatchContext.async?(context), 41 | do: Task.async(fn -> do_dispatch(message_module, context) end), 42 | else: do_dispatch(message_module, context) 43 | end 44 | end 45 | 46 | defp do_dispatch(message_module, context) do 47 | message_module 48 | |> DispatchContext.Configuration.configure(context) 49 | |> DispatchStrategy.dispatch() 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/documentation/metadata_docs.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Documentation.MetadataDocs do 2 | def generate(metadata) do 3 | metadata 4 | |> List.flatten() 5 | |> Keyword.drop([:dispatchable?, :message_type, :primary_key, :schema_fields, :shortdoc]) 6 | |> create_docs_section() 7 | end 8 | 9 | defp create_docs_section([]), do: "" 10 | 11 | defp create_docs_section(metadata) do 12 | metadata = 13 | metadata 14 | |> Enum.map(&metadata_value/1) 15 | |> Enum.join("\n\n") 16 | 17 | ~s[ 18 | ## Metadata 19 | 20 | #{metadata} 21 | 22 | ] 23 | end 24 | 25 | defp metadata_value({name, value}) do 26 | ~s( 27 | ### #{name} 28 | 29 | #{inspect_value(value)} 30 | ) 31 | end 32 | 33 | defp inspect_value(list) when is_list(list) do 34 | if Keyword.keyword?(list) do 35 | Enum.map(list, fn {name, value} -> 36 | """ 37 | * **#{name}** - `#{inspect(value, pretty: true)}` 38 | """ 39 | end) 40 | |> Enum.join("\n") 41 | else 42 | "`#{inspect(list, pretty: true)}`" 43 | end 44 | end 45 | 46 | defp inspect_value(value) do 47 | "`#{inspect(value, pretty: true)}`" 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/field_importer.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.FieldImporter do 2 | @moduledoc false 3 | alias Blunt.Message.Metadata 4 | alias Blunt.Message.FieldImporter 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | @before_compile unquote(__MODULE__) 9 | 10 | Module.register_attribute(__MODULE__, :field_modules, accumulate: true) 11 | 12 | import unquote(__MODULE__), only: :macros 13 | end 14 | end 15 | 16 | def import_fields(module, opts \\ []) when is_list(opts) do 17 | quote do 18 | @field_modules {unquote(module), unquote(opts)} 19 | end 20 | end 21 | 22 | @except [:discarded_data] 23 | 24 | def __import_fields__({source_module, opts}) do 25 | transform = Keyword.get(opts, :transform, &Function.identity/1) 26 | 27 | except = Keyword.get(opts, :except, @except) 28 | except = Enum.uniq(List.wrap(except) ++ @except) 29 | 30 | only = Keyword.get(opts, :only, Metadata.field_names(source_module) -- except) 31 | only = Enum.uniq(List.wrap(only)) 32 | 33 | include_internal_fields = Keyword.get(opts, :include_internal_fields, false) 34 | 35 | source_module 36 | |> Metadata.fields() 37 | |> Enum.filter(fn {name, _type, _opts} -> Enum.member?(only, name) end) 38 | |> Enum.reject(fn {_name, _type, opts} -> 39 | if include_internal_fields, do: false, else: Keyword.get(opts, :internal) 40 | end) 41 | |> Enum.flat_map(fn {name, type, opts} -> 42 | opts = Macro.escape(opts) 43 | 44 | case transform.({name, type, opts}) do 45 | fields when is_list(fields) -> fields 46 | field -> [field] 47 | end 48 | end) 49 | end 50 | 51 | defmacro __before_compile__(%{module: module}) do 52 | imported_fields = 53 | module 54 | |> Module.get_attribute(:field_modules) 55 | |> Enum.flat_map(&FieldImporter.__import_fields__/1) 56 | 57 | Enum.map(imported_fields, fn {name, type, opts} -> 58 | quote do 59 | field unquote(name), unquote(type), unquote(opts) 60 | end 61 | end) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/input.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Input do 2 | @moduledoc false 3 | 4 | require Logger 5 | require Decimal 6 | 7 | def normalize(values, message) when is_list(values) do 8 | cond do 9 | Keyword.keyword?(values) -> 10 | normalize(Enum.into(values, %{}), message) 11 | 12 | true -> 13 | Logger.warning(inspect(message) <> " values are expected to be a keyword list") 14 | normalize(%{}, message) 15 | end 16 | end 17 | 18 | def normalize(values, message) when is_struct(values), 19 | do: normalize(Map.from_struct(values), message) 20 | 21 | def normalize(values, message) when is_map(values) do 22 | values 23 | |> normalize_maps() 24 | |> populate_from_sources(message) 25 | end 26 | 27 | defp normalize_maps(list) when is_list(list), 28 | do: Enum.map(list, &normalize_maps/1) 29 | 30 | defp normalize_maps(map) when is_map(map) do 31 | Enum.into(map, %{}, fn 32 | {key, %Date{} = value} -> 33 | {to_string(key), value} 34 | 35 | {key, %DateTime{} = value} -> 36 | {to_string(key), value} 37 | 38 | {key, %NaiveDateTime{} = value} -> 39 | {to_string(key), value} 40 | 41 | {key, value} when Decimal.is_decimal(value) -> 42 | {to_string(key), Decimal.to_float(value)} 43 | 44 | {key, value} when is_struct(value) -> 45 | {to_string(key), normalize_maps(Map.from_struct(value))} 46 | 47 | {key, value} when is_map(value) -> 48 | {to_string(key), normalize_maps(value)} 49 | 50 | {key, value} -> 51 | {to_string(key), value} 52 | end) 53 | end 54 | 55 | defp normalize_maps(other), do: other 56 | 57 | defp populate_from_sources(values, message) do 58 | Enum.reduce(message.__schema__(:fields), values, fn field, acc -> 59 | field_source = message.__schema__(:field_source, field) 60 | source_value = Map.get(values, field_source) 61 | 62 | acc = 63 | case source_value do 64 | nil -> 65 | acc 66 | 67 | value -> 68 | Map.update(acc, to_string(field), value, fn 69 | nil -> value 70 | other -> other 71 | end) 72 | end 73 | 74 | Map.delete(acc, field_source) 75 | end) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/primary_key.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.PrimaryKey do 2 | @moduledoc false 3 | defmacro register(opts) do 4 | quote bind_quoted: [opts: opts] do 5 | primary_key = 6 | case Keyword.get(opts, :primary_key, false) do 7 | {name, type, config} -> {name, type, config} 8 | value -> value 9 | end 10 | 11 | Module.put_attribute(__MODULE__, :primary_key_type, primary_key) 12 | end 13 | end 14 | 15 | def generate(%{module: module}) do 16 | pk_type = Module.get_attribute(module, :primary_key_type) 17 | 18 | unless pk_type == false do 19 | {name, type, opts} = pk_type 20 | 21 | opts = 22 | opts 23 | |> Keyword.put(:required, true) 24 | |> Keyword.put(:primary_key, true) 25 | 26 | Module.put_attribute(module, :schema_fields, {name, type, opts}) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/schema/built_in_validations.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Schema.BuiltInValidations do 2 | @moduledoc false 3 | 4 | def run({:require_at_least_one, fields}, changeset) do 5 | import Ecto.Changeset, only: [get_change: 2, add_error: 3] 6 | 7 | supplied = fields |> Enum.map(&get_change(changeset, &1)) |> Enum.reject(&is_nil/1) 8 | 9 | error = "expected at least one of following fields to be supplied: #{inspect(fields)}" 10 | 11 | case supplied do 12 | [] -> add_error(changeset, :fields, error) 13 | _ -> changeset 14 | end 15 | end 16 | 17 | def run({:require_exactly_one, fields}, changeset) do 18 | import Ecto.Changeset, only: [get_change: 2, add_error: 3] 19 | 20 | provided_fields = 21 | fields 22 | |> Enum.map(&get_change(changeset, &1)) 23 | |> Enum.reject(&is_nil/1) 24 | 25 | case provided_fields do 26 | [_field] -> 27 | changeset 28 | 29 | _ -> 30 | fields = 31 | fields 32 | |> Enum.sort() 33 | |> Enum.map(&inspect/1) 34 | |> Enum.join(" OR ") 35 | 36 | add_error(changeset, :fields, "expected exactly one of #{fields} to be provided") 37 | end 38 | end 39 | 40 | def run({:require_either, fields}, changeset) do 41 | import Ecto.Changeset, only: [get_change: 2, add_error: 3] 42 | 43 | no_required_fields_supplied = 44 | fields 45 | |> Enum.map(fn 46 | field when is_atom(field) -> [get_change(changeset, field)] 47 | fields when is_list(fields) -> Enum.map(fields, &get_change(changeset, &1)) 48 | end) 49 | |> Enum.map(fn changes -> 50 | Enum.any?(changes, &is_nil/1) 51 | end) 52 | |> Enum.all?(&(&1 == true)) 53 | 54 | expected_fields = 55 | fields 56 | |> Enum.map(fn 57 | field when is_atom(field) -> 58 | inspect(field) 59 | 60 | fields when is_list(fields) -> 61 | message = Enum.map(fields, &inspect/1) |> Enum.join(" AND ") 62 | "(" <> message <> ")" 63 | end) 64 | |> Enum.join(" OR ") 65 | 66 | if no_required_fields_supplied do 67 | error = "expected either #{expected_fields} to be present" 68 | add_error(changeset, :fields, error) 69 | else 70 | changeset 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/schema/default_field_definition.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Schema.DefaultFieldDefinition do 2 | use Blunt.Message.Schema.FieldDefinition 3 | 4 | alias Blunt.Message.Type.{Atom, Pid} 5 | 6 | def define(:atom, opts), do: {Atom, opts} 7 | def define(:pid, opts), do: {Pid, opts} 8 | end 9 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/schema/field_definition.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Schema.FieldDefinition do 2 | alias Blunt.Config 3 | 4 | @type custom_type :: atom() 5 | @type ecto_type :: atom() | module() | {:array, atom()} | {:array, module()} 6 | 7 | @callback define(custom_type, opts :: keyword()) :: {ecto_type, opts :: keyword()} 8 | 9 | defmacro __using__(_opts) do 10 | quote do 11 | @behaviour unquote(__MODULE__) 12 | @before_compile unquote(__MODULE__) 13 | end 14 | end 15 | 16 | defmacro __before_compile__(_env) do 17 | quote do 18 | def define(custom_type, opts), do: {custom_type, opts} 19 | end 20 | end 21 | 22 | def find_field_definition(type, opts) do 23 | definitions = Config.schema_field_definitions() 24 | 25 | custom_field_definition = 26 | Enum.reduce_while(definitions, nil, fn definition, _acc -> 27 | case definition.define(type, opts) do 28 | {^type, _} -> {:cont, nil} 29 | {ecto_type, opts} -> {:halt, {ecto_type, opts}} 30 | end 31 | end) 32 | 33 | with nil <- custom_field_definition do 34 | {type, opts} 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/schema/fields.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Schema.Fields do 2 | @moduledoc false 3 | alias Blunt.Message.Schema 4 | alias Blunt.Message.Schema.FieldDefinition 5 | 6 | def record(name, type, opts \\ []) do 7 | quote bind_quoted: [name: name, type: type, opts: opts, self: __MODULE__] do 8 | internal = Keyword.get(opts, :internal, false) 9 | required = internal == false and Keyword.get(opts, :required, @require_all_fields?) 10 | 11 | validation_name = Keyword.get(opts, :validate) 12 | Schema.put_field_validation(__MODULE__, name, validation_name) 13 | 14 | opts = 15 | [default: nil] 16 | |> Keyword.merge(opts) 17 | |> Keyword.put(:required, required) 18 | |> Keyword.put_new(:internal, false) 19 | |> Keyword.put_new(:virtual, type == :any) 20 | |> self.__put_docs_from_attribute__(__MODULE__) 21 | 22 | if required do 23 | @required_fields name 24 | end 25 | 26 | {type, opts} = 27 | case type do 28 | {:array, type} -> 29 | {type, opts} = FieldDefinition.find_field_definition(type, opts) 30 | {{:array, type}, opts} 31 | 32 | type -> 33 | FieldDefinition.find_field_definition(type, opts) 34 | end 35 | 36 | @schema_fields {name, type, opts} 37 | end 38 | end 39 | 40 | def __put_docs_from_attribute__(opts, module) do 41 | case Module.delete_attribute(module, :doc) do 42 | {_line, doc} -> Keyword.put_new(opts, :desc, doc) 43 | _ -> opts 44 | end 45 | end 46 | 47 | def field_names(fields) do 48 | Enum.map(fields, &elem(&1, 0)) 49 | end 50 | 51 | def internal_field_names(fields) do 52 | fields 53 | |> Enum.filter(fn {_name, _type, config} -> Keyword.fetch!(config, :internal) == true end) 54 | |> field_names() 55 | end 56 | 57 | def virtual_field_names(fields) do 58 | fields 59 | |> Enum.filter(fn {_name, _type, config} -> Keyword.get(config, :virtual) == true end) 60 | |> field_names() 61 | end 62 | 63 | require Logger 64 | 65 | def embedded?(module) when is_atom(module) do 66 | embedded?(Atom.to_string(module)) 67 | end 68 | 69 | def embedded?("Elixir." <> _ = module) do 70 | module = String.to_existing_atom(module) 71 | module = Code.ensure_compiled!(module) 72 | function_exported?(module, :__schema__, 2) 73 | end 74 | 75 | def embedded?(_module), do: false 76 | end 77 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/type/atom.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Type.Atom do 2 | @moduledoc false 3 | 4 | use Ecto.Type 5 | 6 | def type, do: :any 7 | 8 | def cast(nil), do: {:ok, nil} 9 | 10 | def cast(atom) when is_atom(atom), 11 | do: {:ok, atom} 12 | 13 | def cast(string) when is_binary(string) do 14 | {:ok, String.to_existing_atom(string)} 15 | rescue 16 | _ -> :error 17 | end 18 | 19 | def cast(_other), do: :error 20 | 21 | def load(nil), do: {:ok, nil} 22 | 23 | def load(value) when is_binary(value), 24 | do: {:ok, String.to_existing_atom(value)} 25 | 26 | def dump(nil), do: {:ok, nil} 27 | 28 | def dump(value) when is_atom(value), 29 | do: {:ok, to_string(value)} 30 | end 31 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/type/multi.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Type.Multi do 2 | use Ecto.Type 3 | 4 | def type, do: :any 5 | 6 | def cast(value) when is_struct(value, Ecto.Multi), 7 | do: {:ok, value} 8 | 9 | def load(value) when is_map(value), 10 | do: {:ok, struct!(Ecto.Multi, value)} 11 | 12 | def dump(value) when is_map(value), 13 | do: {:ok, Map.from_struct(value)} 14 | end 15 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/type/pid.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Type.Pid do 2 | @moduledoc false 3 | 4 | use Ecto.Type 5 | 6 | def type, do: :any 7 | 8 | def cast(pid) when is_pid(pid), 9 | do: {:ok, pid} 10 | 11 | def cast(string) when is_binary(string), 12 | do: {:error, [message: "is not a valid Pid"]} 13 | 14 | def cast(value) when is_list(value) do 15 | {:ok, :erlang.list_to_pid(value)} 16 | rescue 17 | _ -> {:error, [message: "is not a valid Pid"]} 18 | end 19 | 20 | def cast(_other), do: {:ok, nil} 21 | 22 | def load(value) when is_list(value), 23 | do: {:ok, :erlang.list_to_pid(value)} 24 | 25 | def dump(pid) when is_pid(pid), 26 | do: {:ok, :erlang.pid_to_list(pid)} 27 | end 28 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/type_spec/provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.TypeSpec.Provider do 2 | @type field_definition :: {name :: atom(), type :: atom(), opts :: keyword()} 3 | @callback provide(field_definition()) :: {atom(), Macro.t()} 4 | 5 | require Logger 6 | 7 | alias Blunt.Config 8 | 9 | def provide({name, type, _opts} = field_definition) do 10 | case Config.type_spec_provider() do 11 | nil -> 12 | Logger.warning("unable to generate typespec for field #{name} [#{type}]") 13 | 14 | provider -> 15 | case provider.provide(field_definition) do 16 | nil -> 17 | Logger.warning("unable to generate typespec for field #{name} [#{type}]") 18 | 19 | type_spec -> 20 | type_spec 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/message/version.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Version do 2 | @moduledoc false 3 | 4 | defmacro register(opts) do 5 | quote bind_quoted: [opts: opts] do 6 | if Keyword.get(opts, :versioned?, false) do 7 | Module.put_attribute(__MODULE__, :versioned?, true) 8 | Module.register_attribute(__MODULE__, :version, []) 9 | end 10 | end 11 | end 12 | 13 | def generate(%{module: module}) do 14 | if Module.delete_attribute(module, :versioned?) do 15 | version = Module.get_attribute(module, :version) || 1 16 | Module.put_attribute(module, :metadata, version: version) 17 | Module.put_attribute(module, :schema_fields, {:version, :integer, default: version, required: false}) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/query_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.QueryHandler do 2 | @type opts :: keyword() 3 | @type filter_list :: keyword() 4 | @type query :: Ecto.Query.t() | any() 5 | @type context :: Blunt.DispatchContext.query_context() 6 | 7 | @callback create_query(filter_list(), context()) :: query() 8 | @callback handle_dispatch(query(), context(), opts()) :: any() 9 | 10 | defmacro __using__(_opts) do 11 | quote do 12 | import Ecto.Query 13 | use Blunt.Message.Compilation 14 | 15 | @behaviour Blunt.QueryHandler 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Telemetry do 2 | def start(event_prefix, metadata \\ %{}, additional_measurements \\ %{}) do 3 | start_time = System.monotonic_time() 4 | measurements = Map.put(additional_measurements, :system_time, System.system_time()) 5 | :telemetry.execute(event_prefix ++ [:start], measurements, metadata) 6 | start_time 7 | end 8 | 9 | def stop(event_prefix, start_time, metadata \\ %{}, additional_measurements \\ %{}) do 10 | measurements = include_duration(start_time, additional_measurements) 11 | :telemetry.execute(event_prefix ++ [:stop], measurements, metadata) 12 | end 13 | 14 | defp include_duration(start_time, measurements) do 15 | end_time = System.monotonic_time() 16 | Map.put(measurements, :duration, end_time - start_time) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/testing/factories.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(ExMachina) and Code.ensure_loaded?(Faker) do 2 | defmodule Blunt.Testing.Factories do 3 | defmacro __using__(opts) do 4 | repo = Keyword.get(opts, :repo) 5 | 6 | quote do 7 | use Blunt.Data.Factories, unquote(opts) 8 | use Blunt.Testing.Factories.DispatchStrategy 9 | 10 | if unquote(repo) do 11 | use ExMachina.Ecto, repo: unquote(repo) 12 | else 13 | use ExMachina 14 | end 15 | 16 | builder Blunt.Testing.Factories.Builder.BluntMessageBuilder 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/testing/factories/builder/blunt_message_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Testing.Factories.Builder.BluntMessageBuilder do 2 | @moduledoc false 3 | @behaviour Blunt.Data.Factories.Builder 4 | 5 | defmodule Error do 6 | defexception [:errors] 7 | 8 | def message(%{errors: errors}) do 9 | inspect(errors) 10 | end 11 | end 12 | 13 | alias Blunt.Message.Metadata 14 | alias Blunt.{Behaviour, Message} 15 | 16 | @impl true 17 | def recognizes?(message_module) do 18 | Behaviour.is_valid?(message_module, Message) 19 | end 20 | 21 | @impl true 22 | def message_fields(message_module), 23 | do: Metadata.fields(message_module) 24 | 25 | @impl true 26 | def field_validations(message_module), 27 | do: Metadata.field_validations(message_module) 28 | 29 | @impl true 30 | def build(message_module, data) do 31 | case message_module.new(data) do 32 | {:ok, message} -> 33 | normalize_internal_map_fields(message) 34 | 35 | other -> 36 | other 37 | end 38 | end 39 | 40 | @impl true 41 | def dispatch({:error, _} = message), do: message 42 | 43 | @impl true 44 | def dispatch(%{__struct__: module} = message) do 45 | unless Message.dispatchable?(message) do 46 | message 47 | else 48 | case module.dispatch({:ok, message, %{}}, return: :response) do 49 | {:ok, value} -> value 50 | {:error, errors} -> {:error, %Error{errors: errors}} 51 | end 52 | end 53 | end 54 | 55 | defp normalize_internal_map_fields(%{__struct__: message_module} = message) do 56 | internal_fields = 57 | Metadata.fields(message_module, :internal) 58 | |> Enum.filter(fn {_name, type, _opts} -> type == :map end) 59 | |> Enum.map(&elem(&1, 0)) 60 | |> Kernel.--([:discarded_data]) 61 | 62 | fields = 63 | message 64 | |> Map.from_struct() 65 | |> Enum.into(%{}, fn {key, value} -> 66 | case {Enum.member?(internal_fields, key), value} do 67 | {true, value} when is_map(value) -> 68 | value = atomize(value) 69 | {key, value} 70 | 71 | _ -> 72 | {key, value} 73 | end 74 | end) 75 | 76 | struct!(message_module, fields) 77 | end 78 | 79 | defp atomize(list) when is_list(list), do: Enum.map(list, &atomize(&1)) 80 | 81 | defp atomize(map) when is_map(map) do 82 | Enum.into(map, %{}, fn {key, value} -> {String.to_atom(key), atomize(value)} end) 83 | end 84 | 85 | defp atomize(other), do: other 86 | end 87 | -------------------------------------------------------------------------------- /apps/blunt/lib/blunt/testing/factories/dispatch_strategy.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(ExMachina) and Code.ensure_loaded?(Faker) do 2 | defmodule Blunt.Testing.Factories.DispatchStrategy do 3 | @moduledoc false 4 | use ExMachina.Strategy, function_name: :dispatch 5 | 6 | defmodule Error do 7 | defexception [:message] 8 | end 9 | 10 | alias Blunt.{DispatchContext, Message, Message.Metadata} 11 | 12 | def handle_dispatch(message, opts), 13 | do: handle_dispatch(message, opts, []) 14 | 15 | def handle_dispatch(%{__struct__: module} = message, _opts, dispatch_opts) do 16 | unless Message.dispatchable?(message) do 17 | raise Error, message: "#{inspect(module)} is not a dispatchable message" 18 | end 19 | 20 | dispatch_opts = 21 | dispatch_opts 22 | |> Keyword.put(:dispatched_from, :ex_machina) 23 | |> Keyword.update(:return, :context, &Function.identity/1) 24 | |> Keyword.put(:blunt, %{reply_to: self(), message_module: module}) 25 | |> Keyword.put(:user_supplied_fields, Metadata.field_names(module)) 26 | 27 | case module.dispatch({:ok, message}, dispatch_opts) do 28 | {:error, %DispatchContext{} = context} -> 29 | {:error, DispatchContext.errors(context)} 30 | 31 | {:error, errors} -> 32 | {:error, errors} 33 | 34 | {:ok, %DispatchContext{} = context} -> 35 | case DispatchContext.get_last_pipeline(context) do 36 | {:ok, result} -> {:ok, result} 37 | result -> {:ok, result} 38 | end 39 | 40 | other -> 41 | other 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /apps/blunt/lib/mix/tasks/blunt/verify.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Blunt.Verify do 2 | use Mix.Task 3 | 4 | alias Blunt.Config 5 | 6 | @options strict: [namespace: :string], 7 | aliases: [n: :namespace] 8 | 9 | def run(args) do 10 | Mix.Task.run("app.start", ["--no-start"]) 11 | 12 | args 13 | |> get_namespace!() 14 | |> find_messages_without_pipelines() 15 | |> Enum.to_list() 16 | |> IO.inspect(label: "messages without pipelines") 17 | end 18 | 19 | defp find_messages_without_pipelines(namespace) do 20 | resolver = Config.pipeline_resolver!() 21 | 22 | namespace 23 | |> find_messages() 24 | |> Stream.filter(&(Blunt.Message in behaviours(&1))) 25 | |> Stream.filter(&Blunt.Message.dispatchable?/1) 26 | |> Stream.map(&{&1, resolver.resolve(&1)}) 27 | |> Stream.filter(&match?({_, :error}, &1)) 28 | |> Stream.map(&elem(&1, 0)) 29 | end 30 | 31 | defp find_messages(namespace) when is_binary(namespace) do 32 | modules = 33 | :code.all_available() 34 | |> Enum.filter(fn {name, _file, _loaded} -> String.starts_with?(to_string(name), namespace) end) 35 | |> Enum.map(fn {name, _file, _loaded} -> List.to_atom(name) end) 36 | 37 | with :ok <- :code.ensure_modules_loaded(modules) do 38 | modules 39 | end 40 | end 41 | 42 | defp behaviours(module) do 43 | module.module_info() 44 | |> get_in([:attributes, :behaviour]) 45 | |> List.wrap() 46 | end 47 | 48 | defp get_namespace!(args) do 49 | {opts, _} = OptionParser.parse!(args, @options) 50 | "Elixir." <> Keyword.fetch!(opts, :namespace) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /apps/blunt/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.MixProject do 2 | use Mix.Project 3 | 4 | @version String.trim(File.read!("__VERSION")) 5 | 6 | def project do 7 | [ 8 | app: :blunt, 9 | version: @version, 10 | elixir: "~> 1.12", 11 | # 12 | build_path: "../../_build", 13 | config_path: "../../config/config.exs", 14 | deps_path: "../../deps", 15 | lockfile: "../../mix.lock", 16 | # 17 | start_permanent: Mix.env() == :prod, 18 | deps: deps(), 19 | source_url: "https://github.com/blunt-elixir/blunt", 20 | package: [ 21 | organization: "oforce_dev", 22 | description: "CQRS for Elixir", 23 | licenses: ["MIT"], 24 | files: ~w(lib .formatter.exs mix.exs README* __VERSION), 25 | links: %{"GitHub" => "https://github.com/blunt-elixir/blunt"} 26 | ], 27 | consolidate_protocols: Mix.env() != :test, 28 | dialyzer: [ 29 | plt_add_deps: :apps_direct, 30 | plt_add_apps: [:faker, :mix] 31 | ], 32 | elixirc_paths: elixirc_paths(Mix.env()) 33 | ] 34 | end 35 | 36 | defp elixirc_paths(:test), do: ["lib", "test/support", "test/shared"] 37 | defp elixirc_paths(_), do: ["lib"] 38 | 39 | # Run "mix help compile.app" to learn about applications. 40 | def application do 41 | [ 42 | extra_applications: [:logger] 43 | ] 44 | end 45 | 46 | # Run "mix help deps" to learn about dependencies. 47 | defp deps do 48 | env = System.get_env("MIX_LOCAL") || Mix.env() 49 | 50 | blunt(env) ++ 51 | [ 52 | {:jason, "~> 1.3"}, 53 | {:ecto, "~> 3.9"}, 54 | {:decimal, "~> 1.6 or ~> 2.0"}, 55 | 56 | # Telemetry 57 | {:telemetry, "~> 0.4 or ~> 1.0"}, 58 | {:telemetry_registry, "~> 0.2 or ~> 0.3"}, 59 | 60 | # Optional deps. 61 | {:faker, "~> 0.17.0", optional: true}, 62 | {:ex_machina, "~> 2.7", optional: true}, 63 | 64 | # For testing 65 | {:etso, "~> 0.1.6", only: [:test]}, 66 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 67 | {:uniq, "~> 0.1"}, 68 | {:elixir_uuid, "~> 0.1", hex: :uniq_compat}, 69 | 70 | # generate docs 71 | {:ex_doc, "~> 0.28", only: :dev, runtime: false} 72 | ] 73 | end 74 | 75 | defp blunt(:prod), do: [{:blunt_data, "~> #{@version}", organization: "oforce_dev"}] 76 | 77 | defp blunt(_env) do 78 | case System.get_env("HEX_API_KEY") do 79 | nil -> [{:blunt_data, in_umbrella: true, organization: "oforce_dev"}] 80 | _hex -> [{:blunt_data, "~> #{@version}", organization: "oforce_dev"}] 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /apps/blunt/test/blunt/custom_dispatch_strategy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.CustomDispatchStrategyTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Blunt.DispatchContext, as: Context 5 | alias Blunt.{Command, DispatchStrategy, CustomDispatchStrategy} 6 | 7 | setup do 8 | Application.put_env(:blunt, :dispatch_strategy, CustomDispatchStrategy) 9 | 10 | on_exit(fn -> 11 | Application.put_env(:blunt, :dispatch_strategy, DispatchStrategy.Default) 12 | end) 13 | end 14 | 15 | defmodule CreatePerson do 16 | use Blunt.Command 17 | 18 | field :name, :string 19 | field :id, :binary_id, required: false 20 | 21 | def after_validate(command), 22 | do: %{command | id: UUID.uuid4()} 23 | end 24 | 25 | defmodule CreatePersonHandler do 26 | use Blunt.CustomDispatchStrategy.CustomCommandHandler 27 | 28 | @impl true 29 | def before_dispatch(_command, context) do 30 | {:ok, context} 31 | end 32 | 33 | @impl true 34 | def handle_authorize(_user, _command, context) do 35 | {:ok, context} 36 | end 37 | 38 | @impl true 39 | def handle_dispatch(_command, _context) do 40 | {:ok, :success!} 41 | end 42 | end 43 | 44 | test "dispatch with custom strategy" do 45 | assert {:ok, :success!} = 46 | %{name: "chris"} 47 | |> CreatePerson.new() 48 | |> CreatePerson.dispatch() 49 | end 50 | 51 | test "dispatch and validate context with custom strategy" do 52 | assert {:ok, context} = 53 | %{name: "chris"} 54 | |> CreatePerson.new() 55 | |> CreatePerson.dispatch(return: :context) 56 | 57 | assert %{before_dispatch: :ok, handle_authorize: :ok, handle_dispatch: :success!} = Context.get_pipeline(context) 58 | 59 | assert :success! == Command.results(context) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /apps/blunt/test/blunt/dispatch_context_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.DispatchContextTest do 2 | use ExUnit.Case 3 | alias Blunt.DispatchContext 4 | 5 | describe "discarded data" do 6 | defmodule DiscardedDataCommand do 7 | use Blunt.Command 8 | 9 | field :name, :string 10 | end 11 | 12 | defmodule DiscardedDataCommandHandler do 13 | use Blunt.CommandHandler 14 | 15 | def handle_dispatch(_command, _context), do: :ok 16 | end 17 | 18 | test "is reset in command and placed in context" do 19 | {:ok, context} = 20 | %{name: "chris", dog: "maize"} 21 | |> DiscardedDataCommand.new() 22 | |> DiscardedDataCommand.dispatch(return: :context) 23 | 24 | assert %{"dog" => "maize"} = DispatchContext.discarded_data(context) 25 | assert command = DispatchContext.get_message(context) 26 | assert command.discarded_data == %{} 27 | end 28 | end 29 | 30 | defmodule CustomCommand do 31 | use Blunt.Command 32 | 33 | field :name, :string 34 | field :dog, :string, required: false 35 | end 36 | 37 | defmodule CustomCommandHandler do 38 | use Blunt.CommandHandler 39 | 40 | def handle_dispatch(_command, _context), do: :ok 41 | end 42 | 43 | defmodule CustomContext do 44 | use Blunt.BoundedContext 45 | 46 | command CustomCommand 47 | end 48 | 49 | test "has_user_supplied_field?" do 50 | {:ok, context} = CustomContext.custom_command([name: "chris"], return: :context) 51 | assert DispatchContext.has_user_supplied_field?(context, :name) 52 | 53 | {:ok, context} = CustomContext.custom_command([name: "chris", dog: "maize"], return: :context) 54 | assert DispatchContext.has_user_supplied_field?(context, :name) 55 | assert DispatchContext.has_user_supplied_field?(context, :dog) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /apps/blunt/test/blunt/message/changeset_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.ChangesetTest do 2 | use ExUnit.Case 3 | 4 | defmodule EmbeddedMessage do 5 | use Blunt.ValueObject 6 | field :name, :string 7 | end 8 | 9 | defmodule SomeCommand do 10 | use Blunt.Command 11 | option :test_option, :boolean 12 | option :reply_to, :pid 13 | field :name, :string 14 | field :msg, EmbeddedMessage 15 | 16 | def handle_validate(changeset, opts) do 17 | reply_to = Keyword.get(opts, :reply_to) 18 | send(reply_to, {:opts, opts}) 19 | changeset 20 | end 21 | end 22 | 23 | test "works" do 24 | opts = [test_option: true, reply_to: self()] 25 | 26 | assert {:ok, %SomeCommand{msg: %EmbeddedMessage{name: "chris"}}} = 27 | SomeCommand.new(%{name: "John", msg: %{name: "chris"}}, %{}, opts) 28 | 29 | assert_receive {:opts, opts} 30 | assert true = Keyword.get(opts, :test_option) 31 | end 32 | 33 | describe "autogenerated id field" do 34 | defmodule WithAutoId do 35 | use Blunt.Command 36 | field :id, :binary_id, autogenerate: {UUID, :uuid4}, required: false 37 | end 38 | 39 | test "is generated" do 40 | {:ok, %{id: id}} = WithAutoId.new() 41 | assert {:ok, _} = UUID.info(id) 42 | end 43 | 44 | test "can pass value" do 45 | id = UUID.uuid4() 46 | {:ok, %{id: ^id}} = WithAutoId.new(id: id) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /apps/blunt/test/blunt/message/compiler_hooks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.CompilerHooksTest do 2 | use ExUnit.Case 3 | 4 | alias Blunt.Message.Metadata 5 | 6 | describe "plain messages" do 7 | defmodule Message do 8 | use Blunt.Message, 9 | config: [ 10 | compiler_hooks: [message: {Blunt.Test.CompilerHooks, :add_user_id_field}] 11 | ] 12 | end 13 | 14 | test "do not have user_id field" do 15 | refute Message 16 | |> Metadata.field_names() 17 | |> Enum.member?(:user_id) 18 | end 19 | end 20 | 21 | describe "commands" do 22 | defmodule Command do 23 | use Blunt.Command, 24 | config: [ 25 | compiler_hooks: [command: {Blunt.Test.CompilerHooks, :add_user_id_field}] 26 | ] 27 | end 28 | 29 | test "have user_id field" do 30 | assert Command 31 | |> Metadata.field_names() 32 | |> Enum.member?(:user_id) 33 | end 34 | end 35 | 36 | describe "queries" do 37 | defmodule Query do 38 | use Blunt.Query, 39 | config: [ 40 | compiler_hooks: [query: {Blunt.Test.CompilerHooks, :add_user_id_field}] 41 | ] 42 | end 43 | 44 | test "have user_id field" do 45 | assert Query 46 | |> Metadata.field_names() 47 | |> Enum.member?(:user_id) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /apps/blunt/test/blunt/message/field_importer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.FieldImporterTest do 2 | use ExUnit.Case 3 | 4 | alias Blunt.Message.Metadata 5 | 6 | defmodule One do 7 | use Blunt.Command, create_jason_encoders?: false 8 | 9 | field :id, :integer 10 | field :one, :string 11 | internal_field :one_internal, :string 12 | end 13 | 14 | defmodule Two do 15 | use Blunt.Command, create_jason_encoders?: false 16 | 17 | field :id, :integer 18 | field :two, :string 19 | end 20 | 21 | defmodule Three do 22 | use Blunt.Command, create_jason_encoders?: false 23 | 24 | field :id, :integer 25 | field :three, :string 26 | 27 | import_fields(One, only: [:one]) 28 | 29 | import_fields(Two, 30 | except: [:id], 31 | transform: fn {name, type, opts} -> 32 | name = to_string(name) 33 | 34 | [ 35 | {String.to_atom(name <> "_a"), type, opts}, 36 | {String.to_atom(name <> "_b"), type, opts} 37 | ] 38 | end 39 | ) 40 | end 41 | 42 | defmodule Four do 43 | use Blunt.Command, create_jason_encoders?: false 44 | 45 | field :id, :integer 46 | field :three, :string 47 | 48 | import_fields(One, include_internal_fields: true, except: :id) 49 | end 50 | 51 | test "three has all imported fields" do 52 | assert [:discarded_data, :id, :one, :three, :two_a, :two_b] == 53 | Metadata.field_names(Three) 54 | |> Enum.sort() 55 | 56 | assert [:discarded_data, :id, :one, :one_internal, :three] == 57 | Metadata.field_names(Four) 58 | |> Enum.sort() 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /apps/blunt/test/blunt/message/metadata_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.MetadataTest do 2 | use ExUnit.Case 3 | alias Blunt.Message.Metadata 4 | 5 | defmodule MyMessage do 6 | use Blunt.Message, force_jason_encoder?: true 7 | 8 | field :name, :string 9 | field :dog, :enum, values: [:jake, :maize] 10 | internal_field :calculated, :string, virtual: true 11 | end 12 | 13 | describe "virutal fields" do 14 | test "field names" do 15 | assert [:calculated] == Metadata.field_names(MyMessage, :virtual) 16 | end 17 | 18 | test "are not json serialized" do 19 | {:ok, message} = MyMessage.new(name: "chris", dog: :maize, calculated: "thing") 20 | 21 | rehydrated_map = 22 | message 23 | |> Jason.encode!() 24 | |> Jason.decode!() 25 | 26 | refute Map.has_key?(rehydrated_map, "calculated") 27 | 28 | assert {:ok, %MyMessage{dog: :maize, name: "chris", calculated: nil}} == 29 | MyMessage.new(rehydrated_map) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/blunt/test/blunt/message/schema/built_in_validations_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Message.Schema.BuiltInValidationsTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule MyQuery do 5 | use Blunt.Query 6 | 7 | field :code, :string 8 | field :id, :string 9 | 10 | require_at_least_one([:code, :id]) 11 | end 12 | 13 | describe "require_at_least_one" do 14 | test "error" do 15 | assert {:error, 16 | %{ 17 | fields: ["expected at least one of following fields to be supplied: [:code, :id]"] 18 | }} = MyQuery.new([]) 19 | end 20 | 21 | test "ok" do 22 | assert {:ok, %{id: "123"}} = MyQuery.new(id: "123") 23 | assert {:ok, %{code: "123"}} = MyQuery.new(code: "123") 24 | end 25 | end 26 | 27 | describe "require_either" do 28 | defmodule RequireEitherQuery do 29 | use Blunt.Query 30 | 31 | field :id, :binary_id 32 | field :product_id, :binary_id 33 | field :label, :string 34 | 35 | require_either([:id, [:product_id, :label]]) 36 | end 37 | 38 | test "error" do 39 | assert {:error, %{fields: ["expected either :id OR (:product_id AND :label) to be present"]}} = 40 | RequireEitherQuery.new([]) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /apps/blunt/test/blunt/testing/factories/builder/blunt_message_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Testing.Factories.Builder.BluntMessageBuilderTest do 2 | use ExUnit.Case 3 | alias Blunt.Testing.Factories.Builder.BluntMessageBuilder 4 | 5 | describe "internal fields with a type of map" do 6 | defmodule Testing do 7 | use Blunt.Command 8 | internal_field :data, :map 9 | end 10 | 11 | test "transform string key map into atom key map" do 12 | assert %{data: %{name: "stoobz"}} = BluntMessageBuilder.build(Testing, %{data: %{name: "stoobz"}}) 13 | assert %{data: %{name: "stoobz"}} = BluntMessageBuilder.build(Testing, %{data: %{"name" => "stoobz"}}) 14 | end 15 | 16 | test "discarded_data is not considered" do 17 | assert %{data: %{name: "stoobz"}} = 18 | BluntMessageBuilder.build(Testing, %{data: %{name: "stoobz"}, other_data: DateTime.utc_now()}) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/blunt/test/shared/field_types/email_field.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Test.FieldTypes.EmailField do 2 | use Blunt.Message.Schema.FieldDefinition 3 | 4 | @impl true 5 | def define(:email, opts) do 6 | {:string, opts} 7 | end 8 | 9 | @impl true 10 | def define(__MODULE__, opts) do 11 | {:string, opts} 12 | end 13 | 14 | def fake(__MODULE__) do 15 | "fake_hombre@example.com" 16 | end 17 | 18 | def fake(:email) do 19 | "fake_hombre@example.com" 20 | end 21 | 22 | # @behaviour Blunt.Message.Schema.FieldProvider 23 | 24 | # @field_types [:email, __MODULE__] 25 | 26 | # alias Ecto.Changeset 27 | # alias Blunt.Message.Schema 28 | 29 | # @impl true 30 | # def ecto_field(module, {field_name, field_type, opts}) when field_type in @field_types do 31 | # quote bind_quoted: [module: module, field_name: field_name, opts: opts] do 32 | # Schema.put_field_validation(module, field_name, :email) 33 | # Ecto.Schema.field(field_name, :string, opts) 34 | # end 35 | # end 36 | 37 | # @impl true 38 | # def validate_changeset(:email, field_name, changeset, _module) do 39 | # Changeset.validate_format(changeset, field_name, ~r/@/) 40 | # end 41 | 42 | # @impl true 43 | # def fake(field_type, _validation, _field_config) when field_type in @field_types do 44 | # "fake_hombre@example.com" 45 | # end 46 | end 47 | -------------------------------------------------------------------------------- /apps/blunt/test/shared/field_types/uuid_field.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Test.FieldTypes.UuidField do 2 | @behaviour Blunt.Message.Schema.FieldProvider 3 | 4 | @field_types [:uuid, __MODULE__] 5 | 6 | @impl true 7 | def ecto_field(module, {field_name, field_type, opts}) when field_type in @field_types do 8 | quote bind_quoted: [module: module, field_name: field_name, opts: opts] do 9 | Ecto.Schema.field(field_name, Ecto.UUID, opts) 10 | end 11 | end 12 | 13 | @impl true 14 | def validate_changeset(_field_type, _field_name, changeset, _module) do 15 | changeset 16 | end 17 | 18 | @impl true 19 | def fake(field_type, _validation, _field_config) when field_type in @field_types do 20 | UUID.uuid4() 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/blunt/test/shared/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Repo do 2 | use Ecto.Repo, otp_app: :blunt, adapter: Etso.Adapter 3 | end 4 | -------------------------------------------------------------------------------- /apps/blunt/test/support/command_test_handlers.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.CommandTest.Protocol.DispatchWithPipelineHandler do 2 | use Blunt.CommandHandler 3 | alias Blunt.DispatchContext, as: Context 4 | 5 | defp reply(context, pipeline) do 6 | context 7 | |> Context.get_option(:reply_to) 8 | |> send({pipeline, context}) 9 | end 10 | 11 | @impl true 12 | def handle_dispatch(_command, context) do 13 | reply(context, :handle_dispatch) 14 | 15 | if Context.get_option(context, :return_error) do 16 | {:error, :handle_dispatch_error} 17 | else 18 | "YO-HOHO" 19 | end 20 | end 21 | end 22 | 23 | defmodule Blunt.CommandTest.Protocol.CommandWithMetaHandler do 24 | use Blunt.CommandHandler 25 | 26 | def handle_dispatch(_command, _context) do 27 | :ok 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/blunt/test/support/command_test_messages.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.CommandTest.Protocol do 2 | defmodule CommandOptions do 3 | use Blunt.Command 4 | 5 | option :debug, :boolean, default: false 6 | option :audit, :boolean, default: true 7 | end 8 | 9 | defmodule DispatchNoPipeline do 10 | use Blunt.Command 11 | 12 | field :name, :string, required: true 13 | field :dog, :string, default: "maize" 14 | end 15 | 16 | defmodule CommandViaCommandMacro do 17 | use Blunt 18 | 19 | defcommand do 20 | field :name, :string, required: true 21 | field :dog, :string, default: "maize" 22 | end 23 | end 24 | 25 | defmodule DispatchWithPipeline do 26 | @moduledoc """ 27 | This command has a pipeline that it will be dispatched to 28 | """ 29 | use Blunt.Command, require_all_fields?: false 30 | 31 | field :name, :string, required: true 32 | field :dog, :string, default: "maize" 33 | 34 | option :reply_to, :pid, required: true 35 | option :return_error, :boolean, default: false 36 | end 37 | 38 | defmodule CommandWithMeta do 39 | use Blunt.Command 40 | 41 | metadata :auth, 42 | user_roles: [:owner, :collaborator], 43 | account_types: [:broker, :carrier] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /apps/blunt/test/support/compiler_hooks.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Test.CompilerHooks do 2 | def add_user_id_field(_env) do 3 | quote do 4 | field :user_id, :binary_id 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /apps/blunt/test/support/custom_dispatch_strategy/custom_command_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.CustomDispatchStrategy.CustomCommandHandler do 2 | @type user :: map() 3 | @type command :: struct() 4 | @type context :: Blunt.DispatchContext.command_context() 5 | 6 | @callback before_dispatch(command, context) :: {:ok, context()} | {:error, any()} 7 | @callback handle_authorize(user, command, context) :: {:ok, context()} | {:error, any()} | :error 8 | @callback handle_dispatch(command, context) :: any() 9 | 10 | defmacro __using__(_opts) do 11 | quote do 12 | @behaviour Blunt.CustomDispatchStrategy.CustomCommandHandler 13 | 14 | @impl true 15 | def handle_authorize(_user, _command, context), 16 | do: {:ok, context} 17 | 18 | @impl true 19 | def before_dispatch(_command, context), 20 | do: {:ok, context} 21 | 22 | defoverridable handle_authorize: 3, before_dispatch: 2 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/blunt/test/support/custom_dispatch_strategy/custom_query_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.CustomDispatchStrategy.CustomQueryHandler do 2 | @type opts :: keyword() 3 | @type filters :: struct() 4 | @type filter_list :: keyword() 5 | @type user :: struct() | nil 6 | @type query :: Ecto.Query.t() | any() 7 | @type context :: Blunt.DispatchContext.query_context() 8 | 9 | @callback before_dispatch(filters(), context) :: {:ok, context()} | {:error, any()} 10 | @callback create_query(filter_list(), context()) :: query() 11 | @callback handle_scope(user(), query(), context()) :: query() 12 | @callback handle_dispatch(query(), context(), opts()) :: any() 13 | 14 | defmacro __using__(_opts) do 15 | quote do 16 | import Ecto.Query 17 | 18 | @behaviour Blunt.CustomDispatchStrategy.CustomQueryHandler 19 | 20 | @impl true 21 | def before_dispatch(_filters, context), 22 | do: {:ok, context} 23 | 24 | @impl true 25 | def handle_scope(_user, query, _context), 26 | do: query 27 | 28 | defoverridable before_dispatch: 2, handle_scope: 3 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /apps/blunt/test/support/message/schema/email_field_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Message.Schema.EmailFieldProvider do 2 | @moduledoc """ 3 | Defines a `email` field type for usage in a Blunt message 4 | """ 5 | 6 | # @field_types [:email, __MODULE__] 7 | 8 | # alias Ecto.Changeset 9 | # alias Blunt.Message.Schema 10 | 11 | use Blunt.Message.Schema.FieldDefinition 12 | 13 | @impl true 14 | def define(:email, opts) do 15 | {:string, opts} 16 | end 17 | 18 | def fake(:email) do 19 | "fake_hombre@example.com" 20 | end 21 | 22 | # @impl true 23 | # def ecto_field(module, {field_name, field_type, opts}) when field_type in @field_types do 24 | # quote bind_quoted: [module: module, field_name: field_name, opts: opts] do 25 | # Schema.put_field_validation(module, field_name, :email) 26 | # Ecto.Schema.field(field_name, :string, opts) 27 | # end 28 | # end 29 | 30 | # @impl true 31 | # def validate_changeset(:email, field_name, changeset, _module) do 32 | # Changeset.validate_format(changeset, field_name, ~r/@/) 33 | # end 34 | 35 | # def validate_changeset(:begin_with_capital_letter, field_name, changeset, _mod) do 36 | # Changeset.validate_format(changeset, field_name, ~r/^[A-Z]{1}.+/, message: "must begin with a capital letter") 37 | # end 38 | 39 | # @impl true 40 | # def fake(field_type, _validation, _field_config) when field_type in @field_types do 41 | # send(self(), :email_field_faked) 42 | # "fake_hombre@example.com" 43 | # end 44 | 45 | # def fake(_field_type, :begin_with_capital_letter, _field_config) do 46 | # send(self(), :begin_with_capital_letter_validation_field_faked) 47 | # "Chris" 48 | # end 49 | end 50 | -------------------------------------------------------------------------------- /apps/blunt/test/support/message_test_messages.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.MessageTest.Protocol do 2 | defmodule Simple do 3 | use Blunt.Message 4 | 5 | field :name, :string 6 | end 7 | 8 | defmodule FieldOptions do 9 | @moduledoc """ 10 | Hi 11 | """ 12 | use Blunt.Message 13 | 14 | field :name, :string, required: true 15 | field :dog, :string, default: "maize" 16 | field :gender, :enum, values: [:m, :f] 17 | field :today, :date, autogenerate: {__MODULE__, :today, []} 18 | 19 | def today do 20 | Date.utc_today() 21 | end 22 | end 23 | 24 | defmodule MessageWithInternalField do 25 | use Blunt.Message 26 | 27 | internal_field :id, :binary_id, required: true 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/blunt/test/support/query_test_handlers.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.QueryTest.Protocol.GetPersonHandler do 2 | use Blunt.QueryHandler 3 | 4 | alias Blunt.Repo 5 | alias Blunt.QueryTest.ReadModel.Person 6 | 7 | @impl true 8 | def create_query(filters, _context) do 9 | Enum.reduce(filters, Person, fn 10 | {:id, id}, query -> from q in query, where: q.id == ^id 11 | {:name, name}, query -> from q in query, where: q.name == ^name 12 | end) 13 | end 14 | 15 | @impl true 16 | def handle_dispatch(query, _context, opts) do 17 | Repo.one(query, opts) 18 | end 19 | end 20 | 21 | defmodule Blunt.QueryTest.Protocol.CreatePersonHandler do 22 | use Blunt.CommandHandler 23 | 24 | alias Blunt.Repo 25 | alias Blunt.QueryTest.ReadModel.Person 26 | alias Blunt.QueryTest.Protocol.CreatePerson 27 | 28 | @impl true 29 | def handle_dispatch(%CreatePerson{id: id, name: name}, _context) do 30 | %{id: id, name: name} 31 | |> Person.changeset() 32 | |> Repo.insert() 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/blunt/test/support/query_test_messages.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.QueryTest.Protocol do 2 | defmodule BasicQuery do 3 | use Blunt.Query 4 | 5 | @moduledoc """ 6 | Illustrates the basic idea of a Query 7 | """ 8 | 9 | field :id, :binary_id 10 | field :name, :string 11 | end 12 | 13 | defmodule CreatePerson do 14 | use Blunt.Command 15 | 16 | field :name, :string 17 | field :id, :binary_id, required: false 18 | 19 | def after_validate(command), 20 | do: %{command | id: UUID.uuid4()} 21 | end 22 | 23 | defmodule GetPerson do 24 | use Blunt.Query 25 | 26 | alias Blunt.QueryTest.ReadModel.Person 27 | 28 | field :id, :binary_id 29 | field :name, :string 30 | 31 | binding :person, Person 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /apps/blunt/test/support/query_test_read_model.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.QueryTest.ReadModel do 2 | defmodule Person do 3 | use Ecto.Schema 4 | 5 | @primary_key {:id, :binary_id, autogenerate: false} 6 | schema "people" do 7 | field :name, :string 8 | end 9 | 10 | def changeset(person \\ %__MODULE__{}, attrs) do 11 | person 12 | |> Ecto.Changeset.cast(attrs, [:id, :name]) 13 | |> Ecto.Changeset.validate_required([:id]) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/blunt/test/support/testing/add_reservation.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.AddReservation do 2 | @moduledoc """ 3 | Adds a reservation to the system. 4 | """ 5 | use Blunt.Command 6 | field :id, :binary_id, desc: "The identity of the reservation" 7 | end 8 | -------------------------------------------------------------------------------- /apps/blunt/test/support/testing/create_person.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.CreatePerson do 2 | use Blunt.Command 3 | 4 | @moduledoc """ 5 | Creates a person. 6 | """ 7 | 8 | field :id, :binary_id 9 | field :name, :string 10 | end 11 | 12 | defmodule Support.Testing.CreatePersonHandler do 13 | use Blunt.CommandHandler 14 | 15 | @impl true 16 | def handle_dispatch(command, _context) do 17 | {:dispatched, command} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/blunt/test/support/testing/get_person.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.GetPerson do 2 | use Blunt.Query 3 | 4 | field :id, :binary_id, required: true 5 | 6 | binding :person, Support.ReadModel.Person 7 | end 8 | 9 | defmodule Support.Testing.GetPersonHandler do 10 | use Blunt.QueryHandler 11 | 12 | alias Blunt.Query 13 | alias Support.ReadModel.Person 14 | 15 | @impl true 16 | def create_query(filters, _context) do 17 | query = from(p in Person, as: :person) 18 | 19 | Enum.reduce(filters, query, fn 20 | {:id, id}, query -> from([person: p] in query, where: p.id == ^id) 21 | _other, query -> query 22 | end) 23 | end 24 | 25 | @impl true 26 | def handle_dispatch(_query, context, _opts) do 27 | %{id: id} = Query.filters(context) 28 | %{id: id, name: "chris"} 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /apps/blunt/test/support/testing/layz_factory_value_messages.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.LayzFactoryValueMessages.CreateProduct do 2 | use Blunt.Command 3 | field :id, :binary_id 4 | end 5 | 6 | defmodule Support.Testing.LayzFactoryValueMessages.CreateProductHandler do 7 | use Blunt.CommandHandler 8 | 9 | def handle_dispatch(%{id: id}, _context) do 10 | %{id: id} 11 | end 12 | end 13 | 14 | defmodule Support.Testing.LayzFactoryValueMessages.CreatePolicy do 15 | use Blunt.Command 16 | field :product_id, :binary_id 17 | field :id, :binary_id 18 | end 19 | 20 | defmodule Support.Testing.LayzFactoryValueMessages.CreatePolicyHandler do 21 | use Blunt.CommandHandler 22 | 23 | def handle_dispatch(%{id: id}, _context) do 24 | %{id: id} 25 | end 26 | end 27 | 28 | defmodule Support.Testing.LayzFactoryValueMessages.CreatePolicyFee do 29 | use Blunt.Command 30 | field :policy_id, :binary_id 31 | field :id, :binary_id 32 | end 33 | 34 | defmodule Support.Testing.LayzFactoryValueMessages.CreatePolicyFeeHandler do 35 | use Blunt.CommandHandler 36 | 37 | def handle_dispatch(command, _context) do 38 | command 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /apps/blunt/test/support/testing/plain_message.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.PlainMessage do 2 | use Blunt.Message 3 | 4 | field :id, :binary_id 5 | field :name, :string 6 | end 7 | -------------------------------------------------------------------------------- /apps/blunt/test/support/testing/read_model.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.ReadModel do 2 | defmodule Person do 3 | use Ecto.Schema 4 | 5 | @genders [:male, :female, :not_sure] 6 | def genders, do: @genders 7 | 8 | @primary_key {:id, :binary_id, autogenerate: false} 9 | schema "people" do 10 | field :name, :string 11 | field :gender, Ecto.Enum, values: @genders, default: :not_sure 12 | end 13 | 14 | def changeset(person \\ %__MODULE__{}, attrs) do 15 | person 16 | |> Ecto.Changeset.cast(attrs, [:id, :name, :gender]) 17 | |> Ecto.Changeset.validate_required([:id, :gender]) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/blunt/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Blunt.Repo.start_link([]) 2 | 3 | ExUnit.start(exclude: [:skip]) 4 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals_without_parens = [ 3 | derive_enum: 2, 4 | derive_query: 2, 5 | derive_query: 3, 6 | derive_mutation: 2, 7 | derive_mutation: 3, 8 | derive_object: 2, 9 | derive_object: 3, 10 | derive_mutation_input: 1, 11 | derive_mutation_input: 2, 12 | absinthe_resolver: 1 13 | ] 14 | 15 | [ 16 | locals_without_parens: locals_without_parens, 17 | line_length: 120, 18 | import_deps: [:absinthe, :blunt, :ecto], 19 | export: [ 20 | locals_without_parens: locals_without_parens 21 | ], 22 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 23 | ] 24 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/.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 third-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 | blunt_absinthe-*.tar 24 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Chris Martin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/README.md: -------------------------------------------------------------------------------- 1 | # BluntAbsinthe 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `blunt_absinthe` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:blunt_absinthe, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :blunt_absinthe, 4 | dispatch_context_configuration: Blunt.Absinthe.Test.DispatchContextConfiguration 5 | 6 | config(:logger, :console, format: "[$level] $message\n", level: :warning) 7 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe do 2 | alias Blunt.Absinthe.Enum, as: AbsintheEnum 3 | alias Blunt.Absinthe.{Message, Mutation, Object, Query} 4 | 5 | defmodule Error do 6 | defexception [:message] 7 | end 8 | 9 | defmacro __using__(_opts) do 10 | quote do 11 | Module.register_attribute(__MODULE__, :queries, accumulate: true) 12 | Module.register_attribute(__MODULE__, :mutations, accumulate: true) 13 | 14 | # use Absinthe.Schema 15 | import Blunt.Absinthe, only: :macros 16 | 17 | @after_compile Blunt.Absinthe 18 | end 19 | end 20 | 21 | defmacro derive_object(object_name, message_module, opts \\ []) do 22 | object = quote do: Object.generate_object(unquote(message_module), unquote(object_name), unquote(opts)) 23 | Module.eval_quoted(__CALLER__, object) 24 | end 25 | 26 | defmacro derive_enum(enum_name, {enum_source_module, field_name}) do 27 | enum = quote do: AbsintheEnum.generate_type(unquote(enum_name), {unquote(enum_source_module), unquote(field_name)}) 28 | Module.eval_quoted(__CALLER__, enum) 29 | end 30 | 31 | @spec derive_query(atom(), any(), keyword()) :: term() 32 | defmacro derive_query(query_module, return_type, opts \\ []) do 33 | opts = Macro.escape(opts) 34 | return_type = Macro.escape(return_type) 35 | 36 | field = quote do: Query.generate_field(unquote(query_module), unquote(return_type), unquote(opts)) 37 | field = Module.eval_quoted(__CALLER__, field) 38 | 39 | quote do 40 | @queries unquote(query_module) 41 | unquote(field) 42 | end 43 | end 44 | 45 | @spec derive_mutation(atom(), any(), keyword()) :: term() 46 | defmacro derive_mutation(command_module, return_type, opts \\ []) do 47 | opts = Macro.escape(opts) 48 | return_type = Macro.escape(return_type) 49 | 50 | field = quote do: Mutation.generate_field(unquote(command_module), unquote(return_type), unquote(opts)) 51 | field = Module.eval_quoted(__CALLER__, field) 52 | 53 | quote do 54 | @mutations unquote(command_module) 55 | unquote(field) 56 | end 57 | end 58 | 59 | defmacro derive_mutation_input(command_module, opts \\ []) do 60 | opts = Macro.escape(opts) 61 | input_object = quote do: Mutation.generate_input(unquote(command_module), unquote(opts)) 62 | Module.eval_quoted(__CALLER__, input_object) 63 | end 64 | 65 | defmacro __after_compile__(_env, _bytecode) do 66 | quote do 67 | Enum.each(@queries, &Message.validate!(:query, &1)) 68 | Enum.each(@mutations, &Message.validate!(:command, &1)) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/absinthe_errors.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.AbsintheErrors do 2 | @moduledoc false 3 | 4 | alias Blunt.DispatchContext 5 | 6 | @type context :: Blunt.DispatchContext.t() 7 | 8 | @spec from_dispatch_context(context()) :: list 9 | 10 | def from_dispatch_context(%{id: dispatch_id} = context) do 11 | # TODO: Use more info in context to supply useful errors 12 | case DispatchContext.errors(context) do 13 | error when is_atom(error) -> 14 | [message: to_string(error), dispatch_id: dispatch_id] 15 | 16 | error when is_binary(error) -> 17 | [message: error, dispatch_id: dispatch_id] 18 | 19 | errors when is_map(errors) -> 20 | format(errors, dispatch_id: dispatch_id) 21 | end 22 | end 23 | 24 | def format(errors, extra_properties \\ []) when is_map(errors) do 25 | Enum.reduce(errors, [], fn 26 | {:generic, messages}, acc when is_list(messages) or is_map(messages) -> 27 | Enum.map(messages, fn message -> [message: message] end) ++ acc 28 | 29 | {key, messages}, acc when is_list(messages) or is_map(messages) -> 30 | Enum.map(messages, fn message -> [message: "#{key} #{message}"] end) ++ acc 31 | 32 | {key, message}, acc when is_binary(message) -> 33 | [[message: "#{key} #{message}"] | acc] 34 | end) 35 | |> Enum.map(&Keyword.merge(&1, extra_properties)) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Config do 2 | alias Blunt.Absinthe.DispatchContext 3 | alias Blunt.Behaviour 4 | 5 | @doc false 6 | def dispatch_context_configuration do 7 | :dispatch_context_configuration 8 | |> get(DispatchContext.DefaultConfiguration) 9 | |> Behaviour.validate!(DispatchContext.Configuration) 10 | end 11 | 12 | @doc false 13 | def before_resolve_middleware do 14 | get(:before_resolve_middleware, quote(do: fn res, _ -> res end)) 15 | end 16 | 17 | @doc false 18 | def after_resolve_middleware do 19 | get(:after_resolve_middleware, quote(do: fn res, _ -> res end)) 20 | end 21 | 22 | defp get(key, default), do: Application.get_env(:blunt_absinthe, key, default) 23 | end 24 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/dispatch_context/configuration.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.DispatchContext.Configuration do 2 | @type message_module :: atom() 3 | @type resolution :: Absinthe.Resolution.t() 4 | @callback configure(message_module(), resolution()) :: keyword() 5 | 6 | alias Blunt.Absinthe.Config 7 | 8 | def configure(message_module, %{context: context} = resolution) do 9 | configuration = Config.dispatch_context_configuration() 10 | 11 | metadata = configuration.configure(message_module, resolution) 12 | 13 | Keyword.put(metadata, :blunt, Map.get(context, :blunt)) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/dispatch_context/default_configuration.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.DispatchContext.DefaultConfiguration do 2 | @behaviour Blunt.Absinthe.DispatchContext.Configuration 3 | 4 | def configure(_message_module, _res), do: [] 5 | end 6 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/enum.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Enum do 2 | @moduledoc false 3 | alias Blunt.Absinthe.Error 4 | 5 | def generate_type(enum_name, {enum_source_module, field_name}) do 6 | values = 7 | case enum_values(enum_source_module, field_name) do 8 | [] -> 9 | raise Error, message: "#{inspect(enum_source_module)}.#{field_name} is not a valid enum type" 10 | 11 | values -> 12 | Enum.map(values, fn enum_value -> quote do: value(unquote(enum_value)) end) 13 | end 14 | 15 | quote do 16 | enum unquote(enum_name) do 17 | (unquote_splicing(values)) 18 | end 19 | end 20 | end 21 | 22 | defp enum_values(module, field_name) do 23 | case module.__schema__(:type, field_name) do 24 | {:parameterized, Ecto.Enum, opts} -> read_enum_values(opts) 25 | {:array, {:parameterized, Ecto.Enum, opts}} -> read_enum_values(opts) 26 | _ -> [] 27 | end 28 | end 29 | 30 | defp read_enum_values(opts) do 31 | from_mappings = 32 | opts 33 | |> Map.get(:mappings, []) 34 | |> Keyword.keys() 35 | 36 | from_values = Map.get(opts, :values, []) 37 | 38 | from_mappings ++ from_values 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/log.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Log do 2 | @moduledoc false 3 | 4 | @spec debug(any) :: any() 5 | @spec warning(any) :: any() 6 | @spec error(any) :: any() 7 | @spec info(any) :: any() 8 | 9 | def debug(entry), do: put({:debug, entry}) 10 | def warning(entry), do: put({:warning, entry}) 11 | def error(entry), do: put({:error, entry}) 12 | def info(entry), do: put({:info, entry}) 13 | 14 | defp put({level, entry}) do 15 | logs = Process.get(:blunt_logs, []) 16 | logs = [%{level: level, date: DateTime.utc_now(), message: entry} | logs] 17 | Process.put(:blunt_logs, logs) 18 | entry 19 | end 20 | 21 | require Logger 22 | 23 | @spec dump :: :ok 24 | 25 | def dump do 26 | :blunt_logs 27 | |> Process.get([]) 28 | |> Enum.sort_by(& &1.date) 29 | |> Enum.each(fn %{level: level, message: message} -> 30 | if System.get_env("BLUNT_DEBUG") do 31 | IO.inspect(message, label: "log") 32 | end 33 | 34 | Logger.log(level, message) 35 | end) 36 | 37 | Logger.flush() 38 | 39 | :ok 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Message do 2 | @moduledoc false 3 | 4 | alias Blunt.Behaviour 5 | alias Blunt.Absinthe.Error 6 | alias Blunt.Message.Metadata 7 | alias Blunt.Absinthe.MutationResolver 8 | 9 | def validate!(:command, module) do 10 | error = "#{inspect(module)} is not a valid #{inspect(Blunt.Command)}" 11 | do_validate!(module, :command, error) 12 | end 13 | 14 | def validate!(:query, module) do 15 | error = "#{inspect(module)} is not a valid #{inspect(Blunt.Query)}" 16 | do_validate!(module, :query, error) 17 | end 18 | 19 | defp do_validate!(module, type, error) do 20 | case Code.ensure_compiled(module) do 21 | {:module, module} -> 22 | unless Metadata.is_message_type?(module, type) do 23 | raise Error, message: error 24 | end 25 | 26 | _ -> 27 | raise Error, message: error 28 | end 29 | end 30 | 31 | def defines_resolver?(module) do 32 | case Behaviour.validate(module, MutationResolver) do 33 | {:ok, _} -> true 34 | _ -> false 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Middleware do 2 | @moduledoc false 3 | alias Blunt.Absinthe.Middleware 4 | 5 | def middleware(opts) do 6 | before_resolve = Keyword.get(opts, :before_resolve, &Middleware.identity/2) 7 | after_resolve = Keyword.get(opts, :after_resolve, &Middleware.identity/2) 8 | {before_resolve, after_resolve} 9 | end 10 | 11 | def configured do 12 | before_resolve = Blunt.Absinthe.Config.before_resolve_middleware() 13 | after_resolve = Blunt.Absinthe.Config.after_resolve_middleware() 14 | {before_resolve, after_resolve} 15 | end 16 | 17 | def identity(res, _), do: res 18 | end 19 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/mutation.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Mutation do 2 | @moduledoc false 3 | 4 | alias Blunt.Absinthe.{Args, Field} 5 | 6 | def generate_field(command_module, return_type, opts) do 7 | field_name = Field.name(command_module, opts) 8 | body = Field.generate_body(:absinthe_mutation, field_name, command_module, opts) 9 | 10 | quote do 11 | field unquote(field_name), unquote(return_type) do 12 | unquote(body) 13 | end 14 | end 15 | end 16 | 17 | def generate_input(command_module, opts) do 18 | field_name = :"#{Field.name(command_module, opts)}_input" 19 | 20 | opts = 21 | opts 22 | |> Keyword.put(:field_name, field_name) 23 | |> Keyword.put(:operation, :input_object) 24 | 25 | fields = Args.from_message_fields(command_module, Keyword.put(opts, :type, :fields)) 26 | 27 | quote do 28 | input_object unquote(field_name) do 29 | (unquote_splicing(fields)) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/mutation_resolver.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.MutationResolver do 2 | alias Blunt.Absinthe.Message 3 | 4 | @callback resolve(Absinthe.Resolution.t(), keyword()) :: Absinthe.Resolution.t() 5 | 6 | defmodule Error do 7 | defexception [:message] 8 | end 9 | 10 | defmacro __using__(_opts) do 11 | quote do 12 | import unquote(__MODULE__), only: :macros 13 | end 14 | end 15 | 16 | defmacro absinthe_resolver({:fn, _, [{:->, _, [[{_resolution, _, _}, {_config, _, _}], _]}]} = function) do 17 | quote do 18 | @behaviour unquote(__MODULE__) 19 | @impl unquote(__MODULE__) 20 | @dialyzer {:nowarn_function, resolve: 2} 21 | def resolve(resolution, config) do 22 | result = unquote(function).(resolution, config) 23 | 24 | case result do 25 | {:ok, result} -> 26 | Absinthe.Resolution.put_result(resolution, {:ok, result}) 27 | 28 | {:error, error} -> 29 | Absinthe.Resolution.put_result(resolution, {:error, error}) 30 | 31 | %Absinthe.Resolution{} = resolution -> 32 | resolution 33 | 34 | x -> 35 | raise Error, message: "Expected {:ok, _} or {:error, _}. Got #{inspect(x)}" 36 | end 37 | end 38 | end 39 | end 40 | 41 | defmacro receive_event(resolution, timeout \\ 5000, do: code_block) do 42 | quote do 43 | receive do 44 | unquote(code_block) 45 | after 46 | unquote(timeout) -> 47 | Absinthe.Resolution.put_result(unquote(resolution), {:error, :timeout}) 48 | end 49 | end 50 | end 51 | 52 | def after_resolve(%{errors: [_ | _]} = resolution, _config), do: resolution 53 | 54 | def after_resolve(%{context: context, errors: []} = resolution, config) do 55 | case Map.get(context, :blunt, %{}) do 56 | %{message_module: module} -> 57 | cond do 58 | Message.defines_resolver?(module) -> 59 | module.resolve(resolution, config) 60 | 61 | true -> 62 | resolution 63 | end 64 | 65 | _ -> 66 | resolution 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/object.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Object do 2 | @moduledoc false 3 | 4 | alias Blunt.Absinthe.Args 5 | 6 | def generate_object(message_module, object_name, opts) do 7 | opts = 8 | opts 9 | |> Keyword.put(:type, :fields) 10 | |> Keyword.put(:operation, :derive_object) 11 | |> Keyword.put(:field_name, object_name) 12 | 13 | fields = Args.from_message_fields(message_module, opts) 14 | 15 | case Keyword.get(opts, :input_object, false) do 16 | true -> 17 | quote do 18 | input_object unquote(object_name) do 19 | (unquote_splicing(fields)) 20 | end 21 | end 22 | 23 | false -> 24 | quote do 25 | object unquote(object_name) do 26 | (unquote_splicing(fields)) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/query.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Query do 2 | @moduledoc false 3 | 4 | alias Blunt.Absinthe.Field 5 | 6 | @spec generate_field(atom, any, keyword) :: {:field, [], [...]} 7 | def generate_field(query_module, return_type, opts) do 8 | field_name = Field.name(query_module, opts) 9 | body = Field.generate_body(:absinthe_query, field_name, query_module, opts) 10 | 11 | quote do 12 | field unquote(field_name), unquote(return_type) do 13 | unquote(body) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/lib/blunt/absinthe/type.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Type do 2 | @moduledoc false 3 | 4 | def from_message_field(message_module, {name, :map, _field_opts}, opts) do 5 | operation = Keyword.fetch!(opts, :operation) 6 | field_name = Keyword.fetch!(opts, :field_name) 7 | 8 | error_message = 9 | "#{operation} field `#{field_name}` -- from module `#{inspect(message_module)}` -- requires an arg_types mapping for the argument '#{name}'" 10 | 11 | option_configured_type_mapping(name, opts) || 12 | app_configured_type_mapping(:map) || 13 | raise Blunt.Absinthe.Error, message: error_message 14 | end 15 | 16 | def from_message_field(message_module, {name, {:array, type}, _field_opts}, opts) do 17 | type = from_message_field(message_module, {name, type, nil}, opts) 18 | quote do: list_of(unquote(type)) 19 | end 20 | 21 | def from_message_field(message_module, {name, Ecto.Enum, field_opts}, opts) do 22 | from_message_field(message_module, {name, :enum, field_opts}, opts) 23 | end 24 | 25 | def from_message_field(message_module, {name, :enum, _field_opts}, opts) do 26 | operation = Keyword.fetch!(opts, :operation) 27 | field_name = Keyword.fetch!(opts, :field_name) 28 | 29 | error_message = 30 | "#{operation} field '#{field_name}' -- from module '#{inspect(message_module)}' -- requires an arg_types mapping for the argument '#{name}'" 31 | 32 | enum_type = option_configured_type_mapping(name, opts) || raise Blunt.Absinthe.Error, message: error_message 33 | 34 | quote do: unquote(enum_type) 35 | end 36 | 37 | def from_message_field(_message_module, {_name, :binary_id, _field_opts}, _opts), do: quote(do: :id) 38 | def from_message_field(_message_module, {_name, Ecto.UUID, _field_opts}, _opts), do: quote(do: :id) 39 | def from_message_field(_message_module, {_name, :utc_datetime, _field_opts}, _opts), do: quote(do: :datetime) 40 | 41 | def from_message_field(_message_module, {name, type, _field_opts}, opts) do 42 | type = 43 | option_configured_type_mapping(name, opts) || 44 | app_configured_type_mapping(type) || 45 | type 46 | 47 | quote do: unquote(type) 48 | end 49 | 50 | defp app_configured_type_mapping(type) do 51 | :blunt 52 | |> Application.get_env(:absinthe, []) 53 | |> Keyword.get(:type_mappings, []) 54 | |> Keyword.get(type) 55 | end 56 | 57 | def option_configured_type_mapping(name, opts) do 58 | opts 59 | |> Keyword.get(:arg_types, []) 60 | |> Keyword.get(name) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BluntAbsinthe.MixProject do 2 | use Mix.Project 3 | 4 | @version String.trim(File.read!("__VERSION")) 5 | 6 | def project do 7 | [ 8 | version: @version, 9 | app: :blunt_absinthe, 10 | elixir: "~> 1.12", 11 | # 12 | build_path: "../../_build", 13 | config_path: "../../config/config.exs", 14 | deps_path: "../../deps", 15 | lockfile: "../../mix.lock", 16 | # 17 | start_permanent: Mix.env() == :prod, 18 | deps: deps(), 19 | source_url: "https://github.com/elixir-blunt/blunt_absinthe", 20 | package: [ 21 | organization: "oforce_dev", 22 | description: "Absinthe macros for `blunt` commands and queries", 23 | licenses: ["MIT"], 24 | files: ~w(lib .formatter.exs mix.exs README* __VERSION), 25 | links: %{"GitHub" => "https://github.com/elixir-blunt/blunt_absinthe"} 26 | ], 27 | elixirc_paths: elixirc_paths(Mix.env()) 28 | ] 29 | end 30 | 31 | defp elixirc_paths(:test), do: ["lib", "test/support"] 32 | 33 | defp elixirc_paths(_), do: ["lib"] 34 | 35 | # Run "mix help compile.app" to learn about applications. 36 | def application do 37 | [ 38 | extra_applications: [:logger] 39 | ] 40 | end 41 | 42 | # Run "mix help deps" to learn about dependencies. 43 | defp deps do 44 | env = System.get_env("MIX_LOCAL") || Mix.env() 45 | 46 | blunt(env) ++ 47 | [ 48 | {:absinthe, "~> 1.7"}, 49 | 50 | # For testing 51 | {:etso, "~> 0.1.6", only: [:test]}, 52 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 53 | 54 | 55 | # generate docs 56 | {:ex_doc, "~> 0.28", only: :dev, runtime: false} 57 | ] 58 | end 59 | 60 | defp blunt(:prod) do 61 | [ 62 | {:blunt, "~> 0.1"}, 63 | {:blunt_data, "~> 0.1"} 64 | ] 65 | end 66 | 67 | defp blunt(_env) do 68 | case System.get_env("HEX_API_KEY") do 69 | nil -> 70 | [ 71 | {:blunt, in_umbrella: true}, 72 | {:blunt_ddd, in_umbrella: true}, 73 | {:blunt_data, in_umbrella: true} 74 | ] 75 | 76 | _hex -> 77 | [ 78 | {:blunt, "~> #{@version}", organization: "oforce_dev"}, 79 | {:blunt_ddd, "~> #{@version}", organization: "oforce_dev"}, 80 | {:blunt_data, "~> #{@version}", organization: "oforce_dev"} 81 | ] 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/blunt/absinthe/enum_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.EnumTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Absinthe.Type.Enum 5 | alias Blunt.Absinthe.Test.Schema 6 | 7 | test "enum is defined" do 8 | assert %Enum{values: values} = Absinthe.Schema.lookup_type(Schema, :gender) 9 | assert [:female, :male, :not_sure] = Map.keys(values) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/blunt/absinthe/mutation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.MutationTest do 2 | use ExUnit.Case 3 | 4 | alias Absinthe.Type.{InputObject, Object} 5 | 6 | alias Blunt.DispatchContext 7 | alias Blunt.Absinthe.Test.Schema 8 | 9 | setup do 10 | %{ 11 | query: """ 12 | mutation create($name: String!, $gender: Gender!){ 13 | createPerson(name: $name, gender: $gender){ 14 | id 15 | name 16 | gender 17 | } 18 | } 19 | """ 20 | } 21 | end 22 | 23 | test "field documentation is copied from Query" do 24 | assert %{description: "Creates's a person."} = 25 | Absinthe.Schema.lookup_type(Schema, "RootMutationType") 26 | |> Map.get(:fields) 27 | |> Map.get(:create_person) 28 | end 29 | 30 | test "internal field id is not an arg" do 31 | assert %{fields: %{create_person: %{args: args}}} = Absinthe.Schema.lookup_type(Schema, "RootMutationType") 32 | refute Enum.member?(Map.keys(args), :id) 33 | end 34 | 35 | test "can create a person", %{query: query} do 36 | assert {:ok, %{data: %{"createPerson" => person}}} = 37 | Absinthe.run(query, Schema, variables: %{"name" => "chris", "gender" => "MALE"}) 38 | 39 | assert %{"id" => id, "name" => "chris", "gender" => "MALE"} = person 40 | assert {:ok, _} = UUID.info(id) 41 | end 42 | 43 | test "mutation input types" do 44 | assert %InputObject{fields: fields} = Absinthe.Schema.lookup_type(Schema, :update_person_input) 45 | 46 | assert %{ 47 | id: %{ 48 | type: %Absinthe.Type.NonNull{of_type: :id} 49 | }, 50 | name: %{ 51 | type: %Absinthe.Type.NonNull{of_type: :string} 52 | }, 53 | gender: %{ 54 | type: :gender 55 | } 56 | } = fields 57 | end 58 | 59 | test "derive object" do 60 | assert %Object{fields: fields} = Absinthe.Schema.lookup_type(Schema, :dog) 61 | assert %{name: %{type: :string}} = fields 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/blunt/absinthe/object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.ObjectTest do 2 | use ExUnit.Case 3 | 4 | defmodule MyObject do 5 | use Blunt.ValueObject 6 | field :test, :map, default: %{} 7 | end 8 | 9 | defmodule Schema do 10 | use Absinthe.Schema 11 | use Blunt.Absinthe 12 | 13 | import_types(Types.Json) 14 | 15 | derive_object :my_object, MyObject, arg_types: [test: :json] 16 | 17 | input_object :my_input do 18 | import_fields(:my_object) 19 | end 20 | 21 | query do 22 | field :hello, :string, resolve: fn _, _ -> {:ok, "world"} end 23 | end 24 | end 25 | 26 | test "default value is encoded correctly" do 27 | {:ok, res} = 28 | """ 29 | query IntrospectionQuery { 30 | __type(name: "MyInput"){ 31 | inputFields{ 32 | defaultValue 33 | name 34 | } 35 | } 36 | } 37 | """ 38 | |> Absinthe.run(Schema) 39 | 40 | assert %{"defaultValue" => "{}", "name" => "test"} = get_in(res, [:data, "__type", "inputFields", Access.at(0)]) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/blunt/absinthe/query_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.QueryTest do 2 | use ExUnit.Case 3 | 4 | alias Blunt.Absinthe.Test.Schema 5 | alias Blunt.{DispatchContext, Repo} 6 | alias Blunt.Absinthe.Test.ReadModel.Person 7 | 8 | defp create_person(name) do 9 | %{id: UUID.uuid4(), name: name} 10 | |> Person.changeset() 11 | |> Repo.insert() 12 | end 13 | 14 | setup do 15 | assert {:ok, %{id: person_id}} = create_person("chris") 16 | 17 | %{ 18 | person_id: person_id, 19 | query: """ 20 | query person($id: ID!, $error_out: Boolean){ 21 | getPerson(id: $id, errorOut: $error_out){ 22 | id 23 | name 24 | } 25 | } 26 | """ 27 | } 28 | end 29 | 30 | test "field documentation is copied from Query" do 31 | assert %{description: "Get's a person."} = 32 | Absinthe.Schema.lookup_type(Schema, "RootQueryType") 33 | |> Map.get(:fields) 34 | |> Map.get(:get_person) 35 | end 36 | 37 | test "get_user is a valid query", %{person_id: person_id, query: query} do 38 | assert {:ok, %{data: %{"getPerson" => %{"id" => ^person_id}}}} = 39 | Absinthe.run(query, Schema, variables: %{"id" => person_id}) 40 | end 41 | 42 | test "errors are returned", %{person_id: person_id, query: query} do 43 | variables = %{"id" => person_id, "error_out" => true} 44 | 45 | assert {:ok, %{errors: [%{message: "sumting wong"}]}} = Absinthe.run(query, Schema, variables: variables) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/create_person.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.CreatePerson do 2 | @moduledoc """ 3 | Creates's a person. 4 | """ 5 | 6 | use Blunt.Command 7 | alias Blunt.Absinthe.Test.ReadModel.Person 8 | 9 | field :name, :string 10 | field :gender, :enum, values: Person.genders(), default: :not_sure 11 | 12 | internal_field :id, :binary_id, desc: "Id is set internally. Setting it will have no effect" 13 | 14 | option :send_notification, :boolean, default: false 15 | 16 | @impl true 17 | def after_validate(command) do 18 | Map.put(command, :id, UUID.uuid4()) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/create_person_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.CreatePersonHandler do 2 | use Blunt.CommandHandler 3 | 4 | alias Blunt.Repo 5 | alias Blunt.Absinthe.Test.ReadModel.Person 6 | 7 | @impl true 8 | def handle_dispatch(command, _context) do 9 | command 10 | |> Map.from_struct() 11 | |> Person.changeset() 12 | |> Repo.insert() 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/dispatch_context_configuration.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.DispatchContextConfiguration do 2 | @behaviour Blunt.Absinthe.DispatchContext.Configuration 3 | 4 | def configure(_message_module, %{context: context}) do 5 | context 6 | |> Map.take([:user, :reply_to]) 7 | |> Enum.to_list() 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/dog.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.Dog do 2 | use Blunt.ValueObject 3 | 4 | field :name, :string 5 | end 6 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/get_person.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.GetPerson do 2 | use Blunt.Query 3 | 4 | @moduledoc """ 5 | Get's a person. 6 | """ 7 | 8 | field :id, :binary_id, required: true 9 | 10 | field :error_out, :boolean, default: false 11 | 12 | binding :person, BluntBoundedContext.QueryTest.ReadModel.Person 13 | end 14 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/get_person_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.GetPersonHandler do 2 | use Blunt.QueryHandler 3 | 4 | alias Blunt.Repo 5 | alias Blunt.Absinthe.Test.ReadModel.Person 6 | 7 | @impl true 8 | def create_query(filters, _context) do 9 | if Keyword.get(filters, :error_out) do 10 | {:error, %{sumting: "wong"}} 11 | else 12 | query = from p in Person, as: :person 13 | 14 | Enum.reduce(filters, query, fn 15 | {:id, id}, query -> from([person: p] in query, where: p.id == ^id) 16 | _other, query -> query 17 | end) 18 | end 19 | end 20 | 21 | @impl true 22 | def handle_dispatch(query, _context, opts), 23 | do: Repo.one(query, opts) 24 | end 25 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/json_type.ex: -------------------------------------------------------------------------------- 1 | defmodule Types.Json do 2 | @moduledoc false 3 | use Absinthe.Schema.Notation 4 | alias Absinthe.Blueprint.Input.{Null, String} 5 | 6 | scalar :json, name: "Json" do 7 | description(""" 8 | The `Json` scalar type represents arbitrary json string data, represented as UTF-8 9 | character sequences. The Json type is most often used to represent a free-form 10 | human-readable json string. 11 | """) 12 | 13 | serialize(&encode/1) 14 | parse(&decode/1) 15 | end 16 | 17 | defp decode(%String{value: value}) do 18 | case Jason.decode(value) do 19 | {:ok, result} -> {:ok, result} 20 | _ -> :error 21 | end 22 | end 23 | 24 | defp decode(%Null{}), do: {:ok, nil} 25 | defp decode(_), do: :error 26 | 27 | defp encode(value), do: value 28 | end 29 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/read_model.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.ReadModel do 2 | defmodule Person do 3 | use Ecto.Schema 4 | 5 | @genders [:male, :female, :not_sure] 6 | def genders, do: @genders 7 | 8 | @primary_key {:id, :binary_id, autogenerate: false} 9 | schema "people" do 10 | field :name, :string 11 | field :gender, Ecto.Enum, values: @genders, default: :not_sure 12 | end 13 | 14 | def changeset(person \\ %__MODULE__{}, attrs) do 15 | person 16 | |> Ecto.Changeset.cast(attrs, [:id, :name, :gender]) 17 | |> Ecto.Changeset.validate_required([:id, :gender]) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.Schema do 2 | use Absinthe.Schema 3 | import_types Blunt.Absinthe.Test.SchemaTypes 4 | 5 | query do 6 | import_fields :person_queries 7 | end 8 | 9 | mutation do 10 | import_fields :person_mutations 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/schema_types.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.SchemaTypes do 2 | use Blunt.Absinthe 3 | use Absinthe.Schema.Notation 4 | 5 | alias Blunt.Absinthe.Test.{CreatePerson, GetPerson, UpdatePerson, Dog} 6 | 7 | derive_enum :gender, {CreatePerson, :gender} 8 | 9 | object :person do 10 | field :id, :id 11 | field :name, :string 12 | field :gender, :gender 13 | end 14 | 15 | derive_object(:dog, Dog) 16 | 17 | object :person_queries do 18 | derive_query GetPerson, :person, 19 | arg_transforms: [ 20 | id: &Function.identity/1 21 | ] 22 | end 23 | 24 | derive_mutation_input(UpdatePerson, arg_types: [gender: :gender]) 25 | 26 | object :person_mutations do 27 | derive_mutation CreatePerson, :person, arg_types: [gender: :gender] 28 | derive_mutation UpdatePerson, :person, input_object: true 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/support/update_person.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Test.UpdatePerson do 2 | use Blunt.Command 3 | alias Blunt.Absinthe.Test.ReadModel.Person 4 | 5 | field :id, :binary_id 6 | field :name, :string 7 | field :gender, :enum, values: Person.genders(), required: false 8 | end 9 | -------------------------------------------------------------------------------- /apps/blunt_absinthe/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Blunt.Repo.start_link([]) 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals_without_parens = [ 3 | derive_connection: 3, 4 | define_connection: 2 5 | ] 6 | 7 | [ 8 | locals_without_parens: locals_without_parens, 9 | line_length: 120, 10 | import_deps: [:absinthe, :blunt, :blunt_absinthe, :ecto], 11 | export: [ 12 | locals_without_parens: locals_without_parens 13 | ], 14 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 15 | ] 16 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/.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 third-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 | blunt_absinthe_relay-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/README.md: -------------------------------------------------------------------------------- 1 | # BluntAbsintheRelay 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `blunt_absinthe_relay` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:blunt_absinthe_relay, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :blunt_absinthe, 4 | dispatch_context_configuration: Blunt.Absinthe.Relay.Test.DispatchContextConfiguration 5 | 6 | config :blunt_absinthe_relay, :repo, Blunt.Repo 7 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/lib/blunt/absinthe/relay.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay do 2 | defmodule Error do 3 | defexception [:message] 4 | end 5 | 6 | alias Blunt.Absinthe.Message 7 | alias Blunt.Absinthe.Relay.{Connection, ConnectionField} 8 | 9 | defmacro __using__(_opts) do 10 | quote do 11 | Module.register_attribute(__MODULE__, :queries, accumulate: true) 12 | 13 | import Blunt.Absinthe.Relay, only: :macros 14 | 15 | @after_compile Blunt.Absinthe.Relay 16 | end 17 | end 18 | 19 | defmacro define_connection(node_type, opts \\ []) do 20 | total_count = 21 | if opts[:total_count] do 22 | Connection.generate_total_count_field() 23 | end 24 | 25 | body = opts[:do] 26 | 27 | connection = 28 | quote do 29 | connection node_type: unquote(node_type) do 30 | unquote(total_count) 31 | unquote(body) 32 | 33 | edge do 34 | end 35 | end 36 | end 37 | 38 | Module.eval_quoted(__CALLER__, connection) 39 | end 40 | 41 | defmacro derive_connection(query_module, return_type, opts) do 42 | opts = Macro.escape(opts) 43 | field = quote do: ConnectionField.generate(unquote(query_module), unquote(return_type), unquote(opts)) 44 | field = Module.eval_quoted(__CALLER__, field) 45 | 46 | quote do 47 | @queries unquote(query_module) 48 | unquote(field) 49 | end 50 | end 51 | 52 | defmacro __after_compile__(_env, _bytecode) do 53 | quote do 54 | Enum.each(@queries, &Message.validate!(:query, &1)) 55 | # Enum.each(@mutations, &Message.validate!(:command, &1)) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/lib/blunt/absinthe/relay/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay.Config do 2 | @moduledoc false 3 | 4 | alias Blunt.Absinthe.Relay.Error 5 | 6 | def get_repo!(opts \\ []) do 7 | with nil <- Keyword.get(opts, :repo), 8 | nil <- get(:repo) do 9 | raise_no_repo!() 10 | else 11 | repo -> 12 | {repo, Keyword.delete(opts, :repo)} 13 | end 14 | end 15 | 16 | defp raise_no_repo! do 17 | raise Error, 18 | message: """ 19 | You must either supply a repo via an option 20 | or configure the repo in your config.exs file like so: 21 | 22 | "config :blunt_absinthe_relay, :repo, MyRepo" 23 | """ 24 | end 25 | 26 | defp get(key), do: Application.get_env(:blunt_absinthe_relay, key) 27 | end 28 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/lib/blunt/absinthe/relay/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay.Connection do 2 | @moduledoc false 3 | 4 | require Logger 5 | 6 | alias Blunt.Absinthe.Relay.Connection 7 | 8 | def generate_total_count_field do 9 | quote do 10 | field :total_count, :integer, resolve: &Connection.resolve_total_count/3 11 | end 12 | end 13 | 14 | def resolve_total_count(%{query: query, repo: repo}, _args, _res) do 15 | {:ok, repo.aggregate(query, :count, :id)} 16 | end 17 | 18 | def resolve_total_count(_connection, _args, _res) do 19 | Logger.warning("Requested total_count on a connection that was not created by blunt.") 20 | {:ok, nil} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/schema.graphql: -------------------------------------------------------------------------------- 1 | "Represents a schema" 2 | schema { 3 | query: RootQueryType 4 | } 5 | 6 | type PageInfo { 7 | "When paginating backwards, are there more items?" 8 | hasPreviousPage: Boolean! 9 | 10 | "When paginating forwards, are there more items?" 11 | hasNextPage: Boolean! 12 | 13 | "When paginating backwards, the cursor to continue." 14 | startCursor: String 15 | 16 | "When paginating forwards, the cursor to continue." 17 | endCursor: String 18 | } 19 | 20 | type PersonEdge { 21 | node: Person 22 | cursor: String 23 | } 24 | 25 | type PersonConnection { 26 | pageInfo: PageInfo! 27 | edges: [PersonEdge] 28 | totalCount: Int 29 | } 30 | 31 | type Person { 32 | id: ID 33 | name: String 34 | gender: Gender 35 | } 36 | 37 | type RootQueryType { 38 | listPeople(after: String, first: Int, before: String, last: Int, gender: Gender, name: String): PersonConnection 39 | } 40 | 41 | enum Gender { 42 | MALE 43 | FEMALE 44 | NOT_SURE 45 | } 46 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/test/support/create_people.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay.Test.CreatePeople do 2 | use Blunt.Command 3 | alias Blunt.Absinthe.Relay.Test.Person 4 | 5 | field :peeps, {:array, Person} 6 | end 7 | 8 | defmodule Blunt.Absinthe.Relay.Test.CreatePeopleHandler do 9 | use Blunt.CommandHandler 10 | 11 | alias Blunt.Repo 12 | 13 | def handle_dispatch(%{peeps: peeps}, _context) do 14 | Enum.map(peeps, &Repo.insert!/1) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/test/support/dispatch_context_configuration.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay.Test.DispatchContextConfiguration do 2 | @behaviour Blunt.Absinthe.DispatchContext.Configuration 3 | 4 | def configure(%{context: context}) do 5 | context 6 | |> Map.take([:user, :reply_to]) 7 | |> Enum.to_list() 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/test/support/list_people.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay.Test.ListPeople do 2 | use Blunt.Query 3 | alias Blunt.Absinthe.Relay.Test.Person 4 | 5 | field :name, :string 6 | field :gender, :enum, values: Person.genders() 7 | 8 | binding :person, Person 9 | end 10 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/test/support/list_people_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay.Test.ListPeopleHandler do 2 | use Blunt.QueryHandler 3 | 4 | alias Blunt.Absinthe.Relay.Test.Person 5 | 6 | @impl true 7 | def create_query(filters, _context) do 8 | query = from p in Person, as: :person, order_by: p.name 9 | 10 | Enum.reduce(filters, query, fn 11 | {:name, name}, query -> 12 | from [person: p] in query, 13 | where: p.name == ^name 14 | 15 | {:gender, gender}, query -> 16 | from [person: p] in query, 17 | where: p.gender == ^gender 18 | end) 19 | end 20 | 21 | @impl true 22 | def handle_dispatch(_query, _context, _opts) do 23 | {:error, :this_should_never_be_called_by_absinthe_relay} 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/test/support/person.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay.Test.Person do 2 | use Ecto.Schema 3 | 4 | @genders [:male, :female, :not_sure] 5 | def genders, do: @genders 6 | 7 | @primary_key {:id, :binary_id, autogenerate: false} 8 | schema "people" do 9 | field :name, :string 10 | field :gender, Ecto.Enum, values: @genders, default: :not_sure 11 | end 12 | 13 | def changeset(person \\ %__MODULE__{}, attrs) do 14 | person 15 | |> Ecto.Changeset.cast(attrs, [:id, :name, :gender]) 16 | |> Ecto.Changeset.validate_required([:id, :gender]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/test/support/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay.Test.Schema do 2 | use Absinthe.Schema 3 | use Absinthe.Relay.Schema, :modern 4 | 5 | import_types Blunt.Absinthe.Relay.Test.SchemaTypes 6 | 7 | query do 8 | import_fields :person_queries 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/test/support/schema_types.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Absinthe.Relay.Test.SchemaTypes do 2 | use Blunt.{Absinthe, Absinthe.Relay} 3 | use Absinthe.Schema.Notation 4 | use Absinthe.Relay.Schema.Notation, :modern 5 | 6 | alias Blunt.Absinthe.Relay.Test.ListPeople 7 | 8 | derive_enum :gender, {ListPeople, :gender} 9 | 10 | object :person do 11 | field :id, :id 12 | field :name, :string 13 | field :gender, :gender 14 | end 15 | 16 | define_connection(:person, total_count: true) 17 | 18 | object :person_queries do 19 | derive_connection ListPeople, :person, arg_types: [gender: :gender] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/blunt_absinthe_relay/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Blunt.Repo.start_link([]) 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /apps/blunt_data/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals_without_parens = [ 3 | # blunt data factories macros 4 | builder: 1, 5 | factory: 1, 6 | factory: 2, 7 | factory: 3, 8 | const: 2, 9 | fake: 2, 10 | child: 2, 11 | data: 2, 12 | data: 3, 13 | map: 1, 14 | lazy_data: 2, 15 | lazy_data: 3, 16 | prop: 2, 17 | prop: 3, 18 | merge_prop: 2, 19 | merge_prop: 3, 20 | lazy_prop: 2, 21 | required_prop: 1, 22 | required_props: 1, 23 | defaults: 1, 24 | merge_input: 1, 25 | input: 1, 26 | inspect_props: 0, 27 | inspect_props: 1 28 | ] 29 | 30 | [ 31 | locals_without_parens: locals_without_parens, 32 | line_length: 120, 33 | export: [ 34 | locals_without_parens: locals_without_parens 35 | ], 36 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 37 | ] 38 | -------------------------------------------------------------------------------- /apps/blunt_data/.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 third-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 | blunt_data-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /apps/blunt_data/README.md: -------------------------------------------------------------------------------- 1 | # BluntData 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `blunt_data` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:blunt_data, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Behaviour do 2 | @moduledoc false 3 | 4 | defmodule Error do 5 | defexception [:message] 6 | end 7 | 8 | @spec validate(atom, atom) :: {:error, String.t()} | {:ok, atom} 9 | 10 | def validate(module, behaviour_module) when is_atom(module) do 11 | error = "#{inspect(module)} is not a valid #{inspect(behaviour_module)}" 12 | 13 | case Code.ensure_compiled(module) do 14 | {:module, module} -> 15 | if has_all_callbacks?(module, behaviour_module), 16 | do: {:ok, module}, 17 | else: {:error, error} 18 | 19 | _ -> 20 | {:error, error} 21 | end 22 | end 23 | 24 | def validate(_module, _behaviour_module) do 25 | {:error, :not_a_module} 26 | end 27 | 28 | @spec validate!(atom, atom) :: atom 29 | 30 | def validate!(module, behaviour_module) do 31 | case validate(module, behaviour_module) do 32 | {:ok, module} -> module 33 | {:error, error} -> raise Error, message: error 34 | end 35 | end 36 | 37 | def is_valid?(module, behaviour_module) do 38 | case validate(module, behaviour_module) do 39 | {:ok, _} -> true 40 | _ -> false 41 | end 42 | end 43 | 44 | defp has_all_callbacks?(module, behaviour_module) do 45 | callbacks = behaviour_module.behaviour_info(:callbacks) 46 | optional_callbacks = behaviour_module.behaviour_info(:optional_callbacks) |> Keyword.keys() 47 | 48 | callbacks 49 | |> Enum.reject(fn {name, _arity} -> Enum.member?(optional_callbacks, name) end) 50 | |> Enum.all?(fn {name, arity} -> 51 | # module.__info__(:functions) 52 | # |> IO.inspect(label: "#{inspect(module)} ~/code/personal/blunt/apps/blunt_data/lib/blunt/behaviour.ex:52") 53 | 54 | function_exported?(module, name, arity) 55 | # |> IO.inspect(label: "#{inspect(module)} #{inspect(name)}/#{inspect(arity)}") 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Builder do 2 | @type field_type :: Ecto.Type.t() | atom() 3 | @type field_info :: {name :: atom(), type :: field_type(), opts :: keyword()} 4 | @type message_module :: module() 5 | @type final_message :: struct() | map() 6 | 7 | @callback recognizes?(message_module) :: boolean() 8 | @callback message_fields(message_module()) :: [field_info()] 9 | @callback field_validations(message_module()) :: [{atom(), atom()}] 10 | 11 | @callback build(message_module(), data :: map()) :: final_message 12 | @callback dispatch(final_message()) :: any() 13 | 14 | defmodule NoBuilderError do 15 | defexception [:message] 16 | 17 | def message(%{message: message}) do 18 | message <> ". Messages are expected to either be a map or struct" 19 | end 20 | end 21 | 22 | defmacro __using__(_opts) do 23 | quote do 24 | @behaviour Blunt.Data.Factories.Builder 25 | 26 | @impl true 27 | def dispatch(final_message), do: final_message 28 | 29 | @impl true 30 | def field_validations(_message_module), do: [] 31 | 32 | defoverridable dispatch: 1, field_validations: 1 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/builder/ecto_schema_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Builder.EctoSchemaBuilder do 2 | use Blunt.Data.Factories.Builder 3 | 4 | @impl true 5 | def recognizes?(message_module) do 6 | function_exported?(message_module, :__changeset__, 0) 7 | end 8 | 9 | @impl true 10 | def message_fields(message_module) do 11 | message_module.__changeset__() 12 | # |> Enum.reject(&match?({_name, {:assoc, _}}, &1)) 13 | |> Enum.reject(&match?({:inserted_at, _}, &1)) 14 | |> Enum.reject(&match?({:updated_at, _}, &1)) 15 | |> Enum.map(fn 16 | {name, {:parameterized, Ecto.Enum, config}} -> 17 | values = Map.get(config, :on_dump) |> Map.keys() 18 | {name, :enum, [values: values]} 19 | 20 | {name, type} -> 21 | {name, type, []} 22 | end) 23 | end 24 | 25 | @impl true 26 | def build(message_module, data) do 27 | fields = 28 | message_module 29 | |> message_fields() 30 | |> Enum.map(&elem(&1, 0)) 31 | 32 | data = 33 | data 34 | |> Map.take(fields) 35 | |> data_map() 36 | 37 | if function_exported?(message_module, :changeset, 1) do 38 | message_module 39 | |> apply(:changeset, [data]) 40 | |> Ecto.Changeset.apply_changes() 41 | else 42 | struct!(message_module, data) 43 | end 44 | end 45 | 46 | defp data_map(struct) when is_struct(struct) do 47 | struct 48 | |> Map.from_struct() 49 | |> data_map() 50 | end 51 | 52 | defp data_map(map) when is_map(map) do 53 | Enum.into(map, %{}, fn {key, value} -> {key, data_map(value)} end) 54 | end 55 | 56 | defp data_map(other), do: other 57 | end 58 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/builder/map_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Builder.MapBuilder do 2 | use Blunt.Data.Factories.Builder 3 | 4 | @impl true 5 | def recognizes?(Map), do: true 6 | def recognizes?(_), do: false 7 | 8 | @impl true 9 | def message_fields(_message_module), do: [] 10 | 11 | @impl true 12 | def build(_message_module, data), do: data 13 | end 14 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/builder/struct_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Builder.StructBuilder do 2 | use Blunt.Data.Factories.Builder 3 | 4 | @impl true 5 | def recognizes?(message_module) do 6 | Code.ensure_compiled!(message_module) 7 | function_exported?(message_module, :__struct__, 0) 8 | end 9 | 10 | @impl true 11 | def message_fields(message_module) do 12 | message_module 13 | |> struct() 14 | |> Map.keys() 15 | |> List.delete(:__struct__) 16 | |> Enum.map(fn key -> {key, :string, []} end) 17 | end 18 | 19 | @impl true 20 | def build(message_module, data) do 21 | keys = 22 | message_module 23 | |> struct() 24 | |> Map.keys() 25 | 26 | struct!(message_module, Map.take(data, keys)) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/input_configuration.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.InputConfiguration do 2 | @callback configure(map()) :: map() 3 | 4 | def configure(input) do 5 | config = Application.get_env(:blunt, :factory_input_configuration, __MODULE__.Default) 6 | config.configure(input) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/input_configuration/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.InputConfiguration.Default do 2 | @behaviour Blunt.Data.Factories.InputConfiguration 3 | 4 | def configure(input), do: input 5 | end 6 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/value.ex: -------------------------------------------------------------------------------- 1 | defprotocol Blunt.Data.Factories.Value do 2 | @fallback_to_any true 3 | def evaluate(value, acc, current_factory) 4 | def declared_props(value) 5 | def error(value, error, current_factory) 6 | end 7 | 8 | defmodule Blunt.Data.Factories.ValueError do 9 | defexception [:factory, :prop, :error] 10 | 11 | def message(%{factory: %{name: factory_name}, prop: prop, error: error}) do 12 | """ 13 | 14 | factory: #{factory_name} 15 | prop: #{inspect(prop)} 16 | 17 | #{inspect(error)} 18 | """ 19 | end 20 | end 21 | 22 | defimpl Blunt.Data.Factories.Value, for: Any do 23 | def declared_props(_value), do: [] 24 | def evaluate(_value, acc, _current_factory), do: acc 25 | def error(_value, error, _current_factory), do: error 26 | end 27 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/build.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.Build do 2 | @moduledoc false 3 | 4 | defstruct [:field, :factory_name] 5 | 6 | alias Blunt.Data.Factories.Factory 7 | alias Blunt.Data.Factories.Values.Build 8 | 9 | defimpl Blunt.Data.Factories.Value do 10 | def evaluate(%Build{field: field, factory_name: factory_name}, acc, current_factory) do 11 | factory_name = String.to_existing_atom("#{factory_name}_factory") 12 | value = apply(current_factory.factory_module, factory_name, [acc]) 13 | value = Factory.log_value(current_factory, value, field, false, "child") 14 | Map.put(acc, field, value) 15 | end 16 | 17 | def declared_props(%Build{field: field}), do: [field] 18 | 19 | def error(%{field: field}, error, current_factory), 20 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: field) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/constant.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.Constant do 2 | alias Blunt.Data.Factories.Factory 3 | alias Blunt.Data.Factories.Values.Constant 4 | 5 | @moduledoc false 6 | @derive Inspect 7 | defstruct [:field, :value] 8 | 9 | defimpl Blunt.Data.Factories.Value do 10 | def evaluate(%Constant{field: field, value: value}, acc, current_factory) do 11 | value = Factory.log_value(current_factory, value, field, false, "const") 12 | Map.put(acc, field, value) 13 | end 14 | 15 | def declared_props(%Constant{field: field}), do: [field] 16 | 17 | def error(%{field: field}, error, current_factory), 18 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: field) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/data.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.Data do 2 | @moduledoc false 3 | @derive Inspect 4 | defstruct [:field, :factory, lazy: false] 5 | 6 | alias Blunt.Data.Factories.Factory 7 | alias Blunt.Data.Factories.Values.Data 8 | 9 | defimpl Blunt.Data.Factories.Value do 10 | def declared_props(%Data{field: field}), do: [field] 11 | 12 | def error(%{field: field}, error, current_factory), 13 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: field) 14 | 15 | def evaluate(%Data{field: field, factory: factory, lazy: lazy}, acc, current_factory) do 16 | if not lazy or (lazy and not Map.has_key?(acc, field)) do 17 | operation = 18 | factory 19 | |> Map.fetch!(:operation) 20 | |> validate_factory_operation!(current_factory) 21 | 22 | opts = 23 | factory 24 | |> Map.get(:opts, []) 25 | |> Keyword.merge(current_factory.opts) 26 | 27 | factory_config = 28 | factory 29 | |> Map.put(:input, acc) 30 | |> Map.put(:opts, opts) 31 | |> Map.put(:operation, operation) 32 | |> Map.put(:name, current_factory.name) 33 | |> Map.put(:builders, current_factory.builders) 34 | |> Map.put(:factory_module, current_factory.factory_module) 35 | 36 | value = 37 | Factory 38 | |> struct!(factory_config) 39 | |> Factory.build() 40 | 41 | value = Factory.log_value(current_factory, value, field, lazy, "data") 42 | 43 | Map.put(acc, field, value) 44 | else 45 | acc 46 | end 47 | end 48 | 49 | defp validate_factory_operation!(:dispatch, %{factory_module: module}) do 50 | if function_exported?(module, :dispatch, 1), 51 | do: :dispatch, 52 | else: :builder_dispatch 53 | end 54 | 55 | defp validate_factory_operation!(operation, %{factory_module: module, name: name}) do 56 | if function_exported?(module, operation, 1) do 57 | operation 58 | else 59 | funcs = module.__info__(:functions) |> Keyword.keys() 60 | 61 | raise UndefinedFunctionError, 62 | arity: 1, 63 | module: module, 64 | function: operation, 65 | reason: """ 66 | Attempted to call #{operation} on #{inspect(module)} as part of the `#{name}` factory. 67 | 68 | Available functions: #{inspect(funcs)} 69 | """ 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/defaults.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.Defaults do 2 | @moduledoc false 3 | defstruct values: %{} 4 | 5 | alias Blunt.Data.Factories.Factory 6 | alias Blunt.Data.Factories.Values.Defaults 7 | 8 | defimpl Blunt.Data.Factories.Value do 9 | def declared_props(%Defaults{values: values}), do: Map.keys(values) 10 | 11 | def error(_value, error, current_factory), 12 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: :defaults) 13 | 14 | def evaluate(%Defaults{values: values}, acc, current_factory) do 15 | Enum.reduce(values, acc, fn 16 | {key, _value}, acc when is_map_key(acc, key) -> 17 | acc 18 | 19 | {key, value}, acc -> 20 | value = Factory.log_value(current_factory, value, key, false, "default") 21 | Map.put(acc, key, value) 22 | end) 23 | end 24 | 25 | def error(_value, error), do: raise(%Blunt.Data.Factories.ValueError{error: error, prop: :defaults}) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/input.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.Input do 2 | @moduledoc false 3 | defstruct [:props] 4 | 5 | alias Blunt.Data.Factories.Factory 6 | alias Blunt.Data.Factories.Values.Input 7 | 8 | defimpl Blunt.Data.Factories.Value do 9 | def declared_props(%Input{}), do: [] 10 | 11 | def error(_value, error, current_factory), 12 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: :input) 13 | 14 | def evaluate(%Input{props: props}, acc, current_factory) do 15 | {kept, removed} = Map.split(acc, props) 16 | removed_keys = Map.keys(removed) 17 | 18 | Factory.log_value(current_factory, removed_keys, "input", false, "removed") 19 | Factory.log_value(current_factory, kept, "input", false, "kept") 20 | 21 | kept 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/inspect_props.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.InspectProps do 2 | @moduledoc false 3 | defstruct [:props] 4 | 5 | alias Blunt.Data.Factories.{Factory, Value} 6 | alias Blunt.Data.Factories.Values.InspectProps 7 | 8 | defimpl Blunt.Data.Factories.Value do 9 | def declared_props(%InspectProps{}), do: [] 10 | 11 | def error(_value, error, current_factory), 12 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: :inspect_props) 13 | 14 | def evaluate(%InspectProps{props: :declared}, acc, current_factory) do 15 | keys = 16 | current_factory.values 17 | |> Enum.flat_map(&Value.declared_props/1) 18 | |> Enum.uniq() 19 | 20 | value = Map.take(acc, keys) 21 | 22 | current_factory 23 | |> Factory.enable_debug() 24 | |> Factory.log_value(value, "declared props", false, "inspect") 25 | 26 | acc 27 | end 28 | 29 | def evaluate(%InspectProps{props: :all}, acc, current_factory) do 30 | current_factory 31 | |> Factory.enable_debug() 32 | |> Factory.log_value(acc, "all props", false, "inspect") 33 | end 34 | 35 | def evaluate(%InspectProps{props: :__keys}, acc, current_factory) do 36 | keys = acc |> Map.keys() |> Enum.sort() 37 | 38 | current_factory 39 | |> Factory.enable_debug() 40 | |> Factory.log_value(keys, "all props keys", false, "inspect") 41 | 42 | acc 43 | end 44 | 45 | def evaluate(%InspectProps{props: props}, acc, current_factory) do 46 | value = Map.take(acc, props) 47 | 48 | current_factory 49 | |> Factory.enable_debug() 50 | |> Factory.log_value(value, "props", false, "inspect") 51 | 52 | acc 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/mapper.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.Mapper do 2 | @moduledoc false 3 | defstruct [:func] 4 | 5 | alias Blunt.Data.Factories.Values.Mapper 6 | alias Blunt.Data.Factories.{Factory, Value} 7 | 8 | defimpl Blunt.Data.Factories.Value do 9 | def declared_props(%Mapper{}), do: [] 10 | 11 | def error(_value, error, current_factory), 12 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: :map) 13 | 14 | def evaluate(%Mapper{func: :declared_only}, acc, current_factory) do 15 | keys = 16 | current_factory.values 17 | |> Enum.flat_map(&Value.declared_props/1) 18 | |> Enum.uniq() 19 | 20 | value = Map.take(acc, keys) 21 | Factory.log_value(current_factory, value, "factory data", false, "map") 22 | end 23 | 24 | def evaluate(%Mapper{func: func}, acc, current_factory) do 25 | value = func.(acc) 26 | Factory.log_value(current_factory, value, "factory data", false, "map") 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/merge_input.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.MergeInput do 2 | @moduledoc false 3 | defstruct [:key, :opts] 4 | 5 | alias Blunt.Data.Factories.Factory 6 | alias Blunt.Data.Factories.Values.MergeInput 7 | 8 | defimpl Blunt.Data.Factories.Value do 9 | def declared_props(%MergeInput{}), do: [] 10 | 11 | def error(_value, error, current_factory), 12 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: :merge_input) 13 | 14 | def evaluate(%MergeInput{key: key, opts: opts}, acc, current_factory) do 15 | case Map.pop(acc, key, %{}) do 16 | {input, acc} when is_map(input) -> 17 | only = Keyword.get(opts, :only, []) 18 | except = Keyword.get(opts, :except, []) 19 | 20 | input = 21 | case {only, except} do 22 | {[], []} -> input 23 | {[], except} -> Map.drop(input, except) 24 | {only, []} -> Map.take(input, only) 25 | _ -> raise "#{current_factory.name} you may only specify only or except" 26 | end 27 | 28 | input = Factory.log_value(current_factory, input, "data", false, "merge") 29 | Map.merge(input, acc) 30 | 31 | {input, acc} -> 32 | Map.put(acc, key, input) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/remove_prop.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.RemoveProp do 2 | @moduledoc false 3 | defstruct [:fields] 4 | 5 | alias Blunt.Data.Factories.{Factory, Value} 6 | alias Blunt.Data.Factories.Values.RemoveProp 7 | 8 | defimpl Blunt.Data.Factories.Value do 9 | def declared_props(%RemoveProp{}), do: [] 10 | 11 | def error(_value, error, current_factory), 12 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: :remove_prop) 13 | 14 | def evaluate(%RemoveProp{fields: :undeclared}, acc, current_factory) do 15 | keys = 16 | current_factory.values 17 | |> Enum.flat_map(&Value.declared_props/1) 18 | |> Enum.uniq() 19 | 20 | {keep, drop} = Map.split(acc, keys) 21 | 22 | Factory.log_value(current_factory, Map.keys(drop), "props", false, "removed") 23 | Factory.log_value(current_factory, Map.keys(keep), "props", false, "kept") 24 | 25 | keep 26 | end 27 | 28 | def evaluate(%RemoveProp{fields: fields}, acc, current_factory) do 29 | Enum.reduce(fields, acc, fn field, acc -> 30 | if Map.has_key?(acc, field) do 31 | {_removed_value, acc} = Map.pop!(acc, field) 32 | Factory.log_value(current_factory, :removed, field, false, "removed") 33 | acc 34 | else 35 | acc 36 | end 37 | end) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factories/values/required_prop.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.RequiredProp do 2 | alias Blunt.Data.FactoryError 3 | alias Blunt.Data.Factories.Values.RequiredProp 4 | 5 | @moduledoc false 6 | @derive Inspect 7 | defstruct [:fields] 8 | 9 | defimpl Blunt.Data.Factories.Value do 10 | def declared_props(%RequiredProp{fields: fields}), do: fields 11 | 12 | def error(%{fields: fields}, error, current_factory), 13 | do: raise(Blunt.Data.Factories.ValueError, factory: current_factory, error: error, prop: inspect(fields)) 14 | 15 | def evaluate(%RequiredProp{fields: fields}, acc, current_factory) do 16 | results = 17 | Enum.reduce(fields, [], fn field, results -> 18 | case Map.get(acc, field) do 19 | nil -> [{:error, field} | results] 20 | _present -> [{:ok, acc} | results] 21 | end 22 | end) 23 | 24 | {_, errors} = Enum.split_with(results, &(elem(&1, 0) == :ok)) 25 | 26 | with [_ | _] <- errors do 27 | fields = Enum.map(errors, &elem(&1, 1)) 28 | raise FactoryError.required_field(current_factory, fields) 29 | end 30 | 31 | acc 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/blunt_data/lib/blunt/data/factory_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.FactoryError do 2 | defexception [:reason, :factory, :data] 3 | 4 | def required_field(factory, fields), do: %__MODULE__{reason: :required_field, data: fields, factory: factory} 5 | 6 | def message(%{reason: :unauthorized, factory: %{name: name, message: message}}) do 7 | "#{name} factory was unauthorized while building #{inspect(message)}" 8 | end 9 | 10 | def message(%{reason: :required_field, data: [field], factory: %{name: name, message: message}}) do 11 | "#{name} factory failed while building #{inspect(message)}. #{inspect(field)} is required." 12 | end 13 | 14 | def message(%{reason: :required_field, data: fields, factory: %{name: name, message: message}}) do 15 | "#{name} factory failed while building #{inspect(message)}. #{inspect(fields)} are required." 16 | end 17 | 18 | def message(%{reason: reason, factory: %{name: name, message: message}}) do 19 | "#{name} factory failed while building #{inspect(message)}. #{reason}" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/blunt_data/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BluntData.MixProject do 2 | use Mix.Project 3 | 4 | @version String.trim(File.read!("__VERSION")) 5 | 6 | def project do 7 | [ 8 | app: :blunt_data, 9 | version: @version, 10 | elixir: "~> 1.12", 11 | package: [ 12 | organization: "oforce_dev", 13 | description: "Blunt Testing Utils", 14 | licenses: ["MIT"], 15 | files: ~w(lib .formatter.exs mix.exs README* __VERSION), 16 | links: %{"GitHub" => "https://github.com/blunt-elixir/blunt"} 17 | ], 18 | # 19 | build_path: "../../_build", 20 | config_path: "../../config/config.exs", 21 | deps_path: "../../deps", 22 | lockfile: "../../mix.lock", 23 | # 24 | start_permanent: Mix.env() == :prod, 25 | deps: deps(), 26 | elixirc_paths: elixirc_paths(Mix.env()) 27 | ] 28 | end 29 | 30 | defp elixirc_paths(:test), do: ["lib", "test/support"] 31 | defp elixirc_paths(_), do: ["lib"] 32 | 33 | # Run "mix help compile.app" to learn about applications. 34 | def application do 35 | [ 36 | extra_applications: [:logger] 37 | ] 38 | end 39 | 40 | # Run "mix help deps" to learn about dependencies. 41 | defp deps do 42 | [ 43 | {:ecto, "~> 3.9"}, 44 | # Optional deps. 45 | {:faker, "~> 0.17.0", optional: true}, 46 | {:ex_machina, "~> 2.7", optional: true}, 47 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 48 | ] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /apps/blunt_data/test/blunt/data/factories/factory_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.FactoryTest do 2 | use ExMachina 3 | use Blunt.Data.Factories 4 | use ExUnit.Case, async: true 5 | 6 | alias Blunt.Data.Factories.ValueError 7 | 8 | factory :error_env do 9 | prop :name, fn %{name: name} -> name end 10 | end 11 | 12 | test "error indicates what prop failed evaluatation" do 13 | exception = 14 | assert_raise(ValueError, fn -> 15 | build(:error_env) 16 | end) 17 | 18 | """ 19 | factory: error_env 20 | prop: :name 21 | 22 | %FunctionClauseError{module: Blunt.Data.Factories.FactoryTest, function: :\"-error_env_factory/1-fun-0-\", arity: 1, kind: nil, args: nil, clauses: nil} 23 | """ == ValueError.message(exception) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/blunt_data/test/blunt/data/factories/values/prop_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.PropTest do 2 | use ExMachina 3 | use Blunt.Data.Factories 4 | use ExUnit.Case, async: true 5 | 6 | factory :one do 7 | defaults name: "chris", dog: "maize" 8 | end 9 | 10 | factory :two do 11 | merge_prop(:one_data, &build(:one, &1)) 12 | merge_prop(:prefixed_data, &build(:one, &1), prefix: "one") 13 | end 14 | 15 | test "data is merged from one to two" do 16 | assert %{name: "chris", dog: "maize", one_name: "chris", one_dog: "maize"} = results = build(:two) 17 | refute Map.has_key?(results, :one_data) 18 | refute Map.has_key?(results, :prefixed_data) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/blunt_data/test/blunt/data/factories/values/required_prop_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Data.Factories.Values.RequiredPropTest do 2 | use ExUnit.Case 3 | alias Blunt.Data.FactoryError 4 | alias Blunt.Data.Factories.Value 5 | alias Blunt.Data.Factories.Values.RequiredProp 6 | 7 | test "evaluation" do 8 | alias Blunt.Data.FactoryError 9 | 10 | %FactoryError{data: [:dog, :name]} = 11 | assert_raise(FactoryError, fn -> 12 | Value.evaluate(%RequiredProp{fields: [:name, :dog]}, %{}, %{name: :test, message: %{}}) 13 | end) 14 | 15 | %FactoryError{data: [:name]} = 16 | assert_raise(FactoryError, fn -> 17 | Value.evaluate(%RequiredProp{fields: [:name, :dog]}, %{dog: :maize}, %{name: :test, message: %{}}) 18 | end) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/blunt_data/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/blunt_ddd/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | temp_blunt_funcs = [ 3 | command: 1, 4 | command: 2, 5 | query: 1, 6 | query: 2 7 | ] 8 | 9 | locals_without_parens = [ 10 | command: 1, 11 | command: 2, 12 | query: 1, 13 | query: 2, 14 | defcontext: 1, 15 | defevent: 1, 16 | defevent: 2, 17 | defvalue: 1, 18 | defvalue: 2, 19 | defentity: 1, 20 | defentity: 2, 21 | derive_event: 1, 22 | derive_event: 2, 23 | derive_event: 3 24 | ] 25 | 26 | [ 27 | locals_without_parens: locals_without_parens ++ temp_blunt_funcs, 28 | line_length: 120, 29 | import_deps: [:blunt, :ecto], 30 | export: [ 31 | locals_without_parens: locals_without_parens ++ temp_blunt_funcs 32 | ], 33 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 34 | ] 35 | -------------------------------------------------------------------------------- /apps/blunt_ddd/.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 third-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 | blunt_ddd-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /apps/blunt_ddd/README.md: -------------------------------------------------------------------------------- 1 | # CqrsToolsDdd 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `blunt_ddd` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:blunt_ddd, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /apps/blunt_ddd/config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :blunt, 4 | create_jason_encoders: false, 5 | documentation_output: false 6 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/aggregate_root.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.AggregateRoot do 2 | @type state :: struct() 3 | @type domain_event :: struct() 4 | 5 | @callback apply(state, domain_event) :: state 6 | end 7 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/bounded_context.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.BoundedContext do 2 | alias Blunt.BoundedContext 3 | alias Blunt.BoundedContext.Proxy 4 | 5 | defmodule Error do 6 | defexception [:message] 7 | end 8 | 9 | defmacro __using__(_opts) do 10 | quote do 11 | use Blunt.Message.Compilation 12 | 13 | Module.register_attribute(__MODULE__, :proxies, accumulate: true) 14 | Module.register_attribute(__MODULE__, :messages, accumulate: true, persist: true) 15 | 16 | @before_compile Blunt.BoundedContext 17 | @after_compile Blunt.BoundedContext 18 | 19 | import Blunt.BoundedContext, only: :macros 20 | end 21 | end 22 | 23 | defmacro command(message_module, opts \\ []) do 24 | quote bind_quoted: [message_module: message_module, opts: opts] do 25 | {function_name, _opts} = Proxy.function_name(message_module, opts) 26 | 27 | @messages {:command, message_module, function_name} 28 | @proxies {{:command, message_module, opts}, {__ENV__.file, __ENV__.line}} 29 | end 30 | end 31 | 32 | defmacro query(message_module, opts \\ []) do 33 | quote bind_quoted: [message_module: message_module, opts: opts] do 34 | {function_name, _opts} = Proxy.function_name(message_module, opts) 35 | 36 | @messages {:query, message_module, function_name} 37 | @proxies {{:query, message_module, opts}, {__ENV__.file, __ENV__.line}} 38 | end 39 | end 40 | 41 | defmacro __before_compile__(_env) do 42 | quote do 43 | Enum.map(@proxies, fn {message_info, {file, line}} -> 44 | code = Proxy.generate(message_info) 45 | 46 | __ENV__ 47 | |> Map.put(:file, file) 48 | |> Map.put(:line, line) 49 | |> Module.eval_quoted(code) 50 | end) 51 | end 52 | end 53 | 54 | defmacro __after_compile__(%{module: module}, _bytecode) do 55 | module 56 | |> BoundedContext.proxied_messages() 57 | |> Enum.each(&Proxy.validate!(&1, module)) 58 | 59 | nil 60 | end 61 | 62 | @doc false 63 | def proxied_messages(bounded_context_module) do 64 | :attributes 65 | |> bounded_context_module.__info__() 66 | |> Keyword.get_values(:messages) 67 | |> List.flatten() 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/command/event_derivation.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Command.EventDerivation do 2 | alias Blunt.Command.Events 3 | 4 | defmacro __using__(_opts) do 5 | quote do 6 | Module.register_attribute(__MODULE__, :events, accumulate: true) 7 | 8 | import Blunt.Command.EventDerivation, only: :macros 9 | 10 | @before_compile Blunt.Command.EventDerivation 11 | @after_compile Blunt.Command.EventDerivation 12 | end 13 | end 14 | 15 | defmacro derive_event(name, opts \\ []) 16 | 17 | defmacro derive_event(name, do: body) do 18 | body = Macro.escape(body, unquote: true) 19 | body = quote do: unquote(body) 20 | Events.record(name, do: body) 21 | end 22 | 23 | defmacro derive_event(name, opts), 24 | do: Events.record(name, opts) 25 | 26 | defmacro derive_event(name, opts, do: body) do 27 | body = Macro.escape(body, unquote: true) 28 | body = quote do: unquote(body) 29 | Events.record(name, Keyword.put(opts, :do, body)) 30 | end 31 | 32 | defmacro __before_compile__(_env) do 33 | quote do 34 | def __events__, do: @events 35 | 36 | proxies = Enum.map(@events, &Events.generate_proxy/1) 37 | 38 | Module.eval_quoted(__MODULE__, proxies) 39 | 40 | Module.delete_attribute(__MODULE__, :events) 41 | end 42 | end 43 | 44 | defmacro __after_compile__(env, _bytecode), 45 | do: Events.generate_events(env) 46 | end 47 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/ddd.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Ddd do 2 | defmacro __using__(_opts) do 3 | quote do 4 | import Blunt.Ddd, only: :macros 5 | end 6 | end 7 | 8 | defmacro defcontext(do: body) do 9 | quote do 10 | use Blunt.BoundedContext 11 | unquote(body) 12 | end 13 | end 14 | 15 | defmacro defstate(do: body) do 16 | quote do 17 | use Blunt.State 18 | unquote(body) 19 | end 20 | end 21 | 22 | defmacro defevent(opts \\ [], do: body) do 23 | quote do 24 | use Blunt.DomainEvent, unquote(opts) 25 | unquote(body) 26 | end 27 | end 28 | 29 | defmacro defvalue(opts \\ [], do: body) do 30 | quote do 31 | use Blunt.ValueObject, unquote(opts) 32 | unquote(body) 33 | end 34 | end 35 | 36 | defmacro defentity(opts \\ [], do: body) do 37 | quote do 38 | use Blunt.Entity, unquote(opts) 39 | unquote(body) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/ddd/constructor.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Ddd.Constructor do 2 | @moduledoc false 3 | 4 | alias Blunt.Message.Input 5 | 6 | def put_option(opts), 7 | do: Keyword.put(opts, :constructor, :__new__) 8 | 9 | @spec generate(keyword()) :: any() 10 | defmacro generate(return_type: return_type) do 11 | quote do 12 | @type values :: Input.t() 13 | @type overrides :: Input.t() 14 | 15 | @spec new(values(), overrides()) :: struct() | {:error, any()} 16 | def new(values, overrides \\ []), 17 | do: Blunt.Ddd.Constructor.new(__MODULE__, values, overrides, return_type: unquote(return_type)) 18 | end 19 | end 20 | 21 | def new(module, values, overrides, opts) do 22 | with {:ok, entity} <- module.__new__(values, overrides) do 23 | case Keyword.get(opts, :return_type, :tagged_tuple) do 24 | :tagged_tuple -> {:ok, entity} 25 | :struct -> entity 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/domain_event.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.DomainEvent do 2 | alias Blunt.Ddd.Constructor 3 | 4 | defmodule Error do 5 | defexception [:message] 6 | end 7 | 8 | defmacro __using__(opts) do 9 | {derive_from, opts} = Keyword.pop(opts, :derive_from) 10 | 11 | quote bind_quoted: [derive_from: derive_from, opts: opts] do 12 | use Blunt.Message, 13 | [require_all_fields?: false] 14 | |> Keyword.merge(opts) 15 | |> Constructor.put_option() 16 | |> Keyword.put(:versioned?, true) 17 | |> Keyword.put(:message_type, :domain_event) 18 | 19 | unless is_nil(derive_from) do 20 | fields = Blunt.DomainEvent.__derive_from__(derive_from, opts) 21 | Module.eval_quoted(__MODULE__, fields) 22 | end 23 | 24 | @before_compile Blunt.DomainEvent 25 | end 26 | end 27 | 28 | @doc false 29 | def __derive_from__(command_module, opts) do 30 | quote bind_quoted: [command_module: command_module, opts: opts] do 31 | unless Blunt.Message.Metadata.is_command?(command_module) do 32 | raise Error, message: "derive_from requires a Blunt.Command. #{inspect(command_module)} is not one." 33 | else 34 | to_drop = 35 | opts 36 | |> Keyword.get(:drop, []) 37 | |> List.wrap() 38 | |> Kernel.++([:discarded_data]) 39 | 40 | command_module 41 | |> Blunt.Message.Metadata.fields() 42 | |> Enum.reject(fn {name, _type, _opts} -> Enum.member?(to_drop, name) end) 43 | |> Enum.map(fn field -> 44 | @schema_fields field 45 | end) 46 | end 47 | end 48 | end 49 | 50 | defmacro __before_compile__(_env) do 51 | quote do 52 | require Constructor 53 | Constructor.generate(return_type: :struct) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/entity.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Entity do 2 | alias Blunt.Ddd.Constructor 3 | alias Blunt.Entity.Identity 4 | 5 | @callback identity(struct()) :: any() 6 | 7 | defmodule Error do 8 | defexception [:message] 9 | end 10 | 11 | defmacro __using__(opts) do 12 | quote do 13 | {identity, opts} = Identity.pop(unquote(opts)) 14 | 15 | use Blunt.Message, 16 | [require_all_fields?: false] 17 | |> Keyword.merge(unquote(opts)) 18 | |> Constructor.put_option() 19 | |> Keyword.put(:dispatch?, false) 20 | |> Keyword.put(:message_type, :entity) 21 | |> Keyword.put(:primary_key, identity) 22 | 23 | @behaviour Blunt.Entity 24 | @before_compile Blunt.Entity 25 | end 26 | end 27 | 28 | defmacro __before_compile__(_env) do 29 | quote do 30 | require Identity 31 | require Constructor 32 | 33 | Identity.generate() 34 | Constructor.generate(return_type: :struct) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/entity/identity.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Entity.Identity do 2 | @moduledoc false 3 | 4 | require Logger 5 | 6 | @default {:id, Ecto.UUID, autogenerate: false} 7 | 8 | alias Blunt.Message.Metadata 9 | alias Blunt.{Behaviour, Entity.Error, Entity.Identity} 10 | 11 | def pop(opts) do 12 | opts 13 | |> Keyword.update(:identity, @default, &ensure_field/1) 14 | |> Keyword.pop!(:identity) 15 | end 16 | 17 | defp ensure_field({name, type}), do: {name, type, []} 18 | defp ensure_field({name, type, config}), do: {name, type, config} 19 | 20 | defp ensure_field(value) when value in [false, nil] do 21 | raise Error, message: "Entities require a primary key" 22 | end 23 | 24 | defp ensure_field(_other) do 25 | raise Error, message: "identity must be either {name, type} or {name, type, options}" 26 | end 27 | 28 | defmacro generate do 29 | quote do 30 | def identity(%__MODULE__{} = entity), 31 | do: Identity.identity(__MODULE__, entity) 32 | 33 | def equals?(left, right), 34 | do: Identity.equals?(__MODULE__, left, right) 35 | end 36 | end 37 | 38 | def identity(module, entity) do 39 | Behaviour.validate!(module, Blunt.Entity) 40 | {field_name, _type, _config} = Metadata.primary_key(module) 41 | Map.fetch!(entity, field_name) 42 | end 43 | 44 | def equals?(_module, nil, _), do: false 45 | def equals?(_module, _, nil), do: false 46 | 47 | def equals?(module, %{__struct__: module} = left, %{__struct__: module} = right) do 48 | identity(module, left) == identity(module, right) 49 | end 50 | 51 | def equals?(module, _left, _right) do 52 | Logger.warning("#{inspect(module)}.equals? requires two #{inspect(module)} structs") 53 | false 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/testing/aggregate_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Testing.AggregateCase do 2 | use ExUnit.CaseTemplate 3 | 4 | alias Blunt.Testing.AggregateCase 5 | alias Blunt.{AggregateRoot, Behaviour} 6 | 7 | using aggregate: aggregate do 8 | quote do 9 | import Blunt.Testing.AggregateCase, only: :macros 10 | @aggregate Behaviour.validate!(unquote(aggregate), AggregateRoot) 11 | end 12 | end 13 | 14 | defstruct [:events, :error, :state] 15 | 16 | defmacro execute_command(initial_events \\ [], command) do 17 | quote do 18 | AggregateCase.execute(@aggregate, unquote(initial_events), unquote(command)) 19 | end 20 | end 21 | 22 | @doc false 23 | def execute(aggregate_module, initial_events, command) do 24 | initial_state = struct(aggregate_module) 25 | state = evolve(aggregate_module, initial_state, initial_events) 26 | 27 | case aggregate_module.execute(state, command) do 28 | {:error, _reason} = error -> 29 | %__MODULE__{events: [], error: error, state: state} 30 | 31 | events -> 32 | final_state = evolve(aggregate_module, state, events) 33 | %__MODULE__{events: List.wrap(events), error: nil, state: final_state} 34 | end 35 | end 36 | 37 | @doc false 38 | def evolve(aggregate_module, state, events) do 39 | events 40 | |> List.wrap() 41 | |> Enum.reduce(state, &aggregate_module.apply(&2, &1)) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/value_object.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.ValueObject do 2 | alias Blunt.Ddd.Constructor 3 | 4 | defmacro __using__(opts) do 5 | quote do 6 | use Blunt.ValueObject.Equality 7 | 8 | use Blunt.Message, 9 | [require_all_fields?: false] 10 | |> Keyword.merge(unquote(opts)) 11 | |> Constructor.put_option() 12 | |> Keyword.put(:dispatch?, false) 13 | |> Keyword.put(:message_type, :value_object) 14 | 15 | @before_compile Blunt.ValueObject 16 | end 17 | end 18 | 19 | defmacro __before_compile__(_env) do 20 | quote do 21 | require Constructor 22 | Constructor.generate(return_type: :struct) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/blunt_ddd/lib/blunt/value_object/equality.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.ValueObject.Equality do 2 | require Logger 3 | 4 | defmacro __using__(_opts) do 5 | quote do 6 | def equals?(left, right), 7 | do: unquote(__MODULE__).equals?(__MODULE__, left, right) 8 | end 9 | end 10 | 11 | def equals?(_module, nil, _), do: false 12 | def equals?(_module, _, nil), do: false 13 | 14 | def equals?(module, %{__struct__: module} = left, %{__struct__: module} = right), 15 | do: Map.equal?(left, right) 16 | 17 | def equals?(module, _left, _right) do 18 | Logger.warning("#{inspect(module)}.equals? requires two #{inspect(module)} structs") 19 | false 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/blunt_ddd/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CqrsToolsDdd.MixProject do 2 | use Mix.Project 3 | 4 | @version String.trim(File.read!("__VERSION")) 5 | 6 | def project do 7 | [ 8 | version: @version, 9 | app: :blunt_ddd, 10 | elixir: "~> 1.12", 11 | # 12 | build_path: "../../_build", 13 | config_path: "../../config/config.exs", 14 | deps_path: "../../deps", 15 | lockfile: "../../mix.lock", 16 | # 17 | start_permanent: Mix.env() == :prod, 18 | deps: deps(), 19 | package: [ 20 | organization: "oforce_dev", 21 | description: "DDD semantics for blunt", 22 | licenses: ["MIT"], 23 | files: ~w(lib .formatter.exs mix.exs README* __VERSION), 24 | links: %{"GitHub" => "https://github.com/blunt-elixir/blunt_ddd"} 25 | ], 26 | source_url: "https://github.com/blunt-elixir/blunt_ddd", 27 | elixirc_paths: elixirc_paths(Mix.env()) 28 | ] 29 | end 30 | 31 | defp elixirc_paths(:test), do: ["lib", "test/support"] 32 | defp elixirc_paths(_), do: ["lib"] 33 | 34 | # Run "mix help compile.app" to learn about applications. 35 | def application do 36 | [ 37 | extra_applications: [:logger] 38 | ] 39 | end 40 | 41 | # Run "mix help deps" to learn about dependencies. 42 | defp deps do 43 | env = System.get_env("MIX_LOCAL") || Mix.env() 44 | 45 | blunt(env) ++ 46 | [ 47 | # For testing 48 | {:etso, "~> 0.1.6", only: [:test]}, 49 | {:faker, "~> 0.17.0", optional: true, only: [:test]}, 50 | {:ex_machina, "~> 2.7", optional: true, only: [:test]}, 51 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 52 | 53 | # generate docs 54 | {:ex_doc, "~> 0.28", only: :dev, runtime: false} 55 | ] 56 | end 57 | 58 | defp blunt(:prod) do 59 | [ 60 | {:blunt, "~> 0.1"}, 61 | {:blunt_data, "~> 0.1"} 62 | ] 63 | end 64 | 65 | defp blunt(_env) do 66 | case System.get_env("HEX_API_KEY") do 67 | nil -> 68 | [ 69 | {:blunt, in_umbrella: true}, 70 | {:blunt_data, in_umbrella: true} 71 | ] 72 | 73 | _hex -> 74 | [ 75 | {:blunt, "~> #{@version}", organization: "oforce_dev"}, 76 | {:blunt_data, "~> #{@version}", organization: "oforce_dev"} 77 | ] 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/blunt/compiler_hooks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BluntDdd.Test.Blunt.CompilerHooksTest do 2 | use ExUnit.Case 3 | 4 | alias Blunt.Message.Metadata 5 | 6 | describe "domain events" do 7 | defmodule Event do 8 | use Blunt.DomainEvent, 9 | config: [ 10 | compiler_hooks: [domain_event: {Blunt.Test.CompilerHooks, :add_user_id_field}] 11 | ] 12 | end 13 | 14 | test "have user_id field" do 15 | assert Event 16 | |> Metadata.field_names() 17 | |> Enum.member?(:user_id) 18 | end 19 | end 20 | 21 | describe "value objects" do 22 | defmodule ValueObject do 23 | use Blunt.ValueObject, 24 | config: [ 25 | compiler_hooks: [value_object: {Blunt.Test.CompilerHooks, :add_user_id_field}] 26 | ] 27 | end 28 | 29 | test "have user_id field" do 30 | assert ValueObject 31 | |> Metadata.field_names() 32 | |> Enum.member?(:user_id) 33 | end 34 | end 35 | 36 | describe "entities" do 37 | defmodule Entity do 38 | use Blunt.Entity, 39 | config: [ 40 | compiler_hooks: [entity: {Blunt.Test.CompilerHooks, :add_user_id_field}] 41 | ] 42 | end 43 | 44 | test "have user_id field" do 45 | assert Entity 46 | |> Metadata.field_names() 47 | |> Enum.member?(:user_id) 48 | end 49 | end 50 | 51 | describe "states" do 52 | defmodule State do 53 | use Blunt.State, 54 | config: [ 55 | compiler_hooks: [state: {Blunt.Test.CompilerHooks, :add_user_id_field}] 56 | ] 57 | end 58 | 59 | test "do not have user_id field" do 60 | refute State 61 | |> Metadata.field_names() 62 | |> Enum.member?(:user_id) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/blunt/domain_event_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.DomainEventTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Blunt.Message.Metadata 5 | 6 | alias Support.DomainEventTest.{ 7 | DefaultEvent, 8 | EventWithSetVersion, 9 | EventWithDecimalVersion, 10 | EventDerivedFromCommand, 11 | EventDerivedFromCommandWithDrop 12 | } 13 | 14 | test "is version 1 by default" do 15 | assert %DefaultEvent{version: 1} = DefaultEvent.new(%{}) 16 | end 17 | 18 | test "version is settable" do 19 | assert %EventWithSetVersion{version: 2} = EventWithSetVersion.new(%{}) 20 | end 21 | 22 | test "version is decimal" do 23 | assert %EventWithDecimalVersion{version: 2} = EventWithDecimalVersion.new(%{}) 24 | end 25 | 26 | test "EventDerivedFromCommand has fields from CommandToTestDerivation" do 27 | expected_fields = Metadata.field_names(CommandToTestDerivation) -- [:discarded_data] 28 | actual_fields = Metadata.field_names(EventDerivedFromCommand) 29 | 30 | assert Enum.all?(expected_fields, &Enum.member?(actual_fields, &1)) 31 | end 32 | 33 | test "EventDerivedFromCommandWithDrop drops the name field from CommandToTestDerivation" do 34 | actual_fields = Metadata.field_names(EventDerivedFromCommandWithDrop) 35 | refute Enum.member?(actual_fields, :name) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/blunt/state_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.StateTest do 2 | use ExUnit.Case, async: true 3 | use Blunt.Testing.Factories 4 | 5 | alias Support.StateTest.{PersonAggregateRoot, ReservationEntity} 6 | alias Support.StateTest.Protocol.{PersonCreated, ReservationAdded} 7 | 8 | factory PersonCreated 9 | factory ReservationAdded 10 | factory ReservationEntity 11 | 12 | test "initial aggregate state" do 13 | assert %{id: nil} = %PersonAggregateRoot{} 14 | end 15 | 16 | test "create person" do 17 | id = UUID.uuid4() 18 | 19 | event = build(:person_created, id: id, name: "chris") 20 | 21 | assert %{id: ^id, reservations: []} = PersonAggregateRoot.apply(%PersonAggregateRoot{}, event) 22 | end 23 | 24 | test "add reservation" do 25 | person_id = UUID.uuid4() 26 | reservation_id = UUID.uuid4() 27 | 28 | events = [ 29 | build(:person_created, id: person_id, name: "chris"), 30 | build(:reservation_added, person_id: person_id, reservation_id: reservation_id) 31 | ] 32 | 33 | state = %PersonAggregateRoot{} 34 | 35 | assert %{id: ^person_id, reservations: [%{id: ^reservation_id}]} = 36 | Enum.reduce(events, state, &PersonAggregateRoot.apply(&2, &1)) 37 | end 38 | 39 | describe "state functions" do 40 | test "put function" do 41 | state = %PersonAggregateRoot{} 42 | id = UUID.uuid4() 43 | assert %{id: ^id} = PersonAggregateRoot.put_id(state, id) 44 | end 45 | 46 | test "get function" do 47 | id = UUID.uuid4() 48 | state = %PersonAggregateRoot{id: id} 49 | assert id == PersonAggregateRoot.get_id(state) 50 | end 51 | 52 | test "update function" do 53 | state = %PersonAggregateRoot{id: "e8caa2e5-19fe-4da2-99e6-45b5f3429b5d"} 54 | 55 | id = UUID.uuid4() 56 | reservation_id = UUID.uuid4() 57 | entity = build(:reservation_entity, id: reservation_id) 58 | 59 | values = %{id: id, reservations: [entity]} 60 | 61 | assert %{id: ^id, reservations: [%{id: ^reservation_id}]} = PersonAggregateRoot.update(state, values) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/blunt/testing/aggregate_case_test.exs: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(ExMachina) and Code.ensure_loaded?(Faker) do 2 | defmodule Blunt.Testing.AggregateCaseTest do 3 | use ExUnit.Case 4 | use Blunt.Testing.Factories 5 | 6 | alias Support.Testing.{CreatePerson, PersonAggregate, PersonCreated} 7 | 8 | use Blunt.Testing.AggregateCase, aggregate: PersonAggregate 9 | 10 | factory CreatePerson do 11 | prop :id, &UUID.uuid4/0 12 | prop :name, &Faker.Person.name/0 13 | end 14 | 15 | factory PersonCreated 16 | 17 | test "execute_command" do 18 | %{id: id} = create_person = build(:create_person) 19 | 20 | %{events: [event], state: state} = execute_command(create_person) 21 | 22 | assert %PersonCreated{id: ^id} = event 23 | assert %PersonAggregate{id: ^id, reservations: []} = state 24 | end 25 | 26 | test "execute_command with initial events" do 27 | id = UUID.uuid4() 28 | 29 | person_created = build(:person_created, id: id) 30 | create_person = build(:create_person, id: id) 31 | 32 | %{error: error, events: [], state: state} = execute_command([person_created], create_person) 33 | 34 | assert {:error, "person already created"} = error 35 | assert %PersonAggregate{id: ^id, reservations: []} = state 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/blunt/value_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Blunt.ValueObjectTest do 2 | use ExUnit.Case 3 | import ExUnit.CaptureLog 4 | 5 | defmodule SomeObject do 6 | use Blunt.ValueObject 7 | 8 | field :one, :integer 9 | field :two, :string 10 | end 11 | 12 | describe "equality" do 13 | test "same objects are equal" do 14 | obj = SomeObject.new(one: 1, two: "2") 15 | assert SomeObject.equals?(obj, obj) 16 | end 17 | 18 | test "two objects with same values are equal" do 19 | left = SomeObject.new(one: 1, two: "2") 20 | right = SomeObject.new(one: 1, two: "2") 21 | assert SomeObject.equals?(left, right) 22 | end 23 | 24 | test "two objects with differnt values are not equal" do 25 | left = SomeObject.new(one: 1, two: "2") 26 | right = SomeObject.new(one: 1, two: "two") 27 | refute SomeObject.equals?(left, right) 28 | end 29 | 30 | test "two different objects with differnt values are not equal" do 31 | left = SomeObject.new(one: 1, two: "2") 32 | right = %{one: 1, two: "two"} 33 | 34 | error = "Blunt.ValueObjectTest.SomeObject.equals? requires two Blunt.ValueObjectTest.SomeObject structs" 35 | 36 | capture_log(fn -> refute SomeObject.equals?(left, right) end) =~ error 37 | end 38 | 39 | test "two maps with same values are not equal" do 40 | left = %{one: 1, two: "2"} 41 | right = %{one: 1, two: "two"} 42 | error = "Blunt.ValueObjectTest.SomeObject.equals? requires two Blunt.ValueObjectTest.SomeObject structs" 43 | 44 | capture_log(fn -> refute SomeObject.equals?(left, right) end) =~ error 45 | end 46 | end 47 | 48 | describe "float field types" do 49 | defmodule FloatFieldObject do 50 | use Blunt.ValueObject 51 | field :one, :float 52 | end 53 | 54 | test "are supported" do 55 | assert %{one: 5.0} = FloatFieldObject.new(one: 5.0) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/command/event_derivation_test/command_with_event_derivations.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Command.EventDerivationTest.CommandWithEventDerivations do 2 | use Blunt.Command 3 | use Blunt.Command.EventDerivation 4 | 5 | field :name, :string, required: true 6 | field :dog, :string, default: "maize" 7 | 8 | derive_event DefaultEvent 9 | 10 | derive_event EventWithExtras do 11 | field :date, :date 12 | end 13 | 14 | derive_event EventWithDrops, drop: [:dog] 15 | 16 | derive_event EventWithExtrasAndDrops, drop: [:dog] do 17 | field :date, :date 18 | end 19 | 20 | @event_ns Blunt.CommandTest.Events 21 | 22 | derive_event NamespacedEventWithExtrasAndDrops, drop: [:dog], ns: @event_ns do 23 | field :date, :date 24 | end 25 | end 26 | 27 | defmodule Support.Command.EventDerivationTest.ComandWithVersionedEvent do 28 | use Blunt.Command 29 | use Blunt.Command.EventDerivation 30 | 31 | derive_event VersionedEventHappened, version: 2 32 | end 33 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/command/event_derivation_test/command_with_event_derivations_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Command.EventDerivationTest.CommandWithEventDerivationsHandler do 2 | use Blunt.CommandHandler 3 | 4 | alias Blunt.CommandTest.Events.NamespacedEventWithExtrasAndDrops 5 | 6 | alias Support.Command.EventDerivationTest.{ 7 | DefaultEvent, 8 | EventWithExtras, 9 | EventWithDrops, 10 | EventWithExtrasAndDrops 11 | } 12 | 13 | @impl true 14 | def handle_dispatch(command, _context) do 15 | %{ 16 | default_event: DefaultEvent.new(command), 17 | event_with_drops: EventWithDrops.new(command), 18 | event_with_extras: EventWithExtras.new(command, date: Date.utc_today()), 19 | event_with_extras_and_drops: EventWithExtrasAndDrops.new(command, date: Date.utc_today()), 20 | namespaced_event_with_extras_and_drops: NamespacedEventWithExtrasAndDrops.new(command, date: Date.utc_today()) 21 | } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/context_test/create_person.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.ContextTest.CreatePerson do 2 | use Blunt.Command 3 | 4 | field :name, :string 5 | 6 | field :id, :binary_id, 7 | desc: "Id is set internally. Setting it will have no effect.", 8 | required: false 9 | 10 | option :send_notification, :boolean, default: false 11 | 12 | @impl true 13 | def after_validate(command) do 14 | Map.put(command, :id, UUID.uuid4()) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/context_test/create_person_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.ContextTest.CreatePersonHandler do 2 | use Blunt.CommandHandler 3 | 4 | alias Blunt.Repo 5 | alias Support.ContextTest.ReadModel.Person 6 | 7 | @impl true 8 | def handle_dispatch(%{id: id, name: name}, _context) do 9 | %{id: id, name: name} 10 | |> Person.changeset() 11 | |> Repo.insert() 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/context_test/get_person.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.ContextTest.GetPerson do 2 | use Blunt.Query 3 | 4 | field :id, :binary_id, required: true 5 | 6 | binding :person, CqrsToolsContext.QueryTest.ReadModel.Person 7 | end 8 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/context_test/get_person_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.ContextTest.GetPersonHandler do 2 | use Blunt.QueryHandler 3 | 4 | alias Blunt.Repo 5 | alias Support.ContextTest.ReadModel.Person 6 | 7 | @impl true 8 | def create_query(filters, _context) do 9 | query = from p in Person, as: :person 10 | 11 | Enum.reduce(filters, query, fn 12 | {:id, id}, query -> from [person: p] in query, where: p.id == ^id 13 | end) 14 | end 15 | 16 | @impl true 17 | def handle_dispatch(query, _context, opts), 18 | do: Repo.one(query, opts) 19 | end 20 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/context_test/test_read_model.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.ContextTest.ReadModel do 2 | defmodule Person do 3 | use Ecto.Schema 4 | 5 | @primary_key {:id, :binary_id, autogenerate: false} 6 | schema "people" do 7 | field :name, :string 8 | end 9 | 10 | def changeset(person \\ %__MODULE__{}, attrs) do 11 | person 12 | |> Ecto.Changeset.cast(attrs, [:id, :name]) 13 | |> Ecto.Changeset.validate_required([:id]) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/context_test/users_context.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.ContextTest.UsersContext do 2 | use Blunt.BoundedContext 3 | alias Support.ContextTest.{CreatePerson, GetPerson, ZeroFieldQuery} 4 | 5 | command CreatePerson 6 | command CreatePerson, as: :create_person2 7 | command CreatePerson, as: :create_person_with_custom_opts, send_notification: true 8 | 9 | query GetPerson 10 | query GetPerson, as: :get_known_user, field_values: [id: "07faaf1d-5890-4391-a6db-50e86c240965"] 11 | query ZeroFieldQuery 12 | end 13 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/context_test/zero_field_query.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.ContextTest.ZeroFieldQuery do 2 | use Blunt.Query 3 | end 4 | 5 | defmodule Support.ContextTest.ZeroFieldQueryHandler do 6 | use Blunt.QueryHandler 7 | 8 | alias Blunt.Repo 9 | alias Support.ContextTest.ReadModel.Person 10 | 11 | @impl true 12 | def create_query(_filters, _context), do: Person 13 | 14 | @impl true 15 | def handle_dispatch(query, _context, opts), 16 | do: Repo.all(query, opts) 17 | end 18 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/domain_event_test/default_event.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.DomainEventTest.DefaultEvent do 2 | use Blunt.DomainEvent 3 | field(:user, :string) 4 | end 5 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/domain_event_test/event_derived_from_command.ex: -------------------------------------------------------------------------------- 1 | defmodule CommandToTestDerivation do 2 | use Blunt.Command 3 | 4 | field :id, :binary_id 5 | field :name, :string 6 | end 7 | 8 | defmodule Support.DomainEventTest.EventDerivedFromCommand do 9 | use Blunt.DomainEvent, derive_from: CommandToTestDerivation 10 | end 11 | 12 | defmodule Support.DomainEventTest.EventDerivedFromCommandWithDrop do 13 | use Blunt.DomainEvent, derive_from: CommandToTestDerivation, drop: :name 14 | end 15 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/domain_event_test/event_with_decimal_version.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.DomainEventTest.EventWithDecimalVersion do 2 | use Blunt.DomainEvent 3 | 4 | @version 2 5 | 6 | field(:user, :string) 7 | end 8 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/domain_event_test/event_with_set_version.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.DomainEventTest.EventWithSetVersion do 2 | use Blunt.DomainEvent 3 | 4 | @version 2 5 | 6 | field(:user, :string) 7 | end 8 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/entity_test_messages.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.EntityTestMessages.Protocol do 2 | defmodule Entity1 do 3 | use Blunt.Entity 4 | end 5 | 6 | defmodule Entity2 do 7 | use Blunt.Entity, identity: {:ident, :binary_id, []} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/state_test/person_aggregate_root.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.StateTest.PersonAggregateRoot do 2 | use Blunt.Ddd 3 | 4 | alias Support.StateTest.ReservationEntity 5 | alias Support.StateTest.Protocol.{PersonCreated, ReservationAdded} 6 | 7 | defstate do 8 | field :id, :binary_id 9 | field :reservations, {:array, ReservationEntity}, default: [] 10 | end 11 | 12 | def apply(state, %PersonCreated{id: id}), 13 | do: put_id(state, id) 14 | 15 | def apply(%{reservations: reservations} = state, %ReservationAdded{reservation_id: id}) do 16 | reservation = ReservationEntity.new(id: id) 17 | put_reservations(state, [reservation | reservations]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/state_test/protocol/person_created.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.StateTest.Protocol.PersonCreated do 2 | use Blunt.Message, require_all_fields?: true 3 | 4 | field :id, :binary_id 5 | field :name, :string 6 | end 7 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/state_test/protocol/reservation_added.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.StateTest.Protocol.ReservationAdded do 2 | use Blunt.Message 3 | field :person_id, :binary_id 4 | field :reservation_id, :binary_id 5 | end 6 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/state_test/reservation_entity.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.StateTest.ReservationEntity do 2 | use Blunt.Entity 3 | end 4 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/testing/add_reservation.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.AddReservation do 2 | use Blunt.Command 3 | use Blunt.Command.EventDerivation 4 | field :id, :binary_id 5 | 6 | derive_event ReservationAdded do 7 | field :person_id, :binary_id 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/testing/create_person.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.CreatePerson do 2 | use Blunt.Command 3 | use Blunt.Command.EventDerivation 4 | 5 | field :id, :binary_id 6 | field :name, :string 7 | 8 | derive_event PersonCreated 9 | end 10 | 11 | defmodule Support.Testing.CreatePersonHandler do 12 | use Blunt.CommandHandler 13 | 14 | @impl true 15 | def handle_dispatch(command, _context) do 16 | {:dispatched, command} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/testing/get_person.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.GetPerson do 2 | use Blunt.Query 3 | 4 | field :id, :binary_id, required: true 5 | 6 | binding :person, Support.Testing.ReadModel.Person 7 | end 8 | 9 | defmodule Support.Testing.GetPersonHandler do 10 | use Blunt.QueryHandler 11 | 12 | alias Blunt.Query 13 | alias Support.Testing.ReadModel.Person 14 | 15 | @impl true 16 | def create_query(filters, _context) do 17 | query = from(p in Person, as: :person) 18 | 19 | Enum.reduce(filters, query, fn 20 | {:id, id}, query -> from([person: p] in query, where: p.id == ^id) 21 | _other, query -> query 22 | end) 23 | end 24 | 25 | @impl true 26 | def handle_dispatch(_query, context, _opts) do 27 | %{id: id} = Query.filters(context) 28 | %{id: id, name: "chris"} 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/testing/person_aggregate.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.PersonAggregate do 2 | use Blunt.Ddd 3 | 4 | alias Support.Testing.{CreatePerson, PersonCreated} 5 | alias Support.Testing.{AddReservation, ReservationAdded, ReservationEntity} 6 | 7 | defstate do 8 | field :id, :binary_id 9 | field :reservations, {:array, ReservationEntity}, default: [] 10 | end 11 | 12 | def execute(%{id: nil}, %CreatePerson{} = command), 13 | do: CreatePerson.person_created(command) 14 | 15 | def execute(_state, %CreatePerson{}), 16 | do: {:error, "person already created"} 17 | 18 | def execute(%{id: nil}, _command), 19 | do: {:error, "person not found"} 20 | 21 | def execute(%{id: person_id, reservations: reservations}, %AddReservation{id: reservation_id} = command) do 22 | reservation = ReservationEntity.new(id: reservation_id) 23 | 24 | if Enum.any?(reservations, &ReservationEntity.equals?(&1, reservation)), 25 | do: nil, 26 | else: ReservationAdded.new(command, person_id: person_id) 27 | end 28 | 29 | def apply(state, %PersonCreated{id: id}), 30 | do: put_id(state, id) 31 | 32 | def apply(%{reservations: reservations} = state, %ReservationAdded{id: id}) do 33 | reservation = ReservationEntity.new(id: id) 34 | put_reservations(state, [reservation | reservations]) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/testing/read_model.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.ReadModel do 2 | defmodule Person do 3 | use Ecto.Schema 4 | 5 | @genders [:male, :female, :not_sure] 6 | def genders, do: @genders 7 | 8 | @primary_key {:id, :binary_id, autogenerate: false} 9 | schema "people" do 10 | field :name, :string 11 | field :gender, Ecto.Enum, values: @genders, default: :not_sure 12 | end 13 | 14 | def changeset(person \\ %__MODULE__{}, attrs) do 15 | person 16 | |> Ecto.Changeset.cast(attrs, [:id, :name, :gender]) 17 | |> Ecto.Changeset.validate_required([:id, :gender]) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/support/testing/reservation_entity.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Testing.ReservationEntity do 2 | use Blunt.Entity 3 | end 4 | -------------------------------------------------------------------------------- /apps/blunt_ddd/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Blunt.Repo.start_link([]) 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:blunt, :blunt_ddd], 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/.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 third-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 | blunt_toolkit-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, level: :warn 4 | 5 | config :blunt_toolkit, TestEventStore, 6 | column_data_type: "jsonb", 7 | username: "postgres", 8 | password: "postgres", 9 | hostname: "localhost", 10 | database: "cqrs_toolkit_eventstore", 11 | serializer: EventStore.JsonbSerializer, 12 | types: EventStore.PostgresTypes 13 | 14 | config :eventstore, :event_stores, [EventStore] 15 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/lib/blunt/toolkit/aggregate_inspector/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Toolkit.AggregateInspector.Cache do 2 | @cache_file "_build/cqrs_toolkit_aggregate_inspector_cache" 3 | 4 | use GenServer 5 | 6 | def start_link do 7 | state = read_all() 8 | GenServer.start_link(__MODULE__, state, name: __MODULE__) 9 | end 10 | 11 | def init(state), do: {:ok, state} 12 | 13 | def get(config, key) do 14 | case GenServer.call(__MODULE__, {:get, key}) do 15 | nil -> config 16 | value -> Map.put(config, key, to_string(value) |> String.trim_leading("Elixir.")) 17 | end 18 | end 19 | 20 | def put(key, value), do: GenServer.call(__MODULE__, {:write, key, value}) 21 | 22 | def handle_call({:get, key}, _from, state) do 23 | {:reply, Map.get(state, key, ""), state} 24 | end 25 | 26 | def handle_call(:get, _from, state) do 27 | {:reply, state, state} 28 | end 29 | 30 | def handle_call({:write, key, value}, _from, state) do 31 | state = Map.put(state, key, value) 32 | 33 | _result = File.write!(@cache_file, inspect(state)) 34 | 35 | {:reply, value, state} 36 | end 37 | 38 | defp read_all do 39 | unless File.exists?(@cache_file) do 40 | %{} 41 | else 42 | {values, _} = 43 | @cache_file 44 | |> File.read!() 45 | |> Code.eval_string() 46 | 47 | values 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/lib/blunt/toolkit/aggregate_inspector/commands.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Toolkit.AggregateInspector.Commands do 2 | def load_stream(%{stream: stream, aggregate: aggregate, eventstore: eventstore}) do 3 | with {:ok, events} <- eventstore.read_stream_forward(stream) do 4 | step_through(events, aggregate) 5 | end 6 | end 7 | 8 | defp step_through(events, aggregate_module) do 9 | initial_state = struct(aggregate_module) 10 | acc = {initial_state, [%{state: initial_state, event: nil, event_type: "initial state"}]} 11 | 12 | {_current_state, states} = 13 | events 14 | |> Enum.reduce(acc, fn event, {current_state, states} -> 15 | next_state = aggregate_module.apply(current_state, event.data) 16 | 17 | entry = %{ 18 | state: next_state, 19 | event: event.data, 20 | event_type: String.trim_leading(event.event_type, "Elixir.") 21 | } 22 | 23 | {next_state, [entry | states]} 24 | end) 25 | 26 | Enum.reverse(states) 27 | end 28 | 29 | def get_event_store!(eventstore) do 30 | eventstore = String.to_atom("Elixir." <> eventstore) 31 | Blunt.Behaviour.validate!(eventstore, EventStore) 32 | 33 | {:ok, _} = Application.ensure_all_started(:eventstore) 34 | 35 | case eventstore.start_link() do 36 | {:ok, _} -> eventstore 37 | {:error, {:already_started, _}} -> eventstore 38 | other -> raise inspect(other) 39 | end 40 | end 41 | 42 | def get_aggregate!(aggregate) do 43 | ("Elixir." <> aggregate) 44 | |> String.to_atom() 45 | |> Blunt.Behaviour.validate!(Blunt.AggregateRoot) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/lib/blunt/toolkit/aggregate_inspector/input_handlers.ex: -------------------------------------------------------------------------------- 1 | defmodule Blunt.Toolkit.AggregateInspector.InputHandlers do 2 | alias Blunt.Toolkit.AggregateInspector.InputHandlers 3 | 4 | defmacro __using__(_opts) do 5 | quote do 6 | import InputHandlers, only: :macros 7 | end 8 | end 9 | 10 | defmacro handle_common_input(model, model_field, msg, on_enter: body) do 11 | quote do 12 | current_value = Map.get(unquote(model), unquote(model_field)) 13 | 14 | case unquote(msg) do 15 | {:event, %{key: key}} when key in @delete_keys -> 16 | Map.put(unquote(model), unquote(model_field), String.slice(current_value, 0..-2)) 17 | 18 | {:event, %{ch: ch}} when ch > 0 -> 19 | Map.put(unquote(model), unquote(model_field), current_value <> <>) 20 | 21 | {:event, %{key: @enter_key}} -> 22 | unquote(body).(current_value) 23 | 24 | _ -> 25 | unquote(model) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/lib/blunt_toolkit.ex: -------------------------------------------------------------------------------- 1 | defmodule CqrsToolkit do 2 | import Ratatouille.Constants, only: [key: 1] 3 | 4 | def run_tui(app) do 5 | case Mix.Project.config()[:app] do 6 | nil -> 7 | raise "not in a mix project" 8 | 9 | app_name -> 10 | Logger.configure(level: :warning) 11 | Mix.Task.run("app.start", ["--no-start"]) 12 | 13 | with {:ok, _} <- Application.ensure_all_started(app_name) do 14 | Ratatouille.run(app, quit_events: [{:key, key(:ctrl_c)}]) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/lib/mix/tasks/cqrs/inspect.aggregate.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Blunt.Inspect.Aggregate do 2 | use Mix.Task 3 | 4 | def run(_args) do 5 | CqrsToolkit.run_tui(Blunt.Toolkit.AggregateInspector) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CommandedToolkit.MixProject do 2 | use Mix.Project 3 | 4 | @version String.trim(File.read!("__VERSION")) 5 | 6 | def project do 7 | [ 8 | app: :blunt_toolkit, 9 | version: @version, 10 | elixir: "~> 1.12", 11 | # 12 | build_path: "../../_build", 13 | config_path: "../../config/config.exs", 14 | deps_path: "../../deps", 15 | lockfile: "../../mix.lock", 16 | # 17 | 18 | start_permanent: Mix.env() == :prod, 19 | deps: deps(), 20 | aliases: aliases(), 21 | elixirc_paths: elixirc_paths(Mix.env()), 22 | preferred_cli_env: [ 23 | "blunt.project": :test, 24 | view_state: :test 25 | ] 26 | ] 27 | end 28 | 29 | defp elixirc_paths(:test), do: ["lib", "test/support"] 30 | defp elixirc_paths(_), do: ["lib"] 31 | 32 | # Run "mix help compile.app" to learn about applications. 33 | def application do 34 | [ 35 | extra_applications: [:logger] 36 | ] 37 | end 38 | 39 | # Run "mix help deps" to learn about dependencies. 40 | defp deps do 41 | env = System.get_env("MIX_LOCAL") || Mix.env() 42 | 43 | blunt(env) ++ 44 | [ 45 | {:ratatouille, "~> 0.5"}, 46 | {:commanded, "~> 1.3"}, 47 | {:eventstore, "~> 1.3"}, 48 | {:jason, "~> 1.3"}, 49 | {:uniq, "~> 0.1"}, 50 | {:elixir_uuid, "~> 0.1", hex: :uniq_compat}, 51 | {:faker, "~> 0.17.0", only: :test}, 52 | {:ex_machina, "~> 2.7", only: :test} 53 | ] 54 | end 55 | 56 | defp blunt(:prod) do 57 | [ 58 | {:blunt, "~> 0.1"}, 59 | {:blunt_data, "~> 0.1"}, 60 | {:blunt_ddd, "~> 0.1"}, 61 | {:blunt_absinthe, "~> 0.1"} 62 | ] 63 | end 64 | 65 | defp blunt(_env) do 66 | [ 67 | {:blunt, in_umbrella: true}, 68 | {:blunt_data, in_umbrella: true}, 69 | {:blunt_ddd, in_umbrella: true}, 70 | {:blunt_absinthe, in_umbrella: true} 71 | ] 72 | end 73 | 74 | def aliases do 75 | [ 76 | view_state: "blunt.inspect.aggregate" 77 | ] 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/test/mix/tasks/blunt/inspect.aggregate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Blunt.Inspect.AggregateTest do 2 | use EventStoreCase, async: false 3 | 4 | use Blunt.Testing.Factories 5 | use AppendToStreamStrategy 6 | 7 | @person_id "25d85f07-26ab-4434-851b-d11da9ec942e" 8 | 9 | factory PersonCreated 10 | factory PersonUpdated 11 | 12 | test "populate some events" do 13 | data = %{id: @person_id} 14 | opts = [stream_uuid: "person-" <> @person_id] 15 | 16 | append_to_stream(:person_created, data, opts) 17 | append_to_stream_list(50, :person_updated, data, opts) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/test/support/append_to_stream_strategy.ex: -------------------------------------------------------------------------------- 1 | defmodule AppendToStreamStrategy do 2 | use ExMachina.Strategy, function_name: :append_to_stream 3 | 4 | alias EventStore 5 | alias EventStore.EventData 6 | alias Blunt.Message.Metadata 7 | 8 | def handle_append_to_stream(event, %{stream_uuid: stream_uuid}), 9 | do: handle_append_to_stream(event, %{}, stream_uuid: stream_uuid) 10 | 11 | def handle_append_to_stream(event, strategy_opts), 12 | do: handle_append_to_stream(event, strategy_opts, []) 13 | 14 | def handle_append_to_stream(%{__struct__: module} = event, _strategy_opts, opts) do 15 | fields = Metadata.field_names(module) 16 | event = struct!(module, Map.take(event, fields)) 17 | stream_uuid = Keyword.fetch!(opts, :stream_uuid) 18 | 19 | TestEventStore.append_to_stream(stream_uuid, :any_version, [%EventData{data: event}]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/test/support/event_store_case.ex: -------------------------------------------------------------------------------- 1 | defmodule EventStoreCase do 2 | use ExUnit.CaseTemplate 3 | 4 | alias EventStore.{Config, Storage, Tasks} 5 | 6 | setup_all do 7 | event_store = TestEventStore 8 | 9 | config = Config.parsed(event_store, :blunt_toolkit) 10 | 11 | postgrex_config = Config.default_postgrex_opts(config) 12 | 13 | Tasks.Create.exec(config, quiet: true) 14 | Tasks.Init.exec(config, quiet: true) 15 | Tasks.Migrate.exec(config, quiet: true) 16 | 17 | conn = start_supervised!({Postgrex, postgrex_config}) 18 | 19 | [conn: conn, config: config, event_store: event_store] 20 | end 21 | 22 | setup %{conn: conn, config: config, event_store: event_store} do 23 | Storage.Initializer.reset!(conn, config) 24 | 25 | start_supervised!(event_store) 26 | 27 | :ok 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/test/support/person_aggregate.ex: -------------------------------------------------------------------------------- 1 | defmodule PersonAggregate do 2 | defstruct [:id, :name] 3 | 4 | def apply(state, %PersonCreated{id: id, name: name}) do 5 | %{state | id: id, name: name} 6 | end 7 | 8 | def apply(state, %PersonUpdated{name: name}) do 9 | %{state | name: name} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/test/support/person_created.ex: -------------------------------------------------------------------------------- 1 | defmodule PersonCreated do 2 | use Blunt.DomainEvent 3 | 4 | field :id, :binary_id 5 | field :name, :string 6 | end 7 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/test/support/person_updated.ex: -------------------------------------------------------------------------------- 1 | defmodule PersonUpdated do 2 | use Blunt.DomainEvent 3 | 4 | field :id, :binary_id 5 | field :name, :string 6 | end 7 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/test/support/test_event_store.ex: -------------------------------------------------------------------------------- 1 | defmodule TestEventStore do 2 | use EventStore, otp_app: :blunt_toolkit 3 | end 4 | -------------------------------------------------------------------------------- /apps/blunt_toolkit/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/opentelemetry_blunt/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /apps/opentelemetry_blunt/.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 third-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 | opentelemetry_blunt-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /apps/opentelemetry_blunt/README.md: -------------------------------------------------------------------------------- 1 | # OpentelemetryBlunt 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `opentelemetry_blunt` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:opentelemetry_blunt, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /apps/opentelemetry_blunt/config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :opentelemetry, 4 | traces_exporter: :none 5 | 6 | config :opentelemetry, :processors, [ 7 | {:otel_simple_processor, %{}} 8 | ] 9 | -------------------------------------------------------------------------------- /apps/opentelemetry_blunt/lib/opentelemetry_blunt.ex: -------------------------------------------------------------------------------- 1 | defmodule OpentelemetryBlunt do 2 | @tracer_id __MODULE__ 3 | 4 | def setup do 5 | attach_dispatch_start_handler() 6 | attach_dispatch_stop_handler() 7 | end 8 | 9 | defp attach_dispatch_start_handler do 10 | :telemetry.attach( 11 | "#{__MODULE__}.dispatch_start", 12 | [:blunt, :dispatch_strategy, :execute, :start], 13 | &__MODULE__.handle_start/4, 14 | [] 15 | ) 16 | end 17 | 18 | defp attach_dispatch_stop_handler do 19 | :telemetry.attach( 20 | "#{__MODULE__}.dispatch_stop", 21 | [:blunt, :dispatch_strategy, :execute, :stop], 22 | &__MODULE__.handle_stop/4, 23 | [] 24 | ) 25 | end 26 | 27 | def handle_start(_event, _measurements, metadata, _config) do 28 | parent = OpenTelemetry.Tracer.current_span_ctx() 29 | links = if parent == :undefined, do: [], else: [OpenTelemetry.link(parent)] 30 | OpenTelemetry.Tracer.set_current_span(:undefined) 31 | 32 | OpentelemetryTelemetry.start_telemetry_span(@tracer_id, "blunt.dispatch", metadata, %{ 33 | kind: :consumer, 34 | links: links, 35 | attributes: metadata 36 | }) 37 | end 38 | 39 | def handle_stop(_event, _measurements, metadata, _config) do 40 | OpentelemetryTelemetry.end_telemetry_span(@tracer_id, metadata) 41 | end 42 | 43 | defp read_metadata(%Blunt.DispatchContext{} = context) do 44 | end 45 | 46 | defp read_metadata(metadata), do: metadata 47 | end 48 | -------------------------------------------------------------------------------- /apps/opentelemetry_blunt/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule OpentelemetryBlunt.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :opentelemetry_blunt, 7 | version: "0.1.0", 8 | build_path: "../../_build", 9 | config_path: "../../config/config.exs", 10 | deps_path: "../../deps", 11 | lockfile: "../../mix.lock", 12 | elixir: "~> 1.13", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps() 15 | ] 16 | end 17 | 18 | # Run "mix help compile.app" to learn about applications. 19 | def application do 20 | [ 21 | extra_applications: [:logger] 22 | ] 23 | end 24 | 25 | # Run "mix help deps" to learn about dependencies. 26 | defp deps do 27 | [ 28 | {:opentelemetry_api, "~> 1.1"}, 29 | {:opentelemetry, "~> 1.0", only: :test}, 30 | {:opentelemetry_process_propagator, "~> 0.1"}, 31 | {:opentelemetry_telemetry, "~> 1.0"}, 32 | {:blunt, in_umbrella: true} 33 | ] 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/opentelemetry_blunt/test/opentelemetry_blunt_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OpentelemetryBluntTest do 2 | use ExUnit.Case 3 | 4 | require Record 5 | 6 | for {name, spec} <- Record.extract_all(from_lib: "opentelemetry/include/otel_span.hrl") do 7 | Record.defrecord(name, spec) 8 | end 9 | 10 | for {name, spec} <- Record.extract_all(from_lib: "opentelemetry_api/include/opentelemetry.hrl") do 11 | Record.defrecord(name, spec) 12 | end 13 | 14 | defmodule MyCommand do 15 | use Blunt.Command 16 | field :name, :string 17 | end 18 | 19 | defmodule MyQuery do 20 | use Blunt.Query 21 | field :name, :string 22 | end 23 | 24 | defmodule MyCommandHandler do 25 | use Blunt.CommandHandler 26 | 27 | def handle_dispatch(_command, _context), do: :all_done 28 | end 29 | 30 | defmodule MyQueryHandler do 31 | use Blunt.QueryHandler 32 | 33 | def create_query(_filters, _context) do 34 | %Ecto.Query{} 35 | end 36 | 37 | def handle_dispatch(_query, context, _opts) do 38 | %{name: name} = Blunt.Query.filters(context) 39 | %{__struct__: Person, name: name} 40 | end 41 | end 42 | 43 | setup do 44 | :application.stop(:opentelemetry) 45 | :application.set_env(:opentelemetry, :tracer, :otel_tracer_default) 46 | 47 | :application.set_env(:opentelemetry, :processors, [ 48 | {:otel_batch_processor, %{scheduled_delay_ms: 1, exporter: {:otel_exporter_pid, self()}}} 49 | ]) 50 | 51 | :application.start(:opentelemetry) 52 | 53 | TestHelpers.remove_blunt_handlers() 54 | OpentelemetryBlunt.setup() 55 | 56 | :ok 57 | end 58 | 59 | describe "dispatch" do 60 | setup do 61 | :otel_batch_processor.set_exporter(:otel_exporter_pid, self()) 62 | end 63 | 64 | test "receives span" do 65 | %{name: "chris"} 66 | |> MyCommand.new() 67 | |> MyCommand.dispatch() 68 | 69 | assert_receive( 70 | {:span, 71 | span( 72 | name: "dispatch", 73 | attributes: attributes, 74 | parent_span_id: :undefined, 75 | status: :undefined 76 | )} 77 | ) 78 | 79 | :otel_attributes.map(attributes) 80 | |> IO.inspect(label: "~/code/personal/blunt/apps/opentelemetry_blunt/test/opentelemetry_blunt_test.exs:79") 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /apps/opentelemetry_blunt/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule TestHelpers do 4 | def remove_blunt_handlers() do 5 | Enum.each(:telemetry.list_handlers([:blunt]), fn handler -> 6 | :telemetry.detach(handler[:id]) 7 | end) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # ################################################################### 4 | # This is an example of all the configuration settings in Blunt. 5 | # 6 | # The values set here are for testing the libraries. 7 | # They are *not* recommendations. 8 | # ################################################################### 9 | config :blunt, 10 | log_when_compiling: false, 11 | dispatch_return: :response, 12 | documentation_output: false, 13 | create_jason_encoders: false, 14 | dispatch_strategy: Blunt.DispatchStrategy.Default, 15 | pipeline_resolver: Blunt.DispatchStrategy.PipelineResolver.Default, 16 | dispatch_context_configuration: Blunt.DispatchContext.DefaultConfiguration, 17 | schema_field_definitions: [ 18 | Blunt.Test.FieldTypes.EmailField 19 | ], 20 | type_spec_provider: nil, 21 | compiler_hooks: [ 22 | command: [], 23 | query: [], 24 | domain_event: [], 25 | value_object: [], 26 | entity: [] 27 | ] 28 | 29 | config :blunt_absinthe, 30 | dispatch_context_configuration: Blunt.Absinthe.Test.DispatchContextConfiguration 31 | 32 | config :blunt_absinthe_relay, :repo, Blunt.Repo 33 | 34 | # ################################################################### 35 | # BELOW HERE BE FOR INTERNAL TESTING ONLY 36 | # ################################################################### 37 | config :logger, :console, format: "[$level] $message\n", level: :warning 38 | 39 | config :blunt_toolkit, TestEventStore, 40 | column_data_type: "jsonb", 41 | username: "postgres", 42 | password: "postgres", 43 | hostname: "localhost", 44 | database: "cqrs_toolkit_eventstore", 45 | serializer: EventStore.JsonbSerializer, 46 | types: EventStore.PostgresTypes 47 | 48 | config :eventstore, :event_stores, [EventStore] 49 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BluntU.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | apps_path: "apps", 7 | version: "0.1.0", 8 | start_permanent: Mix.env() == :prod, 9 | deps: deps(), 10 | consolidate_protocols: Mix.env() != :test 11 | ] 12 | end 13 | 14 | # Dependencies listed here are available only for this 15 | # project and cannot be accessed from applications inside 16 | # the apps folder. 17 | # 18 | # Run "mix help deps" for examples and options. 19 | defp deps do 20 | [] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -z "${HEX_API_KEY}" ]]; then 4 | echo "HEX_API_KEY is not set" 5 | exit 1 6 | fi 7 | 8 | publish () { 9 | pushd apps/$1 10 | cp ../../VERSION ./__VERSION 11 | mix deps.get 12 | mix hex.publish package --yes --replace 13 | popd 14 | } 15 | 16 | publish "blunt_data" 17 | publish "blunt" 18 | publish "blunt_ddd" 19 | publish "blunt_absinthe" 20 | publish "blunt_absinthe_relay" 21 | -------------------------------------------------------------------------------- /tools/setup-dev-env: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | git fetch --recurse-submodules 3 | asdf install 4 | 5 | --------------------------------------------------------------------------------