kirei 0.9.3 → 0.11.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: e4a3c571b78f3b40e68be644fd1600d01e4d4cc6d4801ff391bd19d2f4c9b309
4
- data.tar.gz: 38e2ebe1345eea2e527f9538627436d1aa15970ccf4cc2531d0a18a371589bd9
3
+ metadata.gz: 85f4c3fa6b6cc6dd3492be32c1fbc959b15b7dce43ab2de1435ce9129c757474
4
+ data.tar.gz: d80206078da55131d0c1c5c33212303f6eccc5492727093e172b4926673ff201
5
5
  SHA512:
6
- metadata.gz: b9f0950c53791800f8815ea62c7b5f3f97697b9f6f8095070f49d80a35dfccee84d5b47ab6d5ec34864f9d25bc7c542b9e2cf9ebfd61bc6d9b84d5ad2dee7bf2
7
- data.tar.gz: c842729e9d31eceff4e4c50d592ed592658156632b7353707030c7e07064b216d3530116405f957b16ce00f9ea9be24ae4b8edeb28c1bfe9b7924e3ac7b69a2d
6
+ metadata.gz: f48edabdddd9a567ad85f26bbf6bedc723258148426f9125cb45c48799d14695c6ad360bff9c9fa9ce29ea6b1804dd991b09d32b212f076de1ac1ef71c4abd79
7
+ data.tar.gz: dcdb445a12d614ff37a5396856bcade43e133546163f5adbe3a30b114e1973977de613d2ea486bf8081cdf410776835413f9a6fe2b786deb53586e87e273d00a
data/README.md CHANGED
@@ -55,7 +55,7 @@ Find a test app in the [spec/test_app](spec/test_app) directory. It is a fully f
55
55
 
56
56
  #### Models
57
57
 
58
- All models must inherit from `T::Struct` and include `Kirei::Model`. They must implement `id` which must hold the primary key of the table. The primary key must be named `id` and be of type `T.any(String, Integer)`.
58
+ All models must inherit from `T::Struct` and include `Kirei::Model`. They must implement `id` which must hold the primary key of the table. The primary key must be named `id` and be a `String`. Kirei generates it on `create` with `generate_human_id` (for example `user_K7mQ2xRtB9vN`) unless the caller passes one; database sequences are not supported. Override `human_id_length` or `human_id_prefix` per model to change the format.
59
59
 
60
60
  Kirei models are immutable by convention - all properties are defined using `const` and updating a record returns a new instance rather than mutating the original. This immutability, combined with strict typing, makes them naturally suitable for both traditional data-centric applications and domain-driven design approaches.
61
61
 
@@ -66,7 +66,7 @@ class User < T::Struct
66
66
  extend T::Sig
67
67
  include Kirei::Model
68
68
 
69
- const :id, T.any(String, Integer)
69
+ const :id, String
70
70
  const :name, String
71
71
  end
72
72
 
