rider-kick 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -8
  3. data/lib/generators/rider_kick/clean_arch_generator.rb +1 -0
  4. data/lib/generators/rider_kick/entity_type_mapping_spec.rb +52 -0
  5. data/lib/generators/rider_kick/repositories_contract_spec.rb +62 -0
  6. data/lib/generators/rider_kick/scaffold_generator_builder_uploaders_spec.rb +54 -0
  7. data/lib/generators/rider_kick/scaffold_generator_contracts_spec.rb +69 -0
  8. data/lib/generators/rider_kick/scaffold_generator_contracts_with_scope_spec.rb +54 -0
  9. data/lib/generators/rider_kick/scaffold_generator_idempotent_spec.rb +59 -0
  10. data/lib/generators/rider_kick/scaffold_generator_success_spec.rb +82 -0
  11. data/lib/generators/rider_kick/scaffold_generator_with_scope_spec.rb +55 -0
  12. data/lib/generators/rider_kick/structure_generator.rb +33 -0
  13. data/lib/generators/rider_kick/structure_generator_spec.rb +20 -0
  14. data/lib/generators/rider_kick/structure_generator_success_spec.rb +36 -0
  15. data/lib/generators/rider_kick/templates/db/structures/example.yaml.tt +114 -82
  16. data/lib/generators/rider_kick/templates/domains/core/repositories/abstract_repository.rb.tt +0 -12
  17. data/lib/generators/rider_kick/templates/domains/core/repositories/list.rb.tt +2 -2
  18. data/lib/generators/rider_kick/templates/domains/core/utils/abstract_utils.rb.tt +29 -0
  19. data/lib/generators/rider_kick/templates/domains/core/utils/request_methods.rb.tt +2 -1
  20. data/lib/rider-kick.rb +1 -0
  21. data/lib/rider_kick/entities/failure_details.rb +22 -14
  22. data/lib/rider_kick/entities/failure_details_spec.rb +22 -0
  23. data/lib/rider_kick/matchers/use_case_result_edge_spec.rb +28 -0
  24. data/lib/rider_kick/use_cases/abstract_use_case_spec.rb +57 -0
  25. data/lib/rider_kick/version.rb +1 -1
  26. metadata +223 -51
  27. data/.rspec +0 -3
  28. data/.rubocop.yml +0 -1141
  29. data/Rakefile +0 -12
  30. data/lib/rider_kick/builders/abstract_active_record_entity_builder_spec.rb +0 -116
  31. data/lib/rider_kick/matchers/use_case_result_spec.rb +0 -64
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'tmpdir'
5
+ require 'generators/rider_kick/structure_generator'
6
+
7
+ RSpec.describe 'rider_kick:structure generator' do
8
+ let(:klass) { RiderKick::Structure }
9
+
10
+ it 'mengangkat Thor::Error jika app/domains belum ada' do
11
+ Dir.mktmpdir do |dir|
12
+ Dir.chdir(dir) do
13
+ expect(Dir.exist?('app/domains')).to be false
14
+ instance = klass.new(['Models::User']) # ← instansiasi dengan argumen
15
+ expect { instance.generate_use_case } # ← panggil task langsung
16
+ .to raise_error(Thor::Error, /clean_arch.*--setup/i)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'tmpdir'
5
+ require 'active_support/inflector'
6
+ require 'generators/rider_kick/structure_generator'
7
+ require 'ostruct'
8
+
9
+ RSpec.describe 'rider_kick:structure generator (success)' do
10
+ let(:klass) { RiderKick::Structure }
11
+
12
+ it 'membuat db/structures/<resource>_structure.yaml ketika environment valid' do
13
+ Dir.mktmpdir do |dir|
14
+ Dir.chdir(dir) do
15
+ # 1) siapkan struktur minimal Clean Arch
16
+ FileUtils.mkdir_p('app/domains/core/use_cases')
17
+ FileUtils.mkdir_p('app/models/models')
18
+
19
+ # 2) stub namespace & model + metadata kolom
20
+
21
+ # 3) jalankan generator
22
+ instance = klass.new(['Models::User', 'actor:owner']) # ← pakai token
23
+ instance.generate_use_case
24
+
25
+ # 4) verifikasi file output
26
+ expect(File).to exist('db/structures/users_structure.yaml')
27
+ yaml = File.read('db/structures/users_structure.yaml')
28
+ expect(yaml).to include('model: Models::User')
29
+ expect(yaml).to include('resource_name: users')
30
+ expect(yaml).to include('actor: owner')
31
+ expect(yaml).to include('- name') # field dari kolom
32
+ expect(yaml).to include('- price')
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,99 +2,131 @@ model: <%= @model_class %>
2
2
  resource_name: <%= @scope_path %>
