Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import {
2 ContainerTypes,
3 makeDateString,
4 ScalarTypes,
5 type Container,
6 type ContainerType,
7 type ContainerTypeRuntimeType,
8 type ItemIdentifier,
9 type RelatedItem,
10 type ScalarType,
11 type ScalarTypeRuntimeType,
12} from '@roostorg/types';
13import Geohash from 'latlon-geohash';
14import _ from 'lodash';
15import { match } from 'ts-pattern';
16
17import { doesThrow } from '../../utils/misc.js';
18import { isValidUrl, makeUrlString } from '../../utils/url.js';
19
20const { isPlainObject } = _;
21
22/**
23 * For every FieldType, we define two core operations that can be performed on
24 * values (from JSON ItemData) that our schema says should be interpreted as
25 * that FieldType type.
26 *
27 * - `coerce`, on each FieldType, takes any value that shows up in the user
28 * input and returns either:
29 *
30 * - An error, if there's no way to treat the user input as a valid value
31 * for the given field type.
32 *
33 * - `null`, if the value should be treated as though the user didn't
34 * provide the field at all. E.g., on fields that take urls, we allow
35 * users to provide an empty string, and we treat the empty string
36 * equivalently to them having left the field out. (Note: if the user
37 * provides `null` directly, that's always treated equivalently to the
38 * field not being provided, so `coerce` needn't handle this case.)
39 *
40 * - the canonical value to use for this input, which satisifies the
41 * expected ScalarTypeRuntimeType/ContainerTypeRuntimeType for the field,
42 * if the value is valid or can be coerced to something valid. This makes
43 * sure that our signals always receive values of the type they expect.
44 * Eg, on a NUMBER Field, `coerce('13')` should return 13, the JS number.
45 *
46 * - `getValues`, on each FieldType, takes a value and returns any array of all
47 * the scalar values within value. For scalars, there's obviously just one
48 * value, which is the input value itself. But, for containers, this extracts
49 * all the values from the container. Note that the returned values must be
50 * valid ScalarTypeRuntimeType values.
51 */
52type Handlers = {
53 [K in ScalarType]: {
54 coerce: (
55 this: void,
56 value: unknown,
57 legalItemTypeIds: readonly string[],
58 ) => ScalarTypeRuntimeType<K> | null | Error;
59 getValues: (value: ScalarTypeRuntimeType<K>) => [ScalarTypeRuntimeType<K>];
60 };
61} & {
62 [K in ContainerType]: {
63 coerce: (
64 this: void,
65 value: unknown,
66 legalItemTypeIds: readonly string[],
67 container: Container<K>,
68 ) => ContainerTypeRuntimeType<K> | null | Error;
69 getValues: (
70 value: ContainerTypeRuntimeType<K>,
71 container: Container<K>,
72 ) => ScalarTypeRuntimeType[];
73 };
74};
75
76/**
77 * The default implementation of `getValues` for ScalarTypes. By definition,
78 * a scalar type is an atomic value, so we just have to put it in an array.
79 */
80const scalarGetValues = <T>(value: T): [T] => [value];
81
82export const fieldTypeHandlers: Handlers = {
83// NB: for ids (including user ids), we accept numbers or strings for user
84// convenience, but we always coerce the value to a string so that we're not
85// mixing strings and numbers in the same json column in the data warehouse (which
86// could drastically reduce perf).
87 [ScalarTypes.USER_ID]: {
88 // TODO (COOP-745): USER_ID will be deprecated
89 coerce: (v, legalItemTypeIds) => {
90 // NB: We intentionally checks that the id is a string, rather than using
91 // `isIdLike` because we only allow users to give item ids as non-empty
92 // strings (despite our ID ScalarType allowing numbers and empty strings);
93 // cf RawItemSubmission['id'].
94 const isObjectWithRequisiteKeys =
95 typeof v === 'object' &&
96 v !== null &&
97 'id' in v &&
98 typeof v.id === 'string' &&
99 v['id'].length > 0 &&
100 'typeId' in v &&
101 legalItemTypeIds.includes(v['typeId'] as string);
102
103 return isObjectWithRequisiteKeys
104 ? (v satisfies { id: unknown; typeId: unknown } as ItemIdentifier)
105 : new Error(
106 "This field, if given, must be an object with a (non-empty) string 'id' and a valid 'typeId'.",
107 );
108 },
109 getValues: scalarGetValues,
110 },
111 [ScalarTypes.ID]: {
112 coerce: coerceIdLikeInput,
113 getValues: scalarGetValues,
114 },
115 [ScalarTypes.POLICY_ID]: {
116 coerce: coerceIdLikeInput,
117 getValues: scalarGetValues,
118 },
119 [ScalarTypes.STRING]: {
120 coerce: (v) =>
121 typeof v === 'string'
122 ? v
123 : new Error('This field, if given, must be a string.'),
124 getValues: scalarGetValues,
125 },
126 [ScalarTypes.URL]: {
127 coerce: (v) => {
128 const urlString = typeof v === 'string' && makeUrlString(v);
129
130 return urlString
131 ? urlString
132 : v === ''
133 ? null
134 : new Error('This field, if given, must be a valid URL.');
135 },
136 getValues: scalarGetValues,
137 },
138 [ScalarTypes.GEOHASH]: {
139 coerce: (v) =>
140 typeof v === 'string' && !doesThrow(() => Geohash.decode(v))
141 ? v
142 : v === ''
143 ? null
144 : new Error('This field, if given, must be a valid geohash.'),
145 getValues: scalarGetValues,
146 },
147 [ScalarTypes.BOOLEAN]: {
148 coerce(v) {
149 if (typeof v === 'boolean') return v;
150
151 // For legacy reasons and/or user convenience,
152 // we accept boolean-like strings.
153 // TODO: create metric; see if this is used; if not, kill?
154 return match(String(v).toLowerCase())
155 .with('true', '1', () => true)
156 .with('false', '0', () => false)
157 .otherwise(
158 () => new Error('This field, if given, must hold a boolean.'),
159 );
160 },
161 getValues: scalarGetValues,
162 },
163 [ScalarTypes.NUMBER]: {
164 coerce(v) {
165 const asNumber =
166 typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : null;
167 return isFiniteNonNaNNumber(asNumber)
168 ? asNumber
169 : new Error('This field, if given, must hold a number.');
170 },
171 getValues: scalarGetValues,
172 },
173 [ScalarTypes.AUDIO]: {
174 coerce: coerceMediaUrlInput,
175 getValues: scalarGetValues,
176 },
177 [ScalarTypes.IMAGE]: {
178 coerce: coerceMediaUrlInput,
179 getValues: scalarGetValues,
180 },
181 [ScalarTypes.VIDEO]: {
182 coerce: coerceMediaUrlInput,
183 getValues: scalarGetValues,
184 },
185 [ScalarTypes.DATETIME]: {
186 getValues: scalarGetValues,
187 coerce(v) {
188 const asDateString =
189 typeof v === 'string' ? makeDateString(v) : undefined;
190 return asDateString
191 ? asDateString
192 : v === ''
193 ? null
194 : new Error('This field, if given, must contain a valid date string.');
195 },
196 },
197 [ScalarTypes.RELATED_ITEM]: {
198 coerce: (v, legalItemTypeIds) => {
199 // NB: We intentionally checks that the id is a string, rather than using
200 // `isIdLike` because we only allow users to give item ids as non-empty
201 // strings (despite our ID ScalarType allowing numbers and empty strings);
202 // cf RawItemSubmission['id'].
203 const isObjectWithRequisiteKeys =
204 typeof v === 'object' &&
205 v !== null &&
206 'id' in v &&
207 typeof v.id === 'string' &&
208 v['id'].length > 0 &&
209 'typeId' in v &&
210 legalItemTypeIds.includes(v['typeId'] as string) &&
211 (!('name' in v) || typeof v['name'] === 'string');
212
213 return isObjectWithRequisiteKeys
214 ? (v satisfies { id: unknown; typeId: unknown } as RelatedItem)
215 : new Error(
216 "This field, if given, must be an object with a (non-empty) string 'id' and a valid 'typeId', with an optional string name.",
217 );
218 },
219 getValues: scalarGetValues,
220 },
221 [ContainerTypes.ARRAY]: {
222 coerce(value, itemTypeIds, container) {
223 if (!Array.isArray(value)) {
224 return new Error('This field, if given, must be an array.');
225 }
226 const coerceItem = fieldTypeHandlers[container.valueScalarType].coerce;
227 const normalizedValues = value.map((v) => coerceItem(v, itemTypeIds));
228 return normalizedValues.some((v) => v instanceof Error)
229 ? new Error("Some items in this field's array were not valid.")
230 : (normalizedValues.filter((it) => it != null) as Exclude<
231 (typeof normalizedValues)[number],
232 Error | null
233 >[]);
234 },
235 getValues: (v, _container) => v.slice(),
236 },
237 [ContainerTypes.MAP]: {
238 coerce(v, itemTypeIds, container) {
239 if (!isPlainObject(v)) {
240 return new Error('This field, if given, must be an object.');
241 }
242 const { keyScalarType, valueScalarType } = container;
243 const coerceKey = fieldTypeHandlers[keyScalarType].coerce;
244 const coerceValue = fieldTypeHandlers[valueScalarType].coerce;
245
246 const normalizedEntries = Object.entries(v as object)
247 .map(
248 ([key, val]) =>
249 [
250 coerceKey(key, itemTypeIds),
251 coerceValue(val, itemTypeIds),
252 ] as const,
253 )
254 .filter(([key, val]) => key != null && val != null);
255
256 const hasErrors = normalizedEntries.some(
257 ([k, v]) => k instanceof Error || v instanceof Error,
258 );
259
260 if (hasErrors) {
261 return new Error("Some entries in this field's map were not valid.");
262 }
263
264 return Object.fromEntries(normalizedEntries) as {
265 [k: string | number]: Exclude<
266 (typeof normalizedEntries)[number][1],
267 Error | null
268 >;
269 };
270 },
271 getValues: (v, _container) => Object.values(v),
272 },
273};
274
275function coerceMediaUrlInput(value: unknown) {
276 const err = new Error('This field, if given, must hold a valid URL.');
277
278 return typeof value !== 'string'
279 ? err
280 : value === ''
281 ? null
282 : isValidUrl(value)
283 ? // NB: `value` here CANNOT be typed as a UrlString, because we have some
284 // legacy submissions in the data warehouse where the string is not a valid URL.
285 // (Usually, it's the empty string, which previously got through.)
286 // TODO: replace all those submissions in the data warehouse with `field: null`,
287 // and then update the type here/in ScalarTypeRuntimeType.
288 { url: value }
289 : err;
290}
291
292function coerceIdLikeInput(value: unknown) {
293 // NB: we don't currently have any restrictions on the string in an `ID` field;
294 // in particular, it's allowed to be empty. But note that _item ids_ (like in
295 // RELATED_ITEM fields) don't get checked w/ this function and can't be empty.
296 return typeof value === 'string'
297 ? value
298 : isFiniteNonNaNNumber(value)
299 ? String(value)
300 : new Error('This field must be a string or a number.');
301}
302
303function isFiniteNonNaNNumber(value: unknown) {
304 return (
305 typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(value)
306 );
307}