takagi 0.1.0 → 1.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.
Files changed (197) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +70 -7
  3. data/.yard/templates/default/layout/html/layout.erb +34 -0
  4. data/AGENTS.md +16 -0
  5. data/CHANGELOG.md +158 -1
  6. data/CODE_OF_CONDUCT.md +1 -1
  7. data/README.md +590 -23
  8. data/ROADMAP.md +55 -0
  9. data/Rakefile +4 -4
  10. data/Steepfile +39 -0
  11. data/bin/takagi-dev +159 -0
  12. data/docs/FIRST_PLUGIN_GUIDE.md +224 -0
  13. data/docs/HOOKS.md +31 -0
  14. data/examples/client_lifecycle_example.rb +118 -0
  15. data/examples/cloud_gateway_app.rb +217 -0
  16. data/examples/nested_api_app.rb +258 -0
  17. data/examples/simple_device_app.rb +71 -0
  18. data/examples/takagi.yml +138 -0
  19. data/lib/takagi/application.rb +256 -0
  20. data/lib/takagi/base/middleware_management.rb +39 -0
  21. data/lib/takagi/base/plugin_management.rb +75 -0
  22. data/lib/takagi/base/reactor_management.rb +104 -0
  23. data/lib/takagi/base/server_lifecycle.rb +156 -0
  24. data/lib/takagi/base.rb +103 -11
  25. data/lib/takagi/branding.rb +88 -0
  26. data/lib/takagi/cbor/decoder.rb +385 -0
  27. data/lib/takagi/cbor/encoder.rb +260 -0
  28. data/lib/takagi/cbor/error.rb +17 -0
  29. data/lib/takagi/cbor/version.rb +9 -0
  30. data/lib/takagi/client/response.rb +236 -0
  31. data/lib/takagi/client.rb +265 -0
  32. data/lib/takagi/client_base.rb +204 -0
  33. data/lib/takagi/coap/code_helpers.rb +190 -0
  34. data/lib/takagi/coap/registries/base.rb +165 -0
  35. data/lib/takagi/coap/registries/content_format.rb +71 -0
  36. data/lib/takagi/coap/registries/message_type.rb +69 -0
  37. data/lib/takagi/coap/registries/method.rb +38 -0
  38. data/lib/takagi/coap/registries/option.rb +71 -0
  39. data/lib/takagi/coap/registries/response.rb +93 -0
  40. data/lib/takagi/coap/registries/signaling.rb +34 -0
  41. data/lib/takagi/coap/signaling.rb +10 -0
  42. data/lib/takagi/coap.rb +37 -0
  43. data/lib/takagi/composite_router.rb +186 -0
  44. data/lib/takagi/config.rb +337 -0
  45. data/lib/takagi/controller/resource_allocator.rb +164 -0
  46. data/lib/takagi/controller/thread_pool.rb +144 -0
  47. data/lib/takagi/controller.rb +319 -0
  48. data/lib/takagi/core/attribute_set.rb +128 -0
  49. data/lib/takagi/discovery/core_link_format.rb +137 -0
  50. data/lib/takagi/errors.rb +536 -0
  51. data/lib/takagi/event_bus/address_prefix.rb +142 -0
  52. data/lib/takagi/event_bus/async_executor.rb +235 -0
  53. data/lib/takagi/event_bus/coap_bridge.rb +208 -0
  54. data/lib/takagi/event_bus/future.rb +153 -0
  55. data/lib/takagi/event_bus/lru_cache.rb +157 -0
  56. data/lib/takagi/event_bus/message_buffer.rb +237 -0
  57. data/lib/takagi/event_bus/observer_cleanup.rb +110 -0
  58. data/lib/takagi/event_bus/scope.rb +74 -0
  59. data/lib/takagi/event_bus.rb +594 -0
  60. data/lib/takagi/helpers.rb +88 -0
  61. data/lib/takagi/hooks.rb +82 -0
  62. data/lib/takagi/initializer.rb +18 -0
  63. data/lib/takagi/logger.rb +15 -6
  64. data/lib/takagi/message/base.rb +155 -0
  65. data/lib/takagi/message/deduplication_cache.rb +84 -0
  66. data/lib/takagi/message/inbound.rb +147 -0
  67. data/lib/takagi/message/outbound.rb +223 -0
  68. data/lib/takagi/message/request.rb +158 -0
  69. data/lib/takagi/message/retransmission_manager.rb +193 -0
  70. data/lib/takagi/middleware/authentication.rb +19 -0
  71. data/lib/takagi/middleware/caching.rb +23 -0
  72. data/lib/takagi/middleware/debugging.rb +16 -0
  73. data/lib/takagi/middleware/logging.rb +14 -0
  74. data/lib/takagi/middleware/metrics.rb +440 -0
  75. data/lib/takagi/middleware/rate_limiting.rb +24 -0
  76. data/lib/takagi/middleware_stack.rb +166 -0
  77. data/lib/takagi/network/base.rb +76 -0
  78. data/lib/takagi/network/framing/tcp.rb +222 -0
  79. data/lib/takagi/network/framing/udp.rb +110 -0
  80. data/lib/takagi/network/registry.rb +72 -0
  81. data/lib/takagi/network/tcp.rb +60 -0
  82. data/lib/takagi/network/tcp_sender.rb +21 -0
  83. data/lib/takagi/network/udp.rb +61 -0
  84. data/lib/takagi/network/udp_sender.rb +20 -0
  85. data/lib/takagi/observable/emitter.rb +62 -0
  86. data/lib/takagi/observable/reactor.rb +488 -0
  87. data/lib/takagi/observable/registry.rb +122 -0
  88. data/lib/takagi/observe_registry.rb +10 -0
  89. data/lib/takagi/observer/client.rb +68 -0
  90. data/lib/takagi/observer/registry.rb +137 -0
  91. data/lib/takagi/observer/sender.rb +39 -0
  92. data/lib/takagi/observer/watcher.rb +43 -0
  93. data/lib/takagi/plugin.rb +313 -0
  94. data/lib/takagi/profiles.rb +176 -0
  95. data/lib/takagi/reactor.rb +23 -0
  96. data/lib/takagi/reactor_registry.rb +64 -0
  97. data/lib/takagi/registry/base.rb +268 -0
  98. data/lib/takagi/response_builder.rb +141 -0
  99. data/lib/takagi/router/metadata_extractor.rb +133 -0
  100. data/lib/takagi/router/route_matcher.rb +83 -0
  101. data/lib/takagi/router.rb +284 -25
  102. data/lib/takagi/serialization/base.rb +102 -0
  103. data/lib/takagi/serialization/cbor_serializer.rb +92 -0
  104. data/lib/takagi/serialization/json_serializer.rb +96 -0
  105. data/lib/takagi/serialization/octet_stream_serializer.rb +82 -0
  106. data/lib/takagi/serialization/registry.rb +187 -0
  107. data/lib/takagi/serialization/text_serializer.rb +87 -0
  108. data/lib/takagi/serialization.rb +117 -0
  109. data/lib/takagi/server/multi.rb +41 -0
  110. data/lib/takagi/server/registry.rb +71 -0
  111. data/lib/takagi/server/tcp.rb +249 -0
  112. data/lib/takagi/server/udp.rb +139 -0
  113. data/lib/takagi/server/udp_worker.rb +174 -0
  114. data/lib/takagi/server.rb +1 -31
  115. data/lib/takagi/server_registry.rb +10 -0
  116. data/lib/takagi/tcp_client.rb +142 -0
  117. data/lib/takagi/version.rb +2 -1
  118. data/lib/takagi.rb +24 -3
  119. data/sig/takagi/application.rbs +48 -0
  120. data/sig/takagi/base/middleware_management.rbs +33 -0
  121. data/sig/takagi/base/reactor_management.rbs +52 -0
  122. data/sig/takagi/base/server_lifecycle.rbs +54 -0
  123. data/sig/takagi/base.rbs +48 -0
  124. data/sig/takagi/cbor/decoder.rbs +171 -0
  125. data/sig/takagi/cbor/encoder.rbs +146 -0
  126. data/sig/takagi/cbor/error.rbs +19 -0
  127. data/sig/takagi/cbor/version.rbs +7 -0
  128. data/sig/takagi/client/response.rbs +148 -0
  129. data/sig/takagi/client.rbs +119 -0
  130. data/sig/takagi/client_base.rbs +135 -0
  131. data/sig/takagi/coap/code_helpers.rbs +91 -0
  132. data/sig/takagi/coap/registries/base.rbs +95 -0
  133. data/sig/takagi/coap/registries/content_format.rbs +47 -0
  134. data/sig/takagi/coap/registries/message_type.rbs +53 -0
  135. data/sig/takagi/coap/registries/method.rbs +27 -0
  136. data/sig/takagi/coap/registries/option.rbs +43 -0
  137. data/sig/takagi/coap/registries/response.rbs +52 -0
  138. data/sig/takagi/coap.rbs +24 -0
  139. data/sig/takagi/composite_router.rbs +46 -0
  140. data/sig/takagi/config.rbs +134 -0
  141. data/sig/takagi/controller.rbs +73 -0
  142. data/sig/takagi/core/attribute_set.rbs +57 -0
  143. data/sig/takagi/discovery/core_link_format.rbs +50 -0
  144. data/sig/takagi/event_bus/address_prefix.rbs +78 -0
  145. data/sig/takagi/event_bus/async_executor.rbs +88 -0
  146. data/sig/takagi/event_bus/coap_bridge.rbs +93 -0
  147. data/sig/takagi/event_bus/future.rbs +78 -0
  148. data/sig/takagi/event_bus/lru_cache.rbs +86 -0
  149. data/sig/takagi/event_bus/message_buffer.rbs +133 -0
  150. data/sig/takagi/event_bus/observer_cleanup.rbs +62 -0
  151. data/sig/takagi/event_bus.rbs +320 -0
  152. data/sig/takagi/helpers.rbs +34 -0
  153. data/sig/takagi/initializer.rbs +9 -0
  154. data/sig/takagi/logger.rbs +17 -0
  155. data/sig/takagi/message/base.rbs +64 -0
  156. data/sig/takagi/message/deduplication_cache.rbs +49 -0
  157. data/sig/takagi/message/inbound.rbs +76 -0
  158. data/sig/takagi/message/outbound.rbs +48 -0
  159. data/sig/takagi/message/request.rbs +32 -0
  160. data/sig/takagi/message/retransmission_manager.rbs +76 -0
  161. data/sig/takagi/middleware/authentication.rbs +11 -0
  162. data/sig/takagi/middleware/caching.rbs +13 -0
  163. data/sig/takagi/middleware/debugging.rbs +9 -0
  164. data/sig/takagi/middleware/logging.rbs +7 -0
  165. data/sig/takagi/middleware/metrics.rbs +15 -0
  166. data/sig/takagi/middleware/rate_limiting.rbs +13 -0
  167. data/sig/takagi/middleware_stack.rbs +69 -0
  168. data/sig/takagi/network/tcp_sender.rbs +10 -0
  169. data/sig/takagi/network/udp_sender.rbs +14 -0
  170. data/sig/takagi/observe_registry.rbs +36 -0
  171. data/sig/takagi/observer/client.rbs +36 -0
  172. data/sig/takagi/observer/sender.rbs +12 -0
  173. data/sig/takagi/observer/watcher.rbs +18 -0
  174. data/sig/takagi/profiles.rbs +33 -0
  175. data/sig/takagi/reactor.rbs +20 -0
  176. data/sig/takagi/reactor_registry.rbs +14 -0
  177. data/sig/takagi/response_builder.rbs +12 -0
  178. data/sig/takagi/router/metadata_extractor.rbs +71 -0
  179. data/sig/takagi/router/route_matcher.rbs +43 -0
  180. data/sig/takagi/router.rbs +166 -0
  181. data/sig/takagi/serialization.rbs +32 -0
  182. data/sig/takagi/server/multi.rbs +16 -0
  183. data/sig/takagi/server/tcp.rbs +42 -0
  184. data/sig/takagi/server/udp.rbs +52 -0
  185. data/sig/takagi/server/udp_worker.rbs +42 -0
  186. data/sig/takagi/server.rbs +4 -0
  187. data/sig/takagi/server_registry.rbs +71 -0
  188. data/sig/takagi/tcp_client.rbs +23 -0
  189. data/sig/takagi/version.rbs +5 -0
  190. data/takagi.gemspec +37 -35
  191. metadata +204 -31
  192. data/.idea/.gitignore +0 -8
  193. data/.idea/misc.xml +0 -4
  194. data/.idea/modules.xml +0 -8
  195. data/.idea/takagi.iml +0 -81
  196. data/.idea/vcs.xml +0 -6
  197. data/lib/takagi/message.rb +0 -75
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Takagi
4
+ class Router
5
+ # Handles dynamic route matching with parameter extraction.
6
+ #
7
+ # Extracted from Router to follow Single Responsibility Principle.
8
+ # Manages matching URL patterns with dynamic segments (e.g., /users/:id)
9
+ # and extracting parameters from matched routes.
10
+ class RouteMatcher
11
+ # @param logger [Logger] Logger instance for debugging
12
+ def initialize(logger)
13
+ @logger = logger
14
+ end
15
+
16
+ # Matches dynamic routes that contain parameters (e.g., `/users/:id`)
17
+ #
18
+ # @param routes [Hash] Map of route keys to RouteEntry objects
19
+ # @param method [String] HTTP method
20
+ # @param path [String] Request path
21
+ # @return [Array(RouteEntry, Hash), Array(nil, Hash)] Matched route entry and parameters, or [nil, {}]
22
+ def match(routes, method, path)
23
+ matched_route = locate_dynamic_route(routes, method, path)
24
+ return matched_route if matched_route
25
+
26
+ @logger.debug 'No route matched!'
27
+ [nil, {}]
28
+ end
29
+
30
+ private
31
+
32
+ # Locates a dynamic route by iterating through all routes
33
+ #
34
+ # @param routes [Hash] All registered routes
35
+ # @param method [String] HTTP method to match
36
+ # @param path [String] Request path to match
37
+ # @return [Array(RouteEntry, Hash), nil] Matched entry and params, or nil
38
+ def locate_dynamic_route(routes, method, path)
39
+ routes.each_value do |entry|
40
+ route_method = entry.method
41
+ route_path = entry.path
42
+ next unless route_method == method
43
+
44
+ params = extract_dynamic_params(route_path, path)
45
+ next unless params
46
+
47
+ @logger.debug "Match found! Params: #{params.inspect}"
48
+ return [entry, params]
49
+ end
50
+ nil
51
+ end
52
+
53
+ # Extracts dynamic parameters from a route pattern
54
+ #
55
+ # Compares route pattern (e.g., /users/:id) with actual path (e.g., /users/123)
56
+ # and extracts parameter values.
57
+ #
58
+ # @param route_path [String] Route pattern with :param segments
59
+ # @param path [String] Actual request path
60
+ # @return [Hash, nil] Extracted parameters or nil if no match
61
+ def extract_dynamic_params(route_path, path)
62
+ route_parts = route_path.split('/')
63
+ path_parts = path.split('/')
64
+ return unless route_parts.length == path_parts.length
65
+
66
+ params = {}
67
+ matched = true
68
+
69
+ route_parts.each_with_index do |part, index|
70
+ if part.start_with?(':')
71
+ params[part[1..].to_sym] = path_parts[index]
72
+ elsif part != path_parts[index]
73
+ @logger.debug "No Match found! Params: #{params.inspect} to #{path}"
74
+ matched = false
75
+ break
76
+ end
77
+ end
78
+
79
+ matched ? params : nil
80
+ end
81
+ end
82
+ end
83
+ end
data/lib/takagi/router.rb CHANGED
@@ -1,47 +1,306 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'forwardable'
4
+ require_relative 'core/attribute_set'
5
+ require_relative 'helpers'
6
+ require_relative 'router/route_matcher'
7
+ require_relative 'router/metadata_extractor'
8
+ require_relative 'hooks'
9
+
3
10
  module Takagi
