changeset 3813:aa1ad69c7c10

mod_rest: Add JSON support
author Kim Alvefur <zash@zash.se>
date Wed, 01 Jan 2020 16:21:28 +0100
parents f027b8b1e794
children 0dede5b0ab27
files mod_rest/README.markdown mod_rest/jsonmap.lib.lua mod_rest/mod_rest.lua
diffstat 3 files changed, 334 insertions(+), 6 deletions(-) [+]
line wrap: on
line diff
--- a/mod_rest/README.markdown	Wed Jan 01 16:19:10 2020 +0100
+++ b/mod_rest/README.markdown	Wed Jan 01 16:21:28 2020 +0100
@@ -35,7 +35,21 @@
         </body>'
 ```
 
-The `Content-Type` **MUST** be `application/xmpp+xml`.
+or a JSON payload:
+
+``` {.sh}
+curl https://prosody.example:5281/rest \
+    --oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K \
+    -H 'Content-Type: application/json' \
+    --data-binary '{
+           "body" : "Hello!",
+           "kind" : "message",
+           "to" : "user@example.org",
+           "type" : "chat"
+        }'
+```
+
+The `Content-Type` header is important!
 
 ### Replies
 
@@ -66,18 +80,40 @@
 rest_callback_url = "http://my-api.example:9999/stanzas"
 ```
 
+To enable JSON payloads set
+
+``` {.lua}
+rest_callback_content_type = "application/json"
+```
+
 Example callback looks like:
 
 ``` {.xml}
 POST /stanzas HTTP/1.1
 Content-Type: application/xmpp+xml
-Content-Length: 52
+Content-Length: 102
 
 <message to="bot@rest.example.net" from="user@example.com" type="chat">
 <body>Hello</body>
 </message>
 ```
 
+or as JSON:
+
+``` {.json}
+POST /stanzas HTTP/1.1
+Content-Type: application/json
+Content-Length: 133
+
+{
+   "body" : "Hello",
+   "from" : "user@example.com",
+   "kind" : "message",
+   "to" : "bot@rest.example.net",
+   "type" : "chat"
+}
+```
+
 ### Replying
 
 To accept the stanza without returning a reply, respond with HTTP status
@@ -100,6 +136,20 @@
 
 ## Payload format
 
