Mercurial > prosody-modules
view mod_rest/jsonmap.lib.lua @ 5668:ecfd7aece33b
mod_measure_modules: Report module statuses via OpenMetrics
Someone in the chat asked about a health check endpoint, which reminded
me of mod_http_status, which provides access to module statuses with
full details. After that, this idea came about, which seems natural.
As noted in the README, it could be used to monitor that critical
modules are in fact loaded correctly.
As more modules use the status API, the more useful this module and
mod_http_status becomes.
author | Kim Alvefur <zash@zash.se> |
---|---|
date | Fri, 06 Oct 2023 18:34:39 +0200 |
parents | 048e339706ba |
children | e5b5a74feb91 |
line wrap: on
line source
local array = require "util.array"; local jid = require "util.jid"; local json = require "util.json"; local st = require "util.stanza"; local xml = require "util.xml"; local map = require "util.datamapper"; local schema do local f = assert(module:load_resource("res/schema-xmpp.json")); schema = json.decode(f:read("*a")) f:close(); -- Copy common properties to all stanza kinds if schema._common then for key, prop in pairs(schema._common) do for _, copyto in pairs(schema.properties) do copyto.properties[key] = prop; end end end end -- Some mappings that are still hard to do in a nice way with util.datamapper local field_mappings; -- in scope for "func" mappings field_mappings = { -- XEP-0071 html = { type = "func", xmlns = "http://jabber.org/protocol/xhtml-im", tagname = "html", st2json = function (s) --> json string return (tostring(s:get_child("body", "http://www.w3.org/1999/xhtml")):gsub(" xmlns='[^']*'", "", 1)); end; json2st = function (s) --> xml if type(s) == "string" then return assert(xml.parse("<x:html xmlns:x='http://jabber.org/protocol/xhtml-im' xmlns='http://www.w3.org/1999/xhtml'>" .. s .. "</x:html>")); end end; }; -- XEP-0030 disco = { type = "func", xmlns = "http://jabber.org/protocol/disco#info", tagname = "query", st2json = function (s) --> array of features if s.tags[1] == nil then return s.attr.node or true; end local identities, features, extensions = array(), array(), {}; -- features and identities could be done with util.datamapper 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 -- Especially this would be hard to do with util.datamapper for form in s:childtags("x", "jabber:x:data") do local jform = field_mappings.formdata.st2json(form); local form_type = jform["FORM_TYPE"]; if jform then jform["FORM_TYPE"] = nil; extensions[form_type] = jform; end end if next(extensions) == nil then extensions = nil; end return { node = s.attr.node, identities = identities, features = features, extensions = extensions }; end; json2st = function (s) if type(s) == "table" and s ~= json.null then local disco = st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#info", node = s.node }); if s.identities then for _, identity in ipairs(s.identities) do disco:tag("identity", { category = identity.category, type = identity.type, name = identity.name }):up(); end end if s.features then for _, feature in ipairs(s.features) do disco:tag("feature", { var = feature }):up(); end end if s.extensions then for form_type, extension in pairs(s.extensions) do extension["FORM_TYPE"] = form_type; disco:add_child(field_mappings.formdata.json2st(extension)); end end return disco; elseif type(s) == "string" then return st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#info", node = s }); else return st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#info", }); end end; }; items = { type = "func", xmlns = "http://jabber.org/protocol/disco#items", tagname = "query", st2json = function (s) --> array of features | map with node if s.tags[1] == nil then return s.attr.node or true; end 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; json2st = function (s) if type(s) == "table" and s ~= json.null then local disco = st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#items", node = s.node }); for _, item in ipairs(s) do if type(item) == "string" then disco:tag("item", { jid = item }); elseif type(item) == "table" then disco:tag("item", { jid = item.jid, node = item.node, name = item.name }); end end return disco; elseif type(s) == "string" then return st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#items", node = s }); else return st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#items", }); end end; }; -- XEP-0050: Ad-Hoc Commands command = { type = "func", xmlns = "http://jabber.org/protocol/commands", tagname = "command", st2json = function (s) local cmd = { action = s.attr.action, node = s.attr.node, sessionid = s.attr.sessionid, status = s.attr.status, }; local actions = s:get_child("actions"); local note = s:get_child("note"); local form = s:get_child("x", "jabber:x:data"); if actions then cmd.actions = { execute = actions.attr.execute, }; for action in actions:childtags() do cmd.actions[action.name] = true end elseif note then cmd.note = { type = note.attr.type; text = note:get_text(); }; end if form then cmd.form = field_mappings.dataform.st2json(form); end return cmd; end; json2st = function (s) if type(s) == "table" and s ~= json.null then local cmd = st.stanza("command", { xmlns = "http://jabber.org/protocol/commands", action = s.action, node = s.node, sessionid = s.sessionid, status = s.status, }); if type(s.actions) == "table" then cmd:tag("actions", { execute = s.actions.execute }); do if s.actions.next == true then cmd:tag("next"):up(); end if s.actions.prev == true then cmd:tag("prev"):up(); end if s.actions.complete == true then cmd:tag("complete"):up(); end end cmd:up(); elseif type(s.note) == "table" then cmd:text_tag("note", s.note.text, { type = s.note.type }); end if s.form then cmd:add_child(field_mappings.dataform.json2st(s.form)); elseif s.data then cmd:add_child(field_mappings.formdata.json2st(s.data)); end return cmd; elseif type(s) == "string" then -- assume node return st.stanza("command", { xmlns = "http://jabber.org/protocol/commands", node = s }); end -- else .. missing required attribute end; }; -- XEP-0066: Out of Band Data -- TODO Replace by oob.url in datamapper schema oob_url = { type = "func", xmlns = "jabber:x:oob", tagname = "x", -- XXX namespace depends on whether it's in an iq or message stanza st2json = function (s) return s:get_child_text("url"); end; json2st = function (s) if type(s) == "string" then return st.stanza("x", { xmlns = "jabber:x:oob" }):text_tag("url", s); end end; }; -- XEP-0004: Data Forms dataform = { -- Generic and complete dataforms mapping type = "func", xmlns = "jabber:x:data", tagname = "x", st2json = function (s) local fields = array(); local form = { type = s.attr.type; title = s:get_child_text("title"); instructions = s:get_child_text("instructions"); fields = fields; }; for field in s:childtags("field") do local i = { var = field.attr.var; type = field.attr.type; label = field.attr.label; desc = field:get_child_text("desc"); required = field:get_child("required") and true or nil; value = field:get_child_text("value"); }; if field.attr.type == "jid-multi" or field.attr.type == "list-multi" or field.attr.type == "text-multi" then local value = array(); for v in field:childtags("value") do value:push(v:get_text()); end if field.attr.type == "text-multi" then i.value = value:concat("\n"); else i.value = value; end end if field.attr.type == "list-single" or field.attr.type == "list-multi" then local options = array(); for o in field:childtags("option") do options:push({ label = o.attr.label, value = o:get_child_text("value") }); end i.options = options; end fields:push(i); end return form; end; json2st = function (x) if type(x) == "table" and x ~= json.null then local form = st.stanza("x", { xmlns = "jabber:x:data", type = x.type }); if x.title then form:text_tag("title", x.title); end if x.instructions then form:text_tag("instructions", x.instructions); end if type(x.fields) == "table" then for _, f in ipairs(x.fields) do if type(f) == "table" then form:tag("field", { var = f.var, type = f.type, label = f.label }); if f.desc then form:text_tag("desc", f.desc); end if f.required == true then form:tag("required"):up(); end if type(f.value) == "string" then form:text_tag("value", f.value); elseif type(f.value) == "table" then for _, v in ipairs(f.value) do form:text_tag("value", v); end end if type(f.options) == "table" then for _, o in ipairs(f.value) do if type(o) == "table" then form:tag("option", { label = o.label }); form:text_tag("value", o.value); form:up(); end end end end end end return form; end end; }; -- Simpler mapping of dataform from JSON map formdata = { type = "func", xmlns = "jabber:x:data", tagname = "", st2json = function (s) local r = {}; for field in s:childtags("field") do if field.attr.var then local values = array(); for value in field:childtags("value") do values:push(value:get_text()); end if field.attr.type == "list-single" or field.attr.type == "list-multi" then r[field.attr.var] = values; elseif field.attr.type == "text-multi" then r[field.attr.var] = values:concat("\n"); elseif field.attr.type == "boolean" then r[field.attr.var] = values[1] == "1" or values[1] == "true"; elseif field.attr.type then r[field.attr.var] = values[1] or json.null; else -- type is optional, no way to know if multiple or single value is expected r[field.attr.var] = values; end end end return r; end, json2st = function (s, t) local form = st.stanza("x", { xmlns = "jabber:x:data", type = t }); for k, v in pairs(s) do form:tag("field", { var = k }); if type(v) == "string" then form:text_tag("value", v); elseif type(v) == "table" then for _, v_ in ipairs(v) do form:text_tag("value", v_); end end form:up(); end return form; end }; }; local byxmlname = {}; for k, spec in pairs(field_mappings) do for _, replace in pairs(schema.properties) do replace.properties[k] = nil end if type(spec) == "table" then spec.key = k; if spec.xmlns and spec.tagname then byxmlname["{" .. spec.xmlns .. "}" .. spec.tagname] = spec; elseif spec.type == "name" then byxmlname["{" .. spec.xmlns .. "}"] = spec; end elseif type(spec) == "string" then byxmlname["{jabber:client}" .. k] = {key = k; type = spec}; end end local implied_kinds = { disco = "iq", items = "iq", ping = "iq", version = "iq", command = "iq", archive = "iq", body = "message", html = "message", replace = "message", state = "message", subject = "message", thread = "message", join = "presence", priority = "presence", show = "presence", status = "presence", } local implied_types = { command = "set", archive = "set", } 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) if s.name == "xmpp" then local result = array(); for child in s:childtags() do result:push(st2json(child)); end return { xmpp = result }; end local t; do local wrap_s = st.stanza("xmpp", { xmlns = "jabber:client" }):add_child(s); local wrap_t = map.parse(schema, wrap_s); if not wrap_t then return nil, "parse"; end local kind; kind, t = next(wrap_t); if kind == nil then return nil, "parse"; end t.kind = kind; end 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 error = s:get_child("error"); local err_typ, err_condition, err_text = s:get_error(); t.error = { type = err_typ, condition = err_condition, text = err_text, by = error and error.attr.by or nil, }; return t; end if type(t.payload) == "table" then if type(t.payload.data) == "string" then local data, err = json.decode(t.payload.data); if err then return nil, err; else t.payload.data = data; end else return nil, "invalid payload.data"; end end for _, tag in ipairs(s.tags) do local prefix = "{" .. (tag.attr.xmlns or "jabber:client") .. "}"; local mapping = byxmlname[prefix .. tag.name]; if not mapping then mapping = byxmlname[prefix]; end if mapping and mapping.type == "func" and mapping.st2json then t[mapping.key] = mapping.st2json(tag); end end return t; end local function str(s) if type(s) == "string" then return s; end end local function json2st(t) if type(t) ~= "table" or not str(next(t)) then return nil, "invalid-json"; end local t_type = str(t.type); if t_type == nil then for k, implied in pairs(implied_types) do if t[k] then t_type = implied; break; end end end local kind = str(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 if kind == "presence" and t_type == "available" then t_type = nil; elseif kind == "iq" and not t_type then t_type = "get"; end if not schema.properties[kind or "message"] then return nil, "unknown-kind"; end -- XEP-0313 conveninece mapping if kind == "iq" and t_type == "set" and type(t.archive) == "table" and not t.archive.form then local archive = t.archive; if archive["with"] or archive["start"] or archive["end"] or archive["before-id"] or archive["after-id"] or archive["ids"] then if type(archive["ids"]) == "string" then local ids = {}; for id in archive["ids"]:gmatch("[^,]+") do table.insert(ids, id); end archive["ids"] = ids; end archive.form = { type = "submit"; fields = { { var = "FORM_TYPE"; values = { "urn:xmpp:mam:2" } }; { var = "with"; values = { archive["with"] } }; { var = "start"; values = { archive["start"] } }; { var = "end"; values = { archive["end"] } }; { var = "before-id"; values = { archive["before-id"] } }; { var = "after-id"; values = { archive["after-id"] } }; { var = "ids"; values = archive["ids"] }; }; }; archive["with"] = nil; archive["start"] = nil; archive["end"] = nil; archive["before-id"] = nil; archive["after-id"] = nil; archive["ids"] = nil; end if archive["after"] or archive["before"] or archive["max"] then archive.page = { after = archive["after"]; before = archive["before"]; max = tonumber(archive["max"]) } archive["after"] = nil; archive["before"] = nil; archive["max"] = nil; end end if type(t.payload) == "table" then t.payload.data = json.encode(t.payload.data); end if kind == "presence" and t.join == true and t.muc == nil then -- COMPAT Older boolean 'join' property used with XEP-0045 t.muc = {}; end local s = map.unparse(schema, { [kind or "message"] = t }).tags[1]; s.attr.type = t_type; s.attr.to = str(t.to) and jid.prep(t.to); s.attr.from = str(t.to) and jid.prep(t.from); if type(t.error) == "table" then return st.error_reply(st.reply(s), str(t.error.type), str(t.error.condition), str(t.error.text), str(t.error.by)); 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 mapping = field_mappings[k]; if mapping and mapping.type == "func" and mapping.json2st then s:add_child(mapping.json2st(v)):up(); end end s:reset(); return s; end return { st2json = st2json; json2st = json2st; };