kirei 0.8.3 → 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4947e7d5e49692f86c0c631f9f4110eb05f12c5dabaf0eaec50290187aa3200d
4
- data.tar.gz: 77babab3bd17e5646a2cdf0171a132267e324e73f1ffe28340e077e187ef2b92
3
+ metadata.gz: e175975ffe2d5eef5e50f3125cdf29f3c56005458362c1c45080c2cc54642e36
4
+ data.tar.gz: e9b84d4fb46d281d2f601515c96d186daf64a89e95661b48cf288c088255f420
5
5
  SHA512:
6
- metadata.gz: c3bb40d4a5101c510594910f88bcfb68522f12735c7970dcd071cb6ac9b0728545bbca8bf5e3f3b94bc680b9f3050bb55aa78589e7198049051b840bff9318c4
7
- data.tar.gz: dd36b2aa00e79c5c33946e54fc18739bcf29e0e0b9410201eac9404bc0d605e8d00df78d07e81cd326a2971f3f2ef4fdda159d5cc66aa243b669cb758003b52c
6
+ metadata.gz: 7fc9d9756a1f4a7b2e030063f1aad7378dd054e7312ea0d04222fa5537278fae1a29a77e760be80d22507c5c2a054e8bc63743471f9991c60c505f09919b3979
7
+ data.tar.gz: 68a0e0a944c7596f682e48beae0eaade2ee046b1110de7c1f50ea034b035bb0d386347a82117207624d90ee03b267796102a24fb10ece7e2458d2784b95d1629
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Kirei is a strictly typed Ruby micro/REST-framework for building scalable and performant APIs. It is built from the ground up to be clean and easy to use. Kirei is based on [Sequel](https://github.com/jeremyevans/sequel) as an ORM, [Sorbet](https://github.com/sorbet/sorbet) for typing, and [Rack](https://github.com/rack/rack) as web server interface. It strives to have zero magic and to be as explicit as possible.
4
4
 
5
- Kirei's main advantages over other frameworks are its strict typing, low memory footprint, and build-in high-performance logging and metric-tracking toolkits. It is opiniated in terms of tooling, allowing you to focus on your core-business. It is a great choice for building APIs that need to scale.
5
+ Kirei's main advantages over other frameworks are its strict typing, low memory footprint, and built-in high-performance logging and pluggable metric-tracking toolkit. It is opinionated in terms of tooling, allowing you to focus on your core-business. It is a great choice for building APIs that need to scale.
6
6
 
7
7
  > Kirei (きれい) is a Japanese adjective that primarily means "beautiful" or "pretty." It can also be used to describe something that is "clean" or "neat."
8
8
 
@@ -227,6 +227,12 @@ module Kirei::Routing
227
227
  controller: Controllers::Airports,
228
228
  action: "index",
229
229
  ),
230
+ Route.new(
231
+ verb: Verb::GET,
232
+ path: "/airports/:iata",
233
+ controller: Controllers::Airports,
234
+ action: "show",
235
+ ),
230
236
  ],
231
237
  )
232
238
  end
@@ -234,7 +240,24 @@ end
234
240
 
235
241
  #### Controllers
236
242
 
237
- Controllers can be defined anywhere; by convention, they are defined in the `app/controllers` directory:
243
+ Controllers can be defined anywhere; by convention, they are defined in the `app/controllers` directory.
244
+
245
+ Three render helpers are available:
246
+
247
+ | Method | Use case |
248
+ |---|---|
249
+ | `render(body, status:, headers:)` | Raw string responses (plain text, pre-serialized data) |
250
+ | `render_json(data, status:, headers:)` | JSON responses with automatic serialization |
251
+ | `render_error(errors, status:, headers:)` | JSON:API-compliant error responses |
252
+
253
+ `render_json` accepts multiple data types:
254
+
255
+ | `data` type | Behavior |
256
+ |---|---|
257
+ | `String` | Pass-through (assumed to be pre-serialized JSON) |
258
+ | `Hash` / `Array` | Serialized via `Oj.dump` |
259
+ | Object responding to `#serialize` (e.g. `T::Struct`) | Calls `.serialize`, then `Oj.dump` if the result is not a String |
260
+ | Anything else | Raises `ArgumentError` |
238
261
 
239
262
  ```ruby
240
263
  module Controllers
@@ -245,14 +268,24 @@ module Controllers
245
268
  def index
246
269
  search = T.let(params.fetch("q", nil), T.nilable(String))
247
270
 
248
- airports = Kirei::Services::Runner.call("Airports::Filter") do
249
- Airports::Filter.call(search) # T::Array[Airport]
271
+ service = Kirei::Services::Runner.call("Airports::Filter") do
272
+ Airports::Filter.call(search)
250
273
  end
274
+ return render_error(service.errors, status: 400) if service.failed?
251
275
 
252
- # or use a serializer
253
- data = Oj.dump(airports.map(&:serialize))
276
+ render_json(service.result.map(&:serialize))
277
+ end
278
+
279
+ sig { returns(T.anything) }
280
+ def show
281
+ iata = T.must(params.fetch("iata", nil)) # named param from dynamic route
254
282
 
255
- render(status: 200, body: data)
283
+ airport = Kirei::Services::Runner.call("Airports::Find") do
284
+ Airports::Find.call(iata) # T.nilable(Airport)
285
+ end
286
+ return render(status: 204) if airport.nil?
287
+
288
+ render_json(airport) # T::Struct — calls .serialize automatically
256
289
  end
257
290
  end
258
291
  end
@@ -287,11 +320,46 @@ module Airports
287
320
  end
288
321
  ```
