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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'response_builder'
4
+
5
+ module Takagi
6
+ # Helper methods for route handlers to improve DX
7
+ #
8
+ # Dynamically generates response helper methods from CoAP::Registries::Response registry:
9
+ # - Success methods (2.xx): created(data = {}), changed(data = {}), etc.
10
+ # - Error methods (4.xx, 5.xx): bad_request(message = ...), not_found(message = ...), etc.
11
+ #
12
+ # @example Success response
13
+ # created({ id: 123, name: 'Resource' })
14
+ #
15
+ # @example Error response
16
+ # bad_request('Invalid input')
17
+ # unauthorized({ error: 'Token expired' })
18
+ module Helpers
19
+ # Respond with content-format negotiation handled automatically.
20
+ #
21
+ # @param payload [Object] The payload to send
22
+ # @param code [Integer, String, Symbol] CoAP response code (defaults to 2.05 Content)
23
+ # @param formats [Array<Integer, Symbol, String>, nil] Allowed content-formats (defaults to router default)
24
+ # @param force [Integer, Symbol, String, nil] Force a specific content-format code
25
+ # @param options [Hash] Additional CoAP options
26
+ def respond(payload = {}, code: CoAP::Registries::Response::CONTENT, formats: nil, force: nil, options: {})
27
+ formats ||= core_content_formats if respond_to?(:core_content_formats, true)
28
+ ResponseBuilder.respond(request, payload, code: code, formats: formats, force: force, options: options, logger: Takagi.logger)
29
+ end
30
+
31
+ # Returns a JSON response with 2.05 Content status and sets Content-Format to application/json
32
+ # Similar to Sinatra's json helper, automatically sets the Content-Format option
33
+ # @param data [Hash] The data to return as JSON
34
+ # @return [Takagi::Message::Outbound] The response with JSON content-format
35
+ def json(data = {})
36
+ respond(data, code: CoAP::Registries::Response.value_for(:content), formats: [Takagi::Router::DEFAULT_CONTENT_FORMAT])
37
+ end
38
+
39
+ # Validates that required parameters are present
40
+ # @param required_params [Array<Symbol>] List of required parameter names
41
+ # @raise [StandardError] If any required parameter is missing
42
+ def validate_params(*required_params)
43
+ missing = required_params.select { |param| params[param].nil? }
44
+ return if missing.empty?
45
+
46
+ raise ArgumentError, "Missing required parameters: #{missing.join(', ')}"
47
+ end
48
+
49
+ # Halts execution and returns the given response
50
+ # Useful for early returns
51
+ # @param response [Takagi::Message::Outbound, Hash] The response to return
52
+ def halt(response)
53
+ throw :halt, response
54
+ end
55
+
56
+ # Dynamically generate helper methods from CoAP::Registries::Response registry
57
+ # Iterate through all registered response codes
58
+ CoAP::Registries::Response.each_value do |code_number|
59
+ code_string = CoAP::Registries::Response.name_for(code_number)
60
+ metadata = CoAP::Registries::Response.metadata_for(code_number)
61
+ next unless metadata
62
+
63
+ symbol = metadata[:symbol]
64
+ next unless symbol
65
+
66
+ method_name = symbol.to_s
67
+
68
+ # Determine if this is a success (2.xx) or error (4.xx, 5.xx) response
69
+ if CoAP::Registries::Response.success?(code_number)
70
+ # Success methods take optional data hash
71
+ define_method(method_name) do |data = {}, options = {}|
72
+ respond(data, code: code_string, options: options)
73
+ end
74
+ else
75
+ # Error methods take optional message (string or hash)
76
+ default_message = code_string.split(' ', 2)[1] # Extract "Bad Request" from "4.00 Bad Request"
77
+
78
+ define_method(method_name) do |message = default_message|
79
+ data = message.is_a?(Hash) ? message : { error: message }
80
+ respond(data, code: code_string)
81
+ end
82
+ end
83
+ end
84
+
85
+ # Alias for internal_server_error (more concise)
86
+ alias server_error internal_server_error
87
+ end
88
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Takagi
4
+ # Lightweight hook dispatcher used by the plugin system to observe internal events.
5
+ #
6
+ # Hooks are intentionally simple: subscribe with a callable, emit with a hash payload.
7
+ # Errors in subscribers are caught and logged to avoid cascading failures.
8
+ module Hooks
9
+ @subscribers = Hash.new { |h, k| h[k] = [] } # Fallback when EventBus unavailable
10
+ @mutex = Mutex.new
11
+
12
+ class << self
13
+ # Register a handler for a given event symbol.
14
+ #
15
+ # @param event [Symbol] event name
16
+ # @param handler [#call] callable that receives payload hash
17
+ # @yield [Hash] payload if block given instead of handler
18
+ # @return [#call] the handler reference (useful for unsubscribe)
19
+ def subscribe(event, handler = nil, &block)
20
+ callback = handler || block
21
+ raise ArgumentError, 'handler or block required' unless callback
22
+
23
+ if event_bus_ready?
24
+ Takagi::EventBus.consumer(hook_address(event), local_only: true) do |message|
25
+ callback.call(message.body)
26
+ end
27
+ else
28
+ @mutex.synchronize { @subscribers[event] << callback }
29
+ callback
30
+ end
31
+ end
32
+
33
+ # Remove a previously registered handler.
34
+ #
35
+ # @param event [Symbol]
36
+ # @param handler [#call]
37
+ def unsubscribe(event, handler)
38
+ if event_bus_ready?
39
+ Takagi::EventBus.unregister(handler)
40
+ else
41
+ @mutex.synchronize { @subscribers[event].delete(handler) }
42
+ end
43
+ end
44
+
45
+ # Emit an event with a payload hash to all subscribers.
46
+ #
47
+ # @param event [Symbol]
48
+ # @param payload [Hash]
49
+ def emit(event, payload = {})
50
+ if event_bus_ready?
51
+ Takagi::EventBus.publish(hook_address(event), payload, freeze_body: false, scope: Takagi::EventBus::Scope::LOCAL)
52
+ else
53
+ handlers = @mutex.synchronize { @subscribers[event].dup }
54
+ return if handlers.empty?
55
+
56
+ handlers.each do |handler|
57
+ handler.call(payload)
58
+ rescue StandardError => e
59
+ begin
60
+ Takagi.logger.warn("Hook #{event} handler error: #{e.message}")
61
+ rescue StandardError
62
+ # Logger may not be initialized yet; swallow errors silently.
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ def hook_address(event)
69
+ "hooks.#{event}"
70
+ end
71
+
72
+ def event_bus_ready?
73
+ return false unless defined?(Takagi::EventBus)
74
+
75
+ executor = Takagi::EventBus.instance_variable_get(:@executor) rescue nil
76
+ return false if executor && executor.respond_to?(:running?) && !executor.running?
77
+
78
+ true
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Takagi
4
+ # Let's do some initialization
5
+ class Initializer
6
+ # Runs the initialization logic (e.g., loading configurations, setting up databases)
7
+ def self.run!
8
+ load_initializers
9
+ end
10
+
11
+ def self.load_initializers
12
+ initializer_glob = File.expand_path('../../config/initializers/**/*.rb', __dir__)
13
+ Dir.glob(initializer_glob).each do |initializer|
14
+ require initializer
15
+ end
16
+ end
17
+ end
18
+ end
data/lib/takagi/logger.rb CHANGED
@@ -4,22 +4,31 @@ require 'logger'
4
4
 
