kirei 0.0.3 → 0.2.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: b67435621f32def7ede1b291b5d0fe529125bda4dd34149afa5b7db6d2193ddd
4
- data.tar.gz: fb6c0e91a22bdb8cf726f638c5a250c5245c9a2de4e662f853d2b436a865d2fe
3
+ metadata.gz: 8a37402ff7c216a16e3cc2a279d477e91d7ed8c40fd58169a8c598e47efd90d7
4
+ data.tar.gz: 1fd249f44b20dc1dfba0ace6ea52f6de6f67ee47e4c2a7f511438ceafba4985f
5
5
  SHA512:
6
- metadata.gz: 5529b60454e2389fb5df0ff5fad1cdc5f3b1c8385396aa3f65d977db9bb6ceccbd9e93de70de7dba4f719681f8332cc30a2dfd087117589c274b7ad5f99926ef
7
- data.tar.gz: 61f28174f219f9968e59183d473a4296d0fe659e3104b4ab2debe8e7cef27de077f653279a9897f7fc71af541597b8b90c3cf8ef66c0cbbadd40984f1520d1a4
6
+ metadata.gz: d6d29c323e8b3438a0c9775f2b92312182a709443aa04843145bfed76226d71e2d78081361b178dc6293dfefa75571d525981b5ed84fee2664c93a880eb5f6fa
7
+ data.tar.gz: 6b33ff256a0a199333d789c81ff70f4dca49225069f0c854fe0f4cfec4c6a68c918a6966b92c93ef76aebd14117029c5f3e7ad36d65d7d74033c5d7a0f905ab4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
+ # Changelog
2
+
1
3
  ## [Unreleased]
2
4
 
3
5
  ## [0.1.0] - 2023-09-02
4
6
 
5
7
  - Initial release
8
+ - added base model
9
+ - added database connection
10
+ - WIP: basic cli to scaffold a new project
11
+
12
+ ## [0.2.0] - 2023-09-02
13
+
14
+ - added routing
15
+ - added base controller
16
+ - added database tasks (create, drop, migrate, rollback, generate migration)
data/README.md CHANGED
@@ -49,6 +49,10 @@ bundle exec kirei new "MyApp"
49
49
 
50
50
  ### Quick Start
51
51
 
52
+ Find a test app in the [spec/test_app](spec/test_app) directory. It is a fully functional example of a Kirei app.
53
+
54
+ #### Models
55
+
52
56
  All models must inherit from `T::Struct` and include `Kirei::BaseModel`. 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)`.
53
57
 
54
58
  ```ruby
@@ -86,6 +90,93 @@ first_user = User.resolve_first(query) # T.nilable(User)
86
90
  first_user = User.from_hash(query.first.stringify_keys)
