mk_framework 0.2.0 → 0.2.2
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 +20 -0
- data/README.md +442 -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 +58 -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 +70 -0
- data/lib/mk_framework/generator/templates/Rakefile.erb +32 -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 +40 -0
- data/lib/mk_framework/generator/templates/database.rb.erb +17 -0
- data/lib/mk_framework/generator/templates/gitignore.erb +10 -0
- data/lib/mk_framework/generator/templates/handler.rb.erb +16 -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 +108 -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 model and a full CRUD resource with controllers and handlers.'
|
|
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}; resource: /#{resource} (index, show, create, update, delete)"
|
|
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\n bundle exec rake"
|
|
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
|
+
ACTIONS = %w[index show create update delete].freeze
|
|
9
|
+
TYPES = {
|
|
10
|
+
'string' => ['String', 'String', 'Example'],
|
|
11
|
+
'text' => ['String', 'String', 'Example text'],
|
|
12
|
+
'integer' => ['Integer', 'Integer', 1],
|
|
13
|
+
'float' => ['Float', '[Integer, Float]', 1.5],
|
|
14
|
+
'boolean' => ['TrueClass', ':boolean', false],
|
|
15
|
+
'date' => ['Date', 'String', '2026-01-01'],
|
|
16
|
+
'datetime' => ['DateTime', 'String', '2026-01-01T12:00:00Z']
|
|
17
|
+
}.transform_values(&:freeze).freeze
|
|
18
|
+
KEYWORDS = %w[alias and begin break case class def defined do else elsif end ensure false
|
|
19
|
+
for if in module next nil not or redo rescue retry return self super then
|
|
20
|
+
true undef unless until when while yield __FILE__ __LINE__ __ENCODING__].freeze
|
|
21
|
+
RESERVED_FIELDS = (KEYWORDS + %w[id created_at updated_at save save_changes destroy delete
|
|
22
|
+
values errors valid validate set update refresh new db dataset model table_name columns
|
|
23
|
+
pk pk_hash this associations changed_columns raise fail send public_send method methods
|
|
24
|
+
object_id instance_eval instance_exec initialize hash eql equal freeze frozen inspect
|
|
25
|
+
to_s to_json to_hash to_a tap then itself dup clone before_validation after_validation
|
|
26
|
+
before_save after_save before_create after_create before_update after_update
|
|
27
|
+
before_destroy after_destroy around_validation around_save around_create around_update
|
|
28
|
+
around_destroy set_fields update_fields validate_save skip_validation_on_next_save
|
|
29
|
+
get_column_value set_column_value raise_on_save_failure use_transactions require_modification]).freeze
|
|
30
|
+
RESERVED_MODELS = %w[app controller handler database tasks root db].freeze
|
|
31
|
+
RESERVED_CONSTANTS = %w[Object BasicObject Class Module Kernel String Integer Float Numeric
|
|
32
|
+
TrueClass FalseClass NilClass Array Hash Symbol Date DateTime Time File Dir Io Process
|
|
33
|
+
Thread Exception StandardError Sequel Roda Rack Rspec Rake Json Logger Erb FileUtils].freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :app_name, :model_name, :resource, :fields
|
|
36
|
+
|
|
37
|
+
def self.identifier!(value, label: 'Name')
|
|
38
|
+
unless value.is_a?(String) && value.match?(/\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/) && !KEYWORDS.include?(value)
|
|
39
|
+
raise InvalidInput, "#{label} must be a lowercase Ruby name, such as blog or blog_post."
|
|
40
|
+
end
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.model_name!(value)
|
|
45
|
+
identifier!(value, label: 'Model name')
|
|
46
|
+
if RESERVED_MODELS.include?(value) || RESERVED_CONSTANTS.include?(camelize(value))
|
|
47
|
+
raise InvalidInput, 'That model name is reserved by Ruby or the application.'
|
|
48
|
+
end
|
|
49
|
+
value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.app_name!(value)
|
|
53
|
+
identifier!(value, label: 'App name')
|
|
54
|
+
raise InvalidInput, 'That app name conflicts with a Ruby or library constant.' if RESERVED_CONSTANTS.include?(camelize(value))
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.field!(name, type)
|
|
59
|
+
identifier!(name, label: 'Field name')
|
|
60
|
+
raise InvalidInput, "Field #{name} is reserved; id and timestamps are generated automatically." if RESERVED_FIELDS.include?(name)
|
|
61
|
+
raise InvalidInput, "Unknown field type: #{type}." unless TYPES.key?(type)
|
|
62
|
+
{name: name.dup.freeze, type: type.dup.freeze}.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.camelize(name) = name.split('_').map(&:capitalize).join
|
|
66
|
+
|
|
67
|
+
def initialize(app_name:, model_name:, resource:, fields:)
|
|
68
|
+
@app_name = self.class.app_name!(app_name).dup.freeze
|
|
69
|
+
@model_name = self.class.model_name!(model_name).dup.freeze
|
|
70
|
+
@resource = self.class.identifier!(resource, label: 'Resource name').dup.freeze
|
|
71
|
+
if ACTIONS.product(%w[Controller Handler]).any? { |action, kind| action_prefix + action.capitalize + kind == 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,58 @@
|
|
|
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
|
+
'spec_helper.rb' => 'spec/spec_helper.rb', 'request_spec.rb' => 'spec/request/%{resource}_spec.rb'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(configuration)
|
|
20
|
+
@configuration = configuration
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def generate(destination)
|
|
24
|
+
destination = File.expand_path(destination)
|
|
25
|
+
raise InvalidInput, 'Destination already exists; choose a new directory.' if File.exist?(destination) || File.symlink?(destination)
|
|
26
|
+
parent = File.dirname(destination)
|
|
27
|
+
raise InvalidInput, 'Destination parent directory does not exist.' unless File.directory?(parent)
|
|
28
|
+
|
|
29
|
+
# Render before touching the destination, then reserve it without overwriting anything.
|
|
30
|
+
files = render
|
|
31
|
+
Dir.mkdir(destination)
|
|
32
|
+
files.each do |relative, content|
|
|
33
|
+
target = File.join(destination, relative)
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
35
|
+
File.write(target, content, mode: 'wx')
|
|
36
|
+
end
|
|
37
|
+
destination
|
|
38
|
+
rescue Errno::EEXIST
|
|
39
|
+
raise InvalidInput, 'Destination already exists; choose a new directory.'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render
|
|
43
|
+
config = @configuration
|
|
44
|
+
files = TEMPLATES.to_h do |template, path|
|
|
45
|
+
source = File.read(File.join(__dir__, 'templates', "#{template}.erb"))
|
|
46
|
+
[format(path, model: config.model_name, resource: config.resource), ERB.new(source, trim_mode: '-').result(binding)]
|
|
47
|
+
end
|
|
48
|
+
Configuration::ACTIONS.each do |action|
|
|
49
|
+
%w[controller handler].each do |kind|
|
|
50
|
+
source = File.read(File.join(__dir__, 'templates', "#{kind}.rb.erb"))
|
|
51
|
+
files["routes/#{config.resource}/#{kind}s/#{action}.rb"] = ERB.new(source, trim_mode: '-').result(binding)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
files
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
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,70 @@
|
|
|
1
|
+
# <%= config.namespace %>
|
|
2
|
+
|
|
3
|
+
A self-contained MK app with one `<%= config.model_class %>` model and a full CRUD resource.
|
|
4
|
+
Each action has its own controller and handler.
|
|
5
|
+
|
|
6
|
+
| Method | Path | Action |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| GET | `/<%= config.resource %>` | index |
|
|
9
|
+
| GET | `/<%= config.resource %>/:id` | show |
|
|
10
|
+
| POST | `/<%= config.resource %>` | create |
|
|
11
|
+
| PATCH / PUT | `/<%= config.resource %>/:id` | update |
|
|
12
|
+
| DELETE | `/<%= config.resource %>/:id` | delete |
|
|
13
|
+
|
|
14
|
+
Create returns 201; the other actions return 200. Index returns
|
|
15
|
+
`{<%= config.resource %>: [...]}`; member actions return `{<%= config.model_name %>: {...}}`,
|
|
16
|
+
including the deleted record for DELETE. Missing records return 404.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bundle install
|
|
22
|
+
bundle exec rake db:migrate
|
|
23
|
+
bundle exec rake routes
|
|
24
|
+
bundle exec rspec
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Migrations run explicitly, before boot. Development data defaults to
|
|
28
|
+
`<%= config.app_name %>.db` beside `database.rb`. `DATABASE_URL`, `DB_POOL_SIZE`, and
|
|
29
|
+
`DB_POOL_TIMEOUT` configure other connections. Tests always use private in-memory
|
|
30
|
+
SQLite databases and ignore `DATABASE_URL`. Datetimes are stored and read in UTC.
|
|
31
|
+
|
|
32
|
+
To serve the app locally:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
bundle exec rake # defaults to rake dev; Puma on http://127.0.0.1:3000
|
|
36
|
+
# or: bundle exec rake dev
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Set `HOST` or `PORT` to override the bind address or port.
|
|
40
|
+
|
|
41
|
+
Then create a record:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
curl -i http://127.0.0.1:3000/<%= config.resource %> \
|
|
45
|
+
-H 'Content-Type: application/json' \
|
|
46
|
+
-d '<%= JSON.generate(config.example) %>'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
All chosen fields are required on create. PATCH and PUT update only supplied fields
|
|
50
|
+
and preserve omitted fields. Invalid input types return 400; missing fields or
|
|
51
|
+
blank text return 422. Date and datetime inputs use ISO 8601 strings. Booleans
|
|
52
|
+
accept JSON `true` and `false`; numeric fields accept JSON numbers. Unknown fields
|
|
53
|
+
are ignored. IDs and timestamps are assigned by the app.
|
|
54
|
+
|
|
55
|
+
## Files and next changes
|
|
56
|
+
|
|
57
|
+
- `database.rb` connects; `db/migrations/001_initial.rb` defines the table.
|
|
58
|
+
- `models/<%= config.model_name %>.rb` owns validations and the public field list.
|
|
59
|
+
- `app.rb` loads dependencies and models, configures routes, and calls `boot!`.
|
|
60
|
+
- `config.ru` exposes `<%= config.namespace %>::App.app` to Rack.
|
|
61
|
+
- `routes/<%= config.resource %>/controllers/` contains index, show, create, update, and delete actions.
|
|
62
|
+
- `routes/<%= config.resource %>/handlers/` filters raw data and formats each response.
|
|
63
|
+
- MK saves create/update models once and destroys delete models once.
|
|
64
|
+
- `spec/request/<%= config.resource %>_spec.rb` checks persistence and error responses.
|
|
65
|
+
|
|
66
|
+
Add a field through a new migration, update permitted input and model validation,
|
|
67
|
+
then decide whether to expose it in `public_attributes_list`. Add another action by
|
|
68
|
+
creating its controller/handler pair and declaring the route in `app.rb`.
|
|
69
|
+
The starter has no authentication: implement access rules in controllers before
|
|
70
|
+
using it for private data. Handlers must not query or persist records.
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
desc 'Start Puma on port 3000 (HOST and PORT override the defaults)'
|
|
26
|
+
task :dev do
|
|
27
|
+
exec RbConfig.ruby, '-S', 'puma', '--bind',
|
|
28
|
+
"tcp://#{ENV.fetch('HOST', '127.0.0.1')}:#{ENV.fetch('PORT', '3000')}",
|
|
29
|
+
File.join(__dir__, 'config.ru'), chdir: __dir__
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
task default: :dev
|
|
@@ -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 %>, singular: '<%= config.model_name %>'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
App.boot!
|
|
17
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
<% if %w[create update].include?(action) && 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 %><%= action.capitalize %>Controller < MK::Controller
|
|
9
|
+
route do |r|
|
|
10
|
+
<% if action == 'index' -%>
|
|
11
|
+
<%= config.model_class %>.order(:id)
|
|
12
|
+
<% elsif %w[show delete].include?(action) -%>
|
|
13
|
+
<%= config.model_class %>[r.path_params.fetch(:id)] or raise MK::NotFound
|
|
14
|
+
<% else -%>
|
|
15
|
+
<% if action == 'update' -%>
|
|
16
|
+
record = <%= config.model_class %>[r.path_params.fetch(:id)] or raise MK::NotFound
|
|
17
|
+
<% end -%>
|
|
18
|
+
attributes = r.input.permit(
|
|
19
|
+
<% config.fields.each_with_index do |field, index| -%>
|
|
20
|
+
<%= field[:name] %>: <%= MK::Generator::Configuration::TYPES.fetch(field[:type])[1] %><%= index < config.fields.length - 1 ? ',' : '' %>
|
|
21
|
+
<% end -%>
|
|
22
|
+
)
|
|
23
|
+
<% config.fields.select { |field| %w[date datetime].include?(field[:type]) }.each do |field| -%>
|
|
24
|
+
if attributes.key?(:<%= field[:name] %>)
|
|
25
|
+
begin
|
|
26
|
+
attributes[:<%= field[:name] %>] = <%= field[:type] == 'date' ? 'Date' : 'DateTime' %>.iso8601(attributes.fetch(:<%= field[:name] %>))
|
|
27
|
+
rescue ArgumentError
|
|
28
|
+
raise MK::BadRequest, 'Invalid parameter: <%= field[:name] %> (expected ISO 8601)'
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
<% end -%>
|
|
32
|
+
<% if action == 'create' -%>
|
|
33
|
+
<%= config.model_class %>.new(attributes)
|
|
34
|
+
<% else -%>
|
|
35
|
+
record.set(attributes)
|
|
36
|
+
<% end -%>
|
|
37
|
+
<% end -%>
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sequel'
|
|
4
|
+
require 'sequel/extensions/migration'
|
|
5
|
+
|
|
6
|
+
Sequel.default_timezone = :utc
|
|
7
|
+
|
|
8
|
+
module <%= config.namespace %>
|
|
9
|
+
ROOT = __dir__.freeze
|
|
10
|
+
DB = if ENV['RACK_ENV'] == 'test'
|
|
11
|
+
Sequel.sqlite(max_connections: 1)
|
|
12
|
+
else
|
|
13
|
+
url = ENV.fetch('DATABASE_URL') { "sqlite://#{File.join(ROOT, '<%= config.app_name %>.db')}" }
|
|
14
|
+
Sequel.connect(url, max_connections: Integer(ENV.fetch('DB_POOL_SIZE', '5')),
|
|
15
|
+
pool_timeout: Integer(ENV.fetch('DB_POOL_TIMEOUT', '5')))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module <%= config.namespace %>
|
|
4
|
+
class <%= config.action_prefix %><%= action.capitalize %>Handler < MK::Handler
|
|
5
|
+
handler do |r|
|
|
6
|
+
<% if action == 'create' -%>
|
|
7
|
+
r.response.status = 201
|
|
8
|
+
<% end -%>
|
|
9
|
+
<% if action == 'index' -%>
|
|
10
|
+
{<%= config.resource %>: model.map { |record| record.slice(*<%= config.model_class %>.public_attributes_list) }}
|
|
11
|
+
<% else -%>
|
|
12
|
+
{<%= config.model_name %>: model.slice(*<%= config.model_class %>.public_attributes_list)}
|
|
13
|
+
<% end -%>
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
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
|