diff --git a/.busted b/.busted
new file mode 100644
index 0000000..0855a96
--- /dev/null
+++ b/.busted
@@ -0,0 +1,10 @@
+return {
+  default = {
+    ROOT = { "tests/spec" },
+    pattern = "%.lua",
+    lpath = "./?.lua;./?/?.lua;./?/init.lua",
+    verbose = true,
+    coverage = false,
+    output = "gtest",
+  },
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..0709883
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,15 @@
+root                     = true
+
+[*]
+end_of_line              = lf
+insert_final_newline     = true
+trim_trailing_whitespace = true
+charset                  = utf-8
+
+[*.lua]
+indent_style             = tab
+indent_size              = 4
+
+[Makefile]
+indent_style             = tab
+indent_size              = 4
diff --git a/mqtt/init.lua b/mqtt/init.lua
index c6f597c..a1436d6 100644
--- a/mqtt/init.lua
+++ b/mqtt/init.lua
@@ -81,6 +81,192 @@ function mqtt.run_sync(cl)
 	end
 end
 
+
+--- Validates a topic with wildcards.
+-- @tparam string t wildcard topic to validate
+-- @return topic, or false+error
+-- @usage
+-- local t = "invalid/#/subscribe/#/topic"
+-- local topic = assert(mqtt.validate_subscribe_topic(t))
+function mqtt.validate_subscribe_topic(t)
+	if type(t) ~= "string" then
+		return false, "bad subscribe-topic; expected topic to be a string, got: "..type(t)
+	end
+	if #t < 1 then
+		return false, "bad subscribe-topic; expected minimum topic length of 1"
+	end
+	do
+		local _, count = t:gsub("#", "")
+		if count > 1 then
+			return false, "bad subscribe-topic; wildcard '#' may only appear once, got: '"..t.."'"
+		end
+		if count == 1 then
+			if t ~= "#" and not t:find("/#$") then
+				return false, "bad subscribe-topic; wildcard '#' must be the last character, and " ..
+				              "be prefixed with '/' (unless the topic is '#'), got: '"..t.."'"
+			end
+		end
+	end
+	do
+		local t1 = "/"..t.."/"
+		local i = 1
+		while i do
+			i = t1:find("+", i)
+			if i then
+				if t1:sub(i-1, i+1) ~= "/+/" then
+					return false, "bad subscribe-topic; wildcard '+' must be enclosed between '/' " ..
+					              "(except at start/end), got: '"..t.."'"
+				end
+				i = i + 1
+			end
+		end
+	end
+	return t
+end
+
+--- Validates a topic without wildcards.
+-- @tparam string t topic to validate
+-- @return topic, or false+error
+-- @usage
+-- local t = "invalid/#/publish/+/topic"
+-- local topic = assert(mqtt.validate_publish_topic(t))
+function mqtt.validate_publish_topic(t)
+	if type(t) ~= "string" then
+		return false, "bad publish-topic; expected topic to be a string, got: "..type(t)
+	end
+	if #t < 1 then
+		return false, "bad publish-topic; expected minimum topic length of 1"
+	end
+	if t:find("+", nil, true) or t:find("#", nil, true) then
+		return false, "bad publish-topic; wildcards '#', and '+' are not allowed when publishing, got: '"..t.."'"
+	end
+	return t
+end
+
+do
+	local MATCH_ALL = "(.+)"     -- matches anything at least 1 character long
+	local MATCH_HASH = "(.-)"    -- match anything, can be empty
+	local MATCH_PLUS = "([^/]-)" -- match anything between '/', can be empty
+
+	--- Returns a Lua pattern from topic.
+	-- Takes a wildcarded-topic and returns a Lua pattern that can be used
+	-- to validate if a received topic matches the wildcard-topic
+	-- @tparam string t the wildcard topic
+	-- @return Lua-pattern (string) or throws error on invalid input
+	-- @usage
+	-- local patt = compile_topic_pattern("homes/+/+/#")
+	--
+	-- local incoming_topic = "homes/myhome/living/mainlights/brightness"
+	-- local homeid, roomid, varargs = incoming_topic:match(patt)
+	function mqtt.compile_topic_pattern(t)
+		t = assert(mqtt.validate_subscribe_topic(t))
+		if t == "#" then
+			t = MATCH_ALL
+		else
+			t = t:gsub("#", MATCH_HASH)
+			t = t:gsub("%+", MATCH_PLUS)
+		end
+		return "^"..t.."$"
+	end
+end
+
+do
+	local HAS_VARARG_PATTERN = "%(%.[%-%+]%)%$$" -- matches patterns that have a vararg matcher
+
+	--- Parses wildcards in a topic into a table.
+	-- Options include:
+	--
+	-- - `opts.topic`: the wild-carded topic to match against (optional if `opts.pattern` is given)
+	--
+	-- - `opts.pattern`: the compiled pattern for the wild-carded topic (optional if `opts.topic`
+	--   is given). If not given then topic will be compiled and the result will be
+	--   stored in this field for future use (cache).
+	--
+	-- - `opts.keys`: (optional) array of field names. The order must be the same as the
+	--   order of the wildcards in `topic`
+	--
+	-- Returned tables:
+	--
+	-- - `fields` table: the array part will have the values of the wildcards, in
+	--   the order they appeared. The hash part, will have the field names provided
+	--   in `opts.keys`, with the values of the corresponding wildcard. If a `#`
+	--   wildcard was used, that one will be the last in the table.
+	--
+	-- - `varargs` table: will only be returned if the wildcard topic contained the
+	--   `#` wildcard. The returned table is an array, with all segments that were
+	--   matched by the `#` wildcard.
+	-- @tparam string topic incoming topic string (required)
+	-- @tparam table opts options table(required)
+	-- @return fields (table) + varargs (table or nil), or false+err if the match failed,
+	-- or throws an error on invalid input.
+	-- @usage
+	-- local opts = {
+	--   topic = "homes/+/+/#",
+	--   keys = { "homeid", "roomid", "varargs"},
+	-- }
+	-- local fields, varargs = topic_match("homes/myhome/living/mainlights/brightness", opts)
+	--
+	-- print(fields[1], fields.homeid)  -- "myhome myhome"
+	-- print(fields[2], fields.roomid)  -- "living living"
+	-- print(fields[3], fields.varargs) -- "mainlights/brightness mainlights/brightness"
+	--
+	-- print(varargs[1]) -- "mainlights"
+	-- print(varargs[2]) -- "brightness"
+	function mqtt.topic_match(topic, opts)
+		if type(topic) ~= "string" then
+			error("expected topic to be a string, got: "..type(topic))
+		end
+		if type(opts) ~= "table" then
+			error("expected options to be a table, got: "..type(opts))
+		end
+		local pattern = opts.pattern
+		if not pattern then
+			local ptopic = assert(opts.topic, "either 'opts.topic' or 'opts.pattern' must set")
+			pattern = assert(mqtt.compile_topic_pattern(ptopic))
+			-- store/cache compiled pattern for next time
+			opts.pattern = pattern
+		end
+		local values = { topic:match(pattern) }
+		if values[1] == nil then
+			return false, "topic does not match wildcard pattern"
+		end
+		local keys = opts.keys
+		if keys ~= nil then
+			if type(keys) ~= "table" then
+				error("expected 'opts.keys' to be a table (array), got: "..type(keys))
+			end
+			-- we have a table with keys, copy values to fields
+			for i, value in ipairs(values) do
+				local key = keys[i]
+				if key ~= nil then
+					values[key] = value
+				end
+			end
+		end
+		if not pattern:find(HAS_VARARG_PATTERN) then -- pattern for "#" as last char
+			-- we're done
+			return values
+		end
+		-- we have a '#' wildcard
+		local vararg = values[#values]
+		local varargs = {}
+		local i = 0
+		local ni = 0
+		while ni do
+			ni = vararg:find("/", i, true)
+			if ni then
+				varargs[#varargs + 1] = vararg:sub(i, ni-1)
+				i = ni + 1
+			else
+				varargs[#varargs + 1] = vararg:sub(i, -1)
+			end
+		end
+
+		return values, varargs
+	end
+end
+
+
 -- export module table
 return mqtt
 
diff --git a/tests/spec/topics.lua b/tests/spec/topics.lua
new file mode 100644
index 0000000..7b8e568
--- /dev/null
+++ b/tests/spec/topics.lua
@@ -0,0 +1,300 @@
+local mqtt = require "mqtt"
+
+describe("topics", function()
+
+	describe("publish (plain)", function()
+		it("allows proper topics", function()
+			local ok, err
+			ok, err = mqtt.validate_publish_topic("hello/world")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_publish_topic("hello/world/")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_publish_topic("/hello/world")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_publish_topic("/")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_publish_topic("//////")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_publish_topic("/")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+		end)
+
+		it("returns the topic passed in on success", function()
+			local ok = mqtt.validate_publish_topic("hello/world")
+			assert.are.equal("hello/world", ok)
+		end)
+
+		it("must be a string", function()
+			local ok, err = mqtt.validate_publish_topic(true)
+			assert.is_false(ok)
+			assert.is_string(err)
+		end)
+
+		it("minimum length 1", function()
+			local ok, err = mqtt.validate_publish_topic("")
+			assert.is_false(ok)
+			assert.is_string(err)
+		end)
+
+		it("wildcard '#' is not allowed", function()
+			local ok, err = mqtt.validate_publish_topic("hello/world/#")
+			assert.is_false(ok)
+			assert.is_string(err)
+		end)
+
+		it("wildcard '+' is not allowed", function()
+			local ok, err = mqtt.validate_publish_topic("hello/+/world")
+			assert.is_false(ok)
+			assert.is_string(err)
+		end)
+
+	end)
+
+
+
+	describe("subscribe (wildcarded)", function()
+
+		it("allows proper topics", function()
+			local ok, err
+			ok, err = mqtt.validate_subscribe_topic("hello/world")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_subscribe_topic("hello/world/")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_subscribe_topic("/hello/world")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_subscribe_topic("/")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_subscribe_topic("//////")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_subscribe_topic("#")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_subscribe_topic("/#")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_subscribe_topic("+")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_subscribe_topic("+/hello/#")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+
+			ok, err = mqtt.validate_subscribe_topic("+/+/+/+/+")
+			assert.is_nil(err)
+			assert.is.truthy(ok)
+		end)
+
+		it("returns the topic passed in on success", function()
+			local ok = mqtt.validate_subscribe_topic("hello/world")
+			assert.are.equal("hello/world", ok)
+		end)
+
+		it("must be a string", function()
+			local ok, err = mqtt.validate_subscribe_topic(true)
+			assert.is_false(ok)
+			assert.is_string(err)
+		end)
+
+		it("minimum length 1", function()
+			local ok, err = mqtt.validate_subscribe_topic("")
+			assert.is_false(ok)
+			assert.is_string(err)
+		end)
+
+		it("wildcard '#' is only allowed as last segment", function()
+			local ok, err = mqtt.validate_subscribe_topic("hello/#/world")
+			assert.is_false(ok)
+			assert.is_string(err)
+		end)
+
+		it("wildcard '+' is only allowed as full segment", function()
+			local ok, err = mqtt.validate_subscribe_topic("hello/+there/world")
+			assert.is_false(ok)
+			assert.is_string(err)
+		end)
+
+	end)
+
+
+
+	describe("pattern compiler & matcher", function()
+
+		it("basic parsing works", function()
+			local opts = {
+				topic = "+/+",
+				pattern = nil,
+				keys = { "hello", "world"}
+			}
+			local res, err = mqtt.topic_match("hello/world", opts)
+			assert.is_nil(err)
+			assert.same(res, {
+				"hello", "world",
+				hello = "hello",
+				world = "world",
+			})
+			-- compiled pattern is now added
+			assert.not_nil(opts.pattern)
+		end)
+
+		it("incoming topic is required", function()
+			local opts = {
+				topic = "+/+",
+				pattern = nil,
+				keys = { "hello", "world"}
+			}
+			assert.has.error(function()
+				mqtt.topic_match(nil, opts)
+			end, "expected topic to be a string, got: nil")
+		end)
+
+		it("wildcard topic or pattern is required", function()
+			local opts = {
+				topic = nil,
+				pattern = nil,
+				keys = { "hello", "world"}
+			}
+			assert.has.error(function()
+				mqtt.topic_match("hello/world", opts)
+			end, "either 'opts.topic' or 'opts.pattern' must set")
+		end)
+
+		it("pattern must match", function()
+			local opts = {
+				topic = "+/+/+", -- one too many
+				pattern = nil,
+				keys = { "hello", "world"}
+			}
+			local ok, err = mqtt.topic_match("hello/world", opts)
+			assert.is_false(ok)
+			assert.is_string(err)
+		end)
+
+		it("pattern '+' works", function()
+			local opts = {
+				topic = "+",
+				pattern = nil,
+				keys = { "hello" }
+			}
+			-- matches topic
+			local res, err = mqtt.topic_match("hello", opts)
+			assert.is_nil(err)
+			assert.same(res, {
+				"hello",
+				hello = "hello",
+			})
+		end)
+
+		it("wildcard '+' matches empty segments", function()
+			local opts = {
+				topic = "+/+/+",
+				pattern = nil,
+				keys = { "hello", "there", "world"}
+			}
+			local res, err = mqtt.topic_match("//", opts)
+			assert.is_nil(err)
+			assert.same(res, {
+				"", "", "",
+				hello = "",
+				there = "",
+				world = "",
+			})
+		end)
+
+		it("pattern '#' matches all segments", function()
+			local opts = {
+				topic = "#",
+				pattern = nil,
+				keys = nil,
+			}
+			local res, var = mqtt.topic_match("hello/there/world", opts)
+			assert.same(res, {
+				"hello/there/world"
+			})
+			assert.same(var, {
+				"hello",
+				"there",
+				"world",
+			})
+		end)
+
+		it("pattern '/#' skips first segment", function()
+			local opts = {
+				topic = "/#",
+				pattern = nil,
+				keys = nil,
+			}
+			local res, var = mqtt.topic_match("/hello/world", opts)
+			assert.same(res, {
+				"hello/world"
+			})
+			assert.same(var, {
+				"hello",
+				"world",
+			})
+		end)
+
+		it("combined wildcards '+/+/#'", function()
+			local opts = {
+				topic = "+/+/#",
+				pattern = nil,
+				keys = nil,
+			}
+			local res, var = mqtt.topic_match("hello/there/my/world", opts)
+			assert.same(res, {
+				"hello",
+				"there",
+				"my/world"
+			})
+			assert.same(var, {
+				"my",
+				"world",
+			})
+		end)
+
+		it("trailing '/' in topic with '#'", function()
+			local opts = {
+				topic = "+/+/#",
+				pattern = nil,
+				keys = nil,
+			}
+			local res, var = mqtt.topic_match("hello/there/world/", opts)
+			assert.same(res, {
+				"hello",
+				"there",
+				"world/"
+			})
+			assert.same(var, {
+				"world",
+				"",
+			})
+		end)
+
+
+	end)
+
+end)