@reventlessdev/reventless-spec 3.0.0-alpha.85 → 3.0.0-alpha.87

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,29 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Semantic$Reventless from "./Semantic.res.mjs";
5
+
6
+ let e164 = /^\+[1-9]\d{0,14}$/;
7
+
8
+ function fromString(raw) {
9
+ if (e164.test(raw)) {
10
+ return {
11
+ TAG: "Ok",
12
+ _0: raw
13
+ };
14
+ } else {
15
+ return {
16
+ TAG: "Error",
17
+ _0: `expected a phone number in E.164 form — "+" then up to 15 digits, as in "+4930123456" — got ` + Semantic$Reventless.showString(raw) + `. Spaces, dashes and brackets are not part of the stored form.`
18
+ };
19
+ }
20
+ }
21
+
22
+ let schema = Semantic$Reventless.refined(S.string, Semantic$Reventless.Id.phone, fromString);
23
+
24
+ export {
25
+ e164,
26
+ fromString,
27
+ schema,
28
+ }
29
+ /* schema Not a pure module */
@@ -48,6 +48,16 @@ module Id = {
48
48
  let dateTime = "dateTime"
49
49
  let reference = "reference"
50
50
  let storageRef = "storageRef"
51
+
52
+ // The branded scalars. Each refines a `string` or a number without changing
53
+ // its shape, so a field gains one of these without anything stored changing.
54
+ let email = "email"
55
+ let phone = "phone"
56
+ let url = "url"
57
+ let percent = "percent"
58
+ let bytes = "bytes"
59
+ let duration = "duration"
60
+ let color = "color"
51
61
  }
52
62
 
53
63
  let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
