diff --git a/api/spec/packages/aip-client-javascript/src/models/schemas.ts b/api/spec/packages/aip-client-javascript/src/models/schemas.ts index 42893e5382..e4931af211 100644 --- a/api/spec/packages/aip-client-javascript/src/models/schemas.ts +++ b/api/spec/packages/aip-client-javascript/src/models/schemas.ts @@ -2899,8 +2899,6 @@ export const creditAdjustment = z 'Optional description of the resource. Maximum 1024 characters.', ), labels: labels.optional(), - currency: billingCurrencyCode, - amount: numeric, }) .describe( @@ -3655,10 +3653,6 @@ export const appStripe = z .describe( 'The masked Stripe API key that only exposes the first and last few characters.', ), - secret_api_key: z - .string() - .optional() - .describe('The Stripe secret API key used to authenticate API requests.'), }) .describe('Stripe app.') @@ -4014,8 +4008,6 @@ export const creditGrant = z 'Draw-down priority of the grant. Lower values have higher priority.', ), effective_at: dateTime.optional(), - expires_after: iso8601Duration.optional(), - key: externalResourceKey.optional(), expires_at: dateTime.optional(), voided_at: dateTime.optional(), status: creditGrantStatus, @@ -4566,7 +4558,6 @@ export const chargeFlatFee = z .optional() .describe('The feature associated with the charge, when applicable.'), proration_configuration: rateCardProrationConfiguration, - amount_before_proration: currencyAmount, amount_after_proration: currencyAmount, price: price, }) @@ -5006,7 +4997,6 @@ export const subscriptionAddon = z quantity_at: dateTime, active_from: dateTime, active_to: dateTime.optional(), - timing: subscriptionEditTiming, timeline: z .array(subscriptionAddonTimelineSegment) diff --git a/api/spec/packages/aip-client-javascript/src/models/types.ts b/api/spec/packages/aip-client-javascript/src/models/types.ts index 2eba305ff6..374e930a48 100644 --- a/api/spec/packages/aip-client-javascript/src/models/types.ts +++ b/api/spec/packages/aip-client-javascript/src/models/types.ts @@ -2167,10 +2167,6 @@ export interface CreditAdjustment { */ description?: string labels?: Labels - /** The currency of the granted credits. */ - currency: string - /** Granted credit amount. */ - amount: string } /** The credit balance by currency. */ @@ -3222,8 +3218,6 @@ export interface AppStripe { livemode: boolean /** The masked Stripe API key that only exposes the first and last few characters. */ masked_api_key: string - /** The Stripe secret API key used to authenticate API requests. */ - secret_api_key?: string } /** Sandbox app can be used for testing billing features. */ @@ -3638,19 +3632,6 @@ export interface CreditGrant { * Defaults to the current date and time. */ effective_at?: string - /** - * The duration after which the credit grant expires. - * - * Defaults to never expiring. - */ - expires_after?: string - /** - * Idempotency key for the credit grant creation request. - * - * When provided, reusing the same key returns an HTTP 409 Conflict instead of - * creating a duplicate grant, which makes create requests safe to retry. - */ - key?: string /** * The timestamp when the credit grant expires. * @@ -4297,8 +4278,6 @@ export interface ChargeFlatFee { feature_key?: string /** The proration configuration of the charge. */ proration_configuration: RateCardProrationConfiguration - /** The amount before proration of the charge. */ - amount_before_proration: CurrencyAmount /** The amount after proration of the charge. */ amount_after_proration: CurrencyAmount /** The price of the charge. */ @@ -4853,11 +4832,6 @@ export interface SubscriptionAddon { active_from: string /** An ISO-8601 timestamp representation of the cadence end of the resource. */ active_to?: string - /** - * The timing of the operation. After the create or update, a new entry will be - * created in the timeline. - */ - timing: 'immediate' | 'next_billing_cycle' | string /** The timeline of the add-on. The returned periods are sorted and continuous. */ timeline: SubscriptionAddonTimelineSegment[] /** The rate cards of the add-on. */ @@ -5521,19 +5495,6 @@ export interface CreditGrantInput { * Defaults to the current date and time. */ effective_at?: string - /** - * The duration after which the credit grant expires. - * - * Defaults to never expiring. - */ - expires_after?: string - /** - * Idempotency key for the credit grant creation request. - * - * When provided, reusing the same key returns an HTTP 409 Conflict instead of - * creating a duplicate grant, which makes create requests safe to retry. - */ - key?: string /** * The timestamp when the credit grant expires. * @@ -6168,11 +6129,6 @@ export interface SubscriptionAddonInput { active_from: string /** An ISO-8601 timestamp representation of the cadence end of the resource. */ active_to?: string - /** - * The timing of the operation. After the create or update, a new entry will be - * created in the timeline. - */ - timing: 'immediate' | 'next_billing_cycle' | string /** The timeline of the add-on. The returned periods are sorted and continuous. */ timeline: SubscriptionAddonTimelineSegment[] /** The rate cards of the add-on. */ diff --git a/api/spec/packages/typespec-typescript/src/emitter.tsx b/api/spec/packages/typespec-typescript/src/emitter.tsx index 5653521aa3..a107d36252 100644 --- a/api/spec/packages/typespec-typescript/src/emitter.tsx +++ b/api/spec/packages/typespec-typescript/src/emitter.tsx @@ -23,6 +23,10 @@ import { newTopologicalTypeCollector } from './utils.jsx' import { RUNTIME_TEMPLATES } from './runtime-templates.js' import { interfacesFile } from './interface-types.js' import { inputVariantName } from './input-variants.js' +import { + computeResponseReachableModels, + setResponseReachableModels, +} from './visibility.js' import { groupOperations, jsonBodyOverrides, @@ -60,6 +64,16 @@ export async function $onEmit(context: EmitContext) { context.program, context.options['include-services'], ) + + // Gate visibility filtering on response reachability before any model is + // walked: a create-/update-only property is dropped from a model's read shape + // only when that model appears in a response body. Set once, consulted by + // every property walker (interfaces, zod schemas, input-variant detection). + setResponseReachableModels( + context.program, + computeResponseReachableModels(context.program, operations), + ) + const opSchemas = operations.flatMap((op) => operationSchemas(context.program, op), ) diff --git a/api/spec/packages/typespec-typescript/src/input-variants.ts b/api/spec/packages/typespec-typescript/src/input-variants.ts index 3b9ef116d2..d1e1d8604d 100644 --- a/api/spec/packages/typespec-typescript/src/input-variants.ts +++ b/api/spec/packages/typespec-typescript/src/input-variants.ts @@ -1,5 +1,5 @@ import { type Model, type Program, type Type } from '@typespec/compiler' -import { isHttpEnvelopeProperty } from './utils.jsx' +import { bodyProperties } from './utils.jsx' /** * The input shape of a model differs from its output shape only when a defaulted @@ -59,10 +59,7 @@ export function computeDivergentModels( result = true } if (!result) { - for (const prop of model.properties.values()) { - if (isHttpEnvelopeProperty(program, prop)) { - continue - } + for (const prop of bodyProperties(program, model)) { if (prop.defaultValue !== undefined || reaches(prop.type)) { result = true break diff --git a/api/spec/packages/typespec-typescript/src/interface-types.ts b/api/spec/packages/typespec-typescript/src/interface-types.ts index bb311cb750..0f0e188389 100644 --- a/api/spec/packages/typespec-typescript/src/interface-types.ts +++ b/api/spec/packages/typespec-typescript/src/interface-types.ts @@ -1,6 +1,6 @@ import { type Model, type Program, type Type } from '@typespec/compiler' import { $ } from '@typespec/compiler/typekit' -import { isHttpEnvelopeProperty, jsdoc } from './utils.jsx' +import { bodyProperties, jsdoc } from './utils.jsx' import { type IoMode, type RefName, isOptional, tsTypeOf } from './ts-types.js' import { computeDivergentModels, inputVariantName } from './input-variants.js' @@ -47,10 +47,7 @@ function interfaceBody( ): string { const tk = $(program) const lines: string[] = [] - for (const prop of model.properties.values()) { - if (isHttpEnvelopeProperty(program, prop)) { - continue - } + for (const prop of bodyProperties(program, model)) { const doc = jsdoc(tk.type.getDoc(prop), ' ') if (doc) { lines.push(doc) @@ -133,9 +130,7 @@ export function interfacesFile( const baseName = model.baseModel ? refName(model.baseModel) : undefined const extendsClause = baseName ? ` extends ${baseName}` : '' - const hasWireProps = [...model.properties.values()].some( - (prop) => !isHttpEnvelopeProperty(program, prop), - ) + const hasWireProps = bodyProperties(program, model).length > 0 // Models with no wire-mapped properties and no base (records, unions, marker // types) have no structural interface — alias straight to the mapped type so diff --git a/api/spec/packages/typespec-typescript/src/utils.tsx b/api/spec/packages/typespec-typescript/src/utils.tsx index 3ede893d85..cdad0a4f7c 100644 --- a/api/spec/packages/typespec-typescript/src/utils.tsx +++ b/api/spec/packages/typespec-typescript/src/utils.tsx @@ -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), ) } diff --git a/api/spec/packages/typespec-typescript/src/visibility.ts b/api/spec/packages/typespec-typescript/src/visibility.ts new file mode 100644 index 0000000000..7702de7554 --- /dev/null +++ b/api/spec/packages/typespec-typescript/src/visibility.ts @@ -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>() + +/** + * 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, +): 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 { + const tk = $(program) + const reachable = new Set() + + 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) + } + 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 +}