MQTTSuite
Loading...
Searching...
No Matches
MappingAdminRouter.cpp
Go to the documentation of this file.
1/*
2 * MQTTSuite - A lightweight MQTT Integration System
3 * Copyright (C) Tobias Pfeil
4 * 2025, 2026
5 *
6 * This program is free software: you can redistribute it and/or modify it
7 * under the terms of the GNU General Public License as published by the Free
8 * Software Foundation, either version 3 of the License, or (at your option)
9 * any later version.
10 *
11 * This program is distributed in the hope that it will be useful, but WITHOUT
12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14 * more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20/*
21 * MIT License
22 *
23 * Permission is hereby granted, free of charge, to any person obtaining a copy
24 * of this software and associated documentation files (the "Software"), to deal
25 * in the Software without restriction, including without limitation the rights
26 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
27 * copies of the Software, and to permit persons to whom the Software is
28 * furnished to do so, subject to the following conditions:
29 *
30 * The above copyright notice and this permission notice shall be included in
31 * all copies or substantial portions of the Software.
32 *
33 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
34 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
35 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
36 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
37 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
38 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
39 * THE SOFTWARE.
40 */
41
43
46#include "MqttMapper.h"
47
48#include <express/middleware/BasicAuthentication.h>
49#include <express/middleware/JsonMiddleware.h>
50#include <express/middleware/StaticMiddleware.h>
51
52#ifndef DOXYGEN_SHOULD_SKIP_THIS
53
54#include "nlohmann/json-schema.hpp"
55
56#include <exception>
57#include <filesystem>
58#include <fstream>
59#include <vector>
60
61// IWYU pragma: no_include <nlohmann/detail/json_ref.hpp>
62
63#endif // DOXYGEN_SHOULD_SKIP_THIS
64
65namespace mqtt::lib::admin {
66
67 express::Router makeMappingAdminRouter(ConfigApplication* configApplication, const AdminOptions& opt, ReloadCallback onDeploy) {
68 express::Router api;
69
70 api.use(express::middleware::JsonMiddleware());
71 api.use(express::middleware::BasicAuthentication(opt.user, opt.pass, opt.realm));
72
73 // GET /schema
74 api.get("/schema", [] APPLICATION(req, res) {
75 res->status(200).send(MqttMapper::getSchema());
76 });
77
78 // GET /config
79 api.get("/config", [configApplication] APPLICATION(req, res) {
80 try {
81 res->status(200).json(configApplication->getMqttMapper()->getMapping());
82 } catch (const std::exception& e) {
83 res->status(500).json({{"error", "Failed to load configuration"}, {"details", e.what()}});
84 }
85 });
86
87 // PATCH /config
88 api.patch("/config", [configApplication] APPLICATION(req, res) {
89 try {
90 const std::string bodyStr(req->body.begin(), req->body.end());
91 nlohmann::json patchOps = nlohmann::json::parse(bodyStr);
92
93 nlohmann::json current = configApplication->getMqttMapper()->getMapping();
94 current = current.patch(patchOps);
95
97
98 res->status(200).json({{"status", "patched"}, {"path", configApplication->getMappingFilename()}});
99 } catch (const nlohmann::json::parse_error& e) {
100 res->status(400).json({{"error", "Invalid JSON body"}, {"details", e.what()}});
101 } catch (const std::exception& e) {
102 res->status(422).json({{"error", "Patch application failed"}, {"details", e.what()}});
103 }
104 });
105
106 // POST /config (replace full draft config)
107 api.post("/config", [configApplication] APPLICATION(req, res) {
108 try {
109 const std::string bodyStr(req->body.begin(), req->body.end());
110 nlohmann::json replacement = nlohmann::json::parse(bodyStr);
111
112 if (!replacement.is_object()) {
113 res->status(422).json({{"error", "Config replacement must be a JSON object"}});
114 return;
115 }
116
118
119 res->status(200).json({{"status", "replaced"}, {"path", configApplication->getMappingFilename()}});
120 } catch (const nlohmann::json::parse_error& e) {
121 res->status(400).json({{"error", "Invalid JSON body"}, {"details", e.what()}});
122 } catch (const std::exception& e) {
123 res->status(422).json({{"error", "Config replacement failed"}, {"details", e.what()}});
124 }
125 });
126
127 // POST /config/deploy
128 api.post("/config/deploy", [configApplication, onDeploy] APPLICATION(req, res) {
129 try {
130 nlohmann::json newMappingJson = JsonMappingReader::deployDraft(configApplication->getMappingFilename());
131
132 bool mustReconnect = configApplication->getMqttMapper()->setMapping(newMappingJson); // throws in case of an error during
133 // loading or validation. This exeption
134 // is catched in the MappingAdminRouter
135 configApplication->persistMapping();
136
137 if (onDeploy) {
138 ReloadResult reloadResult = onDeploy(mustReconnect);
139
140 res->status(200).json({{"status", "deploy-ack"},
141 {"reload_mode", reloadResult.mode},
142 {"instances", reloadResult.instances},
143 {"subscribed", reloadResult.subscribed},
144 {"unsubscribed", reloadResult.unsubscribed}});
145 } else {
146 res->status(200).json(
147 {{"status", "deploy-ack"}, {"reload_mode", "none"}, {"instances", 0}, {"subscribed", 0}, {"unsubscribed", 0}});
148 }
149 } catch (const std::exception& e) {
150 res->status(500).json({{"error", "Deploy failed"}, {"details", e.what()}});
151 }
152 });
153
154 // POST /config/validate
155 api.post("/config/validate", [] APPLICATION(req, res) {
156 try {
157 const std::string bodyStr(req->body.begin(), req->body.end());
158 auto document = nlohmann::json::parse(bodyStr);
159
161 MqttMapper::validate(document, err);
162
163 if (err) {
164 res->status(422).json({{"valid", false}, {"error", "Validation failed"}});
165 } else {
166 res->status(200).json({{"valid", true}});
167 }
168 } catch (const std::exception& e) {
169 res->status(400).json({{"error", "Validation exception"}, {"details", e.what()}});
170 }
171 });
172
173 // GET /config/validateDraft
174 api.get("/config/validateDraft", [configApplication] APPLICATION(req, res) {
175 try {
176 const std::string draftPath = JsonMappingReader::getDraftPath(configApplication->getMappingFilename());
177
178 if (!std::filesystem::exists(draftPath)) {
179 res->status(404).json({{"valid", false}, {"error", "No draft configuration available"}, {"path", draftPath}});
180 return;
181 }
182
183 std::ifstream draftFile(draftPath);
184 if (!draftFile) {
185 res->status(500).json({{"valid", false}, {"error", "Cannot open draft configuration"}, {"path", draftPath}});
186 return;
187 }
188
189 nlohmann::json draftDocument;
190 draftFile >> draftDocument;
191
193 MqttMapper::validate(draftDocument, err);
194
195 if (err) {
196 res->status(422).json({{"valid", false}, {"error", "Draft validation failed"}, {"path", draftPath}});
197 } else {
198 res->status(200).json({{"valid", true}, {"path", draftPath}});
199 }
200 } catch (const std::exception& e) {
201 res->status(400).json({{"valid", false}, {"error", "Draft validation exception"}, {"details", e.what()}});
202 }
203 });
204
205 // POST /config/rollback
206 api.post("/config/rollback", [configApplication, onDeploy] APPLICATION(req, res) {
207 try {
208 const std::string bodyStr(req->body.begin(), req->body.end());
209 auto jsonBody = nlohmann::json::parse(bodyStr);
210
211 if (!jsonBody.contains("version_id")) {
212 res->status(400).json({{"error", "Missing version_id"}});
213 return;
214 }
215
216 std::string versionId = jsonBody["version_id"];
217
218 nlohmann::json rolledbackMappingJson = JsonMappingReader::rollbackTo(configApplication->getMappingFilename(), versionId);
219
220 bool mustReconnect =
221 configApplication->getMqttMapper()->setMapping(rolledbackMappingJson); // throws in case of an error during loading
222 // or validation. This exeption is catched
223 // in the MappingAdminRouter
224 configApplication->persistMapping();
225
226 ReloadResult reloadResult;
227 if (onDeploy) {
228 reloadResult = onDeploy(mustReconnect); // Trigger hot-reload
229 }
230
231 res->status(200).json({{"status", "deploy-ack"},
232 {"reload_mode", reloadResult.mode},
233 {"instances", reloadResult.instances},
234 {"subscribed", reloadResult.subscribed},
235 {"unsubscribed", reloadResult.unsubscribed}});
236 } catch (const std::exception& e) {
237 res->status(500).json({{"error", "Rollback failed"}, {"details", e.what()}});
238 }
239 });
240
241 // GET /config/history
242 api.get("/config/history", [configApplication] APPLICATION(req, res) {
243 try {
244 auto history = JsonMappingReader::getHistory(configApplication->getMappingFilename());
245 nlohmann::json list = nlohmann::json::array();
246 for (const auto& h : history) {
247 list.push_back({{"id", h.id}, {"comment", h.comment}, {"date", h.date}});
248 }
249 res->status(200).json(list);
250 } catch ([[maybe_unused]] const std::exception& e) {
251 res->status(500).json({{"error", "Failed to fetch history"}});
252 }
253 });
254
255 api.get("/", [] APPLICATION(req, res) {
256 res->redirect("/ui");
257 });
258
259 api.get("/ui", [] APPLICATION(req, res) {
260 res->redirect("/ui/index.html");
261 });
262
263 api.use("/ui",
264 express::middleware::StaticMiddleware("/home/voc/tmp/integrator/mqtt-integrator-ui/dist/mqtt-integrator-ui/browser"));
265
266 api.get("*", [] APPLICATION(req, res) {
267 res->redirect("/ui/index.html");
268 });
269
270 return api;
271 }
272
273} // namespace mqtt::lib::admin
std::string getMappingFilename() const
const std::shared_ptr< MqttMapper > getMqttMapper() const
static nlohmann::json deployDraft(const std::string &mapFilePath)
static nlohmann::json rollbackTo(const std::string &mapFilePath, const std::string &versionId)
static std::vector< VersionEntry > getHistory(const std::string &mapFilePath)
static std::string getDraftPath(const std::string &mapFilePath)
static void saveDraft(const std::string &mapFilePath, const nlohmann::json &content)
bool setMapping(nlohmann::json mappingJson)
static const nlohmann::json validate(const nlohmann::json &json, nlohmann::json_schema::basic_error_handler &err)
const nlohmann::json & getMapping() const
static const std::string & getSchema()
express::Router makeMappingAdminRouter(ConfigApplication *configApplication, const AdminOptions &opt, ReloadCallback onDeploy)
std::function< ReloadResult(bool)> ReloadCallback