kirei 0.2.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +74 -25
  3. data/bin/kirei +1 -1
  4. data/kirei.gemspec +5 -3
  5. data/lib/cli/commands/new_app/base_directories.rb +1 -1
  6. data/lib/cli/commands/new_app/execute.rb +3 -3
  7. data/lib/cli/commands/new_app/files/app.rb +16 -3
  8. data/lib/cli/commands/new_app/files/config_ru.rb +1 -1
  9. data/lib/cli/commands/new_app/files/db_rake.rb +50 -2
  10. data/lib/cli/commands/new_app/files/irbrc.rb +1 -1
  11. data/lib/cli/commands/new_app/files/rakefile.rb +1 -1
  12. data/lib/cli/commands/new_app/files/routes.rb +49 -12
  13. data/lib/cli/commands/new_app/files/sorbet_config.rb +1 -1
  14. data/lib/cli/commands/start.rb +1 -1
  15. data/lib/kirei/app.rb +76 -56
  16. data/lib/kirei/config.rb +4 -1
  17. data/lib/kirei/controller.rb +44 -0
  18. data/lib/kirei/errors/json_api_error.rb +25 -0
  19. data/lib/kirei/errors/json_api_error_source.rb +12 -0
  20. data/lib/kirei/logging/level.rb +33 -0
  21. data/lib/kirei/logging/logger.rb +198 -0
  22. data/lib/kirei/logging/metric.rb +40 -0
  23. data/lib/kirei/model/base_class_interface.rb +55 -0
  24. data/lib/kirei/{base_model.rb → model/class_methods.rb} +42 -108
  25. data/lib/kirei/model/human_id_generator.rb +40 -0
  26. data/lib/kirei/model.rb +52 -0
  27. data/lib/kirei/routing/base.rb +187 -0
  28. data/lib/kirei/routing/nilable_hooks_type.rb +10 -0
  29. data/lib/kirei/{middleware.rb → routing/rack_env_type.rb} +1 -10
  30. data/lib/kirei/routing/rack_response_type.rb +15 -0
  31. data/lib/kirei/routing/route.rb +13 -0
  32. data/lib/kirei/routing/router.rb +56 -0
  33. data/lib/kirei/routing/verb.rb +37 -0
  34. data/lib/kirei/services/result.rb +53 -0
  35. data/lib/kirei/services/runner.rb +47 -0
  36. data/lib/kirei/version.rb +1 -1
  37. data/lib/kirei.rb +31 -3
  38. data/sorbet/rbi/shims/base_model.rbi +1 -1
  39. data/sorbet/rbi/shims/ruby.rbi +15 -0
  40. metadata +55 -14
  41. data/lib/boot.rb +0 -23
  42. data/lib/kirei/app_base.rb +0 -72
  43. data/lib/kirei/base_controller.rb +0 -16
  44. data/lib/kirei/logger.rb +0 -196
  45. data/lib/kirei/router.rb +0 -61
