@reventlessdev/reventless-spec 3.0.0-alpha.127 → 3.0.0-alpha.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ /**
2
+ Renders message text from a template, a JSON payload and the payload's schema.
3
+
4
+ Grammar: `{{ path }}`, `{{ path | formatter }}`, `{{# if path }}…{{/ if }}` and
5
+ `{{# each path }}…{{ .field }}…{{/ each }}`. Total and pure — nothing is
6
+ evaluated, an unresolved path renders a visible placeholder rather than throwing,
7
+ and a field the schema marks `@sensitive` is withheld.
8
+ */
9
+
10
+ /** A path into the payload, in **wire** field names. `text` is what was written,
11
+ so a placeholder can name it. */
12
+ type path = {text: string, relative: bool, segments: array<string>}
13
+
14
+ type rec node =
15
+ | Text(string)
16
+ | Field({path: path, formatter: option<string>})
17
+ | If({path: path, body: array<node>})
18
+ | Each({path: path, body: array<node>})
19
+
20
+ /** A parsed template. */
21
+ type t = array<node>
22
+
23
+ /** Renders the value as the payload holds it, whatever semantic it carries. */
24
+ let rawFormatter = "raw"
25
+
26
+ /** The override vocabulary *is* the semantic vocabulary, so no second table can
27
+ drift from it. */
28
+ let formatters = [
29
+ rawFormatter,
30
+ Semantic.Id.money,
31
+ Semantic.Id.percent,
32
+ Semantic.Id.bytes,
33
+ Semantic.Id.duration,
34
+ Semantic.Id.dateRange,
35
+ Semantic.Id.geoPoint,
36
+ ]
37
+
38
+ // ---------- parsing ----------
39
+
40
+ let segmentGrammar = /^[A-Za-z0-9_]+$/
41
+ let whitespace = /\s+/g
42
+
43
+ let parsePath = (raw: string, ~insideEach: bool): result<path, string> => {
44
+ let text = raw->String.trim
45
+ if text == "" {
46
+ Error("a tag with no path")
47
+ } else {
48
+ let relative = text->String.startsWith(".")
49
+ if relative && !insideEach {
50
+ Error(`"${text}" is item-relative, and there is no item outside an "each"`)
51
+ } else {
52
+ let body = relative ? text->String.slice(~start=1) : text
53
+ let segments = body == "" ? [] : body->String.split(".")
54
+ segments->Array.every(segment => segmentGrammar->RegExp.test(segment))
55
+ ? Ok({text, relative, segments})
56
+ : Error(
57
+ `"${text}" is not a path — segments are letters, digits and ` ++
58
+ `underscores, separated by dots`,
59
+ )
60
+ }
61
+ }
62
+ }
63
+
64
+ type token = TextToken(string) | TagToken(string)
65
+
66
+ let tokenize = (source: string): result<array<token>, string> => {
67
+ let tokens = []
68
+ let rest = ref(source)
69
+ let failure = ref(None)
70
+ let running = ref(true)
71
+ while running.contents {
72
+ let current = rest.contents
73
+ switch current->String.indexOf("{{") {
74
+ | -1 =>
75
+ if current != "" {
76
+ tokens->Array.push(TextToken(current))
77
+ }
78
+ running := false
79
+ | start =>
80
+ let before = current->String.slice(~start=0, ~end=start)
81
+ if before != "" {
82
+ tokens->Array.push(TextToken(before))
83
+ }
84
+ let after = current->String.slice(~start=start + 2)
85
+ switch after->String.indexOf("}}") {
86
+ | -1 =>
87
+ failure := Some(`an opening "{{" with no closing "}}"`)
88
+ running := false
89
+ | stop =>
90
+ tokens->Array.push(
91
+ TagToken(
92
+ after
93
+ ->String.slice(~start=0, ~end=stop)
94
+ ->String.replaceRegExp(whitespace, " ")
95
+ ->String.trim,
96
+ ),
97
+ )
98
+ rest := after->String.slice(~start=stop + 2)
99
+ }
100
+ }
101
+ }
102
+ switch failure.contents {
103
+ | Some(message) => Error(message)
104
+ | None => Ok(tokens)
105
+ }
106
+ }
107
+
108
+ type blockKind = IfBlock | EachBlock
109
+
110
+ let blockWord = (kind: blockKind) =>
111
+ switch kind {
112
+ | IfBlock => "if"
113
+ | EachBlock => "each"
114
+ }
115
+
116
+ type frame = {kind: blockKind, path: path, body: array<node>}
117
+
118
+ /** Parse a template, or say what is wrong with it. */
119
+ let parse = (source: string): result<t, string> =>
120
+ switch tokenize(source) {
121
+ | Error(message) => Error(message)
122
+ | Ok(tokens) => {
123
+ let root: array<node> = []
124
+ let stack: array<frame> = []
125
+ let failure = ref(None)
126
+
127
+ let fail = message =>
128
+ if failure.contents->Option.isNone {
129
+ failure := Some(message)
130
+ }
131
+
132
+ let body = () =>
133
+ switch stack->Array.at(-1) {
134
+ | Some(frame) => frame.body
135
+ | None => root
136
+ }
137
+
138
+ let insideEach = () => stack->Array.some(frame => frame.kind == EachBlock)
139
+
140
+ let addField = (rawPath, formatter) =>
141
+ switch parsePath(rawPath, ~insideEach=insideEach()) {
142
+ | Error(message) => fail(message)
143
+ | Ok(path) => body()->Array.push(Field({path, formatter}))
144
+ }
145
+
146
+ let openBlock = (kind, rawPath) =>
147
+ if kind == EachBlock && insideEach() {
148
+ fail(`an "each" inside an "each" — iteration is one level deep`)
149
+ } else {
150
+ switch parsePath(rawPath, ~insideEach=insideEach()) {
151
+ | Error(message) => fail(message)
152
+ | Ok(path) => stack->Array.push({kind, path, body: []})
153
+ }
154
+ }
155
+
156
+ let closeBlock = word =>
157
+ switch stack->Array.pop {
158
+ | None => fail(`a closing "{{/ ${word} }}" with nothing open`)
159
+ | Some(frame) =>
160
+ blockWord(frame.kind) != word
161
+ ? fail(`"{{/ ${word} }}" closes a "${blockWord(frame.kind)}"`)
162
+ : body()->Array.push(
163
+ switch frame.kind {
164
+ | IfBlock => If({path: frame.path, body: frame.body})
165
+ | EachBlock => Each({path: frame.path, body: frame.body})
166
+ },
167
+ )
168
+ }
169
+
170
+ tokens->Array.forEach(token =>
171
+ if failure.contents->Option.isNone {
172
+ switch token {
173
+ | TextToken(text) => body()->Array.push(Text(text))
174
+ | TagToken(tag) =>
175
+ if tag->String.startsWith("#") {
176
+ let rest = tag->String.slice(~start=1)->String.trim
177
+ switch rest->String.indexOf(" ") {
178
+ | -1 => fail(`"{{# ${rest} }}" opens a block with no path`)
179
+ | at =>
180
+ switch rest->String.slice(~start=0, ~end=at) {
181
+ | "if" => openBlock(IfBlock, rest->String.slice(~start=at + 1))
182
+ | "each" => openBlock(EachBlock, rest->String.slice(~start=at + 1))
183
+ | word => fail(`"${word}" is not a block — "if" and "each" are`)
184
+ }
185
+ }
186
+ } else if tag->String.startsWith("/") {
187
+ closeBlock(tag->String.slice(~start=1)->String.trim)
188
+ } else {
189
+ switch tag->String.split("|") {
190
+ | [rawPath] => addField(rawPath, None)
191
+ | [rawPath, name] =>
192
+ let formatter = name->String.trim
193
+ formatters->Array.includes(formatter)
194
+ ? addField(rawPath, Some(formatter))
195
+ : fail(
196
+ `"${formatter}" is not a formatter — one of ` ++
197
+ formatters->Array.join(", "),
198
+ )
199
+ | _ => fail(`"${tag}" names more than one formatter`)
200
+ }
201
+ }
202
+ }
203
+ }
204
+ )
205
+
206
+ switch failure.contents {
207
+ | Some(message) => Error(message)
208
+ | None =>
209
+ switch stack->Array.at(-1) {
210
+ | Some(frame) =>
211
+ Error(`"{{# ${blockWord(frame.kind)} ${frame.path.text} }}" was never closed`)
212
+ | None => Ok(root)
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // ---------- rendering ----------
219
+
220
+ /** A value and the schema describing it, at one point in the payload. */
221
+ type scope = {value: option<JSON.t>, schema: option<S.t<unknown>>}
222
+
223
+ let nothing: scope = {value: None, schema: None}
224
+
225
+ let missing = (path: path) => `[missing: ${path.text}]`
226
+ let withheld = (path: path) => `[withheld: ${path.text}]`
227
+
228
+ /** How many items one `each` renders before it stops and says how many it left. */
229
+ let maxItems = 100
230
+
231
+ let unwrap = (schema: S.t<unknown>) => schema->Semantic.unwrapOptional->Option.getOr(schema)
232
+
233
+ let propertyOf = (schema: S.t<unknown>, name: string): option<S.t<unknown>> =>
234
+ switch schema->unwrap {
235
+ | Object({properties}) => properties->Dict.get(name)
236
+ | _ => None
237
+ }
238
+
239
+ let elementOf = (schema: S.t<unknown>): option<S.t<unknown>> =>
240
+ switch schema->unwrap {
241
+ | Array({additionalItems: Schema(item)}) => Some(item)
242
+ | _ => None
243
+ }
244
+
245
+ let step = (scope: scope, segment: string): scope => {
246
+ value: switch scope.value {
247
+ | Some(Object(fields)) => fields->Dict.get(segment)
248
+ | _ => None
249
+ },
250
+ schema: scope.schema->Option.flatMap(propertyOf(_, segment)),
251
+ }
252
+
253
+ let resolve = (path: path, ~root: scope, ~item: option<scope>): scope =>
254
+ path.segments->Array.reduce(path.relative ? item->Option.getOr(nothing) : root, step)
255
+
256
+ /** A dotted path looked up in a payload, with no schema and no item scope — the
257
+ same walk a template does, for a caller that is not rendering one. */
258
+ let lookup = (payload: JSON.t, dotted: string): option<JSON.t> =>
259
+ dotted
260
+ ->String.split(".")
261
+ ->Array.reduce({value: Some(payload), schema: None}, step)
262
+ ->(scope => scope.value)
263
+
264
+ /** Absent means "not stated", not "safe": a schema this walk cannot follow
265
+ renders, the open failure `Sensitive` documents. */
266
+ let isWithheld = (schema: option<S.t<unknown>>): bool =>
267
+ switch schema {
268
+ | None => false
269
+ | Some(field) =>
270
+ Sensitive.isFieldSensitive(field) ||
271
+ switch Semantic.getFrom(field) {
272
+ | Some({id}) => Sensitive.impliedBySemantic(id)
273
+ | None => false
274
+ }
275
+ }
276
+
277
+ let parseSafely = (json: JSON.t, schema: S.t<'a>): option<'a> =>
278
+ try Some(json->S.parseOrThrow(~to=schema)) catch {
279
+ | _ => None
280
+ }
281
+
282
+ let formatBySemantic = (json: JSON.t, ~id: string): option<string> =>
283
+ if id == Semantic.Id.money {
284
+ json->parseSafely(Money.schema)->Option.map(Money.format)
285
+ } else if id == Semantic.Id.dateRange {
286
+ json->parseSafely(DateRange.schema)->Option.map(DateRange.format)
287
+ } else if id == Semantic.Id.geoPoint {
288
+ json->parseSafely(GeoPoint.schema)->Option.map(GeoPoint.format)
289
+ } else {
290
+ switch json {
291
+ | Number(number) =>
292
+ if id == Semantic.Id.percent {
293
+ Some(Percent.format(number))
294
+ } else if id == Semantic.Id.bytes {
295
+ Some(Bytes.format(number))
296
+ } else if id == Semantic.Id.duration {
297
+ Some(Duration.format(Float.toInt(number)))
298
+ } else {
299
+ None
300
+ }
301
+ | _ => None
302
+ }
303
+ }
304
+
305
+ let scalar = (value: JSON.t): string =>
306
+ switch value {
307
+ | String(text) => text
308
+ | Number(number) => Float.toString(number)
309
+ | Boolean(flag) => flag ? "true" : "false"
310
+ | Null => ""
311
+ | Object(_) | Array(_) => value->JSON.stringify
312
+ }
313
+
314
+ let truthy = (value: option<JSON.t>): bool =>
315
+ switch value {
316
+ | None
317
+ | Some(Null)
318
+ | Some(Boolean(false))
319
+ | Some(String(""))
320
+ | Some(Array([])) => false
321
+ | Some(Number(number)) => number != 0.0
322
+ | Some(_) => true
323
+ }
324
+
325
+ let renderField = (path, ~formatter, ~root, ~item) => {
326
+ let scope = resolve(path, ~root, ~item)
327
+ if isWithheld(scope.schema) {
328
+ withheld(path)
329
+ } else {
330
+ switch scope.value {
331
+ | None | Some(Null) => missing(path)
332
+ | Some(value) =>
333
+ // An override that does not apply falls back to the plain value rather
334
+ // than to a placeholder: the value is there, only the shaping failed.
335
+ switch formatter {
336
+ | Some(name) if name == rawFormatter => scalar(value)
337
+ | Some(name) => formatBySemantic(value, ~id=name)->Option.getOr(scalar(value))
338
+ | None =>
339
+ switch scope.schema->Option.flatMap(Semantic.getFrom) {
340
+ | Some({id}) => formatBySemantic(value, ~id)->Option.getOr(scalar(value))
341
+ | None => scalar(value)
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ // A guard on a withheld field is allowed: it renders no value, and refusing it
349
+ // would silently drop the body of `{{# if customer.email }}`.
350
+ let rec renderNodes = (
351
+ nodes: array<node>,
352
+ ~root: scope,
353
+ ~item: option<scope>,
354
+ ~out: array<string>,
355
+ ) =>
356
+ nodes->Array.forEach(node =>
357
+ switch node {
358
+ | Text(text) => out->Array.push(text)
359
+ | Field({path, formatter}) => out->Array.push(renderField(path, ~formatter, ~root, ~item))
360
+ | If({path, body}) =>
361
+ if truthy(resolve(path, ~root, ~item).value) {
362
+ renderNodes(body, ~root, ~item, ~out)
363
+ }
364
+ | Each({path, body}) =>
365
+ let scope = resolve(path, ~root, ~item)
366
+ if isWithheld(scope.schema) {
367
+ out->Array.push(withheld(path))
368
+ } else {
369
+ switch scope.value {
370
+ | Some(Array(items)) =>
371
+ let element = scope.schema->Option.flatMap(elementOf)
372
+ let total = Array.length(items)
373
+ let shown = total > maxItems ? maxItems : total
374
+ for index in 0 to shown - 1 {
375
+ renderNodes(
376
+ body,
377
+ ~root,
378
+ ~item=Some({value: items->Array.get(index), schema: element}),
379
+ ~out,
380
+ )
381
+ }
382
+ if total > shown {
383
+ out->Array.push(`[… ${Int.toString(total - shown)} more]`)
384
+ }
385
+ | _ => out->Array.push(missing(path))
386
+ }
387
+ }
388
+ }
389
+ )
390
+
391
+ /** Render a parsed template. `schema` describes `payload` — for an event union,
392
+ take the arm with `variantSchema`. */
393
+ let render = (template: t, ~payload: JSON.t, ~schema: S.t<'a>): string => {
394
+ let out = []
395
+ renderNodes(
396
+ template,
397
+ ~root={value: Some(payload), schema: Some(schema->S.castToUnknown)},
398
+ ~item=None,
399
+ ~out,
400
+ )
401
+ out->Array.join("")
402
+ }
403
+
404
+ /** Parse and render in one call, for a template held as text rather than
405
+ compiled once. */
406
+ let renderSource = (source: string, ~payload: JSON.t, ~schema: S.t<'a>): result<string, string> =>
407
+ parse(source)->Result.map(template => render(template, ~payload, ~schema))
408
+
409
+ /** The object schema of one arm of a command or event union — what `render`
410
+ wants, since a message is about one occurrence. */
411
+ let variantSchema = (schema: S.t<'a>, ~variant: string): option<S.t<unknown>> =>
412
+ schema->S.castToUnknown->Semantic.unionVariant(~variant)