3
3
  resource_owner_id: <%= @resource_owner_id %>
4
4
  actor: <%= @actor %>
5
+
5
6
  fields:
6
- <% @model_class.columns.each do |field| -%>
7
- - <%= field.name.to_s %>
7
+ <% @fields.each do |f| -%>
8
+ - <%= f %>
8
9
  <% end -%>
9
- <% @uploaders.each do |field| -%>
10
- - <%= field %>
10
+ <% @uploaders.each do |f| -%>
11
+ - <%= f %>
11
12
  <% end -%>
13
+
12
14
  uploaders:
13
- <% @uploaders.each do |field| -%>
14
- - <%= field %>
15
+ <% @uploaders.each do |f| -%>
16
+ - <%= f %>
15
17
  <% end -%>
18
+
16
19
  search_able:
17
- <% contract_fields.each do |field| -%>
18
- <% if ['title', 'name'].include?(field) -%>
19
- - <%= field %>
20
+ <% @search_able.each do |f| -%>
21
+ - <%= f %>
22
+ <% end -%>
23
+
24
+ # ---- Enriched metadata (opsional, untuk tooling/insight) ----
25
+ schema:
26
+ columns:
27
+ <% columns_meta.each do |c| -%>
28
+ - name: <%= c[:name] %>
29
+ type: <%= c[:type] %>
30
+ sql_type: <%= c[:sql_type] %>
31
+ null: <%= c[:null] %>
32
+ <% if c[:default].present? -%>
33
+ default: <%= c[:default].inspect %>
34
+ <% end -%>
35
+ <% if c[:precision] || c[:scale] -%>
36
+ precision: <%= c[:precision] || 'null' %>
37
+ scale: <%= c[:scale] || 'null' %>
38
+ <% end -%>
39
+ <% if c[:limit] -%>
40
+ limit: <%= c[:limit] %>
41
+ <% end -%>
42
+ <% end -%>
43
+ foreign_keys:
44
+ <% (fkeys_meta.presence || []).each do |fk| -%>
45
+ - column: <%= fk[:column] %>
46
+ to_table: <%= fk[:to_table] %>
47
+ <% end -%>
48
+ indexes:
49
+ <% (indexes_meta.presence || []).each do |ix| -%>
50
+ - columns: [<%= ix[:columns].join(', ') %>]
51
+ unique: <%= ix[:unique] %>
52
+ <% end -%>
53
+ enums:
54
+ <% if enums_meta.present? -%>
55
+ <% enums_meta.each do |name, map| -%>
56
+ <%= name %>: <%= map.keys %>
20
57
  <% end -%>
58
+ <% else -%>
59
+ {}
21
60
  <% end -%>
61
+
22
62
  controllers:
23
- list_fields:
24
- <% @fields.each do |field| -%>
25
- - <%= field %>
26
- <% end -%>
27
- show_fields:
28
- <% @model_class.columns.each do |field| -%>
29
- - <%= field.name.to_s %>
30
- <% end -%>
31
- <% @uploaders.each do |field| -%>
32
- - <%= field %>
33
- <% end -%>
34
- form_fields:
35
- <% (@fields).each do |field| -%>
36
- - name: <%= field %>
37
- type: string
38
- <% end -%>
39
- <% (@uploaders).each do |field| -%>
40
- <% if is_singular?(field) -%>
41
- - name: <%= field %>
42
- type: file
43
- <% else -%>
44
- - name: <%= field %>
45
- type: files
63
+ list_fields:
64
+ <% @fields.each do |f| -%>
65
+ - <%= f %>
46
66
  <% end -%>
67
+ show_fields:
68
+ <% (@columns.map { _1[:name] } + @uploaders).uniq.each do |f| -%>
69
+ - <%= f %>
47
70
  <% end -%>