87
91
  ```
88
92
 
93
+ #### Database Migrations
94
+
95
+ Read the [Sequel Migrations](https://github.com/jeremyevans/sequel/blob/5.78.0/doc/schema_modification.rdoc) documentation for detailed information.
96
+
97
+ ```ruby
98
+ Sequel.migration do
99
+ up do
100
+ create_table(:airports) do
101
+ primary_key :id
102
+ String :name, null: false
103
+ end
104
+ end
105
+
106
+ down do
107
+ drop_table(:airports)
108
+ end
109
+ end
110
+ ```
111
+
112
+ Applying migrations:
113
+
114
+ ```shell
115
+ # create the database
116
+ bundle exec rake db:create
117
+
118
+ # drop the database
119
+ bundle exec rake db:drop
120
+
121
+ # apply all pending migrations
122
+ bundle exec rake db:migrate
123
+
124
+ # roll back the last n migration
125
+ STEPS=1 bundle exec rake db:rollback
126
+
127
+ # run db/seeds.rb to seed the database
128
+ bundle exec rake db:migrate
129
+
130
+ # scaffold a new migration file
131
+ bundle exec rake 'db:migration[CreateAirports]'
132
+ ```
133
+
134
+ #### Routing
135
+
136
+ Define routes anywhere in your app; by convention, they are defined in `config/routes.rb`:
137
+
138
+ ```ruby
139
+ # config/routes.rb
140
+
141
+ Kirei::Router.add_routes([
142
+ Kirei::Router::Route.new(
143
+ verb: "GET",
144
+ path: "/livez",
145
+ controller: Controllers::Health,
146
+ action: "livez",
147
+ ),
148
+
149
+ Kirei::Router::Route.new(
150
+ verb: "GET",
151
+ path: "/airports",
152
+ controller: Controllers::Airports,
153
+ action: "index",
154
+ ),
155
+ ])
156
+ ```
157
+
158
+ #### Controllers
159
+
160
+ Controllers can be defined anywhere; by convention, they are defined in the `app/controllers` directory:
161
+
162
+ ```ruby
163
+ module Controllers
164
+ class Airports < Kirei::BaseController
165
+ extend T::Sig
166
+
167
+ sig { returns(Kirei::Middleware::RackResponseType) }
168
+ def index
169
+ airports = Airport.all
170
+
171
+ # or use a serializer
172
+ data = Oj.dump(airports.map(&:serialize))
173
+
174
+ render(status: 200, body: data)
175
+ end
176
+ end
177
+ end
178
+ ```
179
+
89
180
  ## Contributions
90
181
 
91
182
  We welcome contributions from the community. Before starting work on a major feature, please get in touch with us either via email or by opening an issue on GitHub. "Major feature" means anything that changes user-facing features or significant changes to the codebase itself.
data/kirei.gemspec CHANGED
@@ -45,14 +45,12 @@ Gem::Specification.new do |spec|
45
45
 
46
46
  # Utilities
47
47
  spec.add_dependency "oj", "~> 3.0"
48
- spec.add_dependency "rake", "~> 13.0"
49
48
  spec.add_dependency "sorbet-runtime", "~> 0.5"
50
49
  spec.add_dependency "tzinfo-data", "~> 1.0" # for containerized environments, e.g. on AWS ECS
51
50
 
52
51
  # Web server & routing
53
52
  spec.add_dependency "puma", "~> 6.0"
54
- spec.add_dependency "sinatra", "~> 3.0"
55
- spec.add_dependency "sinatra-contrib", "~> 3.0"
53
+ spec.add_dependency "rack", "~> 3.0"
56
54
 
57
55
  # Database (Postgres)
58
56
  spec.add_dependency "pg", "~> 1.0"
data/lib/boot.rb CHANGED
@@ -15,16 +15,9 @@ require "bundler/setup"
15
15
  require "logger"
16
16
  require "sorbet-runtime"
17
17
  require "oj"
18
- require "puma"
19
- require "sinatra"
20
- require "sinatra/namespace" # from sinatra-contrib
18
+ require "rack"
21
19
  require "pg"
22
20
  require "sequel" # "sequel_pg" is auto-required by "sequel"
23
21
 
24
- Oj.default_options = {
25
- mode: :compat, # required to dump hashes with symbol-keys
26
- symbol_keys: false, # T::Struct.new works only with string-keys
27
- }
28
-
29
22
  # Third: load all application code
30
23
  Dir[File.join(__dir__, "kirei/**/*.rb")].each { require(_1) }
@@ -25,6 +25,9 @@ module Cli
25
25
  "db/migrate",
26
26
  "db/seeds",
27
27
 
28
+ "lib",
29
+ "lib/tasks",
30
+
28
31
  "sorbet",
29
32
  "sorbet/rbi",
30
33
  "sorbet/rbi/shims",
@@ -9,7 +9,11 @@ module Cli
9
9
  def self.call(app_name:)
10
10
  BaseDirectories.call
11
11
  Files::App.call(app_name)
12
+ Files::ConfigRu.call(app_name)
13
+ Files::DbRake.call(app_name)
12
14
  Files::Irbrc.call
15
+ Files::Rakefile.call
16
+ Files::Routes.call
13
17
 
14
18
  Kirei::Logger.logger.info(
15
19
  "Kirei app '#{app_name}' scaffolded successfully!",
@@ -10,11 +10,33 @@ module Cli
10
10
  end
11
11
 
12
12
  def self.content(app_name)
13
+ snake_case_app_name = app_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
14
+
13
15
  <<~RUBY
14
16
  # typed: true
15
17
  # frozen_string_literal: true
16
18
 
17
- class #{app_name} < Kirei::Base
19
+ # First: check if all gems are installed correctly
20
+ require "bundler/setup"
21
+
22
+ # Second: load all gems
23
+ # we have runtime/production ("default") and development gems ("development")
24
+ Bundler.require(:default)
25
+ Bundler.require(:development) if ENV["RACK_ENV"] == "development"
26
+ Bundler.require(:test) if ENV["RACK_ENV"] == "test"
27
+
28
+ # Third: load all initializers
29
+ Dir[File.join(__dir__, "config/initializers", "*.rb")].each { require(_1) }
30
+
31
+ # Fourth: load all application code
32
+ Dir[File.join(__dir__, "app/**/*", "*.rb")].each { require(_1) }
33
+
34
+ # Fifth: load configs
35
+ Dir[File.join(__dir__, "config", "*.rb")].each { require(_1) }
36
+
37
+ class #{app_name} < Kirei::AppBase
38
+ # Kirei configuration
39
+ config.app_name = "#{snake_case_app_name}"
18
40
  end
19
41
  RUBY
20
42
  end
@@ -0,0 +1,34 @@
1
+ # typed: false
2
+
3
+ module Cli
4
+ module Commands
5
+ module NewApp
6
+ module Files
7
+ class ConfigRu
8
+ def self.call(app_name)
9
+ File.write("config.ru", content(app_name))
10
+ end
11
+
12
+ def self.content(app_name)
13
+ <<~RUBY
14
+ # typed: false
15
+ # frozen_string_literal: true
16
+
17
+ require_relative("app")
18
+
19
+ # Load middlewares here
20
+ use(Rack::Reloader, 0) if #{app_name}.environment == "development"
21
+
22
+ # Launch the app
23
+ run(#{app_name}.new)
24
+
25
+ # "use" all controllers
26
+ # store all routes in a global variable to render (localhost only)
27
+ # put "booted" statement
28
+ RUBY
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,178 @@
1
+ # typed: false
2
+
3
+ # rubocop:disable Metrics/ClassLength
4
+
5
+ module Cli
6
+ module Commands
7
+ module NewApp
8
+ module Files
9
+ class DbRake
10
+ def self.call(app_name)
11
+ # set db_name to snake_case version of app_name
12
+ db_name = app_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
13
+ File.write("lib/tasks/db.rake", content(app_name, db_name))
14
+ end
15
+
16
+ def self.content(app_name, db_name)
17
+ <<~RUBY
18
+ # typed: false
19
+
20
+ # run on the database server once:
21
+ #
22
+ # CREATE DATABASE #{db_name}_${environment};
23
+
24
+ require_relative "../../app"
25
+
26
+ namespace :db do
27
+ # RACK_ENV=development bundle exec rake db:create
28
+ desc "Create the database"
29
+ task :create do
30
+ envs = ENV.key?("RACK_ENV") ? [ENV.fetch("RACK_ENV")] : %w[development test]
31
+ envs.each do |env|
32
+ ENV["RACK_ENV"] = env
33
+ db_name = "#{db_name}_#{env}"
34
+ puts("Creating database \#{db_name}...")
35
+
36
+ reset_memoized_class_level_instance_vars(#{app_name})
37
+ url = #{app_name}.default_db_url.dup # frozen string
38
+ url.gsub!(db_name, "postgres")
39
+ puts("Connecting to \#{url.gsub(%r{://.*@}, "_REDACTED_")}")
40
+ db = Sequel.connect(url)
41
+
42
+ begin
43
+ db.execute("CREATE DATABASE \#{db_name}")
44
+ puts("Created database \#{db_name}.")
45
+ rescue Sequel::DatabaseError, PG::DuplicateDatabase
46
+ puts("Database \#{db_name} already exists, skipping.")
47
+ end
48
+ end
49
+ end
50
+
51
+ desc "Drop the database"
52
+ task :drop do
53
+ envs = ENV.key?("RACK_ENV") ? [ENV.fetch("RACK_ENV")] : %w[development test]
54
+ envs.each do |env|
55
+ ENV["RACK_ENV"] = env
56
+ db_name = "#{db_name}_\#{env}"
57
+ puts("Dropping database \#{db_name}...")
58
+
59
+ reset_memoized_class_level_instance_vars(#{app_name})
60
+ url = #{app_name}.default_db_url.dup # frozen string
61
+ url.gsub!(db_name, "postgres")
62
+ puts("Connecting to \#{url.gsub(%r{://.*@}, "_REDACTED_")}")
63
+ db = Sequel.connect(url)
64
+
65
+ begin
66
+ db.execute("DROP DATABASE \#{db_name} (FORCE)")
67
+ puts("Dropped database \#{db_name}.")
68
+ rescue Sequel::DatabaseError, PG::DuplicateDatabase
69
+ puts("Database \#{db_name} does not exists, nothing to drop.")
70
+ end
71
+ end
72
+ end
73
+
74
+ desc "Run migrations"
75
+ task :migrate do
76
+ Sequel.extension(:migration)
77
+ envs = ENV.key?("RACK_ENV") ? [ENV.fetch("RACK_ENV")] : %w[development test]
78
+ envs.each do |env|
79
+ ENV["RACK_ENV"] = env
80
+ db_name = "#{db_name}_\#{env}"
81
+ reset_memoized_class_level_instance_vars(#{app_name})
82
+ db = Sequel.connect(#{app_name}.default_db_url)
83
+ Sequel::Migrator.run(db, File.join(#{app_name}.root, "db/migrate"))
84
+ current_version = db[:schema_migrations].order(:filename).last[:filename].to_i
85
+ puts "Migrated \#{db_name} to version \#{current_version}!"
86
+ end
87
+ end
88
+
89
+ desc "Rollback the last migration"
90
+ task :rollback do
91
+ envs = ENV.key?("RACK_ENV") ? [ENV.fetch("RACK_ENV")] : %w[development test]
92
+ Sequel.extension(:migration)
93
+ envs.each do |env|
94
+ ENV["RACK_ENV"] = env
95
+ db_name = "#{db_name}_\#{env}"
96
+ reset_memoized_class_level_instance_vars(#{app_name})
97
+ db = Sequel.connect(#{app_name}.default_db_url)
98
+
99
+ steps = (ENV["STEPS"] || 1).to_i + 1
100
+ versions = db[:schema_migrations].order(:filename).all
101
+
102
+ if versions[-steps].nil?
103
+ puts "No more migrations to rollback"
104
+ else
105
+ target_version = versions[-steps][:filename].to_i
106
+
107
+ Sequel::Migrator.run(db, File.join(#{app_name}.root, "db/migrate"), target: target_version)
108
+ puts "Rolled back \#{db_name} \#{steps} steps to version \#{target_version}"
109
+ end
110
+ end
111
+ end
112
+
113
+ desc "Seed the database"
114
+ task :seed do
115
+ load File.join(#{app_name}.root, "db/seeds.rb")
116
+ end
117
+
118
+ desc "Generate a new migration file"
119
+ task :migration, [:name] do |_t, args|
120
+ require "fileutils"
121
+ require "time"
122
+
123
+ # Ensure the migrations directory exists
124
+ migrations_dir = File.join(#{app_name}.root, "db/migrate")
125
+ FileUtils.mkdir_p(migrations_dir)
126
+
127
+ # Generate the migration number
128
+ migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S")
129
+
130
+ # Sanitize and format the migration name
131
+ formatted_name = args[:name].to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
132
+
133
+ # Combine them to create the filename
134
+ filename = "\#{migration_number}_\#{formatted_name}.rb"
135
+ file_path = File.join(migrations_dir, filename)
136
+
137
+ # Define the content of the migration file
138
+ content = <<~MIGRATION
139
+ # typed: strict
140
+ # frozen_string_literal: true
141
+
142
+ Sequel.migration do
143
+ up do
144
+ # your code here
145
+ end
146
+
147
+ down do
148
+ # your code here
149
+ end
150
+ end
151
+ MIGRATION
152
+
153
+ # Write the migration file
154
+ File.write(file_path, content)
155
+
156
+ puts "Generated migration: db/migrate/\#{filename}"
157
+ end
158
+ end
159
+
160
+ def reset_memoized_class_level_instance_vars(app)
161
+ %i[
162
+ @default_db_name
163
+ @default_db_url
164
+ @raw_db_connection
165
+ ].each do |ivar|
166
+ app.remove_instance_variable(ivar) if app.instance_variable_defined?(ivar)
167
+ end
168
+ end
169
+
170
+ RUBY
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ # rubocop:enable Metrics/ClassLength
@@ -16,10 +16,10 @@ module Cli
16
16
  # Kirei needs to know where the root of the project is
17
17
  APP_ROOT = File.expand_path(__dir__)
18
18
 
19
- ENV['RACK_ENV'] ||= 'development'
20
- ENV['APP_VERSION'] ||= (ENV['GIT_SHA'] ||= `git rev-parse --short HEAD`.to_s.chomp.freeze)
21
- require('dotenv/load') if %w[test development].include?(ENV['RACK_ENV'])
22
- require_relative('app')
19
+ ENV["RACK_ENV"] ||= "development"
20
+ ENV["APP_VERSION"] ||= (ENV["GIT_SHA"] ||= `git rev-parse --short HEAD`.to_s.chomp.freeze)
21
+ require("dotenv/load") if %w[test development].include?(ENV["RACK_ENV"])
22
+ require_relative("app")
23
23
  RUBY
24
24
  end
25
25
  end
@@ -0,0 +1,27 @@
1
+ # typed: false
2
+
3
+ module Cli
4
+ module Commands
5
+ module NewApp
6
+ module Files
7
+ class Rakefile
8
+ def self.call
9
+ File.write("Rakefile", content)
10
+ end
11
+
12
+ def self.content
13
+ <<~RUBY
14
+ # typed: false
15
+ # frozen_string_literal: true
16
+
17
+ require "rake"
18
+
19
+ Dir.glob("lib/tasks/**/*.rake").each { import(_1) }
20
+
21
+ RUBY
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # typed: false
2
+
3
+ module Cli
4
+ module Commands
5
+ module NewApp
6
+ module Files
7
+ class Routes
8
+ def self.call
9
+ File.write("config/routes.rb", content)
10
+ end
11
+
12
+ def self.content
13
+ <<~RUBY
14
+ # typed: strict
15
+ # frozen_string_literal: true
16
+
17
+ Kirei::Router.add_routes([
18
+ # Kirei::Router::Route.new(
19
+ # verb: "GET",
20
+ # path: "/livez",
21
+ # controller: Controllers::HealthController,
22
+ # action: "livez",
23
+ # )
24
+ ])
25
+ RUBY
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,4 +1,4 @@
1
- # typed: false
1
+ # typed: true
2
2
 
3
3
  require "fileutils"
4
4
 
@@ -9,7 +9,8 @@ module Cli
9
9
  case args[0]
10
10
  when "new"
11
11
  app_name = args[1] || "MyApp"
12
- app_name = app_name.gsub(/[-\s]/, "_").classify
12
+ app_name = app_name.gsub(/[-\s]/, "_")
13
+ app_name = app_name.split("_").map(&:capitalize).join if app_name.include?("_")
13
14
  NewApp::Execute.call(app_name: app_name)
14
15
  else
15
16
  Kirei::Logger.logger.info("Unknown command")
data/lib/kirei/app.rb ADDED
@@ -0,0 +1,72 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative("middleware")
5
+
6
+ # rubocop:disable Metrics/AbcSize, Layout/LineLength
7
+
8
+ module Kirei
9
+ class App
10
+ include Middleware
11
+ extend T::Sig
12
+
13
+ sig { params(params: T::Hash[String, T.untyped]).void }
14
+ def initialize(params: {})
15
+ @router = T.let(Router.instance, Router)
16
+ @params = T.let(params, T::Hash[String, T.untyped])
17
+ end
18
+
19
+ sig { returns(T::Hash[String, T.untyped]) }
20
+ attr_reader :params
21
+
22
+ sig { params(env: RackEnvType).returns(RackResponseType) }
23
+ def call(env)
24
+ http_verb = T.cast(env.fetch("REQUEST_METHOD"), String)
25
+ req_path = T.cast(env.fetch("REQUEST_PATH"), String)
26
+ # reject requests from unexpected hosts -> allow configuring allowed hosts in a `cors.rb` file
27
+ # ( offer a scaffold for this file )
28
+ # -> use https://github.com/cyu/rack-cors
29
+
30
+ route = Router.instance.get(http_verb, req_path)
31
+ return [404, {}, ["Not Found"]] if route.nil?
32
+
33
+ params = if route.verb == "GET"
34
+ query = T.cast(env.fetch("QUERY_STRING"), String)
35
+ query.split("&").to_h do |p|
36
+ k, v = p.split("=")
37
+ k = T.cast(k, String)
38
+ [k, v]
39
+ end
40
+ else
41
+ # TODO: based on content-type, parse the body differently
42
+ # build-in support for JSON & XML
43
+ body = T.cast(env.fetch("rack.input"), T.any(IO, StringIO))
44
+ res = Oj.load(body.read, Kirei::OJ_OPTIONS)
45
+ body.rewind # TODO: maybe don't rewind if we don't need to?
46
+ T.cast(res, T::Hash[String, T.untyped])
47
+ end
48
+
49
+ instance = route.controller.new(params: params)
50
+ instance.public_send(route.action)
51
+ end
52
+
53
+ sig do
54
+ params(
55
+ status: Integer,
56
+ body: String,
57
+ headers: T::Hash[String, String],
58
+ ).returns(RackResponseType)
59
+ end
60
+ def render(status:, body:, headers: {})
61
+ # merge default headers
62
+ # support a "type" to set content-type header? (or default to json, and users must set the header themselves for other types?)
63
+ [
64
+ status,
65
+ headers,
66
+ [body],
67
+ ]
68
+ end
69
+ end
70
+ end
71
+
72
+ # rubocop:enable Metrics/AbcSize, Layout/LineLength
@@ -1,8 +1,10 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative("app")
5
+
4
6
  module Kirei
5
- class AppBase < ::Sinatra::Base
7
+ class AppBase < Kirei::App
6
8
  class << self
7
9
  extend T::Sig
8
10
 
@@ -55,7 +57,12 @@ module Kirei
55
57
  @raw_db_connection = Sequel.connect(AppBase.config.db_url || default_db_url)
56
58
 
57
59
  config.db_extensions.each do |ext|
58
- @raw_db_connection.extension(ext)
60
+ T.cast(@raw_db_connection, Sequel::Database).extension(ext)
61
+ end
62
+
63
+ if config.db_extensions.include?(:pg_json)
64
+ # https://github.com/jeremyevans/sequel/blob/5.75.0/lib/sequel/extensions/pg_json.rb#L8
65
+ @raw_db_connection.wrap_json_primitives = true
59
66
  end
60
67
 
61
68
  @raw_db_connection
@@ -1,14 +1,16 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative("app")
5
+
4
6
  module Kirei
5
- class BaseController < Sinatra::Base
7
+ class BaseController < Kirei::App
6
8
  extend T::Sig
7
- register(Sinatra::Namespace)
9
+ # register(Sinatra::Namespace)
8
10
 
9
- before do
10
- Thread.current[:request_id] = request.env["HTTP_X_REQUEST_ID"].presence ||
11
- "req_#{AppBase.environment}_#{SecureRandom.uuid}"
12
- end
11
+ # before do
12
+ # Thread.current[:request_id] = request.env["HTTP_X_REQUEST_ID"].presence ||
13
+ # "req_#{AppBase.environment}_#{SecureRandom.uuid}"
14
+ # end
13
15
  end
14
16
  end
@@ -16,10 +16,37 @@ module Kirei
16
16
  ).returns(T.self_type)
17
17
  end
18
18
  def update(hash)
19
+ hash[:updated_at] = Time.now.utc if respond_to?(:updated_at) && hash[:updated_at].nil?
20
+ self.class.wrap_jsonb_non_primivitives!(hash)
19
21
  self.class.db.where({ id: id }).update(hash)
20
22
  self.class.find_by({ id: id })
21
23
  end
22
24
 
25
+ # Delete keeps the original object intact. Returns true if the record was deleted.
26
+ # Calling delete multiple times will return false after the first (successful) call.
27
+ sig { returns(T::Boolean) }
28
+ def delete
29
+ count = self.class.db.where({ id: id }).delete
30
+ count == 1
31
+ end
32
+
33
+ # warning: this is not concurrency-safe
34
+ # save keeps the original object intact, and returns a new object with the updated values.
35
+ sig { returns(T.self_type) }
36
+ def save
37
+ previous_record = self.class.find_by({ id: id })
38
+
39
+ hash = serialize
40
+ Helpers.deep_symbolize_keys!(hash)
41
+ hash = T.cast(hash, T::Hash[Symbol, T.untyped])
42
+
43
+ if previous_record.nil?
44
+ self.class.create(hash)
45
+ else
46
+ update(hash)
47
+ end
48
+ end
49
+
23
50
  module BaseClassInterface
24
51
  extend T::Sig
25
52
  extend T::Helpers
@@ -33,6 +60,18 @@ module Kirei
33
60
  def where(hash)
34
61
  end
35
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
+
36
75
  sig { abstract.params(hash: T.untyped).returns(T.untyped) }
37
76
  def resolve(hash)
38
77
  end
@@ -88,6 +127,54 @@ module Kirei
88
127
  resolve(db.where(hash))
89
128
  end
90
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 AppBase.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
+
91
178
  sig do
92
179
  override.params(
93
180
  hash: T::Hash[Symbol, T.untyped],
@@ -104,7 +191,7 @@ module Kirei
104
191
  # "strict" defaults to "false".
105
192
  sig do
106
193
  override.params(
107
- query: Sequel::Dataset,
194
+ query: T.any(Sequel::Dataset, T::Array[T::Hash[Symbol, T.untyped]]),
108
195
  strict: T.nilable(T::Boolean),
109
196
  ).returns(T::Array[T.attached_class])
110
197
  end
@@ -125,7 +212,9 @@ module Kirei
125
212
  ).returns(T.nilable(T.attached_class))
126
213
  end
127
214
  def resolve_first(query, strict = nil)
128
- resolve(query.limit(1), strict).first
215
+ strict_loading = strict.nil? ? AppBase.config.db_strict_type_resolving : strict
216
+
217
+ resolve(query.limit(1), strict_loading).first
129
218
  end
130
219
  end
131
220
 
data/lib/kirei/config.rb CHANGED
@@ -17,16 +17,18 @@ module Kirei
17
17
 
18
18
  prop :logger, ::Logger, factory: -> { ::Logger.new($stdout) }
19
19
  prop :log_transformer, T.nilable(T.proc.params(msg: T::Hash[Symbol, T.untyped]).returns(T::Array[String]))
20
+ prop :log_default_metadata, T::Hash[Symbol, String], default: {}
21
+
20
22
  # dup to allow the user to extend the existing list of sensitive keys
21
23
  prop :sensitive_keys, T::Array[Regexp], factory: -> { SENSITIVE_KEYS.dup }
24
+
22
25
  prop :app_name, String, default: "kirei"
23
- prop :db_url, T.nilable(String)
24
26
 
25
27
  # must use "pg_json" to parse jsonb columns to hashes
26
28
  #
27
29
  # Source: https://github.com/jeremyevans/sequel/blob/5.75.0/lib/sequel/extensions/pg_json.rb
28
30
  prop :db_extensions, T::Array[Symbol], default: %i[pg_json pg_array]
29
-
31
+ prop :db_url, T.nilable(String)
30
32
  # Extra or unknown properties present in the Hash do not raise exceptions at runtime
31
33
  # unless the optional strict argument to from_hash is passed
32
34
  #
data/lib/kirei/helpers.rb CHANGED
@@ -23,6 +23,26 @@ module Kirei
23
23
  string.nil? || string.to_s.empty?
24
24
  end
25
25
 
26
+ sig { params(object: T.untyped).returns(T.untyped) }
27
+ def deep_stringify_keys(object)
28
+ deep_transform_keys(object) { _1.to_s rescue _1 } # rubocop:disable Style/RescueModifier
29
+ end
30
+
31
+ sig { params(object: T.untyped).returns(T.untyped) }
32
+ def deep_stringify_keys!(object)
33
+ deep_transform_keys!(object) { _1.to_s rescue _1 } # rubocop:disable Style/RescueModifier
34
+ end
35
+
36
+ sig { params(object: T.untyped).returns(T.untyped) }
37
+ def deep_symbolize_keys(object)
38
+ deep_transform_keys(object) { _1.to_sym rescue _1 } # rubocop:disable Style/RescueModifier
39
+ end
40
+
41
+ sig { params(object: T.untyped).returns(T.untyped) }
42
+ def deep_symbolize_keys!(object)
43
+ deep_transform_keys!(object) { _1.to_sym rescue _1 } # rubocop:disable Style/RescueModifier
44
+ end
45
+
26
46
  # Simplified version from Rails' ActiveSupport
27
47
  sig do
28
48
  params(
@@ -30,7 +50,7 @@ module Kirei
30
50
  block: Proc,
31
51
  ).returns(T.untyped) # could be anything due to recursive calls
32
52
  end
33
- def deep_transform_keys(object, &block)
53
+ private def deep_transform_keys(object, &block)
34
54
  case object
35
55
  when Hash
36
56
  object.each_with_object({}) do |(key, value), result|
@@ -49,7 +69,7 @@ module Kirei
49
69
  block: Proc,
50
70
  ).returns(T.untyped) # could be anything due to recursive calls
51
71
  end
52
- def deep_transform_keys!(object, &block)
72
+ private def deep_transform_keys!(object, &block)
53
73
  case object
54
74
  when Hash
55
75
  # using `each_key` results in a `RuntimeError: can't add a new key into hash during iteration`