@@ -114,11 +114,11 @@ Kirei provides support for Domain-Driven Design patterns through `Kirei::Domain:
114
114
  class Flight < T::Struct
115
115
  include Kirei::Domain::Entity
116
116
 
117
- const :id, Integer
117
+ const :id, String
118
118
 
119
119
  const :flight_number, String
120
- const :departure_airport_id, Integer
121
- const :arrival_airport_id, Integer
120
+ const :departure_airport_id, String
121
+ const :arrival_airport_id, String
122
122
  const :scheduled_departure_at, Time
123
123
  const :status, String
124
124
 
@@ -354,6 +354,43 @@ Request timing and service execution timing are tracked automatically.
354
354
 
355
355
  To build a custom backend (e.g. Prometheus, OpenTelemetry), subclass `Kirei::Metrics::Backend` and implement `increment`, `measure`, and `gauge`.
356
356
 
357
+ ### Web servers and request bodies
358
+
359
+ Kirei runs on any Rack server. It routes by `PATH_INFO`, accepts any readable `rack.input`, and keeps the request env fiber-local, so both thread-based servers (Puma) and fiber-based servers (Falcon) work. With Falcon, also set `config.db_global_extensions = [:fiber_concurrency]` (see below).
360
+
361
+ JSON and form bodies are buffered in memory. Bound them per app:
362
+
363
+ ```ruby
364
+ class MyApp < Kirei::App
365
+ config.max_request_body_bytes = 65_536 # nil (default) means unlimited
366
+ end
367
+ ```
368
+
369
+ A larger body gets a `413` JSON:API error with code `payload_too_large`. Multipart uploads stream to tempfiles through Rack and are not limited by this setting. Malformed JSON returns `400` with code `malformed_json`, and a JSON body that is not an object returns `400` with code `invalid_json_body`.
370
+
371
+ ### Database connection
372
+
373
+ `Kirei::App.raw_db_connection` opens one Sequel database per process. Configure it in your app:
374
+
375
+ ```ruby
376
+ class MyApp < Kirei::App
377
+ # Global extensions change Sequel itself and are loaded via `Sequel.extension`
378
+ # before the connection exists. Use `:fiber_concurrency` with Async/Falcon.
379
+ config.db_global_extensions = [:fiber_concurrency]
380
+
381
+ # Database extensions are loaded on the connection via `Sequel::Database#extension`.
382
+ config.db_extensions += [:pgvector]
383
+
384
+ # Pool bounds and per-connection session setup; `nil` keeps the Sequel default.
385
+ config.db_max_connections = Integer(ENV.fetch("DB_POOL_SIZE", "5"))
386
+ config.db_pool_timeout = Float(ENV.fetch("DB_POOL_TIMEOUT", "2"))
387
+ config.db_connect_timeout = 5
388
+ config.db_connect_sqls = ["SET statement_timeout = '10s'"]
389
+ end
390
+ ```
391
+
392
+ A global extension passed to `db_extensions` is silently ignored by Sequel, so keep the two lists apart.
393
+
357
394
  ### Goes well with these gems
358
395
 
359
396
  * [pagy](https://github.com/ddnexus/pagy) for pagination
@@ -44,7 +44,10 @@ module Cli
44
44
 
45
45
  # Fifth: load configs
46
46
  Dir[File.join(__dir__, "config", "**", "*.rb")].each do |cnf|
47
- require(cnf) unless cnf.split("/").include?("initializers")
47
+ next if cnf.split("/").include?("initializers")
48
+ next if cnf.end_with?("puma.rb") # Puma config uses DSL only available when loaded by Puma
49
+
50
+ require(cnf)
48
51
  end
49
52
 
50
53
  class #{app_name} < Kirei::App
@@ -5,10 +5,9 @@ module Cli
5
5
  module NewApp
6
6
  module Files
7
7
  class Routes
8
- def self.call(app_name)
8
+ def self.call(_app_name)
9
9
  File.write("config/routes.rb", router)
10
10
  File.write("app/controllers/base.rb", base_controller)
11
- File.write("app/controllers/health.rb", health_controller(app_name))
12
11
  end
13
12
 
14
13
  def self.router
@@ -17,16 +16,7 @@ module Cli
17
16
  # frozen_string_literal: true
18
17
 
19
18
  module Kirei::Routing
20
- Router.add_routes(
21
- [
22
- Route.new(
23
- verb: Verb::GET,
24
- path: "/livez",
25
- controller: Controllers::Health,
26
- action: "livez",
27
- ),
28
- ],
29
- )
19
+ Router.add_health_routes!
30
20
  end
31
21
  RUBY
32
22
  end
@@ -43,24 +33,6 @@ module Cli
43
33
  end
44
34
  RUBY
45
35
  end
46
-
47
- def self.health_controller(app_name)
48
- <<~RUBY
49
- # typed: strict
50
- # frozen_string_literal: true
51
-
52
- module Controllers
53
- class Health < Base
54
- sig { returns(T.anything) }
55
- def livez
56
- #{app_name}.config.logger.info("Health check")
57
- #{app_name}.config.logger.info(params.inspect)
58
- render(#{app_name}.version, status: 200)
59
- end
60
- end
61
- end
62
- RUBY
63
- end
64
36
  end
65
37
  end
66
38
  end
data/lib/kirei/app.rb CHANGED
@@ -73,19 +73,34 @@ module Kirei
73
73
  @raw_db_connection = T.let(@raw_db_connection, T.nilable(Sequel::Database))
74
74
  return @raw_db_connection unless @raw_db_connection.nil?
75
75
 
76
+ config.db_global_extensions.each { |ext| Sequel.extension(ext) }
77
+
76
78
  # calling "Sequel.connect" creates a new connection
77
- @raw_db_connection = Sequel.connect(App.config.db_url || default_db_url)
79
+ database = Sequel.connect(App.config.db_url || default_db_url, db_connect_options)
80
+ @raw_db_connection = apply_db_extensions(database)
81
+ end
78
82
 
79
- config.db_extensions.each do |ext|
80
- T.cast(@raw_db_connection, Sequel::Database).extension(ext)
81
- end
83
+ sig { params(database: Sequel::Database).returns(Sequel::Database) }
84
+ private def apply_db_extensions(database)
85
+ config.db_extensions.each { |ext| database.extension(ext) }
82
86
 
83
87
  if config.db_extensions.include?(:pg_json)
84
88
  # https://github.com/jeremyevans/sequel/blob/5.75.0/lib/sequel/extensions/pg_json.rb#L8
85
- @raw_db_connection.wrap_json_primitives = true
89
+ database.wrap_json_primitives = true
86
90
  end
87
91
 
88
- @raw_db_connection
92
+ database
93
+ end
94
+
95
+ sig { returns(T::Hash[Symbol, T.any(Integer, Float, T::Array[String])]) }
96
+ private def db_connect_options
97
+ connect_sqls = config.db_connect_sqls
98
+ {
99
+ max_connections: config.db_max_connections,
100
+ pool_timeout: config.db_pool_timeout,
101
+ connect_timeout: config.db_connect_timeout,
102
+ connect_sqls: connect_sqls.empty? ? nil : connect_sqls,
103
+ }.compact
89
104
  end
90
105
  end
91
106
  end
data/lib/kirei/config.rb CHANGED
@@ -28,11 +28,29 @@ module Kirei
28
28
 
29
29
  prop :app_name, String, default: "kirei"
30
30
 
31
+ # Database extensions are loaded on the connection via `Sequel::Database#extension`.
32
+ # They add behaviour to one database object, e.g. column type parsing.
33
+ #
31
34
  # must use "pg_json" to parse jsonb columns to hashes