4
11
  class Router
5
- @@routes = {}
12
+ DEFAULT_CONTENT_FORMAT = Takagi::CoAP::Registries::ContentFormat::JSON
13
+
14
+ class << self
15
+ # Global singleton instance for backward compatibility with Takagi::Base
16
+ # New code should create Router instances directly
17
+ #
18
+ # @return [Router] The global router instance
19
+ def instance
20
+ @instance ||= new
21
+ end
22
+
23
+ # Reset the global instance (primarily for testing)
24
+ #
25
+ # @return [void]
26
+ def reset!
27
+ @instance = nil
28
+ # Ensure Takagi::Base fetches a fresh router after reset
29
+ Takagi::Base.instance_variable_set(:@router, nil) if defined?(Takagi::Base)
30
+ end
31
+ end
32
+
33
+ # Represents a registered route with its handler and CoRE Link Format metadata
34
+ class RouteEntry
35
+ attr_reader :method, :path, :block, :receiver, :attribute_set
36
+
37
+ def initialize(method:, path:, block:, metadata: {}, receiver: nil)
38
+ @method = method
39
+ @path = path
40
+ @block = block
41
+ @receiver = receiver
42
+ @attribute_set = Core::AttributeSet.new(metadata)
43
+ end
44
+
45
+ # Returns the underlying metadata hash for backward compatibility
46
+ def metadata
47
+ @attribute_set.metadata
48
+ end
49
+
50
+ # Configure CoRE Link Format attributes using DSL block
51
+ #
52
+ # @example
53
+ # entry.configure_attributes do
54
+ # rt 'sensor'
55
+ # obs true
56
+ # ct 'application/json'
57
+ # end
58
+ def configure_attributes(&block)
59
+ @attribute_set.core(&block)
60
+ @attribute_set.apply!
61
+ end
62
+
63
+ # Support for dup operation (used in discovery)
64
+ def initialize_copy(original)
65
+ super
66
+ @attribute_set = Core::AttributeSet.new(original.metadata.dup)
67
+ end
68
+ end
69
+
70
+ # Provides the execution context for route handlers, exposing helper
71
+ # methods for configuring CoRE Link Format attributes via a small DSL.
72
+ class RouteContext
73
+ extend Forwardable
74
+ include Takagi::Helpers
75
+
76
+ attr_reader :request, :params
77
+
78
+ # Delegate CoRE attribute methods to @core_attributes
79
+ def_delegators :@core_attributes, :core, :metadata, :attribute
80
+ def_delegators :@core_attributes, :ct, :sz, :title, :obs, :rt, :interface
81
+
82
+ # Aliases for common methods
83
+ alias content_format ct
84
+ alias observable obs
85
+ alias if_ interface
86
+
87
+ def initialize(entry, request, params, receiver)
88
+ @entry = entry
89
+ @request = request
90
+ @params = params
91
+ @receiver = receiver
92
+ # Create a fresh AttributeSet for this request to avoid cross-request state sharing
93
+ # Initialize it with a copy of the entry's current metadata
94
+ @core_attributes = Core::AttributeSet.new(@entry.metadata.dup)
95
+ end
96
+
97
+ def run(block)
98
+ return unless block
99
+
100
+ args = case block.arity
101
+ when 0 then []
102
+ when 1 then [request]
103
+ else
104
+ [request, params]
105
+ end
106
+ args = [request, params] if block.arity.negative?
107
+
108
+ # Support halt for early returns
109
+ result = catch(:halt) do
110
+ instance_exec(*args, &block)
111
+ end
112
+
113
+ result
114
+ ensure
115
+ @core_attributes.apply!
116
+ end
117
+
118
+ private
119
+
120
+ # Returns content-format(s) declared for this route (CoRE `ct` metadata).
121
+ # Used by response helpers to align runtime negotiation with discovery data.
122
+ def core_content_formats
123
+ formats = @entry&.metadata&.[](:ct)
124
+ Array(formats).compact unless formats.nil?
125
+ end
126
+
127
+ # Delegates method calls to the receiver (application instance)
128
+ # This allows route handlers to call application methods within their blocks
129
+ # Example: get '/users' do; fetch_users; end - calls application's fetch_users method
130
+ def method_missing(name, ...)
131
+ if @receiver.respond_to?(name)
132
+ @receiver.public_send(name, ...)
133
+ else
134
+ super
135
+ end
136
+ end
137
+
138
+ # Required pair for method_missing to properly support respond_to?
139
+ def respond_to_missing?(name, include_private = false)
140
+ @receiver.respond_to?(name, include_private) || super
141
+ end
142
+ end
6
143
 
