Skip to content

Commit 1480f39

Browse files
authored
fix(sdk): drop create-only fields from read types in TypeScript emitter (#4605)
1 parent d10745e commit 1480f39

7 files changed

Lines changed: 177 additions & 69 deletions

File tree

api/spec/packages/aip-client-javascript/src/models/schemas.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2899,8 +2899,6 @@ export const creditAdjustment = z
28992899
'Optional description of the resource. Maximum 1024 characters.',
29002900
),
29012901
labels: labels.optional(),
2902-
currency: billingCurrencyCode,
2903-
amount: numeric,
29042902
})
29052903

29062904
.describe(
@@ -3655,10 +3653,6 @@ export const appStripe = z
36553653
.describe(
36563654
'The masked Stripe API key that only exposes the first and last few characters.',
36573655
),
3658-
secret_api_key: z
3659-
.string()
3660-
.optional()
3661-
.describe('The Stripe secret API key used to authenticate API requests.'),
36623656
})
36633657
.describe('Stripe app.')
36643658

@@ -4014,8 +4008,6 @@ export const creditGrant = z
40144008
'Draw-down priority of the grant. Lower values have higher priority.',
40154009
),
40164010
effective_at: dateTime.optional(),
4017-
expires_after: iso8601Duration.optional(),
4018-
key: externalResourceKey.optional(),
40194011
expires_at: dateTime.optional(),
40204012
voided_at: dateTime.optional(),
40214013
status: creditGrantStatus,
@@ -4566,7 +4558,6 @@ export const chargeFlatFee = z
45664558
.optional()
45674559
.describe('The feature associated with the charge, when applicable.'),
45684560
proration_configuration: rateCardProrationConfiguration,
4569-
amount_before_proration: currencyAmount,
45704561
amount_after_proration: currencyAmount,
45714562
price: price,
45724563
})
@@ -5006,7 +4997,6 @@ export const subscriptionAddon = z
50064997
quantity_at: dateTime,
50074998
active_from: dateTime,
50084999
active_to: dateTime.optional(),
5009-
timing: subscriptionEditTiming,
50105000
timeline: z
50115001
.array(subscriptionAddonTimelineSegment)
50125002

api/spec/packages/aip-client-javascript/src/models/types.ts

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2167,10 +2167,6 @@ export interface CreditAdjustment {
21672167
*/
21682168
description?: string
21692169
labels?: Labels
2170-
/** The currency of the granted credits. */
2171-
currency: string
2172-
/** Granted credit amount. */
2173-
amount: string
21742170
}
21752171

21762172
/** The credit balance by currency. */
@@ -3222,8 +3218,6 @@ export interface AppStripe {
32223218
livemode: boolean
32233219
/** The masked Stripe API key that only exposes the first and last few characters. */
32243220
masked_api_key: string
3225-
/** The Stripe secret API key used to authenticate API requests. */
3226-
secret_api_key?: string
32273221
}
32283222

