Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions api/spec/packages/aip-client-javascript/src/models/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2899,8 +2899,6 @@ export const creditAdjustment = z
'Optional description of the resource. Maximum 1024 characters.',
),
labels: labels.optional(),
currency: billingCurrencyCode,
amount: numeric,
})

.describe(
Expand Down Expand Up @@ -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.')

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -5006,7 +4997,6 @@ export const subscriptionAddon = z
quantity_at: dateTime,
active_from: dateTime,
active_to: dateTime.optional(),
timing: subscriptionEditTiming,
timeline: z
.array(subscriptionAddonTimelineSegment)

Expand Down
44 changes: 0 additions & 44 deletions api/spec/packages/aip-client-javascript/src/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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. */
Expand Down
14 changes: 14 additions & 0 deletions api/spec/packages/typespec-typescript/src/emitter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -60,6 +64,16 @@ export async function $onEmit(context: EmitContext<ZodEmitterOptions>) {
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),
)
Expand Down
7 changes: 2 additions & 5 deletions api/spec/packages/typespec-typescript/src/input-variants.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 3 additions & 8 deletions api/spec/packages/typespec-typescript/src/interface-types.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Input Fields Become Read Fields

interfaceBody also emits ...Input variants, but this call now uses bodyProperties(), which filters create/update-only properties from any response-reachable model. For models used in both responses and requests, such as CreditGrant and SubscriptionAddon, valid request fields like expires_after, key, and timing disappear 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
This is a comment left during a code review.
Path: api/spec/packages/typespec-typescript/src/interface-types.ts
Line: 50

Comment:
**Input Fields Become Read Fields**

`interfaceBody` also emits `...Input` variants, but this call now uses `bodyProperties()`, which filters create/update-only properties from any response-reachable model. For models used in both responses and requests, such as `CreditGrant` and `SubscriptionAddon`, valid request fields like `expires_after`, `key`, and `timing` disappear from the generated input types, so SDK callers cannot type valid create/update payloads without casts.

**Context Used:** api/spec/AGENTS.md ([source](https://app.greptile.com/openmeter/github/openmeterio/openmeter/-/custom-context?memory=28ba6068-00f9-4629-9b78-8e49cc802858))

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

const doc = jsdoc(tk.type.getDoc(prop), ' ')
if (doc) {
lines.push(doc)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions api/spec/packages/typespec-typescript/src/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 bodyProperties() helper.

This helper is still used by input-mode paths too: api/spec/packages/typespec-typescript/src/interface-types.ts, Line 175 builds ...Input bodies from it, and api/spec/packages/typespec-typescript/src/input-variants.ts, Line 62 uses it for divergence detection. Once a response-reachable model hides create-only props here, those props disappear from generated input types as well, and models whose only input/output difference is visibility can stop getting an ...Input variant. Splitting the read filter from the shared envelope filter would avoid that.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/spec/packages/typespec-typescript/src/utils.tsx` around lines 110 - 114,
The shared bodyProperties() helper is incorrectly applying read-visibility
filtering, which removes create-only properties from input generation and can
suppress ...Input variants. Update bodyProperties(program, type) in utils.tsx to
keep only the shared envelope filtering, and move the isReadVisible logic to the
response/output-specific call sites instead. Make sure interface-types.ts and
input-variants.ts continue to use bodyProperties() for the common body shape,
while any response-only visibility filtering is applied separately where output
models are produced.

)
}

Expand Down
150 changes: 150 additions & 0 deletions api/spec/packages/typespec-typescript/src/visibility.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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, visit(Dog) climbs to Animal, then Animal.derivedModels pulls in Cat, so Cat starts getting read-visibility filtering even though it never appears in a success response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/spec/packages/typespec-typescript/src/visibility.ts` around lines 89 -
94, The recursive walk in visit is incorrectly traversing type.derivedModels
from every visited model, which pulls sibling discriminator subtypes into the
reachable read set. Update the traversal so it only follows the actual returned
subtype path (for example, by limiting derived-model descent to the concrete
response subtype or otherwise avoiding expanding all siblings when visiting a
base like Animal) while keeping the existing visibility filtering logic in
visibility.ts intact.

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
}
Loading