@@ -56,8 +66,67 @@ let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless",
56
66
  let mark = (schema: S.t<'a>, ~id: string, ~payload: payload=Plain): S.t<'a> =>
57
67
  schema->S.Metadata.set(~id=semanticId, {id, payload})
58
68
 
59
- /** The semantic a field's schema carries, if any. */
60
- let get = (fieldSchema: S.t<'a>): option<t> => S.Metadata.get(fieldSchema, ~id=semanticId)
69
+ /**
70
+ A schema that validates with `check` and carries the semantic `id`.
71
+
72
+ The branded scalars all have the same shape — one constructor function that
73
+ defines the grammar, and a schema that must agree with it — and `StorageRef`
74
+ established that the schema is *derived* from the constructor rather than
75
+ hand-rolling a second check beside it. Deriving it here makes that structural:
76
+ there is one place a grammar can be written, so there is nowhere for a second
77
+ one to drift.
78
+ */
79
+ let refined = (base: S.t<'a>, ~id: string, ~check: 'a => result<'a, string>): S.t<'a> =>
80
+ base
81
+ ->S.refine(s => value =>
82
+ switch check(value) {
83
+ | Ok(_) => ()
84
+ | Error(why) => s.fail(why)
85
+ }
86
+ )
87
+ ->mark(~id)
88
+
89
+ /** A value as it should read back to the person who typed it. Rejection messages
90
+ reach forms through `validateInput`, so they quote the offending value. */
91
+ let showString = (raw: string): string => raw->JSON.Encode.string->JSON.stringify
92
+
93
+ /**
94
+ The semantic a field's schema carries, if any.
95
+
96
+ An **optional** field keeps its marker one level down. The ppx annotates the
97
+ field's `string`, and sury-ppx then wraps that schema in a union with
98
+ `Undefined`/`Null`; the wrapper is a new schema and carries no metadata of its
99
+ own. So a walk that reads only the outer schema sees `imageUrl?: string` as
100
+ carrying no semantic at all — the store goes undeclared, the reference goes
101
+ uncollected, the branded scalar loses its brand. Every reader converges here, so
102
+ following the wrapper once here is what keeps "optional" a statement about
103
+ presence rather than a way to lose the field's type.
104
+
105
+ The outer schema is read first, so a marker set on the wrapper itself still wins.
106
+ Only a union with exactly one non-null variant is followed: that is the shape an
107
+ optional field has, and a genuine multi-variant union has no single inner schema
108
+ whose semantic could stand for the whole.
109
+ */
110
+ let rec getFrom = (schema: S.t<unknown>): option<t> =>
111
+ switch S.Metadata.get(schema, ~id=semanticId) {
112
+ | Some(_) as found => found
113
+ | None =>
114
+ switch schema {
115
+ | Union({anyOf}) =>
116
+ switch anyOf->Array.filter(v =>
117
+ switch v {
118
+ | Null(_) | Undefined(_) => false
119
+ | _ => true
120
+ }
121
+ ) {
122
+ | [inner] => getFrom(inner)
123
+ | _ => None
124
+ }
125
+ | _ => None
126
+ }
127
+ }
128
+
129
+ let get = (fieldSchema: S.t<'a>): option<t> => fieldSchema->S.castToUnknown->getFrom
61
130
 
62
131
  /** Whether a field's schema carries this specific semantic. */
63
132
  let has = (fieldSchema: S.t<'a>, ~id: string): bool =>
@@ -5,7 +5,14 @@ import * as S from "sury/src/S.res.mjs";
5
5
  let Id = {
6
6
  dateTime: "dateTime",
7
7
  reference: "reference",
8
- storageRef: "storageRef"
8
+ storageRef: "storageRef",
9
+ email: "email",
10
+ phone: "phone",
11
+ url: "url",
12
+ percent: "percent",
13
+ bytes: "bytes",
14
+ duration: "duration",
15
+ color: "color"
9
16
  };
10
17
 
11
18
  let semanticId = S.Metadata.Id.make("reventless", "semantic");
@@ -18,12 +25,52 @@ function mark(schema, id, payloadOpt) {
18
25
  });
19
26
  }
20
27
 
21
- function get(fieldSchema) {
22
- return S.Metadata.get(fieldSchema, semanticId);
28
+ function refined(base, id, check) {
29
+ return mark(S.refine(base, s => (value => {
30
+ let why = check(value);
31
+ if (why.TAG === "Ok") {
32
+ return;
33
+ } else {
34
+ return s.fail(why._0, undefined);
35
+ }
36
+ })), id, undefined);
23
37
  }
24
38
 
39
+ function showString(raw) {
40
+ return JSON.stringify(raw);
41
+ }
42
+
43
+ function getFrom(_schema) {
44
+ while (true) {
45
+ let schema = _schema;
46
+ let found = S.Metadata.get(schema, semanticId);
47
+ if (found !== undefined) {
48
+ return found;
49
+ }
50
+ if (schema.type !== "union") {
51
+ return;
52
+ }
53
+ let match = schema.anyOf.filter(v => {
54
+ switch (v.type) {
55
+ case "null" :
56
+ case "undefined" :
57
+ return false;
58
+ default:
59
+ return true;
60
+ }
61
+ });
62
+ if (match.length !== 1) {
63
+ return;
64
+ }
65
+ _schema = match[0];
66
+ continue;
67
+ };
68
+ }
69
+
70
+ let get = getFrom;
71
+
25
72
  function has(fieldSchema, id) {
26
- let s = S.Metadata.get(fieldSchema, semanticId);
73
+ let s = getFrom(fieldSchema);
27
74
  if (s !== undefined) {
28
75
  return s.id === id;
29
76
  } else {
@@ -35,6 +82,9 @@ export {
35
82
  Id,
36
83
  semanticId,
37
84
  mark,
85
+ refined,
86
+ showString,
87
+ getFrom,
38
88
  get,
39
89
  has,
40
90
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ Marks a `string` field as a web address.
3
+
4
+ ## The grammar: parseable, **and** `http`/`https`
5
+
6
+ Sury's `S.url` is the parse — it is `new URL()`, so it settles host, port and
7
+ escaping without this module having an opinion. But `new URL()` accepts every
8
+ scheme, `javascript:alert(1)` included, and that is not an academic gap here: a
9
+ field carrying this semantic renders as an anchor whose `href` is the stored
10
+ value. An append-only log plus a scheme nobody checked is a stored XSS that
11
+ cannot be deleted afterwards — the same shape of hole `StorageRef` exists to
12
+ close, arriving through a different field.
13
+
14
+ So the grammar is sury's parse plus a two-scheme allowlist. `mailto:` and `tel:`
15
+ are deliberately outside it: those are `Email` and `Phone`, which validate what
16
+ they actually hold and render correctly on their own.
17
+
18
+ @example
19
+ ```rescript
20
+ @schema type command =
21
+ | SetSupplierSite({
22
+ supplierId: @s.matches(DcbTag.string) string,
23
+ website: @s.matches(Reventless.Url.schema) string,
24
+ })
25
+ ```
26
+ */
27
+
28
+ /** The address's representation. Transparent `string`; see `Email.t`. */
29
+ type t = string
30
+
31
+ external unsafe: string => t = "%identity"
32
+ external toString: t => string = "%identity"
33
+
34
+ // Sury's parse, held once — `fromString` runs it and adds the scheme check, and
35
+ // `schema` derives from `fromString`. One grammar, in one place.
36
+ let grammar: S.t<string> = S.string->S.url
37
+
38
+ // Schemes are case-insensitive per RFC 3986, and `HTTPS://x` parses fine, so the
39
+ // allowlist has to fold case or it rejects a valid address on a technicality.
40
+ let hasWebScheme = (raw: string): bool => {
41
+ let lower = String.toLowerCase(raw)
42
+ String.startsWith(lower, "http://") || String.startsWith(lower, "https://")
43
+ }
44
+
45
+ /** Validate a raw string as an `http`/`https` URL, saying why when it is not one. */
46
+ let fromString = (raw: string): result<t, string> =>
47
+ switch raw->S.parseOrThrow(grammar) {
48
+ | value =>
49
+ if hasWebScheme(value) {
50
+ Ok(value)
51
+ } else {
52
+ Error(
53
+ `a URL field takes an http:// or https:// address, got ${Semantic.showString(raw)}. ` ++
54
+ `Use Email or Phone for mailto:/tel:, and a storage ref for an uploaded object.`,
55
+ )
56
+ }
57
+ | exception _ =>
58
+ Error(
59
+ `expected an absolute URL, got ${Semantic.showString(
60
+ raw,
61
+ )}. A relative path is not a URL — include the scheme and host.`,
62
+ )
63
+ }
64
+
65
+ /** The sury schema for a URL field. Use with `@s.matches(Reventless.Url.schema)`. */
66
+ let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.url, ~check=fromString)
@@ -0,0 +1,48 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Semantic$Reventless from "./Semantic.res.mjs";
5
+
6
+ let grammar = S.url(S.string, undefined);
7
+
8
+ function hasWebScheme(raw) {
9
+ let lower = raw.toLowerCase();
10
+ if (lower.startsWith("http://")) {
11
+ return true;
12
+ } else {
13
+ return lower.startsWith("https://");
14
+ }
15
+ }
16
+
17
+ function fromString(raw) {
18
+ let value;
19
+ try {
20
+ value = S.parseOrThrow(raw, grammar);
21
+ } catch (exn) {
22
+ return {
23
+ TAG: "Error",
24
+ _0: `expected an absolute URL, got ` + Semantic$Reventless.showString(raw) + `. A relative path is not a URL — include the scheme and host.`
25
+ };
26
+ }
27
+ if (hasWebScheme(value)) {
28
+ return {
29
+ TAG: "Ok",
30
+ _0: value
31
+ };
32
+ } else {
33
+ return {
34
+ TAG: "Error",
35
+ _0: `a URL field takes an http:// or https:// address, got ` + Semantic$Reventless.showString(raw) + `. Use Email or Phone for mailto:/tel:, and a storage ref for an uploaded object.`
36
+ };
37
+ }
38
+ }
39
+
40
+ let schema = Semantic$Reventless.refined(S.string, Semantic$Reventless.Id.url, fromString);
41
+
42
+ export {
43
+ grammar,
44
+ hasWebScheme,
45
+ fromString,
46
+ schema,
47
+ }
48
+ /* grammar Not a pure module */