289
322
 
323
+ #### Metrics
324
+
325
+ Kirei ships with a pluggable metrics interface via `Kirei::Metrics::Backend`. Three backends are included:
326
+
327
+ | Backend | Description |
328
+ |---|---|
329
+ | `LoggingBackend` | **Default.** Prints metrics to stdout via `puts` — great for local development and small MVPs. |
330
+ | `StatsdBackend` | Wraps [`statsd-instrument`](https://github.com/Shopify/statsd-instrument). Add `gem 'statsd-instrument'` to your Gemfile. |
331
+ | `NullBackend` | No-op — silently discards all metrics. |
332
+
333
+ The backend exposes three methods: `increment`, `measure`, and `gauge`.
334
+
335
+ Configure the backend in your app:
336
+
337
+ ```ruby
338
+ class MyApp < Kirei::App
339
+ # Use StatsD (requires `gem 'statsd-instrument'` in Gemfile)
340
+ config.metrics_backend = Kirei::Metrics::StatsdBackend.new
341
+
342
+ # Or disable metrics entirely
343
+ config.metrics_backend = Kirei::Metrics::NullBackend.new
344
+ end
345
+ ```
346
+
347
+ Emit custom metrics anywhere via `Kirei::Logging::Metric`:
348
+
349
+ ```ruby
350
+ Kirei::Logging::Metric.call("airports_search_term", 1, tags: { "query" => search })
351
+ ```
352
+
353
+ Request timing and service execution timing are tracked automatically.
354
+
355
+ To build a custom backend (e.g. Prometheus, OpenTelemetry), subclass `Kirei::Metrics::Backend` and implement `increment`, `measure`, and `gauge`.
356
+
290
357
  ### Goes well with these gems
291
358
 
292
359
  * [pagy](https://github.com/ddnexus/pagy) for pagination
293
360
  * [argon2](https://github.com/technion/ruby-argon2) for password hashing
294
361
  * [rack-session](https://github.com/rack/rack-session) for session management
362
+ * [pgvector](https://github.com/pgvector/pgvector-ruby) for vector columns — add `:pgvector` to `App.config.db_extensions`
295
363
 
296
364
  ### Middlewares
297
365
 
data/cops/layout.yml ADDED
@@ -0,0 +1,17 @@
1
+ Layout/EndAlignment:
2
+ Enabled: true
3
+ EnforcedStyleAlignWith: variable
4
+ AutoCorrect: true
5
+
6
+ Layout/ParameterAlignment:
7
+ EnforcedStyle: with_fixed_indentation
8
+ SupportedStyles:
9
+ - with_first_parameter
10
+ - with_fixed_indentation
11
+
12
+ Layout/LineLength:
13
+ Max: 120
14
+ AllowCopDirectives: false
15
+ AllowedPatterns: ['\s#\s|^#\s'] # allows comments to overflow the line length. E.g. for URLs
16
+ Exclude:
17
+ - "spec/**/*"
data/cops/lint.yml ADDED
@@ -0,0 +1,23 @@
1
+ Lint/DuplicateBranch:
2
+ Enabled: true
3
+
4
+ Lint/DuplicateRegexpCharacterClassElement:
5
+ Enabled: true
6
+
7
+ Lint/EmptyBlock:
8
+ Enabled: true
9
+
10
+ Lint/EmptyClass:
11
+ Enabled: true
12
+
13
+ Lint/NoReturnInBeginEndBlocks:
14
+ Enabled: true
15
+
16
+ Lint/ToEnumArguments:
17
+ Enabled: true
18
+
19
+ Lint/UnexpectedBlockArity:
20
+ Enabled: true
21
+
22
+ Lint/UnmodifiedReduceAccumulator:
23
+ Enabled: true
data/cops/metrics.yml ADDED
@@ -0,0 +1,20 @@
1
+ Metrics/BlockLength:
2
+ Exclude:
3
+ - describe
4
+ - context
5
+ - feature
6
+ - scenario
7
+
8
+ Metrics/MethodLength:
9
+ Max: 30
10
+ CountAsOne:
11
+ - "array"
12
+ - "heredoc"
13
+ - "method_call"
14
+
15
+ Metrics/ModuleLength:
16
+ Max: 150
17
+ CountAsOne:
18
+ - "array"
19
+ - "heredoc"
20
+ - "method_call"
data/cops/naming.yml ADDED
@@ -0,0 +1,2 @@
1
+ Naming/VariableNumber:
2
+ EnforcedStyle: snake_case
data/cops/rspec.yml ADDED
@@ -0,0 +1,13 @@
1
+ RSpec/ContextWording:
2
+ Prefixes:
3
+ - when
4
+ - with
5
+ - without
6
+ - if
7
+ - and
8
+
9
+ RSpec/MultipleExpectations:
10
+ Enabled: false
11
+
12
+ RSpec/ExampleLength:
13
+ Max: 20
data/cops/style.yml ADDED
@@ -0,0 +1,77 @@
1
+ Style/FrozenStringLiteralComment:
2
+ Enabled: false
3
+
4
+ # enforces to use 'name'.to_sym over 'name'.intern and so on
5
+ Style/StringMethods:
6
+ Enabled: true
7
+
8
+ # @NOTE: changed from "compact" to "expanded"
9
+ Style/EmptyMethod:
10
+ EnforcedStyle: expanded
11
+ SupportedStyles:
12
+ - compact
13
+ - expanded
14
+
15
+ # @NOTE: allowed AllowMultipleReturnValues
16
+ Style/RedundantReturn:
17
+ Enabled: true
18
+ AllowMultipleReturnValues: true
19
+
20
+ # @NOTE: allowed AllowAsExpressionSeparator
21
+ Style/Semicolon:
22
+ Enabled: true
23
+ AllowAsExpressionSeparator: true
24
+
25
+ Style/CollectionMethods:
26
+ Enabled: true
27
+
28
+ Style/Documentation:
29
+ Enabled: false
30
+
31
+ # @NOTE: changed EnforcedStyleForMultiline from "no_comma" to "comma"
32
+ Style/TrailingCommaInArguments:
33
+ EnforcedStyleForMultiline: comma
34
+
35
+ # @NOTE: changed EnforcedStyleForMultiline from "no_comma" to "comma"
36
+ Style/TrailingCommaInArrayLiteral:
37
+ EnforcedStyleForMultiline: comma
38
+
39
+ # @NOTE: changed EnforcedStyleForMultiline from "no_comma" to "comma"
40
+ Style/TrailingCommaInHashLiteral:
41
+ EnforcedStyleForMultiline: comma
42
+
43
+ #
44
+ # @NOTE: the following cops are "pending" by default
45
+ #
46
+ Style/ArgumentsForwarding:
47
+ Enabled: false
48
+
49
+ Style/CollectionCompact:
50
+ Enabled: true
51
+
52
+ Style/DocumentDynamicEvalDefinition:
53
+ Enabled: true
54
+
55
+ Style/NegatedIfElseCondition:
56
+ Enabled: true
57
+
58
+ Style/NilLambda:
59
+ Enabled: true
60
+
61
+ Style/RedundantArgument:
62
+ Enabled: true
63
+
64
+ Style/SwapValues:
65
+ Enabled: true
66
+
67
+ # "private_class_method" must always be inlined, no need for a cop
68
+ Style/AccessModifierDeclarations:
69
+ EnforcedStyle: inline
70
+
71
+ Style/StringLiterals:
72
+ Enabled: true
73
+ EnforcedStyle: double_quotes
74
+
75
+ Style/StringLiteralsInInterpolation:
76
+ Enabled: true
77
+ EnforcedStyle: double_quotes
data/cops/types.yml ADDED
@@ -0,0 +1,12 @@
1
+ Sorbet/HasSigil:
2
+ MinimumStrictness: strict
3
+ SuggestedStrictness: strict
4
+ Exclude:
5
+ - "spec/**/*"
6
+ - "test/**/*"
7
+
8
+ Sorbet/StrictSigil:
9
+ Enabled: true
10
+ Exclude:
11
+ - "spec/**/*"
12
+ - "lib/tasks/**/*"
data/kirei.gemspec CHANGED
@@ -34,6 +34,7 @@ Gem::Specification.new do |spec|
34
34
  # do not include RBIs for gems, because users might use different versions
35
35
  "sorbet/rbi/dsl/**/*.rbi",
36
36
  "sorbet/rbi/shims/**/*.rbi",
37
+ "cops/**/*",
37
38
  "LICENSE",
38
39
  "README.md",
39
40
  ]
@@ -46,7 +47,6 @@ Gem::Specification.new do |spec|
46
47
  spec.add_dependency "logger", "~> 1.5" # for Ruby 3.5+
47
48
  spec.add_dependency "oj", "~> 3.0"
48
49
  spec.add_dependency "sorbet-runtime", "~> 0.5"
49
- spec.add_dependency "statsd-instrument", "~> 3.0"
50
50
  spec.add_dependency "tzinfo-data", "~> 1.0" # for containerized environments, e.g. on AWS ECS
51
51
  spec.add_dependency "zeitwerk", "~> 2.5"
52
52
 
data/lib/kirei/config.rb CHANGED
@@ -21,6 +21,7 @@ module Kirei
21
21
  prop :log_level, Kirei::Logging::Level, default: Kirei::Logging::Level::INFO
22
22
 
23
23
  prop :metric_default_tags, T::Hash[String, T.untyped], default: {}
24
+ prop :metrics_backend, Kirei::Metrics::Backend, factory: -> { Kirei::Metrics::LoggingBackend.new }
24
25
 
25
26
  # dup to allow the user to extend the existing list of sensitive keys
26
27
  prop :sensitive_keys, T::Array[Regexp], factory: -> { SENSITIVE_KEYS.dup }
@@ -18,24 +18,6 @@ module Kirei
18
18
  instance_variable_get(var) == other.instance_variable_get(var)
19
19
  end
20
20
  end
21
-
22
- sig do
23
- params(
24
- other: T.untyped,
25
- array_mode: Kirei::Services::ArrayComparison::Mode,
26
- ).returns(T::Boolean)
27
- end
28
- def equal_with_array_mode?(other, array_mode: Kirei::Services::ArrayComparison::Mode::STRICT)
29
- return false unless instance_of?(other.class)
30
-
31
- instance_variables.all? do |var|
32
- one = instance_variable_get(var)
33
- two = other.instance_variable_get(var)
34
- next one == two unless one.is_a?(Array)
35
-
36
- Kirei::Services::ArrayComparison.call(one, two, mode: array_mode)
37
- end
38
- end
39
21
  end
40
22
  end
41
23
  end
data/lib/kirei/helpers.rb CHANGED
@@ -10,19 +10,13 @@ module Kirei
10
10
  sig { params(string: String).returns(String) }
11
11
  def underscore(string)
12
12
  string.gsub!(/([A-Z])(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) do
13
- T.must((::Regexp.last_match(1) || ::Regexp.last_match(2))) << "_"
13
+ T.must(::Regexp.last_match(1) || ::Regexp.last_match(2)) << "_"
14
14
  end
15
15
  string.tr!("-", "_")
16
16
  string.downcase!
17
17
  string
18
18
  end
19
19
 
20
- # Simplified version from Rails' ActiveSupport
21
- sig { params(string: T.any(String, Symbol)).returns(T::Boolean) }
22
- def blank?(string)
23
- string.nil? || string.to_s.empty?
24
- end
25
-
26
20
  sig { params(object: T.untyped).returns(T.untyped) }
27
21
  def deep_stringify_keys(object)
28
22
  deep_transform_keys(object) { _1.to_s rescue _1 } # rubocop:disable Style/RescueModifier
@@ -74,7 +68,7 @@ module Kirei
74
68
  when Hash
75
69
  # using `each_key` results in a `RuntimeError: can't add a new key into hash during iteration`
76
70
  # which is, because the receiver here does not necessarily have a `Hash` type
77
- object.keys.each do |key| # rubocop:disable Style/HashEachMethods
71
+ object.keys.each do |key|
78
72
  value = object.delete(key)
79
73
  object[yield(key)] = deep_transform_keys!(value, &block)
80
74
  end
@@ -156,7 +156,7 @@ module Kirei
156
156
  hash = T.cast(hash, T::Hash[String, T.untyped])
157
157
 
158
158
  hash.each do |key, value|
159
- new_prefix = Kirei::Helpers.blank?(prefix) ? key : "#{prefix}.#{key}"
159
+ new_prefix = prefix.empty? ? key : "#{prefix}.#{key}"
160
160
 
161
161
  case value
162
162
  when Hash
@@ -14,13 +14,11 @@ module Kirei
14
14
  ).void
15
15
  end
16
16
  def self.call(metric_name, value = 1, tags: {})
17
- return if ENV["NO_METRICS"] == "true"
18
-
19
17
  inject_defaults(tags)
20
18
 
21
19
  # Do not `compact_blank` tags, since one might want to track empty strings/"false"/NULLs.
22
20
  # NOT having any tag doesn't tell the user if the tag was empty or not set at all.
23
- StatsD.increment(metric_name, value, tags: tags)
21
+ App.config.metrics_backend.increment(metric_name, value, tags: tags)
24
22
  end
25
23
 
26
24
  sig { params(tags: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) }
@@ -0,0 +1,43 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Metrics
6
+ class Backend
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ abstract!
11
+
12
+ sig do
13
+ abstract.params(
14
+ name: String,
15
+ value: T.any(Integer, Float),
16
+ tags: T::Hash[String, T.untyped],
17
+ ).void
18
+ end
19
+ def increment(name, value = 1, tags: {})
20
+ end
21
+
22
+ sig do
23
+ abstract.params(
24
+ name: String,
25
+ duration_ms: T.any(Integer, Float),
26
+ tags: T::Hash[String, T.untyped],
27
+ ).void
28
+ end
29
+ def measure(name, duration_ms, tags: {})
30
+ end
31
+
32
+ sig do
33
+ abstract.params(
34
+ name: String,
35
+ value: T.any(Integer, Float),
36
+ tags: T::Hash[String, T.untyped],
37
+ ).void
38
+ end
39
+ def gauge(name, value, tags: {})
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,43 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Metrics
6
+ class LoggingBackend < Backend
7
+ extend T::Sig
8
+
9
+ sig do
10
+ override.params(
11
+ name: String,
12
+ value: T.any(Integer, Float),
13
+ tags: T::Hash[String, T.untyped],
14
+ ).void
15
+ end
16
+ def increment(name, value = 1, tags: {})
17
+ puts("[Metric] increment #{name} #{value} #{tags}")
18
+ end
19
+
20
+ sig do
21
+ override.params(
22
+ name: String,
23
+ duration_ms: T.any(Integer, Float),
24
+ tags: T::Hash[String, T.untyped],
25
+ ).void
26
+ end
27
+ def measure(name, duration_ms, tags: {})
28
+ puts("[Metric] measure #{name} #{duration_ms}ms #{tags}")
29
+ end
30
+
31
+ sig do
32
+ override.params(
33
+ name: String,
34
+ value: T.any(Integer, Float),
35
+ tags: T::Hash[String, T.untyped],
36
+ ).void
37
+ end
38
+ def gauge(name, value, tags: {})
39
+ puts("[Metric] gauge #{name} #{value} #{tags}")
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Metrics
6
+ class NullBackend < Backend
7
+ extend T::Sig
8
+
9
+ sig do
10
+ override.params(
11
+ name: String,
12
+ value: T.any(Integer, Float),
13
+ tags: T::Hash[String, T.untyped],
14
+ ).void
15
+ end
16
+ def increment(name, value = 1, tags: {})
17
+ end
18
+
19
+ sig do
20
+ override.params(
21
+ name: String,
22
+ duration_ms: T.any(Integer, Float),
23
+ tags: T::Hash[String, T.untyped],
24
+ ).void
25
+ end
26
+ def measure(name, duration_ms, tags: {})
27
+ end
28
+
29
+ sig do
30
+ override.params(
31
+ name: String,
32
+ value: T.any(Integer, Float),
33
+ tags: T::Hash[String, T.untyped],
34
+ ).void
35
+ end
36
+ def gauge(name, value, tags: {})
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,51 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Metrics
6
+ class StatsdBackend < Backend
7
+ extend T::Sig
8
+
9
+ sig { void }
10
+ def initialize
11
+ super
12
+ return if defined?(::StatsD)
13
+
14
+ raise "statsd-instrument is not loaded. Add `gem 'statsd-instrument'` to your Gemfile to use StatsdBackend."
15
+ end
16
+
17
+ sig do
18
+ override.params(
19
+ name: String,
20
+ value: T.any(Integer, Float),
21
+ tags: T::Hash[String, T.untyped],
22
+ ).void
23
+ end
24
+ def increment(name, value = 1, tags: {})
25
+ ::StatsD.increment(name, value, tags: tags)
26
+ end
27
+
28
+ sig do
29
+ override.params(
30
+ name: String,
31
+ duration_ms: T.any(Integer, Float),
32
+ tags: T::Hash[String, T.untyped],
33
+ ).void
34
+ end
35
+ def measure(name, duration_ms, tags: {})
36
+ ::StatsD.measure(name, duration_ms, tags: tags)
37
+ end
38
+
39
+ sig do
40
+ override.params(
41
+ name: String,
42
+ value: T.any(Integer, Float),
43
+ tags: T::Hash[String, T.untyped],
44
+ ).void
45
+ end
46
+ def gauge(name, value, tags: {})
47
+ ::StatsD.gauge(name, value, tags: tags)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -8,6 +8,7 @@ module Kirei
8
8
  module BaseClassInterface
9
9
  extend T::Sig
10
10
  extend T::Helpers
11
+
11
12
  interface!
12
13
 
13
14
  sig { abstract.params(hash: T.untyped).returns(T.untyped) }
@@ -9,7 +9,7 @@ module Kirei
9
9
 
10
10
  # the attached class is the class that extends this module
11
11
  # e.g. "User", "Airport", ..
12
- has_attached_class!
12
+ has_attached_class!(:out)
13
13
 
14
14
  include Kirei::Model::BaseClassInterface
15
15
 
@@ -85,13 +85,15 @@ module Kirei
85
85
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
86
86
 
87
87
  sig { override.params(attributes: T::Hash[T.any(Symbol, String), T.untyped]).void }
88
- def wrap_jsonb_non_primivitives!(attributes)
88
+ def wrap_jsonb_non_primivitives!(attributes) # rubocop:disable Metrics/AbcSize
89
89
  # setting `@raw_db_connection.wrap_json_primitives = true`
90
90
  # only works on JSON primitives, but not on blank hashes/arrays
91
91
  return unless App.config.db_extensions.include?(:pg_json)
92
92
 
93
+ pgvector_enabled = App.config.db_extensions.include?(:pgvector)
94
+
93
95
  attributes.each_pair do |key, value|
94
- if vector_column?(key.to_s)
96
+ if pgvector_enabled && vector_column?(key.to_s)
95
97
  attributes[key] = cast_to_vector(value)
96
98
  elsif value.is_a?(Hash) || value.is_a?(Array)
97
99
  attributes[key] = T.unsafe(Sequel).pg_jsonb_wrap(value)
@@ -104,6 +106,10 @@ module Kirei
104
106
  # also add `:pgvector` to the `App.config.db_extensions` array
105
107
  # and enable the vector extension on the database.
106
108
  #
109
+ # Note: Sequel caches `db.schema` results internally (Database#cache_schema is true by default),
110
+ # so this only hits the database once per table per app lifecycle.
111
+ # @see https://github.com/jeremyevans/sequel/blob/master/lib/sequel/extensions/schema_caching.rb
112
+ #
107
113
  sig { params(column_name: String).returns(T::Boolean) }
108
114
  def vector_column?(column_name)
109
115
  _col_name, col_info = T.let(
data/lib/kirei/model.rb CHANGED
@@ -25,7 +25,7 @@ module Kirei
25
25
  # Delete keeps the original object intact. Returns true if the record was deleted.
26
26
  # Calling delete multiple times will return false after the first (successful) call.
27
27
  sig { returns(T::Boolean) }
28
- def delete
28
+ def delete # rubocop:disable Naming/PredicateMethod
29
29
  count = self.class.query.where({ id: id }).delete
30
30
  count == 1
31
31
  end
@@ -1,8 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # rubocop:disable Metrics/all
5
-
4
+ # rubocop:disable Metrics
6
5
  module Kirei
7
6
  module Routing
8
7
  class Base
@@ -36,8 +35,10 @@ module Kirei
36
35
  #
37
36
 
38
37
  lookup_verb = http_verb == Verb::HEAD ? Verb::GET : http_verb
39
- route = router.get(lookup_verb, req_path)
40
- return NOT_FOUND if route.nil?
38
+ result = router.resolve(lookup_verb, req_path)
39
+ return NOT_FOUND if result.nil?
40
+
41
+ route, path_params = result
41
42
 
42
43
  router.current_env = env # expose the env to the controller
43
44
 
@@ -52,16 +53,23 @@ module Kirei
52
53
  when Verb::POST, Verb::PUT, Verb::PATCH
53
54
  # TODO: based on content-type, parse the body differently
54
55
  # built-in support for JSON & XML
55
- body = T.cast(env.fetch("rack.input"), T.any(IO, StringIO))
56
- res = Oj.load(body.read, Kirei::OJ_OPTIONS)
57
- body.rewind # TODO: maybe don't rewind if we don't need to?
58
- T.cast(res, T::Hash[String, T.untyped])
56
+ body = env.fetch("rack.input")
57
+ if body.nil? || !body.respond_to?(:read) || (body.respond_to?(:empty?) && body.empty?)
58
+ {}
59
+ else
60
+ body = T.cast(body, T.any(IO, StringIO))
61
+ res = Oj.load(body.read, Kirei::OJ_OPTIONS)
62
+ body.rewind # TODO: maybe don't rewind if we don't need to?
63
+ T.cast(res, T::Hash[String, T.untyped])
64
+ end
59
65
  when Verb::HEAD, Verb::DELETE, Verb::OPTIONS, Verb::TRACE, Verb::CONNECT
60
66
  {}
61
67
  else
62
68
  T.absurd(http_verb)
63
69
  end
64
70
 
71
+ params.merge!(path_params)
72
+
65
73
  req_id = T.cast(env["HTTP_X_REQUEST_ID"], T.nilable(String))
66
74
  req_id ||= "req_#{App.environment}_#{SecureRandom.uuid}"
67
75
  Thread.current[:request_id] = req_id
@@ -120,7 +128,7 @@ module Kirei
120
128
  stop = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
121
129
  if start # early return for 404
122
130
  latency_in_ms = stop - start
123
- ::StatsD.measure("request", latency_in_ms, tags: statsd_timing_tags)
131
+ App.config.metrics_backend.measure("request", latency_in_ms, tags: statsd_timing_tags)
124
132
 
125
133
  Kirei::Logging::Logger.call(
126
134
  level: status >= 500 ? Kirei::Logging::Level::ERROR : Kirei::Logging::Level::INFO,
@@ -154,6 +162,56 @@ module Kirei
154
162
  ]
155
163
  end
156
164
 
165
+ #
166
+ # Renders a JSON response. Accepts:
167
+ # - String: treated as pre-serialized JSON (pass-through)
168
+ # - Hash / Array: serialized via Oj.dump
169
+ # - Object responding to #serialize (e.g. T::Struct): calls #serialize,
170
+ # then Oj.dump if the result is not already a String
171
+ # - Anything else: raises ArgumentError
172
+ #
173
+ sig do
174
+ params(
175
+ data: T.untyped,
176
+ status: Integer,
177
+ headers: T::Hash[String, String],
178
+ ).returns(RackResponseType)
179
+ end
180
+ def render_json(data, status: 200, headers: {})
181
+ body = case data
182
+ when String
183
+ data
184
+ when Hash, Array
185
+ Oj.dump(data, Kirei::OJ_OPTIONS)
186
+ else
187
+ unless data.respond_to?(:serialize)
188
+ raise ArgumentError,
189
+ "render_json expects a String, Hash, Array, or an object responding to #serialize, " \
190
+ "got #{data.class}"
191
+ end
192
+
193
+ result = data.serialize
194
+ result.is_a?(String) ? result : Oj.dump(result, Kirei::OJ_OPTIONS)
195
+ end
196
+
197
+ render(body, status: status, headers: headers)
198
+ end
199
+
200
+ #
201
+ # Renders a JSON:API-compliant error response.
202
+ # Wraps an array of JsonApiError structs into { "errors": [...] }.
203
+ #
204
+ sig do
205
+ params(
206
+ errors: T::Array[Errors::JsonApiError],
207
+ status: Integer,
208
+ headers: T::Hash[String, String],
209
+ ).returns(RackResponseType)
210
+ end
211
+ def render_error(errors, status: 422, headers: {})
212
+ render_json({ "errors" => errors.map(&:serialize) }, status: status, headers: headers)
213
+ end
214
+
157
215
  sig { returns(T::Hash[String, String]) }
158
216
  def default_headers
159
217
  {
@@ -218,5 +276,4 @@ module Kirei
218
276
  end
219
277
  end
220
278
  end
221
-
222
- # rubocop:enable Metrics/all
279
+ # rubocop:enable Metrics
@@ -4,10 +4,22 @@
4
4
  module Kirei
5
5
  module Routing
6
6
  class Route < T::Struct
7
+ extend T::Sig
8
+
7
9
  const :verb, Verb
8
10
  const :path, String
9
11
  const :controller, T.class_of(Controller)
10
12
  const :action, String
13
+
14
+ sig { returns(T::Array[String]) }
15
+ def segments
16
+ @segments ||= T.let(path.split("/"), T.nilable(T::Array[String]))
17
+ end
18
+
19
+ sig { returns(T::Boolean) }
20
+ def dynamic?
21
+ segments.any? { |s| s.start_with?(":") }
22
+ end
11
23
  end
12
24
  end
13
25
  end
@@ -1,6 +1,8 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ # rubocop:disable Metrics
5
+
4
6
  require("singleton")
5
7
 
6
8
  module Kirei
@@ -25,17 +27,26 @@ module Kirei
25
27
  T::Hash[String, Route]
26
28
  end
27
29
 
30
+ ResolveResult = T.type_alias do
31
+ T.nilable([Route, T::Hash[String, String]])
32
+ end
33
+
28
34
  sig { returns(T.nilable(T::Hash[String, T.untyped])) }
29
35
  attr_accessor :current_env
30
36
 
31
37
  sig { void }
32
38
  def initialize
33
39
  @routes = T.let({}, RoutesHash)
40
+ @dynamic_routes = T.let([], T::Array[Route])
34
41
  end
35
42
 
36
43
  sig { returns(RoutesHash) }
37
44
  attr_reader :routes
38
45
 
46
+ sig { returns(T::Array[Route]) }
47
+ attr_reader :dynamic_routes
48
+
49
+ # Looks up a static route by exact verb + path match. O(1).
39
50
  sig do
40
51
  params(
41
52
  verb: Verb,
@@ -47,13 +58,72 @@ module Kirei
47
58
  routes[key]
48
59
  end
49
60
 
61
+ # Resolves a request to a route and extracted path parameters.
62
+ # Tries static O(1) lookup first, then falls back to dynamic segment matching.
63
+ sig do
64
+ params(
65
+ verb: Verb,
66
+ path: String,
67
+ ).returns(ResolveResult)
68
+ end
69
+ def resolve(verb, path)
70
+ static_route = get(verb, path)
71
+ return [static_route, {}] unless static_route.nil?
72
+
73
+ match_dynamic(verb, path)
74
+ end
75
+
50
76
  sig { params(routes: T::Array[Route]).void }
51
77
  def self.add_routes(routes)
52
78
  routes.each do |route|
53
- key = "#{route.verb.serialize} #{route.path}"
54
- instance.routes[key] = route
79
+ if route.dynamic?
80
+ instance.dynamic_routes << route
81
+ else
82
+ key = "#{route.verb.serialize} #{route.path}"
83
+ instance.routes[key] = route
84
+ end
85
+ end
86
+ end
87
+
88
+ # Matches a request path against registered dynamic routes.
89
+ # Returns [Route, extracted_params] or nil.
90
+ sig do
91
+ params(
92
+ verb: Verb,
93
+ path: String,
94
+ ).returns(ResolveResult)
95
+ end
96
+ private def match_dynamic(verb, path)
97
+ request_segments = path.split("/")
98
+
99
+ dynamic_routes.each do |route|
100
+ next unless route.verb == verb
101
+
102
+ route_segments = route.segments
103
+ next unless route_segments.length == request_segments.length
104
+
105
+ path_params = T.let({}, T::Hash[String, String])
106
+ matched = T.let(true, T::Boolean)
107
+
108
+ route_segments.each_with_index do |route_seg, idx|
109
+ req_seg = T.must(request_segments[idx])
110
+
111
+ if route_seg.start_with?(":")
112
+ param_name = T.must(route_seg[1..])
113
+ path_params[param_name] = req_seg
114
+ elsif route_seg != req_seg
115
+ matched = false
116
+ break
117
+ end
118
+ end
119
+
120
+ return [route, path_params] if matched
55
121
  end
122
+
123
+ nil
56
124
  end
57
125
  end
58
126
  end
59
127
  end
128
+
129
+ # rubocop:enable Metrics
@@ -27,7 +27,7 @@ module Kirei
27
27
  result = service_result(service)
28
28
 
29
29
  metric_tags = Logging::Metric.inject_defaults({ "service.result" => result })
30
- ::StatsD.measure(class_name, latency_in_ms, tags: metric_tags)
30
+ App.config.metrics_backend.measure(class_name, latency_in_ms, tags: metric_tags)
31
31
 
32
32
  logtags = {
33
33
  "service.name" => class_name.to_s,
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.8.3"
5
+ VERSION = "0.9.0"
6
6
  end
data/lib/kirei.rb CHANGED
@@ -13,7 +13,6 @@ require "bundler/setup"
13
13
 
14
14
  # Second: load all gems (runtime dependencies only)
15
15
  require "logger"
16
- require "statsd-instrument"
17
16
  require "sorbet-runtime"
18
17
  require "oj"
19
18
  require "rack"
@@ -0,0 +1,17 @@
1
+ # typed: true
2
+
3
+ # Minimal shim for `statsd-instrument`.
4
+ # The gem is an optional dependency — this shim lets Sorbet
5
+ # type-check `StatsdBackend` without requiring the gem at analysis time.
6
+ module StatsD
7
+ extend T::Sig
8
+
9
+ sig { params(name: String, value: T.any(Integer, Float), tags: T.untyped).void }
10
+ def self.increment(name, value = 1, tags: {}); end
11
+
12
+ sig { params(name: String, value: T.any(Integer, Float), tags: T.untyped).void }
13
+ def self.measure(name, value, tags: {}); end
14
+
15
+ sig { params(name: String, value: T.any(Integer, Float), tags: T.untyped).void }
16
+ def self.gauge(name, value, tags: {}); end
17
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kirei
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.3
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ludwig Reinmiedl
@@ -51,20 +51,6 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.5'
54
- - !ruby/object:Gem::Dependency
55
- name: statsd-instrument
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '3.0'
61
- type: :runtime
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '3.0'
68
54
  - !ruby/object:Gem::Dependency
69
55
  name: tzinfo-data
70
56
  requirement: !ruby/object:Gem::Requirement
@@ -165,6 +151,13 @@ files:
165
151
  - ".irbrc"
166
152
  - README.md
167
153
  - bin/kirei
154
+ - cops/layout.yml
155
+ - cops/lint.yml
156
+ - cops/metrics.yml
157
+ - cops/naming.yml
158
+ - cops/rspec.yml
159
+ - cops/style.yml
160
+ - cops/types.yml
168
161
  - kirei.gemspec
169
162
  - lib/cli.rb
170
163
  - lib/cli/commands/new_app/base_directories.rb
@@ -189,6 +182,10 @@ files:
189
182
  - lib/kirei/logging/level.rb
190
183
  - lib/kirei/logging/logger.rb
191
184
  - lib/kirei/logging/metric.rb
185
+ - lib/kirei/metrics/backend.rb
186
+ - lib/kirei/metrics/logging_backend.rb
187
+ - lib/kirei/metrics/null_backend.rb
188
+ - lib/kirei/metrics/statsd_backend.rb
192
189
  - lib/kirei/model.rb
193
190
  - lib/kirei/model/base_class_interface.rb
194
191
  - lib/kirei/model/class_methods.rb
@@ -201,7 +198,6 @@ files:
201
198
  - lib/kirei/routing/route.rb
202
199
  - lib/kirei/routing/router.rb
203
200
  - lib/kirei/routing/verb.rb
204
- - lib/kirei/services/array_comparison.rb
205
201
  - lib/kirei/services/result.rb
206
202
  - lib/kirei/services/runner.rb
207
203
  - lib/kirei/version.rb
@@ -209,6 +205,7 @@ files:
209
205
  - sorbet/rbi/shims/base_model.rbi
210
206
  - sorbet/rbi/shims/domain.rbi
211
207
  - sorbet/rbi/shims/ruby.rbi
208
+ - sorbet/rbi/shims/statsd.rbi
212
209
  homepage: https://github.com/swiknaba/kirei
213
210
  licenses:
214
211
  - MIT
@@ -229,7 +226,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
229
226
  - !ruby/object:Gem::Version
230
227
  version: '0'
231
228
  requirements: []
232
- rubygems_version: 3.6.7
229
+ rubygems_version: 4.0.3
233
230
  specification_version: 4
234
231
  summary: Kirei is a typed Ruby micro/REST-framework for building scalable and performant
235
232
  microservices.
@@ -1,35 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- module Kirei
5
- module Services
6
- class ArrayComparison
7
- extend T::Sig
8
-
9
- class Mode < T::Enum
10
- enums do
11
- STRICT = new("strict")
12
- IGNORE_ORDER = new("ignore_order")
13
- IGNORE_ORDER_AND_DUPLICATES = new("ignore_order_and_duplicates")
14
- end
15
- end
16
-
17
- sig do
18
- params(
19
- array_one: T::Array[T.untyped],
20
- array_two: T::Array[T.untyped],
21
- mode: Mode,
22
- ).returns(T::Boolean)
23
- end
24
- def self.call(array_one, array_two, mode: Mode::STRICT)
25
- case mode
26
- when Mode::STRICT then array_one == array_two
27
- when Mode::IGNORE_ORDER then array_one.sort == array_two.sort
28
- when Mode::IGNORE_ORDER_AND_DUPLICATES then array_one.to_set == array_two.to_set
29
- else
30
- T.absurd(mode)
31
- end
32
- end
33
- end
34
- end
35
- end