File size: 5,219 Bytes
aa89cb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import {
  JsonFieldNamingProfile,
  renderJsonFieldName,
} from './object-location';
import type { CheckResult } from './i18n';

export interface WireFieldMetadata {
  readonly canonicalName: string;
  readonly wireName: string;
  readonly aliases: readonly string[];
}

export interface WireEntityMetadata {
  readonly entityType: string;
  readonly profile: JsonFieldNamingProfile;
  readonly fields: Readonly<Record<string, WireFieldMetadata>>;
}

export interface NormalizedWireInput {
  /** Values keyed only by canonical KSML property names. */
  readonly values: Readonly<Record<string, unknown>>;
  /** Exact submitted RFC 6901 pointer, retained only for accepted aliases. */
  readonly sourceInstancePaths: Readonly<Record<string, string>>;
}

export class WireInputError extends Error {
  constructor(
    public readonly code: 'WIRE_UNKNOWN_FIELD' | 'WIRE_FIELD_COLLISION',
    public readonly instancePath: string,
    message: string,
  ) {
    super(message);
    this.name = 'WireInputError';
  }
}

export function createWireEntityMetadata(
  entityType: string,
  canonicalFields: readonly string[],
  profile: JsonFieldNamingProfile = 'camelCase',
  aliases: Readonly<Record<string, readonly string[]>> = {},
): WireEntityMetadata {
  const fields: Record<string, WireFieldMetadata> = {};
  const spellings = new Map<string, string>();
  for (const canonicalName of canonicalFields) {
    const wireName = renderJsonFieldName(canonicalName, profile);
    const fieldAliases = [...(aliases[canonicalName] ?? [])];
    for (const spelling of [wireName, ...fieldAliases]) {
      const existing = spellings.get(spelling);
      if (existing && existing !== canonicalName) {
        throw new Error(
          `Wire field spelling '${spelling}' maps to both '${existing}' and '${canonicalName}'`,
        );
      }
      spellings.set(spelling, canonicalName);
    }
    fields[canonicalName] = Object.freeze({
      canonicalName,
      wireName,
      aliases: Object.freeze(fieldAliases),
    });
  }
  return Object.freeze({ entityType, profile, fields: Object.freeze(fields) });
}

/**
 * Normalize one JSON object before checker or mutation execution.
 *
 * No serializer naming policy is consulted. The generated field map is the
 * complete authority, and two submitted spellings for one canonical field are
 * always rejected, even when their values happen to be equal.
 */
export function normalizeWireInput(
  input: Readonly<Record<string, unknown>>,
  metadata: WireEntityMetadata,
  parentPointer = '',
): NormalizedWireInput {
  const lookup = new Map<string, WireFieldMetadata>();
  for (const field of Object.values(metadata.fields)) {
    lookup.set(field.wireName, field);
    for (const alias of field.aliases) lookup.set(alias, field);
  }

  const values: Record<string, unknown> = {};
  const sourceInstancePaths: Record<string, string> = {};
  const submitted = new Map<string, string>();
  for (const [submittedName, value] of Object.entries(input)) {
    const pointer = `${parentPointer}/${escapeJsonPointer(submittedName)}`;
    const field = lookup.get(submittedName);
    if (!field) {
      throw new WireInputError(
        'WIRE_UNKNOWN_FIELD', pointer,
        `Unknown ${metadata.entityType} field '${submittedName}'`,
      );
    }
    const previous = submitted.get(field.canonicalName);
    if (previous !== undefined) {
      throw new WireInputError(
        'WIRE_FIELD_COLLISION', pointer,
        `Fields '${previous}' and '${submittedName}' both map to canonical field '${field.canonicalName}'`,
      );
    }
    submitted.set(field.canonicalName, submittedName);
    values[field.canonicalName] = value;
    if (submittedName !== field.wireName) {
      sourceInstancePaths[field.canonicalName] = pointer;
    }
  }
  return Object.freeze({
    values: Object.freeze(values),
    sourceInstancePaths: Object.freeze(sourceInstancePaths),
  });
}

/** Render canonical mutation values to the selected external profile. */
export function encodeWireOutput(
  values: Readonly<Record<string, unknown>>,
  metadata: WireEntityMetadata,
): Readonly<Record<string, unknown>> {
  const output: Record<string, unknown> = {};
  for (const [canonicalName, value] of Object.entries(values)) {
    const field = metadata.fields[canonicalName];
    if (!field) {
      throw new Error(`Unknown canonical ${metadata.entityType} field '${canonicalName}'`);
    }
    output[field.wireName] = value;
  }
  return Object.freeze(output);
}

/** Add submitted alias provenance to checker results without changing location identity. */
export function retainSubmittedPaths(
  results: readonly CheckResult[],
  normalized: NormalizedWireInput,
): CheckResult[] {
  return results.map(result => {
    const firstProperty = result.location.segments.find(segment => segment.kind === 'property');
    if (!firstProperty || firstProperty.kind !== 'property') return { ...result };
    const sourceInstancePath = normalized.sourceInstancePaths[firstProperty.name];
    return sourceInstancePath === undefined ? { ...result } : { ...result, sourceInstancePath };
  });
}

function escapeJsonPointer(value: string): string {
  return value.replace(/~/g, '~0').replace(/\//g, '~1');
}