Skip to content

Commit 5ba0510

Browse files
committed
feat(topics) add validation and matching
1 parent fae3584 commit 5ba0510

File tree

3 files changed

+473
-0
lines changed

3 files changed

+473
-0
lines changed

.editorconfig

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
root = true
2+
3+
[*]
4+
end_of_line = lf
5+
insert_final_newline = true
6+
trim_trailing_whitespace = true
7+
charset = utf-8
8+
9+
[*.lua]
10+
indent_style = tab
11+
indent_size = 4
12+
13+
[Makefile]
14+
indent_style = tab

mqtt/init.lua

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,165 @@ function mqtt.run_sync(cl)
8181
end
8282
end
8383

84+
85+
--- Validates a topic with wildcards.
86+
-- @param t (string) wildcard topic to validate
87+
-- @return topic, or false+error
88+
function mqtt.validate_subscribe_topic(t)
89+
if type(t) ~= "string" then
90+
return false, "not a string"
91+
end
92+
if #t < 1 then
93+
return false, "minimum topic length is 1"
94+
end
95+
do
96+
local _, count = t:gsub("#", "")
97+
if count > 1 then
98+
return false, "wildcard '#' may only appear once"
99+
end
100+
if count == 1 then
101+
if t ~= "#" and not t:find("/#$") then
102+
return false, "wildcard '#' must be the last character, and be prefixed with '/' (unless the topic is '#')"
103+
end
104+
end
105+
end
106+
do
107+
local t1 = "/"..t.."/"
108+
local i = 1
109+
while i do
110+
i = t1:find("+", i)
111+
if i then
112+
if t1:sub(i-1, i+1) ~= "/+/" then
113+
return false, "wildcard '+' must be enclosed between '/' (except at start/end)"
114+
end
115+
i = i + 1
116+
end
117+
end
118+
end
119+
return t
120+
end
121+
122+
--- Validates a topic without wildcards.
123+
-- @param t (string) topic to validate
124+
-- @return topic, or false+error
125+
function mqtt.validate_publish_topic(t)
126+
if type(t) ~= "string" then
127+
return false, "not a string"
128+
end
129+
if #t < 1 then
130+
return false, "minimum topic length is 1"
131+
end
132+
if t:find("+", nil, true) or t:find("#", nil, true) then
133+
return false, "wildcards '#', and '+' are not allowed when publishing"
134+
end
135+
return t
136+
end
137+
138+
--- Returns a Lua pattern from topic.
139+
-- Takes a wildcarded-topic and returns a Lua pattern that can be used
140+
-- to validate if a received topic matches the wildcard-topic
141+
-- @param t (string) the wildcard topic
142+
-- @return Lua-pattern (string) or false+err
143+
function mqtt.compile_topic_pattern(t)
144+
local ok, err = mqtt.validate_subscribe_topic(t)
145+
if not ok then
146+
return ok, err
147+
end
148+
if t == "#" then
149+
t = "(.+)" -- matches anything at least 1 character long
150+
else
151+
t = t:gsub("#","(.-)") -- match anything, can be empty
152+
t = t:gsub("%+","([^/]-)") -- match anything between '/', can be empty
153+
end
154+
return "^"..t.."$"
155+
end
156+
157+
--- Parses wildcards in a topic into a table.
158+
-- Options include:
159+
--
160+
-- - `opts.topic`: the wild-carded topic to match against (optional if `pattern` is given)
161+
--
162+
-- - `opts.pattern`: the compiled pattern for the wild-carded topic (optional if `topic`
163+
-- is given). If not given then topic will be compiled and the result will be
164+
-- stored in this field for future use (cache).
165+
--
166+
-- - `opts.keys`: (optional) array of field names. The order must be the same as the
167+
-- order of the wildcards in `topic`
168+
--
169+
-- Returned tables:
170+
--
171+
-- - `fields` table: the array part will have the values of the wildcards, in
172+
-- the order they appeared. The hash part, will have the field names provided
173+
-- in `opts.keys`, with the values of the corresponding wildcard. If a `#`
174+
-- wildcard was used, that one will be the last in the table.
175+
--
176+
-- - `varargs` table: will only be returned if the wildcard topic contained the
177+
-- `#` wildcard. The returned table is an array, with all segments that were
178+
-- matched by the `#` wildcard.
179+
-- @param topic (string) incoming topic string (required)
180+
-- @param opts (table) with options (required)
181+
-- @return fields (table) + varargs (table or nil), or false+err on error.
182+
function mqtt.topic_match(topic, opts)
183+
if type(topic) ~= "string" then
184+
return false, "expected topic to be a string"
185+
end
186+
if type(opts) ~= "table" then
187+
return false, "expected optionss to be a table"
188+
end
189+
local pattern = opts.pattern
190+
if not pattern then
191+
local ptopic = opts.topic
192+
if not ptopic then
193+
return false, "either 'opts.topic' or 'opts.pattern' must set"
194+
end
195+
local err
196+
pattern, err = mqtt.compile_topic_pattern(ptopic)
197+
if not pattern then
198+
return false, "failed to compile 'opts.topic' into pattern: "..tostring(err)
199+
end
200+
-- store/cache compiled pattern for next time
201+
opts.pattern = pattern
202+
end
203+
local values = { topic:match(pattern) }
204+
if values[1] == nil then
205+
return false, "topic does not match wildcard pattern"
206+
end
207+
local keys = opts.keys
208+
if keys ~= nil then
209+
if type(keys) ~= "table" then
210+
return false, "expected 'opts.keys' to be a table (array)"
211+
end
212+
-- we have a table with keys, copy values to fields
213+
for i, value in ipairs(values) do
214+
local key = keys[i]
215+
if key ~= nil then
216+
values[key] = value
217+
end
218+
end
219+
end
220+
if not pattern:find("%(%.[%-%+]%)%$$") then -- pattern for "#" as last char
221+
-- we're done
222+
return values
223+
end
224+
-- we have a '#' wildcard
225+
local vararg = values[#values]
226+
local varargs = {}
227+
local i = 0
228+
local ni = 0
229+
while ni do
230+
ni = vararg:find("/", i, true)
231+
if ni then
232+
varargs[#varargs + 1] = vararg:sub(i, ni-1)
233+
i = ni + 1
234+
else
235+
varargs[#varargs + 1] = vararg:sub(i, -1)
236+
end
237+
end
238+
239+
return values, varargs
240+
end
241+
242+
84243
-- export module table
85244
return mqtt
86245

0 commit comments

Comments
 (0)