microsoft/typespec

Public

mirrored from https://github.com/microsoft/typespecAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
92f7dc8654f967aa9e489c2e8da384f922f00a4b

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

packages/http/src/metadata.ts

518lines · modecode

1import {
2 compilerAssert,
3 DiagnosticCollector,
4 getEffectiveModelType,
5 isVisible as isVisibleCore,
6 Model,
7 ModelProperty,
8 Program,
9 Queue,
10 TwoLevelMap,
11 Type,
12 Union,
13 walkPropertiesInherited,
14} from "@typespec/compiler";
15import {
16 includeInapplicableMetadataInPayload,
17 isBody,
18 isHeader,
19 isPathParam,
20 isQueryParam,
21 isStatusCode,
22} from "./decorators.js";
23import { HttpVerb } from "./types.js";
24
25/**
26 * Flags enum representation of well-known visibilities that are used in
27 * REST API.
28 */
29export enum Visibility {
30 Read = 1 << 0,
31 Create = 1 << 1,
32 Update = 1 << 2,
33 Delete = 1 << 3,
34 Query = 1 << 4,
35
36 None = 0,
37 All = Read | Create | Update | Delete | Query,
38
39 /**
40 * Additional flag to indicate when something is nested in a collection
41 * and therefore no metadata is applicable.
42 */
43 Item = 1 << 20,
44}
45
46const visibilityToArrayMap: Map<Visibility, string[]> = new Map();
47function visibilityToArray(visibility: Visibility): readonly string[] {
48 // Item flag is not a real visibility.
49 visibility &= ~Visibility.Item;
50
51 let result = visibilityToArrayMap.get(visibility);
52 if (!result) {
53 result = [];
54
55 if (visibility & Visibility.Read) {
56 result.push("read");
57 }
58 if (visibility & Visibility.Create) {
59 result.push("create");
60 }
61 if (visibility & Visibility.Update) {
62 result.push("update");
63 }
64 if (visibility & Visibility.Delete) {
65 result.push("delete");
66 }
67 if (visibility & Visibility.Query) {
68 result.push("query");
69 }
70
71 compilerAssert(result.length > 0 || visibility === Visibility.None, "invalid visibility");
72 visibilityToArrayMap.set(visibility, result);
73 }
74
75 return result;
76}
77
78/**
79 * Provides a naming suffix to create a unique name for a type with this
80 * visibility.
81 *
82 * The canonical visibility (default Visibility.Read) gets empty suffix,
83 * otherwise visibilities are joined in pascal-case with `Or`. And `Item` is
84 * if `Visibility.Item` is produced.
85 *
86 * Examples (with canonicalVisibility = Visibility.Read):
87 * - Visibility.Read => ""
88 * - Visibility.Update => "Update"
89 * - Visibility.Create | Visibility.Update => "CreateOrUpdate"
90 * - Visibility.Create | Visibility.Item => "CreateItem"
91 * - Visibility.Create | Visibility.Update | Visibility.Item => "CreateOrUpdateItem"
92 * */
93export function getVisibilitySuffix(
94 visibility: Visibility,
95 canonicalVisibility: Visibility | undefined = Visibility.None
96) {
97 let suffix = "";
98
99 if ((visibility & ~Visibility.Item) !== canonicalVisibility) {
100 const visibilities = visibilityToArray(visibility);
101 suffix += visibilities.map((v) => v[0].toUpperCase() + v.slice(1)).join("Or");
102 }
103
104 if (visibility & Visibility.Item) {
105 suffix += "Item";
106 }
107
108 return suffix;
109}
110
111/**
112 * Determines the visibility to use for a request with the given verb.
113 *
114 * - GET | HEAD => Visibility.Query
115 * - POST => Visibility.Update
116 * - PUT => Visibility.Create | Update
117 * - DELETE => Visibility.Delete
118 */
119export function getRequestVisibility(verb: HttpVerb): Visibility {
120 switch (verb) {
121 case "get":
122 case "head":
123 return Visibility.Query;
124 case "post":
125 return Visibility.Create;
126 case "put":
127 return Visibility.Create | Visibility.Update;
128 case "patch":
129 return Visibility.Update;
130 case "delete":
131 return Visibility.Delete;
132 default:
133 const _assertNever: never = verb;
134 compilerAssert(false, "unreachable");
135 }
136}
137
138/**
139 * Walks the given type and collects all applicable metadata and `@body`
140 * properties recursively.
141 *
142 * @param rootMapOut If provided, the map will be populated to link
143 * nested metadata properties to their root properties.
144 */
145export function gatherMetadata(
146 program: Program,
147 diagnostics: DiagnosticCollector, // currently unused, but reserved for future diagnostics
148 type: Type,
149 visibility: Visibility,
150 isMetadataCallback = isMetadata,
151 rootMapOut?: Map<ModelProperty, ModelProperty>
152): Set<ModelProperty> {
153 const metadata = new Map<string, ModelProperty>();
154 if (type.kind !== "Model" || type.indexer || type.properties.size === 0) {
155 return new Set();
156 }
157
158 const visited = new Set();
159 const queue = new Queue<[Model, ModelProperty | undefined]>([[type, undefined]]);
160
161 while (!queue.isEmpty()) {
162 const [model, rootOpt] = queue.dequeue();
163 visited.add(model);
164
165 for (const property of walkPropertiesInherited(model)) {
166 const root = rootOpt ?? property;
167
168 if (!isVisible(program, property, visibility)) {
169 continue;
170 }
171
172 // ISSUE: This should probably be an error, but that's a breaking
173 // change that currently breaks some samples and tests.
174 //
175 // The traversal here is level-order so that the preferred metadata in
176 // the case of duplicates, which is the most compatible with prior
177 // behavior where nested metadata was always dropped.
178 if (metadata.has(property.name)) {
179 continue;
180 }
181
182 if (isApplicableMetadataOrBody(program, property, visibility, isMetadataCallback)) {
183 metadata.set(property.name, property);
184 rootMapOut?.set(property, root);
185 }
186
187 if (
188 property.type.kind === "Model" &&
189 !type.indexer &&
190 type.properties.size > 0 &&
191 !visited.has(property.type)
192 ) {
193 queue.enqueue([property.type, root]);
194 }
195 }
196 }
197
198 return new Set(metadata.values());
199}
200
201/**
202 * Determines if a property is metadata. A property is defined to be
203 * metadata if it is marked `@header`, `@query`, `@path`, or `@statusCode`.
204 */
205export function isMetadata(program: Program, property: ModelProperty) {
206 return (
207 isHeader(program, property) ||
208 isQueryParam(program, property) ||
209 isPathParam(program, property) ||
210 isStatusCode(program, property)
211 );
212}
213
214/**
215 * Determines if the given property is visible with the given visibility.
216 */
217export function isVisible(program: Program, property: ModelProperty, visibility: Visibility) {
218 return isVisibleCore(program, property, visibilityToArray(visibility));
219}
220
221/**
222 * Determines if the given property is metadata that is applicable with the
223 * given visibility.
224 *
225 * - No metadata is applicable with Visibility.Item present.
226 * - If only Visibility.Read is present, then only `@header` and `@status`
227 * properties are applicable.
228 * - If Visibility.Read is not present, all metadata properties other than
229 * `@statusCode` are applicable.
230 */
231export function isApplicableMetadata(
232 program: Program,
233 property: ModelProperty,
234 visibility: Visibility,
235 isMetadataCallback = isMetadata
236) {
237 return isApplicableMetadataCore(program, property, visibility, false, isMetadataCallback);
238}
239
240/**
241 * Determines if the given property is metadata or marked `@body` and
242 * applicable with the given visibility.
243 */
244export function isApplicableMetadataOrBody(
245 program: Program,
246 property: ModelProperty,
247 visibility: Visibility,
248 isMetadataCallback = isMetadata
249) {
250 return isApplicableMetadataCore(program, property, visibility, true, isMetadataCallback);
251}
252
253function isApplicableMetadataCore(
254 program: Program,
255 property: ModelProperty,
256 visibility: Visibility,
257 treatBodyAsMetadata: boolean,
258 isMetadataCallback: (program: Program, property: ModelProperty) => boolean
259) {
260 if (visibility & Visibility.Item) {
261 return false; // no metadata is applicable to collection items
262 }
263
264 if (treatBodyAsMetadata && isBody(program, property)) {
265 return true;
266 }
267
268 if (!isMetadataCallback(program, property)) {
269 return false;
270 }
271
272 if (visibility === Visibility.Read) {
273 return isHeader(program, property) || isStatusCode(program, property);
274 }
275
276 if (!(visibility & Visibility.Read)) {
277 return !isStatusCode(program, property);
278 }
279
280 return true;
281}
282
283/**
284 * Provides information about changes that happen to a data type's payload
285 * when inapplicable metadata is added or invisible properties are removed.
286 *
287 * Results are computed on demand and expensive computations are memoized.
288 */
289export interface MetadataInfo {
290 /**
291 * Determines if the given type is a model that becomes empty once
292 * applicable metadata is removed and visibility is applied.
293 *
294 * Note that a model is not considered emptied if it was already empty in
295 * the first place, or has a base model or indexer.
296 *
297 * When the type of a property is emptied by visibility, the property
298 * itself is also removed.
299 */
300 isEmptied(type: Type | undefined, visibility: Visibility): boolean;
301
302 /**
303 * Determines if the given type is transformed by applying the given
304 * visibility and removing invisible properties or adding inapplicable
305 * metadata properties.
306 */
307 isTransformed(type: Type | undefined, visibility: Visibility): boolean;
308
309 /**
310 * Determines if the given property is part of the request or response
311 * payload and not applicable metadata {@link isApplicableMetadata} or
312 * filtered out by the given visibility.
313 */
314 isPayloadProperty(property: ModelProperty, visibility: Visibility): boolean;
315
316 /**
317 * Determines if the given property is optional in the request or
318 * response payload for the given visibility.
319 */
320 isOptional(property: ModelProperty, visibility: Visibility): boolean;
321
322 /**
323 * If type is an anonymous model, tries to find a named model that has the
324 * same set of properties when non-payload properties are excluded.
325 */
326 getEffectivePayloadType(type: Type, visibility: Visibility): Type;
327}
328
329export interface MetadataInfoOptions {
330 /**
331 * The visibility to be used as the baseline against which
332 * {@link MetadataInfo.isEmptied} and {@link MetadataInfo.isTransformed}
333 * are computed. If not specified, {@link Visibility.None} is used, which
334 * will consider that any model that has fields that are only visible to
335 * some visibilities as transformed.
336 */
337 canonicalVisibility?: Visibility;
338
339 /**
340 * Optional callback to indicate that a property can be shared with the
341 * canonical representation even for visibilities where it is not visible.
342 *
343 * This is used, for example, in OpenAPI emit where a property can be
344 * marked `readOnly: true` to represent @visibility("read") without
345 * creating a separate schema schema for {@link Visibility.Read}.
346 */
347 canShareProperty?(property: ModelProperty): boolean;
348}
349
350export function createMetadataInfo(program: Program, options?: MetadataInfoOptions): MetadataInfo {
351 const canonicalVisibility = options?.canonicalVisibility ?? Visibility.None;
352 const enum State {
353 NotTransformed,
354 Transformed,
355 Emptied,
356 ComputationInProgress,
357 }
358
359 const stateMap = new TwoLevelMap<Type, Visibility, State>();
360
361 return {
362 isEmptied,
363 isTransformed,
364 isPayloadProperty,
365 isOptional,
366 getEffectivePayloadType,
367 };
368
369 function isEmptied(type: Type | undefined, visibility: Visibility): boolean {
370 if (!type) {
371 return false;
372 }
373 const state = getState(type, visibility);
374 return state === State.Emptied;
375 }
376
377 function isTransformed(type: Type | undefined, visibility: Visibility): boolean {
378 if (!type) {
379 return false;
380 }
381 const state = getState(type, visibility);
382 switch (state) {
383 case State.Transformed:
384 return true;
385 case State.Emptied:
386 return visibility === canonicalVisibility || !isEmptied(type, canonicalVisibility);
387 default:
388 return false;
389 }
390 }
391
392 function getState(type: Type, visibility: Visibility): State {
393 return stateMap.getOrAdd(
394 type,
395 visibility,
396 () => computeState(type, visibility),
397 State.ComputationInProgress
398 );
399 }
400
401 function computeState(type: Type, visibility: Visibility): State {
402 switch (type.kind) {
403 case "Model":
404 return computeStateForModel(type, visibility);
405 case "Union":
406 return computeStateForUnion(type, visibility);
407 default:
408 return State.NotTransformed;
409 }
410 }
411
412 function computeStateForModel(model: Model, visibility: Visibility) {
413 if (computeIsEmptied(model, visibility)) {
414 return State.Emptied;
415 }
416 if (
417 isTransformed(model.indexer?.value, visibility | Visibility.Item) ||
418 isTransformed(model.baseModel, visibility)
419 ) {
420 return State.Transformed;
421 }
422 for (const property of model.properties.values()) {
423 if (
424 isAddedRemovedOrMadeOptional(property, visibility) ||
425 isTransformed(property.type, visibility)
426 ) {
427 return State.Transformed;
428 }
429 }
430 return State.NotTransformed;
431 }
432
433 function computeStateForUnion(union: Union, visibility: Visibility) {
434 for (const variant of union.variants.values()) {
435 if (isTransformed(variant.type, visibility)) {
436 return State.Transformed;
437 }
438 }
439 return State.NotTransformed;
440 }
441
442 function isAddedRemovedOrMadeOptional(property: ModelProperty, visibility: Visibility) {
443 if (visibility === canonicalVisibility) {
444 return false;
445 }
446 if (isOptional(property, canonicalVisibility) !== isOptional(property, visibility)) {
447 return true;
448 }
449 return (
450 isPayloadProperty(property, visibility, /* keep shared */ true) !==
451 isPayloadProperty(property, canonicalVisibility, /*keep shared*/ true)
452 );
453 }
454
455 function computeIsEmptied(model: Model, visibility: Visibility) {
456 if (model.baseModel || model.indexer || model.properties.size === 0) {
457 return false;
458 }
459 for (const property of model.properties.values()) {
460 if (isPayloadProperty(property, visibility, /* keep shared */ true)) {
461 return false;
462 }
463 }
464 return true;
465 }
466
467 function isOptional(property: ModelProperty, visibility: Visibility): boolean {
468 // Properties are only made optional for update visibility
469 return property.optional || visibility === Visibility.Update;
470 }
471
472 function isPayloadProperty(
473 property: ModelProperty,
474 visibility: Visibility,
475 keepShareableProperties?: boolean
476 ): boolean {
477 if (
478 isEmptied(property.type, visibility) ||
479 isApplicableMetadata(program, property, visibility) ||
480 (isMetadata(program, property) && !includeInapplicableMetadataInPayload(program, property))
481 ) {
482 return false;
483 }
484
485 if (!isVisible(program, property, visibility)) {
486 // NOTE: When we check if a model is transformed for a given
487 // visibility, we retain shared properties. It is not considered
488 // transformed if the only removed properties are shareable. However,
489 // if we do create a unique schema for a visibility, then we still
490 // drop invisible shareable properties from other uses of
491 // isPayloadProperty.
492 //
493 // For OpenAPI emit, for example, this means that we won't put a
494 // readOnly: true property into a specialized schema for a non-read
495 // visibility.
496 keepShareableProperties ||= visibility === canonicalVisibility;
497 return !!(keepShareableProperties && options?.canShareProperty?.(property));
498 }
499
500 return true;
501 }
502
503 /**
504 * If the type is an anonymous model, tries to find a named model that has the same
505 * set of properties when non-payload properties are excluded.
506 */
507 function getEffectivePayloadType(type: Type, visibility: Visibility): Type {
508 if (type.kind === "Model" && !type.name) {
509 const effective = getEffectiveModelType(program, type, (p) =>
510 isPayloadProperty(p, visibility)
511 );
512 if (effective.name) {
513 return effective;
514 }
515 }
516 return type;
517 }
518}
519