cloudflare-ruby 0.0.1 → 0.1.2

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE +21 -0
  4. data/README.md +127 -3
  5. data/lib/cloudflare/configuration.rb +11 -0
  6. data/lib/cloudflare/connection.rb +52 -0
  7. data/lib/cloudflare/errors.rb +25 -0
  8. data/lib/cloudflare/realtime_kit/README.md +202 -0
  9. data/lib/cloudflare/realtime_kit/active_livestream_session.rb +46 -0
  10. data/lib/cloudflare/realtime_kit/active_session.rb +68 -0
  11. data/lib/cloudflare/realtime_kit/analytics.rb +57 -0
  12. data/lib/cloudflare/realtime_kit/app.rb +56 -0
  13. data/lib/cloudflare/realtime_kit/chat.rb +14 -0
  14. data/lib/cloudflare/realtime_kit/livestream.rb +27 -0
  15. data/lib/cloudflare/realtime_kit/livestream_session.rb +26 -0
  16. data/lib/cloudflare/realtime_kit/meeting.rb +82 -0
  17. data/lib/cloudflare/realtime_kit/participant.rb +40 -0
  18. data/lib/cloudflare/realtime_kit/preset.rb +31 -0
  19. data/lib/cloudflare/realtime_kit/recording.rb +85 -0
  20. data/lib/cloudflare/realtime_kit/session.rb +69 -0
  21. data/lib/cloudflare/realtime_kit/session_participant.rb +30 -0
  22. data/lib/cloudflare/realtime_kit/summary.rb +28 -0
  23. data/lib/cloudflare/realtime_kit/transcript.rb +17 -0
  24. data/lib/cloudflare/realtime_kit/webhook.rb +36 -0
  25. data/lib/cloudflare/realtime_kit.rb +23 -0
  26. data/lib/cloudflare/relation.rb +22 -0
  27. data/lib/cloudflare/resource.rb +309 -0
  28. data/lib/cloudflare/version.rb +3 -0
  29. data/lib/cloudflare-ruby.rb +1 -1
  30. data/lib/cloudflare.rb +34 -0
  31. data/sig/cloudflare/configuration.rbs +11 -0
  32. data/sig/cloudflare/connection.rbs +8 -0
  33. data/sig/cloudflare/errors.rbs +25 -0
  34. data/sig/cloudflare/realtime_kit.rbs +4 -0
  35. data/sig/cloudflare/relation.rbs +9 -0
  36. data/sig/cloudflare/resource.rbs +40 -0
  37. data/sig/cloudflare.rbs +11 -0
  38. data/spec/slices/realtime_kit.json +9627 -0
  39. metadata +70 -7
