Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type Opaque, type ReadonlyDeep } from 'type-fest';
2
3import { jsonStringify } from '../../utils/encoding.js';
4import {
5 ErrorType,
6 makeBadRequestError,
7 type CoopError,
8} from '../../utils/errors.js';
9import { type JSON } from '../../utils/json-schema-types.js';
10import {
11 instantiateOpaqueType,
12 type NonEmptyArray,
13} from '../../utils/typescript-types.js';
14import { type ItemType } from '../moderationConfigService/index.js';
15import { getFieldValueForRole } from './extractItemDataValues.js';
16import { fieldTypeHandlers } from './fieldTypeHandlers.js';
17
18// The type of the content that we get from the client, pre validation.
19export type RawItemData = { readonly [key: string]: ReadonlyDeep<JSON> };
20
21export type NormalizedItemData = Opaque<RawItemData, 'NormalizedItemData'>;
22
23/**
24 * For some item field types, we allow input in many different formats (e.g.,
25 * we allow booleans to be represented by the strings '1' or '0' in input json).
26 * However, our signals should always get each ScalarType with a consistent
27 * runtime representation. So, this function 'normalizes' the representation of
28 * an incoming item data object (which could also improve caching on the margin).
29 *
30 * @param itemType The item type that applies to the content.
31 * @param data The submitted content.
32 */
33export function toNormalizedItemDataOrErrors(
34 legalItemTypeIds: readonly string[],
35 itemType: ItemType,
36 data: RawItemData,
37): NormalizedItemData | NonEmptyArray<CoopError> {
38 type CoercionResult = ReturnType<
39 (typeof fieldTypeHandlers)[keyof typeof fieldTypeHandlers]['coerce']
40 >;
41
42 const fieldsByName = new Map(
43 itemType.schema.map((field) => [field.name, field]),
44 );
45 const normalizedEntriesWithErrors = Object.entries(data)
46 .filter(([_, value]) => {
47 // Remove all fields that were provided with a null value, as those
48 // are always treated as being missing, equivalent to if the user had
49 // omitted the key from the JSON payload. We have to do this _first_ so
50 // that these nulls are never passed to `coerce`.
51 return value != null;
52 })
53 .map(([key, value]) => {
54 const fieldDefinition = fieldsByName.get(key);
55 return [
56 key,
57 // If there's no field definition for this key in the schema, retain the
58 // value as-is, which could come in useful later. Otherwise, normalize
59 // the value according to its field type in the schema, possibly
60 // producing errors.
61 !fieldDefinition
62 ? value
63 : fieldDefinition.type === 'ARRAY' || fieldDefinition.type === 'MAP'
64 ? fieldTypeHandlers[fieldDefinition.type].coerce(
65 value,
66 legalItemTypeIds,
67 fieldDefinition.container as never,
68 )
69 : fieldTypeHandlers[fieldDefinition.type].coerce(
70 value,
71 legalItemTypeIds,
72 ),
73 ] as const;
74 })
75 .filter(([key, value]) => {
76 // Now, remove all fields (that were defined in the schema) where the
77 // value became `null` as a result of coercion/normalization, as these
78 // should be treated like missing in the normalized result.
79 return value != null || !fieldsByName.has(key);
80 });
81
82 const potentialNormalizedResult = Object.fromEntries(
83 normalizedEntriesWithErrors,
84 );
85
86 // To find errors, we look over the fields _of the schema_, not the `data`
87 // object, as any fields in the data object that aren't in the schema will
88 // have been left as-is and can't have errors.
89 const errors = itemType.schema.flatMap(({ name, required }) => {
90 const normalizedValueOrError = potentialNormalizedResult[name];
91
92 // Either, the data didn't have a key for this field, or the field was
93 // provided as null, or another value was provided, but that value was
94 // equivalent to null after normalization; all these count as the field
95 // being "missing", which is an error if the field is required.
96 if (normalizedValueOrError == null && required) {
97 return [
98 makeBadRequestError('Invalid Data for Item', {
99 detail: `The field '${name}' is required, but was not provided.`,
100 type: [ErrorType.DataInvalidForItemType],
101 shouldErrorSpan: true,
102 }),
103 ];
104 }
105
106 // If the validation/normalization process found an error, return that error.
107 if (normalizedValueOrError instanceof Error) {
108 const { message } = normalizedValueOrError;
109 return [
110 makeBadRequestError('Invalid Data for Item', {
111 detail:
112 `The field '${name}' has an invalid value. The value you ` +
113 `provided was: ${jsonStringify(data[name])}. ${message}`,
114 type: [ErrorType.DataInvalidForItemType],
115 shouldErrorSpan: true,
116 }),
117 ];
118 }
119
120 return [];
121 });
122
123 if (errors.length > 0) {
124 return errors satisfies CoopError[] as NonEmptyArray<CoopError>;
125 }
126
127 const normalizedData = instantiateOpaqueType<NormalizedItemData>(
128 // Cast is safe because of the check for errors above.
129 // Arbitrary JSON values are possible from the fields we retained as-is,
130 // that weren't in the schema.
131 potentialNormalizedResult satisfies {
132 [k: string]: CoercionResult | ReadonlyDeep<JSON>;
133 } as { [k: string]: Exclude<CoercionResult | ReadonlyDeep<JSON>, Error> },
134 );
135
136 if (itemType.kind === 'CONTENT') {
137 const [parentId, threadId, createdAt] = (
138 ['parentId', 'threadId', 'createdAt'] as const
139 ).map((role) => {
140 return getFieldValueForRole(
141 itemType.schema,
142 itemType.schemaFieldRoles,
143 role,
144 normalizedData,
145 );
146 });
147 if (parentId && (threadId === undefined || createdAt === undefined)) {
148 return [
149 makeBadRequestError('Invalid field roles for Item', {
150 detail:
151 `You provided us a parent: ${itemType.schemaFieldRoles.parentId}` +
152 ` without providing a value for when the item was created: ` +
153 `${itemType.schemaFieldRoles.createdAt} or a value for the ` +
154 `thread: ${itemType.schemaFieldRoles.threadId}`,
155 type: [ErrorType.FieldRolesInvalidForItemType],
156 shouldErrorSpan: true,
157 }),
158 ];
159 }
160 if (threadId && createdAt === undefined) {
161 return [
162 makeBadRequestError('Invalid field roles for Item', {
163 detail:
164 `You provided us a thread: ${itemType.schemaFieldRoles.threadId}` +
165 ` without providing a value for when the item was created: ` +
166 `${itemType.schemaFieldRoles.createdAt}`,
167 type: [ErrorType.FieldRolesInvalidForItemType],
168 shouldErrorSpan: true,
169 }),
170 ];
171 }
172 }
173
174 return normalizedData;
175}