rivulet-rb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/bin/rivulet +4 -0
  3. data/lib/rivulet/application.rb +90 -0
  4. data/lib/rivulet/cli/console.rb +15 -0
  5. data/lib/rivulet/cli/db/migrate.rb +17 -0
  6. data/lib/rivulet/cli/generate/handler/operation.rb +75 -0
  7. data/lib/rivulet/cli/generate/handler/step.rb +91 -0
  8. data/lib/rivulet/cli/generate/handler.rb +135 -0
  9. data/lib/rivulet/cli/generate/migration.rb +31 -0
  10. data/lib/rivulet/cli/generate/operation.rb +111 -0
  11. data/lib/rivulet/cli/generate/resource.rb +25 -0
  12. data/lib/rivulet/cli/generate/service/operation.rb +118 -0
  13. data/lib/rivulet/cli/generate/service/projection.rb +89 -0
  14. data/lib/rivulet/cli/generate/service/step.rb +91 -0
  15. data/lib/rivulet/cli/generate/service.rb +143 -0
  16. data/lib/rivulet/cli/new.rb +191 -0
  17. data/lib/rivulet/cli/routes.rb +15 -0
  18. data/lib/rivulet/cli.rb +43 -0
  19. data/lib/rivulet/container.rb +28 -0
  20. data/lib/rivulet/operation.rb +21 -0
  21. data/lib/rivulet/operations/dispatch_request.rb +21 -0
  22. data/lib/rivulet/operations/migrate.rb +23 -0
  23. data/lib/rivulet/operations/print_routes.rb +17 -0
  24. data/lib/rivulet/operations/run_console.rb +25 -0
  25. data/lib/rivulet/operations/startup.rb +23 -0
  26. data/lib/rivulet/projection.rb +6 -0
  27. data/lib/rivulet/request.rb +18 -0
  28. data/lib/rivulet/response.rb +12 -0
  29. data/lib/rivulet/routing/mapper.rb +78 -0
  30. data/lib/rivulet/routing/route.rb +14 -0
  31. data/lib/rivulet/step.rb +22 -0
  32. data/lib/rivulet/steps/build_config.rb +26 -0
  33. data/lib/rivulet/steps/build_context.rb +76 -0
  34. data/lib/rivulet/steps/compile_response.rb +113 -0
  35. data/lib/rivulet/steps/dispatch.rb +17 -0
  36. data/lib/rivulet/steps/load_app.rb +20 -0
  37. data/lib/rivulet/steps/load_db.rb +24 -0
  38. data/lib/rivulet/steps/load_routes.rb +28 -0
  39. data/lib/rivulet/steps/load_settings.rb +42 -0
  40. data/lib/rivulet/steps/print_routes.rb +123 -0
  41. data/lib/rivulet/steps/run_console.rb +26 -0
  42. data/lib/rivulet/steps/run_migrations.rb +50 -0
  43. data/lib/rivulet/steps/validate_response.rb +42 -0
  44. data/lib/rivulet/telemetry/node.rb +8 -0
  45. data/lib/rivulet/telemetry/sequel_extension.rb +19 -0
  46. data/lib/rivulet/telemetry/timing_wrapper.rb +12 -0
  47. data/lib/rivulet/telemetry.rb +62 -0
  48. data/lib/rivulet/version.rb +3 -0
  49. data/lib/rivulet.rb +66 -0
  50. metadata +342 -0
