# HG changeset patch # User Kim Alvefur # Date 1577892088 -3600 # Node ID aa1ad69c7c10e8bc23a23051557d17262b15238f # Parent f027b8b1e794a97f1ce1ff36f92ce184d2c4c2b9 mod_rest: Add JSON support diff -r f027b8b1e794 -r aa1ad69c7c10 mod_rest/README.markdown --- 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 @@ ' ``` -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 Hello ``` +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} ... @@ -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 diff -r f027b8b1e794 -r aa1ad69c7c10 mod_rest/jsonmap.lib.lua --- /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([[]]..s..[[]]); + 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; +}; diff -r f027b8b1e794 -r aa1ad69c7c10 mod_rest/mod_rest.lua --- 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