32
35
  #
33
36
  # Source: https://github.com/jeremyevans/sequel/blob/5.75.0/lib/sequel/extensions/pg_json.rb
34
- prop :db_extensions, T::Array[Symbol], default: %i[pg_json pg_array] # add "fiber_concurrency" by default, too?
37
+ prop :db_extensions, T::Array[Symbol], default: %i[pg_json pg_array]
38
+ # Global extensions are loaded via `Sequel.extension` before the connection is created.
39
+ # They change Sequel itself, not a database object, e.g. `:fiber_concurrency` switches the
40
+ # concurrency primitive from `Thread.current` to `Fiber.current` for Async/Falcon servers.
41
+ # Loading one on a database object is silently ignored.
42
+ #
43
+ # Source: https://github.com/jeremyevans/sequel/blob/5.75.0/doc/extensions.rdoc
44
+ prop :db_global_extensions, T::Array[Symbol], default: []
35
45
  prop :db_url, T.nilable(String)
46
+ # Connection pool bounds passed to `Sequel.connect`. `nil` keeps the Sequel default.
47
+ #
48
+ # Source: https://github.com/jeremyevans/sequel/blob/5.75.0/doc/opening_databases.rdoc
49
+ prop :db_max_connections, T.nilable(Integer)
50
+ prop :db_pool_timeout, T.nilable(Float)
51
+ prop :db_connect_timeout, T.nilable(Integer)
52
+ # SQL statements run on every new connection, e.g. session timeouts.
53
+ prop :db_connect_sqls, T::Array[String], default: []
36
54
  # Extra or unknown properties present in the Hash do not raise exceptions at runtime
37
55
  # unless the optional strict argument to from_hash is passed
