kirei 0.10.0 → 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: 1926e641bc2fa378b9444e97f33c7aef0bd97ce3c277a38fc572f12d501acb77
4
- data.tar.gz: 2cdaff92504ea20fac445d791da1f9557fe3fbe6f4f780eab70c9022feacb3e1
3
+ metadata.gz: 85f4c3fa6b6cc6dd3492be32c1fbc959b15b7dce43ab2de1435ce9129c757474
4
+ data.tar.gz: d80206078da55131d0c1c5c33212303f6eccc5492727093e172b4926673ff201
5
5
  SHA512:
6
- metadata.gz: 4111679c635210bd6a9389c839c2665e5fea9a1e4bd3e6186725e89022a8964cf7a2dc8c2b5347eaf76962b327a7c0a77243cfc7c6ac214e01f59592a08a132f
7
- data.tar.gz: 8aaf9c748f52d790cc2be69cb29087c96f1ad1001ee629555b5727d93770a1b73be72fbb62a8d1b3993a3ef75676832463b401cb265e5dd18a277d9706b9fc68
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
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
@@ -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,17 +55,7 @@ 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
58
+ parse_request_body(env)
66
59
  when Verb::HEAD, Verb::DELETE, Verb::OPTIONS
67
60
  {}
68
61
  else
@@ -122,6 +115,13 @@ 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
125
  rescue StandardError => e
126
126
  status = 500
127
127
 
@@ -162,10 +162,21 @@ module Kirei
162
162
 
163
163
  # reset global variables after the request has been served
164
164
  # and after all "after" hooks have run to avoid leaking
165
+ router.current_env = nil
165
166
  Thread.current[:enduser_id] = nil
166
167
  Thread.current[:request_id] = nil
167
168
  end
168
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
+
169
180
  #
170
181
  # * "status": defaults to 200
171
182
  # * "headers": Kirei adds some default headers for security, but the user can override them
@@ -317,6 +328,81 @@ module Kirei
317
328
 
318
329
  result
319
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
320
406
  end
321
407
  end
322
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
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.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
@@ -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.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ludwig Reinmiedl
@@ -196,6 +196,7 @@ files:
196
196
  - lib/kirei/routing/rack_env_type.rb
197
197
  - lib/kirei/routing/rack_response_type.rb
198
198
  - lib/kirei/routing/request.rb
199
+ - lib/kirei/routing/request_rejected.rb
199
200
  - lib/kirei/routing/route.rb
200
201
  - lib/kirei/routing/router.rb
201
202
  - lib/kirei/routing/verb.rb
@@ -206,6 +207,7 @@ files:
206
207
  - sorbet/rbi/shims/base_model.rbi
207
208
  - sorbet/rbi/shims/domain.rbi
208
209
  - sorbet/rbi/shims/ruby.rbi
210
+ - sorbet/rbi/shims/sequel_pg_json.rbi
209
211
  - sorbet/rbi/shims/statsd.rbi
210
212
  homepage: https://github.com/swiknaba/kirei
211
213
  licenses:
@@ -227,7 +229,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
227
229
  - !ruby/object:Gem::Version
228
230
  version: '0'
229
231
  requirements: []
230
- rubygems_version: 4.0.6
232
+ rubygems_version: 4.0.20
231
233
  specification_version: 4
232
234
  summary: Kirei is a typed Ruby micro/REST-framework for building scalable and performant
233
235
  microservices.