-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathlegacy-parameters.ts
More file actions
172 lines (157 loc) · 7.45 KB
/
Copy pathlegacy-parameters.ts
File metadata and controls
172 lines (157 loc) · 7.45 KB
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
/**
* Language-neutral `x-legacy-parameters` handling shared by every SDK generator.
*
* A request schema opts in by listing its original parameter set. Each generator keeps
* that published API with its own ordering and construction conventions, and exposes the
* full current schema through an extensible entry point where the language needs one.
* Future optional properties are added to the schema without changing the annotation.
*
* A response record can opt in too. Its legacy list names the properties the published
* record had, in schema property order, so languages with positional construction can keep
* that constructor while the record gains the new fields.
*
* This module is dependency-free so generators outside `src/sdk/scripts/codegen`, such
* as the Java generator, can import it directly.
*/
export const LEGACY_PARAMETERS_KEY = "x-legacy-parameters";
interface LegacyParameterSchema {
properties?: Record<string, unknown>;
required?: readonly string[];
[LEGACY_PARAMETERS_KEY]?: unknown;
}
export interface LegacyParameters {
/** The original parameter set, in declared order. */
readonly legacy: readonly string[];
/** Properties added after the legacy API, in schema property order. All are optional. */
readonly additions: readonly string[];
/** Schema-required properties, excluding implicit ones. */
readonly required: ReadonlySet<string>;
}
export interface LegacyParameterOptions {
/** Properties the SDK supplies itself, such as a session-scoped `sessionId`. */
readonly implicit?: readonly string[];
/** Whether callers may omit the whole request. Optional requests cannot opt in. */
readonly optional?: boolean;
/** Whether the request schema accepts null. Nullable requests cannot opt in. */
readonly nullable?: boolean;
/**
* Whether the legacy list must follow schema property order, as a positional record
* constructor requires.
*/
readonly ordered?: boolean;
}
/** True for the `anyOf: [{ not: {} }, …]` shape of a request callers may omit entirely. */
export function isOmittableRequest(schema: unknown): boolean {
const anyOf = (schema as { anyOf?: unknown } | null | undefined)?.anyOf;
return (
Array.isArray(anyOf) &&
anyOf.some((variant) => {
const not = (variant as { not?: unknown } | null)?.not;
return typeof not === "object" && not !== null && Object.keys(not).length === 0;
})
);
}
export function hasLegacyParameters(schema: unknown): boolean {
return typeof schema === "object" && schema !== null && LEGACY_PARAMETERS_KEY in schema;
}
/**
* Reads and validates a request schema's legacy parameter set.
*
* Returns `undefined` when the schema has not opted in, so unmarked APIs keep their
* existing generation. Throws when the metadata would omit a required input, name an
* unknown property, or otherwise fail to preserve the original API.
*/
export function readLegacyParameters(
schema: unknown,
owner: string,
options: LegacyParameterOptions = {}
): LegacyParameters | undefined {
if (!hasLegacyParameters(schema)) return undefined;
const request = schema as LegacyParameterSchema;
const invalid = (reason: string): never => {
throw new Error(`Invalid ${LEGACY_PARAMETERS_KEY} for ${owner}: ${reason}`);
};
const names = request[LEGACY_PARAMETERS_KEY];
if (!Array.isArray(names) || !names.every((name): name is string => typeof name === "string")) {
return invalid("expected an array of property names");
}
if (options.optional) return invalid("optional requests cannot declare legacy parameters");
if (options.nullable) return invalid("nullable requests cannot declare legacy parameters");
if (new Set(names).size !== names.length) return invalid("duplicate property names");
const implicit = new Set(options.implicit ?? []);
const properties = Object.keys(request.properties ?? {}).filter((name) => !implicit.has(name));
const known = new Set(properties);
for (const name of names) {
if (implicit.has(name)) return invalid(`implicit property ${name} cannot be a legacy parameter`);
if (!known.has(name)) return invalid(`unknown property ${name}`);
}
const required = new Set((request.required ?? []).filter((name) => !implicit.has(name)));
const legacy = new Set(names);
for (const name of required) {
if (!legacy.has(name)) return invalid(`required property ${name} must be a legacy parameter`);
}
const additions = properties.filter((name) => !legacy.has(name));
if (additions.length === 0) return invalid("expected at least one property added after the legacy API");
if (options.ordered && properties.filter((name) => legacy.has(name)).some((name, index) => name !== names[index])) {
return invalid("legacy parameters must follow schema property order");
}
return { legacy: names, additions, required };
}
/** Only requests the SDK sends can preserve a legacy API; handler payloads cannot opt in. */
export function rejectLegacyParameters(schema: unknown, owner: string): void {
if (hasLegacyParameters(schema)) {
throw new Error(`Invalid ${LEGACY_PARAMETERS_KEY} for ${owner}: only server and session requests are supported`);
}
}
/** The schema sections that can carry requests, keyed as in `api.schema.json`. */
export interface LegacyRequestSections<Node> {
server?: Node;
session?: Node;
clientSession?: Node;
clientGlobal?: Node;
}
/**
* Validates every request's `x-legacy-parameters` for generators whose projection needs no
* extra API: added inputs are optional fields of the same request type.
*/
export function validateLegacyRequests<Node, Method extends { rpcMethod: string; params?: unknown }>(
sections: LegacyRequestSections<Node>,
collect: (node: Node) => Method[],
params: (method: Method) => unknown,
nullable: (method: Method) => boolean
): void {
for (const [section, implicit] of [["server", []], ["session", ["sessionId"]]] as const) {
const node = sections[section];
if (!node) continue;
for (const method of collect(node)) {
readLegacyParameters(params(method), method.rpcMethod, {
implicit,
optional: isOmittableRequest(method.params),
nullable: nullable(method),
});
}
}
for (const section of ["clientSession", "clientGlobal"] as const) {
const node = sections[section];
if (!node) continue;
for (const method of collect(node)) rejectLegacyParameters(params(method), method.rpcMethod);
}
}
/**
* Validates `x-legacy-parameters` on every shared definition, including response records
* that no request path reads. Generators whose projection of an added field needs no extra
* API call this so malformed metadata fails identically in every language.
*/
export function validateLegacyDefinitions(collections: {
definitions?: Record<string, unknown>;
$defs?: Record<string, unknown>;
}): void {
for (const definitions of [collections.definitions, collections.$defs]) {
for (const [name, schema] of Object.entries(definitions ?? {})) {
readLegacyParameters(schema, name, { implicit: ["sessionId"] });
}
}
}