-
Notifications
You must be signed in to change notification settings - Fork 197
fix(sdk): drop create-only fields from read types in TypeScript emitter #4605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ import { | |
| type ZodOptionsContext, | ||
| } from './context/zod-options.js' | ||
| import { zod } from './external-packages/zod.js' | ||
| import { isReadVisible } from './visibility.js' | ||
|
|
||
| export const refkeySym = Symbol.for('typespec-typescript.refkey') | ||
|
|
||
|
|
@@ -101,11 +102,16 @@ export function isHttpEnvelopeProperty( | |
|
|
||
| /** | ||
| * The payload (body) properties of a model: every own property minus the HTTP | ||
| * envelope metadata stripped by {@link isHttpEnvelopeProperty}. | ||
| * envelope metadata stripped by {@link isHttpEnvelopeProperty} and, for | ||
| * response-reachable models, the create-/update-only fields dropped by | ||
| * {@link isReadVisible} (so a server-never-returns field does not leak into a | ||
| * read interface or schema). | ||
| */ | ||
| export function bodyProperties(program: Program, type: Model): ModelProperty[] { | ||
| return [...type.properties.values()].filter( | ||
| (prop) => !isHttpEnvelopeProperty(program, prop), | ||
| (prop) => | ||
| !isHttpEnvelopeProperty(program, prop) && | ||
| isReadVisible(program, type, prop), | ||
|
Comment on lines
110
to
+114
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Keep read-visibility filtering out of the shared This helper is still used by input-mode paths too: 🤖 Prompt for AI Agents |
||
| ) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { | ||
| type Model, | ||
| type ModelProperty, | ||
| type Operation, | ||
| type Program, | ||
| type Type, | ||
| } from '@typespec/compiler' | ||
| import { $ } from '@typespec/compiler/typekit' | ||
| import { isVisible, Visibility } from '@typespec/http' | ||
|
|
||
| /** | ||
| * Models reachable from an operation response body, keyed by program. A model in | ||
| * this set is emitted in a read context, so its `@visibility(Lifecycle.Create)`- | ||
| * /`Update`-only properties (which the server never returns) must be dropped from | ||
| * the interface and zod schema. | ||
| * | ||
| * Request-only models are deliberately absent: the spec emits read and request | ||
| * payloads as distinct model trees (`CreditGrant` vs `CreateCreditGrantRequest`), | ||
| * so a request body such as `SubscriptionCreate` — which declares a Create-only | ||
| * `billing_anchor` directly — keeps every property. A global per-property Read | ||
| * filter would wrongly strip those create fields; gating on response-reachability | ||
| * is what makes the filter safe. | ||
| */ | ||
| const responseReachableByProgram = new WeakMap<Program, Set<Model>>() | ||
|
|
||
| /** | ||
| * Record which models are reachable from a response body, so {@link isReadVisible} | ||
| * can gate visibility filtering on read context. Call once per emit, before any | ||
| * type is walked. | ||
| */ | ||
| export function setResponseReachableModels( | ||
| program: Program, | ||
| models: Set<Model>, | ||
| ): void { | ||
| responseReachableByProgram.set(program, models) | ||
| } | ||
|
|
||
| /** | ||
| * Whether a property survives into a model's emitted (read) shape. | ||
| * | ||
| * A property is dropped only when its model is response-reachable AND the | ||
| * property is not visible in `Lifecycle.Read` — i.e. a create-/update-only field | ||
| * leaking into a response type. For request-only models (not response-reachable) | ||
| * every property is kept, because their create-only fields are legitimate request | ||
| * input. Without the response-reachable holder set (e.g. in unit tests that don't | ||
| * compute it) nothing is filtered, preserving prior behavior. | ||
| */ | ||
| export function isReadVisible( | ||
| program: Program, | ||
| model: Model, | ||
| prop: ModelProperty, | ||
| ): boolean { | ||
| const reachable = responseReachableByProgram.get(program) | ||
| if (!reachable || !reachable.has(model)) { | ||
| return true | ||
| } | ||
| return isVisible(program, prop, Visibility.Read) | ||
| } | ||
|
|
||
| /** | ||
| * The set of models transitively reachable from the success-response body of any | ||
| * operation. Descends through properties, base models, array/record element | ||
| * types, union variants, and tuples — the same shape the schema/interface walkers | ||
| * traverse — so a create-only field nested anywhere under a response is caught. | ||
| */ | ||
| export function computeResponseReachableModels( | ||
| program: Program, | ||
| operations: Operation[], | ||
| ): Set<Model> { | ||
| const tk = $(program) | ||
| const reachable = new Set<Model>() | ||
|
|
||
| const visit = (type: Type | undefined): void => { | ||
| if (!type) { | ||
| return | ||
| } | ||
| switch (type.kind) { | ||
| case 'Model': { | ||
| if (reachable.has(type)) { | ||
| return | ||
| } | ||
| reachable.add(type) | ||
| if (type.indexer) { | ||
| visit(type.indexer.value) | ||
| } | ||
| if (type.baseModel) { | ||
| visit(type.baseModel) | ||
| } | ||
| // Descend into subclasses too: a model-form `@discriminator` hierarchy | ||
| // returned as a response is reached through its base, and each concrete | ||
| // subtype carries its own read-side properties to filter. | ||
| for (const derived of type.derivedModels) { | ||
| visit(derived) | ||
| } | ||
|
Comment on lines
+89
to
+94
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Avoid pulling sibling subtypes into the reachable read set. Returning one concrete subtype will currently mark its siblings reachable too. For example, 🤖 Prompt for AI Agents |
||
| for (const prop of type.properties.values()) { | ||
| visit(prop.type) | ||
| } | ||
| break | ||
| } | ||
| case 'Union': | ||
| for (const variant of type.variants.values()) { | ||
| visit(variant.type) | ||
| } | ||
| break | ||
| case 'Tuple': | ||
| for (const value of type.values) { | ||
| visit(value) | ||
| } | ||
| break | ||
| default: | ||
| break | ||
| } | ||
| } | ||
|
|
||
| for (const op of operations) { | ||
| const httpOp = tk.httpOperation.get(op) | ||
| for (const response of httpOp.responses) { | ||
| // Only success bodies define the read shape; error envelopes carry their | ||
| // own models and would otherwise pull unrelated types into the read set. | ||
| if (!isSuccessStatus(response.statusCodes)) { | ||
| continue | ||
| } | ||
| for (const content of response.responses) { | ||
| visit(content.body?.type) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return reachable | ||
| } | ||
|
|
||
| /** | ||
| * Whether a response's status code(s) fall in the 2xx success range. Handles the | ||
| * three shapes `statusCodes` can take: a single number, a `{start, end}` range | ||
| * (success when it overlaps 200–299), and the `"*"` default-response wildcard | ||
| * (treated as success so a body declared only on the catch-all response still | ||
| * contributes its read shape). Without this, a ranged or wildcard success body | ||
| * would be skipped and its create-only fields would leak back into read types. | ||
| */ | ||
| function isSuccessStatus( | ||
| statusCodes: number | { start: number; end: number } | '*', | ||
| ): boolean { | ||
| if (statusCodes === '*') { | ||
| return true | ||
| } | ||
| if (typeof statusCodes === 'number') { | ||
| return statusCodes >= 200 && statusCodes < 300 | ||
| } | ||
| return statusCodes.start < 300 && statusCodes.end >= 200 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
interfaceBodyalso emits...Inputvariants, but this call now usesbodyProperties(), which filters create/update-only properties from any response-reachable model. For models used in both responses and requests, such asCreditGrantandSubscriptionAddon, valid request fields likeexpires_after,key, andtimingdisappear from the generated input types, so SDK callers cannot type valid create/update payloads without casts.Context Used: api/spec/AGENTS.md (source)
Prompt To Fix With AI