Class: Cloudflare::Resource

Inherits:
Object
  • Object
show all
Defined in:
lib/cloudflare/resource.rb

Overview

Base class for every Cloudflare resource — Meeting, Bucket, Zone, Record, Script, etc. Provides Active-Record-flavored CRUD on top of REST endpoints, plus an ‘attribute` macro for explicit, source-visible attribute readers.

Subclasses declare paths and attributes:

class Cloudflare::RealtimeKit::Meeting < Cloudflare::Resource
  collection_path "/accounts/{account_id}/realtime/kit/{app_id}/meetings"
  member_path     "/accounts/{account_id}/realtime/kit/{app_id}/meetings/{id}"
  scope_required  :app_id

  attribute :id,            String
  attribute :title,         String
  attribute :location_hint, String, wire_name: "locationHint"
end

Constant Summary collapse

ENVELOPE_KEYS =

Cloudflare uses two response envelope shapes:

- V4 API: { result: ..., success, errors, messages }    — most products
- RealtimeKit (Dyte heritage): { data: ..., success }
%w[result data].freeze

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(response, scope: {}) ⇒ Resource

Returns a new instance of Resource.



211
212
213
214
215
# File 'lib/cloudflare/resource.rb', line 211

def initialize(response, scope: {})
  @attrs  = self.class.unwrap_envelope(response).then { |r| r.is_a?(Hash) ? r.transform_keys(&:to_s) : {} }
  @scope  = scope
  @loaded = !@attrs.empty?
end

Class Attribute Details

._attributesObject (readonly)

Returns the value of attribute _attributes.



24
25
26
# File 'lib/cloudflare/resource.rb', line 24

def _attributes
  @_attributes
end

._collection_pathObject (readonly)

Returns the value of attribute _collection_path.



24
25
26
# File 'lib/cloudflare/resource.rb', line 24

def _collection_path
  @_collection_path
end

._member_pathObject (readonly)

Returns the value of attribute _member_path.



24
25
26
# File 'lib/cloudflare/resource.rb', line 24

def _member_path
  @_member_path
end

Instance Attribute Details

#scopeObject (readonly)

Returns the value of attribute scope.



209
210
211
# File 'lib/cloudflare/resource.rb', line 209

def scope
  @scope
end

Class Method Details

.all(**params) ⇒ Object



174
175
176
177
178
179
180
# File 'lib/cloudflare/resource.rb', line 174

def all(**params)
  scope    = extract_scope!(params)
  path     = interpolate(_collection_path, scope)
  response = Connection.instance.request(:get, path, params: to_wire_keys(params))
  items    = unwrap_envelope(response)
  Array(items).map { new(_1, scope: scope) }
end

.attribute(name, type = nil, wire_name: nil) ⇒ Object

Declare a typed attribute reader. Coerces on read for known types (Time, Integer, Float). For :boolean, also defines a ‘name?` predicate. Use wire_name when the on-the-wire field name differs from the snake_case Ruby identifier (e.g., camelCase fields like `locationHint`).

Readers gate through ensure_loaded!: a stub created by has_one auto-fetches its attrs on the first attribute read. Action methods (kick, mute, etc.) skip the gate, so action-only flows never pay for an unwanted GET.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/cloudflare/resource.rb', line 72

def attribute(name, type = nil, wire_name: nil)
  key      = name.to_sym
  wire_key = wire_name&.to_s || name.to_s
  (@_attributes ||= {})[key] = { type: type, wire_name: wire_key }

  # +#id+ is special — it's used internally by +member_path+ which
  # +reload+ depends on, so a gated reader would cause infinite
  # recursion. Subclasses can still declare +attribute :id, String+
  # for type documentation; we just don't override the base reader.
  return if key == :id

  define_method(name)        { ensure_loaded!; coerce_attribute(@attrs[wire_key], type) }
  define_method("#{name}?")  { ensure_loaded!; !!@attrs[wire_key] } if type == :boolean
end

.attributesObject



87
# File 'lib/cloudflare/resource.rb', line 87

def attributes = @_attributes ||= {}

.collection_path(path = nil) ⇒ Object



26
27
28
# File 'lib/cloudflare/resource.rb', line 26

def collection_path(path = nil)
  path ? @_collection_path = path : @_collection_path
end