7
- def self.get(path, &block)
8
- @@routes["GET #{path}"] = block
144
+ def initialize
145
+ @routes = {}
146
+ @routes_mutex = Mutex.new # Protects route modifications in multithreaded environments
147
+ @logger = Takagi.logger
148
+ @route_matcher = RouteMatcher.new(@logger)
149
+ @metadata_extractor = MetadataExtractor.new(@logger)
9
150
  end
10
151
 
11
- def self.post(path, &block)
12
- @@routes["POST #{path}"] = block
152
+ # Registers a new route for a given HTTP method and path
153
+ # @param method [String] The HTTP method (GET, POST, etc.)
154
+ # @param path [String] The URL path, can include dynamic segments like `:id`
155
+ # @param block [Proc] The handler to be executed when the route is matched
156
+ def add_route(method, path, metadata: {}, &block)
157
+ @routes_mutex.synchronize do
158
+ entry = build_route_entry(method, path, metadata, block)
159
+ @routes["#{method} #{path}"] = entry
160
+ @logger.debug "Add new route: #{method} #{path}"
161
+
162
+ # Extract metadata from core blocks inside the handler
163
+ extract_metadata_from_handler(entry) if block
164
+
165
+ Takagi::Hooks.emit(
166
+ :router_route_added,
167
+ method: method,
168
+ path: path,
169
+ entry: entry
170
+ )
171
+ end
13
172
  end
