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,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
|
-
|
|
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
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|