mk_framework 0.2.0 → 0.2.1

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.
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'optparse'
5
+
6
+ module MK
7
+ module Generator
8
+ class CLI
9
+ class Cancelled < StandardError; end
10
+
11
+ def self.run(argv = ARGV, input: $stdin, output: $stdout)
12
+ new(input: input, output: output).run(argv)
13
+ end
14
+
15
+ def initialize(input:, output:)
16
+ @input, @output = input, output
17
+ end
18
+
19
+ def run(argv)
20
+ arguments = argv.dup
21
+ inline = nil
22
+ help = false
23
+ parser = OptionParser.new do |options|
24
+ options.banner = 'Usage: mk_frame_init [DESTINATION] [--cli SPEC]'
25
+ options.on('--cli SPEC', 'Generate without prompts: app_name:blog, model_name:posts, fields:[title:string]') { |value| inline = value }
26
+ options.on('-h', '--help', 'Show usage') { help = true }
27
+ end
28
+ parser.parse!(arguments)
29
+ if help
30
+ @output.puts parser
31
+ @output.puts 'Types: ' + Configuration::TYPES.keys.join(', ')
32
+ return 0
33
+ end
34
+ raise InvalidInput, parser.banner if arguments.length > 1
35
+ if inline
36
+ config = Options.parse(inline)
37
+ return generate(config, arguments.first || config.app_name)
38
+ end
39
+
40
+ @output.puts 'MK app generator — one resource, model, create controller, and handler.'
41
+ @output.puts 'Press Ctrl-C to cancel. All fields are required; id and timestamps are automatic.'
42
+ app = validated('1. App name (snake_case)') { |value| Configuration.app_name!(value) }
43
+ model = validated('2. Model name (singular snake_case)') { |value| Configuration.model_name!(value) }
44
+ resource = validated('3. Resource/table name (plural snake_case)', default: Naming.plural(model)) do |value|
45
+ Configuration.identifier!(value, label: 'Resource name')
46
+ end
47
+ fields = collect_fields
48
+ config = Configuration.new(app_name: app, model_name: model, resource: resource, fields: fields)
49
+ destination = File.expand_path(arguments.first || app)
50
+ @output.puts "\nApp: #{config.namespace}; model: #{config.model_class}; route: POST /#{resource}"
51
+ @output.puts "Fields: #{fields.map { |field| "#{field[:name]}:#{field[:type]}" }.join(', ')}"
52
+ @output.puts "Directory: #{destination}"
53
+ answer = validated('5. Generate these files? (y/n)', default: 'y') do |value|
54
+ raise InvalidInput, 'Enter y or n.' unless %w[y n yes no].include?(value.downcase)
55
+ value.downcase
56
+ end
57
+ raise Cancelled if %w[n no].include?(answer)
58
+ generate(config, destination)
59
+ rescue Cancelled, Interrupt
60
+ @output.puts "\nCancelled."
61
+ 1
62
+ rescue InvalidInput, OptionParser::ParseError, SystemCallError => error
63
+ @output.puts "Error: #{error.message}"
64
+ 1
65
+ end
66
+
67
+ private
68
+
69
+ def generate(config, destination)
70
+ destination = Project.new(config).generate(destination)
71
+ @output.puts "\nCreated #{destination}\nNext steps:"
72
+ @output.puts " cd #{Shellwords.escape(destination)}"
73
+ @output.puts " bundle install\n bundle exec rake db:migrate\n bundle exec rake routes\n bundle exec rspec"
74
+ 0
75
+ end
76
+
77
+ def ask(prompt, default: nil)
78
+ @output.print "#{prompt}#{default ? " [#{default}]" : ''}: "
79
+ @output.flush
80
+ line = @input.gets
81
+ raise Cancelled unless line
82
+ value = line.strip
83
+ value.empty? && default ? default : value
84
+ end
85
+
86
+ def validated(prompt, default: nil)
87
+ loop do
88
+ value = ask(prompt, default: default)
89
+ begin
90
+ return yield(value)
91
+ rescue InvalidInput => error
92
+ @output.puts error.message
93
+ end
94
+ end
95
+ end
96
+
97
+ def collect_fields
98
+ fields = []
99
+ @output.puts '4. Add fields. Leave the next field name blank when finished.'
100
+ loop do
101
+ name = validated('Field name') do |value|
102
+ if value.empty?
103
+ raise InvalidInput, 'Add at least one field.' if fields.empty?
104
+ else
105
+ Configuration.field!(value, 'string')
106
+ raise InvalidInput, 'That field already exists.' if fields.any? { |field| field[:name] == value }
107
+ end
108
+ value
109
+ end
110
+ break if name.empty?
111
+ types = Configuration::TYPES.keys
112
+ @output.puts types.each_with_index.map { |type, index| " #{index + 1}. #{type}" }.join("\n")
113
+ type = validated('Type (number or name)', default: 'string') do |value|
114
+ selected = value.match?(/\A[1-9][0-9]*\z/) ? types[value.to_i - 1] : value
115
+ raise InvalidInput, 'Choose a listed type.' unless types.include?(selected)
116
+ selected
117
+ end
118
+ fields << {name: name, type: type}
119
+ end
120
+ fields
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MK
4
+ module Generator
5
+ class InvalidInput < ArgumentError; end
6
+
7
+ class Configuration
8
+ TYPES = {
9
+ 'string' => ['String', 'String', 'Example'],
10
+ 'text' => ['String', 'String', 'Example text'],
11
+ 'integer' => ['Integer', 'Integer', 1],
12
+ 'float' => ['Float', '[Integer, Float]', 1.5],
13
+ 'boolean' => ['TrueClass', ':boolean', false],
14
+ 'date' => ['Date', 'String', '2026-01-01'],
15
+ 'datetime' => ['DateTime', 'String', '2026-01-01T12:00:00Z']
16
+ }.transform_values(&:freeze).freeze
17
+ KEYWORDS = %w[alias and begin break case class def defined do else elsif end ensure false
18
+ for if in module next nil not or redo rescue retry return self super then
19
+ true undef unless until when while yield __FILE__ __LINE__ __ENCODING__].freeze
20
+ RESERVED_FIELDS = (KEYWORDS + %w[id created_at updated_at save save_changes destroy delete
21
+ values errors valid validate set update refresh new db dataset model table_name columns
22
+ pk pk_hash this associations changed_columns raise fail send public_send method methods
23
+ object_id instance_eval instance_exec initialize hash eql equal freeze frozen inspect
24
+ to_s to_json to_hash to_a tap then itself dup clone before_validation after_validation
25
+ before_save after_save before_create after_create before_update after_update
26
+ before_destroy after_destroy around_validation around_save around_create around_update
27
+ around_destroy set_fields update_fields validate_save skip_validation_on_next_save
28
+ get_column_value set_column_value raise_on_save_failure use_transactions require_modification]).freeze
29
+ RESERVED_MODELS = %w[app controller handler database tasks root db].freeze
30
+ RESERVED_CONSTANTS = %w[Object BasicObject Class Module Kernel String Integer Float Numeric
31
+ TrueClass FalseClass NilClass Array Hash Symbol Date DateTime Time File Dir Io Process
32
+ Thread Exception StandardError Sequel Roda Rack Rspec Rake Json Logger Erb FileUtils].freeze
33
+
34
+ attr_reader :app_name, :model_name, :resource, :fields
35
+
36
+ def self.identifier!(value, label: 'Name')
37
+ unless value.is_a?(String) && value.match?(/\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/) && !KEYWORDS.include?(value)
38
+ raise InvalidInput, "#{label} must be a lowercase Ruby name, such as blog or blog_post."
39
+ end
40
+ value
41
+ end
42
+
43
+ def self.model_name!(value)
44
+ identifier!(value, label: 'Model name')
45
+ if RESERVED_MODELS.include?(value) || RESERVED_CONSTANTS.include?(camelize(value))
46
+ raise InvalidInput, 'That model name is reserved by Ruby or the application.'
47
+ end
48
+ value
49
+ end
50
+
51
+ def self.app_name!(value)
52
+ identifier!(value, label: 'App name')
53
+ raise InvalidInput, 'That app name conflicts with a Ruby or library constant.' if RESERVED_CONSTANTS.include?(camelize(value))
54
+ value
55
+ end
56
+
57
+ def self.field!(name, type)
58
+ identifier!(name, label: 'Field name')
59
+ raise InvalidInput, "Field #{name} is reserved; id and timestamps are generated automatically." if RESERVED_FIELDS.include?(name)
60
+ raise InvalidInput, "Unknown field type: #{type}." unless TYPES.key?(type)
61
+ {name: name.dup.freeze, type: type.dup.freeze}.freeze
62
+ end
63
+
64
+ def self.camelize(name) = name.split('_').map(&:capitalize).join
65
+
66
+ def initialize(app_name:, model_name:, resource:, fields:)
67
+ @app_name = self.class.app_name!(app_name).dup.freeze
68
+ @model_name = self.class.model_name!(model_name).dup.freeze
69
+ @resource = self.class.identifier!(resource, label: 'Resource name').dup.freeze
70
+ if self.class.camelize(resource) + 'CreateController' == model_class ||
71
+ self.class.camelize(resource) + 'CreateHandler' == model_class
72
+ raise InvalidInput, 'Model name conflicts with a generated action class.'
73
+ end
74
+ raise InvalidInput, 'Add at least one field.' if fields.empty?
75
+ @fields = fields.map { |field| self.class.field!(field.fetch(:name), field.fetch(:type)) }.freeze
76
+ raise InvalidInput, 'Field names must be unique.' unless @fields.map { |f| f[:name] }.uniq.length == @fields.length
77
+ freeze
78
+ end
79
+
80
+ def namespace = self.class.camelize(app_name)
81
+ def model_class = self.class.camelize(model_name)
82
+ def action_prefix = self.class.camelize(resource)
83
+ def field_names = fields.map { |field| field[:name] }
84
+ def example = fields.to_h { |field| [field[:name], TYPES.fetch(field[:type])[2]] }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+
5
+ module MK
6
+ module Generator
7
+ module Naming
8
+ IRREGULAR = {'person' => 'people', 'child' => 'children', 'man' => 'men', 'woman' => 'women'}.freeze
9
+
10
+ def self.singular(name)
11
+ return IRREGULAR.key(name) if IRREGULAR.value?(name)
12
+ return "#{name[0...-3]}y" if name.end_with?('ies')
13
+ return name[0...-2] if name.match?(/(?:sses|shes|ches|xes|zes|statuses)\z/)
14
+ name.end_with?('s') && !name.end_with?('ss', 'us') ? name[0...-1] : name
15
+ end
16
+
17
+ def self.plural(name)
18
+ return IRREGULAR.fetch(name) if IRREGULAR.key?(name)
19
+ return "#{name[0...-1]}ies" if name.match?(/[^aeiou]y\z/)
20
+ name.match?(/(?:s|sh|ch|x|z)\z/) ? "#{name}es" : "#{name}s"
21
+ end
22
+ end
23
+
24
+ # This is a small data grammar, never Ruby evaluation or shell input.
25
+ class Options
26
+ def self.parse(source)
27
+ new(source).parse
28
+ end
29
+
30
+ def initialize(source)
31
+ @scanner = StringScanner.new(source)
32
+ end
33
+
34
+ def parse
35
+ options = {}
36
+ loop do
37
+ key = identifier
38
+ raise InvalidInput, "Unknown option: #{key}." unless %w[app_name model_name resource_name fields].include?(key)
39
+ raise InvalidInput, "Duplicate option: #{key}." if options.key?(key)
40
+ token(':')
41
+ options[key] = key == 'fields' ? fields : identifier
42
+ whitespace
43
+ break if @scanner.eos?
44
+ token(',')
45
+ end
46
+ %w[app_name model_name fields].each do |key|
47
+ raise InvalidInput, "Missing option: #{key}." unless options.key?(key)
48
+ end
49
+ model = Naming.singular(options.fetch('model_name'))
50
+ Configuration.new(app_name: options.fetch('app_name'), model_name: model,
51
+ resource: options.fetch('resource_name') { Naming.plural(model) }, fields: options.fetch('fields'))
52
+ end
53
+
54
+ private
55
+
56
+ def fields
57
+ token('[')
58
+ result = []
59
+ whitespace
60
+ unless @scanner.peek(1) == ']'
61
+ loop do
62
+ name = identifier
63
+ token(':')
64
+ result << {name: name, type: identifier}
65
+ whitespace
66
+ break if @scanner.peek(1) == ']'
67
+ token(',')
68
+ end
69
+ end
70
+ token(']')
71
+ result
72
+ end
73
+
74
+ def whitespace = @scanner.skip(/\s*/)
75
+
76
+ def identifier
77
+ whitespace
78
+ @scanner.scan(/[a-z][a-z0-9_]*/) || invalid!
79
+ end
80
+
81
+ def token(value)
82
+ whitespace
83
+ invalid! unless @scanner.scan(/#{Regexp.escape(value)}/)
84
+ end
85
+
86
+ def invalid!
87
+ raise InvalidInput, 'Invalid --cli syntax. Use app_name:blog, model_name:posts, fields:[title:string, contents:text].'
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require_relative '../version'
7
+
8
+ module MK
9
+ module Generator
10
+ class Project
11
+ TEMPLATES = {
12
+ 'Gemfile' => 'Gemfile', 'Rakefile' => 'Rakefile', 'gitignore' => '.gitignore',
13
+ 'README.md' => 'README.md', 'app.rb' => 'app.rb', 'config.ru' => 'config.ru',
14
+ 'database.rb' => 'database.rb', 'migration.rb' => 'db/migrations/001_initial.rb',
15
+ 'model.rb' => 'models/%{model}.rb',
16
+ 'controller.rb' => 'routes/%{resource}/controllers/create.rb',
17
+ 'handler.rb' => 'routes/%{resource}/handlers/create.rb',
18
+ 'spec_helper.rb' => 'spec/spec_helper.rb', 'request_spec.rb' => 'spec/request/%{resource}_spec.rb'
19
+ }.freeze
20
+
21
+ def initialize(configuration)
22
+ @configuration = configuration
23
+ end
24
+
25
+ def generate(destination)
26
+ destination = File.expand_path(destination)
27
+ raise InvalidInput, 'Destination already exists; choose a new directory.' if File.exist?(destination) || File.symlink?(destination)
28
+ parent = File.dirname(destination)
29
+ raise InvalidInput, 'Destination parent directory does not exist.' unless File.directory?(parent)
30
+
31
+ # Render before touching the destination, then reserve it without overwriting anything.
32
+ files = render
33
+ Dir.mkdir(destination)
34
+ files.each do |relative, content|
35
+ target = File.join(destination, relative)
36
+ FileUtils.mkdir_p(File.dirname(target))
37
+ File.write(target, content, mode: 'wx')
38
+ end
39
+ destination
40
+ rescue Errno::EEXIST
41
+ raise InvalidInput, 'Destination already exists; choose a new directory.'
42
+ end
43
+
44
+ def render
45
+ config = @configuration
46
+ TEMPLATES.to_h do |template, path|
47
+ source = File.read(File.join(__dir__, 'templates', "#{template}.erb"))
48
+ [format(path, model: config.model_name, resource: config.resource), ERB.new(source, trim_mode: '-').result(binding)]
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require_relative '../generator'
5
+
6
+ namespace :mk_framework do
7
+ desc 'Generate an app (optional DESTINATION=/path/to/app and APP_SPEC for non-interactive input)'
8
+ task :init do
9
+ arguments = ENV['DESTINATION'] ? [ENV.fetch('DESTINATION')] : []
10
+ arguments += ['--cli', ENV.fetch('APP_SPEC')] if ENV['APP_SPEC']
11
+ abort 'App generation did not complete.' unless MK::Generator::CLI.run(arguments).zero?
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'mk_framework', '~> <%= MK::VERSION %>'
4
+ gem 'sequel', '>= 5.92', '< 6'
5
+ gem 'sqlite3', '~> 2.9'
6
+ gem 'rake', '~> 13.4'
7
+ gem 'rackup', '~> 2.3'
8
+ gem 'puma', '~> 8.0'
9
+
10
+ group :test do
11
+ gem 'rspec', '~> 3.13'
12
+ gem 'rack-test', '~> 2.2'
13
+ end
@@ -0,0 +1,54 @@
1
+ # <%= config.namespace %>
2
+
3
+ A self-contained MK app with one `<%= config.model_class %>` model and one resource
4
+ route: `POST /<%= config.resource %>`. The create controller prepares the record;
5
+ MK saves it once, and the handler returns JSON with status 201.
6
+
7
+ ## Setup
8
+
9
+ ```sh
10
+ bundle install
11
+ bundle exec rake db:migrate
12
+ bundle exec rake routes
13
+ bundle exec rspec
14
+ ```
15
+
16
+ Migrations run explicitly, before boot. Development data defaults to
17
+ `<%= config.app_name %>.db` beside `database.rb`. `DATABASE_URL`, `DB_POOL_SIZE`, and
18
+ `DB_POOL_TIMEOUT` configure other connections. Tests always use private in-memory
19
+ SQLite databases and ignore `DATABASE_URL`.
20
+
21
+ To serve the app locally:
22
+
23
+ ```sh
24
+ bundle exec rackup --host 127.0.0.1 --port 9292
25
+ ```
26
+
27
+ Then create a record:
28
+
29
+ ```sh
30
+ curl -i http://127.0.0.1:9292/<%= config.resource %> \
31
+ -H 'Content-Type: application/json' \
32
+ -d '<%= JSON.generate(config.example) %>'
33
+ ```
34
+
35
+ All chosen fields are required. Invalid input types return 400; missing fields or
36
+ blank text return 422. Date and datetime inputs use ISO 8601 strings. Booleans
37
+ accept JSON `true` and `false`; numeric fields accept JSON numbers. Unknown fields
38
+ are ignored. IDs and timestamps are assigned by the app.
39
+
40
+ ## Files and next changes
41
+
42
+ - `database.rb` connects; `db/migrations/001_initial.rb` defines the table.
43
+ - `models/<%= config.model_name %>.rb` owns validations and the public field list.
44
+ - `app.rb` loads dependencies and models, configures routes, and calls `boot!`.
45
+ - `config.ru` exposes `<%= config.namespace %>::App.app` to Rack.
46
+ - `routes/<%= config.resource %>/controllers/create.rb` permits inputs and returns an unsaved model.
47
+ - `routes/<%= config.resource %>/handlers/create.rb` filters raw data and formats the response.
48
+ - `spec/request/<%= config.resource %>_spec.rb` checks persistence and error responses.
49
+
50
+ Add a field through a new migration, update permitted input and model validation,
51
+ then decide whether to expose it in `public_attributes_list`. Add another action by
52
+ creating its controller/handler pair and adding it to `resources` in `app.rb`.
53
+ The starter has no authentication: implement access rules in controllers before
54
+ using it for private data. Handlers must not query or persist records.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require 'rbconfig'
5
+
6
+ namespace :db do
7
+ desc 'Apply database migrations explicitly'
8
+ task :migrate do
9
+ require_relative 'database'
10
+ Sequel::Migrator.run(<%= config.namespace %>::DB, File.join(__dir__, 'db/migrations'))
11
+ end
12
+ end
13
+
14
+ desc 'Print the compiled resource routes'
15
+ task :routes do
16
+ require_relative 'app'
17
+ puts <%= config.namespace %>::App.route_table
18
+ end
19
+
20
+ desc 'Run isolated request specs'
21
+ task :spec do
22
+ sh RbConfig.ruby, '-S', 'rspec', File.join(__dir__, 'spec')
23
+ end
24
+
25
+ task default: :spec
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mk_framework/sequel'
4
+ require_relative 'database'
5
+ require_relative 'models/<%= config.model_name %>'
6
+
7
+ module <%= config.namespace %>
8
+ class App < MK::Application
9
+ configure root: ROOT, namespace: ::<%= config.namespace %>, legacy_post_routes: false
10
+
11
+ resource_routes do
12
+ resources :<%= config.resource %>, only: [:create], singular: '<%= config.model_name %>'
13
+ end
14
+ end
15
+
16
+ App.boot!
17
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'app'
4
+ run <%= config.namespace %>::App.app
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ <% if config.fields.any? { |field| %w[date datetime].include?(field[:type]) } -%>
3
+
4
+ require 'date'
5
+ <% end -%>
6
+
7
+ module <%= config.namespace %>
8
+ class <%= config.action_prefix %>CreateController < MK::Controller
9
+ route do |r|
10
+ attributes = r.input.permit(
11
+ <% config.fields.each_with_index do |field, index| -%>
12
+ <%= field[:name] %>: <%= MK::Generator::Configuration::TYPES.fetch(field[:type])[1] %><%= index < config.fields.length - 1 ? ',' : '' %>
13
+ <% end -%>
14
+ )
15
+ <% config.fields.select { |field| %w[date datetime].include?(field[:type]) }.each do |field| -%>
16
+ if attributes.key?(:<%= field[:name] %>)
17
+ begin
18
+ attributes[:<%= field[:name] %>] = <%= field[:type] == 'date' ? 'Date' : 'DateTime' %>.iso8601(attributes.fetch(:<%= field[:name] %>))
19
+ rescue ArgumentError
20
+ raise MK::BadRequest, 'Invalid parameter: <%= field[:name] %> (expected ISO 8601)'
21
+ end
22
+ end
23
+ <% end -%>
24
+ <%= config.model_class %>.new(attributes)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+ require 'sequel/extensions/migration'
5
+
6
+ module <%= config.namespace %>
7
+ ROOT = __dir__.freeze
8
+ DB = if ENV['RACK_ENV'] == 'test'
9
+ Sequel.sqlite(max_connections: 1)
10
+ else
11
+ url = ENV.fetch('DATABASE_URL') { "sqlite://#{File.join(ROOT, '<%= config.app_name %>.db')}" }
12
+ Sequel.connect(url, max_connections: Integer(ENV.fetch('DB_POOL_SIZE', '5')),
13
+ pool_timeout: Integer(ENV.fetch('DB_POOL_TIMEOUT', '5')))
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ .bundle/
2
+ vendor/
3
+ .env
4
+ .env.*
5
+ *.db
6
+ *.db-*
7
+ *.sqlite*
8
+ log/
9
+ tmp/
10
+ .DS_Store
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= config.namespace %>
4
+ class <%= config.action_prefix %>CreateHandler < MK::Handler
5
+ handler do |r|
6
+ r.response.status = 201
7
+ {<%= config.model_name %>: model.slice(*<%= config.model_class %>.public_attributes_list)}
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ create_table :<%= config.resource %> do
6
+ primary_key :id
7
+ <% config.fields.each do |field| -%>
8
+ <%= MK::Generator::Configuration::TYPES.fetch(field[:type])[0] %> :<%= field[:name] %>, null: false<%= field[:type] == 'text' ? ', text: true' : '' %>
9
+ <% end -%>
10
+ DateTime :created_at, null: false
11
+ DateTime :updated_at, null: false
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= config.namespace %>
4
+ class <%= config.model_class %> < Sequel::Model(DB[:<%= config.resource %>])
5
+ plugin :validation_helpers
6
+ plugin :timestamps, update_on_create: true
7
+
8
+ def validate
9
+ super
10
+ validates_not_null %i[<%= config.field_names.join(' ') %>]
11
+ <% text_fields = config.fields.select { |field| %w[string text].include?(field[:type]) }.map { |field| field[:name] } -%>
12
+ <% unless text_fields.empty? -%>
13
+ validates_presence %i[<%= text_fields.join(' ') %>]
14
+ <% end -%>
15
+ end
16
+
17
+ def self.public_attributes_list
18
+ %i[id <%= config.field_names.join(' ') %> created_at updated_at]
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../spec_helper'
4
+
5
+ RSpec.describe '<%= config.model_class %> creation' do
6
+ let(:client) { Rack::MockRequest.new(Rack::Builder.parse_file(File.join(<%= config.namespace %>::ROOT, 'config.ru'))) }
7
+ let(:attributes) { JSON.parse(<%= JSON.generate(config.example).inspect %>) }
8
+
9
+ before { <%= config.namespace %>::<%= config.model_class %>.dataset.delete }
10
+
11
+ it 'persists permitted fields and returns a 201 response through the Rack entrypoint' do
12
+ response = client.post('/<%= config.resource %>', input: JSON.generate(attributes.merge('id' => 999)),
13
+ 'CONTENT_TYPE' => 'application/json')
14
+
15
+ expect(response.status).to eq(201)
16
+ record = <%= config.namespace %>::<%= config.model_class %>.first
17
+ expect(record).not_to be_nil
18
+ expect(record.id).not_to eq(999)
19
+ payload = JSON.parse(response.body).fetch('<%= config.model_name %>')
20
+ expect(payload.fetch('id')).to eq(record.id)
21
+ expect(payload.keys).to match_array(%w[id <%= config.field_names.join(' ') %> created_at updated_at])
22
+ end
23
+
24
+ it 'rejects missing required fields without inserting a record' do
25
+ response = client.post('/<%= config.resource %>', input: '{}', 'CONTENT_TYPE' => 'application/json')
26
+
27
+ expect(response.status).to eq(422)
28
+ expect(<%= config.namespace %>::<%= config.model_class %>.count).to eq(0)
29
+ end
30
+
31
+ it 'rejects an invalid field type without inserting a record' do
32
+ attributes['<%= config.fields.first[:name] %>'] = []
33
+ response = client.post('/<%= config.resource %>', input: JSON.generate(attributes), 'CONTENT_TYPE' => 'application/json')
34
+
35
+ expect(response.status).to eq(400)
36
+ expect(<%= config.namespace %>::<%= config.model_class %>.count).to eq(0)
37
+ end
38
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV['RACK_ENV'] = 'test'
4
+ require 'rspec'
5
+ require 'rack/mock'
6
+ require 'rack/builder'
7
+ require 'json'
8
+ require_relative '../database'
9
+ Sequel::Migrator.run(<%= config.namespace %>::DB, File.join(<%= config.namespace %>::ROOT, 'db/migrations'))
10
+ require_relative '../app'
11
+
12
+ RSpec.configure do |config|
13
+ config.after(:suite) { <%= config.namespace %>::DB.disconnect }
14
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'generator/configuration'
4
+ require_relative 'generator/options'
5
+ require_relative 'generator/project'
6
+ require_relative 'generator/cli'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MK
4
- VERSION = '0.2.0'
4
+ VERSION = '0.2.1'
5
5
  end