+### JSON
+
+``` {.json}
+{
+   "body" : "Hello!",
+   "kind" : "message",
+   "type" : "chat"
+}
+```
+
+Mapping of various XMPP stanza payloads to JSON.
+
+### XML
+
 ``` {.xml}
 <message type="" id="" to="" from="" xml:lang="">
 ...
@@ -119,7 +169,7 @@
 
 Simple echo bot that responds to messages:
 
-```python
+``` {.python}
 from flask import Flask, Response, request
 import xml.etree.ElementTree as ET
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_rest/jsonmap.lib.lua	Wed Jan 01 16:21:28 2020 +0100
@@ -0,0 +1,250 @@
+local array = require "util.array";
+local jid = require "util.jid";
+local st = require "util.stanza";
+local xml = require "util.xml";
+
+local simple_types = {
+	-- basic message
+	body = "text_tag",
+	subject = "text_tag",
+	thread = "text_tag",
+
+	-- basic presence
+	show = "text_tag",
+	status = "text_tag",
+	priority = "text_tag",
+
+	state = {"name", "http://jabber.org/protocol/chatstates"},
+	nick = {"text_tag", "http://jabber.org/protocol/nick", "nick"},
+	delay = {"attr", "urn:xmpp:delay", "delay", "stamp"},
+	replace = {"attr", "urn:xmpp:message-correct:0", "replace", "id"},
+
+	-- XEP-0045 MUC
+	-- TODO history, password, ???
+	join = {"bool_tag", "http://jabber.org/protocol/muc", "x"},
+
+	-- XEP-0071
+	-- FIXME xmlns is awkward
+	html = {
+		"func", "http://jabber.org/protocol/xhtml-im", "html",
+		function (s) --> json string
+			return tostring(s:get_child("body", "http://www.w3.org/1999/xhtml"));
+		end;
+		function (s) --> xml
+			return xml.parse([[<html xmlns='http://jabber.org/protocol/xhtml-im'>]]..s..[[</html>]]);
+		end;
+	};
+
+	-- XEP-0199
+	ping = {"bool_tag", "urn:xmpp:ping", "ping"},
+
+	-- XEP-0030
+	disco = {
+		"func", "http://jabber.org/protocol/disco#info", "query",
+		function (s) --> array of features
+			local identities, features = array(), array();
+			for tag in s:childtags() do
+				if tag.name == "identity" and tag.attr.category and tag.attr.type then
+					identities:push({ category = tag.attr.category, type = tag.attr.type, name = tag.attr.name });
+				elseif tag.name == "feature" and tag.attr.var then
+					features:push(tag.attr.var);
+				end
+			end
+			return { identities = identities, features = features, };
+		end;
+		function  (s)
+			local disco = st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#info" });
+			if type(s) == "table" then
+				if s.identities then
+					for identity in ipairs(s.identities) do
+						disco:tag("identity", { category = identity[1], type = identity[2] }):up();
+					end
+				end
+				if s.features then
+					for feature in ipairs(s.features) do
+						disco:tag("feature", { var = feature }):up();
+					end
+				end
+			end
+			return disco;
+		end;
+	};
+
+	items = {
+		"func", "http://jabber.org/protocol/disco#items", "query",
+		function (s) --> array of features
+			local items = array();
+			for item in s:childtags("item") do
+				items:push({ jid = item.attr.jid, node = item.attr.node, name = item.attr.name });
+			end
+			return items;
+		end;
+		function  (s)
+			local disco = st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#items" });
+			if type(s) == "table" then
+				for _, item in ipairs(s) do
+					disco:tag("item", item);
+				end
+			end
+			return disco;
+		end;
+	};
+
+	oob_url = {"func", "jabber:iq:oob", "query",
+		function (s)
+			return s:get_child_text("url");
+		end;
+		function (s)
+			return st.stanza("query", { xmlns = "jabber:iq:oob" }):text_tag("url", s);
+		end;
+	};
+};
+
+local implied_kinds = {
+	disco = "iq",
+	items = "iq",
+	ping = "iq",
+
+	body = "message",
+	html = "message",
+	replace = "message",
+	state = "message",
+	subject = "message",
+	thread = "message",
+
+	join = "presence",
+	priority = "presence",
+	show = "presence",
+	status = "presence",
+}
+
+local kind_by_type = {
+	get = "iq", set = "iq", result = "iq",
+	normal = "message", chat = "message", headline = "message", groupchat = "message",
+	available = "presence", unavailable = "presence",
+	subscribe = "presence", unsubscribe = "presence",
+	subscribed = "presence", unsubscribed = "presence",
+}
+
+local function st2json(s)
+	local t = {
+		kind = s.name,
+		type = s.attr.type,
+		to = s.attr.to,
+		from = s.attr.from,
+		id = s.attr.id,
+	};
+	if s.name == "presence" and not s.attr.type then
+		t.type = "available";
+	end
+
+	if t.to then
+		t.to = jid.prep(t.to);
+		if not t.to then return nil, "invalid-jid-to"; end
+	end
+	if t.from then
+		t.from = jid.prep(t.from);
+		if not t.from then return nil, "invalid-jid-from"; end
+	end
+
+	if t.type == "error" then
+		local err_typ, err_condition, err_text = s:get_error();
+		t.error = {
+			type = err_typ,
+			condition = err_condition,
+			text = err_text
+		};
+		return t;
+	end
+
+	for k, typ in pairs(simple_types) do
+		if typ == "text_tag" then
+			t[k] = s:get_child_text(k);
+		elseif typ[1] == "text_tag" then
+			t[k] = s:get_child_text(typ[3], typ[2]);
+		elseif typ[1] == "name" then
+			local child = s:get_child(nil, typ[2]);
+			if child then
+				t[k] = child.name;
+			end
+		elseif typ[1] == "attr" then
+			local child = s:get_child(typ[3], typ[2])
+			if child then
+				t[k] = child.attr[typ[4]];
+			end
+		elseif typ[1] == "bool_tag" then
+			if s:get_child(typ[3], typ[2]) then
+				t[k] = true;
+			end
+		elseif typ[1] == "func" then
+			local child = s:get_child(typ[3], typ[2] or k);
+			-- TODO handle err
+			if child then
+				t[k] = typ[4](child);
+			end
+		end
+	end
+
+	return t;
+end
+
+local function json2st(t)
+	local kind = t.kind or kind_by_type[t.type];
+	if not kind then
+		for k, implied in pairs(implied_kinds) do
+			if t[k] then
+				kind = implied;
+				break
+			end
+		end
+	end
+
+	local s = st.stanza(kind or "message", {
+		type = t.type ~= "available" and t.type or nil,
+		to = jid.prep(t.to);
+		from = jid.prep(t.from);
+		id = t.id,
+	});
+
+	if t.to and not s.attr.to then
+		return nil, "invalid-jid-to";
+	end
+	if t.from and not s.attr.from then
+		return nil, "invalid-jid-from";
+	end
+
+	if t.error then
+		return st.error_reply(st.reply(s), t.error.type, t.error.condition, t.error.text);
+	elseif t.type == "error" then
+		s:text_tag("error", t.body, { code = t.error_code and tostring(t.error_code) });
+		return s;
+	end
+
+	for k, v in pairs(t) do
+		local typ = simple_types[k];
+		if typ then
+			if typ == "text_tag" then
+				s:text_tag(k, v);
+			elseif typ[1] == "text_tag" then
+				s:text_tag(typ[3] or k, v, typ[2] and { xmlns = typ[2] });
+			elseif typ[1] == "name" then
+				s:tag(v, { xmlns = typ[2] }):up();
+			elseif typ[1] == "attr" then
+				s:tag(typ[3] or k, { xmlns = typ[2], [ typ[4] or k ] = v }):up();
+			elseif typ[1] == "bool_tag" then
+				s:tag(typ[3] or k, { xmlns = typ[2] }):up();
+			elseif typ[1] == "func" then
+				s:add_child(typ[5](v)):up();
+			end
+		end
+	end
+
+	s:reset();
+
+	return s;
+end
+
+return {
+	st2json = st2json;
+	json2st = json2st;
+};
--- a/mod_rest/mod_rest.lua	Wed Jan 01 16:19:10 2020 +0100
+++ b/mod_rest/mod_rest.lua	Wed Jan 01 16:21:28 2020 +0100
@@ -8,6 +8,7 @@
 local http = require "net.http";
 local id = require "util.id";
 local jid = require "util.jid";
+local json = require "util.json";
 local st = require "util.stanza";
 local xml = require "util.xml";
 
@@ -17,6 +18,7 @@
 local auth_type = assert(secret:match("^%S+"), "Format of rest_credentials MUST be like 'Bearer secret'");
 assert(auth_type == "Bearer", "Only 'Bearer' is supported in rest_credentials");
 
+local jsonmap = module:require"jsonmap";
 -- Bearer token
 local function check_credentials(request)
 	return request.headers.authorization == secret;
@@ -26,17 +28,40 @@
 	mimetype = mimetype:match("^[^; ]*");
 	if mimetype == "application/xmpp+xml" then
 		return xml.parse(data);
+	elseif mimetype == "application/json" then
+		local parsed, err = json.decode(data);
+		if not parsed then
+			return parsed, err;
+		end
+		return jsonmap.json2st(parsed);
 	elseif mimetype == "text/plain" then
 		return st.message({ type = "chat" }, data);
 	end
 	return nil, "unknown-payload-type";
 end
 
-local function decide_type()
-	return "application/xmpp+xml";
+local supported_types = { "application/xmpp+xml", "application/json" };
+
+local function decide_type(accept)
+	-- assumes the accept header is sorted
+	local ret = supported_types[1];
+	if not accept then
+		return ret;
+	end
+	for i = 2, #supported_types do
+		if (accept:find(supported_types[i], 1, true) or 1000) < (accept:find(ret, 1, true) or 1000) then
+			ret = supported_types[i];
+		end
+	end
+	return ret;
 end
 
 local function encode(type, s)
+	if type == "application/json" then
+		return json.encode(jsonmap.st2json(s));
+	elseif type == "text/plain" then
+		return s:get_child_text("body") or "";
+	end
 	return tostring(s);
 end
 
@@ -128,6 +153,9 @@
 local rest_url = module:get_option_string("rest_callback_url", nil);
 if rest_url then
 	local send_type = module:get_option_string("rest_callback_content_type", "application/xmpp+xml");
+	if send_type == "json" then
+		send_type = "application/json";
+	end
 
 	local code2err = {
 		[400] = { condition = "bad-request"; type = "modify" };
@@ -176,7 +204,7 @@
 				headers = {
 					["Content-Type"] = send_type,
 					["Content-Language"] = stanza.attr["xml:lang"],
-					Accept = "application/xmpp+xml, text/plain",
+					Accept = table.concat(supported_types, ", ");
 				},
 			}, function (body, code, response)
 				if (code == 202 or code == 204) and not reply_needed then