.create(**attrs) ⇒ Object

CRUD defaults. Subclasses override to add explicit kwargs.



160
161
162
163
164
165
# File 'lib/cloudflare/resource.rb', line 160

def create(**attrs)
  scope    = extract_scope!(attrs)
  path     = interpolate(_collection_path, scope)
  response = Connection.instance.request(:post, path, body: to_wire_keys(attrs))
  new(response, scope: scope)
end

.find(id, **scope_attrs) ⇒ Object



167
168
169
170
171
172
# File 'lib/cloudflare/resource.rb', line 167

def find(id, **scope_attrs)
  scope    = build_scope(scope_attrs)
  path     = interpolate(_member_path, scope.merge(id: id))
  response = Connection.instance.request(:get, path)
  new(response, scope: scope)
end

.has_many(name, class_name: nil) ⇒ Object

Declare a nested collection reachable via REST sub-path. Returns a Relation scoped to this parent. Convention: ‘participants` →`Participant` (singularize + classify) in the same product namespace. Override with `class_name: “Cloudflare::Other::Klass”`.



133
134
135
136
137
138
# File 'lib/cloudflare/resource.rb', line 133

def has_many(name, class_name: nil)
  define_method(name) do
    @relations ||= {}
    @relations[name.to_sym] ||= Relation.new(parent: self, model: resolve_class(name, class_name: class_name, singularize: true))
  end
end

.has_one(name, class_name: nil) ⇒ Object

Declare a nested singleton sub-resource (no id, fixed sub-path). Returns an unloaded stub scoped to this parent. Action methods on the stub (e.g., active_session.kick_all) work without a fetch — they only need scope. Attribute reads (e.g., active_session.live_participants) auto-fetch via ensure_loaded! on first access and cache thereafter.

The cost trade-off is HTTP-aware: action-only flows pay nothing extra, read flows pay exactly one GET on first access.



148
149
150
151
152
153
154
155
156
# File 'lib/cloudflare/resource.rb', line 148

def has_one(name, class_name: nil)
  define_method(name) do
    @singletons ||= {}
    @singletons[name.to_sym] ||= begin
      klass = resolve_class(name, class_name: class_name, singularize: false)
      klass.new({}, scope: child_scope_for_nested)
    end
  end
end

.member_path(path = nil) ⇒ Object



30
31
32
# File 'lib/cloudflare/resource.rb', line 30

def member_path(path = nil)
  path ? @_member_path = path : @_member_path
end

.read_onlyObject

Mark this resource as read-only — i.e., the upstream spec exposes only GET endpoints and no POST/PATCH/PUT/DELETE. Use when a child resource surfaced by has_many inherits a .create from Resource that would silently 404 against the spec, e.g., SessionParticipant. Overrides the writer methods to raise NoMethodError with a spec-citing message before any HTTP call.



48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/cloudflare/resource.rb', line 48

def read_only
  @_read_only = true
  define_singleton_method(:create) do |**|
    raise NoMethodError, "#{name} is read-only — upstream has no POST endpoint"
  end
  define_method(:update) do |**|
    raise NoMethodError, "#{self.class.name} is read-only — upstream has no PATCH endpoint"
  end
  define_method(:destroy) do
    raise NoMethodError, "#{self.class.name} is read-only — upstream has no DELETE endpoint"
  end
end

.read_only?Boolean

Returns:

  • (Boolean)


61
# File 'lib/cloudflare/resource.rb', line 61

def read_only? = @_read_only == true

.scope_paramsObject



38
39
40
# File 'lib/cloudflare/resource.rb', line 38

def scope_params
  ((@_explicit_scope || []) + [ :account_id ]).uniq
end

.scope_required(*keys) ⇒ Object



34
35
36
# File 'lib/cloudflare/resource.rb', line 34

def scope_required(*keys)
  @_explicit_scope = keys.map(&:to_sym).freeze
end

.to_wire_keys(hash) ⇒ Object

Translate a kwargs Hash from Ruby snake_case keys to wire-format keys for SENDING in a request body or query.



114
115
116
# File 'lib/cloudflare/resource.rb', line 114

def to_wire_keys(hash)
  hash.each_with_object({}) { |(k, v), out| out[wire_name_for_request(k)] = v }
end

.unwrap_envelope(response) ⇒ Object

Unwrap a Cloudflare response envelope (‘result` for V4, `data` for RealtimeKit) and return the inner payload. Returns the response unchanged when neither envelope key is present.



