kirei 0.3.0 → 0.4.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.
@@ -0,0 +1,157 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Model
6
+ module ClassMethods
7
+ extend T::Sig
8
+ extend T::Generic
9
+
10
+ # the attached class is the class that extends this module
11
+ # e.g. "User", "Airport", ..
12
+ has_attached_class!
13
+
14
+ include Kirei::Model::BaseClassInterface
15
+
16
+ # defaults to a pluralized, underscored version of the class name
17
+ sig { override.returns(String) }
18
+ def table_name
19
+ @table_name ||= T.let("#{model_name}s", T.nilable(String))
20
+ end
21
+
22
+ sig { returns(String) }
23
+ def model_name
24
+ Kirei::Helpers.underscore(T.must(name.split("::").last))
25
+ end
26
+
27
+ sig { override.returns(Sequel::Dataset) }
28
+ def query
29
+ db[table_name.to_sym]
30
+ end
31
+
32
+ sig { override.returns(Sequel::Database) }
33
+ def db
34
+ App.raw_db_connection
35
+ end
36
+
37
+ sig do
38
+ override.params(
39
+ hash: T::Hash[Symbol, T.untyped],
40
+ ).returns(T::Array[T.attached_class])
41
+ end
42
+ def where(hash)
43
+ resolve(query.where(hash))
44
+ end
45
+
46
+ sig { override.returns(T::Array[T.attached_class]) }
47
+ def all
48
+ resolve(query.all)
49
+ end
50
+
51
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
52
+ # default values defined in the model are used, if omitted in the hash
53
+ sig do
54
+ override.params(
55
+ hash: T::Hash[Symbol, T.untyped],
56
+ ).returns(T.attached_class)
57
+ end
58
+ def create(hash)
59
+ # instantiate a new object to ensure we use default values defined in the model
60
+ without_id = !hash.key?(:id)
61
+ hash[:id] = "kirei-fake-id" if without_id
62
+ new_record = from_hash(Helpers.deep_stringify_keys(hash))
63
+ all_attributes = T.let(new_record.serialize, T::Hash[String, T.untyped])
64
+ all_attributes.delete("id") if without_id && all_attributes["id"] == "kirei-fake-id"
65
+
66
+ wrap_jsonb_non_primivitives!(all_attributes)
67
+
68
+ if new_record.respond_to?(:created_at) && all_attributes["created_at"].nil?
69
+ all_attributes["created_at"] = Time.now.utc
70
+ end
71
+ if new_record.respond_to?(:updated_at) && all_attributes["updated_at"].nil?
72
+ all_attributes["updated_at"] = Time.now.utc
73
+ end
74
+
75
+ pkey = T.let(query.insert(all_attributes), String)
76
+
77
+ T.must(find_by({ id: pkey }))
78
+ end
79
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
80
+
81
+ sig { override.params(attributes: T::Hash[T.any(Symbol, String), T.untyped]).void }
82
+ def wrap_jsonb_non_primivitives!(attributes)
83
+ # setting `@raw_db_connection.wrap_json_primitives = true`
84
+ # only works on JSON primitives, but not on blank hashes/arrays
85
+ return unless App.config.db_extensions.include?(:pg_json)
86
+
87
+ attributes.each_pair do |key, value|
88
+ next unless value.is_a?(Hash) || value.is_a?(Array)
89
+
90
+ attributes[key] = T.unsafe(Sequel).pg_jsonb_wrap(value)
91
+ end
92
+ end
93
+
94
+ sig do
95
+ override.params(
96
+ hash: T::Hash[Symbol, T.untyped],
97
+ ).returns(T.nilable(T.attached_class))
98
+ end
99
+ def find_by(hash)
100
+ resolve_first(query.where(hash))
101
+ end
102
+
103
+ # Extra or unknown properties present in the Hash do not raise exceptions at
104
+ # runtime unless the optional strict argument to from_hash is passed
105
+ #
106
+ # Source: https://sorbet.org/docs/tstruct#from_hash-gotchas
107
+ # "strict" defaults to "false".
108
+ sig do
109
+ override.params(
110
+ query: T.any(Sequel::Dataset, T::Array[T::Hash[Symbol, T.untyped]]),
111
+ strict: T.nilable(T::Boolean),
112
+ ).returns(T::Array[T.attached_class])
113
+ end
114
+ def resolve(query, strict = nil)
115
+ strict_loading = strict.nil? ? App.config.db_strict_type_resolving : strict
116
+
117
+ query.map do |row|
118
+ row = T.cast(row, T::Hash[Symbol, T.untyped])
119
+ row.transform_keys!(&:to_s) # sequel returns symbolized keys
120
+ from_hash(row, strict_loading)
121
+ end
122
+ end
123
+
124
+ sig do
125
+ override.params(
126
+ query: Sequel::Dataset,
127
+ strict: T.nilable(T::Boolean),
128
+ ).returns(T.nilable(T.attached_class))
129
+ end
130
+ def resolve_first(query, strict = nil)
131
+ strict_loading = strict.nil? ? App.config.db_strict_type_resolving : strict
132
+
133
+ resolve(query.limit(1), strict_loading).first
134
+ end
135
+
136
+ # defaults to 6
137
+ sig { override.returns(Integer) }
138
+ def human_id_length = 6
139
+
140
+ # defaults to "model_name" (table_name without the trailing "s")
141
+ sig { override.returns(String) }
142
+ def human_id_prefix = model_name
143
+
144
+ #
145
+ # Generates a human-readable ID for the record.
146
+ # The ID is prefixed with the table name and an underscore.
147
+ #
148
+ sig { override.returns(String) }
149
+ def generate_human_id
150
+ Kirei::Model::HumanIdGenerator.call(
151
+ length: human_id_length,
152
+ prefix: human_id_prefix,
153
+ )
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,40 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Model
6
+ class HumanIdGenerator
7
+ extend T::Sig
8
+
9
+ # Removed ambiguous characters 0, 1, O, I, l, 5, S
10
+ ALLOWED_CHARS = "2346789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrtuvwxyz"
11
+ private_constant :ALLOWED_CHARS
12
+
13
+ ALLOWED_CHARS_COUNT = T.let(ALLOWED_CHARS.size, Integer)
14
+ private_constant :ALLOWED_CHARS_COUNT
15
+
16
+ sig do
17
+ params(
18
+ length: Integer,
19
+ prefix: String,
20
+ ).returns(String)
21
+ end
22
+ def self.call(length:, prefix:)
23
+ "#{prefix}_#{random_id(length)}"
24
+ end
25
+
26
+ sig { params(key_length: Integer).returns(String) }
27
+ private_class_method def self.random_id(key_length)
28
+ random_chars = Array.new(key_length)
29
+
30
+ key_length.times do |idx|
31
+ random_char_idx = SecureRandom.random_number(ALLOWED_CHARS_COUNT)
32
+ random_char = T.must(ALLOWED_CHARS[random_char_idx])
33
+ random_chars[idx] = random_char
34
+ end
35
+
36
+ random_chars.join
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/kirei/model.rb CHANGED
@@ -6,7 +6,7 @@ module Kirei
6
6
  extend T::Sig
