File size: 4,735 Bytes
63522a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/**
 * Translate service-reported errors back to the form field that produced them.
 *
 * The host validates and submits the *derived payload*, so errors come back
 * addressed by payload path (`trigger.schedule`) rather than by form field
 * (`schedule`). The reverse lookup is derived from the same builder that made
 * the payload; the mapping is lossy by nature, since one payload value can be
 * built from several fields.
 */

import type { SetupRequestBody } from "./types";

/** An error the service reported, addressed by payload path where it gave one. */
export interface ManifestServiceError {
  path: string;
  message: string;
}

export interface MappedManifestErrors {
  /** Keyed by form field name. */
  fieldErrors: Record<string, string>;
  /** Errors with no field to attach to; shown against the form as a whole. */
  formErrors: string[];
}

const EMPTY_MAPPED_ERRORS: MappedManifestErrors = {
  fieldErrors: {},
  formErrors: [],
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
 * Turn a service `loc` array into a path into the payload actually sent.
 *
 * A validation framework may address a value through segments that are not keys
 * of the request body — a discriminated union reports
 * `["body","trigger","cron","schedule"]` for a body whose trigger has no `cron`
 * key. Walking the payload and skipping segments that do not resolve reduces
 * that to `trigger.schedule` without knowing anything about the payload's
 * meaning.
 */
export function locToPayloadPath(
  loc: readonly (string | number)[],
  payload: unknown,
): string {
  const segments = loc[0] === "body" ? loc.slice(1) : loc;
  const parts: string[] = [];
  let current: unknown = payload;

  segments.forEach((segment) => {
    if (typeof segment === "number") {
      if (Array.isArray(current) && parts.length > 0) {
        parts[parts.length - 1] += `[${segment}]`;
        current = current[segment];
      }
      return;
    }
    if (isRecord(current) && segment in current) {
      parts.push(segment);
      current = current[segment];
    }
  });

  return parts.join(".");
}

/**
 * Read errors out of a service response body, whichever of the two shapes it
 * uses: a preflight result keyed by field, or a validation failure keyed by
 * `loc`.
 */
export function normalizeServiceErrors(
  body: unknown,
  payload: SetupRequestBody | null,
): ManifestServiceError[] {
  if (!isRecord(body)) return [];

  if (Array.isArray(body.errors)) {
    return body.errors.flatMap((entry) => {
      if (!isRecord(entry) || typeof entry.message !== "string") return [];
      return [
        {
          path: typeof entry.field === "string" ? entry.field : "",
          message: entry.message,
        },
      ];
    });
  }

  const { detail } = body;
  if (typeof detail === "string") {
    return [{ path: "", message: detail }];
  }
  if (Array.isArray(detail)) {
    return detail.flatMap((entry) => {
      if (!isRecord(entry) || typeof entry.msg !== "string") return [];
      const loc = Array.isArray(entry.loc)
        ? (entry.loc as (string | number)[])
        : [];
      return [{ path: locToPayloadPath(loc, payload), message: entry.msg }];
    });
  }

  return [];
}

/**
 * The fields behind a payload path.
 *
 * The map is derived from a payload where every list holds a single entry, so
 * a path addressing a later index names the same field the first one does.
 * Reading every index as the first is what lets an error about the second
 * repository of a multi-value field reach that field at all, rather than
 * falling through to a form-level message that points at nothing.
 */
function fieldsAt(
  path: string,
  errorMap: Record<string, string[]>,
): string[] | undefined {
  return errorMap[path] ?? errorMap[path.replace(/\[\d+\]/g, "[0]")];
}

/** Apply the derived payload-path map, falling back to a form-level error. */
export function mapServiceErrors(
  errors: readonly ManifestServiceError[],
  errorMap: Record<string, string[]>,
): MappedManifestErrors {
  if (errors.length === 0) return EMPTY_MAPPED_ERRORS;

  const fieldErrors: Record<string, string> = {};
  const formErrors: string[] = [];

  errors.forEach(({ path, message }) => {
    const fields = path ? fieldsAt(path, errorMap) : undefined;
    if (!fields?.length) {
      formErrors.push(message);
      return;
    }
    fields.forEach((field) => {
      // Keep the first error reported against a field; later ones would
      // silently replace a message the user has not read yet.
      if (!(field in fieldErrors)) fieldErrors[field] = message;
    });
  });

  return { fieldErrors, formErrors };
}