kirei 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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