Class: Clacky::ApiExtension

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/api_extension.rb

Overview

Base class for user-defined HTTP API extensions loaded from ~/.clacky/api_ext/<name>/handler.rb. Subclasses use a tiny route DSL (get/post/put/patch/delete) to expose endpoints under

/api/ext/<name>/<sub-path>

The framework wires up access-key auth, timeouts, JSON error envelopes, path-parameter parsing, and a curated handler context — extension authors only fill in business logic.

Minimal example (~/.clacky/api_ext/my-dashboard/handler.rb):

class MyDashboardExt < Clacky::ApiExtension
  get "/summary" do
    json(sessions: session_manager.list.size)
  end
end

Mounted automatically at: GET /api/ext/my-dashboard/summary

Defined Under Namespace

Classes: Halt, Route, ScopedLogger

Constant Summary collapse

HTTP_METHODS =
%i[get post put patch delete].freeze
MAX_TIMEOUT =
600
DEFAULT_TIMEOUT =
10

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(req:, res:, route:, params:, http_server:) ⇒ ApiExtension

Returns a new instance of ApiExtension.



165
166
167
168
169
170
171
# File 'lib/clacky/api_extension.rb', line 165

def initialize(req:, res:, route:, params:, http_server:)
  @req         = req
  @res         = res
  @route       = route
  @params      = params
  @http_server = http_server
end

Instance Attribute Details

#paramsObject (readonly)

Returns the value of attribute params.



163
164
165
# File 'lib/clacky/api_extension.rb', line 163

def params
  @params
end

#reqObject (readonly)

Returns the value of attribute req.



163
164
165
# File 'lib/clacky/api_extension.rb', line 163

def req
  @req
end

#resObject (readonly)

Returns the value of attribute res.



163
164
165
# File 'lib/clacky/api_extension.rb', line 163

def res
  @res
end

#routeObject (readonly)

Returns the value of attribute route.



163
164
165
# File 'lib/clacky/api_extension.rb', line 163

def route
  @route
end

Class Method Details

.class_timeoutObject



76
77
78
# File 'lib/clacky/api_extension.rb', line 76

def class_timeout
  @class_timeout
end

.compile_pattern(pattern) ⇒ Object



153
154
155
156
157
158
159
160
# File 'lib/clacky/api_extension.rb', line 153

def compile_pattern(pattern)
  param_names = []
  regex_str = pattern.gsub(%r{:([a-zA-Z_][a-zA-Z0-9_]*)}) do |_match|
    param_names << Regexp.last_match(1).to_sym
    "([^/]+)"
  end
  [Regexp.new("\\A#{regex_str}\\z"), param_names]
end

.ext_dirObject



92
93
94
# File 'lib/clacky/api_extension.rb', line 92

def ext_dir
  @ext_dir
end

.ext_dir=(value) ⇒ Object



96
97
98
# File 'lib/clacky/api_extension.rb', line 96

def ext_dir=(value)
  @ext_dir = value
end

.ext_idObject



84
85
86
# File 'lib/clacky/api_extension.rb', line 84

def ext_id
  @ext_id
end

.ext_id=(value) ⇒ Object



88
89
90
# File 'lib/clacky/api_extension.rb', line 88

def ext_id=(value)
  @ext_id = value
end

.inherited(subclass) ⇒ Object



66
67
68
69
# File 'lib/clacky/api_extension.rb', line 66

def inherited(subclass)
  super
  Clacky::ApiExtension.pending_subclasses << subclass
end

.metaObject



100
101
102
# File 'lib/clacky/api_extension.rb', line 100

def meta
  @meta ||= {}
end

.meta=(value) ⇒ Object



104
105
106
# File 'lib/clacky/api_extension.rb', line 104

def meta=(value)
  @meta = value || {}
end

.normalize_pattern(pattern) ⇒ Object



146
147
148
149
150
151
# File 'lib/clacky/api_extension.rb', line 146

def normalize_pattern(pattern)
  pattern = pattern.to_s
  pattern = "/#{pattern}" unless pattern.start_with?("/")
  pattern = pattern.chomp("/")
  pattern.empty? ? "/" : pattern
end

.pending_subclassesObject

Captures every subclass at the moment its ‘class` body finishes being required — the loader pops the most recent one off this list to bind an ext_id/dir without relying on ObjectSpace scans.



62
63
64
# File 'lib/clacky/api_extension.rb', line 62

def pending_subclasses
  @pending_subclasses ||= []
end

.public_endpoint(pattern) ⇒ Object

Mark a route as not requiring access-key auth. Caller must also declare ‘public: true` in meta.yml for the framework to honor this.



119
120
121
# File 'lib/clacky/api_extension.rb', line 119

def public_endpoint(pattern)
  public_paths << normalize_pattern(pattern)