14
173
 
15
- def self.find_route(method, path)
16
- return @@routes["#{method} #{path}"], {} if @@routes.key?("#{method} #{path}")
174
+ # Dynamically define route registration methods from CoAP::Registries::Method registry
175
+ # Generates: get, post, put, delete, fetch, etc.
176
+ CoAP::Registries::Method.all.each_value do |method_name|
177
+ method_string = method_name.split.first # Extract 'GET' from 'GET'
178
+ method_symbol = method_string.downcase.to_sym
17
179
 
18
- match_dynamic_route(method, path)
180
+ define_method(method_symbol) do |path, metadata: {}, &block|
181
+ add_route(method_string, path, metadata: metadata, &block)
182
+ end
19
183
  end
20
184
 
21
- def self.match_dynamic_route(method, path)
22
- @@routes.each do |route_key, block|
23
- route_method, route_path = route_key.split(" ", 2)
24
- next unless route_method == method
185
+ # Registers a OBSERVE route
186
+ # @param path [String] The URL path
187
+ # @param block [Proc] The handler function
188
+ def observable(path, metadata: {}, &block)
189
+ observable_metadata = { obs: true, rt: 'core#observable', if: 'takagi.observe' }
190
+ add_route('OBSERVE', path, metadata: observable_metadata.merge(metadata), &block)
191
+ end
192
+
193
+ def all_routes
194
+ @routes.values.map { |entry| "#{entry.method} #{entry.path}" }
195
+ end
25
196
 
