diff --git a/lib/hanami/cli/commands/app.rb b/lib/hanami/cli/commands/app.rb index 051463cf..ea3ad300 100644 --- a/lib/hanami/cli/commands/app.rb +++ b/lib/hanami/cli/commands/app.rb @@ -41,11 +41,12 @@ def self.extended(base) end register "generate", aliases: ["g"] do |prefix| - prefix.register "slice", Generate::Slice prefix.register "action", Generate::Action - prefix.register "view", Generate::View - prefix.register "part", Generate::Part prefix.register "component", Generate::Component + prefix.register "slice", Generate::Slice + prefix.register "operation", Generate::Operation + prefix.register "part", Generate::Part + prefix.register "view", Generate::View end end end diff --git a/lib/hanami/cli/commands/app/generate/operation.rb b/lib/hanami/cli/commands/app/generate/operation.rb new file mode 100644 index 00000000..ad4fb374 --- /dev/null +++ b/lib/hanami/cli/commands/app/generate/operation.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "dry/inflector" +require "dry/files" +require "shellwords" +require_relative "../../../naming" +require_relative "../../../errors" + +module Hanami + module CLI + module Commands + module App + module Generate + # @since 2.2.0 + # @api private + class Operation < App::Command + argument :name, required: true, desc: "Operation name" + option :slice, required: false, desc: "Slice name" + + example [ + %(books.add (MyApp::Books::Add)), + %(books.add --slice=admin (Admin::Books::Add)), + ] + attr_reader :generator + private :generator + + # @since 2.2.0 + # @api private + def initialize( + fs:, inflector:, + generator: Generators::App::Operation.new(fs: fs, inflector: inflector), + **opts + ) + super(fs: fs, inflector: inflector, **opts) + @generator = generator + end + + # @since 2.2.0 + # @api private + def call(name:, slice: nil, **) + slice = inflector.underscore(Shellwords.shellescape(slice)) if slice + + generator.call(app.namespace, name, slice) + end + end + end + end + end + end +end diff --git a/lib/hanami/cli/generators/app/operation.rb b/lib/hanami/cli/generators/app/operation.rb new file mode 100644 index 00000000..52754dd1 --- /dev/null +++ b/lib/hanami/cli/generators/app/operation.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "erb" +require "dry/files" +require_relative "../../errors" + +module Hanami + module CLI + module Generators + module App + # @since 2.2.0 + # @api private + class Operation + # @since 2.2.0 + # @api private + def initialize(fs:, inflector:, out: $stdout) + @fs = fs + @inflector = inflector + @out = out + end + + # @since 2.2.0 + # @api private + def call(app, key, slice) + context = OperationContext.new(inflector, app, slice, key) + + if slice + generate_for_slice(context, slice) + else + generate_for_app(context) + end + end + + private + + attr_reader :fs, :inflector, :out + + def generate_for_slice(context, slice) + slice_directory = fs.join("slices", slice) + raise MissingSliceError.new(slice) unless fs.directory?(slice_directory) + + if context.namespaces.any? + fs.mkdir(directory = fs.join(slice_directory, context.namespaces)) + fs.write(fs.join(directory, "#{context.name}.rb"), t("nested_slice_operation.erb", context)) + else + fs.mkdir(directory = fs.join(slice_directory)) + fs.write(fs.join(directory, "#{context.name}.rb"), t("top_level_slice_operation.erb", context)) + out.puts(" Note: We generated a top-level operation. To generate into a directory, add a namespace: `my_namespace.#{context.name}`") + end + end + + def generate_for_app(context) + if context.namespaces.any? + fs.mkdir(directory = fs.join("app", context.namespaces)) + fs.write(fs.join(directory, "#{context.name}.rb"), t("nested_app_operation.erb", context)) + else + fs.mkdir(directory = fs.join("app")) + out.puts(" Note: We generated a top-level operation. To generate into a directory, add a namespace: `my_namespace.#{context.name}`") + fs.write(fs.join(directory, "#{context.name}.rb"), t("top_level_app_operation.erb", context)) + end + end + + def template(path, context) + require "erb" + + ERB.new( + File.read(__dir__ + "/operation/#{path}") + ).result(context.ctx) + end + + alias_method :t, :template + end + end + end + end +end diff --git a/lib/hanami/cli/generators/app/operation/nested_app_operation.erb b/lib/hanami/cli/generators/app/operation/nested_app_operation.erb new file mode 100644 index 00000000..f0b7a131 --- /dev/null +++ b/lib/hanami/cli/generators/app/operation/nested_app_operation.erb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module <%= camelized_app_name %> +<%= module_namespace_declaration %> +<%= module_namespace_offset %>class <%= camelized_name %> < <%= camelized_app_name %>::Operation +<%= module_namespace_offset %> def call +<%= module_namespace_offset %> end +<%= module_namespace_offset %>end +<%= module_namespace_end %> +end diff --git a/lib/hanami/cli/generators/app/operation/nested_slice_operation.erb b/lib/hanami/cli/generators/app/operation/nested_slice_operation.erb new file mode 100644 index 00000000..a9a448ad --- /dev/null +++ b/lib/hanami/cli/generators/app/operation/nested_slice_operation.erb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module <%= camelized_slice_name %> +<%= module_namespace_declaration %> +<%= module_namespace_offset %>class <%= camelized_name %> < <%= camelized_slice_name %>::Operation +<%= module_namespace_offset %> def call +<%= module_namespace_offset %> end +<%= module_namespace_offset %>end +<%= module_namespace_end %> +end diff --git a/lib/hanami/cli/generators/app/operation/top_level_app_operation.erb b/lib/hanami/cli/generators/app/operation/top_level_app_operation.erb new file mode 100644 index 00000000..0ce59f47 --- /dev/null +++ b/lib/hanami/cli/generators/app/operation/top_level_app_operation.erb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module <%= camelized_app_name %> +<%= module_namespace_offset %>class <%= camelized_name %> < <%= camelized_app_name %>::Operation +<%= module_namespace_offset %> def call +<%= module_namespace_offset %> end +<%= module_namespace_offset %>end +end diff --git a/lib/hanami/cli/generators/app/operation/top_level_slice_operation.erb b/lib/hanami/cli/generators/app/operation/top_level_slice_operation.erb new file mode 100644 index 00000000..240448ab --- /dev/null +++ b/lib/hanami/cli/generators/app/operation/top_level_slice_operation.erb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module <%= camelized_slice_name %> +<%= module_namespace_offset %>class <%= camelized_name %> < <%= camelized_slice_name %>::Operation +<%= module_namespace_offset %> def call +<%= module_namespace_offset %> end +<%= module_namespace_offset %>end +end diff --git a/lib/hanami/cli/generators/app/operation_context.rb b/lib/hanami/cli/generators/app/operation_context.rb new file mode 100644 index 00000000..03ac07a2 --- /dev/null +++ b/lib/hanami/cli/generators/app/operation_context.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "slice_context" +require "dry/files/path" + +module Hanami + module CLI + module Generators + # @since 2.2.0 + # @api private + module App + # @since 2.2.0 + # @api private + class OperationContext < SliceContext + # TODO: move these constants somewhere that will let us reuse them + KEY_SEPARATOR = %r{\.|/} + private_constant :KEY_SEPARATOR + + NAMESPACE_SEPARATOR = "::" + private_constant :NAMESPACE_SEPARATOR + + INDENTATION = " " + private_constant :INDENTATION + + OFFSET = INDENTATION + private_constant :OFFSET + + # @since 2.2.0 + # @api private + attr_reader :key + + # @since 2.2.0 + # @api private + def initialize(inflector, app, slice, key) + @key = key + super(inflector, app, slice, nil) + end + + # @since 2.2.0 + # @api private + def namespaces + @namespaces ||= key.split(KEY_SEPARATOR)[..-2] + end + + # @since 2.2.0 + # @api private + def name + @name ||= key.split(KEY_SEPARATOR)[-1] + end + + # @api private + # @since 2.2.0 + # @api private + def camelized_name + inflector.camelize(name) + end + + # @since 2.2.0 + # @api private + def module_namespace_declaration + namespaces.each_with_index.map { |token, i| + "#{OFFSET}#{INDENTATION * i}module #{inflector.camelize(token)}" + }.join($/) + end + + # @since 2.2.0 + # @api private + def module_namespace_end + namespaces.each_with_index.map { |_, i| + "#{OFFSET}#{INDENTATION * i}end" + }.reverse.join($/) + end + + # @since 2.2.0 + # @api private + def module_namespace_offset + "#{OFFSET}#{INDENTATION * namespaces.count}" + end + end + end + end + end +end diff --git a/lib/hanami/cli/generators/app/slice.rb b/lib/hanami/cli/generators/app/slice.rb index 6d1624e3..fcd90861 100644 --- a/lib/hanami/cli/generators/app/slice.rb +++ b/lib/hanami/cli/generators/app/slice.rb @@ -31,6 +31,7 @@ def call(app, slice, url, context: SliceContext.new(inflector, app, slice, url)) fs.write(fs.join(directory, "view.rb"), t("view.erb", context)) fs.write(fs.join(directory, "views", "helpers.rb"), t("helpers.erb", context)) fs.write(fs.join(directory, "templates", "layouts", "app.html.erb"), t("app_layout.erb", context)) + fs.write(fs.join(directory, "operation.rb"), t("operation.erb", context)) if context.bundled_assets? fs.write(fs.join(directory, "assets", "js", "app.js"), t("app_js.erb", context)) diff --git a/lib/hanami/cli/generators/app/slice/operation.erb b/lib/hanami/cli/generators/app/slice/operation.erb new file mode 100644 index 00000000..7501188f --- /dev/null +++ b/lib/hanami/cli/generators/app/slice/operation.erb @@ -0,0 +1,7 @@ +# auto_register: false +# frozen_string_literal: true + +module <%= camelized_slice_name %> + class Operation < <%= camelized_app_name %>::Operation + end +end diff --git a/lib/hanami/cli/generators/context.rb b/lib/hanami/cli/generators/context.rb index eb57b63b..fed0e925 100644 --- a/lib/hanami/cli/generators/context.rb +++ b/lib/hanami/cli/generators/context.rb @@ -92,6 +92,12 @@ def bundled_assets? Hanami.bundled?("hanami-assets") end + # @since 2.2.0 + # @api private + def bundled_dry_monads? + Hanami.bundled?("dry-monads") + end + # @since 2.1.0 # @api private # diff --git a/lib/hanami/cli/generators/gem/app.rb b/lib/hanami/cli/generators/gem/app.rb index a2b27fd5..440de26b 100644 --- a/lib/hanami/cli/generators/gem/app.rb +++ b/lib/hanami/cli/generators/gem/app.rb @@ -68,6 +68,8 @@ def generate_app(app, context) # rubocop:disable Metrics/AbcSize fs.write("app/assets/images/favicon.ico", file("favicon.ico")) end + fs.write("app/operation.rb", t("operation.erb", context)) + fs.write("public/404.html", file("404.html")) fs.write("public/500.html", file("500.html")) end diff --git a/lib/hanami/cli/generators/gem/app/action.erb b/lib/hanami/cli/generators/gem/app/action.erb index c573be65..2126d8a5 100644 --- a/lib/hanami/cli/generators/gem/app/action.erb +++ b/lib/hanami/cli/generators/gem/app/action.erb @@ -2,8 +2,11 @@ # frozen_string_literal: true require "hanami/action" +require "dry/monads" module <%= camelized_app_name %> class Action < Hanami::Action + # Provide `Success` and `Failure` for pattern matching on operation results + include Dry::Monads[:result] end end diff --git a/lib/hanami/cli/generators/gem/app/gemfile.erb b/lib/hanami/cli/generators/gem/app/gemfile.erb index 3a43f0c0..771359f4 100644 --- a/lib/hanami/cli/generators/gem/app/gemfile.erb +++ b/lib/hanami/cli/generators/gem/app/gemfile.erb @@ -12,6 +12,7 @@ source "https://rubygems.org" <%= hanami_gem("view") %> gem "dry-types", "~> 1.0", ">= 1.6.1" +gem "dry-operation", github: "dry-rb/dry-operation" gem "puma" gem "rake" diff --git a/lib/hanami/cli/generators/gem/app/operation.erb b/lib/hanami/cli/generators/gem/app/operation.erb new file mode 100644 index 00000000..d0449f2c --- /dev/null +++ b/lib/hanami/cli/generators/gem/app/operation.erb @@ -0,0 +1,9 @@ +# auto_register: false +# frozen_string_literal: true + +require "dry/operation" + +module <%= camelized_app_name %> + class Operation < Dry::Operation + end +end diff --git a/spec/unit/hanami/cli/commands/app/generate/operation_spec.rb b/spec/unit/hanami/cli/commands/app/generate/operation_spec.rb new file mode 100644 index 00000000..b190f68a --- /dev/null +++ b/spec/unit/hanami/cli/commands/app/generate/operation_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "hanami" + +RSpec.describe Hanami::CLI::Commands::App::Generate::Operation, :app do + subject { described_class.new(fs: fs, inflector: inflector, generator: generator) } + + let(:out) { StringIO.new } + let(:fs) { Hanami::CLI::Files.new(memory: true, out: out) } + let(:inflector) { Dry::Inflector.new } + let(:generator) { Hanami::CLI::Generators::App::Operation.new(fs: fs, inflector: inflector, out: out) } + let(:app) { Hanami.app.namespace } + let(:dir) { inflector.underscore(app) } + + def output + out.rewind && out.read.chomp + end + + context "generating for app" do + it "generates an operation without a namespace, with a recommendation" do + subject.call(name: "add_book") + + operation_file = <<~EXPECTED + # frozen_string_literal: true + + module Test + class AddBook < Test::Operation + def call + end + end + end + EXPECTED + + expect(fs.read("app/add_book.rb")).to eq(operation_file) + expect(output).to include("Created app/add_book.rb") + expect(output).to include( + " Note: We generated a top-level operation. " \ + "To generate into a directory, add a namespace: `my_namespace.add_book`" + ) + end + + it "generates a operation in a deep namespace with default separator" do + subject.call(name: "admin.books.add") + + operation_file = <<~EXPECTED + # frozen_string_literal: true + + module Test + module Admin + module Books + class Add < Test::Operation + def call + end + end + end + end + end + EXPECTED + + expect(fs.read("app/admin/books/add.rb")).to eq(operation_file) + expect(output).to include("Created app/admin/books/add.rb") + end + + it "generates an operation in a deep namespace with slash separators" do + subject.call(name: "admin/books/add") + + operation_file = <<~EXPECTED + # frozen_string_literal: true + + module Test + module Admin + module Books + class Add < Test::Operation + def call + end + end + end + end + end + EXPECTED + + expect(fs.read("app/admin/books/add.rb")).to eq(operation_file) + expect(output).to include("Created app/admin/books/add.rb") + end + end + + context "generating for a slice" do + it "generates a operation in a top-level namespace, with recommendation" do + fs.mkdir("slices/main") + subject.call(name: "add_book", slice: "main") + + operation_file = <<~EXPECTED + # frozen_string_literal: true + + module Main + class AddBook < Main::Operation + def call + end + end + end + EXPECTED + + expect(fs.read("slices/main/add_book.rb")).to eq(operation_file) + expect(output).to include("Created slices/main/add_book.rb") + expect(output).to include( + " Note: We generated a top-level operation. " \ + "To generate into a directory, add a namespace: `my_namespace.add_book`" + ) + end + + it "generates a operation in a nested namespace" do + fs.mkdir("slices/main") + subject.call(name: "admin.books.add", slice: "main") + + operation_file = <<~EXPECTED + # frozen_string_literal: true + + module Main + module Admin + module Books + class Add < Main::Operation + def call + end + end + end + end + end + EXPECTED + + expect(fs.read("slices/main/admin/books/add.rb")).to eq(operation_file) + expect(output).to include("Created slices/main/admin/books/add.rb") + end + end +end diff --git a/spec/unit/hanami/cli/commands/app/generate/slice_spec.rb b/spec/unit/hanami/cli/commands/app/generate/slice_spec.rb index 8bd8e99d..a2611854 100644 --- a/spec/unit/hanami/cli/commands/app/generate/slice_spec.rb +++ b/spec/unit/hanami/cli/commands/app/generate/slice_spec.rb @@ -108,6 +108,18 @@ module Helpers expect(fs.read("slices/#{slice}/views/helpers.rb")).to eq(helpers) expect(output).to include("Created slices/#{slice}/views/helpers.rb") + operation = <<~RUBY + # auto_register: false + # frozen_string_literal: true + + module Admin + class Operation < Bookshelf::Operation + end + end + RUBY + expect(fs.read("slices/#{slice}/operation.rb")).to eq(operation) + expect(output).to include("Created slices/#{slice}/operation.rb") + layout = <<~ERB @@ -230,6 +242,31 @@ class Routes < Hanami::Routes end end + context "with dry-monads bundled" do + before do + allow(Hanami).to receive(:bundled?).with("dry-monads").and_return(bundled_assets) + end + + it "generates a slice with an operation that includes dry-monads result" do + within_application_directory do + subject.call(name: slice) + + action = <<~CODE + # auto_register: false + # frozen_string_literal: true + + module Admin + class Action < #{app}::Action + end + end + CODE + + expect(fs.read("slices/#{slice}/action.rb")).to eq(action) + expect(output).to include("Created slices/#{slice}/action.rb") + end + end + end + private def within_application_directory diff --git a/spec/unit/hanami/cli/commands/app/generate/view_spec.rb b/spec/unit/hanami/cli/commands/app/generate/view_spec.rb index 33909bf1..4abf30ad 100644 --- a/spec/unit/hanami/cli/commands/app/generate/view_spec.rb +++ b/spec/unit/hanami/cli/commands/app/generate/view_spec.rb @@ -53,7 +53,7 @@ class Index < Test::View EXPECTED expect(fs.read("app/templates/users/index.html.erb")).to eq(template_file) - expect(output).to include("Created app/views/users/index.rb") + expect(output).to include("Created app/templates/users/index.html.erb") end end @@ -88,7 +88,7 @@ class Index < Test::View EXPECTED expect(fs.read("app/templates/special/users/index.html.erb")).to eq(template_file) - expect(output).to include("Created app/views/special/users/index.rb") + expect(output).to include("Created app/templates/special/users/index.html.erb") end end end @@ -124,7 +124,7 @@ class Index < Main::View EXPECTED expect(fs.read("slices/main/templates/users/index.html.erb")).to eq(template_file) - expect(output).to include("Created slices/main/views/users/index.rb") + expect(output).to include("Created slices/main/templates/users/index.html.erb") end end end diff --git a/spec/unit/hanami/cli/commands/gem/new_spec.rb b/spec/unit/hanami/cli/commands/gem/new_spec.rb index c5d78723..f6c86fe8 100644 --- a/spec/unit/hanami/cli/commands/gem/new_spec.rb +++ b/spec/unit/hanami/cli/commands/gem/new_spec.rb @@ -128,6 +128,7 @@ gem "hanami-view", "#{hanami_version}" gem "dry-types", "~> 1.0", ">= 1.6.1" + gem "dry-operation", github: "dry-rb/dry-operation" gem "puma" gem "rake" @@ -337,9 +338,12 @@ class Routes < Hanami::Routes # frozen_string_literal: true require "hanami/action" + require "dry/monads" module #{inflector.camelize(app)} class Action < Hanami::Action + # Provide `Success` and `Failure` for pattern matching on operation results + include Dry::Monads[:result] end end EXPECTED @@ -435,6 +439,21 @@ module Types expect(fs.read("lib/#{app}/types.rb")).to eq(types) expect(output).to include("Created lib/bookshelf/types.rb") + # app/operation.rb + action = <<~EXPECTED + # auto_register: false + # frozen_string_literal: true + + require "dry/operation" + + module #{inflector.camelize(app)} + class Operation < Dry::Operation + end + end + EXPECTED + expect(fs.read("app/operation.rb")).to eq(action) + expect(output).to include("Created app/operation.rb") + # public/ error pages expect(fs.read("public/404.html")).to include %(The page you were looking for doesn’t exist (404)) expect(fs.read("public/500.html")).to include %(We’re sorry, but something went wrong (500)) @@ -476,6 +495,7 @@ module Types gem "hanami-view", github: "hanami/view", branch: "main" gem "dry-types", "~> 1.0", ">= 1.6.1" + gem "dry-operation", github: "dry-rb/dry-operation" gem "puma" gem "rake"