38
56
  #
@@ -40,5 +58,10 @@ module Kirei
40
58
  prop :db_strict_type_resolving, T.nilable(T::Boolean), default: nil
41
59
 
42
60
  prop :allowed_origins, T::Array[String], default: []
61
+
62
+ # Upper bound for JSON and form request bodies, which Kirei buffers in memory.
63
+ # Requests above it get a 413 JSON:API error. `nil` means no limit.
64
+ # Multipart uploads stream to tempfiles via Rack and are not subject to it.
65
+ prop :max_request_body_bytes, T.nilable(Integer), default: nil
43
66
  end
44
67
  end
@@ -0,0 +1,63 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Controllers
6
+ #
7
+ # Built-in health check controller implementing Kubernetes API health endpoints.
8
+ #
9
+ # Provides three endpoints following the Kubernetes convention:
10
+ # - /livez — Liveness probe. Indicates the process is alive.
11
+ # - /readyz — Readiness probe. Verifies downstream dependencies (DB) are reachable.
12
+ # - /healthz — Deprecated alias for /livez (deprecated since Kubernetes v1.16).
13
+ #
14
+ # Reference: https://kubernetes.io/docs/reference/using-api/health-checks/
15
+ #
16
+ class Health < Kirei::Controller
17
+ extend T::Sig
18
+
19
+ sig { returns(Routing::RackResponseType) }
20
+ def livez
21
+ info = {
22
+ "status" => "ok",
23
+ "version" => App.version,
24
+ "app_name" => App.config.app_name,
25
+ "environment" => App.environment,
26
+ "req_host" => request.host,
27
+ "req_port" => request.port,
28
+ "req_ssl" => request.ssl?,
29
+ }
30
+
31
+ Kirei::Logging::Logger.call(
32
+ level: Kirei::Logging::Level::INFO,
33
+ label: "Health check",
34
+ meta: info,
35
+ )
36
+
37
+ render_json(info, status: 200)
38
+ end
39
+ alias healthz livez
40
+
41
+ sig { returns(Routing::RackResponseType) }
42
+ def readyz
43
+ T.unsafe(App.raw_db_connection).execute("SELECT 1")
44
+
45
+ Kirei::Logging::Logger.call(
46
+ level: Kirei::Logging::Level::INFO,
47
+ label: "Readiness check",
48
+ meta: { "status" => "ok" },
49
+ )
50
+
51
+ render_json({ "status" => "ok" }, status: 200)
52
+ rescue Sequel::Error => e
53
+ Kirei::Logging::Logger.call(
54
+ level: Kirei::Logging::Level::ERROR,
55
+ label: "Readiness check failed",
56
+ meta: { "error" => e.message },
57
+ )
58
+
59
+ render_json({ "status" => "unavailable", "reason" => e.message }, status: 503)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -54,20 +54,19 @@ module Kirei
54
54
  resolve(query.all)
55
55
  end
56
56
 
57
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
58
- # default values defined in the model are used, if omitted in the hash
57
+ # Default values defined in the model are used if omitted in the hash.
58
+ # A missing `id` is filled with `generate_human_id`; primary keys are
59
+ # always application-generated strings, never database sequences.
59
60
  sig do
60
61
  override.params(
61
62
  hash: T::Hash[Symbol, T.untyped],
62
63
  ).returns(T.attached_class)
63
64
  end
64
- def create(hash)
65
+ def create(hash) # rubocop:disable Metrics/AbcSize
66
+ attributes = hash.key?(:id) ? hash : hash.merge(id: generate_human_id)
65
67
  # instantiate a new object to ensure we use default values defined in the model
66
- without_id = !hash.key?(:id)
67
- hash[:id] = "kirei-fake-id" if without_id
68
- new_record = from_hash(Helpers.deep_stringify_keys(hash))
68
+ new_record = from_hash(Helpers.deep_stringify_keys(attributes))
69
69
  all_attributes = T.let(new_record.serialize, T::Hash[String, T.untyped])
70
- all_attributes.delete("id") if without_id && all_attributes["id"] == "kirei-fake-id"
71
70
 