5
5
  module Takagi
6
6
  class Logger
7
- @logger = ::Logger.new($stdout)
8
- @logger.level = ::Logger::INFO
7
+ # Default is INFO (production-friendly). Set the
8
+ # `TAKAGI_LOG_LEVEL` env var or pass a takagi.yml `logger:` block
9
+ # to see the verbose DEBUG noise.
10
+ def initialize(log_output: $stdout, level: ::Logger::INFO)
11
+ @logger = ::Logger.new(log_output)
12
+ @logger.level = level
13
+ end
9
14
 
10
- def self.set_level(level)
15
+ def set_level(level)
11
16
  @logger.level = level
12
17
  end
13
18
 
14
- def self.info(message)
19
+ def info(message)
15
20
  @logger.info(message)
16
21
  end
17
22
 
18
- def self.debug(message)
23
+ def warn(message)
24
+ @logger.warn(message)
25
+ end
26
+
27
+ def debug(message)
19
28
  @logger.debug(message)
20
29
  end
21
30
 
22
- def self.error(message)
31
+ def error(message)
23
32
  @logger.error(message)
24
33
  end
25
34
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Takagi
4
+ module Message
5
+ # Base class for message
6
+ class Base
7
+ attr_reader :version, :type, :token, :message_id, :payload, :options, :code
8
+
9
+ def initialize(data = nil, transport: :udp)
10
+ @transport = transport
11
+ if data.is_a?(String) || data.is_a?(IO)
12
+ case transport
13
+ when :tcp
14
+ parse_tcp(data)
15
+ else
16
+ parse(data)
17
+ end
18
+ end
19
+ @data = data
20
+ @logger = Takagi.logger
21
+ end
22
+
23
+ # Convert CoAP code number to method name using registry
24
+ # @param code [Integer] CoAP code number
25
+ # @return [String] Method name (e.g., 'GET', 'OBSERVE')
26
+ def coap_code_to_method(code)
27
+ # Check if it's an OBSERVE request (GET with Observe option)
28
+ if code == CoAP::Registries::Method::GET && @options && @options[CoAP::Registries::Option::OBSERVE]
29
+ 'OBSERVE'
30
+ else
31
+ # Use CoAP registry to convert code to string
32
+ CoAP::CodeHelpers.to_string(code)
33
+ end
34
+ end
35
+
36
+ # Convert method name to CoAP code number using registry
37
+ # @param method [String, Symbol] Method name (e.g., 'GET', :post)
38
+ # @return [Integer] CoAP code number
39
+ def coap_method_to_code(method)
40
+ CoAP::CodeHelpers.to_numeric(method)
41
+ end
42
+
43
+ private
44
+
45
+ # Parse TCP CoAP message (RFC 8323 §3.2)
46
+ # Format: Len+TKL (1 byte) | Code (1 byte) | Token (TKL bytes) | Options | Payload
47
+ def parse_tcp(data)
48
+ bytes = data.bytes
49
+ first_byte = bytes[0]
50
+
51
+ # First byte contains length nibble (upper 4 bits) and TKL (lower 4 bits)
52
+ # But the length nibble is only used for framing, not parsing the message itself
53
+ token_length = first_byte & 0b1111
54
+
55
+ @code = bytes[1]
56
+ @token = token_length.positive? ? bytes[2, token_length].pack('C*') : ''.b
57
+
58
+ # Parse options starting after code + token
59
+ @options = parse_options(bytes[(2 + token_length)..])
60
+ @payload = extract_payload(data)
61
+
62
+ # TCP CoAP doesn't have version, type, or message_id
63
+ @version = nil
64
+ @type = nil
65
+ @message_id = nil
66
+ end
67
+
68
+ def parse(data)
69
+ bytes = data.bytes
70
+ @version = (bytes[0] >> 6) & 0b11
71
+ @type = (bytes[0] >> 4) & 0b11
72
+ token_length = bytes[0] & 0b1111
73
+ @code = bytes[1]
74
+ @message_id = bytes[2..3].pack('C*').unpack1('n')
75
+ @token = token_length.positive? ? bytes[4, token_length].pack('C*') : ''.b
76
+ @options = parse_options(bytes[(4 + token_length)..])
77
+ @payload = extract_payload(data)
78
+ end
79
+
80
+ def parse_options(bytes)
81
+ options = {}
82
+ position = 0
83
+ last_option_number = 0
84
+
85
+ while position < bytes.length && bytes[position] != 0xFF
86
+ byte = bytes[position]
87
+ position += 1
88
+
89
+ delta_raw = (byte >> 4) & 0x0F
90
+ length_raw = byte & 0x0F
91
+
92
+ delta, position = decode_extended_value(bytes, position, delta_raw)
93
+ length, position = decode_extended_value(bytes, position, length_raw)
94
+
95
+ option_number = last_option_number + delta
96
+ value = bytes[position, length].pack('C*')
97
+ position += length
98
+
99
+ store_option(options, option_number, value)
100
+
101
+ last_option_number = option_number
102
+ end
103
+
104
+ Takagi.logger.debug "Parsed CoAP options: #{options.inspect}"
105
+ options
106
+ end
107
+
108
+ def extract_payload(data)
109
+ Takagi.logger.debug "Extracting payload: #{data.inspect}"
110
+ payload_start = data.index("\xFF".b)
111
+ return nil unless payload_start
112
+
113
+ payload = data[(payload_start + 1)..].dup.force_encoding('ASCII-8BIT')
114
+ utf8 = payload.dup.force_encoding('UTF-8')
115
+ utf8.valid_encoding? ? utf8 : payload
116
+ end
117
+
118
+ def decode_extended_value(bytes, position, raw_value)
119
+ case raw_value
120
+ when 13
121
+ [bytes[position] + 13, position + 1]
122
+ when 14
123
+ extended = bytes[position, 2].pack('C*').unpack1('n') + 269
124
+ [extended, position + 2]
125
+ else
126
+ [raw_value, position]
127
+ end
128
+ end
129
+
130
+ def store_option(options, option_number, value)
131
+ formatted = coerce_option_value(value)
132
+
133
+ # Uri-Path (11) and Uri-Query (15) are always stored as arrays
134
+ case option_number
135
+ when CoAP::Registries::Option::URI_PATH, CoAP::Registries::Option::URI_QUERY
136
+ options[option_number] ||= []
137
+ options[option_number] << formatted
138
+ else
139
+ options[option_number] = if options.key?(option_number)
140
+ Array(options[option_number]) << formatted
141
+ else
142
+ formatted
143
+ end
144
+ end
145
+ end
146
+
147
+ def coerce_option_value(value)
148
+ ascii = value.dup.force_encoding('ASCII-8BIT')
149
+ return ascii.force_encoding('UTF-8') if ascii.valid_encoding?
150
+
151
+ ascii
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Takagi
4
+ module Message
5
+ # Implements message deduplication as per RFC 7252 Section 4.4
6
+ #
7
+ # The server MUST detect duplicates by matching both Message ID and source endpoint.
8
+ # When a duplicate CON message is received, the server MUST resend the cached response.
9
+ #
10
+ # Cache entries expire after EXCHANGE_LIFETIME (247 seconds per RFC 7252 §4.8.2)
11
+ class DeduplicationCache
12
+ # RFC 7252 §4.8.2: EXCHANGE_LIFETIME = 247 seconds
13
+ EXCHANGE_LIFETIME = 247
14
+
15
+ # Entry contains the cached response and metadata
16
+ CacheEntry = Struct.new(:response_data, :timestamp, :source_key) do
17
+ def expired?(current_time)
18
+ current_time - timestamp > EXCHANGE_LIFETIME
19
+ end
20
+ end
21
+
22
+ def initialize
23
+ @cache = {}
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ # Check if message is a duplicate and return cached response if available
28
+ # @param message_id [Integer] The CoAP Message ID
29
+ # @param source_endpoint [String] Source IP:Port identifier
30
+ # @return [String, nil] Cached response data or nil if not a duplicate
31
+ def check_duplicate(message_id, source_endpoint)
32
+ @mutex.synchronize do
33
+ cleanup_expired_entries
34
+ key = cache_key(message_id, source_endpoint)
35
+ entry = @cache[key]
36
+ entry&.response_data
37
+ end
38
+ end
39
+
40
+ # Store a response for future duplicate detection
41
+ # @param message_id [Integer] The CoAP Message ID
42
+ # @param source_endpoint [String] Source IP:Port identifier
43
+ # @param response_data [String] The serialized response to cache
44
+ def store_response(message_id, source_endpoint, response_data)
45
+ @mutex.synchronize do
46
+ key = cache_key(message_id, source_endpoint)
47
+ @cache[key] = CacheEntry.new(
48
+ response_data,
49
+ Time.now.to_f,
50
+ source_endpoint
51
+ )
52
+ end
53
+ end
54
+
55
+ # Clear all cache entries (useful for testing)
56
+ def clear
57
+ @mutex.synchronize { @cache.clear }
58
+ end
59
+
60
+ # Get cache statistics
61
+ def stats
62
+ @mutex.synchronize do
63
+ {
64
+ size: @cache.size,
65
+ entries: @cache.keys
66
+ }
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def cache_key(message_id, source_endpoint)
73
+ "#{source_endpoint}:#{message_id}"
74
+ end
75
+
76
+ # Remove expired entries to prevent unbounded memory growth
77
+ # RFC 7252 §4.8.2: entries older than EXCHANGE_LIFETIME should be discarded
78
+ def cleanup_expired_entries
79
+ current_time = Time.now.to_f
80
+ @cache.delete_if { |_key, entry| entry.expired?(current_time) }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Takagi
4
+ module Message
5
+ # Class for inbound message that is coming to server
6
+ class Inbound < Base
7
+ attr_reader :method, :uri, :response_code
8
+
9
+ def initialize(data, transport: :udp)
10
+ super(data, transport: transport)
11
+ @method = coap_code_to_method(@code)
12
+ @response_code = coap_code_to_method(@code) if @code >= CoAP::Registries::Response::CREATED # Response
13
+ @uri = parse_coap_uri
14
+ @logger.debug "CoAP Options: #{@options.inspect}"
15
+ @logger.debug "Parsed CoAP URI: #{@uri}"
16
+ end
17
+
18
+ def to_response(code, payload, options: {})
19
+ # For TCP transport, type is not used (RFC 8323)
20
+ response_type = if @transport == :tcp
21
+ 0 # No type field in TCP CoAP
22
+ else
23
+ case @type
24
+ when CoAP::Registries::MessageType::CON then CoAP::Registries::MessageType::ACK # CON → ACK
25
+ when CoAP::Registries::MessageType::NON then CoAP::Registries::MessageType::NON # NON → NON
26
+ else CoAP::Registries::MessageType::RST # fallback → RST
27
+ end
28
+ end
29
+
30
+ Outbound.new(
31
+ code: code,
32
+ payload: payload,
33
+ token: @token,
34
+ message_id: @message_id,
35
+ type: response_type,
36
+ options: options,
37
+ transport: @transport
38
+ )
39
+ end
40
+
41
+ def parse_coap_uri
42
+ options = @options || {}
43
+ @logger.debug "Options received by parse_coap_uri: #{options.inspect}"
44
+
45
+ host = options[CoAP::Registries::Option::URI_HOST] || 'localhost'
46
+ path_segments = Array(options[CoAP::Registries::Option::URI_PATH]).flatten
47
+ query_segments = Array(options[CoAP::Registries::Option::URI_QUERY]).flatten
48
+
49
+ path = path_segments.empty? ? '/' : "/#{path_segments.join('/')}"
50
+ query = query_segments.empty? ? nil : query_segments.join('&')
51
+ URI::Generic.build(scheme: 'coap', host: host, path: path, query: query)
52
+ end
53
+
54
+ # DX Helpers for request inspection
55
+
56
+ # Get CoAP option by number
57
+ # @param option_number [Integer] The CoAP option number
58
+ # @return [Object, nil] The option value
59
+ def option(option_number)
60
+ @options[option_number]
61
+ end
62
+
63
+ # Check if request has a specific CoAP option
64
+ # @param option_number [Integer] The CoAP option number
65
+ # @return [Boolean]
66
+ def option?(option_number)
67
+ @options.key?(option_number)
68
+ end
69
+
70
+ # Get Accept option
71
+ # @return [Integer, nil] The Accept content format
72
+ def accept
73
+ option(CoAP::Registries::Option::ACCEPT)
74
+ end
75
+
76
+ # Check if request accepts a specific content format
77
+ # @param format [String, Integer] Format name or number
78
+ # @return [Boolean]
79
+ def accept?(format)
80
+ return false unless accept
81
+
82
+ format_number = format.is_a?(Integer) ? format : content_format_to_number(format)
83
+ accept == format_number
84
+ end
85
+
86
+ # Get Content-Format option
87
+ # @return [Integer, nil] The Content-Format
88
+ def content_format
89
+ option(CoAP::Registries::Option::CONTENT_FORMAT)
90
+ end
91
+
92
+ # Get query parameters as a hash
93
+ # @return [Hash<String, String>]
94
+ def query_params
95
+ return {} unless @uri.query
96
+
97
+ @uri.query.split('&').each_with_object({}) do |param, hash|
98
+ key, value = param.split('=', 2)
99
+ hash[key] = value || ''
100
+ end
101
+ end
102
+
103
+ # Check if request is a GET
104
+ # @return [Boolean]
105
+ def get?
106
+ method == 'GET'
107
+ end
108
+
109
+ # Check if request is a POST
110
+ # @return [Boolean]
111
+ def post?
112
+ method == 'POST'
113
+ end
114
+
115
+ # Check if request is a PUT
116
+ # @return [Boolean]
117
+ def put?
118
+ method == 'PUT'
119
+ end
120
+
121
+ # Check if request is a DELETE
122
+ # @return [Boolean]
123
+ def delete?
124
+ method == 'DELETE'
125
+ end
126
+
127
+ # Check if request is an OBSERVE
128
+ # @return [Boolean]
129
+ def observe?
130
+ method == 'OBSERVE'
131
+ end
132
+
133
+ private
134
+
135
+ # Convert content format name to number using CoAP registry
136
+ def content_format_to_number(format)
137
+ formats = {
138
+ 'text/plain' => CoAP::Registries::ContentFormat::TEXT_PLAIN,
139
+ 'application/link-format' => CoAP::Registries::ContentFormat::LINK_FORMAT,
140
+ 'application/json' => CoAP::Registries::ContentFormat::JSON,
141
+ 'application/cbor' => CoAP::Registries::ContentFormat::CBOR
142
+ }
143
+ formats[format.to_s.downcase] || CoAP::Registries::ContentFormat::JSON
144
+ end
145
+ end
146
+ end
147
+ end