end

.public_pathsObject



80
81
82
# File 'lib/clacky/api_extension.rb', line 80

def public_paths
  @public_paths ||= []
end

.register(ext_id, klass) ⇒ Object



50
51
52
# File 'lib/clacky/api_extension.rb', line 50

def register(ext_id, klass)
  registry[ext_id] = klass
end

.registryObject

Registry of all loaded ApiExtension subclasses, keyed by extension id (== directory name == mount prefix segment).



46
47
48
# File 'lib/clacky/api_extension.rb', line 46

def registry
  @registry ||= {}
end

.reset_registry!Object



54
55
56
57
# File 'lib/clacky/api_extension.rb', line 54

def reset_registry!
  @registry = {}
  @pending_subclasses = []
end

.routesObject

Per-subclass state — inherited classes carry their own routes/options.



72
73
74
# File 'lib/clacky/api_extension.rb', line 72

def routes
  @routes ||= []
end

.timeout(seconds) ⇒ Object

Set a default timeout (seconds) for every handler in this class. Per-route override available via ‘get “/x”, timeout: 30 do … end`.

Raises:

  • (ArgumentError)


110
111
112
113
114
115
# File 'lib/clacky/api_extension.rb', line 110

def timeout(seconds)
  raise ArgumentError, "timeout must be > 0" unless seconds.is_a?(Numeric) && seconds > 0
  raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if seconds > MAX_TIMEOUT

  @class_timeout = seconds.to_f
end

Instance Method Details

#agent_configObject



237
238
239
# File 'lib/clacky/api_extension.rb', line 237

def agent_config
  @http_server&.instance_variable_get(:@agent_config)
end

#configObject



229
230
231
# File 'lib/clacky/api_extension.rb', line 229

def config
  self.class.meta["config"] || {}
end

#data_path(*parts) ⇒ Object



215
216
217
218
219
# File 'lib/clacky/api_extension.rb', line 215

def data_path(*parts)
  base = File.join(self.class.ext_dir, "data")
  FileUtils.mkdir_p(base)
  File.join(base, *parts.map(&:to_s))
end

#error!(message, status: 400, **extra) ⇒ Object

Raises:



196
197
198
199
200
# File 'lib/clacky/api_extension.rb', line 196

def error!(message, status: 400, **extra)
  payload = { error: message.to_s }
  payload.merge!(extra) unless extra.empty?
  raise Halt.new(status, JSON.generate(payload), "application/json; charset=utf-8")
end

#ext_dirObject



221
222
223
# File 'lib/clacky/api_extension.rb', line 221

def ext_dir
  self.class.ext_dir
end

#ext_idObject



225
226
227
# File 'lib/clacky/api_extension.rb', line 225

def ext_id
  self.class.ext_id
end

#invokeObject



173
174
175
# File 'lib/clacky/api_extension.rb', line 173

def invoke
  instance_exec(&route.block)
end

#json(*args, **kwargs) ⇒ Object

—- handler context (white-listed access to host process) —-



179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/clacky/api_extension.rb', line 179

def json(*args, **kwargs)
  if args.empty?
    # Treat kwargs as the body: json(foo: 1, bar: 2)
    # For non-200 status, pass an explicit hash: json({foo: 1}, status: 422)
    raise Halt.new(200, JSON.generate(kwargs), "application/json; charset=utf-8")
  elsif args.size == 1
    status = kwargs[:status] || 200
    raise Halt.new(status, JSON.generate(args[0]), "application/json; charset=utf-8")
  else
    raise ArgumentError, "json: expected (hash) or (key: value, ...)"
  end
end

#json_bodyObject



202
203
204
205
206
207
208
209
# File 'lib/clacky/api_extension.rb', line 202

def json_body
  @json_body ||= begin
    return {} if req.body.nil? || req.body.empty?
    JSON.parse(req.body)
  rescue JSON::ParserError
    {}
  end
end

#loggerObject



245
246
247
# File 'lib/clacky/api_extension.rb', line 245

def logger
  @logger ||= ScopedLogger.new(self.class.ext_id)
end

#queryObject



211
212
213
# File 'lib/clacky/api_extension.rb', line 211

def query
  @query ||= req.query || {}
end

#server_start_timeObject



241
242
243
# File 'lib/clacky/api_extension.rb', line 241

def server_start_time
  @http_server&.instance_variable_get(:@start_time)
end

#session_managerObject



233
234
235
# File 'lib/clacky/api_extension.rb', line 233

def session_manager
  @http_server&.instance_variable_get(:@session_manager)
end

#text(str, status: 200) ⇒ Object

Raises:



192
193
194
# File 'lib/clacky/api_extension.rb', line 192

def text(str, status: 200)
  raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
end