morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
preferences.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <cstddef>
5#include <map>
6#include <memory>
7#include <optional>
8#include <stdexcept>
9#include <string>
10#include <string_view>
11#include <tuple>
12#include <utility>
13#include <vector>
14
15#include "../core/completion.hpp"
16#include "../core/coroutine.hpp"
17#include "../core/executor.hpp"
18#include "../core/model.hpp"
20#include "../core/registry.hpp"
21#include "../session/session.hpp"
22#include "value.hpp"
23
24/// @file
25/// @brief Per-person storage of customization choices (spec 5 §12b, spec 6 §8): the `IPreferencesStore` seam, the
26/// framework's in-memory store, and the server's preferences model, which keys every entry by the person the
27/// verified session names.
28///
29/// Specified in `docs/spec/ui/preferences.md`.
30
31namespace morph::ui {
32
33/// @brief Where one choice is stored.
34struct PrefKey {
35 /// @brief The person, derived on the server from the verified principal; never taken from a request.
36 std::string person;
37 /// @brief The application.
38 std::string app;
39 /// @brief The screen's id, or `app` for the app document's points.
40 std::string scope;
41 /// @brief The customization point.
42 std::string point;
43};
44
45/// @brief Stores customization choices as JSON text, by `PrefKey`.
46///
47/// Every call is asynchronous, so a store may sit on a database or a remote service. A store owns the executor its
48/// completions are delivered on. The preferences model calls it only with keys whose person the server derived and
49/// with values it has already bounded and parsed, so a store applies no bound of its own.
50class IPreferencesStore {
51public:
52 IPreferencesStore() = default;
53 IPreferencesStore(IPreferencesStore const&) = delete;
54 IPreferencesStore& operator=(IPreferencesStore const&) = delete;
55 IPreferencesStore(IPreferencesStore&&) = delete;
56 IPreferencesStore& operator=(IPreferencesStore&&) = delete;
57 virtual ~IPreferencesStore() = default;
58
59 /// @brief Reads a choice.
60 /// @param key The entry.
61 /// @return Its JSON text, or `std::nullopt` when nothing is stored.
62 [[nodiscard]] virtual async::Completion<std::optional<std::string>> get(PrefKey const& key) = 0;
63
64 /// @brief Writes a choice, replacing what was stored.
65 /// @param key The entry.
66 /// @param json Its JSON text.
67 /// @return Whether it was stored; `false` is a refusal the caller reports.
68 [[nodiscard]] virtual async::Completion<bool> set(PrefKey const& key, std::string json) = 0;
69
70 /// @brief Forgets a choice, so the point is at the document's default again.
71 /// @param key The entry.
72 /// @return Whether an entry was there.
73 [[nodiscard]] virtual async::Completion<bool> remove(PrefKey const& key) = 0;
74};
75
76/// @brief The framework's store: a map held in memory, on its own strand.
77///
78/// Its entries last as long as it does. Each call is a task on the strand, and its completion is delivered there.
79class MemoryPreferencesStore final : public IPreferencesStore {
80public:
81 /// @brief A store whose strand runs on @p base.
82 /// @param base Where the strand's tasks run. Borrowed: it must outlive the store.
83 explicit MemoryPreferencesStore(exec::IExecutor& base MORPH_LIFETIMEBOUND) : _strand{base} {}
84
85 /// @brief Reads a choice.
86 /// @param key The entry.
87 /// @return Its JSON text, or `std::nullopt` when nothing is stored.
88 [[nodiscard]] async::Completion<std::optional<std::string>> get(PrefKey const& key) override {
89 auto [completion, promise] = async::Completion<std::optional<std::string>>::makeSettleable(&_strand);
90 _strand.postTask([this, entry = tie(key), settle = std::move(promise)] mutable {
91 auto const found = _entries.find(entry);
92 settle.resolve(found == _entries.end() ? std::nullopt : std::optional<std::string>{found->second});
93 });
94 return std::move(completion);
95 }
96
97 /// @brief Writes a choice.
98 /// @param key The entry.
99 /// @param json Its JSON text.
100 /// @return `true`: the memory store refuses nothing.
101 [[nodiscard]] async::Completion<bool> set(PrefKey const& key, std::string json) override {
102 auto [completion, promise] = async::Completion<bool>::makeSettleable(&_strand);
103 _strand.postTask([this, entry = tie(key), text = std::move(json), settle = std::move(promise)] mutable {
104 _entries.insert_or_assign(std::move(entry), std::move(text));
105 settle.resolve(true);
106 });
107 return std::move(completion);
108 }
109
110 /// @brief Forgets a choice.
111 /// @param key The entry.
112 /// @return Whether an entry was there.
113 [[nodiscard]] async::Completion<bool> remove(PrefKey const& key) override {
114 auto [completion, promise] = async::Completion<bool>::makeSettleable(&_strand);
115 _strand.postTask([this, entry = tie(key), settle = std::move(promise)] mutable {
116 settle.resolve(_entries.erase(entry) != 0);
117 });
118 return std::move(completion);
119 }
120
121private:
122 using Key = std::tuple<std::string, std::string, std::string, std::string>;
123
124 [[nodiscard]] static Key tie(PrefKey const& key) { return Key{key.person, key.app, key.scope, key.point}; }
125
126 std::map<Key, std::string> _entries;
127 /// After the map, so it is destroyed first: a task running on it reads the map.
128 exec::OwnerStrand _strand;
129};
130
131/// @brief The preferences model's bounds.
133 /// @brief The longest JSON text a choice may have, in bytes.
134 std::size_t maxValueBytes = std::size_t{16} * 1024;
135};
136
137/// @brief The type id the preferences model is registered under.
138inline constexpr std::string_view kPreferencesModel = "morph.preferences";
139
140/// @brief The longest application, scope or point name a preferences request may carry, in bytes.
141inline constexpr std::size_t kMaxPrefNameBytes = 256;
142
143/// @brief The most points one `load` may ask for.
144inline constexpr std::size_t kMaxPrefPoints = 256;
145
146/// @brief `load`: the stored choices of a screen's points. A request carries no person.
147struct PrefLoad {
148 /// @brief The application.
149 std::string app;
150 /// @brief The screen's id, or `app`.
151 std::string scope;
152 /// @brief The points to read.
153 std::vector<std::string> points;
154};
155
156/// @brief What `load` answers.
158 /// @brief The stored JSON text of each point that has an entry; a point without one is absent.
159 std::map<std::string, std::string> values;
160};
161
162/// @brief `set`: stores one choice. A request carries no person.
163struct PrefSet {
164 /// @brief The application.
165 std::string app;
166 /// @brief The screen's id, or `app`.
167 std::string scope;
168 /// @brief The point.
169 std::string point;
170 /// @brief The choice's JSON text.
171 std::string value;
172};
173
174/// @brief What `set` answers.
176 /// @brief Whether the store took it.
177 bool stored = false;
178};
179
180/// @brief `remove`: forgets one choice. A request carries no person.
182 /// @brief The application.
183 std::string app;
184 /// @brief The screen's id, or `app`.
185 std::string scope;
186 /// @brief The point.
187 std::string point;
188};
189
190/// @brief What `remove` answers.
192 /// @brief Whether an entry was there.
193 bool removed = false;
194};
195
196/// @brief The server's preferences model: reads and writes the calling person's choices through a store.
197///
198/// The person is the principal of the verified session (`session::current()`), which `RemoteServer` stamps from the
199/// authoriser before dispatch. The authoriser sees the model and the action, never the body, so the model is where
200/// the key is decided: no request names a person, and one that tries reaches only its own caller's entries. With no
201/// verified principal there is no key that belongs to the caller alone, so `load` answers nothing and `set` and
202/// `remove` are refused.
204public:
205 /// @brief A model over @p store.
206 /// @param store Where choices are kept. Shared by every instance.
207 /// @param options The bounds.
208 PreferencesModel(std::shared_ptr<IPreferencesStore> store, PreferencesOptions options)
209 : _store{std::move(store)}, _options{options} {
210 if (_store == nullptr) {
211 throw std::invalid_argument{"PreferencesModel: a null store"};
212 }
213 }
214
215 /// @brief Reads the caller's stored choices of @p request's points.
216 /// @param request The application, the scope and the points.
217 /// @return The stored JSON text of each point that has an entry.
218 // NOLINTNEXTLINE(readability-convert-member-functions-to-static): reads the store.
219 ::core::async::Task<PrefValues> execute(PrefLoad request) {
220 checkName(request.app, "app");
221 checkName(request.scope, "scope");
222 if (request.points.size() > kMaxPrefPoints) {
223 throw std::invalid_argument{"a load names more than " + std::to_string(kMaxPrefPoints) + " points"};
224 }
225 for (std::string const& point : request.points) {
226 checkName(point, "point");
227 }
228 PrefValues reply;
229 std::string const person = verifiedPerson();
230 if (person.empty()) {
231 co_return reply;
232 }
233 for (std::string& point : request.points) {
234 std::optional<std::string> stored = co_await _store->get(
235 PrefKey{.person = person, .app = request.app, .scope = request.scope, .point = point});
236 if (stored) {
237 reply.values.insert_or_assign(std::move(point), std::move(*stored));
238 }
239 }
240 co_return reply;
241 }
242
243 /// @brief Stores the caller's choice.
244 /// @param request The key's application, scope and point, and the choice's JSON text.
245 /// @return Whether the store took it.
246 // NOLINTNEXTLINE(readability-convert-member-functions-to-static): writes the store.
247 ::core::async::Task<PrefStored> execute(PrefSet request) {
248 PrefKey const key = keyOf(request.app, request.scope, request.point);
249 if (request.value.size() > _options.maxValueBytes) {
250 throw std::invalid_argument{"a preference is longer than " + std::to_string(_options.maxValueBytes) +
251 " bytes"};
252 }
253 if (auto const parsed = parseJson(request.value); !parsed) {
254 throw std::invalid_argument{"a preference is JSON text: " + parsed.error()};
255 }
256 bool const stored = co_await _store->set(key, std::move(request.value));
257 co_return PrefStored{.stored = stored};
258 }
259
260 /// @brief Forgets the caller's choice.
261 /// @param request The key's application, scope and point.
262 /// @return Whether an entry was there.
263 // NOLINTNEXTLINE(readability-convert-member-functions-to-static): writes the store.
264 ::core::async::Task<PrefRemoved> execute(PrefRemove request) {
265 PrefKey const key = keyOf(request.app, request.scope, request.point);
266 bool const removed = co_await _store->remove(key);
267 co_return PrefRemoved{.removed = removed};
268 }
269
270private:
271 /// @brief The verified principal of the call being run.
272 /// @return It; empty when the server verified nobody.
273 [[nodiscard]] static std::string verifiedPerson() {
274 session::Context const* const session = session::current();
275 return session == nullptr ? std::string{} : session->principal;
276 }
277
278 /// @brief The caller's key for a write.
279 /// @param app The application.
280 /// @param scope The screen's id, or `app`.
281 /// @param point The point.
282 /// @return The key, with the verified person.
283 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the three parts of a key, in its order.
284 [[nodiscard]] static PrefKey keyOf(std::string const& app, std::string const& scope, std::string const& point) {
285 checkName(app, "app");
286 checkName(scope, "scope");
287 checkName(point, "point");
288 std::string person = verifiedPerson();
289 if (person.empty()) {
290 throw std::runtime_error{"preferences are stored for a signed-in person"};
291 }
292 return PrefKey{.person = std::move(person), .app = app, .scope = scope, .point = point};
293 }
294
295 /// @brief Refuses an empty or overlong name.
296 /// @param name The name.
297 /// @param what What it names, for the refusal.
298 static void checkName(std::string const& name, std::string_view what) {
299 if (name.empty() || name.size() > kMaxPrefNameBytes) {
300 throw std::invalid_argument{"a preference's `" + std::string{what} + "` is 1 to " +
301 std::to_string(kMaxPrefNameBytes) + " bytes"};
302 }
303 }
304
305 std::shared_ptr<IPreferencesStore> _store;
306 PreferencesOptions _options;
307};
308
309} // namespace morph::ui
310
311/// @cond detail
312// The traits the registration macros write, without their static-init registrars: registerPreferences registers.
313// NOLINTBEGIN(misc-const-correctness): inside the macros' codec bodies.
314BRIDGE_DETAIL_MODEL_TRAITS_ONLY_BODY(morph::ui::PreferencesModel, "morph.preferences")
315BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY(morph::ui::PreferencesModel, morph::ui::PrefLoad, morph::ui::PrefValues, "load",
316 ::morph::model::Loggable::No)
317BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY(morph::ui::PreferencesModel, morph::ui::PrefSet, morph::ui::PrefStored, "set",
318 ::morph::model::Loggable::No)
319BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY(morph::ui::PreferencesModel, morph::ui::PrefRemove, morph::ui::PrefRemoved,
320 "remove", ::morph::model::Loggable::No)
321// NOLINTEND(misc-const-correctness)
322/// @endcond
323
324namespace morph::ui {
325
326/// @brief Registers the preferences model and its three actions, with a factory that gives every instance @p store.
327///
328/// Nothing registers it at static initialisation: a server that offers preferences calls this once, before it
329/// serves, with the dispatcher and registry its `RemoteServer` uses. A deployment's authoriser then admits
330/// `morph.preferences` and its actions `load`, `set` and `remove` for every signed-in principal; the model itself
331/// keeps each person to their own entries.
332/// @param store Where choices are kept.
333/// @param dispatcher The server's action dispatcher.
334/// @param registry The server's model registry.
335/// @param options The bounds.
336inline void registerPreferences(std::shared_ptr<IPreferencesStore> store, model::detail::ActionDispatcher& dispatcher,
337 model::detail::ModelRegistryFactory& registry, PreferencesOptions options = {}) {
338 if (store == nullptr) {
339 throw std::invalid_argument{"registerPreferences: a null store"};
340 }
341 registry.registerModel<PreferencesModel>(kPreferencesModel, [store = std::move(store), options] {
342 return std::make_unique<model::detail::ModelHolder<PreferencesModel>>(store, options);
343 });
344 dispatcher.registerAction<PreferencesModel, PrefLoad>(kPreferencesModel, "load");
345 dispatcher.registerAction<PreferencesModel, PrefSet>(kPreferencesModel, "set");
346 dispatcher.registerAction<PreferencesModel, PrefRemove>(kPreferencesModel, "remove");
347}
348
349} // namespace morph::ui
Move-only handle representing the eventual result of an asynchronous operation.
static std::pair< Completion< T >, Promise > makeSettleable(::morph::exec::IExecutor *execPtr)
Constructs a Completion<T>/Promise<T> pair sharing one settleable state.
virtual async::Completion< bool > set(PrefKey const &key, std::string json)=0
Writes a choice, replacing what was stored.
virtual async::Completion< bool > remove(PrefKey const &key)=0
Forgets a choice, so the point is at the document's default again.
virtual async::Completion< std::optional< std::string > > get(PrefKey const &key)=0
Reads a choice.
async::Completion< bool > set(PrefKey const &key, std::string json) override
Writes a choice.
async::Completion< bool > remove(PrefKey const &key) override
Forgets a choice.
MemoryPreferencesStore(exec::IExecutor &base)
A store whose strand runs on base.
async::Completion< std::optional< std::string > > get(PrefKey const &key) override
Reads a choice.
The server's preferences model: reads and writes the calling person's choices through a store.
::core::async::Task< PrefRemoved > execute(PrefRemove request)
Forgets the caller's choice.
::core::async::Task< PrefValues > execute(PrefLoad request)
Reads the caller's stored choices of request's points.
PreferencesModel(std::shared_ptr< IPreferencesStore > store, PreferencesOptions options)
A model over store.
::core::async::Task< PrefStored > execute(PrefSet request)
Stores the caller's choice.
Coroutines on core::async::Task: awaiting a Completion, starting a detached flow on a morph executor,...
OwnerStrand: one core-cpp strand over a morph executor, which is itself a morph executor — the owner ...
constexpr std::size_t kMaxPrefPoints
The most points one load may ask for.
constexpr std::string_view kPreferencesModel
The type id the preferences model is registered under.
constexpr std::size_t kMaxPrefNameBytes
The longest application, scope or point name a preferences request may carry, in bytes.
void registerPreferences(std::shared_ptr< IPreferencesStore > store, model::detail::ActionDispatcher &dispatcher, model::detail::ModelRegistryFactory &registry, PreferencesOptions options={})
Registers the preferences model and its three actions, with a factory that gives every instance store...
Abstract executor interface.
Definition executor.hpp:70
Per-call session context carried through the bridge to the model.
Definition session.hpp:26
Where one choice is stored.
std::string app
The application.
std::string person
The person, derived on the server from the verified principal; never taken from a request.
std::string point
The customization point.
std::string scope
The screen's id, or app for the app document's points.
load: the stored choices of a screen's points. A request carries no person.
std::vector< std::string > points
The points to read.
std::string app
The application.
std::string scope
The screen's id, or app.
remove: forgets one choice. A request carries no person.
std::string scope
The screen's id, or app.
std::string point
The point.
std::string app
The application.
What remove answers.
bool removed
Whether an entry was there.
set: stores one choice. A request carries no person.
std::string point
The point.
std::string app
The application.
std::string value
The choice's JSON text.
std::string scope
The screen's id, or app.
What set answers.
bool stored
Whether the store took it.
What load answers.
std::map< std::string, std::string > values
The stored JSON text of each point that has an entry; a point without one is absent.
The preferences model's bounds.
std::size_t maxValueBytes
The longest JSON text a choice may have, in bytes.
The UI document's typed values (ui::Value), its types (ui::Type), their JSON form,...
std::expected< Json, std::string > parseJson(std::string_view text)
Parses JSON text into the document layer's JSON tree.
Definition value.hpp:752
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194
std::variant< std::int64_t, std::string > Key
The identity of a Switch case, a Select option, a ForEach or Table row and a drag payload.
Definition view.hpp:38