@@ -0,0 +1,52 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Model
6
+ extend T::Sig
7
+ extend T::Helpers
8
+
9
+ sig { returns(Kirei::Model::BaseClassInterface) }
10
+ def class; super; end # rubocop:disable all
11
+
12
+ # An update keeps the original object intact, and returns a new object with the updated values.
13
+ sig do
14
+ params(
15
+ hash: T::Hash[Symbol, T.untyped],
16
+ ).returns(T.self_type)
17
+ end
18
+ def update(hash)
19
+ hash[:updated_at] = Time.now.utc if respond_to?(:updated_at) && hash[:updated_at].nil?
20
+ self.class.wrap_jsonb_non_primivitives!(hash)
21
+ self.class.query.where({ id: id }).update(hash)
22
+ self.class.find_by({ id: id })
23
+ end
24
+
25
+ # Delete keeps the original object intact. Returns true if the record was deleted.
26
+ # Calling delete multiple times will return false after the first (successful) call.
27
+ sig { returns(T::Boolean) }
28
+ def delete
29
+ count = self.class.query.where({ id: id }).delete
30
+ count == 1
31
+ end
32
+
33
+ # warning: this is not concurrency-safe
34
+ # save keeps the original object intact, and returns a new object with the updated values.
35
+ sig { returns(T.self_type) }
36
+ def save
37
+ previous_record = self.class.find_by({ id: id })
38
+
39
+ hash = serialize
40
+ Helpers.deep_symbolize_keys!(hash)
41
+ hash = T.cast(hash, T::Hash[Symbol, T.untyped])
42
+
43
+ if previous_record.nil?
44
+ self.class.create(hash)
45
+ else
46
+ update(hash)
47
+ end
48
+ end
49
+
50
+ mixes_in_class_methods(Kirei::Model::ClassMethods)
51
+ end
52
+ end
@@ -0,0 +1,187 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # rubocop:disable Metrics/all
5
+
6
+ module Kirei
7
+ module Routing
8
+ class Base
9
+ extend T::Sig
10
+
11
+ sig { params(params: T::Hash[String, T.untyped]).void }
12
+ def initialize(params: {})
13
+ @router = T.let(Router.instance, Router)
14
+ @params = T.let(params, T::Hash[String, T.untyped])
15
+ end
16
+
17
+ sig { returns(T::Hash[String, T.untyped]) }
18
+ attr_reader :params
19
+
20
+ sig { returns(Router) }
21
+ attr_reader :router; private :router
22
+
23
+ sig { params(env: RackEnvType).returns(RackResponseType) }
24
+ def call(env)
25
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
26
+
27
+ http_verb = Verb.deserialize(env.fetch("REQUEST_METHOD"))
28
+ req_path = T.cast(env.fetch("REQUEST_PATH"), String)
29
+ #
30
+ # TODO: reject requests from unexpected hosts -> allow configuring allowed hosts in a `cors.rb` file
31
+ # ( offer a scaffold for this file )
32
+ # -> use https://github.com/cyu/rack-cors ?
33
+ #
34
+
35
+ route = router.get(http_verb, req_path)
36
+ return [404, {}, ["Not Found"]] if route.nil?
37
+
38
+ params = case route.verb
39
+ when Verb::GET
40
+ query = T.cast(env.fetch("QUERY_STRING"), String)
41
+ query.split("&").to_h do |p|
42
+ k, v = p.split("=")
43
+ k = T.cast(k, String)
44
+ [k, v]
45
+ end
46
+ when Verb::POST, Verb::PUT, Verb::PATCH
47
+ # TODO: based on content-type, parse the body differently
48
+ # build-in support for JSON & XML
49
+ body = T.cast(env.fetch("rack.input"), T.any(IO, StringIO))
50
+ res = Oj.load(body.read, Kirei::OJ_OPTIONS)
51
+ body.rewind # TODO: maybe don't rewind if we don't need to?
52
+ T.cast(res, T::Hash[String, T.untyped])
53
+ else
54
+ Logging::Logger.logger.warn("Unsupported HTTP verb: #{http_verb.serialize} send to #{req_path}")
55
+ {}
56
+ end
57
+
58
+ req_id = T.cast(env["HTTP_X_REQUEST_ID"], T.nilable(String))
59
+ req_id ||= "req_#{App.environment}_#{SecureRandom.uuid}"
60
+ Thread.current[:request_id] = req_id
61
+
62
+ controller = route.controller
63
+ before_hooks = collect_hooks(controller, :before_hooks)
64
+ run_hooks(before_hooks)
65
+
66
+ Kirei::Logging::Logger.call(
67
+ level: Kirei::Logging::Level::INFO,
68
+ label: "Request Started",
69
+ meta: params,
70
+ )
71
+
72
+ statsd_timing_tags = {
73
+ "controller" => controller.name,
74
+ "route" => route.action,
75
+ }
76
+ Logging::Metric.inject_defaults(statsd_timing_tags)
77
+
78
+ status, headers, response_body = T.cast(
79
+ controller.new(params: params).public_send(route.action),
80
+ RackResponseType,
81
+ )
82
+
83
+ after_hooks = collect_hooks(controller, :after_hooks)
84
+ run_hooks(after_hooks)
85
+
86
+ headers["X-Request-Id"] ||= req_id
87
+
88
+ default_headers.each do |header_name, default_value|
89
+ headers[header_name] ||= default_value
90
+ end
91
+
92
+ [
93
+ status,
94
+ headers,
95
+ response_body,
96
+ ]
97
+ ensure
98
+ stop = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
99
+ if start # early return for 404
100
+ latency_in_ms = stop - start
101
+ ::StatsD.measure("request", latency_in_ms, tags: statsd_timing_tags)
102
+
103
+ Kirei::Logging::Logger.call(
104
+ level: Kirei::Logging::Level::INFO,
105
+ label: "Request Finished",
106
+ meta: { "response.body" => response_body, "response.latency_in_ms" => latency_in_ms },
107
+ )
108
+ end
109
+
110
+ # reset global variables after the request has been served
111
+ # and after all "after" hooks have run to avoid leaking
112
+ Thread.current[:enduser_id] = nil
113
+ Thread.current[:request_id] = nil
114
+ end
115
+
116
+ #
117
+ # * "status": defaults to 200
118
+ # * "headers": Kirei adds some default headers for security, but the user can override them
119
+ #
120
+ sig do
121
+ params(
122
+ body: String,
123
+ status: Integer,
124
+ headers: T::Hash[String, String],
125
+ ).returns(RackResponseType)
126
+ end
127
+ def render(body, status: 200, headers: {})
128
+ [
129
+ status,
130
+ headers,
131
+ [body],
132
+ ]
133
+ end
134
+
135
+ sig { returns(T::Hash[String, String]) }
136
+ def default_headers
137
+ # "Access-Control-Allow-Origin": the user should set that, see comment about "cors" above
138
+ {
139
+ # security relevant headers
140
+ "X-Frame-Options" => "DENY",
141
+ "X-Content-Type-Options" => "nosniff",
142
+ "X-XSS-Protection" => "1; mode=block", # for legacy clients/browsers
143
+ "Strict-Transport-Security" => "max-age=31536000; includeSubDomains", # for HTTPS
144
+ "Cache-Control" => "no-store", # the user should set that if caching is needed
145
+ "Referrer-Policy" => "strict-origin-when-cross-origin",
146
+ "Content-Security-Policy" => "default-src 'none'; frame-ancestors 'none'",
147
+
148
+ # other headers
149
+ "Content-Type" => "application/json; charset=utf-8",
150
+ }
151
+ end
152
+
153
+ sig { params(hooks: NilableHooksType).void }
154
+ private def run_hooks(hooks)
155
+ return if hooks.nil? || hooks.empty?
156
+
157
+ hooks.each(&:call)
158
+ end
159
+
160
+ sig do
161
+ params(
162
+ controller: T.class_of(Controller),
163
+ hooks_type: Symbol,
164
+ ).returns(NilableHooksType)
165
+ end
166
+ private def collect_hooks(controller, hooks_type)
167
+ result = T.let(Set.new, T::Set[T.proc.void])
168
+
169
+ controller.ancestors.reverse.each do |ancestor|
170
+ next unless ancestor < Controller
171
+
172
+ supported_hooks = %i[before_hooks after_hooks]
173
+ unless supported_hooks.include?(hooks_type)
174
+ raise "Unexpected hook type, got #{hooks_type}, expected one of: #{supported_hooks.join(",")}"
175
+ end
176
+
177
+ hooks = T.let(ancestor.public_send(hooks_type), NilableHooksType)
178
+ result.merge(hooks) if hooks&.any?
179
+ end
180
+
181
+ result
182
+ end
183
+ end
184
+ end
185
+ end
186
+
187
+ # rubocop:enable Metrics/all
@@ -0,0 +1,10 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Routing
6
+ NilableHooksType = T.type_alias do
7
+ T.nilable(T::Set[T.proc.void])
8
+ end
9
+ end
10
+ end
@@ -2,16 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Kirei
5
- module Middleware
6
- # https://github.com/rack/rack/blob/main/UPGRADE-GUIDE.md#rack-3-upgrade-guide
7
- RackResponseType = T.type_alias do
8
- [
9
- Integer,
10
- T::Hash[String, String], # in theory, the values are allowed to be arrays of integers for binary representations
11
- T.any(T::Array[String], Proc),
12
- ]
13
- end
14
-
5
+ module Routing
15
6
  RackEnvType = T.type_alias do
