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

Open
#11,756 1 comment 1 reaction 0 assignees View on GitHub
bug emitter:openapi3 triaged:core
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.