72
71
  wrap_jsonb_non_primivitives!(all_attributes)
73
72
 
@@ -78,11 +77,10 @@ module Kirei
78
77
  all_attributes["updated_at"] = Time.now.utc
79
78
  end
80
79
 
81
- pkey = T.let(query.insert(all_attributes), String)
80
+ query.insert(all_attributes)
82
81
 
83
- T.must(find_by({ id: pkey }))
82
+ T.must(find_by({ id: new_record.id }))
84
83
  end
85
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
86
84
 
87
85
  sig { override.params(attributes: T::Hash[T.any(Symbol, String), T.untyped]).void }
88
86
  def wrap_jsonb_non_primivitives!(attributes) # rubocop:disable Metrics/AbcSize
@@ -180,9 +178,10 @@ module Kirei
180
178
  resolve(query.limit(1), strict_loading).first
181
179
  end
182
180
 
183
- # defaults to 6
181
+ # 12 characters from a 55-character alphabet; collisions are negligible
182
+ # even for large tables. Override per model for shorter, hand-typed ids.
184
183
  sig { override.returns(Integer) }
185
- def human_id_length = 6
184
+ def human_id_length = 12
186
185
 
187
186
  # defaults to "model_name" (table_name without the trailing "s")
188
187
  sig { override.returns(String) }
@@ -8,6 +8,9 @@ module Kirei
8
8
  extend T::Sig
9
9
 
10
10
  NOT_FOUND = T.let([404, {}, ["Not Found"]], RackResponseType) # rubocop:disable Style/MutableConstant
11
+ # Oj raises EncodingError for malformed input in the modes Kirei uses, and
12
+ # Oj::ParseError for other parse failures.
13
+ JSON_PARSE_ERRORS = T.let([Oj::ParseError, EncodingError].freeze, T::Array[T.class_of(StandardError)])
11
14
 
12
15
  sig { params(params: T::Hash[String, T.untyped]).void }
13
16
  def initialize(params: {})
@@ -28,7 +31,7 @@ module Kirei
28
31
  status = 500 # we use it in the "ensure" block, so we need to define early (Sorbet doesn't like `status ||= 418`)
29
32
 
30
33
  http_verb = Verb.deserialize(env.fetch("REQUEST_METHOD"))
31
- req_path = T.cast(env.fetch("REQUEST_PATH"), String)
34
+ req_path = request_path(env)
32
35
  #
33
36
  # TODO: reject requests from unexpected hosts -> allow configuring allowed hosts in a `cors.rb` file
34
37
  # ( offer a scaffold for this file )
@@ -52,18 +55,8 @@ module Kirei
52
55
  [k, v]
53
56
  end
54
57
  when Verb::POST, Verb::PUT, Verb::PATCH
55
- # TODO: based on content-type, parse the body differently
56
- # built-in support for JSON & XML
57
- body = env.fetch("rack.input")
58
- if body.nil? || !body.respond_to?(:read) || (body.respond_to?(:empty?) && body.empty?)
59
- {}
60
- else
61
- body = T.cast(body, T.any(IO, StringIO))
62
- res = Oj.load(body.read, Kirei::OJ_OPTIONS)
63
- body.rewind # TODO: maybe don't rewind if we don't need to?
64
- T.cast(res, T::Hash[String, T.untyped])
65
- end
66
- when Verb::HEAD, Verb::DELETE, Verb::OPTIONS, Verb::TRACE, Verb::CONNECT
58
+ parse_request_body(env)
59
+ when Verb::HEAD, Verb::DELETE, Verb::OPTIONS
67
60
  {}
68
61
  else
69
62
  T.absurd(http_verb)
@@ -95,7 +88,7 @@ module Kirei
95
88
  statsd_timing_tags["route"] = route.action
96
89
 
97
90
  status, headers, response_body = case http_verb
98
- when Verb::HEAD, Verb::OPTIONS, Verb::TRACE, Verb::CONNECT
91
+ when Verb::HEAD, Verb::OPTIONS
99
92
  [200, {}, []]
100
93
  when Verb::GET, Verb::POST, Verb::PUT, Verb::PATCH, Verb::DELETE