32293223
/** Sandbox app can be used for testing billing features. */
@@ -3638,19 +3632,6 @@ export interface CreditGrant {
36383632
* Defaults to the current date and time.
36393633
*/
36403634
effective_at?: string
3641-
/**
3642-
* The duration after which the credit grant expires.
3643-
*
3644-
* Defaults to never expiring.
3645-
*/
3646-
expires_after?: string
3647-
/**
3648-
* Idempotency key for the credit grant creation request.
3649-
*
3650-
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
3651-
* creating a duplicate grant, which makes create requests safe to retry.
3652-
*/
3653-
key?: string
36543635
/**
36553636
* The timestamp when the credit grant expires.
36563637
*
@@ -4297,8 +4278,6 @@ export interface ChargeFlatFee {
42974278
feature_key?: string
42984279
/** The proration configuration of the charge. */
42994280
proration_configuration: RateCardProrationConfiguration
4300-
/** The amount before proration of the charge. */
4301-
amount_before_proration: CurrencyAmount
43024281
/** The amount after proration of the charge. */
43034282
amount_after_proration: CurrencyAmount
43044283
/** The price of the charge. */
@@ -4853,11 +4832,6 @@ export interface SubscriptionAddon {
48534832
active_from: string
48544833
/** An ISO-8601 timestamp representation of the cadence end of the resource. */
48554834
active_to?: string
4856-
/**
4857-
* The timing of the operation. After the create or update, a new entry will be
4858-
* created in the timeline.
4859-
*/
4860-
timing: 'immediate' | 'next_billing_cycle' | string
48614835
/** The timeline of the add-on. The returned periods are sorted and continuous. */
48624836
timeline: SubscriptionAddonTimelineSegment[]
48634837
/** The rate cards of the add-on. */
@@ -5521,19 +5495,6 @@ export interface CreditGrantInput {
55215495
* Defaults to the current date and time.
55225496
*/
55235497
effective_at?: string
5524-
/**
5525-
* The duration after which the credit grant expires.
5526-
*
5527-
* Defaults to never expiring.
5528-
*/
5529-
expires_after?: string
5530-
/**
5531-
* Idempotency key for the credit grant creation request.
5532-
*
5533-
* When provided, reusing the same key returns an HTTP 409 Conflict instead of
5534-
* creating a duplicate grant, which makes create requests safe to retry.
5535-
*/
5536-
key?: string
55375498
/**
55385499
* The timestamp when the credit grant expires.
55395500
*
@@ -6168,11 +6129,6 @@ export interface SubscriptionAddonInput {
61686129
active_from: string
61696130
/** An ISO-8601 timestamp representation of the cadence end of the resource. */
61706131
active_to?: string
6171-
/**
6172-
* The timing of the operation. After the create or update, a new entry will be
6173-
* created in the timeline.
6174-
*/
6175-
timing: 'immediate' | 'next_billing_cycle' | string
61766132
/** The timeline of the add-on. The returned periods are sorted and continuous. */
61776133
timeline: SubscriptionAddonTimelineSegment[]
61786134
/** The rate cards of the add-on. */

api/spec/packages/typespec-typescript/src/emitter.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import { newTopologicalTypeCollector } from './utils.jsx'
2323
import { RUNTIME_TEMPLATES } from './runtime-templates.js'
2424
import { interfacesFile } from './interface-types.js'
2525
import { inputVariantName } from './input-variants.js'
26+
import {
27+
computeResponseReachableModels,
28+
setResponseReachableModels,
29+
} from './visibility.js'
2630
import {
2731
groupOperations,
2832
jsonBodyOverrides,
@@ -60,6 +64,16 @@ export async function $onEmit(context: EmitContext<ZodEmitterOptions>) {
6064
context.program,
6165
context.options['include-services'],
6266
)
67+
68+
// Gate visibility filtering on response reachability before any model is
69+
// walked: a create-/update-only property is dropped from a model's read shape
70+
// only when that model appears in a response body. Set once, consulted by
71+
// every property walker (interfaces, zod schemas, input-variant detection).
72+
setResponseReachableModels(
73+
context.program,
74+
computeResponseReachableModels(context.program, operations),
75+
)
76+
6377
const opSchemas = operations.flatMap((op) =>
6478
operationSchemas(context.program, op),
6579
)

api/spec/packages/typespec-typescript/src/input-variants.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type Model, type Program, type Type } from '@typespec/compiler'
2-
import { isHttpEnvelopeProperty } from './utils.jsx'
2+
import { bodyProperties } from './utils.jsx'
33

44
/**
55
* The input shape of a model differs from its output shape only when a defaulted
@@ -59,10 +59,7 @@ export function computeDivergentModels(
5959
result = true
6060
}
6161
if (!result) {
62-
for (const prop of model.properties.values()) {
63-
if (isHttpEnvelopeProperty(program, prop)) {
64-
continue
65-
}
62+
for (const prop of bodyProperties(program, model)) {
6663
if (prop.defaultValue !== undefined || reaches(prop.type)) {
6764
result = true
6865
break

api/spec/packages/typespec-typescript/src/interface-types.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type Model, type Program, type Type } from '@typespec/compiler'
22
import { $ } from '@typespec/compiler/typekit'
3-
import { isHttpEnvelopeProperty, jsdoc } from './utils.jsx'
3+
import { bodyProperties, jsdoc } from './utils.jsx'
44
import { type IoMode, type RefName, isOptional, tsTypeOf } from './ts-types.js'
55
import { computeDivergentModels, inputVariantName } from './input-variants.js'
66

@@ -47,10 +47,7 @@ function interfaceBody(
4747
): string {
4848
const tk = $(program)
4949
const lines: string[] = []
50-
for (const prop of model.properties.values()) {
51-
if (isHttpEnvelopeProperty(program, prop)) {
52-
continue
53-
}
50+
for (const prop of bodyProperties(program, model)) {
5451
const doc = jsdoc(tk.type.getDoc(prop), ' ')
5552
if (doc) {
5653
lines.push(doc)
@@ -133,9 +130,7 @@ export function interfacesFile(
133130
const baseName = model.baseModel ? refName(model.baseModel) : undefined
134131
const extendsClause = baseName ? ` extends ${baseName}` : ''
135132

136-
const hasWireProps = [...model.properties.values()].some(
137-
(prop) => !isHttpEnvelopeProperty(program, prop),
138-
)
133+
const hasWireProps = bodyProperties(program, model).length > 0
139134

140135
// Models with no wire-mapped properties and no base (records, unions, marker
141136
// types) have no structural interface — alias straight to the mapped type so

api/spec/packages/typespec-typescript/src/utils.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type ZodOptionsContext,
1515
} from './context/zod-options.js'
1616
import { zod } from './external-packages/zod.js'
17+
import { isReadVisible } from './visibility.js'
1718

1819
export const refkeySym = Symbol.for('typespec-typescript.refkey')
1920

@@ -101,11 +102,16 @@ export function isHttpEnvelopeProperty(
101102

102103
/**
103104
* The payload (body) properties of a model: every own property minus the HTTP
104-
* envelope metadata stripped by {@link isHttpEnvelopeProperty}.
105+
* envelope metadata stripped by {@link isHttpEnvelopeProperty} and, for
106+
* response-reachable models, the create-/update-only fields dropped by
107+
* {@link isReadVisible} (so a server-never-returns field does not leak into a
108+
* read interface or schema).
105109
*/
106110
export function bodyProperties(program: Program, type: Model): ModelProperty[] {
107111
return [...type.properties.values()].filter(
108-
(prop) => !isHttpEnvelopeProperty(program, prop),
112+
(prop) =>
113+
!isHttpEnvelopeProperty(program, prop) &&
114+
isReadVisible(program, type, prop),
109115
)
110116
}
111117

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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

Comments
 (0)