data/lib/kirei/logger.rb CHANGED
@@ -17,12 +17,12 @@ module Kirei
17
17
  #
18
18
  # You can define a custom log transformer to transform the logline:
19
19
  #
20
- # Kirei.config.log_transformer = Proc.new { _1 }
20
+ # Kirei::AppBase.config.log_transformer = Proc.new { _1 }
21
21
  #
22
- # By default, "meta" is flattened, and sensitive values are masked using see `Kirei.config.sensitive_keys`.
22
+ # By default, "meta" is flattened, and sensitive values are masked using see `Kirei::AppBase.config.sensitive_keys`.
23
23
  # You can also build on top of the provided log transformer:
24
24
  #
25
- # Kirei.config.log_transformer = Proc.new do |meta|
25
+ # Kirei::AppBase.config.log_transformer = Proc.new do |meta|
26
26
  # flattened_meta = Kirei::Logger.flatten_hash_and_mask_sensitive_values(meta)
27
27
  # # Do something with the flattened meta
28
28
  # flattened_meta.map { _1.to_json }
@@ -30,7 +30,9 @@ module Kirei
30
30
  #
31
31
  # NOTE: The log transformer must return an array of strings to allow emitting multiple lines per log event.
32
32
  #
33
- class Logger < Kirei::Base
33
+ class Logger
34
+ extend T::Sig
35
+
34
36
  FILTERED = "[FILTERED]"
