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.
- checksums.yaml +4 -4
- data/.rubocop.yml +70 -7
- data/.yard/templates/default/layout/html/layout.erb +34 -0
- data/AGENTS.md +16 -0
- data/CHANGELOG.md +158 -1
- data/CODE_OF_CONDUCT.md +1 -1
- data/README.md +590 -23
- data/ROADMAP.md +55 -0
- data/Rakefile +4 -4
- data/Steepfile +39 -0
- data/bin/takagi-dev +159 -0
- data/docs/FIRST_PLUGIN_GUIDE.md +224 -0
- data/docs/HOOKS.md +31 -0
- data/examples/client_lifecycle_example.rb +118 -0
- data/examples/cloud_gateway_app.rb +217 -0
- data/examples/nested_api_app.rb +258 -0
- data/examples/simple_device_app.rb +71 -0
- data/examples/takagi.yml +138 -0
- data/lib/takagi/application.rb +256 -0
- data/lib/takagi/base/middleware_management.rb +39 -0
- data/lib/takagi/base/plugin_management.rb +75 -0
- data/lib/takagi/base/reactor_management.rb +104 -0
- data/lib/takagi/base/server_lifecycle.rb +156 -0
- data/lib/takagi/base.rb +103 -11
- data/lib/takagi/branding.rb +88 -0
- data/lib/takagi/cbor/decoder.rb +385 -0
- data/lib/takagi/cbor/encoder.rb +260 -0
- data/lib/takagi/cbor/error.rb +17 -0
- data/lib/takagi/cbor/version.rb +9 -0
- data/lib/takagi/client/response.rb +236 -0
- data/lib/takagi/client.rb +265 -0
- data/lib/takagi/client_base.rb +204 -0
- data/lib/takagi/coap/code_helpers.rb +190 -0
- data/lib/takagi/coap/registries/base.rb +165 -0
- data/lib/takagi/coap/registries/content_format.rb +71 -0
- data/lib/takagi/coap/registries/message_type.rb +69 -0
- data/lib/takagi/coap/registries/method.rb +38 -0
- data/lib/takagi/coap/registries/option.rb +71 -0
- data/lib/takagi/coap/registries/response.rb +93 -0
- data/lib/takagi/coap/registries/signaling.rb +34 -0
- data/lib/takagi/coap/signaling.rb +10 -0
- data/lib/takagi/coap.rb +37 -0
- data/lib/takagi/composite_router.rb +186 -0
- data/lib/takagi/config.rb +337 -0
- data/lib/takagi/controller/resource_allocator.rb +164 -0
- data/lib/takagi/controller/thread_pool.rb +144 -0
- data/lib/takagi/controller.rb +319 -0
- data/lib/takagi/core/attribute_set.rb +128 -0
- data/lib/takagi/discovery/core_link_format.rb +137 -0
- data/lib/takagi/errors.rb +536 -0
- data/lib/takagi/event_bus/address_prefix.rb +142 -0
- data/lib/takagi/event_bus/async_executor.rb +235 -0
- data/lib/takagi/event_bus/coap_bridge.rb +208 -0
- data/lib/takagi/event_bus/future.rb +153 -0
- data/lib/takagi/event_bus/lru_cache.rb +157 -0
- data/lib/takagi/event_bus/message_buffer.rb +237 -0
- data/lib/takagi/event_bus/observer_cleanup.rb +110 -0
- data/lib/takagi/event_bus/scope.rb +74 -0
- data/lib/takagi/event_bus.rb +594 -0
- data/lib/takagi/helpers.rb +88 -0
- data/lib/takagi/hooks.rb +82 -0
- data/lib/takagi/initializer.rb +18 -0
- data/lib/takagi/logger.rb +15 -6
- data/lib/takagi/message/base.rb +155 -0
- data/lib/takagi/message/deduplication_cache.rb +84 -0
- data/lib/takagi/message/inbound.rb +147 -0
- data/lib/takagi/message/outbound.rb +223 -0
- data/lib/takagi/message/request.rb +158 -0
- data/lib/takagi/message/retransmission_manager.rb +193 -0
- data/lib/takagi/middleware/authentication.rb +19 -0
- data/lib/takagi/middleware/caching.rb +23 -0
- data/lib/takagi/middleware/debugging.rb +16 -0
- data/lib/takagi/middleware/logging.rb +14 -0
- data/lib/takagi/middleware/metrics.rb +440 -0
- data/lib/takagi/middleware/rate_limiting.rb +24 -0
- data/lib/takagi/middleware_stack.rb +166 -0
- data/lib/takagi/network/base.rb +76 -0
- data/lib/takagi/network/framing/tcp.rb +222 -0
- data/lib/takagi/network/framing/udp.rb +110 -0
- data/lib/takagi/network/registry.rb +72 -0
- data/lib/takagi/network/tcp.rb +60 -0
- data/lib/takagi/network/tcp_sender.rb +21 -0
- data/lib/takagi/network/udp.rb +61 -0
- data/lib/takagi/network/udp_sender.rb +20 -0
- data/lib/takagi/observable/emitter.rb +62 -0
- data/lib/takagi/observable/reactor.rb +488 -0
- data/lib/takagi/observable/registry.rb +122 -0
- data/lib/takagi/observe_registry.rb +10 -0
- data/lib/takagi/observer/client.rb +68 -0
- data/lib/takagi/observer/registry.rb +137 -0
- data/lib/takagi/observer/sender.rb +39 -0
- data/lib/takagi/observer/watcher.rb +43 -0
- data/lib/takagi/plugin.rb +313 -0
- data/lib/takagi/profiles.rb +176 -0
- data/lib/takagi/reactor.rb +23 -0
- data/lib/takagi/reactor_registry.rb +64 -0
- data/lib/takagi/registry/base.rb +268 -0
- data/lib/takagi/response_builder.rb +141 -0
- data/lib/takagi/router/metadata_extractor.rb +133 -0
- data/lib/takagi/router/route_matcher.rb +83 -0
- data/lib/takagi/router.rb +284 -25
- data/lib/takagi/serialization/base.rb +102 -0
- data/lib/takagi/serialization/cbor_serializer.rb +92 -0
- data/lib/takagi/serialization/json_serializer.rb +96 -0
- data/lib/takagi/serialization/octet_stream_serializer.rb +82 -0
- data/lib/takagi/serialization/registry.rb +187 -0
- data/lib/takagi/serialization/text_serializer.rb +87 -0
- data/lib/takagi/serialization.rb +117 -0
- data/lib/takagi/server/multi.rb +41 -0
- data/lib/takagi/server/registry.rb +71 -0
- data/lib/takagi/server/tcp.rb +249 -0
- data/lib/takagi/server/udp.rb +139 -0
- data/lib/takagi/server/udp_worker.rb +174 -0
- data/lib/takagi/server.rb +1 -31
- data/lib/takagi/server_registry.rb +10 -0
- data/lib/takagi/tcp_client.rb +142 -0
- data/lib/takagi/version.rb +2 -1
- data/lib/takagi.rb +24 -3
- data/sig/takagi/application.rbs +48 -0
- data/sig/takagi/base/middleware_management.rbs +33 -0
- data/sig/takagi/base/reactor_management.rbs +52 -0
- data/sig/takagi/base/server_lifecycle.rbs +54 -0
- data/sig/takagi/base.rbs +48 -0
- data/sig/takagi/cbor/decoder.rbs +171 -0
- data/sig/takagi/cbor/encoder.rbs +146 -0
- data/sig/takagi/cbor/error.rbs +19 -0
- data/sig/takagi/cbor/version.rbs +7 -0
- data/sig/takagi/client/response.rbs +148 -0
- data/sig/takagi/client.rbs +119 -0
- data/sig/takagi/client_base.rbs +135 -0
- data/sig/takagi/coap/code_helpers.rbs +91 -0
- data/sig/takagi/coap/registries/base.rbs +95 -0
- data/sig/takagi/coap/registries/content_format.rbs +47 -0
- data/sig/takagi/coap/registries/message_type.rbs +53 -0
- data/sig/takagi/coap/registries/method.rbs +27 -0
- data/sig/takagi/coap/registries/option.rbs +43 -0
- data/sig/takagi/coap/registries/response.rbs +52 -0
- data/sig/takagi/coap.rbs +24 -0
- data/sig/takagi/composite_router.rbs +46 -0
- data/sig/takagi/config.rbs +134 -0
- data/sig/takagi/controller.rbs +73 -0
- data/sig/takagi/core/attribute_set.rbs +57 -0
- data/sig/takagi/discovery/core_link_format.rbs +50 -0
- data/sig/takagi/event_bus/address_prefix.rbs +78 -0
- data/sig/takagi/event_bus/async_executor.rbs +88 -0
- data/sig/takagi/event_bus/coap_bridge.rbs +93 -0
- data/sig/takagi/event_bus/future.rbs +78 -0
- data/sig/takagi/event_bus/lru_cache.rbs +86 -0
- data/sig/takagi/event_bus/message_buffer.rbs +133 -0
- data/sig/takagi/event_bus/observer_cleanup.rbs +62 -0
- data/sig/takagi/event_bus.rbs +320 -0
- data/sig/takagi/helpers.rbs +34 -0
- data/sig/takagi/initializer.rbs +9 -0
- data/sig/takagi/logger.rbs +17 -0
- data/sig/takagi/message/base.rbs +64 -0
- data/sig/takagi/message/deduplication_cache.rbs +49 -0
- data/sig/takagi/message/inbound.rbs +76 -0
- data/sig/takagi/message/outbound.rbs +48 -0
- data/sig/takagi/message/request.rbs +32 -0
- data/sig/takagi/message/retransmission_manager.rbs +76 -0
- data/sig/takagi/middleware/authentication.rbs +11 -0
- data/sig/takagi/middleware/caching.rbs +13 -0
- data/sig/takagi/middleware/debugging.rbs +9 -0
- data/sig/takagi/middleware/logging.rbs +7 -0
- data/sig/takagi/middleware/metrics.rbs +15 -0
- data/sig/takagi/middleware/rate_limiting.rbs +13 -0
- data/sig/takagi/middleware_stack.rbs +69 -0
- data/sig/takagi/network/tcp_sender.rbs +10 -0
- data/sig/takagi/network/udp_sender.rbs +14 -0
- data/sig/takagi/observe_registry.rbs +36 -0
- data/sig/takagi/observer/client.rbs +36 -0
- data/sig/takagi/observer/sender.rbs +12 -0
- data/sig/takagi/observer/watcher.rbs +18 -0
- data/sig/takagi/profiles.rbs +33 -0
- data/sig/takagi/reactor.rbs +20 -0
- data/sig/takagi/reactor_registry.rbs +14 -0
- data/sig/takagi/response_builder.rbs +12 -0
- data/sig/takagi/router/metadata_extractor.rbs +71 -0
- data/sig/takagi/router/route_matcher.rbs +43 -0
- data/sig/takagi/router.rbs +166 -0
- data/sig/takagi/serialization.rbs +32 -0
- data/sig/takagi/server/multi.rbs +16 -0
- data/sig/takagi/server/tcp.rbs +42 -0
- data/sig/takagi/server/udp.rbs +52 -0
- data/sig/takagi/server/udp_worker.rbs +42 -0
- data/sig/takagi/server.rbs +4 -0
- data/sig/takagi/server_registry.rbs +71 -0
- data/sig/takagi/tcp_client.rbs +23 -0
- data/sig/takagi/version.rbs +5 -0
- data/takagi.gemspec +37 -35
- metadata +204 -31
- data/.idea/.gitignore +0 -8
- data/.idea/misc.xml +0 -4
- data/.idea/modules.xml +0 -8
- data/.idea/takagi.iml +0 -81
- data/.idea/vcs.xml +0 -6
- 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
|
data/lib/takagi/hooks.rb
ADDED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
|
15
|
+
def set_level(level)
|
|
11
16
|
@logger.level = level
|
|
12
17
|
end
|
|
13
18
|
|
|
14
|
-
def
|
|
19
|
+
def info(message)
|
|
15
20
|
@logger.info(message)
|
|
16
21
|
end
|
|
17
22
|
|
|
18
|
-
def
|
|
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
|
|
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
|