kirei 0.0.3 → 0.2.0

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