35
37
 
36
38
  @instance = T.let(nil, T.nilable(Kirei::Logger))
@@ -83,7 +85,16 @@ module Kirei
83
85
  ).void
84
86
  end
85
87
  def call(level:, label:, meta: {})
88
+ Kirei::AppBase.config.log_default_metadata.each_pair do |key, value|
89
+ meta[key] ||= value
90
+ end
91
+
92
+ #
93
+ # key names follow OpenTelemetry Semantic Conventions
94
+ # Source: https://opentelemetry.io/docs/concepts/semantic-conventions/
95
+ #
86
96
  meta[:"service.instance.id"] ||= Thread.current[:request_id]
97
+ meta[:"service.name"] ||= Kirei::AppBase.config.app_name
87
98
 
88
99
  # The Ruby logger only accepts one string as the only argument
89
100
  @queue << { level: level, label: label, meta: meta }
@@ -107,7 +118,10 @@ module Kirei
107
118
  loglines = if log_transformer
108
119
  log_transformer.call(meta)
109
120
  else
110
- [Oj.dump(Kirei::Logger.flatten_hash_and_mask_sensitive_values(meta))]
121
+ [Oj.dump(
122
+ Kirei::Logger.flatten_hash_and_mask_sensitive_values(meta),
123
+ Kirei::OJ_OPTIONS,
124
+ )]
111
125
  end
