|
| 1 | +import { |
| 2 | + type Model, |
| 3 | + type ModelProperty, |
| 4 | + type Operation, |
| 5 | + type Program, |
| 6 | + type Type, |
| 7 | +} from '@typespec/compiler' |
| 8 | +import { $ } from '@typespec/compiler/typekit' |
| 9 | +import { isVisible, Visibility } from '@typespec/http' |
| 10 | + |
| 11 | +/** |
| 12 | + * Models reachable from an operation response body, keyed by program. A model in |
| 13 | + * this set is emitted in a read context, so its `@visibility(Lifecycle.Create)`- |
| 14 | + * /`Update`-only properties (which the server never returns) must be dropped from |
| 15 | + * the interface and zod schema. |
| 16 | + * |
| 17 | + * Request-only models are deliberately absent: the spec emits read and request |
| 18 | + * payloads as distinct model trees (`CreditGrant` vs `CreateCreditGrantRequest`), |
| 19 | + * so a request body such as `SubscriptionCreate` — which declares a Create-only |
| 20 | + * `billing_anchor` directly — keeps every property. A global per-property Read |
| 21 | + * filter would wrongly strip those create fields; gating on response-reachability |
| 22 | + * is what makes the filter safe. |
| 23 | + */ |
| 24 | +const responseReachableByProgram = new WeakMap<Program, Set<Model>>() |
| 25 | + |
| 26 | +/** |
| 27 | + * Record which models are reachable from a response body, so {@link isReadVisible} |
| 28 | + * can gate visibility filtering on read context. Call once per emit, before any |
| 29 | + * type is walked. |
| 30 | + */ |
| 31 | +export function setResponseReachableModels( |
| 32 | + program: Program, |
| 33 | + models: Set<Model>, |
| 34 | +): void { |
| 35 | + responseReachableByProgram.set(program, models) |
| 36 | +} |
| 37 | + |
| 38 | +/** |
| 39 | + * Whether a property survives into a model's emitted (read) shape. |
| 40 | + * |
| 41 | + * A property is dropped only when its model is response-reachable AND the |
| 42 | + * property is not visible in `Lifecycle.Read` — i.e. a create-/update-only field |
| 43 | + * leaking into a response type. For request-only models (not response-reachable) |
| 44 | + * every property is kept, because their create-only fields are legitimate request |
| 45 | + * input. Without the response-reachable holder set (e.g. in unit tests that don't |
| 46 | + * compute it) nothing is filtered, preserving prior behavior. |
| 47 | + */ |
| 48 | +export function isReadVisible( |
| 49 | + program: Program, |
| 50 | + model: Model, |
| 51 | + prop: ModelProperty, |
| 52 | +): boolean { |
| 53 | + const reachable = responseReachableByProgram.get(program) |
| 54 | + if (!reachable || !reachable.has(model)) { |
| 55 | + return true |
| 56 | + } |
| 57 | + return isVisible(program, prop, Visibility.Read) |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * The set of models transitively reachable from the success-response body of any |
| 62 | + * operation. Descends through properties, base models, array/record element |
| 63 | + * types, union variants, and tuples — the same shape the schema/interface walkers |
| 64 | + * traverse — so a create-only field nested anywhere under a response is caught. |
| 65 | + */ |
| 66 | +export function computeResponseReachableModels( |
| 67 | + program: Program, |
| 68 | + operations: Operation[], |
| 69 | +): Set<Model> { |
| 70 | + const tk = $(program) |
| 71 | + const reachable = new Set<Model>() |
| 72 | + |
| 73 | + const visit = (type: Type | undefined): void => { |
| 74 | + if (!type) { |
| 75 | + return |
| 76 | + } |
| 77 | + switch (type.kind) { |
| 78 | + case 'Model': { |
| 79 | + if (reachable.has(type)) { |
| 80 | + return |
| 81 | + } |
| 82 | + reachable.add(type) |
| 83 | + if (type.indexer) { |
| 84 | + visit(type.indexer.value) |
| 85 | + } |
| 86 | + if (type.baseModel) { |
| 87 | + visit(type.baseModel) |
| 88 | + } |
| 89 | + // Descend into subclasses too: a model-form `@discriminator` hierarchy |
| 90 | + // returned as a response is reached through its base, and each concrete |
| 91 | + // subtype carries its own read-side properties to filter. |
| 92 | + for (const derived of type.derivedModels) { |
| 93 | + visit(derived) |
| 94 | + } |
| 95 | + for (const prop of type.properties.values()) { |
| 96 | + visit(prop.type) |
| 97 | + } |
| 98 | + break |
| 99 | + } |
| 100 | + case 'Union': |
| 101 | + for (const variant of type.variants.values()) { |
| 102 | + visit(variant.type) |
| 103 | + } |
| 104 | + break |
| 105 | + case 'Tuple': |
| 106 | + for (const value of type.values) { |
| 107 | + visit(value) |
| 108 | + } |
| 109 | + break |
| 110 | + default: |
| 111 | + break |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + for (const op of operations) { |
| 116 | + const httpOp = tk.httpOperation.get(op) |
| 117 | + for (const response of httpOp.responses) { |
| 118 | + // Only success bodies define the read shape; error envelopes carry their |
| 119 | + // own models and would otherwise pull unrelated types into the read set. |
| 120 | + if (!isSuccessStatus(response.statusCodes)) { |
| 121 | + continue |
| 122 | + } |
| 123 | + for (const content of response.responses) { |
| 124 | + visit(content.body?.type) |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + return reachable |
| 130 | +} |
| 131 | + |
| 132 | +/** |
| 133 | + * Whether a response's status code(s) fall in the 2xx success range. Handles the |
| 134 | + * three shapes `statusCodes` can take: a single number, a `{start, end}` range |
| 135 | + * (success when it overlaps 200–299), and the `"*"` default-response wildcard |
| 136 | + * (treated as success so a body declared only on the catch-all response still |
| 137 | + * contributes its read shape). Without this, a ranged or wildcard success body |
| 138 | + * would be skipped and its create-only fields would leak back into read types. |
| 139 | + */ |
| 140 | +function isSuccessStatus( |
| 141 | + statusCodes: number | { start: number; end: number } | '*', |
| 142 | +): boolean { |
| 143 | + if (statusCodes === '*') { |
| 144 | + return true |
| 145 | + } |
| 146 | + if (typeof statusCodes === 'number') { |
| 147 | + return statusCodes >= 200 && statusCodes < 300 |
| 148 | + } |
| 149 | + return statusCodes.start < 300 && statusCodes.end >= 200 |
| 150 | +} |
0 commit comments