48
- domains:
49
- action_list:
50
- use_case:
51
- contract:
52
- action_fetch_by_id:
53
- use_case:
54
- contract:
55
- - required(:id).filled(:string)
56
- action_create:
57
- use_case:
58
- contract:
59
- <% (@fields + @uploaders).each do |field| -%>
60
- <% column_type = get_column_type(field) -%>
61
- <% dry_type = @type_mapping[column_type.to_s] || ':string' -%>
62
- <% if @uploaders.include?(field) -%>
63
- <% if is_singular?(field) -%>
64
- - optional(:<%= field %>).maybe(<%= dry_type %>)
65
- <% else -%>
66
- - optional(:<%= field %>).maybe(:array)
71
+ form_fields:
72
+ <% @fields.each do |f| -%>
73
+ - name: <%= f %>
74
+ type: <%= get_column_type(f) %>
67
75
  <% end -%>
68
- <% else -%>
69
- - required(:<%= field %>).filled(<%= dry_type %>)
70
- <% end -%>
71
- <% end -%>
72
- action_update:
73
- use_case:
74
- contract:
75
- - required(:id).filled(:string)
76
- <% (@fields + @uploaders).each do |field| -%>
77
- <% column_type = get_column_type(field) -%>
78
- <% dry_type = @type_mapping[column_type.to_s] || ':string' -%>
79
- <% if @uploaders.include?(field) -%>
80
- <% if is_singular?(field) -%>
81
- - optional(:<%= field %>).maybe(<%= dry_type %>)
82
- <% else -%>
83
- - optional(:<%= field %>).maybe(:array)
76
+ <% @uploaders.each do |f| -%>
77
+ - name: <%= f %>
78
+ type: <%= is_singular?(f) ? 'file' : 'files' %>
84
79
  <% end -%>
85
- <% else -%>
86
- - optional(:<%= field %>).maybe(<%= dry_type %>)
87
- <% end -%>
88
- <% end -%>
89
- action_destroy:
90
- use_case:
91
- contract:
92
- - required(:id).filled(:string)
80
+
81
+ domains:
82
+ action_list:
83
+ use_case:
84
+ contract:
85
+ <% @search_able.each do |f| -%>
86
+ # optional search fields: <%= f %>
87
+ <% end -%>
88
+ action_fetch_by_id:
89
+ use_case:
90
+ contract:
91
+ - required(:id).filled(:string)
92
+ <% if @resource_owner_id.present? -%>
93
+ - required(:<%= @resource_owner_id %>).filled(:string)
94
+ <% end -%>
95
+
96
+ action_create:
97
+ use_case:
98
+ contract:
99
+ <% if @resource_owner_id.present? -%>
100
+ - required(:<%= @resource_owner_id %>).filled(:string)
101
+ <% end -%>
102
+ <% contract_lines_for_create.each do |line| -%>
103
+ <%= line %>
104
+ <% end -%>
105
+
106
+ action_update:
107
+ use_case:
108
+ contract:
109
+ - required(:id).filled(:string)
110
+ <% if @resource_owner_id.present? -%>
111
+ - required(:<%= @resource_owner_id %>).filled(:string)
112
+ <% end -%>
113
+ <% contract_lines_for_update.each do |line| -%>
114
+ <%= line %>
115
+ <% end -%>
116
+
117
+ action_destroy:
118
+ use_case:
119
+ contract:
120
+ - required(:id).filled(:string)
121
+ <% if @resource_owner_id.present? -%>
122
+ - required(:<%= @resource_owner_id %>).filled(:string)
123
+ <% end -%>
124
+
93
125
  entity:
94
- skipped_fields:
95
- - id
96
- - created_at
97
- - updated_at
98
- <% if @model_class.columns.map(&:name).include?(:type) -%>
99
- - type
100
- <% end -%>
126
+ skipped_fields:
127
+ - id
128
+ - created_at
129
+ - updated_at
130
+ <% if @columns.map { _1[:name] }.include?('type') -%>
131
+ - type
132
+ <% end -%>
@@ -7,18 +7,6 @@ class Core::Repositories::AbstractRepository
7
7
  extend(Core::Utils::RequestMethods)
8
8
  end
9
9
 
10
- def parse_response(response)
11
- begin
12
- res = JSON.parse(response.body)
13
- rescue => e
14
- return Failure e
15
- end
16
- unless ['200', '201', '202'].include?(response.code.to_s)
17
- return Failure Hashie::Mash.new(res)
18
- end
19
- Success Hashie::Mash.new(res)
20
- end
21
-
22
10
  def error_messages_for(record)