26
- route_parts = route_path.split("/")
27
- path_parts = path.split("/")
28
- next unless route_parts.length == path_parts.length
197
+ def find_observable(path)
198
+ @routes.values.find { |entry| entry.method == 'OBSERVE' && entry.path == path }
199
+ end
29
200
 
201
+ # Finds a registered route for a given method and path
202
+ # @param method [String] HTTP method
203
+ # @param path [String] URL path
204
+ # @return [Proc, Hash] The matching handler and extracted parameters
205
+ def find_route(method, path)
206
+ @routes_mutex.synchronize do
207
+ @logger.debug "Routes: #{@routes.inspect}"
208
+ @logger.debug "Looking for route: #{method} #{path}"
209
+ entry = @routes["#{method} #{path}"]
30
210
  params = {}
31
- match = route_parts.each_with_index.all? do |part, index|
32
- if part.start_with?(":")
33
- param_name = part[1..]
34
- params[param_name.to_sym] = path_parts[index]
35
- true
36
- else
37
- part == path_parts[index]
38
- end
211
+
212
+ return wrap_block(entry), params if entry
213
+
214
+ @logger.debug '[Debug] Find dynamic route'
215
+ entry, params = match_dynamic_route(method, path)
216
+
217
+ return wrap_block(entry), params if entry
218
+
219
+ [nil, {}]
220
+ end
221
+ end
222
+
223
+ def link_format_entries
224
+ @routes_mutex.synchronize do
225
+ @routes.values.reject { |entry| entry.metadata[:discovery] }.map(&:dup)
226
+ end
227
+ end
228
+
229
+ # Applies CoRE metadata outside the request cycle. Useful for boot time
230
+ # configuration where the DSL block does not have a live request object.
231
+ def configure_core(method, path, &block)
232
+ return unless block
233
+
234
+ @routes_mutex.synchronize do
235
+ entry = @routes["#{method} #{path}"]
236
+ unless entry
237
+ @logger.warn "configure_core skipped: #{method} #{path} not registered"
238
+ return
39
239
  end