7
7
  extend T::Helpers
8
8
 
9
- sig { returns(BaseClassInterface) }
9
+ sig { returns(Kirei::Model::BaseClassInterface) }
10
10
  def class; super; end # rubocop:disable all
11
11
 
12
12
  # An update keeps the original object intact, and returns a new object with the updated values.
@@ -18,7 +18,7 @@ module Kirei
18
18
  def update(hash)
19
19
  hash[:updated_at] = Time.now.utc if respond_to?(:updated_at) && hash[:updated_at].nil?
20
20
  self.class.wrap_jsonb_non_primivitives!(hash)
21
- self.class.db.where({ id: id }).update(hash)
21
+ self.class.query.where({ id: id }).update(hash)
22
22
  self.class.find_by({ id: id })
23
23
  end
24
24
 
@@ -26,7 +26,7 @@ module Kirei
26
26
  # Calling delete multiple times will return false after the first (successful) call.
27
27
  sig { returns(T::Boolean) }
28
28
  def delete
29
- count = self.class.db.where({ id: id }).delete
29
+ count = self.class.query.where({ id: id }).delete
30
30
  count == 1
31
31
  end
32
32
 
@@ -47,177 +47,6 @@ module Kirei
47
47
  end
48
48
  end
49
49
 
50
- module BaseClassInterface
51
- extend T::Sig
52
- extend T::Helpers
53
- interface!
54
-
55
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
56
- def find_by(hash)
57
- end
58
-
59
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
60
- def where(hash)
61
- end
62
-
63
- sig { abstract.returns(T.untyped) }
64
- def all
65
- end
66
-
67
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
68
- def create(hash)
69
- end
70
-
71
- sig { abstract.params(attributes: T.untyped).void }
72
- def wrap_jsonb_non_primivitives!(attributes)
73
- end
74
-
75
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
76
- def resolve(hash)
77
- end
78
-
79
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
80
- def resolve_first(hash)
81
- end
82
-
83
- sig { abstract.returns(T.untyped) }
84
- def table_name
85
- end
86
-
87
- sig { abstract.returns(T.untyped) }
88
- def db
89
- end
90
- end
91
-
92
- module ClassMethods
93
- extend T::Sig
94
- extend T::Generic
95
-
96
- # the attached class is the class that extends this module
97
- # e.g. "User"
98
- # extend T::Generic
99
- # has_attached_class!
100
- has_attached_class!
101
-
102
- include BaseClassInterface
103
-
104
- # defaults to a pluralized, underscored version of the class name
105
- sig { override.returns(String) }
106
- def table_name
107
- @table_name ||= T.let(
108
- begin
109
- table_name_ = Kirei::Helpers.underscore(T.must(name.split("::").last))
110
- "#{table_name_}s"
111
- end,
112
- T.nilable(String),
113
- )
114
- end
115
-
116
- sig { override.returns(Sequel::Dataset) }
117
- def db
118
- App.raw_db_connection[table_name.to_sym]
119
- end
120
-
121
- sig do
122
- override.params(
123
- hash: T::Hash[Symbol, T.untyped],
124
- ).returns(T::Array[T.attached_class])
125
- end
126
- def where(hash)
127
- resolve(db.where(hash))
128
- end
129
-
130
- sig { override.returns(T::Array[T.attached_class]) }
131
- def all
132
- resolve(db.all)
133
- end
134
-
135
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
136
- # default values defined in the model are used, if omitted in the hash
137
- sig do
138
- override.params(
139
- hash: T::Hash[Symbol, T.untyped],
140
- ).returns(T.attached_class)
141
- end
142
- def create(hash)
143
- # instantiate a new object to ensure we use default values defined in the model
144
- without_id = !hash.key?(:id)
145
- hash[:id] = "kirei-fake-id" if without_id
146
- new_record = from_hash(Helpers.deep_stringify_keys(hash))
147
- all_attributes = T.let(new_record.serialize, T::Hash[String, T.untyped])
148
- all_attributes.delete("id") if without_id && all_attributes["id"] == "kirei-fake-id"
149
-
150
- wrap_jsonb_non_primivitives!(all_attributes)
151
-
152
- if new_record.respond_to?(:created_at) && all_attributes["created_at"].nil?
153
- all_attributes["created_at"] = Time.now.utc
154
- end
155
- if new_record.respond_to?(:updated_at) && all_attributes["updated_at"].nil?
156
- all_attributes["updated_at"] = Time.now.utc
157
- end
158
-
159
- pkey = T.let(db.insert(all_attributes), String)
160
-
161
- T.must(find_by({ id: pkey }))
162
- end
163
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
164
-
165
- sig { override.params(attributes: T::Hash[T.any(Symbol, String), T.untyped]).void }
166
- def wrap_jsonb_non_primivitives!(attributes)
167
- # setting `@raw_db_connection.wrap_json_primitives = true`
168
- # only works on JSON primitives, but not on blank hashes/arrays
169
- return unless App.config.db_extensions.include?(:pg_json)
170
-
171
- attributes.each_pair do |key, value|
172
- next unless value.is_a?(Hash) || value.is_a?(Array)
173
-
174
- attributes[key] = T.unsafe(Sequel).pg_jsonb_wrap(value)
175
- end
176
- end
177
-
178
- sig do
179
- override.params(
180
- hash: T::Hash[Symbol, T.untyped],
181
- ).returns(T.nilable(T.attached_class))
182
- end
183
- def find_by(hash)
184
- resolve_first(db.where(hash))
185
- end
186
-
187
- # Extra or unknown properties present in the Hash do not raise exceptions at
188
- # runtime unless the optional strict argument to from_hash is passed
189
- #
190
- # Source: https://sorbet.org/docs/tstruct#from_hash-gotchas
191
- # "strict" defaults to "false".
192
- sig do
193
- override.params(
194
- query: T.any(Sequel::Dataset, T::Array[T::Hash[Symbol, T.untyped]]),
195
- strict: T.nilable(T::Boolean),
196
- ).returns(T::Array[T.attached_class])
197
- end
198
- def resolve(query, strict = nil)
199
- strict_loading = strict.nil? ? App.config.db_strict_type_resolving : strict
200
-
201
- query.map do |row|
202
- row = T.cast(row, T::Hash[Symbol, T.untyped])
203
- row.transform_keys!(&:to_s) # sequel returns symbolized keys
204
- from_hash(row, strict_loading)
205
- end
206
- end
207
-
208
- sig do
209
- override.params(
210
- query: Sequel::Dataset,
211
- strict: T.nilable(T::Boolean),
212
- ).returns(T.nilable(T.attached_class))
213
- end
214
- def resolve_first(query, strict = nil)
215
- strict_loading = strict.nil? ? App.config.db_strict_type_resolving : strict
216
-
217
- resolve(query.limit(1), strict_loading).first
218
- end
219
- end
220
-
221
- mixes_in_class_methods(ClassMethods)
50
+ mixes_in_class_methods(Kirei::Model::ClassMethods)
222
51
  end