23
11
  record.errors.to_a.join(', ')
24
12
  end
@@ -12,8 +12,8 @@ class Core::Repositories::<%= @scope_class %>::<%= @repository_class%> < Core::R
12
12
  <% else -%>
13
13
  resources = <%= @model_class %>
14
14
  <% end -%>
15
- if @search.present?
16
- resources = resources.where('<%= @search_able.map { |field| "LOWER(#{field}) LIKE :search" }.join(' OR ')%>', search: "%#{search}%")
15
+ if @search.present? && @search_able.any?
16
+ resources = resources.where("<%= @search_able.map { |f| "LOWER(#{f}) LIKE :search" }.join(' OR ') %>", search: "%#{@search.to_s.downcase}%")
17
17
  end
18
18
  return Success Hashie::Mash.new(response: [], meta: {}) unless resources.present?
19
19
  pagy, results = pagy(resources.order(created_at: :desc), limit: @per_page, page: @page)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Core::Utils::AbstractUtils
4
+ include Dry::Monads[:result, :do]
5
+
6
+ def http
7
+ extend(Core::Utils::RequestMethods)
8
+ end
9
+
10
+ def error_messages_for(record)
11
+ record.errors.to_a.join(', ')
12
+ end
13
+
14
+ def build_errors(resource)
15
+ errors = []
16
+ resource.errors.each do |error|
17
+ errors << Core::Builders::Error.new(error.as_json).build
18
+ end
19
+ errors
20
+ end
21
+
22
+ def prepare!(params, sanitize: true)
23
+ if sanitize
24
+ Hashie::Mash.new(params.reject { |_, v| v.nil? || (v.is_a?(String) && v.blank?) })
25
+ else
26
+ Hashie::Mash.new(params)
27
+ end
28
+ end
29
+ end
@@ -76,6 +76,7 @@ module Core::Utils::RequestMethods
76
76
  if is_ssl
77
77
  http.use_ssl = true
78
78
  end
79
- http.request(request)
79
+ response = http.request(request)
80
+ parse_response(response)
80
81
  end
81
82
  end
data/lib/rider-kick.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'hashie'
3
4
  require 'rider_kick/entities/failure_details'
4
5
  require 'rider_kick/builders/abstract_active_record_entity_builder'
5
6
  require 'rider_kick/matchers/use_case_result'
@@ -1,4 +1,3 @@
1
- # typed: false
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require 'rider_kick/types'
@@ -7,23 +6,32 @@ require 'dry/struct'
7
6
  module RiderKick
8
7
  module Entities
9
8
  class FailureDetails < Dry::Struct
10
- failure_types = Types::Strict::String.enum(
11
- 'error',
12
- 'expectation_failed',
13
- 'not_found',
14
- 'unauthorized',
15
- 'unprocessable_entity'
16
- )
17
- attribute :type, failure_types
18
- attribute :message, Types::Strict::String
9
+ # enum + default HARUS: default dulu, baru enum (sesuai dry-types)
10
+ TYPE = Types::Coercible::Symbol
11
+ .default(:error)
12
+ .enum(:error, :expectation_failed, :not_found, :unauthorized, :unprocessable_entity)
13
+ private_constant :TYPE
14
+
15
+ attribute :type, TYPE
16
+ attribute :message, Types::Strict::String
19
17
  attribute :other_properties, Types::Strict::Hash.default({}.freeze)
20
18
 
21
- def self.from_array(array)
22
- new(message: 'failure 1, failure 2', other_properties: {}, type: 'error')
19
+ # Kumpulkan array pesan jadi satu kalimat, type default :error
20
+ def self.from_array(array, type: :error, **extras)
21
+ new(
22
+ type: type,
23
+ message: Array(array).map!(&:to_s).join(', '),
24
+ other_properties: extras
25
+ )
23
26
  end
24
27
 
25
- def self.from_string(string)
26
- new(message: string, other_properties: {}, type: 'error')
28
+ # Bungkus string jadi FailureDetails, type default :error
29
+ def self.from_string(string, type: :error, **extras)
30
+ new(
31
+ type: type,
32
+ message: string.to_s,
33
+ other_properties: extras
34
+ )
27
35
  end
28
36
  end
29
37
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe RiderKick::Entities::FailureDetails do
4
+ it 'from_array menggabungkan pesan secara dinamis' do
5
+ fd = described_class.from_array(%w[alpha beta gamma])
6
+ expect(fd.message).to eq('alpha, beta, gamma')
7
+ expect(fd.type).to eq(:error)
8
+ expect(fd.other_properties).to eq({})
9
+ end
10
+
11
+ it 'from_string membungkus string menjadi FailureDetails' do
12
+ fd = described_class.from_string('oops')
13
+ expect(fd.message).to eq('oops')
14
+ expect(fd.type).to eq(:error)
15
+ end
16
+
17
+ it 'bisa dibuat dengan default type error' do
18
+ fd = described_class.new(message: 'X') # sengaja tanpa :type
19
+ expect(fd.type).to eq(:error)
20
+ expect(fd.other_properties).to eq({})
21
+ end
22
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rider_kick/matchers/use_case_result'
4
+ require 'dry/monads'
5
+
6
+ RSpec.describe RiderKick::Matchers::UseCaseResult do
7
+ subject(:matcher) { described_class }
8
+
9
+ it 'melempar error untuk Failure value yang tidak didukung' do
10
+ weird = Object.new
11
+ expect {
12
+ matcher.call(Dry::Monads::Failure(weird)) { |m| m.failure { |_| :ok } }
13
+ }.to raise_error(ArgumentError, /Unexpected failure value/)
14
+ end
15
+
16
+ it 'meneruskan Entities::FailureDetails apa adanya' do
17
+ fd = RiderKick::Entities::FailureDetails.new(message: 'boom')
18
+ out = nil
19
+
20
+ RiderKick::Matchers::UseCaseResult.call(Dry::Monads::Failure(fd)) do |m|
21
+ m.success { |_| raise 'unexpected success' } # <- tambahkan handler success
22
+ m.failure { |v| out = v }
23
+ end
24
+
25
+ expect(out).to be_a(RiderKick::Entities::FailureDetails)
26
+ expect(out.message).to eq('boom')
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/validation'
4
+ require 'rider_kick/use_cases/abstract_use_case'
5
+
6
+ RSpec.describe RiderKick::UseCases::AbstractUseCase do
7
+ # Use case dummy untuk menguji .contract, .contract! dan build_parameter!
8
+ class DummyUseCase < RiderKick::UseCases::AbstractUseCase
9
+ contract do
10
+ params do
11
+ required(:name).filled(:string)
12
+ optional(:age).maybe(:integer)
13
+ end
14
+ end
15
+
16
+ include Dry::Monads::Do.for(:result)
17
+ def result
18
+ params = yield build_parameter!
19
+ Success(params)
20
+ end
21
+ end
22
+
23
+ describe '.contract/.contract!' do
24
+ it 'membangun dan menjalankan contract' do
25
+ contract = DummyUseCase.contract!(name: 'Kotaro')
26
+ expect(contract).to be_success
27
+ end
28
+ end
29
+
30
+ describe '#build_parameter!' do
31
+ it 'mengembalikan Success(Hashie::Mash) ketika valid' do
32
+ contract = DummyUseCase.contract!(name: 'Kotaro', age: 7)
33
+ use_case = DummyUseCase.new(contract)
34
+ result = use_case.build_parameter!
35
+ expect(result).to be_a(Dry::Monads::Success)
36
+ expect(result.value!).to respond_to(:name)
37
+ expect(result.value!.name).to eq('Kotaro')
38
+ end
39
+
40
+ it 'mengembalikan Failure(hash error) ketika tidak valid' do
41
+ contract = DummyUseCase.contract!(age: 'tujuh')
42
+ result = DummyUseCase.new(contract).build_parameter!
43
+ expect(result).to be_a(Dry::Monads::Failure)
44
+ expect(result.failure).to be_a(Hash)
45
+ expect(result.failure.keys).to include(:name) # name wajib
46
+ end
47
+ end
48
+
49
+ describe '#result' do
50
+ it 'menggunakan Do-notation dengan mulus' do
51
+ contract = DummyUseCase.contract!(name: 'Alam')
52
+ res = DummyUseCase.new(contract).result
53
+ expect(res).to be_success
54
+ expect(res.value!.name).to eq('Alam')
55
+ end
56
+ end
57
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RiderKick
4
- VERSION = '0.0.12'
4
+ VERSION = '0.0.13'
5
5
  public_constant :VERSION
6
6
  end