101
94
  T.cast(
@@ -122,6 +115,37 @@ module Kirei
122
115
  headers,
123
116
  response_body,
124
117
  ]
118
+ rescue RequestRejected => e
119
+ status = e.status
120
+ error = Errors::JsonApiError.new(code: e.code, detail: e.message)
121
+ body = Oj.dump({ "errors" => [error.serialize] }, Kirei::OJ_OPTIONS)
122
+ response_body = [body]
123
+
124
+ [status, { "Content-Type" => "application/json; charset=utf-8" }, response_body]
125
+ rescue StandardError => e
126
+ status = 500
127
+
128
+ Kirei::Logging::Logger.call(
129
+ level: Kirei::Logging::Level::ERROR,
130
+ label: "Unhandled Exception",
131
+ meta: {
132
+ "error.class" => e.class.name,
133
+ "error.message" => e.message,
134
+ "error.backtrace" => e.backtrace&.first(10)&.join("\n"),
135
+ },
136
+ )
137
+
138
+ detail = if Kirei::App.environment == "development"
139
+ "#{e.class}: #{e.message}\n#{e.backtrace&.first(10)&.join("\n")}"
140
+ else
141
+ "An unexpected error occurred"
142
+ end
143
+
144
+ error = Errors::JsonApiError.new(code: "internal_server_error", detail: detail)
145
+ body = Oj.dump({ "errors" => [error.serialize] }, Kirei::OJ_OPTIONS)
146
+ response_body = [body]
147
+
148
+ [status, { "Content-Type" => "application/json; charset=utf-8" }, response_body]
125
149
  ensure
126
150
  stop = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
127
151
  if start && statsd_timing_tags # early return for 404
@@ -138,10 +162,21 @@ module Kirei
138
162
 
139
163
  # reset global variables after the request has been served
140
164
  # and after all "after" hooks have run to avoid leaking
165
+ router.current_env = nil
141
166
  Thread.current[:enduser_id] = nil
142
167
  Thread.current[:request_id] = nil
143
168
  end
144
169
 
170
+ # `PATH_INFO` is the Rack-specified key. `REQUEST_PATH` is a Puma
171
+ # extension that Falcon does not set.
172
+ sig { params(env: RackEnvType).returns(String) }
173
+ private def request_path(env)
174
+ path = T.cast(env.fetch("PATH_INFO", nil), T.nilable(String))
175
+ return path unless path.nil? || path.empty?
176
+
177
+ T.cast(env.fetch("REQUEST_PATH"), String)
178
+ end
179
+
145
180
  #
146
181
  # * "status": defaults to 200
147
182
  # * "headers": Kirei adds some default headers for security, but the user can override them
@@ -293,6 +328,81 @@ module Kirei
293
328
 
294
329
  result
295
330
  end
