microsoft/typespec

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
e26e7826068edd7ad2659a4a2ea74397dcdd1ea6

Branches

Tags

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

Clone

HTTPS

Download ZIP

packages/http/src/metadata.ts

605lines · modecode

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