112
126
 
113
127
  loglines.each { Kirei::Logger.logger.error(_1) }
@@ -131,19 +145,24 @@ module Kirei
131
145
 
132
146
  sig do
133
147
  params(
134
- hash: T::Hash[Symbol, T.untyped],
148
+ hash: T::Hash[T.any(Symbol, String), T.untyped],
135
149
  prefix: Symbol,
136
150
  ).returns(T::Hash[Symbol, T.untyped])
137
151
  end
138
152
  def self.flatten_hash_and_mask_sensitive_values(hash, prefix = :'')
139
153
  result = T.let({}, T::Hash[Symbol, T.untyped])
140
- Kirei::Helpers.deep_transform_keys!(hash) { _1.to_sym rescue _1 } # rubocop:disable Style/RescueModifier
154
+ Kirei::Helpers.deep_symbolize_keys!(hash)
155
+ hash = T.cast(hash, T::Hash[Symbol, T.untyped])
141
156
 
142
157
  hash.each do |key, value|
143
158
  new_prefix = Kirei::Helpers.blank?(prefix) ? key : :"#{prefix}.#{key}"
144
159
 
145
160
  case value
146
- when Hash then result.merge!(flatten_hash_and_mask_sensitive_values(value.transform_keys(&:to_sym), new_prefix))
161
+ when Hash
162
+ # Some libraries have a custom Hash class that inhert from Hash, but act differently, e.g. OmniAuth::AuthHash.
163
+ # This results in `transform_keys` being available but without any effect.
164
+ value = value.to_h if value.class != Hash
165
+ result.merge!(flatten_hash_and_mask_sensitive_values(value.transform_keys(&:to_sym), new_prefix))
147
166
  when Array