331
+
332
+ #
333
+ # Body parsing by Content-Type:
334
+ # - multipart/form-data: form fields plus uploaded files wrapped in Rack::Multipart::UploadedFile
335
+ # - application/json: JSON object
336
+ # - anything else: JSON first, URL-encoded form data as fallback
337
+ #
338
+ # `rack.input` is only required to respond to `read`; Puma passes an IO or
339
+ # StringIO, Falcon passes a Protocol::Rack::Input.
340
+ #
341
+ sig { params(env: RackEnvType).returns(T::Hash[String, T.untyped]) }
342
+ private def parse_request_body(env)
343
+ content_type = T.cast(env.fetch("CONTENT_TYPE", ""), String)
344
+ body = env["rack.input"]
345
+ return {} if body.nil? || !body.respond_to?(:read)
346
+
347
+ return wrap_uploaded_files(Rack::Request.new(env).params) if content_type.include?("multipart/form-data")
348
+
349
+ raw = read_bounded_body(body)
350
+ return {} if raw.empty?
351
+
352
+ return parse_json_object(raw) if content_type.include?("application/json")
353
+
354
+ begin
355
+ parsed = Oj.load(raw, Kirei::OJ_OPTIONS)
356
+ return parsed if parsed.is_a?(Hash)
357
+ rescue *JSON_PARSE_ERRORS
358
+ # fall through to form data
359
+ end
360
+
361
+ T.cast(Rack::Request.new(env).params, T::Hash[String, T.untyped])
362
+ end
363
+
364
+ sig { params(body: T.untyped).returns(String) }
365
+ private def read_bounded_body(body)
366
+ limit = App.config.max_request_body_bytes
367
+ raw = limit.nil? ? body.read : body.read(limit + 1)
368
+ body.rewind if body.respond_to?(:rewind)
369
+ raw = T.cast(raw || "", String)
370
+ if !limit.nil? && raw.bytesize > limit
371
+ raise RequestRejected.new(
372
+ status: 413,
373
+ code: "payload_too_large",
374
+ detail: "Request body exceeds #{limit} bytes",
375
+ )
376
+ end
377
+
378
+ raw
379
+ end
380
+
381
+ sig { params(raw: String).returns(T::Hash[String, T.untyped]) }
382
+ private def parse_json_object(raw)
383
+ parsed = Oj.load(raw, Kirei::OJ_OPTIONS)
384
+ unless parsed.is_a?(Hash)
385
+ raise RequestRejected.new(status: 400, code: "invalid_json_body", detail: "JSON body must be an object")
386
+ end
387
+
388
+ parsed
389
+ rescue *JSON_PARSE_ERRORS => e
390
+ raise RequestRejected.new(status: 400, code: "malformed_json", detail: e.message)
391
+ end
392
+
393
+ # Rack's multipart parser yields a Hash (symbol keys) per uploaded file; wrap it in a typed object.
394
+ sig { params(params: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) }
395
+ private def wrap_uploaded_files(params)
396
+ params.transform_values do |value|
397
+ next value unless value.is_a?(Hash) && value.key?(:tempfile)
398
+
399
+ Rack::Multipart::UploadedFile.new(
400
+ io: value.fetch(:tempfile),
401
+ content_type: value.fetch(:type, "application/octet-stream"),
402
+ filename: value.fetch(:filename),
403
+ )
404
+ end
405
+ end
296
406
  end
297
407
  end
298
408
  end
@@ -0,0 +1,25 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Routing
6
+ # Raised while reading a request body when the request itself is at fault.
7
+ # `Base#call` renders it as a JSON:API error with the given status.
8
+ class RequestRejected < StandardError
9
+ extend T::Sig
10
+
11
+ sig { returns(Integer) }
12
+ attr_reader :status
13
+
14
+ sig { returns(String) }
15
+ attr_reader :code
16
+
17
+ sig { params(status: Integer, code: String, detail: String).void }
18
+ def initialize(status:, code:, detail:)
19
+ @status = status
20
+ @code = code
21
+ super(detail)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -31,8 +31,18 @@ module Kirei
31
31
  T.nilable([Route, T::Hash[String, String]])
32
32
  end
33
33
 
34
- sig { returns(T.nilable(T::Hash[String, T.untyped])) }
35
- attr_accessor :current_env
34
+ # The router is a process-wide singleton, but a request env must be
35
+ # scoped to the request. `Thread.current[]` is fiber-local, so this is
36
+ # safe for both thread-based (Puma) and fiber-based (Falcon) servers.
37
+ sig { returns(T.nilable(RackEnvType)) }
38
+ def current_env
39
+ Thread.current[:kirei_current_env]
40
+ end
41
+
42
+ sig { params(env: T.nilable(RackEnvType)).returns(T.nilable(RackEnvType)) }
43
+ def current_env=(env)
44
+ Thread.current[:kirei_current_env] = env
45
+ end
36
46
 
37
47
  sig { void }
38
48
  def initialize
@@ -85,6 +95,18 @@ module Kirei
85
95
  end
86
96
  end
87
97
 
