punk 0.0.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 +7 -0
- data/.document +5 -0
- data/Gemfile +121 -0
- data/Gemfile.lock +353 -0
- data/LICENSE +24 -0
- data/README.md +7 -0
- data/Rakefile +31 -0
- data/VERSION +1 -0
- data/bin/punk +18 -0
- data/lib/punk.rb +32 -0
- data/lib/punk/commands/auth.rb +57 -0
- data/lib/punk/commands/generate.rb +54 -0
- data/lib/punk/commands/http.rb +71 -0
- data/lib/punk/commands/list.rb +28 -0
- data/lib/punk/config/console/defaults.json +5 -0
- data/lib/punk/config/defaults.json +47 -0
- data/lib/punk/config/schema.json +55 -0
- data/lib/punk/config/script/defaults.json +5 -0
- data/lib/punk/config/server/development.json +9 -0
- data/lib/punk/config/spec/defaults.json +5 -0
- data/lib/punk/core/app.rb +233 -0
- data/lib/punk/core/boot.rb +9 -0
- data/lib/punk/core/cli.rb +13 -0
- data/lib/punk/core/commander.rb +82 -0
- data/lib/punk/core/commands.rb +26 -0
- data/lib/punk/core/env.rb +290 -0
- data/lib/punk/core/error.rb +10 -0
- data/lib/punk/core/exec.rb +38 -0
- data/lib/punk/core/interface.rb +76 -0
- data/lib/punk/core/load.rb +9 -0
- data/lib/punk/core/logger.rb +33 -0
- data/lib/punk/core/monkey.rb +7 -0
- data/lib/punk/core/monkey_unreloader.rb +18 -0
- data/lib/punk/core/pry.rb +39 -0
- data/lib/punk/core/settings.rb +38 -0
- data/lib/punk/core/version.rb +7 -0
- data/lib/punk/core/worker.rb +13 -0
- data/lib/punk/framework/action.rb +29 -0
- data/lib/punk/framework/all.rb +10 -0
- data/lib/punk/framework/command.rb +117 -0
- data/lib/punk/framework/model.rb +52 -0
- data/lib/punk/framework/plugins/all.rb +3 -0
- data/lib/punk/framework/plugins/validation.rb +55 -0
- data/lib/punk/framework/runnable.rb +31 -0
- data/lib/punk/framework/service.rb +67 -0
- data/lib/punk/framework/view.rb +26 -0
- data/lib/punk/framework/worker.rb +34 -0
- data/lib/punk/helpers/all.rb +8 -0
- data/lib/punk/helpers/loggable.rb +79 -0
- data/lib/punk/helpers/publishable.rb +9 -0
- data/lib/punk/helpers/renderable.rb +75 -0
- data/lib/punk/helpers/swagger.rb +20 -0
- data/lib/punk/helpers/validatable.rb +57 -0
- data/lib/punk/plugins/all.rb +4 -0
- data/lib/punk/plugins/cors.rb +19 -0
- data/lib/punk/plugins/ssl.rb +13 -0
- data/lib/punk/startup/cache.rb +11 -0
- data/lib/punk/startup/database.rb +57 -0
- data/lib/punk/startup/environment.rb +10 -0
- data/lib/punk/startup/logger.rb +20 -0
- data/lib/punk/startup/task.rb +10 -0
- data/lib/punk/templates/fail.jbuilder +4 -0
- data/lib/punk/templates/fail.rcsv +6 -0
- data/lib/punk/templates/fail.slim +9 -0
- data/lib/punk/templates/fail.xml.slim +6 -0
- data/lib/punk/templates/info.jbuilder +3 -0
- data/lib/punk/templates/info.rcsv +2 -0
- data/lib/punk/templates/info.slim +6 -0
- data/lib/punk/templates/info.xml.slim +3 -0
- data/lib/punk/views/all.rb +4 -0
- data/lib/punk/views/fail.rb +21 -0
- data/lib/punk/views/info.rb +20 -0
- data/punk.gemspec +246 -0
- metadata +747 -0
@@ -0,0 +1,233 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'roda'
|
4
|
+
require 'sidekiq/web'
|
5
|
+
require 'sidekiq/cron/web'
|
6
|
+
require 'rack/protection'
|
7
|
+
|
8
|
+
require_relative '../plugins/all'
|
9
|
+
|
10
|
+
# don't use rack session for sidekiq (we do our own auth when routing)
|
11
|
+
Sidekiq::Web.set(:sessions, false)
|
12
|
+
# TODO: need custom roda route csrf in sidekiq web forms
|
13
|
+
Sidekiq::Web.use(::Rack::Protection, use: :none, logging: true, logger: SemanticLogger['PUNK::RPT'])
|
14
|
+
|
15
|
+
module PUNK
|
16
|
+
def self.route(name, &block)
|
17
|
+
PUNK.profile_info("route", path: name) do
|
18
|
+
PUNK::App.route(name) do |r|
|
19
|
+
r.scope.instance_exec(&block)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
ROUTES = Tempfile.new("routes.json").path
|
25
|
+
PUNK.profile_info("generate", path: ROUTES) do
|
26
|
+
system "roda-parse_routes -f #{ROUTES} #{File.expand_path(File.join(PUNK.get.app.path, 'routes', '*'))}"
|
27
|
+
end
|
28
|
+
|
29
|
+
class App < Roda
|
30
|
+
include Loggable
|
31
|
+
|
32
|
+
PUBLIC = File.join(PUNK.get.app.path, '..', 'www')
|
33
|
+
INDEX = File.read(File.join(PUBLIC, 'index.html'))
|
34
|
+
REMOTE = PUNK.env.staging? || PUNK.env.production?
|
35
|
+
|
36
|
+
plugin :sessions, secret: [PUNK.get.cookie.secret].pack('H*'),
|
37
|
+
key: PUNK.get.cookie.key,
|
38
|
+
max_seconds: 1.year.to_i,
|
39
|
+
max_idle_sessions: 1.month.to_i,
|
40
|
+
cookie_options: {
|
41
|
+
same_site: REMOTE ? :strict : :lax,
|
42
|
+
secure: REMOTE,
|
43
|
+
max_age: 1.year.to_i
|
44
|
+
}
|
45
|
+
plugin :ssl if REMOTE
|
46
|
+
plugin :cors, PUNK.get.app.client
|
47
|
+
# plugin :route_csrf
|
48
|
+
plugin :all_verbs
|
49
|
+
plugin :class_level_routing
|
50
|
+
plugin :delegate
|
51
|
+
plugin :indifferent_params
|
52
|
+
plugin :json_parser
|
53
|
+
plugin :path_rewriter
|
54
|
+
plugin :slash_path_empty
|
55
|
+
plugin :multi_route
|
56
|
+
plugin :type_routing, types: { csv: 'text/csv' }, default_type: :json
|
57
|
+
plugin :route_list, file: ROUTES
|
58
|
+
plugin :disallow_file_uploads
|
59
|
+
plugin :public, root: PUBLIC
|
60
|
+
|
61
|
+
plugin :default_headers, 'Content-Type' => 'text/html', 'Content-Security-Policy' => "default-src 'self';img-src *", 'Strict-Transport-Security' => 'max-age=16070400;', 'X-Frame-Options' => 'deny', 'X-Content-Type-Options' => 'nosniff', 'X-XSS-Protection' => '1; mode=block'
|
62
|
+
|
63
|
+
plugin :error_handler
|
64
|
+
plugin :not_found
|
65
|
+
plugin :hooks
|
66
|
+
|
67
|
+
request_delegate :root, :on, :is, :get, :post, :redirect, :patch, :delete
|
68
|
+
|
69
|
+
rewrite_path(/\A\/?\z/, '/index.html')
|
70
|
+
rewrite_path(/\A\/api\/?\z/, '/api/index.html')
|
71
|
+
|
72
|
+
def require_session!
|
73
|
+
begin
|
74
|
+
# TODO
|
75
|
+
@_current_session = nil #Session[request.session['session_id']]
|
76
|
+
if @_current_session&.active?
|
77
|
+
@_current_session.touch
|
78
|
+
else
|
79
|
+
clear_session
|
80
|
+
@_current_session = nil
|
81
|
+
end
|
82
|
+
rescue StandardError => e
|
83
|
+
exception(e)
|
84
|
+
raise Unauthorized, e.message
|
85
|
+
end
|
86
|
+
raise Unauthorized, 'Session does not exist' if @_current_session.nil?
|
87
|
+
PUNK.logger.info "require_session!", { current_session: current_session.inspect, current_identity: current_identity.inspect, current_user: current_user.inspect }.inspect
|
88
|
+
end
|
89
|
+
|
90
|
+
def require_anonymous!
|
91
|
+
raise BadRequest, "Session already exists" if request.session.present?
|
92
|
+
PUNK.logger.info "require_anonymous!"
|
93
|
+
end
|
94
|
+
|
95
|
+
def require_tenant!
|
96
|
+
raise Unauthorized, 'Session does not exist' if @_current_session.nil?
|
97
|
+
@_current_tenant = current_user.tenants_dataset[id: params[:tenant_id]]
|
98
|
+
PUNK.logger.info "require_tenant!", { current_tenant: @_current_tenant.inspect }.inspect
|
99
|
+
end
|
100
|
+
|
101
|
+
def args
|
102
|
+
params.transform_keys(&:to_sym)
|
103
|
+
end
|
104
|
+
|
105
|
+
def current_session
|
106
|
+
@_current_session
|
107
|
+
end
|
108
|
+
|
109
|
+
def current_identity
|
110
|
+
@_current_session&.identity
|
111
|
+
end
|
112
|
+
|
113
|
+
def current_user
|
114
|
+
@_current_session&.user
|
115
|
+
end
|
116
|
+
|
117
|
+
def current_tenant
|
118
|
+
@_current_tenant
|
119
|
+
end
|
120
|
+
|
121
|
+
def perform(action_class, **kwargs)
|
122
|
+
raise InternalServerError, "Not an action: #{action_class}" unless action_class < Action
|
123
|
+
render action_class.perform(**kwargs)
|
124
|
+
end
|
125
|
+
|
126
|
+
def present(view_class, **kwargs)
|
127
|
+
raise InternalServerError, "Not a view: #{view_class}" unless view_class < View
|
128
|
+
render view_class.present(**kwargs)
|
129
|
+
end
|
130
|
+
|
131
|
+
PUNK_CONTENT_TYPE_LOOKUP = {
|
132
|
+
csv: 'text/csv',
|
133
|
+
html: 'text/html',
|
134
|
+
json: 'application/json',
|
135
|
+
xml: 'application/xml'
|
136
|
+
}.freeze
|
137
|
+
def render(view)
|
138
|
+
raise InternalServerError, "Not a view: #{view}" unless view.is_a?(View)
|
139
|
+
format = request.requested_type
|
140
|
+
view.profile_info("render", format: format) do
|
141
|
+
response.status = view.status if view.is_a?(Fail)
|
142
|
+
response['Content-Type'] = PUNK_CONTENT_TYPE_LOOKUP[format]
|
143
|
+
view.render(format)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
error do |e|
|
148
|
+
exception(e, current_user: current_user)
|
149
|
+
case e
|
150
|
+
when BadRequest
|
151
|
+
present Fail, message: 'Bad Request', error_messages: [e.message], status: 400
|
152
|
+
when Unauthorized
|
153
|
+
present Fail, message: "Unauthorized", error_messages: [e.message], status: 401
|
154
|
+
when Forbidden
|
155
|
+
present Fail, message: "Forbidden", error_messages: [e.message], status: 403
|
156
|
+
when NotFound
|
157
|
+
present Fail, message: "Not Found", error_messages: [e.message], status: 404
|
158
|
+
when NotImplemented
|
159
|
+
present Fail, message: "Not Implemented", error_messages: [e.message], status: 501
|
160
|
+
when InternalServerError
|
161
|
+
present Fail, message: "Internal Server Error", error_messages: [e.message], status: 500
|
162
|
+
else
|
163
|
+
present Fail, message: "Unknown Failure", error_messages: [e.message], status: 500
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
not_found do
|
168
|
+
raise NotFound, "Cannot serve #{request.path}" unless request.is_get?
|
169
|
+
response.status = 200
|
170
|
+
INDEX
|
171
|
+
end
|
172
|
+
|
173
|
+
before do
|
174
|
+
@_started = Time.now.utc
|
175
|
+
name = "#{request.request_method} #{request.path}"
|
176
|
+
logger.info "Started #{name} for #{request.ip}", params.deep_symbolize_keys.sanitize.inspect
|
177
|
+
logger.trace request.env['HTTP_USER_AGENT']
|
178
|
+
# TODO
|
179
|
+
# logger.info "Started #{name} for #{request.ip || Session.default_values[:remote_addr].to_s}", params.deep_symbolize_keys.sanitize.inspect
|
180
|
+
# logger.trace request.env['HTTP_USER_AGENT'] || Session.default_values[:user_agent]
|
181
|
+
logger.trace request.env['HTTP_COOKIE']
|
182
|
+
logger.push_tags(name)
|
183
|
+
_set_cookie(request.env)
|
184
|
+
end
|
185
|
+
|
186
|
+
after do |response|
|
187
|
+
status, headers, _body = response
|
188
|
+
name = logger.pop_tags(1).join
|
189
|
+
duration = 1000.0 * (Time.now.utc - @_started)
|
190
|
+
logger.pop_tags
|
191
|
+
logger.trace((headers.present? ? headers['Set-Cookie'] : nil) || "(no cookie set)")
|
192
|
+
logger.info message: "Completed #{name} status #{status}", duration: duration
|
193
|
+
_store_cookie(headers)
|
194
|
+
end
|
195
|
+
|
196
|
+
route do |r|
|
197
|
+
r.public
|
198
|
+
r.on 'jobs' do
|
199
|
+
require_session!
|
200
|
+
r.run Sidekiq::Web
|
201
|
+
end
|
202
|
+
r.multi_route
|
203
|
+
end
|
204
|
+
|
205
|
+
private
|
206
|
+
|
207
|
+
def _set_cookie(headers)
|
208
|
+
return if REMOTE
|
209
|
+
return if headers.blank?
|
210
|
+
return if headers['HTTP_USER_AGENT'].present?
|
211
|
+
return if headers['HTTP_COOKIE'].present?
|
212
|
+
cookie_file = 'tmp/.cookie-jar'
|
213
|
+
cookie_file += '-test' if PUNK.env.test?
|
214
|
+
cookie = File.read(cookie_file) if File.exist?(cookie_file)
|
215
|
+
return if cookie.blank?
|
216
|
+
headers['HTTP_COOKIE'] = cookie
|
217
|
+
end
|
218
|
+
|
219
|
+
def _store_cookie(headers)
|
220
|
+
return if REMOTE
|
221
|
+
return if headers.blank?
|
222
|
+
cookie = headers['Set-Cookie']
|
223
|
+
return if cookie.blank?
|
224
|
+
cookie_file = 'tmp/.cookie-jar'
|
225
|
+
cookie_file += '-test' if PUNK.env.test?
|
226
|
+
if cookie =~ /max-age=0/
|
227
|
+
File.delete(cookie_file)
|
228
|
+
else
|
229
|
+
File.open(cookie_file, 'w') { |file| file << cookie }
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../punk'
|
4
|
+
|
5
|
+
require 'highline/import'
|
6
|
+
|
7
|
+
PUNK.init(task: 'console', config: { app: { name: 'Punk!' } }).exec
|
8
|
+
|
9
|
+
PUNK.commands(:pry)
|
10
|
+
|
11
|
+
say HighLine.color('Commands: "reload!", "perform [action]", "present [view]", "run [service]". Type "help punk" for more.', :green, :bold)
|
12
|
+
|
13
|
+
PUNK.db.loggers.first.level = :debug
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
command :test do |c|
|
4
|
+
c.description = 'Run rubocop and rspec'
|
5
|
+
c.action do
|
6
|
+
say('Running tests...')
|
7
|
+
warn('!!! PUNK_ENV should be test !!!') unless ENV['PUNK_ENV'] == 'test'
|
8
|
+
ENV.delete_if { |name, _value| name =~ /^PUNK_/ }
|
9
|
+
system('rubocop') &&
|
10
|
+
system('quasar build -m pwa') &&
|
11
|
+
system('PUNK_ENV=test rspec')
|
12
|
+
exit $CHILD_STATUS.exitstatus # rubocop:disable Rails/Exit
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
command :console do |c|
|
17
|
+
c.description = 'Launch the console'
|
18
|
+
c.action do
|
19
|
+
say(HighLine.color('🤘 Are you ready to rock?', :green, :bold))
|
20
|
+
path = File.join(__dir__, 'cli.rb')
|
21
|
+
exec "pry -r #{path}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
alias_command :c, :console
|
25
|
+
|
26
|
+
command :server do |c|
|
27
|
+
c.description = 'Start the server'
|
28
|
+
c.action do
|
29
|
+
say('Starting server...')
|
30
|
+
exec 'puma -C ./config/puma.rb'
|
31
|
+
end
|
32
|
+
end
|
33
|
+
alias_command :s, :server
|
34
|
+
|
35
|
+
command :worker do |c|
|
36
|
+
c.description = 'Start the worker'
|
37
|
+
c.action do
|
38
|
+
say('Starting worker...')
|
39
|
+
path = File.join(__dir__, 'worker.rb')
|
40
|
+
exec "sidekiq -r #{path} -C ./config/sidekiq.yml"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
alias_command :w, :worker
|
44
|
+
|
45
|
+
command 'env dump' do |c|
|
46
|
+
c.description = 'Display the environment'
|
47
|
+
c.action do
|
48
|
+
say('Dumping env...')
|
49
|
+
ap PUNK.get # rubocop:disable Rails/Output
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
command 'db create' do |c|
|
54
|
+
c.description = 'Create the database (dropping it first if necessary)'
|
55
|
+
c.action do
|
56
|
+
say('Creating db...')
|
57
|
+
require 'sequel'
|
58
|
+
database = File.basename(PUNK.get.db.url)
|
59
|
+
server = PUNK.get.db.url.gsub(/[^\/]+$/, 'postgres')
|
60
|
+
pg = Sequel.connect(server)
|
61
|
+
pg.execute "DROP DATABASE IF EXISTS #{database}"
|
62
|
+
pg.execute "CREATE DATABASE #{database}"
|
63
|
+
pg.disconnect
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
command 'db migrate' do |c|
|
68
|
+
c.description = 'Run database migrations'
|
69
|
+
c.option '-r', 'Number of versions to migrate, +ve for up and -ve for down', '--relative NUMBER', Integer
|
70
|
+
c.action do |_args, options|
|
71
|
+
say('Migrating db...')
|
72
|
+
PUNK.boot
|
73
|
+
Sequel.extension :migration
|
74
|
+
if options.relative.nil?
|
75
|
+
Sequel::Migrator.run(PUNK.db, File.join(PUNK.get.app.path, 'migrations'))
|
76
|
+
else
|
77
|
+
Sequel::Migrator.run(PUNK.db, File.join(PUNK.get.app.path, 'migrations'), relative: options.relative)
|
78
|
+
end
|
79
|
+
database = File.basename(PUNK.get.db.url)
|
80
|
+
`pg_dump #{database} --schema-only > schema.psql`
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../framework/command'
|
4
|
+
|
5
|
+
module PUNK
|
6
|
+
def self.commands(target, scope=nil)
|
7
|
+
path = File.expand_path(File.join(__dir__, '..', 'commands'))
|
8
|
+
PUNK.profile_debug("commands", path: path) do
|
9
|
+
Dir.glob(File.join(path, '**/*.rb')).sort.each { |file| require(file) }
|
10
|
+
end
|
11
|
+
path = File.expand_path(File.join(PUNK.get.app.path, 'commands'))
|
12
|
+
PUNK.profile_debug("commands", path: path) do
|
13
|
+
Dir.glob(File.join(path, '**/*.rb')).sort.each { |file| require(file) }
|
14
|
+
end
|
15
|
+
case target
|
16
|
+
when :commander
|
17
|
+
require_relative 'commander'
|
18
|
+
Command.commander
|
19
|
+
when :pry
|
20
|
+
require_relative 'pry'
|
21
|
+
Command.pry
|
22
|
+
when :spec
|
23
|
+
Command.spec(scope)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,290 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/string_inquirer'
|
4
|
+
|
5
|
+
require 'date'
|
6
|
+
require 'fileutils'
|
7
|
+
require 'dotenv'
|
8
|
+
|
9
|
+
module PUNK
|
10
|
+
class Env < Settings
|
11
|
+
def logger
|
12
|
+
SemanticLogger['PUNK::Env']
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(*args)
|
16
|
+
super(*args)
|
17
|
+
return unless args.empty?
|
18
|
+
@loaded = false
|
19
|
+
parent_methods = Module.new do
|
20
|
+
def to_s
|
21
|
+
raise InternalServerError, "Environment not yet loaded" unless @loaded
|
22
|
+
env.to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_sym
|
26
|
+
raise InternalServerError, "Environment not yet loaded" unless @loaded
|
27
|
+
env.to_sym
|
28
|
+
end
|
29
|
+
|
30
|
+
def env
|
31
|
+
ActiveSupport::StringInquirer.new(self[:env].to_s)
|
32
|
+
end
|
33
|
+
|
34
|
+
def task
|
35
|
+
ActiveSupport::StringInquirer.new(self[:task].to_s)
|
36
|
+
end
|
37
|
+
|
38
|
+
def load!
|
39
|
+
return if @loaded
|
40
|
+
_load
|
41
|
+
@loaded = true
|
42
|
+
end
|
43
|
+
end
|
44
|
+
extend(parent_methods)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def _load
|
50
|
+
_load_environment(File.join(PUNK.store.args.path, '..', 'env'), ENV.fetch('PUNK_ENV'), PUNK.store.args.task)
|
51
|
+
@schema = {}
|
52
|
+
_load_schemas(File.join(__dir__, '..', 'config'), ENV.fetch('PUNK_ENV'), PUNK.store.args.task)
|
53
|
+
_load_schemas(File.join(PUNK.store.args.path, 'config'), ENV.fetch('PUNK_ENV'), PUNK.store.args.task)
|
54
|
+
@values = @schema.keys.zip(Array.new(@schema.length, nil)).to_h
|
55
|
+
_add_config(
|
56
|
+
task: PUNK.store.args.task,
|
57
|
+
app: {
|
58
|
+
path: PUNK.store.args.path
|
59
|
+
}
|
60
|
+
)
|
61
|
+
_load_configs(File.join(__dir__, '..', 'config'), ENV.fetch('PUNK_ENV'), PUNK.store.args.task)
|
62
|
+
_load_configs(File.join(PUNK.store.args.path, 'config'), ENV.fetch('PUNK_ENV'), PUNK.store.args.task)
|
63
|
+
_add_environment
|
64
|
+
_add_arguments
|
65
|
+
_validate
|
66
|
+
load(_unflatten(@values))
|
67
|
+
@loaded = true
|
68
|
+
end
|
69
|
+
|
70
|
+
def _flatten(data)
|
71
|
+
results = []
|
72
|
+
data.each do |key, value|
|
73
|
+
if value.is_a?(Hash)
|
74
|
+
_flatten(value).each do |k, v|
|
75
|
+
results << [[key.to_sym, k].flatten, v]
|
76
|
+
end
|
77
|
+
else
|
78
|
+
results << [[key.to_sym], value]
|
79
|
+
end
|
80
|
+
end
|
81
|
+
results.to_h
|
82
|
+
end
|
83
|
+
|
84
|
+
def _unflatten(data)
|
85
|
+
results = {}
|
86
|
+
data.each do |key, value|
|
87
|
+
name = key.join('.')
|
88
|
+
search = results
|
89
|
+
while key.length > 1
|
90
|
+
item = key.shift
|
91
|
+
search[item] ||= {}
|
92
|
+
unless search[item].is_a?(Hash)
|
93
|
+
logger.warn "Skipping #{name} due to conflicted nesting"
|
94
|
+
next
|
95
|
+
end
|
96
|
+
search = search[item]
|
97
|
+
end
|
98
|
+
search[key[0]] = value
|
99
|
+
end
|
100
|
+
results
|
101
|
+
end
|
102
|
+
|
103
|
+
def _load_schema(path)
|
104
|
+
return unless File.exist?(path)
|
105
|
+
logger.trace "Loading schema #{path}..."
|
106
|
+
_flatten(ActiveSupport::JSON.decode(File.read(path))).each do |key, value|
|
107
|
+
override = true
|
108
|
+
required = false
|
109
|
+
key.map! do |name|
|
110
|
+
match = /^(_?)([a-z_]+)(!?)$/.match(name)
|
111
|
+
raise InternalServerError, "Invalid schema key: #{key.join('.')}" if match.nil?
|
112
|
+
override &&= match[1] != '_'
|
113
|
+
required ||= match[3] == '!'
|
114
|
+
match[2].to_sym
|
115
|
+
end
|
116
|
+
raise InternalServerError, "Duplicate schema key: #{key.join('.')}" if @schema.key?(key)
|
117
|
+
@schema[key] = {
|
118
|
+
required: required,
|
119
|
+
override: override,
|
120
|
+
validate: value
|
121
|
+
}
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def _load_schemas(base, name=nil, dir=nil)
|
126
|
+
base = File.expand_path(base)
|
127
|
+
_load_schema(File.join(base, 'schema.json'))
|
128
|
+
_load_schema(File.join(base, "schema_#{name}.json")) unless name.nil?
|
129
|
+
_load_schemas(File.join(base, dir), name) unless dir.nil?
|
130
|
+
end
|
131
|
+
|
132
|
+
def _validate
|
133
|
+
@schema.each do |key, value|
|
134
|
+
next unless value[:required]
|
135
|
+
current_value = @values[key]
|
136
|
+
raise InternalServerError, "Missing required configuration value: #{key.join('.')}" if current_value.nil?
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def _typecast(key, value, validation, required)
|
141
|
+
valid = false
|
142
|
+
match = /^\$([A-Z][A-Z_]+[A-Z])$/.match(value) if value.is_a?(String)
|
143
|
+
if match
|
144
|
+
value = ENV.fetch(match[1], nil)
|
145
|
+
raise InternalServerError, "Env var does not exist: #{match[1]}" if value.nil? && required
|
146
|
+
end
|
147
|
+
value =
|
148
|
+
case validation
|
149
|
+
when /^Enum\((.*)\)$/
|
150
|
+
values = Regexp.last_match(1).split(/ *, */)
|
151
|
+
valid = values.include?(value)
|
152
|
+
value.to_sym
|
153
|
+
when /^Symbol\(\/(.*)\/\)$/
|
154
|
+
regexp = Regexp.last_match(1)
|
155
|
+
valid = value =~ /#{regexp}/
|
156
|
+
value.to_sym
|
157
|
+
when /^Symbol$/
|
158
|
+
valid = true
|
159
|
+
value.to_sym
|
160
|
+
when /^String\(\/(.*)\/\)$/
|
161
|
+
regexp = Regexp.last_match(1)
|
162
|
+
valid = value =~ /#{regexp}/
|
163
|
+
value.to_s
|
164
|
+
when /^String$/
|
165
|
+
valid = true
|
166
|
+
value.to_s
|
167
|
+
when /^Dir\(\/(.*)\/\)$/
|
168
|
+
regexp = Regexp.last_match(1)
|
169
|
+
valid = value =~ /#{regexp}/
|
170
|
+
FileUtils.mkdir_p(value)
|
171
|
+
valid &&= Dir.exist?(value)
|
172
|
+
value
|
173
|
+
when /^Dir$/
|
174
|
+
FileUtils.mkdir_p(value)
|
175
|
+
valid = Dir.exist?(value)
|
176
|
+
value
|
177
|
+
when /^File\(\/(.*)\/\)$/
|
178
|
+
regexp = Regexp.last_match(1)
|
179
|
+
valid = value =~ /#{regexp}/
|
180
|
+
dir = File.dirname(value)
|
181
|
+
FileUtils.mkdir_p(dir)
|
182
|
+
FileUtils.touch(value)
|
183
|
+
valid &&= File.file?(value)
|
184
|
+
value
|
185
|
+
when /^File$/
|
186
|
+
dir = File.dirname(value)
|
187
|
+
FileUtils.mkdir_p(dir)
|
188
|
+
FileUtils.touch(value)
|
189
|
+
valid = File.file?(value)
|
190
|
+
value
|
191
|
+
when /^Flag$/
|
192
|
+
valid = value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
193
|
+
value
|
194
|
+
when /^Integer$/
|
195
|
+
valid = value.is_a?(Integer)
|
196
|
+
value
|
197
|
+
when /^Float$/
|
198
|
+
valid = value.is_a?(Float) || value.is_a?(Integer)
|
199
|
+
value.to_f
|
200
|
+
when /^Date$/
|
201
|
+
valid = Date.parse(value)
|
202
|
+
value
|
203
|
+
when /^Email$/
|
204
|
+
valid = value =~ /^[^@]+@[^@]+$/
|
205
|
+
value
|
206
|
+
when /^URI$/
|
207
|
+
valid = value =~ /^[a-z]+:\/\/[^ ]+$/
|
208
|
+
value
|
209
|
+
when /^Array$/
|
210
|
+
valid = value.is_a?(Array)
|
211
|
+
value
|
212
|
+
else
|
213
|
+
raise InternalServerError, "Unknown validation: #{validation}"
|
214
|
+
end
|
215
|
+
raise InternalServerError, "#{value} is invalid for #{key.join('.')}: #{validation}" unless valid
|
216
|
+
value
|
217
|
+
end
|
218
|
+
|
219
|
+
def _add_value(key, value)
|
220
|
+
schema = @schema[key]
|
221
|
+
raise InternalServerError, "Configuration not found in schema: #{key.join('.')}" if schema.nil?
|
222
|
+
unless schema[:override]
|
223
|
+
current_value = @values[key]
|
224
|
+
raise InternalServerError, "Cannot override configuration value: #{key.join('.')}" unless current_value.nil?
|
225
|
+
end
|
226
|
+
@values[key] = _typecast(key, value, schema[:validate], schema[:required])
|
227
|
+
end
|
228
|
+
|
229
|
+
def _add_config(data)
|
230
|
+
_flatten(data).each do |key, value|
|
231
|
+
_add_value(key, value)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def _load_config(path)
|
236
|
+
return unless File.exist?(path)
|
237
|
+
logger.trace "Loading config #{path}..."
|
238
|
+
_add_config(ActiveSupport::JSON.decode(File.read(path)))
|
239
|
+
end
|
240
|
+
|
241
|
+
def _load_configs(base, name=nil, dir=nil)
|
242
|
+
base = File.expand_path(base)
|
243
|
+
_load_config(File.join(base, 'defaults.json'))
|
244
|
+
_load_config(File.join(base, "#{name}.json")) unless name.nil?
|
245
|
+
_load_configs(File.join(base, dir), name) unless dir.nil?
|
246
|
+
end
|
247
|
+
|
248
|
+
def _load_dotenv(path)
|
249
|
+
return unless File.exist?(path)
|
250
|
+
logger.trace "Loading dotenv #{path}..."
|
251
|
+
Dotenv.load(path)
|
252
|
+
end
|
253
|
+
|
254
|
+
def _load_environment(base, name=nil, dir=nil)
|
255
|
+
base = File.expand_path(base)
|
256
|
+
_load_environment(File.join(base, dir), name) unless dir.nil?
|
257
|
+
_load_dotenv(File.join(base, 'locals.sh'))
|
258
|
+
_load_dotenv(File.join(base, "#{name}.sh"))
|
259
|
+
_load_dotenv(File.join(base, 'defaults.sh'))
|
260
|
+
end
|
261
|
+
|
262
|
+
def _add_environment
|
263
|
+
ENV.each do |key, value|
|
264
|
+
key = key.downcase
|
265
|
+
match = /^punk_(.*)$/.match(key)
|
266
|
+
next unless match
|
267
|
+
key = match[1].split('_').reject(&:empty?).map(&:to_sym)
|
268
|
+
_add_value(key, value)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
def _add_arguments
|
273
|
+
_add_config(PUNK.store.args.config)
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
def self.env
|
278
|
+
PUNK.get.env
|
279
|
+
end
|
280
|
+
|
281
|
+
def self.task
|
282
|
+
PUNK.get.task
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
PUNK::Interface.register(:get) do
|
287
|
+
PUNK::Env.new
|
288
|
+
end
|
289
|
+
|
290
|
+
PUNK.inject :get
|