clowk 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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clowk
4
+ class Current
5
+ attr_reader :attributes
6
+
7
+ def initialize(attributes = {})
8
+ @attributes = attributes.to_h.deep_symbolize_keys
9
+ end
10
+
11
+ def id
12
+ attributes[:sub] || attributes[:id]
13
+ end
14
+
15
+ def email
16
+ attributes[:email]
17
+ end
18
+
19
+ def name
20
+ attributes[:name]
21
+ end
22
+
23
+ def avatar_url
24
+ attributes[:avatar_url]
25
+ end
26
+
27
+ def provider
28
+ attributes[:provider]
29
+ end
30
+
31
+ def instance_id
32
+ attributes[:instance_id]
33
+ end
34
+
35
+ def app_id
36
+ attributes[:app_id]
37
+ end
38
+
39
+ def [](key)
40
+ attributes[key.to_sym]
41
+ end
42
+
43
+ def to_h
44
+ attributes.merge(id: id)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clowk
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Clowk
6
+
7
+ initializer 'clowk.helpers' do
8
+ ActiveSupport.on_load(:action_controller_base) do
9
+ include Clowk::Helpers::UrlHelpers
10
+ end
11
+
12
+ ActiveSupport.on_load(:action_view) do
13
+ include Clowk::Helpers::UrlHelpers
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Clowk
6
+ module Helpers
7
+ module UrlHelpers
8
+ def clowk_sign_in_path(return_to: nil)
9
+ append_query(clowk_local_path('/sign_in'), return_to:)
10
+ end
11
+
12
+ def clowk_sign_up_path(return_to: nil)
13
+ append_query(clowk_local_path('/sign_up'), return_to:)
14
+ end
15
+
16
+ def clowk_sign_out_path(return_to: nil)
17
+ append_query(clowk_local_path('/sign_out'), return_to:)
18
+ end
19
+
20
+ def clowk_callback_url(return_to: nil, state: nil)
21
+ append_query("#{request.base_url}#{Clowk.config.callback_path}", return_to:, state:)
22
+ end
23
+
24
+ def clowk_sign_in_url(redirect_to: nil, return_to: nil, state: nil)
25
+ clowk_remote_auth_url('sign-in', redirect_to:, return_to:, state:)
26
+ end
27
+
28
+ def clowk_sign_up_url(redirect_to: nil, return_to: nil, state: nil)
29
+ clowk_remote_auth_url('sign-up', redirect_to:, return_to:, state:)
30
+ end
31
+
32
+ private
33
+
34
+ def clowk_remote_auth_url(action, redirect_to:, return_to:, state:)
35
+ callback_url = clowk_callback_url(return_to: redirect_to || return_to, state:)
36
+ query = { redirect_uri: callback_url }
37
+
38
+ append_query("#{clowk_instance_base_url}/#{action}", query)
39
+ end
40
+
41
+ def clowk_instance_base_url
42
+ Clowk::Subdomain.resolve_url!
43
+ end
44
+
45
+ def clowk_local_path(path)
46
+ "#{Clowk.config.mount_path}#{path}"
47
+ end
48
+
49
+ def append_query(url, params = {})
50
+ filtered = params.compact.reject { |_key, value| value.respond_to?(:empty?) && value.empty? }
51
+ return url if filtered.empty?
52
+
53
+ separator = url.include?('?') ? '&' : '?'
54
+ "#{url}#{separator}#{Rack::Utils.build_query(filtered)}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module Clowk
8
+ class Http
9
+ MAX_BODY_SIZE = 1 * 1024 * 1024
10
+
11
+ HTTP_METHODS = {
12
+ get: Net::HTTP::Get,
13
+ post: Net::HTTP::Post,
14
+ put: Net::HTTP::Put,
15
+ patch: Net::HTTP::Patch,
16
+ delete: Net::HTTP::Delete,
17
+ head: Net::HTTP::Head,
18
+ options: Net::HTTP::Options
19
+ }.freeze
20
+
21
+ def self.get(base_url:, path:, headers: {}, logger: nil)
22
+ new(base_url:, headers:, logger:).get(path)
23
+ end
24
+
25
+ def self.post(base_url:, path:, body: nil, headers: {}, logger: nil)
26
+ new(base_url:, headers:, logger:).post(path, body)
27
+ end
28
+
29
+ def self.put(base_url:, path:, body: nil, headers: {}, logger: nil)
30
+ new(base_url:, headers:, logger:).put(path, body)
31
+ end
32
+
33
+ def self.patch(base_url:, path:, body: nil, headers: {}, logger: nil)
34
+ new(base_url:, headers:, logger:).patch(path, body)
35
+ end
36
+
37
+ def self.delete(base_url:, path:, body: nil, headers: {}, logger: nil)
38
+ new(base_url:, headers:, logger:).delete(path, body)
39
+ end
40
+
41
+ def self.head(base_url:, path:, headers: {}, logger: nil)
42
+ new(base_url:, headers:, logger:).head(path)
43
+ end
44
+
45
+ def self.options(base_url:, path:, headers: {}, logger: nil)
46
+ new(base_url:, headers:, logger:).options(path)
47
+ end
48
+
49
+ def initialize(base_url:, headers: {}, logger: nil, open_timeout: 5, read_timeout: 10, write_timeout: 10, retry_attempts: 2, retry_interval: 0.05, middlewares: nil)
50
+ @base_url = base_url
51
+ @headers = headers
52
+ @logger = logger
53
+ @open_timeout = open_timeout
54
+ @read_timeout = read_timeout
55
+ @write_timeout = write_timeout
56
+ @retry_attempts = retry_attempts
57
+ @retry_interval = retry_interval
58
+ @middlewares = middlewares || [TimeoutMiddleware, RetryMiddleware, LoggerMiddleware]
59
+ end
60
+
61
+ def get(path, headers: {})
62
+ request(:get, path, headers:)
63
+ end
64
+
65
+ def post(path, body = nil, headers: {})
66
+ request(:post, path, body:, headers:)
67
+ end
68
+
69
+ def put(path, body = nil, headers: {})
70
+ request(:put, path, body:, headers:)
71
+ end
72
+
73
+ def patch(path, body = nil, headers: {})
74
+ request(:patch, path, body:, headers:)
75
+ end
76
+
77
+ def delete(path, body = nil, headers: {})
78
+ request(:delete, path, body:, headers:)
79
+ end
80
+
81
+ def head(path, headers: {})
82
+ request(:head, path, headers:)
83
+ end
84
+
85
+ def options(path, headers: {})
86
+ request(:options, path, headers:)
87
+ end
88
+
89
+ def request(method, path, body: nil, headers: {})
90
+ env = {
91
+ method: method.to_sym,
92
+ uri: build_uri(path),
93
+ body: body,
94
+ headers: headers,
95
+ open_timeout: open_timeout,
96
+ read_timeout: read_timeout,
97
+ write_timeout: write_timeout,
98
+ retry_attempts: retry_attempts,
99
+ retry_interval: retry_interval
100
+ }
101
+
102
+ build_stack.call(env)
103
+ end
104
+
105
+ private
106
+
107
+ attr_reader :base_url, :headers, :logger, :middlewares, :open_timeout, :read_timeout, :write_timeout, :retry_attempts, :retry_interval
108
+
109
+ def build_stack
110
+ app = lambda { |env| perform_request(env) }
111
+
112
+ middlewares.reverse.inject(app) do |next_app, middleware|
113
+ middleware.new(next_app, logger:, open_timeout:, read_timeout:, write_timeout:)
114
+ end
115
+ end
116
+
117
+ def perform_request(env)
118
+ request = request_class_for(env[:method]).new(env[:uri])
119
+
120
+ apply_headers(request, env[:headers])
121
+
122
+ request.body = JSON.generate(env[:body]) unless env[:body].nil?
123
+
124
+ raw_response = Net::HTTP.start(env[:uri].host, env[:uri].port, use_ssl: env[:uri].scheme == 'https') do |http|
125
+ apply_timeouts(http, env[:timeouts])
126
+ http.request(request)
127
+ end
128
+
129
+ parse_response(raw_response)
130
+ end
131
+
132
+ def request_class_for(method)
133
+ HTTP_METHODS.fetch(method.to_sym) do
134
+ raise ArgumentError, "unsupported HTTP method: #{method}"
135
+ end
136
+ end
137
+
138
+ def build_uri(path)
139
+ base_uri = URI(base_url)
140
+ base_uri.path = join_paths(base_uri.path, normalize_path(path))
141
+ base_uri.query = nil
142
+ base_uri.fragment = nil
143
+ base_uri
144
+ end
145
+
146
+ def normalize_path(path)
147
+ path.to_s.start_with?('/') ? path : "/#{path}"
148
+ end
149
+
150
+ def join_paths(base_path, extra_path)
151
+ segments = [base_path.to_s, extra_path.to_s].map { |segment| segment.gsub(%r{^/+|/+$}, '') }.reject(&:empty?)
152
+ "/#{segments.join('/')}"
153
+ end
154
+
155
+ def apply_headers(request, request_headers)
156
+ merged_headers = default_headers.merge(headers).merge(request_headers)
157
+ merged_headers.each { |key, value| request[key] = value }
158
+ end
159
+
160
+ def apply_timeouts(http, timeouts)
161
+ return unless timeouts
162
+
163
+ http.open_timeout = timeouts[:open_timeout] if timeouts.key?(:open_timeout)
164
+ http.read_timeout = timeouts[:read_timeout] if timeouts.key?(:read_timeout)
165
+ http.write_timeout = timeouts[:write_timeout] if timeouts.key?(:write_timeout) && http.respond_to?(:write_timeout=)
166
+ end
167
+
168
+ def default_headers
169
+ {
170
+ 'Accept' => 'application/json',
171
+ 'Content-Type' => 'application/json'
172
+ }
173
+ end
174
+
175
+ def parse_response(response)
176
+ body = response.body.to_s
177
+
178
+ raise Error, "response body too large (#{body.bytesize} bytes, max #{MAX_BODY_SIZE})" if body.bytesize > MAX_BODY_SIZE
179
+
180
+ parsed = parse_body(body)
181
+
182
+ Response.new(
183
+ status: response.code.to_i,
184
+ body: body,
185
+ body_parsed: parsed,
186
+ headers: response.to_hash,
187
+ success: response.is_a?(Net::HTTPSuccess)
188
+ )
189
+ end
190
+
191
+ def parse_body(body)
192
+ return {} if body.empty?
193
+
194
+ JSON.parse(body)
195
+ rescue JSON::ParserError
196
+ nil
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Clowk
6
+ class Http
7
+ class LoggerMiddleware
8
+ def initialize(app, logger: nil, **)
9
+ @app = app
10
+ @logger = logger || NullLogger.new
11
+ end
12
+
13
+ def call(env)
14
+ logger.info("[Clowk::Http] #{env[:method].upcase} #{env[:uri]}")
15
+ response = app.call(env)
16
+ logger.info("[Clowk::Http] -> #{response[:status]}")
17
+ response
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :app, :logger
23
+
24
+ class NullLogger
25
+ def info(*); end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clowk
4
+ class Http
5
+ class Response
6
+ attr_reader :body, :body_parsed, :headers, :status
7
+
8
+ def initialize(status:, body:, body_parsed:, headers:, success:)
9
+ @status = status
10
+ @body = body
11
+ @body_parsed = body_parsed
12
+ @headers = headers
13
+ @success = success
14
+ end
15
+
16
+ def success?
17
+ @success
18
+ end
19
+
20
+ def [](key)
21
+ to_h.fetch(key)
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ status: status,
27
+ body: body,
28
+ body_parsed: body_parsed,
29
+ headers: headers,
30
+ success?: success?
31
+ }
32
+ end
33
+
34
+ def ==(other)
35
+ if other.respond_to?(:to_h)
36
+ to_h == other.to_h
37
+ else
38
+ to_h == other
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Clowk
6
+ class Http
7
+ class RetryMiddleware
8
+ RETRYABLE_ERRORS = [
9
+ EOFError,
10
+ Errno::ECONNRESET,
11
+ Errno::ETIMEDOUT,
12
+ IOError,
13
+ Net::OpenTimeout,
14
+ Net::ReadTimeout,
15
+ Net::WriteTimeout,
16
+ SocketError
17
+ ].freeze
18
+
19
+ def initialize(app, logger: nil, **)
20
+ @app = app
21
+ @logger = logger || LoggerMiddleware::NullLogger.new
22
+ end
23
+
24
+ def call(env)
25
+ attempts = env.fetch(:retry_attempts, 0)
26
+ interval = env.fetch(:retry_interval, 0.0)
27
+ current_attempt = 0
28
+
29
+ begin
30
+ current_attempt += 1
31
+ env[:attempt] = current_attempt
32
+ app.call(env)
33
+ rescue *RETRYABLE_ERRORS => error
34
+ raise error if current_attempt > attempts
35
+
36
+ logger.info("[Clowk::Http] retry=#{current_attempt} error=#{error.class}")
37
+ sleep(interval) if interval.positive?
38
+ retry
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :app, :logger
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clowk
4
+ class Http
5
+ class TimeoutMiddleware
6
+ def initialize(app, logger: nil, open_timeout: nil, read_timeout: nil, write_timeout: nil, **)
7
+ @app = app
8
+ @logger = logger || LoggerMiddleware::NullLogger.new
9
+ @open_timeout = open_timeout
10
+ @read_timeout = read_timeout
11
+ @write_timeout = write_timeout
12
+ end
13
+
14
+ def call(env)
15
+ env[:timeouts] = {
16
+ open_timeout: env.fetch(:open_timeout, open_timeout),
17
+ read_timeout: env.fetch(:read_timeout, read_timeout),
18
+ write_timeout: env.fetch(:write_timeout, write_timeout)
19
+ }.compact
20
+
21
+ logger.info("[Clowk::Http] timeouts=#{env[:timeouts]}") unless env[:timeouts].empty?
22
+ app.call(env)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :app, :logger, :open_timeout, :read_timeout, :write_timeout
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module Clowk
6
+ class JwtVerifier
7
+ ALGORITHM = 'HS256'
8
+
9
+ def initialize(secret_key: Clowk.config.secret_key, issuer: Clowk.config.issuer)
10
+ @secret_key = secret_key
11
+ @issuer = issuer
12
+ end
13
+
14
+ def verify(token)
15
+ raise ConfigurationError, 'missing Clowk secret_key' if @secret_key.to_s.empty?
16
+
17
+ options = { algorithm: ALGORITHM }
18
+ options[:iss] = @issuer if @issuer
19
+ options[:verify_iss] = @issuer.present?
20
+
21
+ payload, = JWT.decode(token, @secret_key, true, options)
22
+ payload.deep_symbolize_keys
23
+ rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature, JWT::InvalidIssuerError => e
24
+ raise InvalidTokenError, e.message
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clowk
4
+ module Middleware
5
+ class TokenExtractor
6
+ def initialize(request, token_param: Clowk.config.token_param, cookie_key: Clowk.config.cookie_key)
7
+ @request = request
8
+ @token_param = token_param
9
+ @cookie_key = cookie_key
10
+ end
11
+
12
+ def call
13
+ token_from_params || token_from_bearer || token_from_cookies
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :request, :token_param, :cookie_key
19
+
20
+ def token_from_params
21
+ params = request.respond_to?(:params) && request.params ? request.params : {}
22
+ params[token_param.to_s].presence
23
+ end
24
+
25
+ def token_from_bearer
26
+ header = request.authorization.to_s
27
+ return if header.empty?
28
+
29
+ scheme, token = header.split(' ', 2)
30
+ return unless scheme.to_s.casecmp('Bearer').zero?
31
+
32
+ token.presence
33
+ end
34
+
35
+ def token_from_cookies
36
+ return unless request.respond_to?(:cookie_jar) && request.cookie_jar
37
+
38
+ request.cookie_jar[cookie_key].presence
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/inflector'
4
+
5
+ module Clowk
6
+ module SDK
7
+ class Client
8
+ def initialize(options = {})
9
+ @api_base_url = options.fetch(:api_base_url, Clowk.config.api_base_url)
10
+ @secret_key = options.fetch(:secret_key, Clowk.config.secret_key)
11
+ @publishable_key = options.fetch(:publishable_key, Clowk.config.publishable_key)
12
+ end
13
+
14
+ def method_missing(method_name, *, **, &)
15
+ resource_class_name = ActiveSupport::Inflector.camelize(
16
+ ActiveSupport::Inflector.singularize(method_name.to_s)
17
+ )
18
+
19
+ return super unless Clowk::SDK.const_defined?(resource_class_name)
20
+
21
+ resource_ivar = "@#{method_name}"
22
+ return instance_variable_get(resource_ivar) if instance_variable_defined?(resource_ivar)
23
+
24
+ resource_class = Clowk::SDK.const_get(resource_class_name)
25
+ instance_variable_set(resource_ivar, resource_class.new(self))
26
+ end
27
+
28
+ def respond_to_missing?(method_name, include_private = false)
29
+ resource_class_name = ActiveSupport::Inflector.camelize(
30
+ ActiveSupport::Inflector.singularize(method_name.to_s)
31
+ )
32
+
33
+ Clowk::SDK.const_defined?(resource_class_name) || super
34
+ end
35
+
36
+ def delete(path, body = nil, headers: {})
37
+ http.delete(path, body, headers:)
38
+ end
39
+
40
+ def patch(path, body = {}, headers: {})
41
+ http.patch(path, body, headers:)
42
+ end
43
+
44
+ def get(path, headers: {})
45
+ http.get(path, headers:)
46
+ end
47
+
48
+ def post(path, body = {}, headers: {})
49
+ http.post(path, body, headers:)
50
+ end
51
+
52
+ def put(path, body = {}, headers: {})
53
+ http.put(path, body, headers:)
54
+ end
55
+
56
+ def head(path, headers: {})
57
+ http.head(path, headers:)
58
+ end
59
+
60
+ def options(path, headers: {})
61
+ http.options(path, headers:)
62
+ end
63
+
64
+ private
65
+
66
+ attr_reader :api_base_url, :publishable_key, :secret_key
67
+
68
+ def http
69
+ @http ||= Clowk::Http.new(
70
+ base_url: api_base_url,
71
+ headers: default_headers,
72
+ logger: Clowk.config.http_logger,
73
+ open_timeout: Clowk.config.http_open_timeout,
74
+ read_timeout: Clowk.config.http_read_timeout,
75
+ write_timeout: Clowk.config.http_write_timeout,
76
+ retry_attempts: Clowk.config.http_retry_attempts,
77
+ retry_interval: Clowk.config.http_retry_interval
78
+ )
79
+ end
80
+
81
+ def default_headers
82
+ {}.tap do |headers|
83
+ headers['X-Clowk-Secret-Key'] = secret_key if secret_key.present?
84
+ headers['X-Clowk-Publishable-Key'] = publishable_key if publishable_key.present?
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module Clowk
6
+ module SDK
7
+ class Resource
8
+ def self.resource_path
9
+ raise NotImplementedError, 'resource_path must be implemented'
10
+ end
11
+
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ # Usage
17
+ # client.list
18
+ # @return [Array<Hash>] list of all resources
19
+ def list
20
+ client.get(self.class.resource_path)
21
+ end
22
+
23
+ # @example keywords
24
+ # client.users.search(email: "user@example.com", status: "active")
25
+ # # GET /users/search?query=email%3Auser%40example.com+status%3Aactive
26
+ #
27
+ # @example raw string
28
+ # client.users.search("email:user@example.com active:true created_at>2026-01-01")
29
+ # # GET /users/search?query=email%3Auser%40example.com+active%3Atrue+created_at%3E2026-01-01
30
+ #
31
+ # @return [Clowk::Http::Response]
32
+ def search(raw_query = nil, **filters)
33
+ query = if raw_query.is_a?(String)
34
+ raw_query
35
+ else
36
+ filters.map { |k, v| "#{k}:#{v}" }.join(' ')
37
+ end
38
+
39
+ client.get("#{self.class.resource_path}/search?query=#{ERB::Util.url_encode(query)}")
40
+ end
41
+
42
+ # Usage
43
+ # client.find("123")
44
+ # @return [Hash] resource with the given id
45
+ def find(id)
46
+ client.get("#{self.class.resource_path}/#{id}")
47
+ end
48
+
49
+ # Usage
50
+ # client.show("123")
51
+ # @return [Hash] resource with the given id
52
+ def show(id)
53
+ client.get("#{self.class.resource_path}/#{id}")
54
+ end
55
+
56
+ # Usage
57
+ # client.destroy("123")
58
+ # @return [Hash] deleted resource
59
+ def destroy(id)
60
+ client.delete("#{self.class.resource_path}/#{id}")
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :client
66
+ end
67
+ end
68
+ end