98
+ # must be added manually => we don't want to magically add routes for the user
99
+ sig { void }
100
+ def self.add_health_routes!
101
+ add_routes(
102
+ [
103
+ Route.new(verb: Verb::GET, path: "/livez", controller: Kirei::Controllers::Health, action: "livez"),
104
+ Route.new(verb: Verb::GET, path: "/readyz", controller: Kirei::Controllers::Health, action: "readyz"),
105
+ Route.new(verb: Verb::GET, path: "/healthz", controller: Kirei::Controllers::Health, action: "healthz"),
106
+ ],
107
+ )
108
+ end
109
+
88
110
  # Matches a request path against registered dynamic routes.
89
111
  # Returns [Route, extracted_params] or nil.
90
112
  sig do
@@ -25,12 +25,6 @@ module Kirei
25
25
 
26
26
  # idempotent
27
27
  OPTIONS = new("OPTIONS")
28
-
29
- # idempotent
30
- TRACE = new("TRACE")
31
-
32
- # non-idempotent
33
- CONNECT = new("CONNECT")
34
28
  end
35
29
  end
36
30
  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.9.3"
5
+ VERSION = "0.11.0"
6
6
  end
data/lib/kirei.rb CHANGED
@@ -67,6 +67,4 @@ loader.eager_load
67
67
 
68
68
  Kirei.configure(&:itself)
69
69
 
70
- yjit_enabled = defined?(RubyVM::YJIT) ? RubyVM::YJIT.enabled? : false
71
-
72
- Kirei::Logging::Logger.logger.info("Kirei v#{Kirei::VERSION} booted; YJIT enabled: #{yjit_enabled}")
70
+ Kirei::Logging::Logger.logger.info("Kirei v#{Kirei::VERSION} booted")
@@ -6,7 +6,7 @@ module Kirei
6
6
  include Kernel # "self" is a class since we include the module in a class
7
7
  include T::Props::Serializable
8
8
 
9
- sig { returns(T.any(String, Integer)) }
9
+ sig { returns(String) }
10
10
  def id; end
11
11
 
12
12
  module ClassMethods
@@ -0,0 +1,10 @@
1
+ # typed: strict
2
+
3
+ # The pg_json database extension adds this setter at runtime via
4
+ # `Sequel::Database#extension(:pg_json)`; Tapioca cannot see it.
5
+ #
6
+ # Source: https://github.com/jeremyevans/sequel/blob/5.75.0/lib/sequel/extensions/pg_json.rb#L8
7
+ class Sequel::Database
8
+ sig { params(value: T::Boolean).returns(T::Boolean) }
9
+ def wrap_json_primitives=(value); end
10
+ 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.9.3
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ludwig Reinmiedl
@@ -174,6 +174,7 @@ files:
174
174
  - lib/kirei/app.rb
175
175
  - lib/kirei/config.rb
176
176
  - lib/kirei/controller.rb
177
+ - lib/kirei/controllers/health.rb
177
178
  - lib/kirei/domain/entity.rb
178
179
  - lib/kirei/domain/value_object.rb
179
180
  - lib/kirei/errors/json_api_error.rb
@@ -195,6 +196,7 @@ files:
195
196
  - lib/kirei/routing/rack_env_type.rb
196
197
  - lib/kirei/routing/rack_response_type.rb
197
198
  - lib/kirei/routing/request.rb
199
+ - lib/kirei/routing/request_rejected.rb
198
200
  - lib/kirei/routing/route.rb
199
201
  - lib/kirei/routing/router.rb
200
202
  - lib/kirei/routing/verb.rb
@@ -205,6 +207,7 @@ files:
205
207
  - sorbet/rbi/shims/base_model.rbi
206
208
  - sorbet/rbi/shims/domain.rbi
207
209
  - sorbet/rbi/shims/ruby.rbi
210
+ - sorbet/rbi/shims/sequel_pg_json.rbi
208
211
  - sorbet/rbi/shims/statsd.rbi
209
212
  homepage: https://github.com/swiknaba/kirei
210
213
  licenses:
@@ -226,7 +229,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
226
229
  - !ruby/object:Gem::Version
227
230
  version: '0'
228
231
  requirements: []
229
- rubygems_version: 4.0.3
232
+ rubygems_version: 4.0.20
230
233
  specification_version: 4
231
234
  summary: Kirei is a typed Ruby micro/REST-framework for building scalable and performant
232
235
  microservices.