40
240
 
41
- return [block, params] if match
241
+ entry.configure_attributes(&block)
42
242
  end
243
+ end
244
+
245
+ private
246
+
247
+ def wrap_block(entry)
248
+ block = entry&.block
249
+ return nil unless block
250
+
251
+ lambda do |req, params = {}|
252
+ context = RouteContext.new(entry, req, params, entry.receiver)
253
+ context.run(block)
254
+ end
255
+ end
256
+
257
+ # Matches dynamic routes that contain parameters (e.g., `/users/:id`)
258
+ # Delegates to RouteMatcher for the actual matching logic
259
+ # @param method [String] HTTP method
260
+ # @param path [String] Request path
261
+ # @return [Array(RouteEntry, Hash)] Matched route entry and extracted parameters
262
+ def match_dynamic_route(method, path)
263
+ @route_matcher.match(@routes, method, path)
264
+ end
265
+
266
+ def build_route_entry(method, path, metadata, block)
267
+ RouteEntry.new(
268
+ method: method,
269
+ path: path,
270
+ block: block,
271
+ metadata: normalize_metadata(method, path, metadata),
272
+ receiver: block&.binding&.receiver
273
+ )
274
+ end
275
+
276
+ # Normalizes route metadata with sensible defaults for CoRE Link Format
277
+ #
278
+ # @param method [String] HTTP-like method (GET, POST, OBSERVE, etc.)
279
+ # @param path [String] Route path
280
+ # @param metadata [Hash, nil] User-provided metadata
281
+ # @return [Hash] Normalized metadata with defaults applied
282
+ def normalize_metadata(method, path, metadata)
283
+ normalized = (metadata || {}).transform_keys(&:to_sym)
284
+ normalized[:rt] ||= default_resource_type(method)
285
+ normalized[:if] ||= default_interface(method)
286
+ normalized[:ct] = DEFAULT_CONTENT_FORMAT unless normalized.key?(:ct)
287
+ normalized[:title] ||= "#{method} #{path}"
288
+ normalized
289
+ end
290
+
291
+ def default_resource_type(method)
292
+ method == 'OBSERVE' ? 'core#observable' : 'core#endpoint'
293
+ end
294
+
295
+ def default_interface(method)
296
+ method == 'OBSERVE' ? 'takagi.observe' : "takagi.#{method.downcase}"
297
+ end
43
298
 
44
- [nil, {}]
299
+ # Executes route handler in metadata extraction mode to capture core block attributes
300
+ # Delegates to MetadataExtractor for the actual extraction logic
301
+ # @param entry [RouteEntry] The route entry to extract metadata from
302
+ def extract_metadata_from_handler(entry)
303
+ @metadata_extractor.extract(entry)
45
304
  end
46
305
  end