@@ -0,0 +1,28 @@
1
+ module Rivulet
2
+ class Container
3
+ extend Dry::Core::Container::Mixin
4
+
5
+ namespace('operations') do
6
+ register('startup') { Rivulet::Operations::Startup.new }
7
+ register('dispatch_request') { Rivulet::Operations::DispatchRequest.new }
8
+ register('migrate') { Rivulet::Operations::Migrate.new }
9
+ register('run_console') { Rivulet::Operations::RunConsole.new }
10
+ register('print_routes') { Rivulet::Operations::PrintRoutes.new }
11
+ end
12
+
13
+ namespace('steps') do
14
+ register('build_config') { Rivulet::Steps::BuildConfig.new }
15
+ register('build_context') { Rivulet::Steps::BuildContext.new }
16
+ register('validate_response') { Rivulet::Steps::ValidateResponse.new }
17
+ register('compile_response') { Rivulet::Steps::CompileResponse.new }
18
+ register('dispatch') { Rivulet::Steps::Dispatch.new }
19
+ register('load_app') { Rivulet::Steps::LoadApp.new }
20
+ register('load_settings') { Rivulet::Steps::LoadSettings.new }
21
+ register('load_db') { Rivulet::Steps::LoadDb.new }
22
+ register('load_routes') { Rivulet::Steps::LoadRoutes.new }
23
+ register('run_migrations') { Rivulet::Steps::RunMigrations.new }
24
+ register('run_console') { Rivulet::Steps::RunConsole.new }
25
+ register('print_routes') { Rivulet::Steps::PrintRoutes.new }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,21 @@
1
+ module Rivulet
2
+ class Operation < Dry::Operation
3
+ def self.container_class_path
4
+ self
5
+ .name
6
+ .split('::')
7
+ .then { |path| path[0...path.index('Operations')] }
8
+ .push('Container')
9
+ .inject(Object) { |mod, name| mod.const_get(name) }
10
+ end
11
+
12
+ def self.inherited(subclass)
13
+ super
14
+
15
+ subclass.prepend(Rivulet::Telemetry::TimingWrapper)
16
+ subclass.const_set(
17
+ :Import, Dry::AutoInject(subclass.container_class_path)
18
+ )
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ module Rivulet
2
+ module Operations
3
+ class DispatchRequest < Rivulet::Operation
4
+ include Import[
5
+ build_context: 'steps.build_context',
6
+ dispatch: 'steps.dispatch',
7
+ validate_response: 'steps.validate_response',
8
+ compile_response: 'steps.compile_response'
9
+ ]
10
+
11
+ def call(input = {})
12
+ result = step build_context.(input)
13
+ result = step dispatch.(result)
14
+ result = step validate_response.(result)
15
+ result = step compile_response.(result)
16
+
17
+ result
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ module Rivulet
2
+ module Operations
3
+ class Migrate < Rivulet::Operation
4
+ include Import[
5
+ build_config: 'steps.build_config',
6
+ load_settings: 'steps.load_settings',
7
+ load_app: 'steps.load_app',
8
+ load_db: 'steps.load_db',
9
+ run_migrations: 'steps.run_migrations'
10
+ ]
11
+
12
+ def call(input = {})
13
+ result = step build_config.(input)
14
+ result = step load_settings.(result)
15
+ result = step load_db.(result)
16
+ result = step load_app.(result)
17
+ result = step run_migrations.(result)
18
+
19
+ result
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ module Rivulet
2
+ module Operations
3
+ class PrintRoutes < Rivulet::Operation
4
+ include Import[
5
+ load_routes: 'steps.load_routes',
6
+ print_routes: 'steps.print_routes'
7
+ ]
8
+
9
+ def call(input = {})
10
+ result = step load_routes.(input)
11
+ result = step print_routes.(result)
12
+
13
+ result
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ module Rivulet
2
+ module Operations
3
+ class RunConsole < Rivulet::Operation
4
+ include Import[
5
+ build_config: 'steps.build_config',
6
+ load_settings: 'steps.load_settings',
7
+ load_app: 'steps.load_app',
8
+ load_db: 'steps.load_db',
9
+ load_routes: 'steps.load_routes',
10
+ run_console: 'steps.run_console'
11
+ ]
12
+
13
+ def call(input = {})
14
+ result = step build_config.(input)
15
+ result = step load_settings.(result)
16
+ result = step load_db.(result)
17
+ result = step load_app.(result)
18
+ result = step load_routes.(result)
19
+ result = step run_console.(result)
20
+
21
+ result
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,23 @@
1
+ module Rivulet
2
+ module Operations
3
+ class Startup < Rivulet::Operation
4
+ include Import[
5
+ build_config: 'steps.build_config',
6
+ load_settings: 'steps.load_settings',
7
+ load_app: 'steps.load_app',
8
+ load_db: 'steps.load_db',
9
+ load_routes: 'steps.load_routes'
10
+ ]
11
+
12
+ def call(input = {})
13
+ result = step build_config.(input)
14
+ result = step load_settings.(result)
15
+ result = step load_db.(result)
16
+ result = step load_app.(result)
17
+ result = step load_routes.(result)
18
+
19
+ result
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ module Rivulet
2
+ class Projection < Dry::Transformer::Pipe
3
+ import Dry::Transformer::ArrayTransformations
4
+ import Dry::Transformer::HashTransformations
5
+ end
6
+ end
@@ -0,0 +1,18 @@
1
+ module Rivulet
2
+ class Request
3
+ attr_reader :http_method, :path, :content_type, :body, :session, :env
4
+
5
+ def initialize(env)
6
+ @http_method = env['REQUEST_METHOD'].downcase.to_sym
7
+ @path = env['PATH_INFO']
8
+ @content_type = env['CONTENT_TYPE']
9
+ @body = env['rack.input']
10
+ @session = env['rack.session']
11
+ @env = env
12
+ end
13
+
14
+ def cookies
15
+ Rack::Utils.parse_cookies(env)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ module Rivulet
2
+ class Response
3
+ attr_accessor :status, :headers, :body, :format
4
+
5
+ def initialize(status: 200, headers: {}, body: [], format: :json)
6
+ @status = status
7
+ @headers = headers
8
+ @body = body
9
+ @format = format
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,78 @@
1
+ module Rivulet
2
+ module Routing
3
+ class Mapper
4
+ include Enumerable
5
+
6
+ def each(&block)
7
+ @routes.each(&block)
8
+ end
9
+
10
+ def initialize(routes, prefix: '', scopes: [])
11
+ @routes = routes
12
+ @prefix = prefix
13
+ @scopes = scopes
14
+ end
15
+
16
+ %i[get post put patch delete].each do |method|
17
+ define_method(method) do |path, to:|
18
+ handler_name = case to
19
+ when String then to
20
+ when Hash then "#{to[:to]}#{'#' + to[:action] if to[:action]}"
21
+ else nil
22
+ end
23
+
24
+ route_path = join(@prefix, path.to_s)
25
+ path_regex, param_names = compile_path(route_path)
26
+
27
+ @routes << Route.new(
28
+ http_method: method,
29
+ path: route_path,
30
+ callable: build_callable(to),
31
+ scopes: @scopes,
32
+ handler_name: handler_name,
33
+ path_regex: path_regex,
34
+ param_names: param_names
35
+ )
36
+ end
37
+ end
38
+
39
+ def draw(&block)
40
+ instance_eval(&block)
41
+ end
42
+
43
+ def scope(name, &block)
44
+ Mapper.new(
45
+ @routes,
46
+ prefix: join(@prefix, name.to_s),
47
+ scopes: @scopes + [name]
48
+ ).instance_eval(&block)
49
+ end
50
+
51
+ def build_callable(to)
52
+ case to
53
+ when String
54
+ handler, action = to.split('#')
55
+ when Hash
56
+ handler, action = to.values_at(:to, :action)
57
+ else
58
+ raise 'Cannot parse route handler'
59
+ end
60
+
61
+ ->(input) { ::Handlers[handler].send(action, input) }
62
+ end
63
+
64
+ private
65
+
66
+ def join(*parts)
67
+ '/' + parts.flat_map { |p| p.to_s.split('/') }.reject(&:empty?).join('/')
68
+ end
69
+
70
+ def compile_path(path)
71
+ param_names = path.scan(/:([^\/]+)/).flatten.map(&:to_sym)
72
+ regex = /\A#{Regexp.escape(path).gsub(/:[^\/]+/, '([^/]+)')}\z/
73
+
74
+ [regex, param_names]
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,14 @@
1
+ module Rivulet
2
+ module Routing
3
+ Route = Struct.new(
4
+ :http_method,
5
+ :path,
6
+ :callable,
7
+ :scopes,
8
+ :handler_name,
9
+ :path_regex,
10
+ :param_names,
11
+ keyword_init: true
12
+ )
13
+ end
14
+ end
@@ -0,0 +1,22 @@
1
+ module Rivulet
2
+ class Step
3
+ include Dry::Monads[:result]
4
+
5
+ def self.container_class_path
6
+ self
7
+ .name
8
+ .split('::')
9
+ .then { |path| path[0...path.index('Steps')] }
10
+ .push('Container')
11
+ .inject(Object) { |mod, name| mod.const_get(name) }
12
+ end
13
+
14
+ def self.inherited(subclass)
15
+ super
16
+ subclass.prepend(Rivulet::Telemetry::TimingWrapper)
17
+ subclass.const_set(
18
+ :Import, Dry::AutoInject(subclass.container_class_path)
19
+ )
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ module Rivulet
2
+ module Steps
3
+ class BuildConfig < Rivulet::Step
4
+ def call(input)
5
+ Rivulet::Application.setting :database do
6
+ setting :dsn
7
+ setting :pool
8
+ end
9
+
10
+ Rivulet::Application.setting :logger, reader: true do
11
+ setting :engine
12
+ setting :name
13
+ setting :level
14
+ end
15
+
16
+ Rivulet::Application.setting :sendfile do
17
+ setting :enabled, default: false
18
+ setting :variation, default: 'x-accel-redirect'
19
+ setting :mappings, default: []
20
+ end
21
+
22
+ Success(input)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,76 @@
1
+ module Rivulet
2
+ module Steps
3
+ class BuildContext < Rivulet::Step
4
+ def call(input)
5
+ input => { env:, resource: }
6
+
7
+ request = Rivulet::Request.new(env)
8
+ routes = resource.routes
9
+
10
+ route, path_match = find_route(routes, request)
11
+ return Failure[:route_not_found] unless route
12
+
13
+ input.merge!(
14
+ route: route,
15
+ params: build_params(route, request, path_match),
16
+ context: {
17
+ headers: extract_headers(request),
18
+ cookies: request.cookies,
19
+ session: request.session
20
+ }
21
+ )
22
+
23
+ resource.logger.info(
24
+ "Request #{request.http_method.upcase} #{request.path} #{input[:params]}"
25
+ )
26
+
27
+ Success(input)
28
+ end
29
+
30
+ private
31
+
32
+ def build_params(route, request, path_match)
33
+ path_params = extract_params(route, path_match)
34
+ body_params = parse_body(request)
35
+ body_params.merge(path_params)
36
+ end
37
+
38
+ def parse_body(request)
39
+ return {} unless request.content_type&.include?('application/json')
40
+ raw = request.body.read
41
+ return {} if raw.nil? || raw.empty?
42
+ Oj.load(raw, symbolize_names: true)
43
+ rescue Oj::ParseError
44
+ {}
45
+ end
46
+
47
+ def find_route(routes, request)
48
+ request_method = request.http_method
49
+ path_info = request.path
50
+
51
+ routes.each do |route|
52
+ next unless route.http_method == request_method
53
+
54
+ path_match = route.path_regex.match(path_info)
55
+ return [route, path_match] if path_match
56
+ end
57
+
58
+ [nil, nil]
59
+ end
60
+
61
+ def extract_headers(request)
62
+ request.env.each_with_object({}) do |(key, value), headers|
63
+ if key.start_with?('HTTP_')
64
+ headers[key[5..].split('_').map(&:capitalize).join('-')] = value
65
+ elsif (key == 'CONTENT_TYPE' || key == 'CONTENT_LENGTH') && !value.to_s.empty?
66
+ headers[key.split('_').map(&:capitalize).join('-')] = value
67
+ end
68
+ end
69
+ end
70
+
71
+ def extract_params(route, path_match)
72
+ route.param_names.zip(path_match.captures).to_h
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,113 @@
1
+ module Rivulet
2
+ module Steps
3
+ class CompileResponse < Rivulet::Step
4
+ def call(input)
5
+ input => { resource:, response: }
6
+
7
+ status = response.status
8
+ format = response.format
9
+
10
+ body, headers =
11
+ case format
12
+ when :json
13
+ payload = Oj.dump(response.body, mode: :json)
14
+
15
+ [
16
+ Protocol::HTTP::Body::Buffered.wrap(payload),
17
+ {
18
+ 'Content-Type' => 'application/json',
19
+ 'Content-Length' => payload.bytesize.to_s
20
+ }
21
+ ]
22
+ when :text
23
+ payload = response.body.to_s
24
+
25
+ [
26
+ Protocol::HTTP::Body::Buffered.wrap(payload),
27
+ {
28
+ 'Content-Type' => 'text/plain; charset=utf-8',
29
+ 'Content-Length' => payload.bytesize.to_s
30
+ }
31
+ ]
32
+ when :file
33
+ result = build_file_body(response.body, resource)
34
+ return result if result in Failure
35
+ result
36
+ when :stream
37
+ [Protocol::HTTP::Body::Stream.new(response.body), {}]
38
+ when :as_is
39
+ [response.body, {}]
40
+ else
41
+ [[], {}]
42
+ end
43
+
44
+ headers.merge!(response.headers)
45
+
46
+ input[:response] = [status, headers, body]
47
+
48
+ Success(input)
49
+ end
50
+
51
+ private
52
+
53
+ def build_file_body(body, app)
54
+ path = body.is_a?(Hash) ? body[:path] : body
55
+
56
+ if app.config.sendfile.enabled
57
+ build_sendfile_body(body, path, app.config.sendfile)
58
+ else
59
+ build_streaming_file_body(body, path)
60
+ end
61
+ end
62
+
63
+ def build_streaming_file_body(body, path)
64
+ file_body =
65
+ begin
66
+ Protocol::HTTP::Body::File.open(path)
67
+ rescue Errno::ENOENT, Errno::EACCES
68
+ return Failure[:file_not_found, "Cannot read file: #{path}"]
69
+ end
70
+
71
+ [file_body, file_headers(body, file_body.length)]
72
+ end
73
+
74
+ def build_sendfile_body(body, path, sendfile_config)
75
+ uri = map_sendfile_path(path, sendfile_config.mappings)
76
+ headers = file_headers(body, 0)
77
+ headers[sendfile_config.variation.downcase] = uri
78
+ [[], headers]
79
+ end
80
+
81
+ def map_sendfile_path(path, mappings)
82
+ return path if mappings.empty?
83
+
84
+ mappings.each do |internal, external|
85
+ mapped = path.sub(/\A#{Regexp.escape(internal)}/i, external)
86
+ return mapped unless mapped == path
87
+ end
88
+
89
+ path
90
+ end
91
+
92
+ def file_headers(body, length)
93
+ if body.is_a?(Hash)
94
+ path = body[:path]
95
+ filename = body.fetch(:filename) { File.basename(path) }
96
+ disposition = body.fetch(:disposition, 'inline')
97
+ mime_type = body.fetch(:mime_type) { Rack::Mime.mime_type(File.extname(filename)) }
98
+
99
+ {
100
+ 'Content-Type' => mime_type,
101
+ 'Content-Length' => length.to_s,
102
+ 'Content-Disposition' => "#{disposition}; filename=\"#{filename}\""
103
+ }
104
+ else
105
+ {
106
+ 'Content-Type' => Rack::Mime.mime_type(File.extname(body)),
107
+ 'Content-Length' => length.to_s
108
+ }
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,17 @@
1
+ module Rivulet
2
+ module Steps
3
+ class Dispatch < Rivulet::Step
4
+ def call(input)
5
+ input => { route:, params:, context: }
6
+
7
+ result = route.callable.call(params: params, context: context)
8
+
9
+ # We don't care if it's success or failure.
10
+ # The result will be a response anyway.
11
+ input[:response] = result.value_or(result.failure)
12
+
13
+ Success(input)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ require 'zeitwerk'
2
+
3
+ module Rivulet
4
+ module Steps
5
+ class LoadApp < Rivulet::Step
6
+ def call(input)
7
+ app_dir = File.expand_path('app')
8
+ return Success(input) unless Dir.exist?(app_dir)
9
+
10
+ loader = Zeitwerk::Loader.new
11
+ loader.push_dir(app_dir)
12
+ loader.push_dir("#{app_dir}/models")
13
+ loader.setup
14
+ loader.eager_load
15
+
16
+ Success(input)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ require 'sequel'
2
+ require_relative '../telemetry/sequel_extension'
3
+
4
+ module Rivulet
5
+ module Steps
6
+ class LoadDb < Rivulet::Step
7
+ def call(input)
8
+ app = input[:resource]
9
+ db_config = app.config.database
10
+ return Failure("Rivulet.config.database.dsn is required") if db_config&.dsn.to_s.empty?
11
+
12
+ pool = db_config.pool&.to_h || {}
13
+ db = Sequel.connect(db_config.dsn, **pool, logger: app.logger, sql_log_level: :debug)
14
+ db.extension(:rivulet_telemetry)
15
+ app.db = db
16
+
17
+ Sequel::Model.db = db
18
+ Sequel::Model.require_valid_table = false
19
+
20
+ Success(input)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ module Rivulet
2
+ module Steps
3
+ class LoadRoutes < Rivulet::Step
4
+ def call(input)
5
+ routes_file = File.expand_path('config/routes.rb')
6
+ return Failure("Routes file not found: #{routes_file}") unless File.exist?(routes_file)
7
+
8
+ load(routes_file)
9
+
10
+ duplicates = duplicate_routes(input[:resource].routes)
11
+ return Failure(duplicate_message(duplicates)) unless duplicates.empty?
12
+
13
+ Success(input)
14
+ end
15
+
16
+ private
17
+
18
+ def duplicate_routes(routes)
19
+ routes.group_by { |r| [r.http_method, r.path] }.select { |_, v| v.size > 1 }
20
+ end
21
+
22
+ def duplicate_message(duplicates)
23
+ list = duplicates.keys.map { |m, p| "#{m.to_s.upcase} #{p}" }.join(', ')
24
+ "Duplicate routes: #{list}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,42 @@
1
+ module Rivulet
2
+ module Steps
3
+ class LoadSettings < Rivulet::Step
4
+ def call(input)
5
+ app = input[:resource]
6
+ app_file = File.expand_path('config/application.rb')
7
+ return Failure(:settings_file_not_found) unless File.exist?(app_file)
8
+
9
+ load app_file
10
+
11
+ app.config.logger = app.config.logger.engine || default_logger(app)
12
+ app.config.finalize!
13
+
14
+ Success(input)
15
+ end
16
+
17
+ private
18
+
19
+ def default_logger(app)
20
+ Dry.Logger(app.config.logger.name, level: app.config.logger.level) do |setup|
21
+ setup.add_backend(
22
+ stream: $stdout,
23
+ log_if: :debug?,
24
+ template: '<gray>[%<severity>s]</gray> %<time>s %<message>s'
25
+ )
26
+
27
+ setup.add_backend(
28
+ stream: $stdout,
29
+ log_if: :info?,
30
+ template: '<blue>[%<severity>s]</blue> %<time>s %<message>s'
31
+ )
32
+
33
+ setup.add_backend(
34
+ stream: $stdout,
35
+ log_if: :error?,
36
+ template: '<red>[%<severity>s]</red> %<time>s %<message>s'
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end