223
52
  end
@@ -17,9 +17,14 @@ module Kirei
17
17
  sig { returns(T::Hash[String, T.untyped]) }
18
18
  attr_reader :params
19
19
 
20
+ sig { returns(Router) }
21
+ attr_reader :router; private :router
22
+
20
23
  sig { params(env: RackEnvType).returns(RackResponseType) }
21
24
  def call(env)
22
- http_verb = Router::Verb.deserialize(env.fetch("REQUEST_METHOD"))
25
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
26
+
27
+ http_verb = Verb.deserialize(env.fetch("REQUEST_METHOD"))
23
28
  req_path = T.cast(env.fetch("REQUEST_PATH"), String)
24
29
  #
25
30
  # TODO: reject requests from unexpected hosts -> allow configuring allowed hosts in a `cors.rb` file
@@ -27,18 +32,18 @@ module Kirei
27
32
  # -> use https://github.com/cyu/rack-cors ?
28
33
  #
29
34
 
30
- route = Router.instance.get(http_verb, req_path)
35
+ route = router.get(http_verb, req_path)
31
36
  return [404, {}, ["Not Found"]] if route.nil?
32
37
 
33
38
  params = case route.verb
34
- when Router::Verb::GET
39
+ when Verb::GET
35
40
  query = T.cast(env.fetch("QUERY_STRING"), String)