47
306
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Takagi
4
+ module Serialization
5
+ # Base interface for all content-format serializers
6
+ #
7
+ # Serializers implement encoding/decoding logic for specific content-formats.
8
+ # Each serializer handles one CoAP content-format (MIME type).
9
+ #
10
+ # @example Implementing a custom serializer
11
+ # class XmlSerializer < Takagi::Serialization::Base
12
+ # def encode(data)
13
+ # # Convert Ruby object to XML bytes
14
+ # end
15
+ #
16
+ # def decode(bytes)
17
+ # # Parse XML bytes to Ruby object
18
+ # end
19
+ #
20
+ # def content_type
21
+ # 'application/xml'
22
+ # end
23
+ #
24
+ # def content_format_code
25
+ # 41 # CoAP XML content-format
26
+ # end
27
+ # end
28
+ #
29
+ # @abstract Subclass and implement all methods
30
+ class Base
31
+ # Encode Ruby object to bytes
32
+ #
33
+ # @param data [Object] Ruby object to encode
34
+ # @return [String] Binary string (ASCII-8BIT encoding)
35
+ # @raise [EncodeError] if encoding fails
36
+ #
37
+ # @example
38
+ # serializer.encode({ temp: 25 }) # => "\x{...}"
39
+ def encode(data)
40
+ raise NotImplementedError, "#{self.class}#encode must be implemented"
41
+ end
42
+
43
+ # Decode bytes to Ruby object
44
+ #
45
+ # @param bytes [String] Binary string to decode
46
+ # @return [Object] Decoded Ruby object
47
+ # @raise [DecodeError] if decoding fails
48
+ #
49
+ # @example
50
+ # serializer.decode("\x{...}") # => { temp: 25 }
51
+ def decode(bytes)
52
+ raise NotImplementedError, "#{self.class}#decode must be implemented"
53
+ end
54
+
55
+ # MIME type this serializer handles
56
+ #
57
+ # @return [String] MIME type (e.g., 'application/json')
58
+ #
59
+ # @example
60
+ # serializer.content_type # => 'application/json'
61
+ def content_type
62
+ raise NotImplementedError, "#{self.class}#content_type must be implemented"
63
+ end
64
+
65
+ # CoAP content-format code
66
+ #
67
+ # @return [Integer] CoAP content-format code from RFC 7252 §12.3
68
+ #
69
+ # @example
70
+ # serializer.content_format_code # => 50 (JSON)
71
+ def content_format_code
72
+ raise NotImplementedError, "#{self.class}#content_format_code must be implemented"
73
+ end
74
+
75
+ # Check if this serializer can handle binary data
76
+ #
77
+ # @return [Boolean] true if binary format
78
+ def binary?
79
+ false
80
+ end
81
+
82
+ # Human-readable name for this serializer
83
+ #
84
+ # @return [String] Serializer name
85
+ def name
86
+ self.class.name.split('::').last.sub(/Serializer$/, '')
87
+ end
88
+ end
89
+
90
+ # Raised when encoding fails
91
+ class EncodeError < StandardError; end
92
+
93
+ # Raised when decoding fails
94
+ class DecodeError < StandardError; end
95
+
96
+ # Raised when content-format is not registered
97
+ class UnknownFormatError < StandardError; end
98
+
99
+ # Raised when serializer implementation is invalid
100
+ class InvalidSerializerError < StandardError; end
101
+ end
102
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Takagi
4
+ module Serialization
5
+ # CBOR serializer (RFC 8949)
6
+ #
7
+ # Handles application/cbor content-format (code 60).
8
+ # Encodes Ruby objects to CBOR binary format and decodes CBOR to Ruby objects.
9
+ #
10
+ # Uses Takagi's built-in CBOR implementation for zero external dependencies.
11
+ #
12
+ # @example Encoding
13
+ # serializer = CborSerializer.new
14
+ # bytes = serializer.encode({ temp: 25 }) # => "\xA1dtemp\x18\x19"
15
+ #
16
+ # @example Decoding
17
+ # data = serializer.decode("\xA1dtemp\x18\x19") # => { "temp" => 25 }
18
+ class CborSerializer < Base
19
+ # Encode Ruby object to CBOR bytes
20
+ #
21
+ # @param data [Object] Ruby object to encode
22
+ # @return [String] CBOR binary string
23
+ # @raise [EncodeError] if encoding fails
24
+ #
25
+ # @example Hash encoding
26
+ # encode({ temp: 25 }) # => CBOR bytes
27
+ #
28
+ # @example Array encoding
29
+ # encode([1, 2, 3]) # => CBOR bytes
30
+ #
31
+ # @example Supported types
32
+ # - Integer (signed/unsigned, up to 64-bit)
33
+ # - Float (64-bit IEEE 754)
34
+ # - String (UTF-8)
35
+ # - Array
36
+ # - Hash
37
+ # - true, false, nil
38
+ # - Time (as timestamp)
39
+ def encode(data)
40
+ return ''.b if data.nil? || data == ''
41
+
42
+ CBOR::Encoder.encode(data)
43
+ rescue CBOR::EncodeError => e
44
+ raise EncodeError, "CBOR encoding failed: #{e.message}"
45
+ rescue StandardError => e
46
+ raise EncodeError, "CBOR encoding error: #{e.message}"
47
+ end
48
+
49
+ # Decode CBOR bytes to Ruby object
50
+ #
51
+ # @param bytes [String] CBOR binary data
52
+ # @return [Object] Decoded Ruby object (Hash, Array, Integer, etc.)
53
+ # @raise [DecodeError] if decoding fails
54
+ #
55
+ # @example Object decoding
56
+ # decode(cbor_bytes) # => { "temp" => 25 }
57
+ #
58
+ # @example Array decoding
59
+ # decode(cbor_bytes) # => [1, 2, 3]
60
+ def decode(bytes)
61
+ return nil if bytes.nil? || bytes.empty?
62
+
63
+ CBOR::Decoder.decode(bytes)
64
+ rescue CBOR::DecodeError => e
65
+ raise DecodeError, "CBOR decoding failed: #{e.message}"
66
+ rescue StandardError => e
67
+ raise DecodeError, "CBOR decoding error: #{e.message}"
68
+ end
69
+
70
+ # MIME type for CBOR
71
+ #
72
+ # @return [String] 'application/cbor'
73
+ def content_type
74
+ 'application/cbor'
75
+ end
76
+
77
+ # CoAP content-format code for CBOR
78
+ #
79
+ # @return [Integer] 60 (RFC 7049)
80
+ def content_format_code
81
+ CoAP::Registries::ContentFormat::CBOR
82
+ end
83
+
84
+ # CBOR is a binary format
85
+ #
86
+ # @return [Boolean] true
87
+ def binary?
88
+ true
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Takagi
6
+ module Serialization
7
+ # JSON serializer (RFC 8259)
8
+ #
9
+ # Handles application/json content-format (code 50).
10
+ # Encodes Ruby objects to JSON and decodes JSON to Ruby objects.
11
+ #
12
+ # @example Encoding
13
+ # serializer = JsonSerializer.new
14
+ # bytes = serializer.encode({ temp: 25 }) # => "{\"temp\":25}"
15
+ #
16
+ # @example Decoding
17
+ # data = serializer.decode("{\"temp\":25}") # => { "temp" => 25 }
18
+ class JsonSerializer < Base
19
+ # Encode Ruby object to JSON bytes
20
+ #
21
+ # @param data [Object] Ruby object to encode
22
+ # @return [String] JSON string as binary
23
+ # @raise [EncodeError] if encoding fails
24
+ #
25
+ # @example Hash encoding
26
+ # encode({ temp: 25 }) # => "{\"temp\":25}"
27
+ #
28
+ # @example Array encoding
29
+ # encode([1, 2, 3]) # => "[1,2,3]"
30
+ #
31
+ # @example String pass-through
32
+ # encode("hello") # => "hello"
33
+ def encode(data)
34
+ return ''.b if data.nil? || data == ''
35
+
36
+ result = case data
37
+ when String
38
+ # Already a string, assume it's valid
39
+ data
40
+ when Hash, Array
41
+ # Structured data - convert to JSON
42
+ JSON.generate(data)
43
+ else
44
+ # Other objects - try to_json
45
+ data.to_json
46
+ end
47
+
48
+ result.b
49
+ rescue StandardError => e
50
+ raise EncodeError, "JSON encoding failed: #{e.message}"
51
+ end
52
+
53
+ # Decode JSON bytes to Ruby object
54
+ #
55
+ # @param bytes [String] JSON bytes to decode
56
+ # @return [Object, nil] Decoded Ruby object (Hash, Array, String, etc.) or nil if invalid
57
+ #
58
+ # @example Object decoding
59
+ # decode("{\"temp\":25}") # => { "temp" => 25 }
60
+ #
61
+ # @example Array decoding
62
+ # decode("[1,2,3]") # => [1, 2, 3]
63
+ #
64
+ # @example Invalid JSON
65
+ # decode("{invalid}") # => nil
66
+ def decode(bytes)
67
+ return nil if bytes.nil? || bytes.empty?
68
+
69
+ JSON.parse(bytes)
70
+ rescue JSON::ParserError => e
71
+ nil # Return nil for invalid JSON
72
+ end
73
+
74
+ # MIME type for JSON
75
+ #
76
+ # @return [String] 'application/json'
77
+ def content_type
78
+ 'application/json'
79
+ end
80
+
81
+ # CoAP content-format code for JSON
82
+ #
83
+ # @return [Integer] 50 (RFC 7252 §12.3)
84
+ def content_format_code
85
+ CoAP::Registries::ContentFormat::JSON
86
+ end
87
+
88
+ # JSON is text-based, not binary
89
+ #
90
+ # @return [Boolean] false
91
+ def binary?
92
+ false
93
+ end
94
+ end
95
+ end
96
+ end