148
167
  value.each_with_index do |element, index|
149
168
  if element.is_a?(Hash) || element.is_a?(Array)
@@ -0,0 +1,32 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Middleware
6
+ # https://github.com/rack/rack/blob/main/UPGRADE-GUIDE.md#rack-3-upgrade-guide
7
+ RackResponseType = T.type_alias do
8
+ [
9
+ Integer,
10
+ T::Hash[String, String], # in theory, the values are allowed to be arrays of integers for binary representations
11
+ T.any(T::Array[String], Proc),
12
+ ]
13
+ end
14
+
15
+ RackEnvType = T.type_alias do
16
+ T::Hash[
17
+ String,
18
+ T.any(
19
+ T::Array[T.untyped],
20
+ IO,
21
+ T::Boolean,
22
+ String,
23
+ Numeric,
24
+ TCPSocket,
25
+ Puma::Client,
26
+ StringIO,
27
+ Puma::Configuration,
28
+ )
29
+ ]
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,61 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require("singleton")
5
+
6
+ module Kirei
7
+ #
8
+ # Usage:
9
+ #
10
+ # Router.add_routes([
11
+ # Route.new(
12
+ # verb: "GET",
13
+ # path: "/livez",
14
+ # controller: Controllers::HealthController,
15
+ # action: "livez",
16
+ # ),
17
+ # ])
18
+ #
19
+ class Router
20
+ extend T::Sig
21
+ include ::Singleton
22
+
23
+ class Route < T::Struct
24
+ const :verb, String
25
+ const :path, String
26
+ const :controller, T.class_of(BaseController)
27
+ const :action, String
28
+ end
29
+
30
+ RoutesHash = T.type_alias do
31
+ T::Hash[String, Route]
32
+ end
33
+
34
+ sig { void }
35
+ def initialize
36
+ @routes = T.let({}, RoutesHash)
37
+ end
38
+
39
+ sig { returns(RoutesHash) }
40
+ attr_reader :routes
41
+
42
+ sig do
43
+ params(
44
+ verb: String,
45
+ path: String,
46
+ ).returns(T.nilable(Route))
47
+ end
48
+ def get(verb, path)
49
+ key = "#{verb} #{path}"
50
+ routes[key]
51
+ end
52
+
53
+ sig { params(routes: T::Array[Route]).void }
54
+ def self.add_routes(routes)
55
+ routes.each do |route|
56
+ key = "#{route.verb} #{route.path}"
57
+ instance.routes[key] = route
58
+ end
59
+ end
60
+ end
61
+ 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.0.3"
5
+ VERSION = "0.2.0"
6
6
  end