36
41
  query.split("&").to_h do |p|
37
42
  k, v = p.split("=")
38
43
  k = T.cast(k, String)
39
44
  [k, v]
40
45
  end
41
- when Router::Verb::POST, Router::Verb::PUT, Router::Verb::PATCH
46
+ when Verb::POST, Verb::PUT, Verb::PATCH
42
47
  # TODO: based on content-type, parse the body differently
43
48
  # build-in support for JSON & XML
44
49
  body = T.cast(env.fetch("rack.input"), T.any(IO, StringIO))
@@ -46,7 +51,7 @@ module Kirei
46
51
  body.rewind # TODO: maybe don't rewind if we don't need to?
47
52
  T.cast(res, T::Hash[String, T.untyped])
48
53
  else
49
- Logger.logger.warn("Unsupported HTTP verb: #{http_verb.serialize} send to #{req_path}")
54
+ Logging::Logger.logger.warn("Unsupported HTTP verb: #{http_verb.serialize} send to #{req_path}")
50
55
  {}
51
56
  end
52
57
 
@@ -58,7 +63,19 @@ module Kirei
58
63
  before_hooks = collect_hooks(controller, :before_hooks)
59
64
  run_hooks(before_hooks)
60
65
 
61
- status, headers, body = T.cast(
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(
62
79
  controller.new(params: params).public_send(route.action),
63
80
  RackResponseType,
64
81
  )
@@ -75,14 +92,30 @@ module Kirei
75
92
  [
76
93
  status,
77
94
  headers,
78
- body,
95
+ response_body,
79
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
80
114
  end
81
115
 
82
116
  #
83
- # Kirei::App#render
84
117
  # * "status": defaults to 200
85
- # * "headers": defaults to an empty hash
118
+ # * "headers": Kirei adds some default headers for security, but the user can override them
86
119
  #
87
120
  sig do
88
121
  params(
@@ -92,8 +125,6 @@ module Kirei
92
125
  ).returns(RackResponseType)
93
126
  end
94
127
  def render(body, status: 200, headers: {})
95
- # merge default headers
96
- # support a "type" to set content-type header? (or default to json, and users must set the header themselves for other types?)
97
128
  [
98
129
  status,
99
130
  headers,
@@ -103,7 +134,7 @@ module Kirei
103
134
 
104
135
  sig { returns(T::Hash[String, String]) }
105
136
  def default_headers
106
- # "Access-Control-Allow-Origin": the user should set that
137
+ # "Access-Control-Allow-Origin": the user should set that, see comment about "cors" above
107
138
  {
108
139
  # security relevant headers
109
140
  "X-Frame-Options" => "DENY",
@@ -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
@@ -10,7 +10,7 @@ module Kirei
10
10
  #
11
11
  # Router.add_routes([
12
12
  # Route.new(
13
- # verb: Kirei::Router::Verb::GET,
13
+ # verb: Verb::GET,
14
14
  # path: "/livez",
15
15
  # controller: Controllers::HealthController,
16
16
  # action: "livez",
@@ -21,36 +21,6 @@ module Kirei
21
21
  extend T::Sig
22
22
  include ::Singleton
23
23
 
24
- class Verb < T::Enum
25
- enums do
26
- # idempotent
27
- GET = new("GET")
28
- # non-idempotent
29
- POST = new("POST")
30
- # idempotent
31
- PUT = new("PUT")
32
- # non-idempotent
33
- PATCH = new("PATCH")
34
- # non-idempotent
35
- DELETE = new("DELETE")
36
- # idempotent
37
- HEAD = new("HEAD")
38
- # idempotent
39
- OPTIONS = new("OPTIONS")
40
- # idempotent
41
- TRACE = new("TRACE")
42
- # non-idempotent
43
- CONNECT = new("CONNECT")
44
- end
45
- end
46
-
47
- class Route < T::Struct
48
- const :verb, Verb
49
- const :path, String
50
- const :controller, T.class_of(Controller)
51
- const :action, String
52
- end
53
-
54
24
  RoutesHash = T.type_alias do
55
25
  T::Hash[String, Route]
56
26
  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