better_auth-hanami 0.1.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.
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Hanami
5
+ module Migration
6
+ module_function
7
+
8
+ def render(options)
9
+ tables = BetterAuth::Schema.auth_tables(options)
10
+ lines = [
11
+ "# frozen_string_literal: true",
12
+ "",
13
+ "require \"date\"",
14
+ "require \"rom-sql\"",
15
+ "",
16
+ "ROM::SQL.migration do",
17
+ " change do"
18
+ ]
19
+ tables.each_value { |table| lines.concat(create_table_lines(table, options)) }
20
+ lines.concat([" end", "end", ""])
21
+ lines.join("\n")
22
+ end
23
+
24
+ def create_table_lines(table, options)
25
+ table_name = table.fetch(:model_name)
26
+ lines = ["", " create_table :#{table_name} do"]
27
+ table.fetch(:fields).each do |logical_field, attributes|
28
+ lines << column_line(logical_field, attributes, options)
29
+ end
30
+ lines << " primary_key [:id]" if table.fetch(:fields).key?("id")
31
+ table.fetch(:fields).each do |logical_field, attributes|
32
+ index = index_line(logical_field, attributes)
33
+ lines << index if index
34
+ end
35
+ lines << " end"
36
+ lines
37
+ end
38
+
39
+ def column_line(logical_field, attributes, options)
40
+ column = attributes[:field_name] || physical_name(logical_field)
41
+ reference = attributes[:references]
42
+ if reference
43
+ target = foreign_key_target(reference.fetch(:model), options)
44
+ parts = ["foreign_key :#{column}, :#{target}", "type: #{hanami_type(attributes)}"]
45
+ parts << "null: false" if attributes[:required]
46
+ parts << "on_delete: :#{reference[:on_delete]}" if reference[:on_delete]
47
+ return " #{parts.join(", ")}"
48
+ end
49
+
50
+ parts = ["column :#{column}", hanami_type(attributes)]
51
+ parts << "null: false" if attributes[:required]
52
+ default = default_value(attributes)
53
+ parts << "default: #{default}" unless default.nil?
54
+ " #{parts.join(", ")}"
55
+ end
56
+
57
+ def index_line(logical_field, attributes)
58
+ return unless attributes[:unique] || attributes[:index]
59
+
60
+ column = attributes[:field_name] || physical_name(logical_field)
61
+ unique = attributes[:unique] ? ", unique: true" : ""
62
+ " index :#{column}#{unique}"
63
+ end
64
+
65
+ def hanami_type(attributes)
66
+ case attributes[:type]
67
+ when "boolean" then "TrueClass"
68
+ when "date" then "DateTime"
69
+ when "number" then "Integer"
70
+ else "String"
71
+ end
72
+ end
73
+
74
+ def default_value(attributes)
75
+ default = attributes[:default_value]
76
+ return if default.respond_to?(:call)
77
+
78
+ case default
79
+ when true then "true"
80
+ when false then "false"
81
+ when Numeric then default.to_s
82
+ when String then default.inspect
83
+ end
84
+ end
85
+
86
+ def foreign_key_target(model, options)
87
+ tables = BetterAuth::Schema.auth_tables(options)
88
+ tables.fetch(model.to_s, nil)&.fetch(:model_name) || model
89
+ end
90
+
91
+ def physical_name(value)
92
+ value.to_s
93
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
94
+ .tr("-", "_")
95
+ .downcase
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Hanami
5
+ class MountedApp
6
+ def initialize(auth, mount_path:)
7
+ @auth = auth
8
+ @mount_path = normalize_path(mount_path)
9
+ end
10
+
11
+ def call(env)
12
+ @auth.call(env.merge("PATH_INFO" => mounted_path_info(env)))
13
+ end
14
+
15
+ private
16
+
17
+ def mounted_path_info(env)
18
+ path_info = normalize_path(env["PATH_INFO"])
19
+ script_name = normalize_path(env["SCRIPT_NAME"])
20
+ prefix = (script_name == "/") ? @mount_path : script_name
21
+
22
+ return path_info if path_info == prefix || path_info.start_with?("#{prefix}/")
23
+
24
+ normalize_path("#{prefix}/#{path_info.delete_prefix("/")}")
25
+ end
26
+
27
+ def normalize_path(path)
28
+ normalized = path.to_s
29
+ normalized = "/#{normalized}" unless normalized.start_with?("/")
30
+ normalized = normalized.squeeze("/")
31
+ normalized = normalized.delete_suffix("/") unless normalized == "/"
32
+ normalized.empty? ? "/" : normalized
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Hanami
5
+ module Routing
6
+ HTTP_METHODS = %i[get post put patch delete options].freeze
7
+
8
+ def self.included(base)
9
+ base.extend(self)
10
+ end
11
+
12
+ def better_auth(auth: nil, at: BetterAuth::Configuration::DEFAULT_BASE_PATH)
13
+ mount_path = normalize_better_auth_mount_path(at)
14
+ auth ||= BetterAuth::Hanami.auth(base_path: mount_path)
15
+ app = BetterAuth::Hanami::MountedApp.new(auth, mount_path: mount_path)
16
+
17
+ HTTP_METHODS.each do |method_name|
18
+ public_send(method_name, mount_path, to: app)
19
+ public_send(method_name, "#{mount_path}/*path", to: app)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def normalize_better_auth_mount_path(path)
26
+ normalized = path.to_s
27
+ normalized = "/#{normalized}" unless normalized.start_with?("/")
28
+ normalized = normalized.squeeze("/")
29
+ normalized = normalized.delete_suffix("/") unless normalized == "/"
30
+ normalized.empty? ? "/" : normalized
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+ require "sequel"
6
+
7
+ module BetterAuth
8
+ module Hanami
9
+ class SequelAdapter < BetterAuth::Adapters::Base
10
+ attr_reader :connection
11
+
12
+ def self.from_hanami(options, container: nil)
13
+ if container.nil? && defined?(::Hanami) && ::Hanami.respond_to?(:app)
14
+ container = ::Hanami.app
15
+ end
16
+ return BetterAuth::Adapters::Memory.new(options) unless container
17
+
18
+ from_container(container, options)
19
+ end
20
+
21
+ def self.from_container(container, options)
22
+ gateway = if container.respond_to?(:key?) && container.key?("db.gateway")
23
+ container["db.gateway"]
24
+ elsif container.respond_to?(:[]) && safe_fetch(container, "db.gateway")
25
+ container["db.gateway"]
26
+ end
27
+ return BetterAuth::Adapters::Memory.new(options) unless gateway
28
+
29
+ connection = gateway.respond_to?(:connection) ? gateway.connection : gateway
30
+ new(options, connection: connection)
31
+ end
32
+
33
+ def self.safe_fetch(container, key)
34
+ container[key]
35
+ rescue KeyError
36
+ nil
37
+ end
38
+
39
+ def initialize(options, connection:)
40
+ super(options)
41
+ @connection = connection
42
+ end
43
+
44
+ def create(model:, data:, force_allow_id: false)
45
+ model = model.to_s
46
+ input = transform_input(model, data, "create", force_allow_id)
47
+ table_dataset(model).insert(physical_attributes(model, input))
48
+ find_one(model: model, where: [{field: "id", value: input.fetch("id")}])
49
+ end
50
+
51
+ def find_one(model:, where: [], select: nil, join: nil)
52
+ find_many(model: model, where: where, select: select, join: join, limit: 1).first
53
+ end
54
+
55
+ def find_many(model:, where: [], sort_by: nil, limit: nil, offset: nil, select: nil, join: nil)
56
+ model = model.to_s
57
+ dataset = table_dataset(model)
58
+ dataset = apply_where(model, dataset, where || [])
59
+ dataset = apply_select(model, dataset, select) if select
60
+ dataset = apply_order(model, dataset, sort_by) if sort_by
61
+ dataset = dataset.limit(Integer(limit)) if limit
62
+ dataset = dataset.offset(Integer(offset)) if offset
63
+
64
+ records = dataset.all.map { |row| normalize_record(model, row) }
65
+ attach_joins(model, records, join)
66
+ end
67
+
68
+ def update(model:, where:, update:)
69
+ model = model.to_s
70
+ existing = find_one(model: model, where: where, select: ["id"])
71
+ return nil unless existing
72
+
73
+ update_many(model: model, where: where, update: update)
74
+ find_one(model: model, where: [{field: "id", value: existing.fetch("id")}])
75
+ end
76
+
77
+ def update_many(model:, where:, update:, returning: false)
78
+ model = model.to_s
79
+ existing = returning ? find_many(model: model, where: where, select: ["id"]) : []
80
+ attributes = physical_attributes(model, transform_input(model, update, "update", true))
81
+ apply_where(model, table_dataset(model), where || []).update(attributes)
82
+ return unless returning
83
+
84
+ existing.map { |record| find_one(model: model, where: [{field: "id", value: record.fetch("id")}]) }
85
+ end
86
+
87
+ def delete(model:, where:)
88
+ delete_many(model: model, where: where)
89
+ nil
90
+ end
91
+
92
+ def delete_many(model:, where:)
93
+ model = model.to_s
94
+ apply_where(model, table_dataset(model), where || []).delete
95
+ end
96
+
97
+ def count(model:, where: nil)
98
+ model = model.to_s
99
+ apply_where(model, table_dataset(model), where || []).count
100
+ end
101
+
102
+ def transaction
103
+ connection.transaction { yield self }
104
+ end
105
+
106
+ private
107
+
108
+ def table_dataset(model)
109
+ connection[table_for(model).to_sym]
110
+ end
111
+
112
+ def apply_where(model, dataset, where)
113
+ expression = Array(where).each_with_index.reduce(nil) do |combined, (clause, index)|
114
+ current = where_expression(model, clause)
115
+ next current if index.zero?
116
+
117
+ connector = fetch_key(clause, :connector).to_s.upcase
118
+ (connector == "OR") ? Sequel.|(combined, current) : Sequel.&(combined, current)
119
+ end
120
+ expression ? dataset.where(expression) : dataset
121
+ end
122
+
123
+ def where_expression(model, clause)
124
+ field = storage_key(fetch_key(clause, :field))
125
+ column = storage_field(model, field)
126
+ identifier = Sequel[column.to_sym]
127
+ operator = (fetch_key(clause, :operator) || "eq").to_s
128
+ value = fetch_key(clause, :value)
129
+
130
+ case operator
131
+ when "in" then {column.to_sym => Array(value)}
132
+ when "not_in" then Sequel.~(column.to_sym => Array(value))
133
+ when "ne" then Sequel.~(column.to_sym => value)
134
+ when "gt" then identifier > value
135
+ when "gte" then identifier >= value
136
+ when "lt" then identifier < value
137
+ when "lte" then identifier <= value
138
+ when "contains" then Sequel.like(identifier, "%#{value}%")
139
+ when "starts_with" then Sequel.like(identifier, "#{value}%")
140
+ when "ends_with" then Sequel.like(identifier, "%#{value}")
141
+ else {column.to_sym => value}
142
+ end
143
+ end
144
+
145
+ def apply_select(model, dataset, select)
146
+ dataset.select(*Array(select).map { |field| storage_field(model, storage_key(field)).to_sym })
147
+ end
148
+
149
+ def apply_order(model, dataset, sort_by)
150
+ column = storage_field(model, storage_key(fetch_key(sort_by, :field))).to_sym
151
+ direction = (fetch_key(sort_by, :direction).to_s.downcase == "desc") ? Sequel.desc(column) : column
152
+ dataset.order(direction)
153
+ end
154
+
155
+ def attach_joins(model, records, join)
156
+ return records unless join
157
+
158
+ records.each do |record|
159
+ join.each_key do |join_model|
160
+ join_model = join_model.to_s
161
+ case [model.to_s, join_model]
162
+ when ["session", "user"], ["account", "user"]
163
+ record[join_model] = find_one(model: join_model, where: [{field: "id", value: record["userId"]}])
164
+ when ["user", "account"]
165
+ record[join_model] = find_many(model: "account", where: [{field: "userId", value: record["id"]}])
166
+ end
167
+ end
168
+ end
169
+ records
170
+ end
171
+
172
+ def transform_input(model, data, action, force_allow_id)
173
+ fields = schema_for(model).fetch(:fields)
174
+ input = stringify_keys(data)
175
+ output = {}
176
+
177
+ fields.each do |field, attributes|
178
+ next if field == "id" && input.key?(field) && !force_allow_id
179
+
180
+ value_provided = input.key?(field)
181
+ value = input[field]
182
+ if value_provided && attributes[:input] == false && value && !force_allow_id
183
+ raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
184
+ end
185
+
186
+ if !value_provided && action == "create" && attributes.key?(:default_value)
187
+ value = resolve_default(attributes[:default_value])
188
+ value_provided = true
189
+ elsif !value_provided && action == "update" && attributes[:on_update]
190
+ value = resolve_default(attributes[:on_update])
191
+ value_provided = true
192
+ end
193
+ if !value_provided && action == "create" && attributes[:required]
194
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required") unless field == "id"
195
+ end
196
+ output[field] = coerce_value(value, attributes) if value_provided
197
+ end
198
+
199
+ output["id"] = generated_id if action == "create" && !output.key?("id")
200
+ output
201
+ end
202
+
203
+ def physical_attributes(model, logical)
204
+ logical.each_with_object({}) do |(field, value), attributes|
205
+ attributes[storage_field(model, field).to_sym] = value
206
+ end
207
+ end
208
+
209
+ def normalize_record(model, row)
210
+ return nil unless row
211
+
212
+ schema_for(model).fetch(:fields).each_with_object({}) do |(field, attributes), output|
213
+ column = (attributes[:field_name] || physical_name(field)).to_sym
214
+ output[field] = coerce_output_value(row[column], attributes) if row.key?(column)
215
+ end
216
+ end
217
+
218
+ def table_for(model)
219
+ schema_for(model).fetch(:model_name)
220
+ end
221
+
222
+ def schema_for(model)
223
+ BetterAuth::Schema.auth_tables(options).fetch(model.to_s)
224
+ end
225
+
226
+ def storage_field(model, field)
227
+ schema_for(model).fetch(:fields).fetch(field.to_s).fetch(:field_name, physical_name(field))
228
+ end
229
+
230
+ def generated_id
231
+ generator = options.advanced.dig(:database, :generate_id)
232
+ return generator.call.to_s if generator.respond_to?(:call)
233
+ return SecureRandom.uuid if generator == "uuid"
234
+
235
+ SecureRandom.hex(16)
236
+ end
237
+
238
+ def resolve_default(default)
239
+ default.respond_to?(:call) ? default.call : default
240
+ end
241
+
242
+ def coerce_value(value, attributes)
243
+ return value if value.nil?
244
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
245
+
246
+ value
247
+ end
248
+
249
+ def coerce_output_value(value, attributes)
250
+ return value if value.nil?
251
+ return coerce_boolean(value) if attributes[:type] == "boolean"
252
+ return Time.parse(value.to_s) if attributes[:type] == "date" && !value.is_a?(Time)
253
+
254
+ value
255
+ end
256
+
257
+ def coerce_boolean(value)
258
+ return value if value == true || value == false
259
+ return false if value == 0 || value.to_s == "0" || value.to_s.downcase == "f" || value.to_s.downcase == "false"
260
+ return true if value == 1 || value.to_s == "1" || value.to_s.downcase == "t" || value.to_s.downcase == "true"
261
+
262
+ value
263
+ end
264
+
265
+ def stringify_keys(data)
266
+ data.each_with_object({}) do |(key, value), result|
267
+ result[storage_key(key)] = value
268
+ end
269
+ end
270
+
271
+ def fetch_key(hash, key)
272
+ hash[key] || hash[key.to_s] || hash[storage_key(key)] || hash[storage_key(key).to_sym]
273
+ end
274
+
275
+ def storage_key(value)
276
+ parts = physical_name(value).split("_")
277
+ ([parts.first] + parts.drop(1).map(&:capitalize)).join
278
+ end
279
+
280
+ def physical_name(value)
281
+ value.to_s
282
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
283
+ .tr("-", "_")
284
+ .downcase
285
+ end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Hanami
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth"
4
+ require_relative "hanami/version"
5
+ require_relative "hanami/configuration"
6
+ require_relative "hanami/mounted_app"
7
+ require_relative "hanami/routing"
8
+ require_relative "hanami/migration"
9
+ require_relative "hanami/sequel_adapter"
10
+ require_relative "hanami/action_helpers"
11
+ require_relative "hanami/generators/install_generator"
12
+ require_relative "hanami/generators/migration_generator"
13
+ require_relative "hanami/generators/relation_generator"
14
+
15
+ module BetterAuth
16
+ module Hanami
17
+ class << self
18
+ def configuration
19
+ @configuration ||= Configuration.new
20
+ end
21
+
22
+ def configure
23
+ yield configuration
24
+ @auth = nil
25
+ end
26
+
27
+ def auth(overrides = nil)
28
+ options = configuration.to_auth_options
29
+ return @auth ||= BetterAuth.auth(options) if overrides.nil? || overrides.empty?
30
+
31
+ BetterAuth.auth(options.merge(overrides))
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth/hanami"
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth/hanami"
4
+
5
+ namespace :better_auth do
6
+ desc "Create Better Auth Hanami provider, routes, settings, tasks, and base migration"
7
+ task :init do
8
+ BetterAuth::Hanami::Generators::InstallGenerator.new.run
9
+ end
10
+
11
+ namespace :generate do
12
+ desc "Create the Better Auth Hanami base migration"
13
+ task :migration do
14
+ BetterAuth::Hanami::Generators::MigrationGenerator.new.run
15
+ end
16
+
17
+ desc "Create Hanami relations and repos for Better Auth tables"
18
+ task :relations do
19
+ BetterAuth::Hanami::Generators::RelationGenerator.new.run
20
+ end
21
+ end
22
+ end