121
122
123
124
125
126
127
# File 'lib/cloudflare/resource.rb', line 121

def unwrap_envelope(response)
  return response unless response.is_a?(Hash)
  ENVELOPE_KEYS.each do |key|
    return response[key] if response.key?(key) && !response[key].nil?
  end
  response
end

.wire_kwarg(ruby_name, wire_name) ⇒ Object

Records the on-the-wire field name for a request body kwarg. Use this when the request shape uses a different name than the response (e.g., R2 sends ‘storageClass` but reads back `storage_class`), or when a kwarg isn’t represented as an attribute at all (write-only fields).



93
94
95
# File 'lib/cloudflare/resource.rb', line 93

def wire_kwarg(ruby_name, wire_name)
  (@_request_wire_names ||= {})[ruby_name.to_sym] = wire_name.to_s
end

.wire_name_for_request(ruby_key) ⇒ Object

Wire name used when sending a kwarg in a request body or query. Lookup order: explicit wire_kwarg → attribute’s wire_name → ruby key as string.



99
100
101
102
103
104
# File 'lib/cloudflare/resource.rb', line 99

def wire_name_for_request(ruby_key)
  sym = ruby_key.to_sym
  (@_request_wire_names || {})[sym] ||
    attributes.dig(sym, :wire_name) ||
    sym.to_s
end

.wire_name_for_response(ruby_key) ⇒ Object

Wire name used when reading from a response. Only consults the attribute declaration (response field name).



108
109
110
# File 'lib/cloudflare/resource.rb', line 108

def wire_name_for_response(ruby_key)
  attributes.dig(ruby_key.to_sym, :wire_name) || ruby_key.to_s
end

Instance Method Details

#==(other) ⇒ Object Also known as: eql?



256
257
258
# File 'lib/cloudflare/resource.rb', line 256

def ==(other)
  other.is_a?(self.class) && other.id == id
end

#[](key) ⇒ Object



224
# File 'lib/cloudflare/resource.rb', line 224

def [](key)     = (ensure_loaded!; @attrs[key.to_s])

#attributesObject



226
# File 'lib/cloudflare/resource.rb', line 226

def attributes  = (ensure_loaded!; @attrs)

#destroyObject



234
235
236
237
# File 'lib/cloudflare/resource.rb', line 234

def destroy
  Connection.instance.request(:delete, member_path)
  freeze
end

#hashObject



261
# File 'lib/cloudflare/resource.rb', line 261

def hash = [ self.class, id ].hash

#idObject

Note: #id is intentionally not gated through ensure_loaded!. It’s called inside member_path, which reload itself depends on — gating would create a fetch loop. On a not-yet-loaded stub this returns nil; in practice singletons (the only path that produces stubs) don’t have {id} in their member_path, so the nil is harmless during the first reload.



223
# File 'lib/cloudflare/resource.rb', line 223

def id          = @attrs["id"]

#reloadObject



239
240
241
242
243
# File 'lib/cloudflare/resource.rb', line 239

def reload
  response = Connection.instance.request(:get, member_path)
  set_attrs_from_response(response)
  self
end

#set_attrs_from_response(response) ⇒ Object

Replace @attrs from a freshly-received response and mark the instance loaded. Subclasses that issue their own writes (e.g., Recording#transition, Summary#generate) call this so a subsequent attribute read doesn’t trigger a stale re-fetch and clobber the just-written state. Public so subclasses can use it.



250
251
252
253
254
# File 'lib/cloudflare/resource.rb', line 250

def set_attrs_from_response(response)
  return unless response.is_a?(Hash)
  @attrs  = self.class.unwrap_envelope(response).transform_keys(&:to_s)
  @loaded = true
end

#to_hObject



225
# File 'lib/cloudflare/resource.rb', line 225

def to_h        = (ensure_loaded!; @attrs)

#update(**changes) ⇒ Object



228
229
230
231
232
# File 'lib/cloudflare/resource.rb', line 228

def update(**changes)
  response = Connection.instance.request(:patch, member_path, body: self.class.to_wire_keys(changes))
  set_attrs_from_response(response)
  self
end