@@ -0,0 +1,309 @@
1
+ module Cloudflare
2
+ # Base class for every Cloudflare resource — Meeting, Bucket, Zone, Record,
3
+ # Script, etc. Provides Active-Record-flavored CRUD on top of REST endpoints,
4
+ # plus an `attribute` macro for explicit, source-visible attribute readers.
5
+ #
6
+ # Subclasses declare paths and attributes:
7
+ #
8
+ # class Cloudflare::RealtimeKit::Meeting < Cloudflare::Resource
9
+ # collection_path "/accounts/{account_id}/realtime/kit/{app_id}/meetings"
10
+ # member_path "/accounts/{account_id}/realtime/kit/{app_id}/meetings/{id}"
11
+ # scope_required :app_id
12
+ #
13
+ # attribute :id, String
14
+ # attribute :title, String
15
+ # attribute :location_hint, String, wire_name: "locationHint"
16
+ # end
17
+ class Resource
18
+ # Cloudflare uses two response envelope shapes:
19
+ # - V4 API: { result: ..., success, errors, messages } — most products
20
+ # - RealtimeKit (Dyte heritage): { data: ..., success }
21
+ ENVELOPE_KEYS = %w[result data].freeze
22
+
23
+ class << self
24
+ attr_reader :_collection_path, :_member_path, :_attributes
25
+
26
+ def collection_path(path = nil)
27
+ path ? @_collection_path = path : @_collection_path
28
+ end
29
+
30
+ def member_path(path = nil)
31
+ path ? @_member_path = path : @_member_path
32
+ end
33
+
34
+ def scope_required(*keys)
35
+ @_explicit_scope = keys.map(&:to_sym).freeze
36
+ end
37
+
38
+ def scope_params
39
+ ((@_explicit_scope || []) + [ :account_id ]).uniq
40
+ end
41
+
42
+ # Mark this resource as read-only — i.e., the upstream spec exposes only
43
+ # GET endpoints and no POST/PATCH/PUT/DELETE. Use when a child resource
44
+ # surfaced by +has_many+ inherits a +.create+ from +Resource+ that would
45
+ # silently 404 against the spec, e.g., +SessionParticipant+. Overrides
46
+ # the writer methods to raise +NoMethodError+ with a spec-citing
47
+ # message before any HTTP call.
48
+ def read_only
49
+ @_read_only = true
50
+ define_singleton_method(:create) do |**|
51
+ raise NoMethodError, "#{name} is read-only — upstream has no POST endpoint"
52
+ end
53
+ define_method(:update) do |**|
54
+ raise NoMethodError, "#{self.class.name} is read-only — upstream has no PATCH endpoint"
55
+ end
56
+ define_method(:destroy) do
57
+ raise NoMethodError, "#{self.class.name} is read-only — upstream has no DELETE endpoint"
58
+ end
59
+ end
60
+
61
+ def read_only? = @_read_only == true
62
+
63
+ # Declare a typed attribute reader. Coerces on read for known types
64
+ # (Time, Integer, Float). For :boolean, also defines a `name?` predicate.
65
+ # Use wire_name when the on-the-wire field name differs from the
66
+ # snake_case Ruby identifier (e.g., camelCase fields like `locationHint`).
67
+ #
68
+ # Readers gate through +ensure_loaded!+: a stub created by +has_one+
69
+ # auto-fetches its attrs on the first attribute read. Action methods
70
+ # (+kick+, +mute+, etc.) skip the gate, so action-only flows never pay
71
+ # for an unwanted GET.
72
+ def attribute(name, type = nil, wire_name: nil)
73
+ key = name.to_sym
74
+ wire_key = wire_name&.to_s || name.to_s
75
+ (@_attributes ||= {})[key] = { type: type, wire_name: wire_key }
76
+
77
+ # +#id+ is special — it's used internally by +member_path+ which
78
+ # +reload+ depends on, so a gated reader would cause infinite
79
+ # recursion. Subclasses can still declare +attribute :id, String+
80
+ # for type documentation; we just don't override the base reader.
81
+ return if key == :id
82
+
83
+ define_method(name) { ensure_loaded!; coerce_attribute(@attrs[wire_key], type) }
84
+ define_method("#{name}?") { ensure_loaded!; !!@attrs[wire_key] } if type == :boolean
85
+ end
86
+
87
+ def attributes = @_attributes ||= {}
88
+
89
+ # Records the on-the-wire field name for a request body kwarg. Use this
90
+ # when the request shape uses a different name than the response (e.g.,
91
+ # R2 sends `storageClass` but reads back `storage_class`), or when a
92
+ # kwarg isn't represented as an attribute at all (write-only fields).
93
+ def wire_kwarg(ruby_name, wire_name)
94
+ (@_request_wire_names ||= {})[ruby_name.to_sym] = wire_name.to_s
95
+ end
96
+
97
+ # Wire name used when sending a kwarg in a request body or query.
98
+ # Lookup order: explicit wire_kwarg → attribute's wire_name → ruby key as string.
99
+ def wire_name_for_request(ruby_key)
100
+ sym = ruby_key.to_sym
101
+ (@_request_wire_names || {})[sym] ||
102
+ attributes.dig(sym, :wire_name) ||
103
+ sym.to_s
104
+ end
105
+
106
+ # Wire name used when reading from a response. Only consults the
107
+ # attribute declaration (response field name).
108
+ def wire_name_for_response(ruby_key)
109
+ attributes.dig(ruby_key.to_sym, :wire_name) || ruby_key.to_s
110
+ end
111
+
112
+ # Translate a kwargs Hash from Ruby snake_case keys to wire-format keys
113
+ # for SENDING in a request body or query.
114
+ def to_wire_keys(hash)
115
+ hash.each_with_object({}) { |(k, v), out| out[wire_name_for_request(k)] = v }
116
+ end
117
+
118
+ # Unwrap a Cloudflare response envelope (`result` for V4, `data` for
119
+ # RealtimeKit) and return the inner payload. Returns the response
120
+ # unchanged when neither envelope key is present.
121
+ def unwrap_envelope(response)
122
+ return response unless response.is_a?(Hash)
123
+ ENVELOPE_KEYS.each do |key|
124
+ return response[key] if response.key?(key) && !response[key].nil?
125
+ end
126
+ response
127
+ end
128
+
129
+ # Declare a nested collection reachable via REST sub-path. Returns a
130
+ # Relation scoped to this parent. Convention: `participants` →
131
+ # `Participant` (singularize + classify) in the same product namespace.
132
+ # Override with `class_name: "Cloudflare::Other::Klass"`.
133
+ def has_many(name, class_name: nil)
134
+ define_method(name) do
135
+ @relations ||= {}
136
+ @relations[name.to_sym] ||= Relation.new(parent: self, model: resolve_class(name, class_name: class_name, singularize: true))
137
+ end
138
+ end
139
+
140
+ # Declare a nested singleton sub-resource (no id, fixed sub-path).
141
+ # Returns an unloaded stub scoped to this parent. Action methods on the
142
+ # stub (e.g., +active_session.kick_all+) work without a fetch — they
143
+ # only need scope. Attribute reads (e.g., +active_session.live_participants+)
144
+ # auto-fetch via +ensure_loaded!+ on first access and cache thereafter.
145
+ #
146
+ # The cost trade-off is HTTP-aware: action-only flows pay nothing extra,
147
+ # read flows pay exactly one GET on first access.
148
+ def has_one(name, class_name: nil)
149
+ define_method(name) do
150
+ @singletons ||= {}
151
+ @singletons[name.to_sym] ||= begin
152
+ klass = resolve_class(name, class_name: class_name, singularize: false)
153
+ klass.new({}, scope: child_scope_for_nested)
154
+ end
155
+ end
156
+ end
157
+
158
+ # CRUD defaults. Subclasses override to add explicit kwargs.
159
+
160
+ def create(**attrs)
161
+ scope = extract_scope!(attrs)
162
+ path = interpolate(_collection_path, scope)
163
+ response = Connection.instance.request(:post, path, body: to_wire_keys(attrs))
164
+ new(response, scope: scope)
165
+ end
166
+
167
+ def find(id, **scope_attrs)
168
+ scope = build_scope(scope_attrs)
169
+ path = interpolate(_member_path, scope.merge(id: id))
170
+ response = Connection.instance.request(:get, path)
171
+ new(response, scope: scope)
172
+ end
173
+
174
+ def all(**params)
175
+ scope = extract_scope!(params)
176
+ path = interpolate(_collection_path, scope)
177
+ response = Connection.instance.request(:get, path, params: to_wire_keys(params))
178
+ items = unwrap_envelope(response)
179
+ Array(items).map { new(_1, scope: scope) }
180
+ end
181
+
182
+ private
183
+ # Pulls scope params out of the kwargs hash (mutating). Defaults
184
+ # account_id from global config if not provided.
185
+ def extract_scope!(attrs)
186
+ scope_params.each_with_object({}) do |key, h|
187
+ value = attrs.delete(key) || (key == :account_id && Cloudflare.account_id)
188
+ raise ArgumentError, "missing required scope param: #{key}" unless value
189
+ h[key] = value
190
+ end
191
+ end
192
+
193
+ def build_scope(provided)
194
+ scope_params.each_with_object({}) do |key, h|
195
+ value = provided[key] || (key == :account_id && Cloudflare.account_id)
196
+ raise ArgumentError, "missing required scope param: #{key}" unless value
197
+ h[key] = value
198
+ end
199
+ end
200
+
201
+ def interpolate(path, values)
202
+ path.gsub(/\{(\w+)\}/) do
203
+ key = $1.to_sym
204
+ values[key] || raise(ArgumentError, "missing path param: #{key}")
205
+ end
206
+ end
207
+ end
208
+
209
+ attr_reader :scope
210
+
211
+ def initialize(response, scope: {})
212
+ @attrs = self.class.unwrap_envelope(response).then { |r| r.is_a?(Hash) ? r.transform_keys(&:to_s) : {} }
213
+ @scope = scope
214
+ @loaded = !@attrs.empty?
215
+ end
216
+
217
+ # Note: +#id+ is intentionally not gated through +ensure_loaded!+. It's
218
+ # called inside +member_path+, which +reload+ itself depends on — gating
219
+ # would create a fetch loop. On a not-yet-loaded stub this returns nil;
220
+ # in practice singletons (the only path that produces stubs) don't have
221
+ # +{id}+ in their member_path, so the nil is harmless during the first
222
+ # +reload+.
223
+ def id = @attrs["id"]
224
+ def [](key) = (ensure_loaded!; @attrs[key.to_s])
225
+ def to_h = (ensure_loaded!; @attrs)
226
+ def attributes = (ensure_loaded!; @attrs)
227
+
228
+ def update(**changes)
229
+ response = Connection.instance.request(:patch, member_path, body: self.class.to_wire_keys(changes))
230
+ set_attrs_from_response(response)
231
+ self
232
+ end
233
+
234
+ def destroy
235
+ Connection.instance.request(:delete, member_path)
236
+ freeze
237
+ end
238
+
239
+ def reload
240
+ response = Connection.instance.request(:get, member_path)
241
+ set_attrs_from_response(response)
242
+ self
243
+ end
244
+
245
+ # Replace +@attrs+ from a freshly-received response and mark the instance
246
+ # loaded. Subclasses that issue their own writes (e.g., +Recording#transition+,
247
+ # +Summary#generate+) call this so a subsequent attribute read doesn't
248
+ # trigger a stale re-fetch and clobber the just-written state. Public so
249
+ # subclasses can use it.
250
+ def set_attrs_from_response(response)
251
+ return unless response.is_a?(Hash)
252
+ @attrs = self.class.unwrap_envelope(response).transform_keys(&:to_s)
253
+ @loaded = true
254
+ end
255
+
256
+ def ==(other)
257
+ other.is_a?(self.class) && other.id == id
258
+ end
259
+ alias_method :eql?, :==
260
+
261
+ def hash = [ self.class, id ].hash
262
+
263
+ private
264
+ # Auto-fetch this stub's attrs from upstream on first attribute read.
265
+ # No-op when already loaded. Skipped by action methods (kick, mute, etc.)
266
+ # which only need +@scope+, so action-only flows incur zero GETs.
267
+ def ensure_loaded!
268
+ reload unless @loaded
269
+ end
270
+
271
+ def member_path
272
+ self.class.send(:interpolate, self.class._member_path, @scope.merge(id: id))
273
+ end
274
+
275
+ COERCERS = {
276
+ Time => ->(v) { v.is_a?(Time) ? v : Time.parse(v) },
277
+ Integer => ->(v) { Integer(v) },
278
+ Float => ->(v) { Float(v) }
279
+ }.freeze
280
+ private_constant :COERCERS
281
+
282
+ def coerce_attribute(value, type)
283
+ return nil if value.nil?
284
+ (COERCERS[type] || ->(v) { v }).call(value)
285
+ end
286
+
287
+ # Resolve a nested resource class by name. `singularize: true` strips
288
+ # a trailing 's' (`participants` → `Participant`); `false` leaves the
289
+ # name as-is (`active_session` → `ActiveSession`). Override entirely
290
+ # via `class_name: "Cloudflare::Other::Klass"`.
291
+ def resolve_class(name, class_name: nil, singularize: false)
292
+ return Object.const_get(class_name) if class_name
293
+
294
+ product_namespace = self.class.name.split("::")[0..-2].join("::")
295
+ basename = name.to_s
296
+ basename = basename.sub(/s$/, "") if singularize
297
+ klass = basename.split("_").map(&:capitalize).join
298
+ Object.const_get("#{product_namespace}::#{klass}")
299
+ end
300
+
301
+ # Parent's scope + parent's id mapped to its conventional key. For
302
+ # `Cloudflare::RealtimeKit::Meeting` (id "mtg-1") this produces
303
+ # { account_id: "...", app_id: "...", meeting_id: "mtg-1" }
304
+ def child_scope_for_nested
305
+ parent_id_key = self.class.name.split("::").last.downcase + "_id"
306
+ @scope.merge(parent_id_key.to_sym => id)
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,3 @@
1
+ module Cloudflare
2
+ VERSION = "0.1.2"
3
+ end
@@ -1 +1 @@
1
- warn "[cloudflare-ruby] This is a placeholder release reserving the gem name. The real SDK has not yet been published. See https://github.com/tokimonki/cloudflare-ruby."
1
+ require "cloudflare"
data/lib/cloudflare.rb ADDED
@@ -0,0 +1,34 @@
1
+ require "faraday"
2
+ require "faraday/retry"
3
+ require "time"
4
+
5
+ require_relative "cloudflare/version"
6
+
7
+ module Cloudflare
8
+ autoload :Configuration, "cloudflare/configuration"
9
+ autoload :Connection, "cloudflare/connection"
10
+ autoload :Resource, "cloudflare/resource"
11
+ autoload :Relation, "cloudflare/relation"
12
+ autoload :RealtimeKit, "cloudflare/realtime_kit"
13
+
14
+ class << self
15
+ def configure
16
+ yield configuration
17
+ end
18
+
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def reset_configuration!
24
+ @configuration = nil
25
+ Connection.reset!
26
+ end
27
+
28
+ def api_token = configuration.api_token
29
+ def account_id = configuration.account_id
30
+ def base_url = configuration.base_url
31
+ end
32
+ end
33
+
34
+ require_relative "cloudflare/errors"
@@ -0,0 +1,11 @@
1
+ module Cloudflare
2
+ class Configuration
3
+ attr_accessor api_token: String?
4
+ attr_accessor account_id: String?
5
+ attr_accessor base_url: String
6
+ attr_accessor timeout: Integer
7
+ attr_accessor user_agent: String
8
+
9
+ def initialize: () -> void
10
+ end
11
+ end
@@ -0,0 +1,8 @@
1
+ module Cloudflare
2
+ class Connection
3
+ def self.instance: () -> Connection
4
+ def self.reset!: () -> void
5
+
6
+ def request: (Symbol method, String path, ?body: Hash[Symbol | String, untyped]?, ?params: Hash[Symbol | String, untyped]?) -> untyped
7
+ end
8
+ end
@@ -0,0 +1,25 @@
1
+ module Cloudflare
2
+ class Error < StandardError
3
+ attr_reader status: Integer?
4
+ attr_reader body: untyped
5
+
6
+ def initialize: (?String? message, ?status: Integer?, ?body: untyped) -> void
7
+ end
8
+
9
+ class AuthenticationError < Error
10
+ end
11
+
12
+ class NotFoundError < Error
13
+ end
14
+
15
+ class ValidationError < Error
16
+ end
17
+
18
+ class RateLimitError < Error
19
+ end
20
+
21
+ class ServerError < Error
22
+ end
23
+
24
+ ERROR_BY_STATUS: Hash[Integer, Class]
25
+ end
@@ -0,0 +1,4 @@
1
+ module Cloudflare
2
+ module RealtimeKit
3
+ end
4
+ end
@@ -0,0 +1,9 @@
1
+ module Cloudflare
2
+ class Relation
3
+ def initialize: (parent: Resource, model: Class) -> void
4
+
5
+ def all: (**untyped params) -> Array[Resource]
6
+ def create: (**untyped attrs) -> Resource
7
+ def find: (String id) -> Resource
8
+ end
9
+ end
@@ -0,0 +1,40 @@
1
+ module Cloudflare
2
+ class Resource
3
+ type scope_hash = Hash[Symbol, String]
4
+ type response = Hash[String, untyped]
5
+
6
+ attr_reader scope: scope_hash
7
+
8
+ def self._collection_path: () -> String?
9
+ def self._member_path: () -> String?
10
+ def self._attributes: () -> Hash[Symbol, untyped]
11
+ def self.scope_params: () -> Array[Symbol]
12
+
13
+ def self.collection_path: (?String? path) -> String?
14
+ def self.member_path: (?String? path) -> String?
15
+ def self.scope_required: (*Symbol keys) -> Array[Symbol]
16
+ def self.attribute: (Symbol name, ?untyped type) -> Symbol
17
+ def self.attributes: () -> Hash[Symbol, untyped]
18
+ def self.has_many: (Symbol name, ?class_name: String?) -> Symbol
19
+ def self.has_one: (Symbol name, ?class_name: String?) -> Symbol
20
+
21
+ def self.create: (**untyped attrs) -> Resource
22
+ def self.find: (String id, **untyped scope_attrs) -> Resource
23
+ def self.all: (**untyped params) -> Array[Resource]
24
+
25
+ def initialize: (response, ?scope: scope_hash) -> void
26
+
27
+ def id: () -> String?
28
+ def []: (Symbol | String key) -> untyped
29
+ def to_h: () -> response
30
+ def attributes: () -> response
31
+
32
+ def update: (**untyped changes) -> self
33
+ def destroy: () -> self
34
+ def reload: () -> self
35
+
36
+ def ==: (untyped other) -> bool
37
+ def eql?: (untyped other) -> bool
38
+ def hash: () -> Integer
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ module Cloudflare
2
+ VERSION: String
3
+
4
+ def self.configure: () { (Configuration) -> void } -> void
5
+ def self.configuration: () -> Configuration
6
+ def self.reset_configuration!: () -> void
7
+
8
+ def self.api_token: () -> String?
9
+ def self.account_id: () -> String?
10
+ def self.base_url: () -> String
11
+ end