microsoft / microsoft/typespec

[Bug]: openapi3 silently drops every decorator written on a single `@body` property

Open
#11,825 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

`@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

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.