data/lib/kirei.rb CHANGED
@@ -6,6 +6,17 @@ require "boot"
6
6
  module Kirei
7
7
  extend T::Sig
8
8
 
9
+ # we don't know what Oj does under the hood with the options hash, so don't freeze it
10
+ # rubocop:disable Style/MutableConstant
11
+ OJ_OPTIONS = T.let(
12
+ {
13
+ mode: :compat, # required to dump hashes with symbol-keys
14
+ symbol_keys: false, # T::Struct.new works only with string-keys
15
+ },
16
+ T::Hash[Symbol, T.untyped],
17
+ )
18
+ # rubocop:enable Style/MutableConstant
19
+
9
20
  GEM_ROOT = T.let(
10
21
  Gem::Specification.find_by_name("kirei").gem_dir,
11
22
  String,
@@ -3,6 +3,9 @@
3
3
  # rubocop:disable Style/EmptyMethod
4
4
  module Kirei
5
5
  module BaseModel
6
+ include Kernel # "self" is a class since we include the module in a class
7
+ include T::Props::Serializable
8
+
6
9
  sig { returns(T.any(String, Integer)) }
7
10
  def id; end
8
11
 
@@ -12,6 +15,10 @@ module Kirei
12
15
  sig { returns(String) }
13
16
  def name; end
14
17
  end
18
+
19
+ module BaseClassInterface
20
+ # include T::Props::Serializable::ClassMethods
21
+ end
15
22
  end
16
23
  end
17
24
  # rubocop:enable Style/EmptyMethod
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kirei
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ludwig Reinmiedl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-31 00:00:00.000000000 Z
11
+ date: 2024-03-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: oj
@@ -24,20 +24,6 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '3.0'
27
- - !ruby/object:Gem::Dependency
28
- name: rake
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '13.0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '13.0'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: sorbet-runtime
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -81,21 +67,7 @@ dependencies:
81
67
  - !ruby/object:Gem::Version
82
68
  version: '6.0'
83
69
  - !ruby/object:Gem::Dependency
84
- name: sinatra
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - "~>"
88
- - !ruby/object:Gem::Version
89
- version: '3.0'
90
- type: :runtime
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - "~>"
95
- - !ruby/object:Gem::Version
96
- version: '3.0'
97
- - !ruby/object:Gem::Dependency
98
- name: sinatra-contrib
70
+ name: rack
99
71
  requirement: !ruby/object:Gem::Requirement
100
72
  requirements:
101
73
  - - "~>"
@@ -173,16 +145,22 @@ files:
173
145
  - lib/cli/commands/new_app/base_directories.rb
174
146
  - lib/cli/commands/new_app/execute.rb
175
147
  - lib/cli/commands/new_app/files/app.rb
148
+ - lib/cli/commands/new_app/files/config_ru.rb
149
+ - lib/cli/commands/new_app/files/db_rake.rb
176
150
  - lib/cli/commands/new_app/files/irbrc.rb
151
+ - lib/cli/commands/new_app/files/rakefile.rb
152
+ - lib/cli/commands/new_app/files/routes.rb
177
153
  - lib/cli/commands/start.rb
178
154
  - lib/kirei.rb
155
+ - lib/kirei/app.rb
179
156
  - lib/kirei/app_base.rb
180
- - lib/kirei/base.rb
181
157
  - lib/kirei/base_controller.rb
182
158
  - lib/kirei/base_model.rb
183
159
  - lib/kirei/config.rb
184
160
  - lib/kirei/helpers.rb
185
161
  - lib/kirei/logger.rb
162
+ - lib/kirei/middleware.rb
163
+ - lib/kirei/router.rb
186
164
  - lib/kirei/version.rb
187
165
  - sorbet/rbi/shims/base_model.rbi
188
166
  homepage: https://github.com/swiknaba/kirei
@@ -206,7 +184,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
206
184
  - !ruby/object:Gem::Version
207
185
  version: '0'
208
186
  requirements: []
209
- rubygems_version: 3.5.3
187
+ rubygems_version: 3.5.6
210
188
  signing_key:
211
189
  specification_version: 4
212
190
  summary: Kirei is a strictly typed Ruby micro/REST-framework for building scaleable
data/lib/kirei/base.rb DELETED
@@ -1,8 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- module Kirei
5
- class Base
6
- extend T::Sig
7
- end
8
- end