morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
route.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2#pragma once
3
4#include <algorithm>
5#include <charconv>
6#include <cstddef>
7#include <cstdint>
8#include <expected>
9#include <functional>
10#include <limits>
11#include <memory>
12#include <optional>
13#include <string>
14#include <string_view>
15#include <system_error>
16#include <utility>
17#include <vector>
18
19#include "../util/rational.hpp"
20#include "document.hpp"
21#include "value.hpp"
22
23/// @file
24/// @brief Routes (spec 6 §20): the string form of a screen instance, `<screen id>?<param>=<value>&…`, written from a
25/// screen's declared params and read back against them.
26///
27/// Specified in `docs/spec/ui/host.md`.
28
29namespace morph::ui {
30
31/// @brief A route read back: the screen and its params.
32struct Route {
33 /// @brief The screen id.
34 std::string screen;
35 /// @brief The params, by name; a null optional is absent.
37
38 /// @brief Equality of screen and params.
39 /// @param other The route to compare with.
40 /// @return Whether both are equal.
41 [[nodiscard]] bool operator==(Route const& other) const = default;
42};
43
44namespace detail::route {
45
46/// @brief Whether a byte is an RFC 3986 unreserved character, written as is in a route.
47/// @param byte The byte.
48/// @return It.
49[[nodiscard]] constexpr bool unreserved(char byte) noexcept {
50 return (byte >= 'A' && byte <= 'Z') || (byte >= 'a' && byte <= 'z') || (byte >= '0' && byte <= '9') ||
51 byte == '-' || byte == '.' || byte == '_' || byte == '~';
52}
53
54/// @brief Percent-encodes every byte that is not an RFC 3986 unreserved character.
55/// @param text The text.
56/// @return It, encoded.
57[[nodiscard]] inline std::string encode(std::string_view text) {
58 static constexpr std::string_view kHex = "0123456789ABCDEF";
59 std::string out;
60 out.reserve(text.size());
61 for (char const byte : text) {
62 if (unreserved(byte)) {
63 out += byte;
64 } else {
65 auto const code = static_cast<unsigned char>(byte);
66 out += '%';
67 out += kHex.at(code >> 4U);
68 out += kHex.at(code & 0x0FU);
69 }
70 }
71 return out;
72}
73
74/// @brief The value of a hexadecimal digit.
75/// @param digit The digit.
76/// @return 0 to 15, or -1 for a byte that is not one.
77[[nodiscard]] constexpr int hexDigit(char digit) noexcept {
78 if (digit >= '0' && digit <= '9') {
79 return digit - '0';
80 }
81 if (digit >= 'A' && digit <= 'F') {
82 return digit - 'A' + 10;
83 }
84 if (digit >= 'a' && digit <= 'f') {
85 return digit - 'a' + 10;
86 }
87 return -1;
88}
89
90/// @brief Decodes `%XX` escapes.
91/// @param text The text.
92/// @return It, decoded; nothing for a truncated or non-hexadecimal escape.
93[[nodiscard]] inline std::optional<std::string> decode(std::string_view text) {
94 std::string out;
95 out.reserve(text.size());
96 for (std::size_t i = 0; i < text.size(); ++i) {
97 char const byte = text.at(i);
98 if (byte != '%') {
99 out += byte;
100 continue;
101 }
102 if (i + 2 >= text.size()) {
103 return std::nullopt;
104 }
105 int const high = hexDigit(text.at(i + 1));
106 int const low = hexDigit(text.at(i + 2));
107 if (high < 0 || low < 0) {
108 return std::nullopt;
109 }
110 out += static_cast<char>((static_cast<unsigned>(high) << 4U) | static_cast<unsigned>(low));
111 i += 2;
112 }
113 return out;
114}
115
116/// @brief Reads a whole decimal integer.
117/// @param text The text.
118/// @return The integer; nothing for anything else, an empty text and a `+` sign included.
119[[nodiscard]] inline std::optional<std::int64_t> integer(std::string_view text) {
120 std::int64_t number = 0;
121 char const* const last = std::to_address(text.end());
122 auto const [end, error] = std::from_chars(std::to_address(text.begin()), last, number);
123 if (error != std::errc{} || end != last || text.empty()) {
124 return std::nullopt;
125 }
126 return number;
127}
128
129/// @brief The canonical text of a param's value.
130/// @param type The param's declared type.
131/// @param value The value.
132/// @return The text; nothing for a type with no route form, or a value not of the type.
133[[nodiscard]] inline std::optional<std::string> textOf(Type const& type, Value const& value) {
134 switch (type.kind) {
135 case TypeKind::Int:
136 if (std::int64_t const* const number = value.asInt()) {
137 return std::to_string(*number);
138 }
139 return std::nullopt;
140 case TypeKind::Bool:
141 if (bool const* const flag = value.asBool()) {
142 return *flag ? "true" : "false";
143 }
144 return std::nullopt;
145 case TypeKind::String:
146 if (std::string const* const text = value.asString()) {
147 return encode(*text);
148 }
149 return std::nullopt;
151 if (Decimal const* const number = value.asDecimal()) {
152 return std::to_string(number->value.numerator) + "/" + std::to_string(number->value.denominator) +
153 "@" + std::to_string(number->value.decimalPlaces.value);
154 }
155 return std::nullopt;
156 case TypeKind::Key:
157 if (std::int64_t const* const number = value.asInt()) {
158 return "i:" + std::to_string(*number);
159 }
160 if (std::string const* const text = value.asString()) {
161 return "s:" + encode(*text);
162 }
163 return std::nullopt;
164 case TypeKind::Any:
167 case TypeKind::Record:
168 case TypeKind::List:
169 default:
170 return std::nullopt;
171 }
172}
173
174/// @brief Reads a param's text.
175/// @param type The param's declared type.
176/// @param text The text.
177/// @return The value; nothing when the text does not parse as the type.
178[[nodiscard]] inline std::optional<Value> valueOf(Type const& type, std::string_view text) {
179 switch (type.kind) {
180 case TypeKind::Int:
181 if (auto const number = integer(text)) {
182 return Value{*number};
183 }
184 return std::nullopt;
185 case TypeKind::Bool:
186 if (text == "true" || text == "false") {
187 return Value{text == "true"};
188 }
189 return std::nullopt;
190 case TypeKind::String:
191 if (auto decoded = decode(text)) {
192 return Value{std::move(*decoded)};
193 }
194 return std::nullopt;
195 case TypeKind::Decimal: {
196 auto const slash = text.find('/');
197 auto const atSign = text.find('@');
198 if (slash == std::string_view::npos || atSign == std::string_view::npos || atSign < slash) {
199 return std::nullopt;
200 }
201 auto const num = integer(text.substr(0, slash));
202 auto const den = integer(text.substr(slash + 1, atSign - slash - 1));
203 auto const places = integer(text.substr(atSign + 1));
204 if (!num || !den || !places || *den <= 0 || *places < 0 ||
205 std::cmp_greater(*places, math::kMaxDecimalPlaces)) {
206 return std::nullopt;
207 }
209 math::DecimalPlaces{static_cast<std::uint32_t>(*places)}}}};
210 }
211 case TypeKind::Key:
212 if (text.starts_with("i:")) {
213 if (auto const number = integer(text.substr(2))) {
214 return Value{*number};
215 }
216 return std::nullopt;
217 }
218 if (text.starts_with("s:")) {
219 if (auto decoded = decode(text.substr(2))) {
220 return Value{std::move(*decoded)};
221 }
222 }
223 return std::nullopt;
224 case TypeKind::Any:
227 case TypeKind::Record:
228 case TypeKind::List:
229 default:
230 return std::nullopt;
231 }
232}
233
234} // namespace detail::route
235
236/// @brief Writes the route of a screen instance: its id, then each param the screen declares that @p params gives,
237/// in declaration order, as `name=value` joined by `&` after a `?`.
238///
239/// A param's value is the canonical text of its declared type: an `int` in decimal, a `bool` as `true` or `false`, a
240/// `decimal` as `num/den@dp` (the canonical fraction and the places), a `key` as `i:<int64>` or `s:<text>`, and a
241/// `string` percent-encoded (every byte but RFC 3986's unreserved characters). A null or absent optional is left
242/// out. `quantity`, `timestamp`, list and record params have no route form.
243/// @param screen The screen id.
244/// @param declared The screen's declared params (`Document::params`), in order.
245/// @param params The params of this instance.
246/// @return The route, or why the instance has none: a param of a type with no route form, a required param that is
247/// missing or null, or a value that does not have its declared type.
248[[nodiscard]] inline std::expected<std::string, std::string> routeOf(
249 std::string_view screen, std::vector<std::pair<std::string, Type>> const& declared, ValueRecord const& params) {
250 std::string route{screen};
251 char separator = '?';
252 for (auto const& [name, type] : declared) {
253 auto const given = params.find(name);
254 if (given == params.end() || given->second.isNull()) {
255 if (!type.optional) {
256 return std::unexpected("the required param " + name + " is missing");
257 }
258 continue;
259 }
260 auto text = detail::route::textOf(type, given->second);
261 if (!text) {
262 return std::unexpected("the param " + name + " has no route form for its value");
263 }
264 route += separator;
265 route += detail::route::encode(name);
266 route += '=';
267 route += *text;
268 separator = '&';
269 }
270 return route;
271}
272
273/// @brief Writes the route of a mounted document's instance: `routeOf` with the document's id and declared params.
274/// @param document The document.
275/// @param params The params of this instance.
276/// @return The route, or why it has none.
277[[nodiscard]] inline std::expected<std::string, std::string> routeOf(Document const& document,
278 ValueRecord const& params) {
279 return routeOf(document.id, document.params, params);
280}
281
282/// @brief Reads a route back against the screen it names.
283///
284/// Refused, with the route and the reason: a screen @p lookup does not know, a param the screen does not declare or
285/// gives twice, a value that does not parse as its declared type, and a required param that is missing.
286/// @param route The route text.
287/// @param lookup The document of a screen id, or null for an unknown one.
288/// @return The screen and its params, or why the route is refused.
289[[nodiscard]] inline std::expected<Route, std::string> parseRoute(
290 std::string_view route, std::function<std::shared_ptr<Document const>(std::string_view)> const& lookup) {
291 auto const refuse = [route](std::string const& reason) {
292 return std::unexpected("route " + std::string{route} + ": " + reason);
293 };
294 auto const question = route.find('?');
295 Route parsed{.screen = std::string{route.substr(0, question)}, .params = {}};
296 std::shared_ptr<Document const> const document = lookup ? lookup(parsed.screen) : nullptr;
297 if (!document) {
298 return refuse("unknown screen " + parsed.screen);
299 }
300 std::string_view rest = question == std::string_view::npos ? std::string_view{} : route.substr(question + 1);
301 while (!rest.empty()) {
302 auto const amp = rest.find('&');
303 std::string_view const pair = rest.substr(0, amp);
304 rest = amp == std::string_view::npos ? std::string_view{} : rest.substr(amp + 1);
305 auto const equals = pair.find('=');
306 if (equals == std::string_view::npos) {
307 return refuse("a param without a value");
308 }
309 auto name = detail::route::decode(pair.substr(0, equals));
310 if (!name) {
311 return refuse("a param name that does not decode");
312 }
313 auto const declared = std::ranges::find(document->params, *name, &std::pair<std::string, Type>::first);
314 if (declared == document->params.end()) {
315 return refuse("the screen declares no param " + *name);
316 }
317 if (parsed.params.contains(*name)) {
318 return refuse("the param " + *name + " is given twice");
319 }
320 auto value = detail::route::valueOf(declared->second, pair.substr(equals + 1));
321 if (!value) {
322 return refuse("the param " + *name + " does not parse as its type");
323 }
324 parsed.params.emplace(std::move(*name), std::move(*value));
325 }
326 for (auto const& [name, type] : document->params) {
327 if (!type.optional && !parsed.params.contains(name)) {
328 return refuse("the required param " + name + " is missing");
329 }
330 }
331 return parsed;
332}
333
334} // namespace morph::ui
The document's typed value (spec 5 §3): what every expression evaluates to.
Definition value.hpp:157
Decimal const * asDecimal() const noexcept
The decimal, if held.
Definition value.hpp:228
bool const * asBool() const noexcept
The bool, if held.
Definition value.hpp:216
std::int64_t const * asInt() const noexcept
The int, if held.
Definition value.hpp:220
std::string const * asString() const noexcept
The string, if held.
Definition value.hpp:224
The UI document (spec 5 §3): its model — declarations, commands, view nodes, components and customiza...
Exact rational arithmetic for morph model/action values.
constexpr std::uint32_t kMaxDecimalPlaces
Largest decimal precision the type supports: 10^18 is the greatest power of ten that fits in std::int...
Definition rational.hpp:167
std::expected< std::string, std::string > routeOf(std::string_view screen, std::vector< std::pair< std::string, Type > > const &declared, ValueRecord const &params)
Writes the route of a screen instance: its id, then each param the screen declares that params gives,...
Definition route.hpp:248
constexpr bool unreserved(char byte) noexcept
Whether a byte is an RFC 3986 unreserved character, written as is in a route.
Definition route.hpp:49
std::optional< std::int64_t > integer(std::string_view text)
Reads a whole decimal integer.
Definition route.hpp:119
std::string encode(std::string_view text)
Percent-encodes every byte that is not an RFC 3986 unreserved character.
Definition route.hpp:57
std::optional< std::string > textOf(Type const &type, Value const &value)
The canonical text of a param's value.
Definition route.hpp:133
constexpr int hexDigit(char digit) noexcept
The value of a hexadecimal digit.
Definition route.hpp:77
std::optional< Value > valueOf(Type const &type, std::string_view text)
Reads a param's text.
Definition route.hpp:178
std::expected< Route, std::string > parseRoute(std::string_view route, std::function< std::shared_ptr< Document const >(std::string_view)> const &lookup)
Reads a route back against the screen it names.
Definition route.hpp:289
std::optional< std::string > decode(std::string_view text)
Decodes XX escapes.
Definition route.hpp:93
Strong type for a decimal-precision count.
Definition rational.hpp:121
Strong type for a Rational denominator.
Definition rational.hpp:154
Strong type for a Rational numerator.
Definition rational.hpp:140
Exact rational number with a runtime decimal-precision tag.
Definition rational.hpp:375
An exact decimal: a math::Rational whose DecimalPlaces are the value's declared places.
Definition value.hpp:95
A loaded and validated UI document: a screen, or the app document (id app, spec 6 §5).
Definition document.hpp:536
std::string id
The document id, such as lims.results.
Definition document.hpp:540
std::vector< std::pair< std::string, Type > > params
The typed params, in declaration order.
Definition document.hpp:554
A route read back: the screen and its params.
Definition route.hpp:32
bool operator==(Route const &other) const =default
Equality of screen and params.
std::string screen
The screen id.
Definition route.hpp:34
ValueRecord params
The params, by name; a null optional is absent.
Definition route.hpp:36
A document type (spec 5 §3): a kind, whether null is allowed, and a list's element type.
Definition value.hpp:311
TypeKind kind
The kind.
Definition value.hpp:313
The UI document's typed values (ui::Value), its types (ui::Type), their JSON form,...
@ Quantity
quantity.
Definition value.hpp:300
@ Record
record: a record whose fields the document does not declare.
Definition value.hpp:303
@ Key
key: an int or a string.
Definition value.hpp:302
@ Decimal
decimal.
Definition value.hpp:299
@ Timestamp
timestamp.
Definition value.hpp:301
@ Any
Not known to the document: a query result, a reply, an element's value.
Definition value.hpp:295
std::map< std::string, Value, std::less<> > ValueRecord
A record value: field name to value, ordered by name.
Definition value.hpp:129
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194