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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +436 -25
- data/bin/mk_frame_init +5 -0
- data/docs/deployment.md +3 -3
- data/lib/mk_framework/generator/cli.rb +124 -0
- data/lib/mk_framework/generator/configuration.rb +87 -0
- data/lib/mk_framework/generator/options.rb +91 -0
- data/lib/mk_framework/generator/project.rb +53 -0
- data/lib/mk_framework/generator/tasks.rb +13 -0
- data/lib/mk_framework/generator/templates/Gemfile.erb +13 -0
- data/lib/mk_framework/generator/templates/README.md.erb +54 -0
- data/lib/mk_framework/generator/templates/Rakefile.erb +25 -0
- data/lib/mk_framework/generator/templates/app.rb.erb +17 -0
- data/lib/mk_framework/generator/templates/config.ru.erb +4 -0
- data/lib/mk_framework/generator/templates/controller.rb.erb +27 -0
- data/lib/mk_framework/generator/templates/database.rb.erb +15 -0
- data/lib/mk_framework/generator/templates/gitignore.erb +10 -0
- data/lib/mk_framework/generator/templates/handler.rb.erb +10 -0
- data/lib/mk_framework/generator/templates/migration.rb.erb +14 -0
- data/lib/mk_framework/generator/templates/model.rb.erb +21 -0
- data/lib/mk_framework/generator/templates/request_spec.rb.erb +38 -0
- data/lib/mk_framework/generator/templates/spec_helper.rb.erb +14 -0
- data/lib/mk_framework/generator.rb +6 -0
- data/lib/mk_framework/version.rb +1 -1
- metadata +26 -6
- data/docs/upgrading.md +0 -124
|
@@ -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,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
|
+
# 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
|
data/lib/mk_framework/version.rb
CHANGED