microsoft / microsoft/typespec
[Bug]: openapi3 silently drops every decorator written on a single `@body` property
- Dominant language
- Java
- Stars
- 5.9k
- Forks
- 394
- Avg merge
- 1d 23h
- Merged PRs (30d)
- 104
Description
## Describe the bug
`@typespec/openapi3` discards what is written on a `@body` property when the body is a single (non-multipart) payload. `@summary`, `@extension(...)` (including `x-` extensions), a property default, and — on the response side — `@doc` all emit nothing, with no diagnostic, under `warn-as-error: true`. The same decorators on an ordinary model property all emit.
Two asymmetries make this look like a gap rather than a design choice:
- `getRequestBody` reads `getDoc(program, body.property)` and writes it to `requestBody.description`, so the request `@body` property *is* consulted for one decorator — but `getBodyContentEntry` then schemas `body.type` via `getSchemaForSingleBody` and never looks at `body.property` again, so `@summary`, `@extension` and the default are lost.
- `getBodyContentForMultipartBody` calls `attachExtensions(program, part.property, schema)` for each part, so `@extension` on a multipart part property emits. The single-body path has no equivalent call.
Silence is the real problem: an author cannot tell a decorator that did nothing from one that was never written.
## Related
- #3075 asks for the `@doc` half of this on the response side (docs on an ad-hoc inline return type) and is filed as a feature request; this report is about the rest of the position dropping silently, and about the request side.
- #8266 / #8267 fixed the same gap for multipart parts by calling `attachExtensions(program, part.property, schema)`; the single-body path never got the equivalent.
## Reproduction
`main.tsp`:
```typespec
import "@typespec/http";
import "@typespec/openapi";
using Http;
using OpenAPI;
@service(#{ title: "Repro" })
namespace Repro;
model Widget {
@doc("An ordinary property: every decorator below emits.")
@summary("Widget id")
@extension("format", "byte")
id: string = "w-1";
}
model NotFound {
@statusCode statusCode: 404;
@doc("A body property: none of these emit.")
@summary("WidgetNotFound")
@extension("title", "WidgetNotFound")
@extension("x-note", "x-note on a body property")
@body body: {
@summary("Detail") detail: string;
};
}
@route("/widgets/{id}")
@get
op read(@path id: string): Widget | NotFound;
@route("/widgets")
@post
op create(
@doc("Request body: none of these emit either.")
@summary("WidgetCreate")
@extension("title", "WidgetCreate")
@body body: Widget = #{ id: "w-1" },
): Widget;
```
`tspconfig.yaml`:
```yaml
emit:
- "@typespec/openapi3"
options:
"@typespec/openapi3":
file-type: json
output-file: "openapi.json"
openapi-versions:
- "3.1.0"
warn-as-error: true
```
`tsp compile .` succeeds with no diagnostics.
## Actual output (relevant parts)
```json
"404": {
"description": "The server cannot find the requested resource.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "detail": { "type": "string", "title": "Detail" } },
"required": ["detail"]
}
}
}
}
```
```json
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Widget" } } },
"description": "Request body: none of these emit either."
}
```
```json
"Widget": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string",
"description": "An ordinary property: every decorator below emits.",
"title": "Widget id",
"format": "byte",
"default": "w-1"
}
}
}
```
So:
| Written on | `@doc` | `@summary` | `@extension("title")` | `@extension("x-note")` | default |
| --- | --- | --- | --- | --- | --- |
| ordinary property (`Widget.id`) | emits | emits | (n/a) | (n/a) | emits |
| nested property inside the body (`detail`) | — | emits | — | — | — |
| response `@body` property | **dropped** (response keeps the default description) | **dropped** | **dropped** | **dropped** | — |
| request `@body` property | emits as `requestBody.description` | **dropped** | **dropped** | — | **dropped** |
## Expected
Either of these would be fine; the current combination of "partially honoured, silently" is the defect:
1. Decorators on a single `@body` property are applied to the emitted media-type schema (`title` from `@summary`, `@extension` keys, `default`), and `@doc` on a response `@body` property lands on the response `description` the same way the request side already lands it on `requestBody.description`; or
2. The emitter reports a diagnostic that the decorator has no effect in that position.
## Environment
- `@typespec/compiler` 1.15.0, `@typespec/http` 1.15.0, `@typespec/openapi` 1.15.0, `@typespec/openapi3` 1.15.0
- Node v26.7.0, macOS
- `openapi-versions: ["3.1.0"]` (also checked: unchanged under `3.0.0`)
## Where it lives
`packages/openapi3/src/openapi.ts` — `getBodyContentEntry` (`case "single"` returns `getSchemaForSingleBody(body.type, …)` without touching `body.property`), compared with `getBodyContentForMultipartBody`, which does call `attachExtensions(program, part.property, schema)`, and `getRequestBody`, which reads `getDoc(program, body.property)`.
### 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 in packages/openapi3/src/openapi.ts, comparing getBodyContentEntry's single-body case with getBodyContentForMultipartBody and getRequestBody. Run the provided main.tsp reproduction with tsp compile . and inspect the generated OpenAPI output. Done means single @body properties either emit their documented metadata, extensions, and default on the schema, with response @doc reflected appropriately, or produce a diagnostic.
Written by the indexing model from the issue text.
Assessment
- Domain
- api
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100