microsoft / microsoft/typespec
[Bug]: openapi3 1.15.0 emits a duplicate, identical schema with a `ReadOrCreateOrUpdateOrDeleteOrQuery` suffix for types shared between an operation and an unreachable derived model
- Dominant language
- Java
- Stars
- 5.9k
- Forks
- 394
- Avg merge
- 1d 23h
- Merged PRs (30d)
- 104
Description
### Describe the bug
This report originated from duplicated schema files when upgrading our repository from 1.14 to 1.15.
The assessment of the issue is 🤖 AI generated and reviewed by me - although I am not familiar with
the internals it seems reasonable and unexpected behavior.
---
Since `@typespec/openapi3` 1.15.0, a model that is referenced both from an HTTP operation and from an
*unreachable* derived model is emitted twice: once under its normal name, and once again under a
`...ReadOrCreateOrUpdateOrDeleteOrQuery` suffix. The two schemas are identical except for the name, so the
second declaration carries no information — it only renames the reference and adds a redundant schema
to `components.schemas`.
This is a regression: 1.14.0 emits a single schema for the same spec.
Versions:
- Broken: `@typespec/openapi3` 1.15.0
- Working: `@typespec/openapi3` 1.14.0
The emitter is the only package involved. Pinning `@typespec/openapi3` to 1.14.0 while running
`@typespec/compiler` and `@typespec/http` at 1.15.0 produces the correct single-schema output.
## Analysis
The trigger looks like [#11427](https://github.com/microsoft/typespec/pull/11427). `modelDeclaration`
now forces `Visibility.All` when a base model emits an unreachable derived model:
```js
for (const child of derivedModels) {
if (this._visibilityUsage.isUnreachable(child)) {
// Unreachable derived models will be emitted by processUnreferencedSchemas with
// Visibility.All context. Force the same context here to avoid a duplicate declaration.
this.emitter.emitTypeReference(child, { referenceContext: { visibility: Visibility.All } });
} else {
this.emitter.emitTypeReference(child);
}
}
```
That forced context reaches the child's *property* types, so a type reached through such a child now
records a second visibility usage. `#createDeclaration` then appends a suffix purely on usage count:
```js
const usage = this._visibilityUsage.getUsage(type);
const shouldAddSuffix = usage !== undefined && usage.size > 1;
const visibility = this.#getVisibilityContext();
const fullName = name + (shouldAddSuffix ? getVisibilitySuffix(visibility, Visibility.Read) : "");
```
With an instrumented emitter, the affected wrapper reports `usage = {Read, All}` and is declared
twice, while every other instantiation of the same template reports `usage = {Read}` and is declared
once. `metadataInfo.isTransformed(wrapper, Visibility.All)` is `false` — the extra visibility does not
change the schema at all, which is why the two declarations come out identical.
Two directions a fix could take, whichever matches the intent of #11427:
1. Let the forced-`All` child's property references still go through the usual `reduceContext`
collapse, so an untransformed type stays canonical.
2. Gate `shouldAddSuffix` on the type actually being transformed by the visibility, rather than on
`usage.size > 1` alone.
### Reproduction
`main.tsp`
```tsp
import "@typespec/http";
using TypeSpec.Http;
@service(#{ title: "Repro" })
namespace Repro;
model Widget {
id: string;
}
@friendlyName("{name}Page", T)
model Page {
items: T[];
}
@route("/widgets")
@get
op listWidgets(): Page;
// Documentation-only models: no operation references them, so they are "unreachable"
// and emitted only because omit-unreachable-types defaults to false.
@discriminator("kind")
model Event {
kind: string;
}
model WidgetsEvent extends Event {
kind: "widgets";
widgets: Page;
}
```
## Expected (produced by 1.14.0)
```yaml
WidgetPage:
type: object
required:
- items
properties:
items:
type: array
items:
$ref: '#/components/schemas/Widget'
WidgetsEvent:
type: object
required:
- kind
- widgets
properties:
kind:
type: string
enum:
- widgets
widgets:
$ref: '#/components/schemas/WidgetPage'
allOf:
- $ref: '#/components/schemas/Event'
```
## Actual (1.15.0)
```yaml
WidgetPage:
type: object
required:
- items
properties:
items:
type: array
items:
$ref: '#/components/schemas/Widget'
WidgetPageReadOrCreateOrUpdateOrDeleteOrQuery: # identical to WidgetPage
type: object
required:
- items
properties:
items:
type: array
items:
$ref: '#/components/schemas/Widget'
WidgetsEvent:
type: object
required:
- kind
- widgets
properties:
kind:
type: string
enum:
- widgets
widgets:
$ref: '#/components/schemas/WidgetPageReadOrCreateOrUpdateOrDeleteOrQuery'
allOf:
- $ref: '#/components/schemas/Event'
```
### Checklist
- [x] Follow our [Code of Conduct](https://github.com/microsoft/typespec/blob/main/CODE_OF_CONDUCT.md)
- [x] Check that there isn't already an issue that request the same bug to avoid creating a duplicate.
- [x] Check that this is a concrete bug. For Q&A open a [GitHub Discussion](https://github.com/Microsoft/typespec/discussions).
- [x] The provided reproduction is a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) of the bug.
Contributor guide
Research direction
Start with the main.tsp reproduction and the openapi3 emitter entry points named in the report: modelDeclaration, processUnreferencedSchemas, reduceContext, and #createDeclaration. Compare the generated schemas under 1.14.0 and 1.15.0, then trace why the forced Visibility.All context records a second usage. Done means the reproduction emits one canonical WidgetPage schema and references it without the redundant suffix.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- openapi
- Domain
- api
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 50/100