16
7
  T::Hash[
17
8
  String,
@@ -0,0 +1,15 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Routing
6
+ # https://github.com/rack/rack/blob/main/UPGRADE-GUIDE.md#rack-3-upgrade-guide
7
+ RackResponseType = T.type_alias do
8
+ [
9
+ Integer, # status
10
+ T::Hash[String, String], # headers. Values may be arrays of integers for binary representations
11
+ T.any(T::Array[String], Proc), # body
12
+ ]
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Routing
6
+ class Route < T::Struct
7
+ const :verb, Verb
8
+ const :path, String
9
+ const :controller, T.class_of(Controller)
10
+ const :action, String
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,56 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require("singleton")
5
+
6
+ module Kirei
7
+ module Routing
8
+ #
9
+ # Usage:
10
+ #
11
+ # Router.add_routes([
12
+ # Route.new(
13
+ # verb: Verb::GET,
14
+ # path: "/livez",
15
+ # controller: Controllers::HealthController,
16
+ # action: "livez",
17
+ # ),
18
+ # ])
19
+ #
20
+ class Router
21
+ extend T::Sig
22
+ include ::Singleton
23
+
24
+ RoutesHash = T.type_alias do
25
+ T::Hash[String, Route]
26
+ end
27
+
28
+ sig { void }
29
+ def initialize
30
+ @routes = T.let({}, RoutesHash)
31
+ end
32
+
33
+ sig { returns(RoutesHash) }
34
+ attr_reader :routes
35
+
36
+ sig do
37
+ params(
38
+ verb: Verb,
39
+ path: String,
40
+ ).returns(T.nilable(Route))
41
+ end
42
+ def get(verb, path)
43
+ key = "#{verb.serialize} #{path}"
44
+ routes[key]
45
+ end
46
+
47
+ sig { params(routes: T::Array[Route]).void }
48
+ def self.add_routes(routes)
49
+ routes.each do |route|
50
+ key = "#{route.verb.serialize} #{route.path}"
51
+ instance.routes[key] = route
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,37 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Routing
6
+ class Verb < T::Enum
7
+ enums do
8
+ # idempotent
9
+ GET = new("GET")
10
+
11
+ # non-idempotent
12
+ POST = new("POST")
13
+
14
+ # idempotent
15
+ PUT = new("PUT")
16
+
17
+ # non-idempotent
18
+ PATCH = new("PATCH")
19
+
20
+ # non-idempotent
21
+ DELETE = new("DELETE")
22
+
23
+ # idempotent
24
+ HEAD = new("HEAD")
25
+
26
+ # idempotent
27
+ OPTIONS = new("OPTIONS")
28
+
29
+ # idempotent
30
+ TRACE = new("TRACE")
31
+
32
+ # non-idempotent
33
+ CONNECT = new("CONNECT")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,53 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Services
6
+ class Result
7
+ extend T::Sig
8
+ extend T::Generic
9
+
10
+ ErrorType = type_member { { fixed: T::Array[Errors::JsonApiError] } }
11
+ ResultType = type_member { { upper: Object } }
12
+
13
+ sig do
14
+ params(
15
+ result: T.nilable(ResultType),
16
+ errors: ErrorType,
17
+ ).void
18
+ end
19
+ def initialize(result: nil, errors: [])
20
+ if (result && !errors.empty?) || (!result && errors.empty?)
21
+ raise ArgumentError, "Must provide either result or errors, got both or neither"
22
+ end
23
+
24
+ @result = result
25
+ @errors = errors
26
+ end
27
+
28
+ sig { returns(T::Boolean) }
29
+ def success?
30
+ @errors.empty?
31
+ end
32
+
33
+ sig { returns(T::Boolean) }
34
+ def failed?
35
+ !success?
36
+ end
37
+
38
+ sig { returns(ResultType) }
39
+ def result
40
+ raise "Cannot call 'result' when there are errors" if failed?
41
+
42
+ T.must(@result)
43
+ end
44
+
45
+ sig { returns(ErrorType) }
46
+ def errors
47
+ raise "Cannot call 'errors' when there is a result" if success?
48
+
49
+ @errors
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,47 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Services
6
+ class Runner
7
+ extend T::Sig
8
+ extend T::Generic
9
+
10
+ sig do
11
+ type_parameters(:T)
12
+ .params(
13
+ class_name: String,
14
+ log_tags: T::Hash[String, T.untyped],
15
+ _: T.proc.returns(T.type_parameter(:T)),
16
+ ).returns(T.type_parameter(:T))
17
+ end
18
+ def self.call(class_name, log_tags: {}, &_)
19
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
20
+ service = yield
21
+
22
+ service
23
+ ensure
24
+ stop = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
25
+ latency_in_ms = stop - T.must(start)
26
+
27
+ result = case service
28
+ when Services::Result
29
+ service.success? ? "success" : "failure"
30
+ else
31
+ "unknown"
32
+ end
33
+
34
+ metric_tags = Logging::Metric.inject_defaults({ "service.result" => result })
35
+ ::StatsD.measure(class_name, latency_in_ms, tags: metric_tags)
36
+
37
+ logtags = {
38
+ "service.name" => class_name,
39
+ "service.latency_in_ms" => latency_in_ms,
40
+ "service.result" => result,
41
+ }
42
+ logtags.merge!(log_tags)
43
+ Logging::Logger.call(level: Logging::Level::INFO, label: "Service Finished", meta: logtags)
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/kirei/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Kirei
5
- VERSION = "0.2.1"
5
+ VERSION = "0.4.0"
6
6
  end
data/lib/kirei.rb CHANGED
@@ -1,7 +1,31 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require "boot"
4
+ # This is the entrypoint into the application,
5
+ # This file loads first, hence we don't have Sorbet loaded yet.
6
+
7
+ #
8
+ # Load Order is important!
9
+ #
10
+
11
+ # First: check if all gems are installed correctly
12
+ require "bundler/setup"
13
+
14
+ # Second: load all gems (runtime dependencies only)
15
+ require "logger"
16
+ require "statsd-instrument"
17
+ require "sorbet-runtime"
18
+ require "oj"
19
+ require "rack"
20
+ require "pg"
21
+ require "sequel" # "sequel_pg" is auto-required by "sequel"
22
+
23
+ # Third: load all application code
24
+ require("zeitwerk")
25
+ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
26
+ loader.ignore("#{__dir__}/cli")
27
+ loader.ignore("#{__dir__}/cli.rb")
28
+ loader.setup
5
29
 
6
30
  module Kirei
7
31
  extend T::Sig
@@ -10,7 +34,7 @@ module Kirei
10
34
  # rubocop:disable Style/MutableConstant
11
35
  OJ_OPTIONS = T.let(
12
36
  {
13
- mode: :compat, # required to dump hashes with symbol-keys
37
+ mode: :compat, # required to dump hashes with symbol-keys. @TODO(lud, 14.05.2024): drop this, and enforce String Keys?
14
38
  symbol_keys: false, # T::Struct.new works only with string-keys
15
39
  },
16
40
  T::Hash[Symbol, T.untyped],
@@ -40,6 +64,10 @@ module Kirei
40
64
  end
41
65
  end
42
66
 
67
+ loader.eager_load
68
+
43
69
  Kirei.configure(&:itself)
44
70
 
45
- Kirei::Logger.logger.info("Kirei (#{Kirei::VERSION}) booted successfully!")
71
+ yjit_enabled = defined?(RubyVM::YJIT) ? RubyVM::YJIT.enabled? : false
72
+
73
+ Kirei::Logging::Logger.logger.info("Kirei v#{Kirei::VERSION} booted; YJIT enabled: #{yjit_enabled}")
@@ -2,7 +2,7 @@
2
2
 
3
3
  # rubocop:disable Style/EmptyMethod
4
4
  module Kirei
5
- module BaseModel
5
+ module Model
6
6
  include Kernel # "self" is a class since we include the module in a class
7
7
  include T::Props::Serializable
8
8
 
@@ -0,0 +1,15 @@
1
+ # typed: true
2
+
3
+ #
4
+ # The RubyVM module only exists on MRI. RubyVM is not defined in other Ruby implementations such as JRuby and TruffleRuby.
5
+ #
6
+ # The RubyVM module provides some access to MRI internals. This module is for very limited purposes, such as debugging, prototyping, and research. Normal users must not use it. This module is not portable between Ruby implementations.
7
+ #
8
+ class RubyVM
9
+ module YJIT
10
+ extend T::Sig
11
+
12
+ sig { returns(T::Boolean) }
13
+ def self.enabled?; end
14
+ end
15
+ end